Skip to content

Commit 23d78c2

Browse files
authored
feat(sdk-py): add sentinel to skip auto loading api key on sdk client create (#6500)
**Description:** There are times a user might want to create the client, but conditionally set the API key. For example, consider a complex auth situation where the system has user callers using jwts and system callers using API keys. This allows explicitly disabling the auto-loading behavior of API keys in the client today, so no key is set. **Issue:** N/A **Dependencies:** None **Twitter handle:** N/A
1 parent f8c1a32 commit 23d78c2

File tree

3 files changed

+137
-29
lines changed

3 files changed

+137
-29
lines changed

.github/scripts/check_sdk_methods.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def main():
3838
tree = ast.parse(file.read())
3939

4040
classes = find_classes(tree)
41-
41+
4242
def is_sync(class_spec: Tuple[str, List[str]]) -> bool:
4343
return class_spec[0].startswith("Sync")
4444

libs/sdk-py/langgraph_sdk/client.py

Lines changed: 66 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -81,25 +81,37 @@
8181

8282
RESERVED_HEADERS = ("x-api-key",)
8383

84+
NOT_PROVIDED = cast(None, object())
8485

85-
def _get_api_key(api_key: str | None = None) -> str | None:
86+
87+
def _get_api_key(api_key: str | None = NOT_PROVIDED) -> str | None:
8688
"""Get the API key from the environment.
8789
Precedence:
88-
1. explicit argument
89-
2. LANGGRAPH_API_KEY
90-
3. LANGSMITH_API_KEY
91-
4. LANGCHAIN_API_KEY
90+
1. explicit string argument
91+
2. LANGGRAPH_API_KEY (if api_key not provided)
92+
3. LANGSMITH_API_KEY (if api_key not provided)
93+
4. LANGCHAIN_API_KEY (if api_key not provided)
94+
95+
Args:
96+
api_key: The API key to use. Can be:
97+
- A string: use this exact API key
98+
- None: explicitly skip loading from environment
99+
- NOT_PROVIDED (default): auto-load from environment variables
92100
"""
93-
if api_key:
101+
if isinstance(api_key, str):
94102
return api_key
95-
for prefix in ["LANGGRAPH", "LANGSMITH", "LANGCHAIN"]:
96-
if env := os.getenv(f"{prefix}_API_KEY"):
97-
return env.strip().strip('"').strip("'")
98-
return None # type: ignore
103+
if api_key is NOT_PROVIDED:
104+
# api_key is not explicitly provided, try to load from environment
105+
for prefix in ["LANGGRAPH", "LANGSMITH", "LANGCHAIN"]:
106+
if env := os.getenv(f"{prefix}_API_KEY"):
107+
return env.strip().strip('"').strip("'")
108+
# api_key is explicitly None, don't load from environment
109+
return None
99110

100111

101112
def _get_headers(
102-
api_key: str | None, custom_headers: Mapping[str, str] | None
113+
api_key: str | None,
114+
custom_headers: Mapping[str, str] | None,
103115
) -> dict[str, str]:
104116
"""Combine api_key and custom user-provided headers."""
105117
custom_headers = custom_headers or {}
@@ -111,9 +123,9 @@ def _get_headers(
111123
"User-Agent": f"langgraph-sdk-py/{langgraph_sdk.__version__}",
112124
**custom_headers,
113125
}
114-
api_key = _get_api_key(api_key)
115-
if api_key:
116-
headers["x-api-key"] = api_key
126+
resolved_api_key = _get_api_key(api_key)
127+
if resolved_api_key:
128+
headers["x-api-key"] = resolved_api_key
117129

118130
return headers
119131

@@ -164,7 +176,7 @@ def _get_run_metadata_from_response(
164176
def get_client(
165177
*,
166178
url: str | None = None,
167-
api_key: str | None = None,
179+
api_key: str | None = NOT_PROVIDED,
168180
headers: Mapping[str, str] | None = None,
169181
timeout: TimeoutTypes | None = None,
170182
) -> LangGraphClient:
@@ -179,12 +191,13 @@ def get_client(
179191
- If `None`, the client first attempts an in-process connection via ASGI transport.
180192
If that fails, it falls back to `http://localhost:8123`.
181193
api_key:
182-
API key for authentication. If omitted, the client reads from environment
183-
variables in the following order:
184-
1. Function argument
185-
2. `LANGGRAPH_API_KEY`
186-
3. `LANGSMITH_API_KEY`
187-
4. `LANGCHAIN_API_KEY`
194+
API key for authentication. Can be:
195+
- A string: use this exact API key
196+
- `None`: explicitly skip loading from environment variables
197+
- Not provided (default): auto-load from environment in this order:
198+
1. `LANGGRAPH_API_KEY`
199+
2. `LANGSMITH_API_KEY`
200+
3. `LANGCHAIN_API_KEY`
188201
headers:
189202
Additional HTTP headers to include in requests. Merged with authentication headers.
190203
timeout:
@@ -225,6 +238,18 @@ async def my_node(...):
225238
input={"messages": [{"role": "user", "content": "Foo"}]},
226239
)
227240
```
241+
242+
???+ example "Skip auto-loading API key from environment:"
243+
244+
```python
245+
from langgraph_sdk import get_client
246+
247+
# Don't load API key from environment variables
248+
client = get_client(
249+
url="http://localhost:8123",
250+
api_key=None
251+
)
252+
```
228253
"""
229254

230255
transport: httpx.AsyncBaseTransport | None = None
@@ -3471,20 +3496,21 @@ async def list_namespaces(
34713496
def get_sync_client(
34723497
*,
34733498
url: str | None = None,
3474-
api_key: str | None = None,
3499+
api_key: str | None = NOT_PROVIDED,
34753500
headers: Mapping[str, str] | None = None,
34763501
timeout: TimeoutTypes | None = None,
34773502
) -> SyncLangGraphClient:
34783503
"""Get a synchronous LangGraphClient instance.
34793504
34803505
Args:
34813506
url: The URL of the LangGraph API.
3482-
api_key: The API key. If not provided, it will be read from the environment.
3483-
Precedence:
3484-
1. explicit argument
3485-
2. LANGGRAPH_API_KEY
3486-
3. LANGSMITH_API_KEY
3487-
4. LANGCHAIN_API_KEY
3507+
api_key: API key for authentication. Can be:
3508+
- A string: use this exact API key
3509+
- `None`: explicitly skip loading from environment variables
3510+
- Not provided (default): auto-load from environment in this order:
3511+
1. `LANGGRAPH_API_KEY`
3512+
2. `LANGSMITH_API_KEY`
3513+
3. `LANGCHAIN_API_KEY`
34883514
headers: Optional custom headers
34893515
timeout: Optional timeout configuration for the HTTP client.
34903516
Accepts an httpx.Timeout instance, a float (seconds), or a tuple of timeouts.
@@ -3505,6 +3531,18 @@ def get_sync_client(
35053531
# example usage: client.<model>.<method_name>()
35063532
assistant = client.assistants.get(assistant_id="some_uuid")
35073533
```
3534+
3535+
???+ example "Skip auto-loading API key from environment:"
3536+
3537+
```python
3538+
from langgraph_sdk import get_sync_client
3539+
3540+
# Don't load API key from environment variables
3541+
client = get_sync_client(
3542+
url="http://localhost:8123",
3543+
api_key=None
3544+
)
3545+
```
35083546
"""
35093547

35103548
if url is None:
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Tests for api_key parameter behavior."""
2+
3+
import pytest
4+
5+
from langgraph_sdk import get_client, get_sync_client
6+
7+
8+
class TestSkipAutoLoadApiKey:
9+
"""Test the api_key parameter's auto-loading behavior."""
10+
11+
@pytest.mark.asyncio
12+
async def test_get_client_loads_from_env_by_default(self, monkeypatch):
13+
"""Test that API key is loaded from environment by default."""
14+
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
15+
16+
client = get_client(url="http://localhost:8123")
17+
assert "x-api-key" in client.http.client.headers
18+
assert client.http.client.headers["x-api-key"] == "test-key-from-env"
19+
await client.aclose()
20+
21+
@pytest.mark.asyncio
22+
async def test_get_client_skips_env_when_sentinel_used(self, monkeypatch):
23+
"""Test that API key is not loaded from environment when None is explicitly passed."""
24+
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
25+
26+
client = get_client(url="http://localhost:8123", api_key=None)
27+
assert "x-api-key" not in client.http.client.headers
28+
await client.aclose()
29+
30+
@pytest.mark.asyncio
31+
async def test_get_client_uses_explicit_key_when_provided(self, monkeypatch):
32+
"""Test that explicit API key takes precedence over environment."""
33+
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
34+
35+
client = get_client(
36+
url="http://localhost:8123",
37+
api_key="explicit-key",
38+
)
39+
assert "x-api-key" in client.http.client.headers
40+
assert client.http.client.headers["x-api-key"] == "explicit-key"
41+
await client.aclose()
42+
43+
def test_get_sync_client_loads_from_env_by_default(self, monkeypatch):
44+
"""Test that sync client loads API key from environment by default."""
45+
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
46+
47+
client = get_sync_client(url="http://localhost:8123")
48+
assert "x-api-key" in client.http.client.headers
49+
assert client.http.client.headers["x-api-key"] == "test-key-from-env"
50+
client.close()
51+
52+
def test_get_sync_client_skips_env_when_sentinel_used(self, monkeypatch):
53+
"""Test that sync client doesn't load from environment when None is explicitly passed."""
54+
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
55+
56+
client = get_sync_client(url="http://localhost:8123", api_key=None)
57+
assert "x-api-key" not in client.http.client.headers
58+
client.close()
59+
60+
def test_get_sync_client_uses_explicit_key_when_provided(self, monkeypatch):
61+
"""Test that sync client uses explicit API key when provided."""
62+
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
63+
64+
client = get_sync_client(
65+
url="http://localhost:8123",
66+
api_key="explicit-key",
67+
)
68+
assert "x-api-key" in client.http.client.headers
69+
assert client.http.client.headers["x-api-key"] == "explicit-key"
70+
client.close()

0 commit comments

Comments
 (0)