From 62470049bd15575d7f7128ce335c9f6fd346634c Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Wed, 29 Jul 2026 14:40:03 -0700 Subject: [PATCH 1/4] docs: add HTTP client mTLS recipe --- README.md | 116 +++++++++++++++++++++++++++ examples/mtls_httpx.py | 29 +++++++ examples/mtls_httpx2.py | 29 +++++++ examples/mtls_httpx2_async.py | 35 ++++++++ examples/mtls_httpx_async.py | 35 ++++++++ tests/fixtures/mtls/client-chain.pem | 41 ++++++++++ tests/fixtures/mtls/client.key | 28 +++++++ tests/fixtures/mtls/root.pem | 20 +++++ tests/fixtures/mtls/server-chain.pem | 41 ++++++++++ tests/fixtures/mtls/server.key | 28 +++++++ tests/test_mtls_http_client.py | 111 +++++++++++++++++++++++++ 11 files changed, 513 insertions(+) create mode 100644 examples/mtls_httpx.py create mode 100644 examples/mtls_httpx2.py create mode 100644 examples/mtls_httpx2_async.py create mode 100644 examples/mtls_httpx_async.py create mode 100644 tests/fixtures/mtls/client-chain.pem create mode 100644 tests/fixtures/mtls/client.key create mode 100644 tests/fixtures/mtls/root.pem create mode 100644 tests/fixtures/mtls/server-chain.pem create mode 100644 tests/fixtures/mtls/server.key create mode 100644 tests/test_mtls_http_client.py diff --git a/README.md b/README.md index 00013c00a5..68e1bb54ad 100644 --- a/README.md +++ b/README.md @@ -899,6 +899,122 @@ You can also customize the client on a per-request basis by using `with_options( client.with_options(http_client=DefaultHttpxClient(...)) ``` +#### Mutual TLS + +For API-key authenticated HTTP requests that require mutual TLS (mTLS), configure +a native [`ssl.SSLContext`](https://docs.python.org/3/library/ssl.html#ssl.SSLContext) +and pass it through the custom HTTP client: + +```python +import os +import ssl + +from openai import OpenAI, DefaultHttpxClient + +# Server trust is configured independently. Without `cafile`, this uses the +# operating system's normal trusted certificate authorities. +ssl_context = ssl.create_default_context( + cafile=os.environ.get("OPENAI_MTLS_CA_BUNDLE"), +) +ssl_context.load_cert_chain( + # This PEM must contain the leaf certificate first, followed by every + # intermediate certificate needed to reach the server's trust anchor. + certfile=os.environ["OPENAI_MTLS_CERTIFICATE_CHAIN"], + keyfile=os.environ["OPENAI_MTLS_PRIVATE_KEY"], + password=os.environ.get("OPENAI_MTLS_PRIVATE_KEY_PASSWORD"), +) + +client = OpenAI( + api_key=os.environ["OPENAI_API_KEY"], + # A custom HTTP client does not tell the SDK that mTLS is configured, so + # select the mTLS endpoint explicitly. Preserve an EU or custom override. + base_url=os.environ.get( + "OPENAI_BASE_URL", + "https://mtls.api.openai.com/v1", + ), + # A client certificate belongs to the HTTP client, not the base URL. + # Disable redirects so it cannot follow a response to another origin. + http_client=DefaultHttpxClient( + verify=ssl_context, + follow_redirects=False, + ), +) +``` + +The async configuration is equivalent: + +```python +import os +import ssl + +from openai import AsyncOpenAI, DefaultAsyncHttpxClient + +ssl_context = ssl.create_default_context( + cafile=os.environ.get("OPENAI_MTLS_CA_BUNDLE"), +) +ssl_context.load_cert_chain( + certfile=os.environ["OPENAI_MTLS_CERTIFICATE_CHAIN"], + keyfile=os.environ["OPENAI_MTLS_PRIVATE_KEY"], + password=os.environ.get("OPENAI_MTLS_PRIVATE_KEY_PASSWORD"), +) + +client = AsyncOpenAI( + api_key=os.environ["OPENAI_API_KEY"], + base_url=os.environ.get( + "OPENAI_BASE_URL", + "https://mtls.api.openai.com/v1", + ), + http_client=DefaultAsyncHttpxClient( + verify=ssl_context, + follow_redirects=False, + ), +) +``` + +Experimental HTTPX2 uses the same native `SSLContext`. Install the optional +extra with `pip install 'openai[httpx2]'`, then use `DefaultHttpx2Client` or +`DefaultAsyncHttpx2Client` in place of the corresponding HTTPX client above: + +```python +from openai import OpenAI, DefaultHttpx2Client + +client = OpenAI( + api_key=os.environ["OPENAI_API_KEY"], + base_url=os.environ.get( + "OPENAI_BASE_URL", + "https://mtls.api.openai.com/v1", + ), + http_client=DefaultHttpx2Client( + verify=ssl_context, + follow_redirects=False, + ), +) +``` + +See the complete [sync HTTPX2](examples/mtls_httpx2.py) and +[async HTTPX2](examples/mtls_httpx2_async.py) examples. + +The certificate-bearing HTTP client is transport-wide. Dedicate it to the +selected mTLS origin; do not reuse it for other services or pass it through +`with_options()` with a different `base_url`. If redirects are required, add an +HTTPX request hook that rejects requests whose scheme, host, or port differs +from the configured mTLS origin before enabling `follow_redirects`. + +`SSLContext.load_cert_chain()` raises during setup for unreadable or malformed +files and for a private key that does not match the leaf certificate. Certificate +expiry, key usage, extended key usage, SAN, and trust policy remain TLS server +decisions. OpenAI does not fetch missing intermediates through AIA, so provide a +complete, leaf-first client-chain PEM. + +For certificate rotation, build a new `SSLContext`, HTTP client, and `OpenAI` or +`AsyncOpenAI` client. This creates a fresh connection pool; close the old SDK +client after its in-flight requests finish. Do not assume existing TLS +connections will renegotiate. + +This recipe applies to ordinary API-key HTTP traffic. It does not implement +certificate-only X.509 workload identity, token exchange, or Realtime WebSocket +mTLS. + ### Managing HTTP resources By default the library closes underlying HTTP connections whenever the client is [garbage collected](https://docs.python.org/3/reference/datamodel.html#object.__del__). You can manually close the client using the `.close()` method if desired, or with a context manager that closes when exiting. diff --git a/examples/mtls_httpx.py b/examples/mtls_httpx.py new file mode 100644 index 0000000000..1c35231f00 --- /dev/null +++ b/examples/mtls_httpx.py @@ -0,0 +1,29 @@ +#!/usr/bin/env -S rye run python + +import os +import ssl + +from openai import OpenAI, DefaultHttpxClient + +ssl_context = ssl.create_default_context( + cafile=os.environ.get("OPENAI_MTLS_CA_BUNDLE"), +) +ssl_context.load_cert_chain( + # Leaf certificate first, followed by all required intermediates. + certfile=os.environ["OPENAI_MTLS_CERTIFICATE_CHAIN"], + keyfile=os.environ["OPENAI_MTLS_PRIVATE_KEY"], + password=os.environ.get("OPENAI_MTLS_PRIVATE_KEY_PASSWORD"), +) + +with OpenAI( + api_key=os.environ["OPENAI_API_KEY"], + base_url=os.environ.get( + "OPENAI_BASE_URL", + "https://mtls.api.openai.com/v1", + ), + http_client=DefaultHttpxClient( + verify=ssl_context, + follow_redirects=False, + ), +) as client: + print(client.models.list()) diff --git a/examples/mtls_httpx2.py b/examples/mtls_httpx2.py new file mode 100644 index 0000000000..1b987565be --- /dev/null +++ b/examples/mtls_httpx2.py @@ -0,0 +1,29 @@ +#!/usr/bin/env -S rye run python + +import os +import ssl + +from openai import OpenAI, DefaultHttpx2Client + +ssl_context = ssl.create_default_context( + cafile=os.environ.get("OPENAI_MTLS_CA_BUNDLE"), +) +ssl_context.load_cert_chain( + # Leaf certificate first, followed by all required intermediates. + certfile=os.environ["OPENAI_MTLS_CERTIFICATE_CHAIN"], + keyfile=os.environ["OPENAI_MTLS_PRIVATE_KEY"], + password=os.environ.get("OPENAI_MTLS_PRIVATE_KEY_PASSWORD"), +) + +with OpenAI( + api_key=os.environ["OPENAI_API_KEY"], + base_url=os.environ.get( + "OPENAI_BASE_URL", + "https://mtls.api.openai.com/v1", + ), + http_client=DefaultHttpx2Client( + verify=ssl_context, + follow_redirects=False, + ), +) as client: + print(client.models.list()) diff --git a/examples/mtls_httpx2_async.py b/examples/mtls_httpx2_async.py new file mode 100644 index 0000000000..62d4cc4d04 --- /dev/null +++ b/examples/mtls_httpx2_async.py @@ -0,0 +1,35 @@ +#!/usr/bin/env -S rye run python + +import os +import ssl +import asyncio + +from openai import AsyncOpenAI, DefaultAsyncHttpx2Client + + +async def main() -> None: + ssl_context = ssl.create_default_context( + cafile=os.environ.get("OPENAI_MTLS_CA_BUNDLE"), + ) + ssl_context.load_cert_chain( + # Leaf certificate first, followed by all required intermediates. + certfile=os.environ["OPENAI_MTLS_CERTIFICATE_CHAIN"], + keyfile=os.environ["OPENAI_MTLS_PRIVATE_KEY"], + password=os.environ.get("OPENAI_MTLS_PRIVATE_KEY_PASSWORD"), + ) + + async with AsyncOpenAI( + api_key=os.environ["OPENAI_API_KEY"], + base_url=os.environ.get( + "OPENAI_BASE_URL", + "https://mtls.api.openai.com/v1", + ), + http_client=DefaultAsyncHttpx2Client( + verify=ssl_context, + follow_redirects=False, + ), + ) as client: + print(await client.models.list()) + + +asyncio.run(main()) diff --git a/examples/mtls_httpx_async.py b/examples/mtls_httpx_async.py new file mode 100644 index 0000000000..cd7a8dcc04 --- /dev/null +++ b/examples/mtls_httpx_async.py @@ -0,0 +1,35 @@ +#!/usr/bin/env -S rye run python + +import os +import ssl +import asyncio + +from openai import AsyncOpenAI, DefaultAsyncHttpxClient + + +async def main() -> None: + ssl_context = ssl.create_default_context( + cafile=os.environ.get("OPENAI_MTLS_CA_BUNDLE"), + ) + ssl_context.load_cert_chain( + # Leaf certificate first, followed by all required intermediates. + certfile=os.environ["OPENAI_MTLS_CERTIFICATE_CHAIN"], + keyfile=os.environ["OPENAI_MTLS_PRIVATE_KEY"], + password=os.environ.get("OPENAI_MTLS_PRIVATE_KEY_PASSWORD"), + ) + + async with AsyncOpenAI( + api_key=os.environ["OPENAI_API_KEY"], + base_url=os.environ.get( + "OPENAI_BASE_URL", + "https://mtls.api.openai.com/v1", + ), + http_client=DefaultAsyncHttpxClient( + verify=ssl_context, + follow_redirects=False, + ), + ) as client: + print(await client.models.list()) + + +asyncio.run(main()) diff --git a/tests/fixtures/mtls/client-chain.pem b/tests/fixtures/mtls/client-chain.pem new file mode 100644 index 0000000000..ae81b000c5 --- /dev/null +++ b/tests/fixtures/mtls/client-chain.pem @@ -0,0 +1,41 @@ +-----BEGIN CERTIFICATE----- +MIIDdzCCAl+gAwIBAgIUYfX+FG1H1GIF9JGcTGENkRmmO0AwDQYJKoZIhvcNAQEL +BQAwLzEtMCsGA1UEAwwkb3BlbmFpLXB5dGhvbi1tdGxzLXRlc3QtaW50ZXJtZWRp +YXRlMCAXDTI2MDcyODE5Mjc1MloYDzIxMjYwNzA0MTkyNzUyWjApMScwJQYDVQQD +DB5vcGVuYWktcHl0aG9uLW10bHMtdGVzdC1jbGllbnQwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDKfqXwVVGGA+o6tk1pJi8yyYpApRadLwTO643ETasj +7x41w9MH1jznEaZB+UZkm8IO/tcRF11+YiKqY2gFS0bthafjDCwB4+Ts6v3/GLfz +kCi6FgMFoOhNs5QvQiM5hpSfUOyHNf0yPsMxxqb8K0+YF0yMmryc+cVTwD31ZyKw +gQ24zWNR9HOsHAqHCo0+SDzcBufo6Ps+BstJvxkp1DDRFTvvSqhVZ7w5WrP/CfzP +qvKXTbuv0fGrFCakhNeyRN9y7ygP0YOODglnduIV9al4N9k/8axAZsQjjnxMV+5l +T/FLlqSQTmH9HHsjTQoOKyqOhHk8JSP+QctAotLWYK+dAgMBAAGjgY4wgYswDAYD +VR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwIw +FgYDVR0RBA8wDYILY2xpZW50LnRlc3QwHQYDVR0OBBYEFJELELi4PoIeS0pnxF9s +EoToL3etMB8GA1UdIwQYMBaAFLZ4Gtv+mnYi/DpIUXkfprFU6HbEMA0GCSqGSIb3 +DQEBCwUAA4IBAQAp7S4SWpxs6GPmBBT8Nu6bXmlSdjtLNZ2C4sG9BBY3uACseN2B +6G149VLxLMaWPHd/L46SYAhAkN/zj7LnIrygiFW5eDZCxvYCBEk75U5zTpk9SQL5 +bQqsR0RH9NxJkoKIUkQjrnxD+u7C2RBF3sE5KZmTGWYbvJXIOVrDHHWrb+z07bzQ +T8XRa4qdBGuV8OYQBzYR6EIb/DkEgBmfC/uTu7XFpQbmfw7Dnh0JcoJ9Dx9St7Xl +0HBb5gY0thL2cCrwe+PTSLV7S0nZLxp/azz/ROuzYWFSsSWMC4/hqdsoaa7PoVC6 +Lr66LVF6hzL/TtACRCS4X7nu/7GzXNwE1BTa +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDTDCCAjSgAwIBAgIUDYU6Ki4xOPXM8RawT32qDO00934wDQYJKoZIhvcNAQEL +BQAwJzElMCMGA1UEAwwcb3BlbmFpLXB5dGhvbi1tdGxzLXRlc3Qtcm9vdDAgFw0y +NjA3MjgxOTI3NTJaGA8yMTI2MDcwNDE5Mjc1MlowLzEtMCsGA1UEAwwkb3BlbmFp +LXB5dGhvbi1tdGxzLXRlc3QtaW50ZXJtZWRpYXRlMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAmye/0UvrNwfMZRx53QYM1W47rTtCQlp4Yo7L6EmceAzE +9Njf7IW6NPDqfH35zFOtHlD4rFaU6elkwNO0dyG37u3haaRUcUGhpDerI0RebZkH +XFwXVB9nbDI/5+7wtpqdKM10Mn88UtHG/akTpoqjZnlBfYBoNaTzb8UgVCQxsXuH +qxoFzxr1zjBl+wCm0WidzhsLCCBogH3h9RZ/kKrWTWkoVx6Wky7gUut04g3CEQtW +6XGV3edVHEBUAnXsx32ktpFiZwL1MKo68Lebb4PrXrAHuVyjYBFGXwSXluxSDKsH +44q82YMtwge1uhwdFg9Tiz7U5LeEcTTSp48aCeymnwIDAQABo2YwZDASBgNVHRMB +Af8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUtnga2/6adiL8 +OkhReR+msVTodsQwHwYDVR0jBBgwFoAUHDFyfiCXhqFiwcl9ZAWApe40+m0wDQYJ +KoZIhvcNAQELBQADggEBACaN1i97TDdZUB0I2p1yBOnbGDVNdmJYwkrWU+CR3StT +dz84/QAWztvKSwtPzuzk4RRpE6EzOPbYmEqTxRdKB08t1aBMZTasdkKjl8WmRoAE +XOYtPE/d53dnC+T3qfyJhq882H95kHBBsvPG8kWEhX5BN30qET2FP6xi6cTAXtR0 +py3AV9qZqx9gxWIC2O1sh9nHmkvhk7WKxlhjkn5EEpDcLX9QfWy8iGrhoN+5RMR7 +1/T6JyP58q1DR8SZ5RaFBO7e5QPYNj8mFf2/wMJZN8kMZZgrXQmX3W9GkIq48evk +ACDggWyBKx+I44bcCRuSOfDTiGLRY6AmEGx7uQBHDT4= +-----END CERTIFICATE----- diff --git a/tests/fixtures/mtls/client.key b/tests/fixtures/mtls/client.key new file mode 100644 index 0000000000..a60815950b --- /dev/null +++ b/tests/fixtures/mtls/client.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDKfqXwVVGGA+o6 +tk1pJi8yyYpApRadLwTO643ETasj7x41w9MH1jznEaZB+UZkm8IO/tcRF11+YiKq +Y2gFS0bthafjDCwB4+Ts6v3/GLfzkCi6FgMFoOhNs5QvQiM5hpSfUOyHNf0yPsMx +xqb8K0+YF0yMmryc+cVTwD31ZyKwgQ24zWNR9HOsHAqHCo0+SDzcBufo6Ps+BstJ +vxkp1DDRFTvvSqhVZ7w5WrP/CfzPqvKXTbuv0fGrFCakhNeyRN9y7ygP0YOODgln +duIV9al4N9k/8axAZsQjjnxMV+5lT/FLlqSQTmH9HHsjTQoOKyqOhHk8JSP+QctA +otLWYK+dAgMBAAECggEAR/hdiB7627P2gymaN94fdmCVZ8aFVBaLEfQ8reGhCyOI +zDkufyGRAduPCPHNKCMIBQZkcCmqzCmbAo5UQVVw/yi69AK2fXF/Qwl+fzVM5B9/ +qiv6pPx8tGk4KNfL5z1DA6DigGga9snB3KYrYYMPRhI53dt9YBmSHeM84kTm2m5S +DNfK0kzedahJ38FihqeHShSL/vsxqX2zU9MWhT/zkyw4ca3NVJ1Bvio/d+Ds0e3o +LEM2K7t2Oe1ALhn7RPG2yqF1Jts6CfVgNzMDLwlv/oOkckqwMbvsWTThixJsqRdL +A/nYWf6mraXQSOLvtntwYxG38s7VGWLwbWqumBLMLQKBgQDorRkw0VlIneldnpnF +KiTlCVxnG1mtGOYRHee5UgN2AwKIursLtuEv7oSy1bHh5rRX0udSGPFrBr7TaPH2 +/fY4Le5hVUD7SLCl7pS4gpfdCeg5IYibSU6GxvRiXAKTGAUO4Pcu9xXNNDG3B4Vx +jOMkkUrSIm1yisTZhhA5MZyQywKBgQDeywm3rB7hTc1kjrkNL5/gTmuG86lYck9D +P8MrkaATt1YK5icPTL1GwyMjrliR7UvpP3pEdA1sG1UX2PoS4y58wTmfBFQCWb9a +9ZQMmrZQqxHyl3fuVGnBOTMDjUQdmJPTpHS/OaMjEEiR8MWOT/GS+tEwRCSb/AK+ +Zkv/X6o8NwKBgQCqjnhwuITiHh76aU/+ny38VihNzFan9CBxW6KIzf2LfBlXcMm7 +hIr9P7I2BT8ngJ2h4w99tpsBASjQf5UeoHrkI4ciAgRoLpiOiZyqw8/eT2zStCoW +6l2Nnjl2AExC1tCeX3nSC30HtsLaj8DZw5SdMYPPFT11QRObABLUWfGSkQKBgQCq +DoDUWeUYRLLKVsaZcgiuxiz9TW+tu1MVGc53qyhs5DwhBZw66XBwWvKvgZzJhj+z +QmipZ4v3QMWq9kurrw0E3NiGsF8PjEGrxFfFZzJSUMHaUhORL42pl2eBBos/q/7q +RVV3wR7s3LkH7KhfAFZ8wkZ6eQkYpzvQ6XSI8RSX8QKBgQDcWG98InGPTmUTxKqH +41BLurASrV9tgQ4IBWUETh6kDiAFf4pqprnWU2WWWXW1Gvh53SCWLpR+ZoCF7ChE +xxFEbdKHQXF9YF9i6B6tLtfZAuHa2rP/oW7pJOPMZGgdP0fqxajS9q75bbkIkHVX +Z5IDyucHjXINQKwwOpSXGC/uQw== +-----END PRIVATE KEY----- diff --git a/tests/fixtures/mtls/root.pem b/tests/fixtures/mtls/root.pem new file mode 100644 index 0000000000..2b3d24dee9 --- /dev/null +++ b/tests/fixtures/mtls/root.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQTCCAimgAwIBAgIUF3vN0MB349w6hj4TzZ8M1leIxqowDQYJKoZIhvcNAQEL +BQAwJzElMCMGA1UEAwwcb3BlbmFpLXB5dGhvbi1tdGxzLXRlc3Qtcm9vdDAgFw0y +NjA3MjgxOTI3NTJaGA8yMTI2MDcwNDE5Mjc1MlowJzElMCMGA1UEAwwcb3BlbmFp +LXB5dGhvbi1tdGxzLXRlc3Qtcm9vdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBAMDtWi85fXPWQGPnIh6bgRk2/Yht4Y9Rw4Cfads/gMbXWwY6uj9wPpyu +00nC56+hseL0DucIX7eKBWnmhHkJEkZERZL7QADlmeB3OzAnG+rUYq/YU/vQJ8ZW +GdIeJKDFoGy9gDCj37QXfvzQ9j02jWHzBhMyIvdyxfTskKgWsmUVQXj7Bl+2F6yC +g8dqNfY8bFbEab+QkxziMGuCuJ4Xy5A+Gsh7/XnxZouGqUZ48mhJ7BkPfJuK+lqY +sWF5symLegLlHgcVr5sDZNCVLXbeQoPjaXafL4SYpyCLBKR7sgJg36rzKtatlviz +wHByx1nh/leCydR3cWzfKqXZz9Qfe50CAwEAAaNjMGEwHQYDVR0OBBYEFBwxcn4g +l4ahYsHJfWQFgKXuNPptMB8GA1UdIwQYMBaAFBwxcn4gl4ahYsHJfWQFgKXuNPpt +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUA +A4IBAQBrn+gwfOuJgLFNp1Ss7u67QlAT0CdQnF+WntTEbucOEXFkPU8hSA9GYy84 +rCAGeBjruNwkOGXiXUdCL3Ijz0Vde96C6UdSUWoRePsQfztrD9FxXJjQCEUFUHOl +AjMP/oGxY44t9WlxDJ8vk0O7klAliOSGRCSFPe6RoAM7Zq6G7psxLUBsNStKn154 +GWLhAo2GlPZqRhIdjpdsyznP60bRGURhsr/tLUrecVSWIh3PeDLdTNNoQZypZr1s +ewHx5+bFAWhndWf7aDLmFHEThgABYJ8Pim1BrGM1TbPPw+UJi4mKcx6LP8bcP/Je +W8L2YCAvvQEYWWrDOehzotLOH59N +-----END CERTIFICATE----- diff --git a/tests/fixtures/mtls/server-chain.pem b/tests/fixtures/mtls/server-chain.pem new file mode 100644 index 0000000000..8aa2c92efc --- /dev/null +++ b/tests/fixtures/mtls/server-chain.pem @@ -0,0 +1,41 @@ +-----BEGIN CERTIFICATE----- +MIIDZjCCAk6gAwIBAgIUYfX+FG1H1GIF9JGcTGENkRmmO0EwDQYJKoZIhvcNAQEL +BQAwLzEtMCsGA1UEAwwkb3BlbmFpLXB5dGhvbi1tdGxzLXRlc3QtaW50ZXJtZWRp +YXRlMCAXDTI2MDcyODE5Mjc1MloYDzIxMjYwNzA0MTkyNzUyWjAUMRIwEAYDVQQD +DAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQClNMQb +1AZcVw7yeRjW7KkzsIZoZcYTIccE4+XmxQbPtsSL97DeIQWs7q2338w8bUkhUNnd +72xzGQsHXmUHbg7uzXopOoUmEq5oRFM8DFBm0CfzhjtYYCAvAV42SAEEw9Tjpuln +OxdL5ETRCl9MrAeRpObwsiDwgjfHGelqvUFQCzEU3Wwfg3ASbHJi9e68aI3eE8Bu +ztNudDNVD5QGBWf90Sfka6z5ZNE/F2Bw7oawPuaUEYDppxyAZj9qMjXwfCYeAFuK +pBNbbXg63HX0+OI40t3JcBMJ2Gq2sPHk4tk0t9qOHMw8vxlH6aDJr2Glef0iHNA2 +VatMf+wINC9mhDizAgMBAAGjgZIwgY8wDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8E +BAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwGgYDVR0RBBMwEYIJbG9jYWxob3N0 +hwR/AAABMB0GA1UdDgQWBBQ+QAQjOrnj9tFsg0MLhXDBw+U0rjAfBgNVHSMEGDAW +gBS2eBrb/pp2Ivw6SFF5H6axVOh2xDANBgkqhkiG9w0BAQsFAAOCAQEAf0O9Gz87 +mJojijghkDJZiWEy9vTHW2+OYGtDzzfdSgpRfNDMrhmFs+BINLNfZoS9D+XmUffM +J5MWAOTbD4XYgjSwbEkTP+1piz4ZZ5t3SjCDlxYm8znip0FGyeOOs4sCxVH9FJSK +AlEkyoN1jjSyKYAtOWlGZ7QafPuqKJZpozCMR0XOoNzh9VX+IzsfJVRrQbKNWqTV +1o+6aEop+9ooOgQRhrpGzHFLPhkjtPef+fhIcpyIyoOPiPxVoin+wRM2mzuezR1q +6hPU6CA/4mfGk2hO1vkNPbjCcnwfE0qK4VSmafD/TubrF9ABHrMud4wAhvwLV36z +KSpRj8j4j/7L6w== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDTDCCAjSgAwIBAgIUDYU6Ki4xOPXM8RawT32qDO00934wDQYJKoZIhvcNAQEL +BQAwJzElMCMGA1UEAwwcb3BlbmFpLXB5dGhvbi1tdGxzLXRlc3Qtcm9vdDAgFw0y +NjA3MjgxOTI3NTJaGA8yMTI2MDcwNDE5Mjc1MlowLzEtMCsGA1UEAwwkb3BlbmFp +LXB5dGhvbi1tdGxzLXRlc3QtaW50ZXJtZWRpYXRlMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAmye/0UvrNwfMZRx53QYM1W47rTtCQlp4Yo7L6EmceAzE +9Njf7IW6NPDqfH35zFOtHlD4rFaU6elkwNO0dyG37u3haaRUcUGhpDerI0RebZkH +XFwXVB9nbDI/5+7wtpqdKM10Mn88UtHG/akTpoqjZnlBfYBoNaTzb8UgVCQxsXuH +qxoFzxr1zjBl+wCm0WidzhsLCCBogH3h9RZ/kKrWTWkoVx6Wky7gUut04g3CEQtW +6XGV3edVHEBUAnXsx32ktpFiZwL1MKo68Lebb4PrXrAHuVyjYBFGXwSXluxSDKsH +44q82YMtwge1uhwdFg9Tiz7U5LeEcTTSp48aCeymnwIDAQABo2YwZDASBgNVHRMB +Af8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUtnga2/6adiL8 +OkhReR+msVTodsQwHwYDVR0jBBgwFoAUHDFyfiCXhqFiwcl9ZAWApe40+m0wDQYJ +KoZIhvcNAQELBQADggEBACaN1i97TDdZUB0I2p1yBOnbGDVNdmJYwkrWU+CR3StT +dz84/QAWztvKSwtPzuzk4RRpE6EzOPbYmEqTxRdKB08t1aBMZTasdkKjl8WmRoAE +XOYtPE/d53dnC+T3qfyJhq882H95kHBBsvPG8kWEhX5BN30qET2FP6xi6cTAXtR0 +py3AV9qZqx9gxWIC2O1sh9nHmkvhk7WKxlhjkn5EEpDcLX9QfWy8iGrhoN+5RMR7 +1/T6JyP58q1DR8SZ5RaFBO7e5QPYNj8mFf2/wMJZN8kMZZgrXQmX3W9GkIq48evk +ACDggWyBKx+I44bcCRuSOfDTiGLRY6AmEGx7uQBHDT4= +-----END CERTIFICATE----- diff --git a/tests/fixtures/mtls/server.key b/tests/fixtures/mtls/server.key new file mode 100644 index 0000000000..d6f8ea3087 --- /dev/null +++ b/tests/fixtures/mtls/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQClNMQb1AZcVw7y +eRjW7KkzsIZoZcYTIccE4+XmxQbPtsSL97DeIQWs7q2338w8bUkhUNnd72xzGQsH +XmUHbg7uzXopOoUmEq5oRFM8DFBm0CfzhjtYYCAvAV42SAEEw9TjpulnOxdL5ETR +Cl9MrAeRpObwsiDwgjfHGelqvUFQCzEU3Wwfg3ASbHJi9e68aI3eE8BuztNudDNV +D5QGBWf90Sfka6z5ZNE/F2Bw7oawPuaUEYDppxyAZj9qMjXwfCYeAFuKpBNbbXg6 +3HX0+OI40t3JcBMJ2Gq2sPHk4tk0t9qOHMw8vxlH6aDJr2Glef0iHNA2VatMf+wI +NC9mhDizAgMBAAECggEANXUeEh0pE78t/IL36S/6TloMHAL2taEj666s6WAO5K6w +6dOz3STVV9CB0PJvfYwlckzdusVrE9FiMre2PFG+LkK6CVZA2IGKAv486rzXVXV8 +v/3K/T1ZnKw2Jp1lCvwtSp7rfrZtwuZx6CyRitdNubCg8/jH1NtmHhyB3cKwvCvl +ubKH4kS8P4u9dbwhtOrbxeTJAxlaYuV6jRu2YTQkcueL1f1PqvTEiLZD79GUgRxH +WG3wRdAdCICGBFoRDyknhO77mzDpKalzlZ4JDaW4pDegatLKFEGVqhvDZ9FFSW9m +MdEsuIXT/ClztVO/fr/H006JjMI+kqY02ydTownONQKBgQDWLuvPSZAiR4HxfZoT +1WX2ameJIwEMEM9JITHhf+fsJhZ08iP7Hq41/kYZqc8kyFOhFiNYFcrlf89Tkand +CXGY/tMKGVu1C3XtQE5Dcoe6qmH2AfJ6xV1PacZPcC++Tv7baCTG61XQHXjW7gbQ +m9qvdW+U1O0IXwjx3PQLdix/zwKBgQDFdeuz2NJxvrlDPuZqYwjifjLXbfZoD+v2 +wZXdS5Ea91bbRY+5oBZofFDNBVu/3UANUcfJc5bXGk7JQv+is1XSlnVKH/qm8PkK ++AZk1BkiwKmTHtuITt3d45dZ7XzVzVXeJcPjbJQFECGDxy3iH6sEpr0xTAg4ow6Z +PYfyIFWt3QKBgQCt1BTz/gsplwmCOeMDt8zx6bev2CXwafAhtPwrvMg4o0zUivTi +ySqwjXbNO0Dv5FnjQflbcwxhqJJWi8DlsNVuS1pyNtR0IiIKdIdQPDKmL8Qjib8H +Hwk0+27EaBOHi8tRvLskajkSF+lL3pDPW75napMtooXhpme3DBFRAA7rhwKBgQCq +4Vif5DSCQOY8vpNSX/ARadr/ueayuYyfl3nk739ckc21pmYx4sthkquuMUPsL0E+ +BZbazFAuSFMEMxndKEtOGezYwAH/NKyhBHEsEqzJ+WcGrX6YYH/6hPm21iHhOHhl +7dKu3ojeNM58JwObG4K5XL5/iefXc6yviqM6MydSdQKBgQDGlZQBO1Zsg6wsgCzG +GUoDUkvdaaghh9krTvbXH3a+CoNMC70w3h6yQFUssai2dmO4T6FcH3djdCoQ3YBW +s6WY/FEXd/lIeQJvZ6OEVQbZuYLgTdG4WEQCk5+3So3VNWnUpEfgo47DRLWZHnIG +SZ+akQs30o7bUEUSbzmyhkqimA== +-----END PRIVATE KEY----- diff --git a/tests/test_mtls_http_client.py b/tests/test_mtls_http_client.py new file mode 100644 index 0000000000..365b3c1179 --- /dev/null +++ b/tests/test_mtls_http_client.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import os +import ssl +import sys +import threading +import subprocess +from typing import Any, cast +from pathlib import Path +from contextlib import contextmanager +from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler +from collections.abc import Iterator +from typing_extensions import override + +import pytest + +ROOT = Path(__file__).parent.parent +FIXTURES = Path(__file__).parent / "fixtures" / "mtls" +CERTIFICATE_CHAIN = FIXTURES / "client-chain.pem" +PRIVATE_KEY = FIXTURES / "client.key" +ROOT_CERTIFICATE = FIXTURES / "root.pem" +SERVER_CERTIFICATE_CHAIN = FIXTURES / "server-chain.pem" +SERVER_PRIVATE_KEY = FIXTURES / "server.key" + + +class _Handler(BaseHTTPRequestHandler): + peer_certificates: list[dict[str, Any]] = [] + + def do_GET(self) -> None: + peer_certificate = cast(ssl.SSLSocket, self.connection).getpeercert() + assert peer_certificate is not None + self.peer_certificates.append(cast(dict[str, Any], peer_certificate)) + body = b'{"object":"list","data":[]}' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + @override + def log_message(self, format: str, *args: object) -> None: + pass + + +@contextmanager +def _mtls_server() -> Iterator[ThreadingHTTPServer]: + _Handler.peer_certificates = [] + server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_context.load_cert_chain( + certfile=SERVER_CERTIFICATE_CHAIN, + keyfile=SERVER_PRIVATE_KEY, + ) + server_context.load_verify_locations(cafile=ROOT_CERTIFICATE) + server_context.verify_mode = ssl.CERT_REQUIRED + + server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler) + server.socket = server_context.wrap_socket(server.socket, server_side=True) + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + yield server + finally: + server.shutdown() + server.server_close() + thread.join() + + +@pytest.mark.parametrize( + "example", + [ + "mtls_httpx.py", + "mtls_httpx_async.py", + "mtls_httpx2.py", + "mtls_httpx2_async.py", + ], +) +def test_mtls_example_presents_full_client_chain(example: str) -> None: + assert CERTIFICATE_CHAIN.read_text().count("-----BEGIN CERTIFICATE-----") == 2 + + with _mtls_server() as server: + proxy_variables = { + "ALL_PROXY", + "HTTPS_PROXY", + "HTTP_PROXY", + "NO_PROXY", + "all_proxy", + "https_proxy", + "http_proxy", + "no_proxy", + } + environment = { + **{name: value for name, value in os.environ.items() if name not in proxy_variables}, + "OPENAI_API_KEY": "test-api-key", + "OPENAI_BASE_URL": f"https://127.0.0.1:{server.server_port}/v1", + "OPENAI_MTLS_CA_BUNDLE": str(ROOT_CERTIFICATE), + "OPENAI_MTLS_CERTIFICATE_CHAIN": str(CERTIFICATE_CHAIN), + "OPENAI_MTLS_PRIVATE_KEY": str(PRIVATE_KEY), + } + result = subprocess.run( + [sys.executable, str(ROOT / "examples" / example)], + cwd=ROOT, + env=environment, + capture_output=True, + text=True, + timeout=15, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "data=[]" in result.stdout + assert _Handler.peer_certificates[0]["subject"] == ((("commonName", "openai-python-mtls-test-client"),),) From dfcba7866b0673e3c827affbd1192423dd4861a5 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Thu, 30 Jul 2026 09:00:00 -0700 Subject: [PATCH 2/4] test: require TLS 1.2 in mTLS fixture --- tests/test_mtls_http_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_mtls_http_client.py b/tests/test_mtls_http_client.py index 365b3c1179..1992e38a5f 100644 --- a/tests/test_mtls_http_client.py +++ b/tests/test_mtls_http_client.py @@ -46,6 +46,7 @@ def log_message(self, format: str, *args: object) -> None: def _mtls_server() -> Iterator[ThreadingHTTPServer]: _Handler.peer_certificates = [] server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + server_context.minimum_version = ssl.TLSVersion.TLSv1_2 server_context.load_cert_chain( certfile=SERVER_CERTIFICATE_CHAIN, keyfile=SERVER_PRIVATE_KEY, From beff2248e0b2009dd89569dade5b7adcdd673487 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Thu, 30 Jul 2026 09:30:26 -0700 Subject: [PATCH 3/4] docs: address mTLS review feedback --- README.md | 4 +++- examples/mtls_httpx.py | 5 +++-- examples/mtls_httpx2.py | 5 +++-- examples/mtls_httpx2_async.py | 5 +++-- examples/mtls_httpx_async.py | 5 +++-- tests/test_mtls_http_client.py | 4 ++++ 6 files changed, 19 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 68e1bb54ad..3b896b8c18 100644 --- a/README.md +++ b/README.md @@ -1004,7 +1004,9 @@ from the configured mTLS origin before enabling `follow_redirects`. files and for a private key that does not match the leaf certificate. Certificate expiry, key usage, extended key usage, SAN, and trust policy remain TLS server decisions. OpenAI does not fetch missing intermediates through AIA, so provide a -complete, leaf-first client-chain PEM. +complete, leaf-first client-chain PEM. Intermediate-chain support is currently +enabled by request. Until it is enabled for your organization, use a client leaf +certificate directly signed by the uploaded CA. For certificate rotation, build a new `SSLContext`, HTTP client, and `OpenAI` or `AsyncOpenAI` client. This creates a fresh connection pool; close the old SDK diff --git a/examples/mtls_httpx.py b/examples/mtls_httpx.py index 1c35231f00..591f6dd25f 100644 --- a/examples/mtls_httpx.py +++ b/examples/mtls_httpx.py @@ -9,7 +9,8 @@ cafile=os.environ.get("OPENAI_MTLS_CA_BUNDLE"), ) ssl_context.load_cert_chain( - # Leaf certificate first, followed by all required intermediates. + # Leaf certificate first; if intermediate-chain support is enabled, follow + # it with all required intermediates. certfile=os.environ["OPENAI_MTLS_CERTIFICATE_CHAIN"], keyfile=os.environ["OPENAI_MTLS_PRIVATE_KEY"], password=os.environ.get("OPENAI_MTLS_PRIVATE_KEY_PASSWORD"), @@ -26,4 +27,4 @@ follow_redirects=False, ), ) as client: - print(client.models.list()) + print(client.files.list()) diff --git a/examples/mtls_httpx2.py b/examples/mtls_httpx2.py index 1b987565be..0b192d8644 100644 --- a/examples/mtls_httpx2.py +++ b/examples/mtls_httpx2.py @@ -9,7 +9,8 @@ cafile=os.environ.get("OPENAI_MTLS_CA_BUNDLE"), ) ssl_context.load_cert_chain( - # Leaf certificate first, followed by all required intermediates. + # Leaf certificate first; if intermediate-chain support is enabled, follow + # it with all required intermediates. certfile=os.environ["OPENAI_MTLS_CERTIFICATE_CHAIN"], keyfile=os.environ["OPENAI_MTLS_PRIVATE_KEY"], password=os.environ.get("OPENAI_MTLS_PRIVATE_KEY_PASSWORD"), @@ -26,4 +27,4 @@ follow_redirects=False, ), ) as client: - print(client.models.list()) + print(client.files.list()) diff --git a/examples/mtls_httpx2_async.py b/examples/mtls_httpx2_async.py index 62d4cc4d04..d35db5be66 100644 --- a/examples/mtls_httpx2_async.py +++ b/examples/mtls_httpx2_async.py @@ -12,7 +12,8 @@ async def main() -> None: cafile=os.environ.get("OPENAI_MTLS_CA_BUNDLE"), ) ssl_context.load_cert_chain( - # Leaf certificate first, followed by all required intermediates. + # Leaf certificate first; if intermediate-chain support is enabled, + # follow it with all required intermediates. certfile=os.environ["OPENAI_MTLS_CERTIFICATE_CHAIN"], keyfile=os.environ["OPENAI_MTLS_PRIVATE_KEY"], password=os.environ.get("OPENAI_MTLS_PRIVATE_KEY_PASSWORD"), @@ -29,7 +30,7 @@ async def main() -> None: follow_redirects=False, ), ) as client: - print(await client.models.list()) + print(await client.files.list()) asyncio.run(main()) diff --git a/examples/mtls_httpx_async.py b/examples/mtls_httpx_async.py index cd7a8dcc04..e4d262c23e 100644 --- a/examples/mtls_httpx_async.py +++ b/examples/mtls_httpx_async.py @@ -12,7 +12,8 @@ async def main() -> None: cafile=os.environ.get("OPENAI_MTLS_CA_BUNDLE"), ) ssl_context.load_cert_chain( - # Leaf certificate first, followed by all required intermediates. + # Leaf certificate first; if intermediate-chain support is enabled, + # follow it with all required intermediates. certfile=os.environ["OPENAI_MTLS_CERTIFICATE_CHAIN"], keyfile=os.environ["OPENAI_MTLS_PRIVATE_KEY"], password=os.environ.get("OPENAI_MTLS_PRIVATE_KEY_PASSWORD"), @@ -29,7 +30,7 @@ async def main() -> None: follow_redirects=False, ), ) as client: - print(await client.models.list()) + print(await client.files.list()) asyncio.run(main()) diff --git a/tests/test_mtls_http_client.py b/tests/test_mtls_http_client.py index 1992e38a5f..ae3318c741 100644 --- a/tests/test_mtls_http_client.py +++ b/tests/test_mtls_http_client.py @@ -25,11 +25,13 @@ class _Handler(BaseHTTPRequestHandler): peer_certificates: list[dict[str, Any]] = [] + request_paths: list[str] = [] def do_GET(self) -> None: peer_certificate = cast(ssl.SSLSocket, self.connection).getpeercert() assert peer_certificate is not None self.peer_certificates.append(cast(dict[str, Any], peer_certificate)) + self.request_paths.append(self.path) body = b'{"object":"list","data":[]}' self.send_response(200) self.send_header("Content-Type", "application/json") @@ -45,6 +47,7 @@ def log_message(self, format: str, *args: object) -> None: @contextmanager def _mtls_server() -> Iterator[ThreadingHTTPServer]: _Handler.peer_certificates = [] + _Handler.request_paths = [] server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) server_context.minimum_version = ssl.TLSVersion.TLSv1_2 server_context.load_cert_chain( @@ -109,4 +112,5 @@ def test_mtls_example_presents_full_client_chain(example: str) -> None: assert result.returncode == 0, result.stderr assert "data=[]" in result.stdout + assert _Handler.request_paths == ["/v1/files"] assert _Handler.peer_certificates[0]["subject"] == ((("commonName", "openai-python-mtls-test-client"),),) From 4272e720e6c6b1c45b8c3bb0d286e2c1d05b96f8 Mon Sep 17 00:00:00 2001 From: Justin Beckwith Date: Thu, 30 Jul 2026 09:42:39 -0700 Subject: [PATCH 4/4] docs: link mTLS beta program --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 3b896b8c18..b5774f4c94 100644 --- a/README.md +++ b/README.md @@ -901,6 +901,10 @@ client.with_options(http_client=DefaultHttpxClient(...)) #### Mutual TLS +Before configuring a client, review the +[OpenAI Mutual TLS Beta Program](https://help.openai.com/en/articles/10876024-openai-mutual-tls-beta-program) +for enrollment, currently supported endpoints, and certificate requirements. + For API-key authenticated HTTP requests that require mutual TLS (mTLS), configure a native [`ssl.SSLContext`](https://docs.python.org/3/library/ssl.html#ssl.SSLContext) and pass it through the custom HTTP client: