diff --git a/.github/scripts/check_sdk_methods.py b/.github/scripts/check_sdk_methods.py index 5daeed7e5..48c7429dd 100644 --- a/.github/scripts/check_sdk_methods.py +++ b/.github/scripts/check_sdk_methods.py @@ -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") diff --git a/libs/sdk-py/langgraph_sdk/client.py b/libs/sdk-py/langgraph_sdk/client.py index fa39817e0..a35cb171b 100644 --- a/libs/sdk-py/langgraph_sdk/client.py +++ b/libs/sdk-py/langgraph_sdk/client.py @@ -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..() 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: diff --git a/libs/sdk-py/tests/test_skip_auto_load_api_key.py b/libs/sdk-py/tests/test_skip_auto_load_api_key.py new file mode 100644 index 000000000..4ac3b7482 --- /dev/null +++ b/libs/sdk-py/tests/test_skip_auto_load_api_key.py @@ -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()