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
This commit is contained in:
Josh Rogers
2025-11-25 11:10:56 -08:00
committed by GitHub
parent f8c1a323cc
commit 23d78c2817
3 changed files with 137 additions and 29 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ def main():
tree = ast.parse(file.read())
classes = find_classes(tree)
def is_sync(class_spec: Tuple[str, List[str]]) -> bool:
return class_spec[0].startswith("Sync")
+66 -28
View File
@@ -81,25 +81,37 @@ logger = logging.getLogger(__name__)
RESERVED_HEADERS = ("x-api-key",)
NOT_PROVIDED = cast(None, object())
def _get_api_key(api_key: str | None = None) -> str | None:
def _get_api_key(api_key: str | None = NOT_PROVIDED) -> str | None:
"""Get the API key from the environment.
Precedence:
1. explicit argument
2. LANGGRAPH_API_KEY
3. LANGSMITH_API_KEY
4. LANGCHAIN_API_KEY
1. explicit string argument
2. LANGGRAPH_API_KEY (if api_key not provided)
3. LANGSMITH_API_KEY (if api_key not provided)
4. LANGCHAIN_API_KEY (if api_key not provided)
Args:
api_key: The API key to use. Can be:
- A string: use this exact API key
- None: explicitly skip loading from environment
- NOT_PROVIDED (default): auto-load from environment variables
"""
if api_key:
if isinstance(api_key, str):
return api_key
for prefix in ["LANGGRAPH", "LANGSMITH", "LANGCHAIN"]:
if env := os.getenv(f"{prefix}_API_KEY"):
return env.strip().strip('"').strip("'")
return None # type: ignore
if api_key is NOT_PROVIDED:
# api_key is not explicitly provided, try to load from environment
for prefix in ["LANGGRAPH", "LANGSMITH", "LANGCHAIN"]:
if env := os.getenv(f"{prefix}_API_KEY"):
return env.strip().strip('"').strip("'")
# api_key is explicitly None, don't load from environment
return None
def _get_headers(
api_key: str | None, custom_headers: Mapping[str, str] | None
api_key: str | None,
custom_headers: Mapping[str, str] | None,
) -> dict[str, str]:
"""Combine api_key and custom user-provided headers."""
custom_headers = custom_headers or {}
@@ -111,9 +123,9 @@ def _get_headers(
"User-Agent": f"langgraph-sdk-py/{langgraph_sdk.__version__}",
**custom_headers,
}
api_key = _get_api_key(api_key)
if api_key:
headers["x-api-key"] = api_key
resolved_api_key = _get_api_key(api_key)
if resolved_api_key:
headers["x-api-key"] = resolved_api_key
return headers
@@ -164,7 +176,7 @@ def _get_run_metadata_from_response(
def get_client(
*,
url: str | None = None,
api_key: str | None = None,
api_key: str | None = NOT_PROVIDED,
headers: Mapping[str, str] | None = None,
timeout: TimeoutTypes | None = None,
) -> LangGraphClient:
@@ -179,12 +191,13 @@ def get_client(
- If `None`, the client first attempts an in-process connection via ASGI transport.
If that fails, it falls back to `http://localhost:8123`.
api_key:
API key for authentication. If omitted, the client reads from environment
variables in the following order:
1. Function argument
2. `LANGGRAPH_API_KEY`
3. `LANGSMITH_API_KEY`
4. `LANGCHAIN_API_KEY`
API key for authentication. Can be:
- A string: use this exact API key
- `None`: explicitly skip loading from environment variables
- Not provided (default): auto-load from environment in this order:
1. `LANGGRAPH_API_KEY`
2. `LANGSMITH_API_KEY`
3. `LANGCHAIN_API_KEY`
headers:
Additional HTTP headers to include in requests. Merged with authentication headers.
timeout:
@@ -225,6 +238,18 @@ def get_client(
input={"messages": [{"role": "user", "content": "Foo"}]},
)
```
???+ example "Skip auto-loading API key from environment:"
```python
from langgraph_sdk import get_client
# Don't load API key from environment variables
client = get_client(
url="http://localhost:8123",
api_key=None
)
```
"""
transport: httpx.AsyncBaseTransport | None = None
@@ -3471,7 +3496,7 @@ class StoreClient:
def get_sync_client(
*,
url: str | None = None,
api_key: str | None = None,
api_key: str | None = NOT_PROVIDED,
headers: Mapping[str, str] | None = None,
timeout: TimeoutTypes | None = None,
) -> SyncLangGraphClient:
@@ -3479,12 +3504,13 @@ def get_sync_client(
Args:
url: The URL of the LangGraph API.
api_key: The API key. If not provided, it will be read from the environment.
Precedence:
1. explicit argument
2. LANGGRAPH_API_KEY
3. LANGSMITH_API_KEY
4. LANGCHAIN_API_KEY
api_key: API key for authentication. Can be:
- A string: use this exact API key
- `None`: explicitly skip loading from environment variables
- Not provided (default): auto-load from environment in this order:
1. `LANGGRAPH_API_KEY`
2. `LANGSMITH_API_KEY`
3. `LANGCHAIN_API_KEY`
headers: Optional custom headers
timeout: Optional timeout configuration for the HTTP client.
Accepts an httpx.Timeout instance, a float (seconds), or a tuple of timeouts.
@@ -3505,6 +3531,18 @@ def get_sync_client(
# example usage: client.<model>.<method_name>()
assistant = client.assistants.get(assistant_id="some_uuid")
```
???+ example "Skip auto-loading API key from environment:"
```python
from langgraph_sdk import get_sync_client
# Don't load API key from environment variables
client = get_sync_client(
url="http://localhost:8123",
api_key=None
)
```
"""
if url is None:
@@ -0,0 +1,70 @@
"""Tests for api_key parameter behavior."""
import pytest
from langgraph_sdk import get_client, get_sync_client
class TestSkipAutoLoadApiKey:
"""Test the api_key parameter's auto-loading behavior."""
@pytest.mark.asyncio
async def test_get_client_loads_from_env_by_default(self, monkeypatch):
"""Test that API key is loaded from environment by default."""
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
client = get_client(url="http://localhost:8123")
assert "x-api-key" in client.http.client.headers
assert client.http.client.headers["x-api-key"] == "test-key-from-env"
await client.aclose()
@pytest.mark.asyncio
async def test_get_client_skips_env_when_sentinel_used(self, monkeypatch):
"""Test that API key is not loaded from environment when None is explicitly passed."""
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
client = get_client(url="http://localhost:8123", api_key=None)
assert "x-api-key" not in client.http.client.headers
await client.aclose()
@pytest.mark.asyncio
async def test_get_client_uses_explicit_key_when_provided(self, monkeypatch):
"""Test that explicit API key takes precedence over environment."""
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
client = get_client(
url="http://localhost:8123",
api_key="explicit-key",
)
assert "x-api-key" in client.http.client.headers
assert client.http.client.headers["x-api-key"] == "explicit-key"
await client.aclose()
def test_get_sync_client_loads_from_env_by_default(self, monkeypatch):
"""Test that sync client loads API key from environment by default."""
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
client = get_sync_client(url="http://localhost:8123")
assert "x-api-key" in client.http.client.headers
assert client.http.client.headers["x-api-key"] == "test-key-from-env"
client.close()
def test_get_sync_client_skips_env_when_sentinel_used(self, monkeypatch):
"""Test that sync client doesn't load from environment when None is explicitly passed."""
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
client = get_sync_client(url="http://localhost:8123", api_key=None)
assert "x-api-key" not in client.http.client.headers
client.close()
def test_get_sync_client_uses_explicit_key_when_provided(self, monkeypatch):
"""Test that sync client uses explicit API key when provided."""
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
client = get_sync_client(
url="http://localhost:8123",
api_key="explicit-key",
)
assert "x-api-key" in client.http.client.headers
assert client.http.client.headers["x-api-key"] == "explicit-key"
client.close()