From 49194cfa711328216ff131d6f65c9298822a7c51 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 19:29:58 +0000 Subject: [PATCH 01/11] feat(internal): implement indices array format for query and form serialization --- scripts/mock | 4 ++-- scripts/test | 2 +- src/openai/_qs.py | 5 ++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 9ecceca0a7..4931f304c7 100755 --- a/scripts/mock +++ b/scripts/mock @@ -24,7 +24,7 @@ if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout npm exec --package=@stdy/cli@0.19.7 -- steady --version - npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=brackets --validator-query-array-format=brackets --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=brackets --validator-form-array-format=brackets --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-form-array-format=brackets --validator-query-array-format=brackets --validator-form-object-format=brackets --validator-query-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=brackets --validator-form-array-format=brackets --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 9231513853..52bcc4cec8 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.7 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-form-array-format=brackets --validator-query-array-format=brackets --validator-form-object-format=brackets --validator-query-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.7 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=brackets --validator-form-array-format=brackets --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 diff --git a/src/openai/_qs.py b/src/openai/_qs.py index ada6fd3f72..de8c99bc63 100644 --- a/src/openai/_qs.py +++ b/src/openai/_qs.py @@ -101,7 +101,10 @@ def _stringify_item( items.extend(self._stringify_item(key, item, opts)) return items elif array_format == "indices": - raise NotImplementedError("The array indices format is not supported yet") + items = [] + for i, item in enumerate(value): + items.extend(self._stringify_item(f"{key}[{i}]", item, opts)) + return items elif array_format == "brackets": items = [] key = key + "[]" From a9e7ebd505b9ae90514339aa63c6f1984a08cf6b Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:46:42 +0000 Subject: [PATCH 02/11] docs(api): update file parameter descriptions in vector_stores files and file_batches --- .stats.yml | 4 ++-- .../resources/vector_stores/file_batches.py | 20 +++++++++++-------- src/openai/resources/vector_stores/files.py | 8 ++++++-- .../vector_stores/file_batch_create_params.py | 14 ++++++++----- .../types/vector_stores/file_create_params.py | 4 +++- 5 files changed, 32 insertions(+), 18 deletions(-) diff --git a/.stats.yml b/.stats.yml index b2067da764..7fb8cd473d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 152 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/openai%2Fopenai-00994178cc8e20d71754b00c54b0e4f5b4128e1c1cce765e9b7d696bd8c80d33.yml -openapi_spec_hash: 81f404053b663f987209b4fb2d08a230 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/openai%2Fopenai-24647ccd7356fee965aaca347476460727c6aec762e16a9eb41950d6fbccf0be.yml +openapi_spec_hash: ef99e305f20ae8ae7b2758a205280cca config_hash: 5635033cdc8c930255f8b529a78de722 diff --git a/src/openai/resources/vector_stores/file_batches.py b/src/openai/resources/vector_stores/file_batches.py index f097cf8a92..1ffd7642c0 100644 --- a/src/openai/resources/vector_stores/file_batches.py +++ b/src/openai/resources/vector_stores/file_batches.py @@ -79,14 +79,16 @@ def create( file_ids: A list of [File](https://platform.openai.com/docs/api-reference/files) IDs that the vector store should use. Useful for tools like `file_search` that can access files. If `attributes` or `chunking_strategy` are provided, they will be applied - to all files in the batch. The maximum batch size is 2000 files. Mutually - exclusive with `files`. + to all files in the batch. The maximum batch size is 2000 files. This endpoint + is recommended for multi-file ingestion and helps reduce per-vector-store write + request pressure. Mutually exclusive with `files`. files: A list of objects that each include a `file_id` plus optional `attributes` or `chunking_strategy`. Use this when you need to override metadata for specific files. The global `attributes` or `chunking_strategy` will be ignored and must - be specified for each file. The maximum batch size is 2000 files. Mutually - exclusive with `file_ids`. + be specified for each file. The maximum batch size is 2000 files. This endpoint + is recommended for multi-file ingestion and helps reduce per-vector-store write + request pressure. Mutually exclusive with `file_ids`. extra_headers: Send extra headers @@ -452,14 +454,16 @@ async def create( file_ids: A list of [File](https://platform.openai.com/docs/api-reference/files) IDs that the vector store should use. Useful for tools like `file_search` that can access files. If `attributes` or `chunking_strategy` are provided, they will be applied - to all files in the batch. The maximum batch size is 2000 files. Mutually - exclusive with `files`. + to all files in the batch. The maximum batch size is 2000 files. This endpoint + is recommended for multi-file ingestion and helps reduce per-vector-store write + request pressure. Mutually exclusive with `files`. files: A list of objects that each include a `file_id` plus optional `attributes` or `chunking_strategy`. Use this when you need to override metadata for specific files. The global `attributes` or `chunking_strategy` will be ignored and must - be specified for each file. The maximum batch size is 2000 files. Mutually - exclusive with `file_ids`. + be specified for each file. The maximum batch size is 2000 files. This endpoint + is recommended for multi-file ingestion and helps reduce per-vector-store write + request pressure. Mutually exclusive with `file_ids`. extra_headers: Send extra headers diff --git a/src/openai/resources/vector_stores/files.py b/src/openai/resources/vector_stores/files.py index 8666434587..3ef6137267 100644 --- a/src/openai/resources/vector_stores/files.py +++ b/src/openai/resources/vector_stores/files.py @@ -67,7 +67,9 @@ def create( Args: file_id: A [File](https://platform.openai.com/docs/api-reference/files) ID that the vector store should use. Useful for tools like `file_search` that can access - files. + files. For multi-file ingestion, we recommend + [`file_batches`](https://platform.openai.com/docs/api-reference/vector-stores-file-batches/createBatch) + to minimize per-vector-store write requests. attributes: Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object in a structured format, and @@ -498,7 +500,9 @@ async def create( Args: file_id: A [File](https://platform.openai.com/docs/api-reference/files) ID that the vector store should use. Useful for tools like `file_search` that can access - files. + files. For multi-file ingestion, we recommend + [`file_batches`](https://platform.openai.com/docs/api-reference/vector-stores-file-batches/createBatch) + to minimize per-vector-store write requests. attributes: Set of 16 key-value pairs that can be attached to an object. This can be useful for storing additional information about the object in a structured format, and diff --git a/src/openai/types/vector_stores/file_batch_create_params.py b/src/openai/types/vector_stores/file_batch_create_params.py index 7ca0de81da..1e578888c5 100644 --- a/src/openai/types/vector_stores/file_batch_create_params.py +++ b/src/openai/types/vector_stores/file_batch_create_params.py @@ -33,8 +33,9 @@ class FileBatchCreateParams(TypedDict, total=False): A list of [File](https://platform.openai.com/docs/api-reference/files) IDs that the vector store should use. Useful for tools like `file_search` that can access files. If `attributes` or `chunking_strategy` are provided, they will be applied - to all files in the batch. The maximum batch size is 2000 files. Mutually - exclusive with `files`. + to all files in the batch. The maximum batch size is 2000 files. This endpoint + is recommended for multi-file ingestion and helps reduce per-vector-store write + request pressure. Mutually exclusive with `files`. """ files: Iterable[File] @@ -42,8 +43,9 @@ class FileBatchCreateParams(TypedDict, total=False): A list of objects that each include a `file_id` plus optional `attributes` or `chunking_strategy`. Use this when you need to override metadata for specific files. The global `attributes` or `chunking_strategy` will be ignored and must - be specified for each file. The maximum batch size is 2000 files. Mutually - exclusive with `file_ids`. + be specified for each file. The maximum batch size is 2000 files. This endpoint + is recommended for multi-file ingestion and helps reduce per-vector-store write + request pressure. Mutually exclusive with `file_ids`. """ @@ -52,7 +54,9 @@ class File(TypedDict, total=False): """ A [File](https://platform.openai.com/docs/api-reference/files) ID that the vector store should use. Useful for tools like `file_search` that can access - files. + files. For multi-file ingestion, we recommend + [`file_batches`](https://platform.openai.com/docs/api-reference/vector-stores-file-batches/createBatch) + to minimize per-vector-store write requests. """ attributes: Optional[Dict[str, Union[str, float, bool]]] diff --git a/src/openai/types/vector_stores/file_create_params.py b/src/openai/types/vector_stores/file_create_params.py index 5b8989251a..530adee8f6 100644 --- a/src/openai/types/vector_stores/file_create_params.py +++ b/src/openai/types/vector_stores/file_create_params.py @@ -15,7 +15,9 @@ class FileCreateParams(TypedDict, total=False): """ A [File](https://platform.openai.com/docs/api-reference/files) ID that the vector store should use. Useful for tools like `file_search` that can access - files. + files. For multi-file ingestion, we recommend + [`file_batches`](https://platform.openai.com/docs/api-reference/vector-stores-file-batches/createBatch) + to minimize per-vector-store write requests. """ attributes: Optional[Dict[str, Union[str, float, bool]]] From d3cc40165cd86015833d15167cc7712b4102f932 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 18:13:17 +0000 Subject: [PATCH 03/11] fix(types): remove web_search_call.results from ResponseIncludable --- .stats.yml | 4 ++-- src/openai/resources/realtime/calls.py | 10 ++++++---- src/openai/types/realtime/call_accept_params.py | 5 +++-- .../types/realtime/realtime_session_create_request.py | 5 +++-- .../realtime/realtime_session_create_request_param.py | 5 +++-- .../types/realtime/realtime_session_create_response.py | 5 +++-- src/openai/types/responses/response_includable.py | 1 - 7 files changed, 20 insertions(+), 15 deletions(-) diff --git a/.stats.yml b/.stats.yml index 7fb8cd473d..4d731cc503 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 152 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/openai%2Fopenai-24647ccd7356fee965aaca347476460727c6aec762e16a9eb41950d6fbccf0be.yml -openapi_spec_hash: ef99e305f20ae8ae7b2758a205280cca +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/openai%2Fopenai-2fab88288cbbe872f5d61d1d47da2286662a123b4312bc7fc36addba6607cd67.yml +openapi_spec_hash: a7ee80374e409ed9ecc8ea2e3cd31071 config_hash: 5635033cdc8c930255f8b529a78de722 diff --git a/src/openai/resources/realtime/calls.py b/src/openai/resources/realtime/calls.py index f34748d239..8fa2569a96 100644 --- a/src/openai/resources/realtime/calls.py +++ b/src/openai/resources/realtime/calls.py @@ -193,8 +193,9 @@ def accept( tools: Tools available to the model. tracing: Realtime API can write session traces to the - [Traces Dashboard](/logs?api=traces). Set to null to disable tracing. Once - tracing is enabled for a session, the configuration cannot be modified. + [Traces Dashboard](https://platform.openai.com/logs?api=traces). Set to null to + disable tracing. Once tracing is enabled for a session, the configuration cannot + be modified. `auto` will create a trace for the session with default values for the workflow name, group id, and metadata. @@ -522,8 +523,9 @@ async def accept( tools: Tools available to the model. tracing: Realtime API can write session traces to the - [Traces Dashboard](/logs?api=traces). Set to null to disable tracing. Once - tracing is enabled for a session, the configuration cannot be modified. + [Traces Dashboard](https://platform.openai.com/logs?api=traces). Set to null to + disable tracing. Once tracing is enabled for a session, the configuration cannot + be modified. `auto` will create a trace for the session with default values for the workflow name, group id, and metadata. diff --git a/src/openai/types/realtime/call_accept_params.py b/src/openai/types/realtime/call_accept_params.py index 6d8caf9306..1baddbfc2c 100644 --- a/src/openai/types/realtime/call_accept_params.py +++ b/src/openai/types/realtime/call_accept_params.py @@ -101,8 +101,9 @@ class CallAcceptParams(TypedDict, total=False): tracing: Optional[RealtimeTracingConfigParam] """ Realtime API can write session traces to the - [Traces Dashboard](/logs?api=traces). Set to null to disable tracing. Once - tracing is enabled for a session, the configuration cannot be modified. + [Traces Dashboard](https://platform.openai.com/logs?api=traces). Set to null to + disable tracing. Once tracing is enabled for a session, the configuration cannot + be modified. `auto` will create a trace for the session with default values for the workflow name, group id, and metadata. diff --git a/src/openai/types/realtime/realtime_session_create_request.py b/src/openai/types/realtime/realtime_session_create_request.py index e34136a10a..163a0d16d8 100644 --- a/src/openai/types/realtime/realtime_session_create_request.py +++ b/src/openai/types/realtime/realtime_session_create_request.py @@ -103,8 +103,9 @@ class RealtimeSessionCreateRequest(BaseModel): tracing: Optional[RealtimeTracingConfig] = None """ Realtime API can write session traces to the - [Traces Dashboard](/logs?api=traces). Set to null to disable tracing. Once - tracing is enabled for a session, the configuration cannot be modified. + [Traces Dashboard](https://platform.openai.com/logs?api=traces). Set to null to + disable tracing. Once tracing is enabled for a session, the configuration cannot + be modified. `auto` will create a trace for the session with default values for the workflow name, group id, and metadata. diff --git a/src/openai/types/realtime/realtime_session_create_request_param.py b/src/openai/types/realtime/realtime_session_create_request_param.py index f3180c9ed6..19c73b909b 100644 --- a/src/openai/types/realtime/realtime_session_create_request_param.py +++ b/src/openai/types/realtime/realtime_session_create_request_param.py @@ -103,8 +103,9 @@ class RealtimeSessionCreateRequestParam(TypedDict, total=False): tracing: Optional[RealtimeTracingConfigParam] """ Realtime API can write session traces to the - [Traces Dashboard](/logs?api=traces). Set to null to disable tracing. Once - tracing is enabled for a session, the configuration cannot be modified. + [Traces Dashboard](https://platform.openai.com/logs?api=traces). Set to null to + disable tracing. Once tracing is enabled for a session, the configuration cannot + be modified. `auto` will create a trace for the session with default values for the workflow name, group id, and metadata. diff --git a/src/openai/types/realtime/realtime_session_create_response.py b/src/openai/types/realtime/realtime_session_create_response.py index 3c3bef93f4..e2ed5ddce5 100644 --- a/src/openai/types/realtime/realtime_session_create_response.py +++ b/src/openai/types/realtime/realtime_session_create_response.py @@ -509,8 +509,9 @@ class RealtimeSessionCreateResponse(BaseModel): tracing: Optional[Tracing] = None """ Realtime API can write session traces to the - [Traces Dashboard](/logs?api=traces). Set to null to disable tracing. Once - tracing is enabled for a session, the configuration cannot be modified. + [Traces Dashboard](https://platform.openai.com/logs?api=traces). Set to null to + disable tracing. Once tracing is enabled for a session, the configuration cannot + be modified. `auto` will create a trace for the session with default values for the workflow name, group id, and metadata. diff --git a/src/openai/types/responses/response_includable.py b/src/openai/types/responses/response_includable.py index 675c83405a..06e56be8a7 100644 --- a/src/openai/types/responses/response_includable.py +++ b/src/openai/types/responses/response_includable.py @@ -6,7 +6,6 @@ ResponseIncludable: TypeAlias = Literal[ "file_search_call.results", - "web_search_call.results", "web_search_call.action.sources", "message.input_image.image_url", "computer_call_output.output.image_url", From 3e5834efb39b24e019a29dc54d890c67d18cbb54 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:45:02 +0000 Subject: [PATCH 04/11] feat(api): add phase field to conversations message --- .stats.yml | 4 ++-- src/openai/types/conversations/message.py | 10 +++++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.stats.yml b/.stats.yml index 4d731cc503..de471b8f60 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 152 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/openai%2Fopenai-2fab88288cbbe872f5d61d1d47da2286662a123b4312bc7fc36addba6607cd67.yml -openapi_spec_hash: a7ee80374e409ed9ecc8ea2e3cd31071 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/openai%2Fopenai-89e54b8e2c185d30e869f73e7798308d56a6a835a675d54628dd86836f147879.yml +openapi_spec_hash: 85b0dd465aa1a034f2764b0758671f21 config_hash: 5635033cdc8c930255f8b529a78de722 diff --git a/src/openai/types/conversations/message.py b/src/openai/types/conversations/message.py index 86c8860da8..075f5bd290 100644 --- a/src/openai/types/conversations/message.py +++ b/src/openai/types/conversations/message.py @@ -1,6 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import List, Union +from typing import List, Union, Optional from typing_extensions import Literal, Annotated, TypeAlias from ..._utils import PropertyInfo @@ -68,3 +68,11 @@ class Message(BaseModel): type: Literal["message"] """The type of the message. Always set to `message`.""" + + phase: Optional[Literal["commentary", "final_answer"]] = None + """ + Labels an `assistant` message as intermediate commentary (`commentary`) or the + final answer (`final_answer`). For models like `gpt-5.3-codex` and beyond, when + sending follow-up requests, preserve and resend phase on all assistant messages + — dropping it can degrade performance. Not used for user messages. + """ From d60e2eea7f6916540cd4ba901dceb07051119da4 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:33:41 +0000 Subject: [PATCH 05/11] chore(tests): bump steady to v0.20.1 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 4931f304c7..8b82c3e59c 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.19.7 -- steady --version + npm exec --package=@stdy/cli@0.20.1 -- steady --version - npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=brackets --validator-form-array-format=brackets --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.20.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=brackets --validator-form-array-format=brackets --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.19.7 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=brackets --validator-form-array-format=brackets --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.20.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=brackets --validator-form-array-format=brackets --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 52bcc4cec8..ed64d32077 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.19.7 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=brackets --validator-form-array-format=brackets --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=brackets --validator-form-array-format=brackets --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 From 6508d474332d4e82d9615c0a9a77379f9b5e4412 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 19:26:08 +0000 Subject: [PATCH 06/11] chore(tests): bump steady to v0.20.2 --- scripts/mock | 6 +++--- scripts/test | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/mock b/scripts/mock index 8b82c3e59c..886f2ffc14 100755 --- a/scripts/mock +++ b/scripts/mock @@ -22,9 +22,9 @@ echo "==> Starting mock server with URL ${URL}" # Run steady mock on the given spec if [ "$1" == "--daemon" ]; then # Pre-install the package so the download doesn't eat into the startup timeout - npm exec --package=@stdy/cli@0.20.1 -- steady --version + npm exec --package=@stdy/cli@0.20.2 -- steady --version - npm exec --package=@stdy/cli@0.20.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=brackets --validator-form-array-format=brackets --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & + npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=brackets --validator-form-array-format=brackets --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" @@ -48,5 +48,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stdy/cli@0.20.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=brackets --validator-form-array-format=brackets --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" + npm exec --package=@stdy/cli@0.20.2 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=brackets --validator-form-array-format=brackets --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index ed64d32077..57cabda6ae 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! steady_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=brackets --validator-form-array-format=brackets --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.20.2 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=brackets --validator-form-array-format=brackets --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 From ffd8741dd38609a5af0159ceb800d8ddba7925f8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 4 Apr 2026 04:45:12 +0000 Subject: [PATCH 07/11] feat(api): add web_search_call.results to ResponseIncludable type --- .stats.yml | 4 ++-- src/openai/types/responses/response_includable.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index de471b8f60..b542dc82ac 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 152 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/openai%2Fopenai-89e54b8e2c185d30e869f73e7798308d56a6a835a675d54628dd86836f147879.yml -openapi_spec_hash: 85b0dd465aa1a034f2764b0758671f21 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/openai%2Fopenai-dd99495ad509338e6de862802839360dfe394d5cd6d6ba6d13fec8fca92328b8.yml +openapi_spec_hash: 68abda9122013a9ae3f084cfdbe8e8c1 config_hash: 5635033cdc8c930255f8b529a78de722 diff --git a/src/openai/types/responses/response_includable.py b/src/openai/types/responses/response_includable.py index 06e56be8a7..675c83405a 100644 --- a/src/openai/types/responses/response_includable.py +++ b/src/openai/types/responses/response_includable.py @@ -6,6 +6,7 @@ ResponseIncludable: TypeAlias = Literal[ "file_search_call.results", + "web_search_call.results", "web_search_call.action.sources", "message.input_image.image_url", "computer_call_output.output.image_url", From 92e109c3d9569a942e1919e75977dc13fa015f9a Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:09:47 +0000 Subject: [PATCH 08/11] fix(client): preserve hardcoded query params when merging with user params --- src/openai/_base_client.py | 4 ++++ tests/test_client.py | 48 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index cf4571bf45..148e273135 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -542,6 +542,10 @@ def _build_request( files = cast(HttpxRequestFiles, ForceMultipartDict()) prepared_url = self._prepare_url(options.url) + # preserve hard-coded query params from the url + if params and prepared_url.query: + params = {**dict(prepared_url.params.items()), **params} + prepared_url = prepared_url.copy_with(raw_path=prepared_url.raw_path.split(b"?", 1)[0]) if "_" in prepared_url.host: # work around https://github.com/encode/httpx/discussions/2880 kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} diff --git a/tests/test_client.py b/tests/test_client.py index a015cd7d40..04ef6794ed 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -434,6 +434,30 @@ def test_default_query_option(self) -> None: client.close() + def test_hardcoded_query_params_in_url(self, client: OpenAI) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + def test_request_extra_json(self, client: OpenAI) -> None: request = client._build_request( FinalRequestOptions( @@ -1466,6 +1490,30 @@ async def test_default_query_option(self) -> None: await client.close() + async def test_hardcoded_query_params_in_url(self, async_client: AsyncOpenAI) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + def test_request_extra_json(self, client: OpenAI) -> None: request = client._build_request( FinalRequestOptions( From f1bc52ef641dfca6fdf2a5b00ce3b09bff2552f5 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:06:02 +0000 Subject: [PATCH 09/11] feat(client): support sending raw data over websockets --- src/openai/resources/realtime/realtime.py | 6 ++++++ src/openai/resources/responses/responses.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/openai/resources/realtime/realtime.py b/src/openai/resources/realtime/realtime.py index 73a87fc2e7..82c9815b03 100644 --- a/src/openai/resources/realtime/realtime.py +++ b/src/openai/resources/realtime/realtime.py @@ -292,6 +292,9 @@ async def send(self, event: RealtimeClientEvent | RealtimeClientEventParam) -> N ) await self._connection.send(data) + async def send_raw(self, data: bytes | str) -> None: + await self._connection.send(data) + async def close(self, *, code: int = 1000, reason: str = "") -> None: await self._connection.close(code=code, reason=reason) @@ -483,6 +486,9 @@ def send(self, event: RealtimeClientEvent | RealtimeClientEventParam) -> None: ) self._connection.send(data) + def send_raw(self, data: bytes | str) -> None: + self._connection.send(data) + def close(self, *, code: int = 1000, reason: str = "") -> None: self._connection.close(code=code, reason=reason) diff --git a/src/openai/resources/responses/responses.py b/src/openai/resources/responses/responses.py index 63795f95a9..360c5afa1a 100644 --- a/src/openai/resources/responses/responses.py +++ b/src/openai/resources/responses/responses.py @@ -3632,6 +3632,9 @@ async def send(self, event: ResponsesClientEvent | ResponsesClientEventParam) -> ) await self._connection.send(data) + async def send_raw(self, data: bytes | str) -> None: + await self._connection.send(data) + async def close(self, *, code: int = 1000, reason: str = "") -> None: await self._connection.close(code=code, reason=reason) @@ -3798,6 +3801,9 @@ def send(self, event: ResponsesClientEvent | ResponsesClientEventParam) -> None: ) self._connection.send(data) + def send_raw(self, data: bytes | str) -> None: + self._connection.send(data) + def close(self, *, code: int = 1000, reason: str = "") -> None: self._connection.close(code=code, reason=reason) From 22fe7228d4990c197cd721b3ad7931ad05cca5dd Mon Sep 17 00:00:00 2001 From: Kar Petrosyan <92274156+karpetrosyan@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:57:12 +0400 Subject: [PATCH 10/11] feat(client): add support for short-lived tokens (#1608) Co-authored-by: stainless-app[bot] <142633134+stainless-app[bot]@users.noreply.github.com> --- .stats.yml | 4 +- README.md | 103 ++++++ api.md | 1 + src/openai/__init__.py | 2 + src/openai/_base_client.py | 31 +- src/openai/_client.py | 159 +++++++-- src/openai/_exceptions.py | 28 ++ src/openai/auth/__init__.py | 19 ++ src/openai/auth/_workload.py | 313 +++++++++++++++++ src/openai/lib/azure.py | 9 + src/openai/types/__init__.py | 1 + src/openai/types/shared/__init__.py | 1 + src/openai/types/shared/oauth_error_code.py | 8 + src/openai/types/shared_params/__init__.py | 1 + .../types/shared_params/oauth_error_code.py | 10 + tests/test_auth.py | 190 +++++++++++ tests/test_client.py | 323 +++++++++++++++++- 17 files changed, 1170 insertions(+), 33 deletions(-) create mode 100644 src/openai/auth/__init__.py create mode 100644 src/openai/auth/_workload.py create mode 100644 src/openai/types/shared/oauth_error_code.py create mode 100644 src/openai/types/shared_params/oauth_error_code.py create mode 100644 tests/test_auth.py diff --git a/.stats.yml b/.stats.yml index b542dc82ac..461d4c7c07 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 152 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/openai%2Fopenai-dd99495ad509338e6de862802839360dfe394d5cd6d6ba6d13fec8fca92328b8.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/openai%2Fopenai-a6eca1bd01e0c434af356fe5275c206057216a4e626d1051d294c27016cd6d05.yml openapi_spec_hash: 68abda9122013a9ae3f084cfdbe8e8c1 -config_hash: 5635033cdc8c930255f8b529a78de722 +config_hash: 4975e16a94e8f9901428022044131888 diff --git a/README.md b/README.md index 7e4f0ae657..9450c0bc51 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,109 @@ to add `OPENAI_API_KEY="My API Key"` to your `.env` file so that your API key is not stored in source control. [Get an API key here](https://platform.openai.com/settings/organization/api-keys). +### Workload Identity Authentication + +For secure, automated environments like cloud-managed Kubernetes, Azure, and Google Cloud Platform, you can use workload identity authentication with short-lived tokens from cloud identity providers instead of long-lived API keys. + +#### Kubernetes (service account tokens) + +```python +from openai import OpenAI +from openai.auth import k8s_service_account_token_provider + +client = OpenAI( + workload_identity={ + "client_id": "your-client-id", + "identity_provider_id": "idp-123", + "service_account_id": "sa-456", + "provider": k8s_service_account_token_provider( + "/var/run/secrets/kubernetes.io/serviceaccount/token" + ), + }, + organization="org-xyz", + project="proj-abc", +) + +response = client.chat.completions.create( + model="gpt-4", + messages=[{"role": "user", "content": "Hello!"}], +) +``` + +#### Azure (managed identity) + +```python +from openai import OpenAI +from openai.auth import azure_managed_identity_token_provider + +client = OpenAI( + workload_identity={ + "client_id": "your-client-id", + "identity_provider_id": "idp-123", + "service_account_id": "sa-456", + "provider": azure_managed_identity_token_provider( + resource="https://management.azure.com/", + ), + }, +) +``` + +#### Google Cloud Platform (compute engine metadata) + +```python +from openai import OpenAI +from openai.auth import gcp_id_token_provider + +client = OpenAI( + workload_identity={ + "client_id": "your-client-id", + "identity_provider_id": "idp-123", + "service_account_id": "sa-456", + "provider": gcp_id_token_provider(audience="https://api.openai.com/v1"), + }, +) +``` + +#### Custom subject token provider + +```python +from openai import OpenAI + + +def get_custom_token() -> str: + return "your-jwt-token" + + +client = OpenAI( + workload_identity={ + "client_id": "your-client-id", + "identity_provider_id": "idp-123", + "service_account_id": "sa-456", + "provider": { + "token_type": "jwt", + "get_token": get_custom_token, + }, + } +) +``` + +You can also customize the token refresh buffer (default is 1200 seconds (20 minutes) before expiration): + +```python +from openai import OpenAI +from openai.auth import k8s_service_account_token_provider + +client = OpenAI( + workload_identity={ + "client_id": "your-client-id", + "identity_provider_id": "idp-123", + "service_account_id": "sa-456", + "provider": k8s_service_account_token_provider("/var/token"), + "refresh_buffer_seconds": 120.0, + } +) +``` + ### Vision With an image URL: diff --git a/api.md b/api.md index 852df5bb8a..decf4e0129 100644 --- a/api.md +++ b/api.md @@ -11,6 +11,7 @@ from openai.types import ( FunctionDefinition, FunctionParameters, Metadata, + OAuthErrorCode, Reasoning, ReasoningEffort, ResponseFormatJSONObject, diff --git a/src/openai/__init__.py b/src/openai/__init__.py index b2093ada68..3e0a135929 100644 --- a/src/openai/__init__.py +++ b/src/openai/__init__.py @@ -16,6 +16,7 @@ from ._constants import DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES, DEFAULT_CONNECTION_LIMITS from ._exceptions import ( APIError, + OAuthError, OpenAIError, ConflictError, NotFoundError, @@ -57,6 +58,7 @@ "APIResponseValidationError", "BadRequestError", "AuthenticationError", + "OAuthError", "PermissionDeniedError", "NotFoundError", "ConflictError", diff --git a/src/openai/_base_client.py b/src/openai/_base_client.py index 148e273135..a1d0960700 100644 --- a/src/openai/_base_client.py +++ b/src/openai/_base_client.py @@ -30,7 +30,7 @@ cast, overload, ) -from typing_extensions import Literal, override, get_origin +from typing_extensions import Unpack, Literal, override, get_origin import anyio import httpx @@ -81,6 +81,7 @@ ) from ._streaming import Stream, SSEDecoder, AsyncStream, SSEBytesDecoder from ._exceptions import ( + OpenAIError, APIStatusError, APITimeoutError, APIConnectionError, @@ -936,6 +937,15 @@ def _prepare_request( """ return None + def _send_request( + self, + request: httpx.Request, + *, + stream: bool, + **kwargs: Unpack[HttpxSendArgs], + ) -> httpx.Response: + return self._client.send(request, stream=stream, **kwargs) + @overload def request( self, @@ -1006,7 +1016,7 @@ def request( response = None try: - response = self._client.send( + response = self._send_request( request, stream=stream or self._should_stream_response_body(request=request), **kwargs, @@ -1025,6 +1035,9 @@ def request( log.debug("Raising timeout error") raise APITimeoutError(request=request) from err + except OpenAIError as err: + # Propagate OpenAIErrors as-is, without retrying or wrapping in APIConnectionError + raise err except Exception as err: log.debug("Encountered Exception", exc_info=True) @@ -1530,6 +1543,15 @@ async def _prepare_request( """ return None + async def _send_request( + self, + request: httpx.Request, + *, + stream: bool, + **kwargs: Unpack[HttpxSendArgs], + ) -> httpx.Response: + return await self._client.send(request, stream=stream, **kwargs) + @overload async def request( self, @@ -1605,7 +1627,7 @@ async def request( response = None try: - response = await self._client.send( + response = await self._send_request( request, stream=stream or self._should_stream_response_body(request=request), **kwargs, @@ -1624,6 +1646,9 @@ async def request( log.debug("Raising timeout error") raise APITimeoutError(request=request) from err + except OpenAIError as err: + # Propagate OpenAIErrors as-is, without retrying or wrapping in APIConnectionError + raise err except Exception as err: log.debug("Encountered Exception", exc_info=True) diff --git a/src/openai/_client.py b/src/openai/_client.py index aadf3601f2..434f957e19 100644 --- a/src/openai/_client.py +++ b/src/openai/_client.py @@ -4,18 +4,20 @@ import os from typing import TYPE_CHECKING, Any, Mapping, Callable, Awaitable -from typing_extensions import Self, override +from typing_extensions import Self, Unpack, override import httpx from . import _exceptions from ._qs import Querystring +from .auth import WorkloadIdentity, WorkloadIdentityAuth from ._types import ( Omit, Timeout, NotGiven, Transport, ProxiesTypes, + HttpxSendArgs, RequestOptions, not_given, ) @@ -82,13 +84,17 @@ __all__ = ["Timeout", "Transport", "ProxiesTypes", "RequestOptions", "OpenAI", "AsyncOpenAI", "Client", "AsyncClient"] +WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER = "workload-identity-auth" + class OpenAI(SyncAPIClient): # client options api_key: str + workload_identity: WorkloadIdentity | None organization: str | None project: str | None webhook_secret: str | None + _workload_identity_auth: WorkloadIdentityAuth | None websocket_base_url: str | httpx.URL | None """Base URL for WebSocket connections. @@ -102,6 +108,7 @@ def __init__( self, *, api_key: str | None | Callable[[], str] = None, + workload_identity: WorkloadIdentity | None = None, organization: str | None = None, project: str | None = None, webhook_secret: str | None = None, @@ -133,18 +140,31 @@ def __init__( - `project` from `OPENAI_PROJECT_ID` - `webhook_secret` from `OPENAI_WEBHOOK_SECRET` """ - if api_key is None: - api_key = os.environ.get("OPENAI_API_KEY") - if api_key is None: - raise OpenAIError( - "The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable" + if api_key is not None and api_key != WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER and workload_identity is not None: + raise OpenAIError("The `api_key` and `workload_identity` arguments are mutually exclusive") + + self.workload_identity = workload_identity + + if workload_identity is not None: + self.api_key = WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER + self._api_key_provider = None + self._workload_identity_auth = WorkloadIdentityAuth( + workload_identity=workload_identity, ) - if callable(api_key): - self.api_key = "" - self._api_key_provider: Callable[[], str] | None = api_key else: - self.api_key = api_key - self._api_key_provider = None + if api_key is None: + api_key = os.environ.get("OPENAI_API_KEY") + if api_key is None: + raise OpenAIError( + "The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable" + ) + if callable(api_key): + self.api_key = "" + self._api_key_provider: Callable[[], str] | None = api_key # type: ignore[no-redef] + else: + self.api_key = api_key + self._api_key_provider = None + self._workload_identity_auth = None if organization is None: organization = os.environ.get("OPENAI_ORG_ID") @@ -344,11 +364,46 @@ def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions: self._refresh_api_key() return super()._prepare_options(options) + def _send_with_auth_retry( + self, + request: httpx.Request, + *, + stream: bool, + retried: bool = False, + **kwargs: Unpack[HttpxSendArgs], + ) -> httpx.Response: + if self._workload_identity_auth: + request.headers["Authorization"] = f"Bearer {self._workload_identity_auth.get_token()}" + + response = super()._send_request(request, stream=stream, **kwargs) + + if not retried and response.status_code == 401 and self._workload_identity_auth: + response.close() + + self._workload_identity_auth.invalidate_token() + fresh_token = self._workload_identity_auth.get_token() + + request.headers["Authorization"] = f"Bearer {fresh_token}" + + return self._send_with_auth_retry(request, stream=stream, retried=True, **kwargs) + + return response + + @override + def _send_request( + self, + request: httpx.Request, + *, + stream: bool, + **kwargs: Unpack[HttpxSendArgs], + ) -> httpx.Response: + return self._send_with_auth_retry(request, stream=stream, **kwargs) + @property @override def auth_headers(self) -> dict[str, str]: api_key = self.api_key - if not api_key: + if not api_key or api_key == WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER: # if the api key is an empty string, encoding the header will fail return {} return {"Authorization": f"Bearer {api_key}"} @@ -368,6 +423,7 @@ def copy( self, *, api_key: str | Callable[[], str] | None = None, + workload_identity: WorkloadIdentity | None = None, organization: str | None = None, project: str | None = None, webhook_secret: str | None = None, @@ -404,8 +460,10 @@ def copy( params = set_default_query http_client = http_client or self._client + return self.__class__( api_key=api_key or self._api_key_provider or self.api_key, + workload_identity=workload_identity or self.workload_identity, organization=organization or self.organization, project=project or self.project, webhook_secret=webhook_secret or self.webhook_secret, @@ -461,9 +519,11 @@ def _make_status_error( class AsyncOpenAI(AsyncAPIClient): # client options api_key: str + workload_identity: WorkloadIdentity | None organization: str | None project: str | None webhook_secret: str | None + _workload_identity_auth: WorkloadIdentityAuth | None websocket_base_url: str | httpx.URL | None """Base URL for WebSocket connections. @@ -476,7 +536,8 @@ class AsyncOpenAI(AsyncAPIClient): def __init__( self, *, - api_key: str | Callable[[], Awaitable[str]] | None = None, + api_key: str | None | Callable[[], Awaitable[str]] = None, + workload_identity: WorkloadIdentity | None = None, organization: str | None = None, project: str | None = None, webhook_secret: str | None = None, @@ -508,18 +569,31 @@ def __init__( - `project` from `OPENAI_PROJECT_ID` - `webhook_secret` from `OPENAI_WEBHOOK_SECRET` """ - if api_key is None: - api_key = os.environ.get("OPENAI_API_KEY") - if api_key is None: - raise OpenAIError( - "The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable" + if api_key is not None and api_key != WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER and workload_identity is not None: + raise OpenAIError("The `api_key` and `workload_identity` arguments are mutually exclusive") + + self.workload_identity = workload_identity + + if workload_identity is not None: + self.api_key = WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER + self._api_key_provider = None + self._workload_identity_auth = WorkloadIdentityAuth( + workload_identity=workload_identity, ) - if callable(api_key): - self.api_key = "" - self._api_key_provider: Callable[[], Awaitable[str]] | None = api_key else: - self.api_key = api_key - self._api_key_provider = None + if api_key is None: + api_key = os.environ.get("OPENAI_API_KEY") + if api_key is None: + raise OpenAIError( + "The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable" + ) + if callable(api_key): + self.api_key = "" + self._api_key_provider: Callable[[], Awaitable[str]] | None = api_key # type: ignore[no-redef] + else: + self.api_key = api_key + self._api_key_provider = None + self._workload_identity_auth = None if organization is None: organization = os.environ.get("OPENAI_ORG_ID") @@ -719,11 +793,46 @@ async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOp await self._refresh_api_key() return await super()._prepare_options(options) + async def _send_with_auth_retry( + self, + request: httpx.Request, + *, + stream: bool, + retried: bool = False, + **kwargs: Unpack[HttpxSendArgs], + ) -> httpx.Response: + if self._workload_identity_auth: + request.headers["Authorization"] = f"Bearer {await self._workload_identity_auth.get_token_async()}" + + response = await super()._send_request(request, stream=stream, **kwargs) + + if not retried and response.status_code == 401 and self._workload_identity_auth: + await response.aclose() + + self._workload_identity_auth.invalidate_token() + fresh_token = await self._workload_identity_auth.get_token_async() + + request.headers["Authorization"] = f"Bearer {fresh_token}" + + return await self._send_with_auth_retry(request, stream=stream, retried=True, **kwargs) + + return response + + @override + async def _send_request( + self, + request: httpx.Request, + *, + stream: bool, + **kwargs: Unpack[HttpxSendArgs], + ) -> httpx.Response: + return await self._send_with_auth_retry(request, stream=stream, **kwargs) + @property @override def auth_headers(self) -> dict[str, str]: api_key = self.api_key - if not api_key: + if not api_key or api_key == WORKLOAD_IDENTITY_API_KEY_PLACEHOLDER: # if the api key is an empty string, encoding the header will fail return {} return {"Authorization": f"Bearer {api_key}"} @@ -743,6 +852,7 @@ def copy( self, *, api_key: str | Callable[[], Awaitable[str]] | None = None, + workload_identity: WorkloadIdentity | None = None, organization: str | None = None, project: str | None = None, webhook_secret: str | None = None, @@ -781,6 +891,7 @@ def copy( http_client = http_client or self._client return self.__class__( api_key=api_key or self._api_key_provider or self.api_key, + workload_identity=workload_identity or self.workload_identity, organization=organization or self.organization, project=project or self.project, webhook_secret=webhook_secret or self.webhook_secret, diff --git a/src/openai/_exceptions.py b/src/openai/_exceptions.py index 09016dfedb..a37ed8ca82 100644 --- a/src/openai/_exceptions.py +++ b/src/openai/_exceptions.py @@ -9,6 +9,7 @@ from ._utils import is_dict from ._models import construct_type +from .types.shared.oauth_error_code import OAuthErrorCode if TYPE_CHECKING: from .types.chat import ChatCompletion @@ -16,6 +17,7 @@ __all__ = [ "BadRequestError", "AuthenticationError", + "OAuthError", "PermissionDeniedError", "NotFoundError", "ConflictError", @@ -25,6 +27,7 @@ "LengthFinishReasonError", "ContentFilterFinishReasonError", "InvalidWebhookSignatureError", + "SubjectTokenProviderError", ] @@ -32,6 +35,14 @@ class OpenAIError(Exception): pass +class SubjectTokenProviderError(OpenAIError): + response: httpx.Response | None + + def __init__(self, message: str, *, response: httpx.Response | None = None) -> None: + super().__init__(message) + self.response = response + + class APIError(OpenAIError): message: str request: httpx.Request @@ -109,6 +120,23 @@ class AuthenticationError(APIStatusError): status_code: Literal[401] = 401 # pyright: ignore[reportIncompatibleVariableOverride] +class OAuthError(AuthenticationError): + error: Optional[OAuthErrorCode] + + def __init__(self, *, response: httpx.Response, body: object | None) -> None: + message = "OAuth authentication error." + error = None + + if is_dict(body): + error = body.get("error") + description = body.get("error_description") + if description and isinstance(description, str): + message = description + + super().__init__(message, response=response, body=body) + self.error = cast(Optional[OAuthErrorCode], error) + + class PermissionDeniedError(APIStatusError): status_code: Literal[403] = 403 # pyright: ignore[reportIncompatibleVariableOverride] diff --git a/src/openai/auth/__init__.py b/src/openai/auth/__init__.py new file mode 100644 index 0000000000..367aa86b72 --- /dev/null +++ b/src/openai/auth/__init__.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from ._workload import ( + WorkloadIdentity as WorkloadIdentity, + SubjectTokenProvider as SubjectTokenProvider, + WorkloadIdentityAuth as WorkloadIdentityAuth, + gcp_id_token_provider as gcp_id_token_provider, + k8s_service_account_token_provider as k8s_service_account_token_provider, + azure_managed_identity_token_provider as azure_managed_identity_token_provider, +) + +__all__ = [ + "SubjectTokenProvider", + "WorkloadIdentity", + "WorkloadIdentityAuth", + "k8s_service_account_token_provider", + "azure_managed_identity_token_provider", + "gcp_id_token_provider", +] diff --git a/src/openai/auth/_workload.py b/src/openai/auth/_workload.py new file mode 100644 index 0000000000..e3f6f7fb75 --- /dev/null +++ b/src/openai/auth/_workload.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import time +import threading +from typing import Any, Callable, TypedDict, cast +from pathlib import Path +from typing_extensions import Literal, NotRequired + +import httpx + +from .._exceptions import OAuthError, OpenAIError, SubjectTokenProviderError +from .._utils._sync import to_thread + +TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" +DEFAULT_TOKEN_EXCHANGE_URL = "https://auth.openai.com/oauth/token" +DEFAULT_REFRESH_BUFFER_SECONDS = 1200 + +SUBJECT_TOKEN_TYPES = { + "jwt": "urn:ietf:params:oauth:token-type:jwt", + "id": "urn:ietf:params:oauth:token-type:id_token", +} + + +class SubjectTokenProvider(TypedDict): + token_type: Literal["jwt", "id"] + get_token: Callable[[], str] + + +class WorkloadIdentity(TypedDict): + """A unique string that identifies the client.""" + + client_id: str + + """Identity provider resource id in WIFAPI.""" + identity_provider_id: str + + """Service account id to bind the verified external identity to.""" + service_account_id: str + + """The provider configuration for obtaining the subject token.""" + provider: SubjectTokenProvider + + """Optional buffer time in seconds to refresh the OpenAI token before it expires. Defaults to 1200 seconds (20 minutes).""" + refresh_buffer_seconds: NotRequired[float] + + +def k8s_service_account_token_provider( + token_file_path: str | Path = "/var/run/secrets/kubernetes.io/serviceaccount/token", +) -> SubjectTokenProvider: + """ + Get a subject token provider for Kubernetes clusters with Workload Identity configured. + + Cloud providers typically mount the subject token as a file in the container. + + Args: + token_file_path: path to the mounted service account token file. Defaults to `/var/run/secrets/kubernetes.io/serviceaccount/token`. + """ + + def get_token() -> str: + try: + with open(token_file_path, "r") as f: + token = f.read().strip() + if not token: + raise SubjectTokenProviderError(f"The token file at {token_file_path} is empty.") + return token + except Exception as e: + raise SubjectTokenProviderError(f"Failed to read the token file at {token_file_path}: {e}") from e + + return {"token_type": "jwt", "get_token": get_token} + + +def azure_managed_identity_token_provider( + resource: str = "https://management.azure.com/", + *, + object_id: str | None = None, + client_id: str | None = None, + msi_res_id: str | None = None, + api_version: str = "2018-02-01", + timeout: float = 10.0, + http_client: httpx.Client | None = None, +) -> SubjectTokenProvider: + """ + Get a subject token provider for Azure Managed Identities. + + See: https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http + + Args: + resource: the resource URI to request a token for. Defaults to `https://management.azure.com/` (Azure Resource Manager). + object_id: the object ID of the managed identity to use, when multiple are assigned. + client_id: the client ID of the managed identity to use, when multiple are assigned. + msi_res_id: the ARM resource ID of the managed identity to use, when multiple are assigned. + api_version: the Azure IMDS API version. Defaults to `2018-02-01`. + timeout: the request timeout in seconds. Defaults to 10.0. + http_client: optional httpx.Client instance to use for requests. If not provided, a new client will be created for each request. + """ + + def get_token() -> str: + try: + url = "http://169.254.169.254/metadata/identity/oauth2/token" + params: dict[str, str] = {"api-version": api_version, "resource": resource} + if object_id is not None: + params["object_id"] = object_id + if client_id is not None: + params["client_id"] = client_id + if msi_res_id is not None: + params["msi_res_id"] = msi_res_id + + if http_client is not None: + response = http_client.get(url, params=params, headers={"Metadata": "true"}, timeout=timeout) + else: + with httpx.Client() as client: + response = client.get(url, params=params, headers={"Metadata": "true"}, timeout=timeout) + + if response.is_error: + raise SubjectTokenProviderError( + f"Failed to fetch Azure subject token from IMDS: HTTP {response.status_code}", + response=response, + ) + data = response.json() + token = data.get("access_token") + if not token: + raise SubjectTokenProviderError( + "Azure IMDS response did not include an access_token", response=response + ) + return cast(str, token) + except Exception as e: + raise SubjectTokenProviderError(f"Failed to fetch Azure subject token from IMDS: {e}") from e + + return {"token_type": "jwt", "get_token": get_token} + + +def gcp_id_token_provider( + audience: str = "https://api.openai.com/v1", + *, + timeout: float = 10.0, + http_client: httpx.Client | None = None, +) -> SubjectTokenProvider: + """ + Get a subject token provider for GCP VM instances using the instance metadata server. + + See: https://cloud.google.com/compute/docs/instances/verifying-instance-identity + + Args: + audience: the unique URI agreed upon by both the instance and the system verifying + the instance's identity. Defaults to `https://api.openai.com/v1`. + timeout: the request timeout in seconds. Defaults to 10.0. + http_client: optional httpx.Client instance to use for requests. If not provided, a new client will be created for each request. + """ + + def get_token() -> str: + try: + url = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity" + params = {"audience": audience} + + if http_client is not None: + response = http_client.get(url, params=params, headers={"Metadata-Flavor": "Google"}, timeout=timeout) + else: + with httpx.Client() as client: + response = client.get(url, params=params, headers={"Metadata-Flavor": "Google"}, timeout=timeout) + + if response.is_error: + raise SubjectTokenProviderError( + f"Failed to fetch GCP subject token from metadata server: HTTP {response.status_code}", + response=response, + ) + token = response.text.strip() + if not token: + raise SubjectTokenProviderError("GCP metadata server returned an empty token", response=response) + return token + except Exception as e: + raise SubjectTokenProviderError(f"Failed to fetch GCP subject token from metadata server: {e}") from e + + return {"token_type": "id", "get_token": get_token} + + +class WorkloadIdentityAuth: + def __init__( + self, + *, + workload_identity: WorkloadIdentity, + token_exchange_url: str = DEFAULT_TOKEN_EXCHANGE_URL, + ): + self.workload_identity = workload_identity + self.token_exchange_url = token_exchange_url + + self._cached_token: str | None = None + self._cached_token_expires_at_monotonic: float | None = None + self._cached_token_refresh_at_monotonic: float | None = None + self._refreshing: bool = False + self._lock = threading.Lock() + self._condition = threading.Condition(self._lock) + + def get_token(self) -> str: + with self._lock: + while self._refreshing and self._token_unusable(): + self._condition.wait() + + if not self._token_unusable() and not self._needs_refresh(): + return cast(str, self._cached_token) + + if self._refreshing: + while self._refreshing: + self._condition.wait() + token = self._cached_token # type: ignore[unreachable] + if self._token_unusable(): + raise RuntimeError("Token is unusable after refresh completed") + return cast(str, token) + + self._refreshing = True + + try: + self._perform_refresh() + with self._lock: + if self._token_unusable(): + raise RuntimeError("Token is unusable after refresh completed") + return cast(str, self._cached_token) + finally: + with self._lock: + self._refreshing = False + self._condition.notify_all() + + async def get_token_async(self) -> str: + return await to_thread(self.get_token) + + def invalidate_token(self) -> None: + with self._lock: + self._cached_token = None + self._cached_token_expires_at_monotonic = None + self._cached_token_refresh_at_monotonic = None + + def _perform_refresh(self) -> None: + token_data = self._fetch_token_from_exchange() + now = time.monotonic() + expires_in = token_data["expires_in"] + + with self._lock: + self._cached_token = token_data["access_token"] + self._cached_token_expires_at_monotonic = now + expires_in + self._cached_token_refresh_at_monotonic = now + self._refresh_delay_seconds(expires_in) + + def _fetch_token_from_exchange(self) -> dict[str, Any]: + subject_token = self._get_subject_token() + + token_type = self.workload_identity["provider"]["token_type"] + subject_token_type = SUBJECT_TOKEN_TYPES.get(token_type) + if subject_token_type is None: + raise OpenAIError( + f"Unsupported token type: {token_type!r}. Supported types: {', '.join(SUBJECT_TOKEN_TYPES.keys())}" + ) + + with httpx.Client() as client: + response = client.post( + self.token_exchange_url, + json={ + "grant_type": TOKEN_EXCHANGE_GRANT_TYPE, + "client_id": self.workload_identity["client_id"], + "subject_token": subject_token, + "subject_token_type": subject_token_type, + "identity_provider_id": self.workload_identity["identity_provider_id"], + "service_account_id": self.workload_identity["service_account_id"], + }, + timeout=10.0, + ) + return self._handle_token_response(response) + + def _handle_token_response(self, response: httpx.Response) -> dict[str, Any]: + try: + body = response.json() if response.content else None + except ValueError: + body = None + + if response.status_code in (400, 401, 403): + raise OAuthError(response=response, body=body) + + if response.is_success: + if body is None: + raise OpenAIError("Token exchange succeeded but response body was empty") + access_token = body.get("access_token") + expires_in = body.get("expires_in") + if not isinstance(access_token, str) or not access_token: + raise OpenAIError("Token exchange response did not include a valid access_token") + if not isinstance(expires_in, (int, float)): + raise OpenAIError("Token exchange response did not include a valid expires_in") + return {"access_token": access_token, "expires_in": float(expires_in)} + + raise OpenAIError( + f"Token exchange failed with status {response.status_code}", + ) + + def _get_subject_token(self) -> str: + provider = self.workload_identity["provider"] + subject_token = provider["get_token"]() + if not subject_token: + raise OpenAIError("The workload identity provider returned an empty subject token") + return subject_token + + def _token_unusable(self) -> bool: + return self._cached_token is None or self._token_expired() + + def _token_expired(self) -> bool: + if self._cached_token_expires_at_monotonic is None: + return True + return time.monotonic() >= self._cached_token_expires_at_monotonic + + def _needs_refresh(self) -> bool: + if self._cached_token_refresh_at_monotonic is None: + return False + return time.monotonic() >= self._cached_token_refresh_at_monotonic + + def _refresh_delay_seconds(self, expires_in: float) -> float: + configured_buffer = self.workload_identity.get("refresh_buffer_seconds", DEFAULT_REFRESH_BUFFER_SECONDS) + effective_buffer = min(configured_buffer, expires_in / 2) + return max(expires_in - effective_buffer, 0.0) diff --git a/src/openai/lib/azure.py b/src/openai/lib/azure.py index ad64707261..09fdd9507e 100644 --- a/src/openai/lib/azure.py +++ b/src/openai/lib/azure.py @@ -7,6 +7,7 @@ import httpx +from ..auth import WorkloadIdentity from .._types import NOT_GIVEN, Omit, Query, Timeout, NotGiven from .._utils import is_given, is_mapping from .._client import OpenAI, AsyncOpenAI @@ -155,6 +156,8 @@ def __init__( azure_endpoint: str | None = None, azure_deployment: str | None = None, api_key: str | Callable[[], str] | None = None, + # workload_identity is not functional in the Azure client + workload_identity: WorkloadIdentity | None = None, # noqa: ARG002 azure_ad_token: str | None = None, azure_ad_token_provider: AzureADTokenProvider | None = None, organization: str | None = None, @@ -259,6 +262,7 @@ def copy( self, *, api_key: str | Callable[[], str] | None = None, + workload_identity: WorkloadIdentity | None = None, organization: str | None = None, project: str | None = None, webhook_secret: str | None = None, @@ -281,6 +285,7 @@ def copy( """ return super().copy( api_key=api_key, + workload_identity=workload_identity, organization=organization, project=project, webhook_secret=webhook_secret, @@ -436,6 +441,8 @@ def __init__( azure_deployment: str | None = None, api_version: str | None = None, api_key: str | Callable[[], Awaitable[str]] | None = None, + # workload_identity is not functional in the Azure client + workload_identity: WorkloadIdentity | None = None, # noqa: ARG002 azure_ad_token: str | None = None, azure_ad_token_provider: AsyncAzureADTokenProvider | None = None, organization: str | None = None, @@ -540,6 +547,7 @@ def copy( self, *, api_key: str | Callable[[], Awaitable[str]] | None = None, + workload_identity: WorkloadIdentity | None = None, organization: str | None = None, project: str | None = None, webhook_secret: str | None = None, @@ -562,6 +570,7 @@ def copy( """ return super().copy( api_key=api_key, + workload_identity=workload_identity, organization=organization, project=project, webhook_secret=webhook_secret, diff --git a/src/openai/types/__init__.py b/src/openai/types/__init__.py index d8dbea71ad..6074eb0a1d 100644 --- a/src/openai/types/__init__.py +++ b/src/openai/types/__init__.py @@ -14,6 +14,7 @@ Reasoning as Reasoning, ErrorObject as ErrorObject, CompoundFilter as CompoundFilter, + OAuthErrorCode as OAuthErrorCode, ResponsesModel as ResponsesModel, ReasoningEffort as ReasoningEffort, ComparisonFilter as ComparisonFilter, diff --git a/src/openai/types/shared/__init__.py b/src/openai/types/shared/__init__.py index 2930b9ae3b..55d3e86371 100644 --- a/src/openai/types/shared/__init__.py +++ b/src/openai/types/shared/__init__.py @@ -7,6 +7,7 @@ from .error_object import ErrorObject as ErrorObject from .compound_filter import CompoundFilter as CompoundFilter from .responses_model import ResponsesModel as ResponsesModel +from .oauth_error_code import OAuthErrorCode as OAuthErrorCode from .reasoning_effort import ReasoningEffort as ReasoningEffort from .comparison_filter import ComparisonFilter as ComparisonFilter from .function_definition import FunctionDefinition as FunctionDefinition diff --git a/src/openai/types/shared/oauth_error_code.py b/src/openai/types/shared/oauth_error_code.py new file mode 100644 index 0000000000..4ef3924293 --- /dev/null +++ b/src/openai/types/shared/oauth_error_code.py @@ -0,0 +1,8 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from typing import Union +from typing_extensions import Literal, TypeAlias + +__all__ = ["OAuthErrorCode"] + +OAuthErrorCode: TypeAlias = Union[Literal["invalid_grant", "invalid_subject_token"], str] diff --git a/src/openai/types/shared_params/__init__.py b/src/openai/types/shared_params/__init__.py index b6c0912b0f..0ed5fbaa80 100644 --- a/src/openai/types/shared_params/__init__.py +++ b/src/openai/types/shared_params/__init__.py @@ -5,6 +5,7 @@ from .chat_model import ChatModel as ChatModel from .compound_filter import CompoundFilter as CompoundFilter from .responses_model import ResponsesModel as ResponsesModel +from .oauth_error_code import OAuthErrorCode as OAuthErrorCode from .reasoning_effort import ReasoningEffort as ReasoningEffort from .comparison_filter import ComparisonFilter as ComparisonFilter from .function_definition import FunctionDefinition as FunctionDefinition diff --git a/src/openai/types/shared_params/oauth_error_code.py b/src/openai/types/shared_params/oauth_error_code.py new file mode 100644 index 0000000000..cb3c981881 --- /dev/null +++ b/src/openai/types/shared_params/oauth_error_code.py @@ -0,0 +1,10 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import Union +from typing_extensions import Literal, TypeAlias + +__all__ = ["OAuthErrorCode"] + +OAuthErrorCode: TypeAlias = Union[Literal["invalid_grant", "invalid_subject_token"], str] diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000000..c8f4f2d7cf --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,190 @@ +import json +from typing import cast +from pathlib import Path + +import httpx +import respx +import pytest +from respx.models import Call +from inline_snapshot import snapshot + +from openai import OpenAI, OAuthError +from openai.auth._workload import ( + gcp_id_token_provider, + k8s_service_account_token_provider, + azure_managed_identity_token_provider, +) + + +@respx.mock +def test_basic_auth(): + respx.post("https://auth.openai.com/oauth/token").mock( + return_value=httpx.Response( + 200, + json={ + "access_token": "fake_access_token", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + ) + + respx.get("https://api.openai.com/v1/models").mock( + return_value=httpx.Response(200, json={"data": [], "object": "list"}) + ) + + client = OpenAI( + workload_identity={ + "client_id": "client_123", + "identity_provider_id": "idp_123", + "service_account_id": "sa_123", + "provider": { + "get_token": lambda: "fake_subject_token", + "token_type": "jwt", + }, + }, + ) + + client.models.list() + + assert len(respx.calls) == 2 + token_call = cast(Call, respx.calls[0]) + api_call = cast(Call, respx.calls[1]) + + assert token_call.request.url == "https://auth.openai.com/oauth/token" + assert api_call.request.headers.get("Authorization") == "Bearer fake_access_token" + + +@respx.mock +def test_workload_identity_exchange_payload_and_cache() -> None: + provider_call_count = 0 + + def provider() -> str: + nonlocal provider_call_count + provider_call_count += 1 + return "fake_subject_token" + + exchange_route = respx.post("https://auth.openai.com/oauth/token").mock( + return_value=httpx.Response( + 200, + json={ + "access_token": "fake_access_token", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + ) + api_route = respx.get("https://api.openai.com/v1/models").mock( + return_value=httpx.Response(200, json={"data": [], "object": "list"}) + ) + + client = OpenAI( + workload_identity={ + "client_id": "client_123", + "identity_provider_id": "idp_123", + "service_account_id": "sa_123", + "provider": { + "get_token": provider, + "token_type": "jwt", + }, + }, + ) + + client.models.list() + client.models.list() + + assert provider_call_count == 1 + assert exchange_route.call_count == 1 + assert api_route.call_count == 2 + + exchange_request = cast(respx.models.Call, exchange_route.calls[0]).request + assert json.loads(exchange_request.content) == snapshot( + { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "client_id": "client_123", + "subject_token": "fake_subject_token", + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "identity_provider_id": "idp_123", + "service_account_id": "sa_123", + } + ) + + assert ( + cast(respx.models.Call, api_route.calls[0]).request.headers.get("Authorization") == "Bearer fake_access_token" + ) + assert ( + cast(respx.models.Call, api_route.calls[1]).request.headers.get("Authorization") == "Bearer fake_access_token" + ) + + +@respx.mock +def test_workload_identity_exchange_error() -> None: + exchange_route = respx.post("https://auth.openai.com/oauth/token").mock( + return_value=httpx.Response( + 401, + json={ + "error": "invalid_grant", + "error_description": "No service account mapping found for the provided service_account_id.", + }, + ) + ) + api_route = respx.get("https://api.openai.com/v1/models").mock( + return_value=httpx.Response(200, json={"data": [], "object": "list"}) + ) + + client = OpenAI( + workload_identity={ + "client_id": "client_123", + "identity_provider_id": "idp_123", + "service_account_id": "sa_123", + "provider": { + "get_token": lambda: "fake_subject_token", + "token_type": "jwt", + }, + }, + ) + + with pytest.raises(OAuthError) as exc: + client.models.list() + + assert exc.value.message == "No service account mapping found for the provided service_account_id." + assert exc.value.error == "invalid_grant" + assert exc.value.status_code == 401 + assert exchange_route.call_count == 1 + assert api_route.call_count == 0 + + +def test_k8s_service_account_token_provider(tmp_path: Path) -> None: + token_file = tmp_path / "token" + token_file.write_text("my-k8s-token") + + provider = k8s_service_account_token_provider(token_file) + + assert provider["token_type"] == "jwt" + assert provider["get_token"]() == "my-k8s-token" + + +@respx.mock +def test_azure_managed_identity_token_provider() -> None: + respx.get("http://169.254.169.254/metadata/identity/oauth2/token").mock( + return_value=httpx.Response(200, json={"access_token": "azure-token"}) + ) + + provider = azure_managed_identity_token_provider() + + assert provider["token_type"] == "jwt" + assert provider["get_token"]() == "azure-token" + + +@respx.mock +def test_gcp_id_token_provider() -> None: + respx.get("http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity").mock( + return_value=httpx.Response(200, text="gcp-token") + ) + + provider = gcp_id_token_provider() + + assert provider["token_type"] == "id" + assert provider["get_token"]() == "gcp-token" diff --git a/tests/test_client.py b/tests/test_client.py index 04ef6794ed..570042c46a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -20,6 +20,7 @@ from pydantic import ValidationError from openai import OpenAI, AsyncOpenAI, APIResponseValidationError +from openai.auth import WorkloadIdentity from openai._types import Omit from openai._utils import asyncify from openai._models import BaseModel, FinalRequestOptions @@ -41,6 +42,15 @@ T = TypeVar("T") base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") api_key = "My API Key" +workload_identity: WorkloadIdentity = { + "client_id": "client-id", + "identity_provider_id": "identity-provider-id", + "service_account_id": "service-account-id", + "provider": { + "token_type": "jwt", + "get_token": lambda: "external-subject-token", + }, +} class MockRequestCall(Protocol): @@ -414,6 +424,19 @@ def test_validate_headers(self) -> None: client2 = OpenAI(base_url=base_url, api_key=None, _strict_response_validation=True) _ = client2 + def test_workload_identity_is_mutually_exclusive_with_api_key(self) -> None: + with pytest.raises( + OpenAIError, + match="The `api_key` and `workload_identity` arguments are mutually exclusive", + ): + OpenAI( + base_url=base_url, + api_key=api_key, + workload_identity=workload_identity, # type: ignore[reportArgumentType] + organization="org_123", + _strict_response_validation=True, + ) + def test_default_query_option(self) -> None: client = OpenAI( base_url=base_url, api_key=api_key, _strict_response_validation=True, default_query={"query_param": "bar"} @@ -2189,7 +2212,6 @@ async def test_follow_redirects_disabled(self, respx_mock: MockRouter, async_cli assert exc_info.value.response.status_code == 302 assert exc_info.value.response.headers["Location"] == f"{base_url}/redirected" - @pytest.mark.asyncio async def test_api_key_before_after_refresh_provider(self) -> None: async def mock_api_key_provider(): return "test_bearer_token" @@ -2204,7 +2226,6 @@ async def mock_api_key_provider(): assert client.api_key == "test_bearer_token" assert client.auth_headers.get("Authorization") == "Bearer test_bearer_token" - @pytest.mark.asyncio async def test_api_key_before_after_refresh_str(self) -> None: client = AsyncOpenAI(base_url=base_url, api_key="test_api_key") @@ -2213,7 +2234,6 @@ async def test_api_key_before_after_refresh_str(self) -> None: assert client.auth_headers.get("Authorization") == "Bearer test_api_key" - @pytest.mark.asyncio @pytest.mark.respx() async def test_bearer_token_refresh_async(self, respx_mock: MockRouter) -> None: respx_mock.post(base_url + "/chat/completions").mock( @@ -2244,7 +2264,6 @@ async def token_provider() -> str: assert calls[0].request.headers.get("Authorization") == "Bearer first" assert calls[1].request.headers.get("Authorization") == "Bearer second" - @pytest.mark.asyncio async def test_copy_auth(self) -> None: async def token_provider_1() -> str: return "test_bearer_token_1" @@ -2255,3 +2274,299 @@ async def token_provider_2() -> str: client = AsyncOpenAI(base_url=base_url, api_key=token_provider_1).copy(api_key=token_provider_2) await client._refresh_api_key() assert client.auth_headers == {"Authorization": "Bearer test_bearer_token_2"} + + +class TestWorkloadIdentity401Retry: + @pytest.mark.respx() + def test_workload_identity_401_retry(self, respx_mock: MockRouter) -> None: + provider_call_count = 0 + + def provider() -> str: + nonlocal provider_call_count + provider_call_count += 1 + return f"external-subject-token-{provider_call_count}" + + respx_mock.post("https://auth.openai.com/oauth/token").mock( + side_effect=[ + httpx.Response( + 200, + json={ + "access_token": "openai-access-token-1", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ), + httpx.Response( + 200, + json={ + "access_token": "openai-access-token-2", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ), + ] + ) + + respx_mock.post(base_url + "/chat/completions").mock( + side_effect=[ + httpx.Response(401, json={"error": {"message": "Unauthorized", "type": "invalid_request_error"}}), + httpx.Response( + 200, + json={ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1234567890, + "model": "gpt-4", + "choices": [], + }, + ), + ] + ) + + with OpenAI( + base_url=base_url, + workload_identity={ + **workload_identity, + "provider": { + "get_token": provider, + "token_type": "jwt", + }, + }, + organization="org_123", + project="proj_123", + _strict_response_validation=True, + ) as client: + client.chat.completions.create(messages=[], model="gpt-4") + + calls = cast("list[MockRequestCall]", respx_mock.calls) + assert len(calls) == 4 + + assert calls[0].request.url == httpx.URL("https://auth.openai.com/oauth/token") + assert calls[1].request.url == httpx.URL(base_url + "/chat/completions") + assert calls[1].request.headers.get("Authorization") == "Bearer openai-access-token-1" + + assert calls[2].request.url == httpx.URL("https://auth.openai.com/oauth/token") + + assert calls[3].request.url == httpx.URL(base_url + "/chat/completions") + assert calls[3].request.headers.get("Authorization") == "Bearer openai-access-token-2" + + assert provider_call_count == 2 + + @pytest.mark.respx() + def test_401_without_workload_identity_no_retry(self, respx_mock: MockRouter) -> None: + respx_mock.post(base_url + "/chat/completions").mock( + return_value=httpx.Response( + 401, json={"error": {"message": "Unauthorized", "type": "invalid_request_error"}} + ) + ) + + with OpenAI( + base_url=base_url, + api_key="test-api-key", + _strict_response_validation=True, + ) as client: + with pytest.raises(APIStatusError) as exc_info: + client.chat.completions.create(messages=[], model="gpt-4") + + assert exc_info.value.status_code == 401 + + calls = cast("list[MockRequestCall]", respx_mock.calls) + assert len(calls) == 1 + + @pytest.mark.respx() + def test_non_401_errors_no_retry(self, respx_mock: MockRouter) -> None: + provider_call_count = 0 + + def provider() -> str: + nonlocal provider_call_count + provider_call_count += 1 + return "external-subject-token" + + respx_mock.post("https://auth.openai.com/oauth/token").mock( + return_value=httpx.Response( + 200, + json={ + "access_token": "openai-access-token-1", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + ) + + respx_mock.post(base_url + "/chat/completions").mock( + return_value=httpx.Response(403, json={"error": {"message": "Forbidden", "type": "invalid_request_error"}}) + ) + + with OpenAI( + base_url=base_url, + workload_identity={ + **workload_identity, + "provider": { + "get_token": provider, + "token_type": "jwt", + }, + }, + organization="org_123", + project="proj_123", + _strict_response_validation=True, + ) as client: + with pytest.raises(APIStatusError) as exc_info: + client.chat.completions.create(messages=[], model="gpt-4") + + assert exc_info.value.status_code == 403 + + calls = cast("list[MockRequestCall]", respx_mock.calls) + assert len(calls) == 2 + + assert provider_call_count == 1 + + +class TestAsyncWorkloadIdentity401Retry: + @pytest.mark.respx() + async def test_workload_identity_401_retry(self, respx_mock: MockRouter) -> None: + provider_call_count = 0 + + def provider() -> str: + nonlocal provider_call_count + provider_call_count += 1 + return f"external-subject-token-{provider_call_count}" + + respx_mock.post("https://auth.openai.com/oauth/token").mock( + side_effect=[ + httpx.Response( + 200, + json={ + "access_token": "openai-access-token-1", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ), + httpx.Response( + 200, + json={ + "access_token": "openai-access-token-2", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ), + ] + ) + + respx_mock.post(base_url + "/chat/completions").mock( + side_effect=[ + httpx.Response(401, json={"error": {"message": "Unauthorized", "type": "invalid_request_error"}}), + httpx.Response( + 200, + json={ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1234567890, + "model": "gpt-4", + "choices": [], + }, + ), + ] + ) + + async with AsyncOpenAI( + base_url=base_url, + workload_identity={ + **workload_identity, + "provider": { + "get_token": provider, + "token_type": "jwt", + }, + }, + organization="org_123", + project="proj_123", + _strict_response_validation=True, + ) as client: + await client.chat.completions.create(messages=[], model="gpt-4") + + calls = cast("list[MockRequestCall]", respx_mock.calls) + assert len(calls) == 4 + + assert calls[0].request.url == httpx.URL("https://auth.openai.com/oauth/token") + assert calls[1].request.url == httpx.URL(base_url + "/chat/completions") + assert calls[1].request.headers.get("Authorization") == "Bearer openai-access-token-1" + + assert calls[2].request.url == httpx.URL("https://auth.openai.com/oauth/token") + + assert calls[3].request.url == httpx.URL(base_url + "/chat/completions") + assert calls[3].request.headers.get("Authorization") == "Bearer openai-access-token-2" + + assert provider_call_count == 2 + + @pytest.mark.respx() + async def test_401_without_workload_identity_no_retry(self, respx_mock: MockRouter) -> None: + respx_mock.post(base_url + "/chat/completions").mock( + return_value=httpx.Response( + 401, json={"error": {"message": "Unauthorized", "type": "invalid_request_error"}} + ) + ) + + async with AsyncOpenAI( + base_url=base_url, + api_key="test-api-key", + _strict_response_validation=True, + ) as client: + with pytest.raises(APIStatusError) as exc_info: + await client.chat.completions.create(messages=[], model="gpt-4") + + assert exc_info.value.status_code == 401 + + calls = cast("list[MockRequestCall]", respx_mock.calls) + assert len(calls) == 1 + + @pytest.mark.respx() + async def test_non_401_errors_no_retry(self, respx_mock: MockRouter) -> None: + provider_call_count = 0 + + def provider() -> str: + nonlocal provider_call_count + provider_call_count += 1 + return "external-subject-token" + + respx_mock.post("https://auth.openai.com/oauth/token").mock( + return_value=httpx.Response( + 200, + json={ + "access_token": "openai-access-token-1", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + ) + + respx_mock.post(base_url + "/chat/completions").mock( + return_value=httpx.Response(403, json={"error": {"message": "Forbidden", "type": "invalid_request_error"}}) + ) + + async with AsyncOpenAI( + base_url=base_url, + workload_identity={ + **workload_identity, + "provider": { + "get_token": provider, + "token_type": "jwt", + }, + }, + organization="org_123", + project="proj_123", + _strict_response_validation=True, + ) as client: + with pytest.raises(APIStatusError) as exc_info: + await client.chat.completions.create(messages=[], model="gpt-4") + + assert exc_info.value.status_code == 403 + + calls = cast("list[MockRequestCall]", respx_mock.calls) + assert len(calls) == 2 + + assert provider_call_count == 1 From 12eeba1df7988a79573425fc282d41863c94cc53 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:57:59 +0000 Subject: [PATCH 11/11] release: 2.31.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/openai/_version.py | 2 +- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ef8743735a..8f5c652f67 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.30.0" + ".": "2.31.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 785fab5782..bf8092f51f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## 2.31.0 (2026-04-08) + +Full Changelog: [v2.30.0...v2.31.0](https://github.com/openai/openai-python/compare/v2.30.0...v2.31.0) + +### Features + +* **api:** add phase field to conversations message ([3e5834e](https://github.com/openai/openai-python/commit/3e5834efb39b24e019a29dc54d890c67d18cbb54)) +* **api:** add web_search_call.results to ResponseIncludable type ([ffd8741](https://github.com/openai/openai-python/commit/ffd8741dd38609a5af0159ceb800d8ddba7925f8)) +* **client:** add support for short-lived tokens ([#1608](https://github.com/openai/openai-python/issues/1608)) ([22fe722](https://github.com/openai/openai-python/commit/22fe7228d4990c197cd721b3ad7931ad05cca5dd)) +* **client:** support sending raw data over websockets ([f1bc52e](https://github.com/openai/openai-python/commit/f1bc52ef641dfca6fdf2a5b00ce3b09bff2552f5)) +* **internal:** implement indices array format for query and form serialization ([49194cf](https://github.com/openai/openai-python/commit/49194cfa711328216ff131d6f65c9298822a7c51)) + + +### Bug Fixes + +* **client:** preserve hardcoded query params when merging with user params ([92e109c](https://github.com/openai/openai-python/commit/92e109c3d9569a942e1919e75977dc13fa015f9a)) +* **types:** remove web_search_call.results from ResponseIncludable ([d3cc401](https://github.com/openai/openai-python/commit/d3cc40165cd86015833d15167cc7712b4102f932)) + + +### Chores + +* **tests:** bump steady to v0.20.1 ([d60e2ee](https://github.com/openai/openai-python/commit/d60e2eea7f6916540cd4ba901dceb07051119da4)) +* **tests:** bump steady to v0.20.2 ([6508d47](https://github.com/openai/openai-python/commit/6508d474332d4e82d9615c0a9a77379f9b5e4412)) + + +### Documentation + +* **api:** update file parameter descriptions in vector_stores files and file_batches ([a9e7ebd](https://github.com/openai/openai-python/commit/a9e7ebd505b9ae90514339aa63c6f1984a08cf6b)) + ## 2.30.0 (2026-03-25) Full Changelog: [v2.29.0...v2.30.0](https://github.com/openai/openai-python/compare/v2.29.0...v2.30.0) diff --git a/pyproject.toml b/pyproject.toml index bfc0e13be7..c85db6b519 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "openai" -version = "2.30.0" +version = "2.31.0" description = "The official Python library for the openai API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/openai/_version.py b/src/openai/_version.py index 788e82e056..a435bdb765 100644 --- a/src/openai/_version.py +++ b/src/openai/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "openai" -__version__ = "2.30.0" # x-release-please-version +__version__ = "2.31.0" # x-release-please-version