Skip to content

Commit f3f8097

Browse files
committed
feat: Add AWS Bedrock Mantle client with SigV4 authentication
Add AwsOpenAI and AsyncAwsOpenAI clients that support AWS SigV4 request signing for Bedrock Mantle APIs, alongside standard API key auth. Key changes: - Add src/openai/lib/aws.py with sync and async client classes that sign requests using botocore's SigV4Auth - Support custom credential providers (sync and async) as well as automatic credential resolution via the default botocore chain - Export AwsOpenAI and AsyncAwsOpenAI from the top-level package - Add examples for basic usage and STS assume-role credential refresh - Add comprehensive test suite covering SigV4 signing, credential resolution, API key fallback, and copy/with_options behavior - Add botocore as a dev dependency in pyproject.toml - Fix all pyright and mypy lint errors for botocore type stubs
1 parent 94c88b8 commit f3f8097

7 files changed

Lines changed: 1112 additions & 0 deletions

File tree

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,41 @@ In addition to the options provided in the base `OpenAI` client, the following o
933933

934934
An example of using the client with Microsoft Entra ID (formerly known as Azure Active Directory) can be found [here](https://github.com/openai/openai-python/blob/main/examples/azure_ad.py).
935935

936+
## AWS Bedrock Mantle
937+
938+
To use this library with [AWS Bedrock Mantle](https://docs.aws.amazon.com/bedrock/), use the `AwsOpenAI`
939+
class instead of the `OpenAI` class.
940+
941+
> [!IMPORTANT]
942+
> This requires `botocore` to be installed for SigV4 request signing. Install it with: `pip install 'openai[aws]'`
943+
944+
```py
945+
from openai import AwsOpenAI
946+
947+
# uses the default botocore credential chain (env vars, ~/.aws/credentials, IAM role, etc.)
948+
client = AwsOpenAI(
949+
region="us-west-2",
950+
)
951+
952+
completion = client.chat.completions.create(
953+
model="openai.gpt-oss-120b",
954+
messages=[
955+
{
956+
"role": "user",
957+
"content": "How do I output all files in a directory using Python?",
958+
},
959+
],
960+
)
961+
print(completion.choices[0].message.content)
962+
```
963+
964+
In addition to the options provided in the base `OpenAI` client, the following options are provided:
965+
966+
- `region` (or the `AWS_REGION` / `AWS_DEFAULT_REGION` environment variable)
967+
- `credential_provider` - a callable that returns credentials with `access_key`, `secret_key`, and optional `token` attributes
968+
969+
An example of using the client with a custom credential provider and STS assume-role refresh can be found [here](https://github.com/openai/openai-python/blob/main/examples/aws_credential_provider.py).
970+
936971
## Versioning
937972

938973
This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions:

examples/aws_client.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Example: Using AwsOpenAI (sync) and AsyncAwsOpenAI (async) with SigV4 signing.
2+
3+
Requires:
4+
- botocore installed (pip install botocore)
5+
- AWS credentials configured (env vars, ~/.aws/credentials, IAM role, etc.)
6+
- AWS_REGION or AWS_DEFAULT_REGION set (or pass region= explicitly)
7+
8+
Run:
9+
export AWS_REGION=us-west-2
10+
PYTHONPATH=src python3 examples/bedrock_mantle.py
11+
"""
12+
13+
import asyncio
14+
15+
from openai.lib.aws import AwsOpenAI, AsyncAwsOpenAI
16+
17+
# --- Synchronous usage ---
18+
19+
client = AwsOpenAI(region="us-west-2")
20+
21+
response = client.chat.completions.create(
22+
model="openai.gpt-oss-120b",
23+
messages=[{"role": "user", "content": "Hello, how are you?"}],
24+
)
25+
26+
print("Sync:", response.choices[0].message.content)
27+
28+
29+
# --- Asynchronous usage ---
30+
31+
32+
async def main() -> None:
33+
async_client = AsyncAwsOpenAI(region="us-west-2")
34+
35+
response = await async_client.chat.completions.create(
36+
model="openai.gpt-oss-120b",
37+
messages=[{"role": "user", "content": "Hello from async!"}],
38+
)
39+
40+
print("Async:", response.choices[0].message.content)
41+
42+
43+
asyncio.run(main())
44+
45+
46+
# --- Streaming usage (sync) ---
47+
48+
print("\nStreaming: ", end="")
49+
stream = client.chat.completions.create(
50+
model="openai.gpt-oss-120b",
51+
messages=[{"role": "user", "content": "Count from 1 to 5."}],
52+
stream=True,
53+
)
54+
for chunk in stream:
55+
delta = chunk.choices[0].delta.content
56+
if delta:
57+
print(delta, end="", flush=True)
58+
print()
59+
60+
61+
# --- Streaming usage (async) ---
62+
63+
64+
async def stream_async() -> None:
65+
async_client = AsyncAwsOpenAI(region="us-west-2")
66+
67+
print("Async streaming: ", end="")
68+
stream = await async_client.chat.completions.create(
69+
model="openai.gpt-oss-120b",
70+
messages=[{"role": "user", "content": "Count from 1 to 5."}],
71+
stream=True,
72+
)
73+
async for chunk in stream:
74+
delta = chunk.choices[0].delta.content
75+
if delta:
76+
print(delta, end="", flush=True)
77+
print()
78+
79+
80+
asyncio.run(stream_async())
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Example: Using AwsOpenAI with a custom credential provider and auto-refresh.
2+
3+
This shows how to:
4+
1. Use a custom credential provider that returns fresh credentials on each call
5+
2. Use botocore's RefreshableCredentials for automatic STS assume-role refresh
6+
3. Use an async credential provider with AsyncAwsOpenAI
7+
8+
Requires:
9+
- botocore installed (pip install botocore)
10+
- boto3 installed (pip install boto3) — for the STS assume-role example
11+
- AWS credentials configured for the initial session
12+
- AWS_REGION or AWS_DEFAULT_REGION set (or pass region= explicitly)
13+
14+
Run:
15+
export AWS_REGION=us-west-2
16+
PYTHONPATH=src python3 examples/bedrock_mantle_credential_provider.py
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import asyncio
22+
from typing import Any, Callable
23+
from dataclasses import dataclass
24+
25+
from openai.lib.aws import AwsOpenAI, AsyncAwsOpenAI
26+
27+
# ---------------------------------------------------------------------------
28+
# 1. Simple custom credential provider
29+
# ---------------------------------------------------------------------------
30+
31+
32+
@dataclass
33+
class MyCredentials:
34+
"""Minimal object satisfying the Credentials protocol."""
35+
36+
access_key: str
37+
secret_key: str
38+
token: str | None = None
39+
40+
41+
def my_credential_provider() -> MyCredentials:
42+
"""Return credentials from your own secret store, vault, etc.
43+
44+
This callable is invoked before every request, so returning fresh
45+
credentials here is all you need for auto-refresh.
46+
"""
47+
# Replace with your actual credential fetching logic
48+
return MyCredentials(
49+
access_key="AKIA...",
50+
secret_key="wJalr...",
51+
token="FwoGZX...", # optional session token
52+
)
53+
54+
55+
client = AwsOpenAI(
56+
region="us-west-2",
57+
credential_provider=my_credential_provider,
58+
)
59+
60+
response = client.chat.completions.create(
61+
model="openai.gpt-oss-120b",
62+
messages=[{"role": "user", "content": "Hello from custom credentials!"}],
63+
)
64+
print("Custom provider:", response.choices[0].message.content)
65+
66+
67+
# ---------------------------------------------------------------------------
68+
# 2. Auto-refreshing STS assume-role credentials via botocore
69+
# ---------------------------------------------------------------------------
70+
71+
72+
def make_sts_credential_provider(role_arn: str, session_name: str = "bedrock-mantle") -> Callable[[], Any]:
73+
"""Create a credential provider that assumes an IAM role and auto-refreshes.
74+
75+
botocore's RefreshableCredentials handles expiry checks and refresh
76+
transparently — accessing .access_key / .secret_key / .token on the
77+
returned object triggers a refresh if the credentials are expired.
78+
"""
79+
import botocore.session # type: ignore[import-untyped, import-not-found]
80+
import botocore.credentials # type: ignore[import-untyped, import-not-found]
81+
82+
session: Any = botocore.session.get_session() # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
83+
sts: Any = session.create_client("sts") # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
84+
85+
def fetch_credentials() -> dict[str, Any]:
86+
resp: Any = sts.assume_role(RoleArn=role_arn, RoleSessionName=session_name)["Credentials"] # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
87+
return {
88+
"access_key": resp["AccessKeyId"],
89+
"secret_key": resp["SecretAccessKey"],
90+
"token": resp["SessionToken"],
91+
"expiry_time": resp["Expiration"].isoformat(), # pyright: ignore[reportUnknownMemberType]
92+
}
93+
94+
refreshable: Any = botocore.credentials.RefreshableCredentials.create_from_metadata( # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
95+
metadata=fetch_credentials(),
96+
refresh_using=fetch_credentials,
97+
method="sts-assume-role",
98+
)
99+
100+
# Return a provider that gives back the refreshable object.
101+
# Accessing its attributes auto-refreshes when expired.
102+
def provider() -> Any:
103+
return refreshable # pyright: ignore[reportUnknownVariableType]
104+
105+
return provider
106+
107+
108+
# Uncomment to use:
109+
# sts_client = AwsOpenAI(
110+
# region="us-west-2",
111+
# credential_provider=make_sts_credential_provider("arn:aws:iam::123456789012:role/MyRole"),
112+
# )
113+
114+
115+
# ---------------------------------------------------------------------------
116+
# 3. Async credential provider
117+
# ---------------------------------------------------------------------------
118+
119+
120+
async def async_credential_provider() -> MyCredentials:
121+
"""An async provider — useful when credentials come from an async API."""
122+
# Simulate async credential fetch (e.g., from an async HTTP vault client)
123+
await asyncio.sleep(0)
124+
return MyCredentials(
125+
access_key="AKIA...",
126+
secret_key="wJalr...",
127+
token="FwoGZX...",
128+
)
129+
130+
131+
async def main() -> None:
132+
async_client = AsyncAwsOpenAI(
133+
region="us-west-2",
134+
credential_provider=async_credential_provider,
135+
)
136+
137+
response = await async_client.chat.completions.create(
138+
model="openai.gpt-oss-120b",
139+
messages=[{"role": "user", "content": "Hello from async credentials!"}],
140+
)
141+
print("Async provider:", response.choices[0].message.content)
142+
143+
144+
asyncio.run(main())

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9"]
5050
realtime = ["websockets >= 13, < 16"]
5151
datalib = ["numpy >= 1", "pandas >= 1.2.3", "pandas-stubs >= 1.1.0.11"]
5252
voice_helpers = ["sounddevice>=0.5.1", "numpy>=2.0.2"]
53+
aws = ["botocore >= 1.29.0"]
5354

5455
[tool.rye]
5556
managed = true
@@ -68,6 +69,7 @@ dev-dependencies = [
6869
"rich>=13.7.1",
6970
"inline-snapshot>=0.28.0",
7071
"azure-identity >=1.14.1",
72+
"botocore >=1.29.0",
7173
"types-tqdm > 4",
7274
"types-pyaudio > 0",
7375
"trio >=0.22.2",

src/openai/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@
9797
from ._utils._resources_proxy import resources as resources
9898

9999
from .lib import azure as _azure, pydantic_function_tool as pydantic_function_tool
100+
from .lib.aws import (
101+
AwsOpenAI as AwsOpenAI,
102+
AsyncAwsOpenAI as AsyncAwsOpenAI,
103+
)
100104
from .version import VERSION as VERSION
101105
from .lib.azure import AzureOpenAI as AzureOpenAI, AsyncAzureOpenAI as AsyncAzureOpenAI
102106
from .lib._old_api import *

0 commit comments

Comments
 (0)