From f1bcd08ebbf2387d577b3d9f0c04db246d197926 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 31 Mar 2026 13:38:42 -0700 Subject: [PATCH 1/7] fix dev tag for sea binary should never be used --- tests/test_sea_binary.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_sea_binary.py b/tests/test_sea_binary.py index 1ad9c8eb..300543a8 100644 --- a/tests/test_sea_binary.py +++ b/tests/test_sea_binary.py @@ -32,7 +32,11 @@ def test_resolve_binary_path_defaults_cache_version_to_package_version( captured: dict[str, object] = {} + # This test is exercising the packaged-resource path, so clear the env override + # that would otherwise bypass _resource_binary_path() entirely. + monkeypatch.delenv("STAGEHAND_SEA_BINARY", raising=False) monkeypatch.delenv("STAGEHAND_VERSION", raising=False) + def _fake_resource_binary_path(_filename: str) -> Path: return resource_path From 1deada12913b16cb2b3edfc084c444c8c96ff1e7 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 1 Apr 2026 11:15:24 -0700 Subject: [PATCH 2/7] Support provider env vars for model API keys --- examples/local_browser_playwright_example.py | 1 - examples/local_example.py | 1 - ...ocal_server_multiregion_browser_example.py | 1 - src/stagehand/_client.py | 54 +++++-- src/stagehand/lib/sea_server.py | 46 +++--- tests/test_client.py | 67 ++++++++- tests/test_local_server.py | 140 ++++++++++++++++++ 7 files changed, 266 insertions(+), 44 deletions(-) diff --git a/examples/local_browser_playwright_example.py b/examples/local_browser_playwright_example.py index f5107d57..d3f208df 100644 --- a/examples/local_browser_playwright_example.py +++ b/examples/local_browser_playwright_example.py @@ -104,7 +104,6 @@ def main() -> None: browserbase_api_key=bb_api_key, browserbase_project_id=bb_project_id, model_api_key=model_api_key, - local_openai_api_key=model_api_key, local_ready_timeout_s=30.0, ) as client: print("⏳ Starting Stagehand session (local server + local browser)...") diff --git a/examples/local_example.py b/examples/local_example.py index e9bddd07..c4671cb7 100644 --- a/examples/local_example.py +++ b/examples/local_example.py @@ -51,7 +51,6 @@ def main() -> None: client = Stagehand( server="local", - local_openai_api_key=model_key, local_ready_timeout_s=30.0, ) diff --git a/examples/local_server_multiregion_browser_example.py b/examples/local_server_multiregion_browser_example.py index ca6ac89f..2bb1ed61 100644 --- a/examples/local_server_multiregion_browser_example.py +++ b/examples/local_server_multiregion_browser_example.py @@ -75,7 +75,6 @@ def main() -> None: browserbase_api_key=bb_api_key, browserbase_project_id=bb_project_id, model_api_key=model_api_key, - local_openai_api_key=model_api_key, local_ready_timeout_s=30.0, ) as client: print("⏳ Starting Stagehand session (local server + Browserbase browser)...") diff --git a/src/stagehand/_client.py b/src/stagehand/_client.py index 3997b5e1..6f173475 100644 --- a/src/stagehand/_client.py +++ b/src/stagehand/_client.py @@ -47,6 +47,36 @@ "AsyncClient", ] +_MODEL_API_KEY_ENV_VARS: tuple[str, ...] = ( + "MODEL_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "GOOGLE_GENERATIVE_AI_API_KEY", + "GOOGLE_API_KEY", + "GOOGLE_VERTEX_AI_API_KEY", + "GROQ_API_KEY", + "CEREBRAS_API_KEY", + "TOGETHER_AI_API_KEY", + "MISTRAL_API_KEY", + "DEEPSEEK_API_KEY", + "PERPLEXITY_API_KEY", + "AZURE_API_KEY", + "XAI_API_KEY", +) + + +def _resolve_model_api_key(model_api_key: str | None) -> str | None: + if model_api_key is not None: + return model_api_key + + for env_var in _MODEL_API_KEY_ENV_VARS: + value = os.environ.get(env_var) + if value: + return value + + return None + class Stagehand(SyncAPIClient): # client options @@ -93,7 +123,7 @@ def __init__( This automatically infers the following arguments from their corresponding environment variables if they are not provided: - `browserbase_api_key` from `BROWSERBASE_API_KEY` - `browserbase_project_id` from `BROWSERBASE_PROJECT_ID` - - `model_api_key` from `MODEL_API_KEY` + - `model_api_key` from `MODEL_API_KEY` or a recognized provider API key env var """ self._server_mode: Literal["remote", "local"] = server self._local_stagehand_binary_path = _local_stagehand_binary_path @@ -113,11 +143,11 @@ def __init__( self.browserbase_api_key = browserbase_api_key self.browserbase_project_id = browserbase_project_id - if model_api_key is None: - model_api_key = os.environ.get("MODEL_API_KEY") + model_api_key = _resolve_model_api_key(model_api_key) if model_api_key is None: raise StagehandError( - "The model_api_key client option must be set either by passing model_api_key to the client or by setting the MODEL_API_KEY environment variable" + "The model_api_key client option must be set either by passing model_api_key to the client " + f"or by setting one of the supported environment variables: {', '.join(_MODEL_API_KEY_ENV_VARS)}" ) self.model_api_key = model_api_key @@ -127,14 +157,14 @@ def __init__( if base_url is None: base_url = "http://127.0.0.1" - openai_api_key = local_openai_api_key or os.environ.get("OPENAI_API_KEY") or model_api_key + local_model_api_key = local_openai_api_key or model_api_key self._sea_server = SeaServerManager( config=SeaServerConfig( host=local_host, port=local_port, headless=local_headless, ready_timeout_s=local_ready_timeout_s, - openai_api_key=openai_api_key, + model_api_key=local_model_api_key, chrome_path=local_chrome_path, shutdown_on_close=local_shutdown_on_close, ), @@ -381,7 +411,7 @@ def __init__( This automatically infers the following arguments from their corresponding environment variables if they are not provided: - `browserbase_api_key` from `BROWSERBASE_API_KEY` - `browserbase_project_id` from `BROWSERBASE_PROJECT_ID` - - `model_api_key` from `MODEL_API_KEY` + - `model_api_key` from `MODEL_API_KEY` or a recognized provider API key env var """ self._server_mode: Literal["remote", "local"] = server self._local_stagehand_binary_path = _local_stagehand_binary_path @@ -401,11 +431,11 @@ def __init__( self.browserbase_api_key = browserbase_api_key self.browserbase_project_id = browserbase_project_id - if model_api_key is None: - model_api_key = os.environ.get("MODEL_API_KEY") + model_api_key = _resolve_model_api_key(model_api_key) if model_api_key is None: raise StagehandError( - "The model_api_key client option must be set either by passing model_api_key to the client or by setting the MODEL_API_KEY environment variable" + "The model_api_key client option must be set either by passing model_api_key to the client " + f"or by setting one of the supported environment variables: {', '.join(_MODEL_API_KEY_ENV_VARS)}" ) self.model_api_key = model_api_key @@ -414,14 +444,14 @@ def __init__( if base_url is None: base_url = "http://127.0.0.1" - openai_api_key = local_openai_api_key or os.environ.get("OPENAI_API_KEY") or model_api_key + local_model_api_key = local_openai_api_key or model_api_key self._sea_server = SeaServerManager( config=SeaServerConfig( host=local_host, port=local_port, headless=local_headless, ready_timeout_s=local_ready_timeout_s, - openai_api_key=openai_api_key, + model_api_key=local_model_api_key, chrome_path=local_chrome_path, shutdown_on_close=local_shutdown_on_close, ), diff --git a/src/stagehand/lib/sea_server.py b/src/stagehand/lib/sea_server.py index baed6da0..23105cf2 100644 --- a/src/stagehand/lib/sea_server.py +++ b/src/stagehand/lib/sea_server.py @@ -24,7 +24,7 @@ class SeaServerConfig: port: int headless: bool ready_timeout_s: float - openai_api_key: str | None + model_api_key: str | None chrome_path: str | None shutdown_on_close: bool @@ -118,6 +118,22 @@ def __init__( def base_url(self) -> str | None: return self._base_url + def _build_process_env(self, *, port: int) -> dict[str, str]: + proc_env = dict(os.environ) + # Defaults that make the server boot under SEA (avoid pino-pretty transport) + proc_env.setdefault("NODE_ENV", "production") + # Server package expects BB_ENV to be set (see packages/server/src/lib/env.ts) + proc_env.setdefault("BB_ENV", "local") + proc_env["HOST"] = self._config.host + proc_env["PORT"] = str(port) + proc_env["HEADLESS"] = "true" if self._config.headless else "false" + if self._config.model_api_key: + proc_env["MODEL_API_KEY"] = self._config.model_api_key + if self._config.chrome_path: + proc_env["CHROME_PATH"] = self._config.chrome_path + proc_env["LIGHTHOUSE_CHROMIUM_PATH"] = self._config.chrome_path + return proc_env + def ensure_running_sync(self) -> str: with self._lock: if self._proc is not None and self._proc.poll() is None and self._base_url is not None: @@ -169,20 +185,7 @@ def _start_sync(self) -> tuple[str, subprocess.Popen[bytes]]: port = _pick_free_port(self._config.host) if self._config.port == 0 else self._config.port base_url = _build_base_url(host=self._config.host, port=port) - - proc_env = dict(os.environ) - # Defaults that make the server boot under SEA (avoid pino-pretty transport) - proc_env.setdefault("NODE_ENV", "production") - # Server package expects BB_ENV to be set (see packages/server/src/lib/env.ts) - proc_env.setdefault("BB_ENV", "local") - proc_env["HOST"] = self._config.host - proc_env["PORT"] = str(port) - proc_env["HEADLESS"] = "true" if self._config.headless else "false" - if self._config.openai_api_key: - proc_env["OPENAI_API_KEY"] = self._config.openai_api_key - if self._config.chrome_path: - proc_env["CHROME_PATH"] = self._config.chrome_path - proc_env["LIGHTHOUSE_CHROMIUM_PATH"] = self._config.chrome_path + proc_env = self._build_process_env(port=port) preexec_fn = None creationflags = 0 @@ -221,18 +224,7 @@ async def _start_async(self) -> tuple[str, subprocess.Popen[bytes]]: port = _pick_free_port(self._config.host) if self._config.port == 0 else self._config.port base_url = _build_base_url(host=self._config.host, port=port) - - proc_env = dict(os.environ) - proc_env.setdefault("NODE_ENV", "production") - proc_env.setdefault("BB_ENV", "local") - proc_env["HOST"] = self._config.host - proc_env["PORT"] = str(port) - proc_env["HEADLESS"] = "true" if self._config.headless else "false" - if self._config.openai_api_key: - proc_env["OPENAI_API_KEY"] = self._config.openai_api_key - if self._config.chrome_path: - proc_env["CHROME_PATH"] = self._config.chrome_path - proc_env["LIGHTHOUSE_CHROMIUM_PATH"] = self._config.chrome_path + proc_env = self._build_process_env(port=port) preexec_fn = None creationflags = 0 diff --git a/tests/test_client.py b/tests/test_client.py index 95758e1e..d79201fa 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -20,6 +20,7 @@ from pydantic import ValidationError from stagehand import Stagehand, AsyncStagehand, APIResponseValidationError +from stagehand._client import _MODEL_API_KEY_ENV_VARS from stagehand._types import Omit from stagehand._utils import asyncify from stagehand._models import BaseModel, FinalRequestOptions @@ -45,6 +46,10 @@ model_api_key = "My Model API Key" +def _omit_model_api_key_env_vars() -> dict[str, Omit]: + return {name: Omit() for name in _MODEL_API_KEY_ENV_VARS} + + def _get_params(client: BaseClient[Any, Any]) -> dict[str, str]: request = client._build_request(FinalRequestOptions(method="get", url="/foo")) url = httpx.URL(request.url) @@ -469,7 +474,7 @@ def test_validate_headers(self) -> None: **{ "BROWSERBASE_API_KEY": Omit(), "BROWSERBASE_PROJECT_ID": Omit(), - "MODEL_API_KEY": Omit(), + **_omit_model_api_key_env_vars(), } ): client2 = Stagehand( @@ -481,6 +486,35 @@ def test_validate_headers(self) -> None: ) client2.sessions.start(model_name="openai/gpt-5-nano") + def test_model_api_key_falls_back_to_openai_env(self) -> None: + with update_env( + MODEL_API_KEY=Omit(), + OPENAI_API_KEY="openai-key", + ): + client = Stagehand( + base_url=base_url, + browserbase_api_key=browserbase_api_key, + browserbase_project_id=browserbase_project_id, + model_api_key=None, + ) + + assert client.model_api_key == "openai-key" + + def test_model_api_key_falls_back_to_gemini_env(self) -> None: + with update_env( + MODEL_API_KEY=Omit(), + OPENAI_API_KEY=Omit(), + GEMINI_API_KEY="gemini-key", + ): + client = Stagehand( + base_url=base_url, + browserbase_api_key=browserbase_api_key, + browserbase_project_id=browserbase_project_id, + model_api_key=None, + ) + + assert client.model_api_key == "gemini-key" + def test_default_query_option(self) -> None: client = Stagehand( base_url=base_url, @@ -1517,7 +1551,7 @@ def test_validate_headers(self) -> None: **{ "BROWSERBASE_API_KEY": Omit(), "BROWSERBASE_PROJECT_ID": Omit(), - "MODEL_API_KEY": Omit(), + **_omit_model_api_key_env_vars(), } ): client2 = AsyncStagehand( @@ -1529,6 +1563,35 @@ def test_validate_headers(self) -> None: ) _ = client2 + async def test_model_api_key_falls_back_to_openai_env(self) -> None: + with update_env( + MODEL_API_KEY=Omit(), + OPENAI_API_KEY="openai-key", + ): + client = AsyncStagehand( + base_url=base_url, + browserbase_api_key=browserbase_api_key, + browserbase_project_id=browserbase_project_id, + model_api_key=None, + ) + + assert client.model_api_key == "openai-key" + + async def test_model_api_key_falls_back_to_gemini_env(self) -> None: + with update_env( + MODEL_API_KEY=Omit(), + OPENAI_API_KEY=Omit(), + GEMINI_API_KEY="gemini-key", + ): + client = AsyncStagehand( + base_url=base_url, + browserbase_api_key=browserbase_api_key, + browserbase_project_id=browserbase_project_id, + model_api_key=None, + ) + + assert client.model_api_key == "gemini-key" + async def test_default_query_option(self) -> None: client = AsyncStagehand( base_url=base_url, diff --git a/tests/test_local_server.py b/tests/test_local_server.py index ebf44686..14ef7ff1 100644 --- a/tests/test_local_server.py +++ b/tests/test_local_server.py @@ -1,12 +1,15 @@ from __future__ import annotations import json +from pathlib import Path import httpx import pytest from respx import MockRouter from stagehand import Stagehand, AsyncStagehand +from stagehand._client import _MODEL_API_KEY_ENV_VARS +from stagehand.lib import sea_server from stagehand._exceptions import StagehandError @@ -31,12 +34,72 @@ async def aclose(self) -> None: self.closed += 1 +class _DummyProcess: + pid = 12345 + + def __init__(self) -> None: + self._returncode: int | None = None + + def poll(self) -> int | None: + return self._returncode + + def wait(self, _timeout: float | None = None) -> int: + self._returncode = 0 + return 0 + + def terminate(self) -> None: + self._returncode = 0 + + def kill(self) -> None: + self._returncode = 0 + + def _set_required_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("BROWSERBASE_API_KEY", "bb_key") monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "bb_project") + _clear_model_api_key_envs(monkeypatch) monkeypatch.setenv("MODEL_API_KEY", "model_key") +def _set_browserbase_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BROWSERBASE_API_KEY", "bb_key") + monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "bb_project") + + +def _clear_model_api_key_envs(monkeypatch: pytest.MonkeyPatch) -> None: + for env_var in _MODEL_API_KEY_ENV_VARS: + monkeypatch.delenv(env_var, raising=False) + + +def _install_fake_sea_runtime( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + captured_env: dict[str, str], + *, + port: int, +) -> None: + binary_path = tmp_path / "stagehand-test-binary" + binary_path.write_text("binary") + + def _fake_popen( + args: list[str], + env: dict[str, str], + stdout: object, + stderr: object, + preexec_fn: object, + creationflags: int, + ) -> _DummyProcess: + del args, stdout, stderr, preexec_fn, creationflags + captured_env.update(env) + return _DummyProcess() + + monkeypatch.setattr(sea_server, "_pick_free_port", lambda _host: port) + monkeypatch.setattr(sea_server, "_terminate_process", lambda proc: setattr(proc, "_returncode", 0)) + monkeypatch.setattr(sea_server, "_wait_ready_sync", lambda **_kwargs: None) + monkeypatch.setattr(sea_server, "resolve_binary_path", lambda **_kwargs: binary_path) + monkeypatch.setattr(sea_server.subprocess, "Popen", _fake_popen) + + @pytest.mark.respx(base_url="http://127.0.0.1:43123") def test_sync_local_mode_starts_before_first_request(respx_mock: MockRouter, monkeypatch: pytest.MonkeyPatch) -> None: _set_required_env(monkeypatch) @@ -172,3 +235,80 @@ def _post(*_args: object, **_kwargs: object) -> object: model_name="openai/gpt-5-nano", browser={"type": "local"}, ) + + +def test_async_local_mode_hydrates_sea_model_api_key_from_anthropic_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _set_browserbase_env(monkeypatch) + _clear_model_api_key_envs(monkeypatch) + monkeypatch.setenv("ANTHROPIC_API_KEY", "anthropic_key") + + client = AsyncStagehand(server="local", _local_stagehand_binary_path="/does/not/matter/in/test") + + assert client._sea_server is not None + assert client._sea_server._config.model_api_key == "anthropic_key" # type: ignore[attr-defined] + + +def test_sync_local_mode_maps_explicit_local_openai_override_to_model_api_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _set_required_env(monkeypatch) + + client = Stagehand( + server="local", + local_openai_api_key="override_key", + _local_stagehand_binary_path="/does/not/matter/in/test", + ) + + assert client._sea_server is not None + assert client._sea_server._config.model_api_key == "override_key" # type: ignore[attr-defined] + + client.close() + + +@pytest.mark.parametrize("env_var", _MODEL_API_KEY_ENV_VARS) +def test_local_mode_forwards_each_supported_env_var_to_sea_binary( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + env_var: str, +) -> None: + _set_browserbase_env(monkeypatch) + _clear_model_api_key_envs(monkeypatch) + monkeypatch.setenv(env_var, f"value-for-{env_var}") + + captured_env: dict[str, str] = {} + _install_fake_sea_runtime(monkeypatch, tmp_path, captured_env, port=43129) + + client = Stagehand(server="local", _local_stagehand_binary_path="/does/not/matter/in/test") + assert client._sea_server is not None + + client._sea_server.ensure_running_sync() + + assert captured_env["MODEL_API_KEY"] == f"value-for-{env_var}" + client.close() + + +def test_local_mode_explicit_model_api_key_overrides_env_vars_for_sea_binary( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _set_browserbase_env(monkeypatch) + _clear_model_api_key_envs(monkeypatch) + monkeypatch.setenv("GEMINI_API_KEY", "env-gemini-key") + monkeypatch.setenv("OPENAI_API_KEY", "env-openai-key") + + captured_env: dict[str, str] = {} + _install_fake_sea_runtime(monkeypatch, tmp_path, captured_env, port=43130) + + client = Stagehand( + server="local", + _local_stagehand_binary_path="/does/not/matter/in/test", + model_api_key="explicit-model-key", + ) + assert client._sea_server is not None + + client._sea_server.ensure_running_sync() + + assert captured_env["MODEL_API_KEY"] == "explicit-model-key" + client.close() From 900c0c67dca2d7032210630ffbdff8bdda090b84 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 1 Apr 2026 11:33:46 -0700 Subject: [PATCH 3/7] Require explicit model_api_key for Python SDK --- examples/local_example.py | 1 + src/stagehand/_client.py | 72 ++----------------- src/stagehand/lib/sea_server.py | 3 +- tests/test_client.py | 107 ++++++---------------------- tests/test_local_server.py | 120 ++++++++++++-------------------- 5 files changed, 76 insertions(+), 227 deletions(-) diff --git a/examples/local_example.py b/examples/local_example.py index c4671cb7..5a8c952b 100644 --- a/examples/local_example.py +++ b/examples/local_example.py @@ -51,6 +51,7 @@ def main() -> None: client = Stagehand( server="local", + model_api_key=model_key, local_ready_timeout_s=30.0, ) diff --git a/src/stagehand/_client.py b/src/stagehand/_client.py index 6f173475..d740d700 100644 --- a/src/stagehand/_client.py +++ b/src/stagehand/_client.py @@ -24,7 +24,7 @@ from ._models import FinalRequestOptions from ._version import __version__ from ._streaming import Stream as Stream, AsyncStream as AsyncStream -from ._exceptions import APIStatusError, StagehandError +from ._exceptions import APIStatusError from ._base_client import ( DEFAULT_MAX_RETRIES, SyncAPIClient, @@ -47,42 +47,12 @@ "AsyncClient", ] -_MODEL_API_KEY_ENV_VARS: tuple[str, ...] = ( - "MODEL_API_KEY", - "OPENAI_API_KEY", - "ANTHROPIC_API_KEY", - "GEMINI_API_KEY", - "GOOGLE_GENERATIVE_AI_API_KEY", - "GOOGLE_API_KEY", - "GOOGLE_VERTEX_AI_API_KEY", - "GROQ_API_KEY", - "CEREBRAS_API_KEY", - "TOGETHER_AI_API_KEY", - "MISTRAL_API_KEY", - "DEEPSEEK_API_KEY", - "PERPLEXITY_API_KEY", - "AZURE_API_KEY", - "XAI_API_KEY", -) - - -def _resolve_model_api_key(model_api_key: str | None) -> str | None: - if model_api_key is not None: - return model_api_key - - for env_var in _MODEL_API_KEY_ENV_VARS: - value = os.environ.get(env_var) - if value: - return value - - return None - class Stagehand(SyncAPIClient): # client options browserbase_api_key: str | None browserbase_project_id: str | None - model_api_key: str + model_api_key: str | None def __init__( self, @@ -97,7 +67,6 @@ def __init__( local_headless: bool = True, local_chrome_path: str | None = None, local_ready_timeout_s: float = 10.0, - local_openai_api_key: str | None = None, local_shutdown_on_close: bool = True, base_url: str | httpx.URL | None = None, timeout: float | Timeout | None | NotGiven = not_given, @@ -123,7 +92,6 @@ def __init__( This automatically infers the following arguments from their corresponding environment variables if they are not provided: - `browserbase_api_key` from `BROWSERBASE_API_KEY` - `browserbase_project_id` from `BROWSERBASE_PROJECT_ID` - - `model_api_key` from `MODEL_API_KEY` or a recognized provider API key env var """ self._server_mode: Literal["remote", "local"] = server self._local_stagehand_binary_path = _local_stagehand_binary_path @@ -132,7 +100,6 @@ def __init__( self._local_headless = local_headless self._local_chrome_path = local_chrome_path self._local_ready_timeout_s = local_ready_timeout_s - self._local_openai_api_key = local_openai_api_key self._local_shutdown_on_close = local_shutdown_on_close if browserbase_api_key is None: @@ -143,12 +110,6 @@ def __init__( self.browserbase_api_key = browserbase_api_key self.browserbase_project_id = browserbase_project_id - model_api_key = _resolve_model_api_key(model_api_key) - if model_api_key is None: - raise StagehandError( - "The model_api_key client option must be set either by passing model_api_key to the client " - f"or by setting one of the supported environment variables: {', '.join(_MODEL_API_KEY_ENV_VARS)}" - ) self.model_api_key = model_api_key self._sea_server: SeaServerManager | None = None @@ -157,14 +118,13 @@ def __init__( if base_url is None: base_url = "http://127.0.0.1" - local_model_api_key = local_openai_api_key or model_api_key self._sea_server = SeaServerManager( config=SeaServerConfig( host=local_host, port=local_port, headless=local_headless, ready_timeout_s=local_ready_timeout_s, - model_api_key=local_model_api_key, + model_api_key=model_api_key, chrome_path=local_chrome_path, shutdown_on_close=local_shutdown_on_close, ), @@ -240,7 +200,7 @@ def _bb_project_id_auth(self) -> dict[str, str]: @property def _llm_model_api_key_auth(self) -> dict[str, str]: model_api_key = self.model_api_key - return {"x-model-api-key": model_api_key} + return {"x-model-api-key": model_api_key} if model_api_key else {} @property @override @@ -266,7 +226,6 @@ def copy( local_headless: bool | None = None, local_chrome_path: str | None = None, local_ready_timeout_s: float | None = None, - local_openai_api_key: str | None = None, local_shutdown_on_close: bool | None = None, base_url: str | httpx.URL | None = None, timeout: float | Timeout | None | NotGiven = not_given, @@ -313,9 +272,6 @@ def copy( local_ready_timeout_s=local_ready_timeout_s if local_ready_timeout_s is not None else self._local_ready_timeout_s, - local_openai_api_key=local_openai_api_key - if local_openai_api_key is not None - else self._local_openai_api_key, local_shutdown_on_close=local_shutdown_on_close if local_shutdown_on_close is not None else self._local_shutdown_on_close, @@ -370,7 +326,7 @@ class AsyncStagehand(AsyncAPIClient): # client options browserbase_api_key: str | None browserbase_project_id: str | None - model_api_key: str + model_api_key: str | None def __init__( self, @@ -385,7 +341,6 @@ def __init__( local_headless: bool = True, local_chrome_path: str | None = None, local_ready_timeout_s: float = 10.0, - local_openai_api_key: str | None = None, local_shutdown_on_close: bool = True, base_url: str | httpx.URL | None = None, timeout: float | Timeout | None | NotGiven = not_given, @@ -411,7 +366,6 @@ def __init__( This automatically infers the following arguments from their corresponding environment variables if they are not provided: - `browserbase_api_key` from `BROWSERBASE_API_KEY` - `browserbase_project_id` from `BROWSERBASE_PROJECT_ID` - - `model_api_key` from `MODEL_API_KEY` or a recognized provider API key env var """ self._server_mode: Literal["remote", "local"] = server self._local_stagehand_binary_path = _local_stagehand_binary_path @@ -420,7 +374,6 @@ def __init__( self._local_headless = local_headless self._local_chrome_path = local_chrome_path self._local_ready_timeout_s = local_ready_timeout_s - self._local_openai_api_key = local_openai_api_key self._local_shutdown_on_close = local_shutdown_on_close if browserbase_api_key is None: @@ -431,12 +384,6 @@ def __init__( self.browserbase_api_key = browserbase_api_key self.browserbase_project_id = browserbase_project_id - model_api_key = _resolve_model_api_key(model_api_key) - if model_api_key is None: - raise StagehandError( - "The model_api_key client option must be set either by passing model_api_key to the client " - f"or by setting one of the supported environment variables: {', '.join(_MODEL_API_KEY_ENV_VARS)}" - ) self.model_api_key = model_api_key self._sea_server: SeaServerManager | None = None @@ -444,14 +391,13 @@ def __init__( if base_url is None: base_url = "http://127.0.0.1" - local_model_api_key = local_openai_api_key or model_api_key self._sea_server = SeaServerManager( config=SeaServerConfig( host=local_host, port=local_port, headless=local_headless, ready_timeout_s=local_ready_timeout_s, - model_api_key=local_model_api_key, + model_api_key=model_api_key, chrome_path=local_chrome_path, shutdown_on_close=local_shutdown_on_close, ), @@ -527,7 +473,7 @@ def _bb_project_id_auth(self) -> dict[str, str]: @property def _llm_model_api_key_auth(self) -> dict[str, str]: model_api_key = self.model_api_key - return {"x-model-api-key": model_api_key} + return {"x-model-api-key": model_api_key} if model_api_key else {} @property @override @@ -553,7 +499,6 @@ def copy( local_headless: bool | None = None, local_chrome_path: str | None = None, local_ready_timeout_s: float | None = None, - local_openai_api_key: str | None = None, local_shutdown_on_close: bool | None = None, base_url: str | httpx.URL | None = None, timeout: float | Timeout | None | NotGiven = not_given, @@ -600,9 +545,6 @@ def copy( local_ready_timeout_s=local_ready_timeout_s if local_ready_timeout_s is not None else self._local_ready_timeout_s, - local_openai_api_key=local_openai_api_key - if local_openai_api_key is not None - else self._local_openai_api_key, local_shutdown_on_close=local_shutdown_on_close if local_shutdown_on_close is not None else self._local_shutdown_on_close, diff --git a/src/stagehand/lib/sea_server.py b/src/stagehand/lib/sea_server.py index 23105cf2..ce16a60e 100644 --- a/src/stagehand/lib/sea_server.py +++ b/src/stagehand/lib/sea_server.py @@ -127,8 +127,7 @@ def _build_process_env(self, *, port: int) -> dict[str, str]: proc_env["HOST"] = self._config.host proc_env["PORT"] = str(port) proc_env["HEADLESS"] = "true" if self._config.headless else "false" - if self._config.model_api_key: - proc_env["MODEL_API_KEY"] = self._config.model_api_key + proc_env["MODEL_API_KEY"] = self._config.model_api_key or "" if self._config.chrome_path: proc_env["CHROME_PATH"] = self._config.chrome_path proc_env["LIGHTHOUSE_CHROMIUM_PATH"] = self._config.chrome_path diff --git a/tests/test_client.py b/tests/test_client.py index d79201fa..42cb3374 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -20,12 +20,11 @@ from pydantic import ValidationError from stagehand import Stagehand, AsyncStagehand, APIResponseValidationError -from stagehand._client import _MODEL_API_KEY_ENV_VARS from stagehand._types import Omit from stagehand._utils import asyncify from stagehand._models import BaseModel, FinalRequestOptions from stagehand._streaming import Stream, AsyncStream -from stagehand._exceptions import APIStatusError, StagehandError, APITimeoutError, APIResponseValidationError +from stagehand._exceptions import APIStatusError, APITimeoutError, APIResponseValidationError from stagehand._base_client import ( DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, @@ -46,10 +45,6 @@ model_api_key = "My Model API Key" -def _omit_model_api_key_env_vars() -> dict[str, Omit]: - return {name: Omit() for name in _MODEL_API_KEY_ENV_VARS} - - def _get_params(client: BaseClient[Any, Any]) -> dict[str, str]: request = client._build_request(FinalRequestOptions(method="get", url="/foo")) url = httpx.URL(request.url) @@ -469,51 +464,21 @@ def test_validate_headers(self) -> None: request = client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-model-api-key") == model_api_key - with pytest.raises(StagehandError): - with update_env( - **{ - "BROWSERBASE_API_KEY": Omit(), - "BROWSERBASE_PROJECT_ID": Omit(), - **_omit_model_api_key_env_vars(), - } - ): - client2 = Stagehand( - base_url=base_url, - browserbase_api_key=None, - browserbase_project_id=None, - model_api_key=None, - _strict_response_validation=True, - ) - client2.sessions.start(model_name="openai/gpt-5-nano") - - def test_model_api_key_falls_back_to_openai_env(self) -> None: - with update_env( - MODEL_API_KEY=Omit(), - OPENAI_API_KEY="openai-key", - ): - client = Stagehand( - base_url=base_url, - browserbase_api_key=browserbase_api_key, - browserbase_project_id=browserbase_project_id, - model_api_key=None, - ) - - assert client.model_api_key == "openai-key" - - def test_model_api_key_falls_back_to_gemini_env(self) -> None: with update_env( - MODEL_API_KEY=Omit(), - OPENAI_API_KEY=Omit(), - GEMINI_API_KEY="gemini-key", + BROWSERBASE_API_KEY=Omit(), + BROWSERBASE_PROJECT_ID=Omit(), ): - client = Stagehand( + client2 = Stagehand( base_url=base_url, - browserbase_api_key=browserbase_api_key, - browserbase_project_id=browserbase_project_id, + browserbase_api_key=None, + browserbase_project_id=None, model_api_key=None, + _strict_response_validation=True, ) - - assert client.model_api_key == "gemini-key" + request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-bb-api-key") is None + assert request.headers.get("x-bb-project-id") is None + assert request.headers.get("x-model-api-key") is None def test_default_query_option(self) -> None: client = Stagehand( @@ -1546,51 +1511,21 @@ def test_validate_headers(self) -> None: request = client._build_request(FinalRequestOptions(method="get", url="/foo")) assert request.headers.get("x-model-api-key") == model_api_key - with pytest.raises(StagehandError): - with update_env( - **{ - "BROWSERBASE_API_KEY": Omit(), - "BROWSERBASE_PROJECT_ID": Omit(), - **_omit_model_api_key_env_vars(), - } - ): - client2 = AsyncStagehand( - base_url=base_url, - browserbase_api_key=None, - browserbase_project_id=None, - model_api_key=None, - _strict_response_validation=True, - ) - _ = client2 - - async def test_model_api_key_falls_back_to_openai_env(self) -> None: - with update_env( - MODEL_API_KEY=Omit(), - OPENAI_API_KEY="openai-key", - ): - client = AsyncStagehand( - base_url=base_url, - browserbase_api_key=browserbase_api_key, - browserbase_project_id=browserbase_project_id, - model_api_key=None, - ) - - assert client.model_api_key == "openai-key" - - async def test_model_api_key_falls_back_to_gemini_env(self) -> None: with update_env( - MODEL_API_KEY=Omit(), - OPENAI_API_KEY=Omit(), - GEMINI_API_KEY="gemini-key", + BROWSERBASE_API_KEY=Omit(), + BROWSERBASE_PROJECT_ID=Omit(), ): - client = AsyncStagehand( + client2 = AsyncStagehand( base_url=base_url, - browserbase_api_key=browserbase_api_key, - browserbase_project_id=browserbase_project_id, + browserbase_api_key=None, + browserbase_project_id=None, model_api_key=None, + _strict_response_validation=True, ) - - assert client.model_api_key == "gemini-key" + request = client2._build_request(FinalRequestOptions(method="get", url="/foo")) + assert request.headers.get("x-bb-api-key") is None + assert request.headers.get("x-bb-project-id") is None + assert request.headers.get("x-model-api-key") is None async def test_default_query_option(self) -> None: client = AsyncStagehand( diff --git a/tests/test_local_server.py b/tests/test_local_server.py index 14ef7ff1..f4eb22da 100644 --- a/tests/test_local_server.py +++ b/tests/test_local_server.py @@ -8,7 +8,6 @@ from respx import MockRouter from stagehand import Stagehand, AsyncStagehand -from stagehand._client import _MODEL_API_KEY_ENV_VARS from stagehand.lib import sea_server from stagehand._exceptions import StagehandError @@ -57,8 +56,6 @@ def kill(self) -> None: def _set_required_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("BROWSERBASE_API_KEY", "bb_key") monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "bb_project") - _clear_model_api_key_envs(monkeypatch) - monkeypatch.setenv("MODEL_API_KEY", "model_key") def _set_browserbase_env(monkeypatch: pytest.MonkeyPatch) -> None: @@ -66,11 +63,6 @@ def _set_browserbase_env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("BROWSERBASE_PROJECT_ID", "bb_project") -def _clear_model_api_key_envs(monkeypatch: pytest.MonkeyPatch) -> None: - for env_var in _MODEL_API_KEY_ENV_VARS: - monkeypatch.delenv(env_var, raising=False) - - def _install_fake_sea_runtime( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -120,7 +112,11 @@ def test_sync_local_mode_starts_before_first_request(respx_mock: MockRouter, mon ) ) - client = Stagehand(server="local", _local_stagehand_binary_path="/does/not/matter/in/test") + client = Stagehand( + server="local", + model_api_key="model_key", + _local_stagehand_binary_path="/does/not/matter/in/test", + ) # Swap in a dummy server so we don't spawn a real binary in unit tests. client._sea_server = dummy # type: ignore[attr-defined] @@ -157,7 +153,11 @@ def _capture_start_request(request: httpx.Request) -> httpx.Response: respx_mock.post("/v1/sessions/start").mock(side_effect=_capture_start_request) - client = Stagehand(server="local", _local_stagehand_binary_path="/does/not/matter/in/test") + client = Stagehand( + server="local", + model_api_key="model_key", + _local_stagehand_binary_path="/does/not/matter/in/test", + ) client._sea_server = dummy # type: ignore[attr-defined] resp = client.sessions.start(model_name="openai/gpt-5-nano") @@ -194,7 +194,11 @@ async def test_async_local_mode_starts_before_first_request( ) ) - async with AsyncStagehand(server="local", _local_stagehand_binary_path="/does/not/matter/in/test") as client: + async with AsyncStagehand( + server="local", + model_api_key="model_key", + _local_stagehand_binary_path="/does/not/matter/in/test", + ) as client: client._sea_server = dummy # type: ignore[attr-defined] resp = await client.sessions.start(model_name="openai/gpt-5-nano") assert resp.success is True @@ -209,7 +213,11 @@ def test_local_server_requires_browserbase_keys_for_browserbase_sessions( _set_required_env(monkeypatch) monkeypatch.delenv("BROWSERBASE_API_KEY", raising=False) monkeypatch.delenv("BROWSERBASE_PROJECT_ID", raising=False) - client = Stagehand(server="local", _local_stagehand_binary_path="/does/not/matter/in/test") + client = Stagehand( + server="local", + model_api_key="model_key", + _local_stagehand_binary_path="/does/not/matter/in/test", + ) client._sea_server = _DummySeaServer("http://127.0.0.1:43125") # type: ignore[attr-defined] with pytest.raises(StagehandError): client.sessions.start(model_name="openai/gpt-5-nano") @@ -221,7 +229,11 @@ def test_local_server_allows_local_browser_without_browserbase_keys( _set_required_env(monkeypatch) monkeypatch.delenv("BROWSERBASE_API_KEY", raising=False) monkeypatch.delenv("BROWSERBASE_PROJECT_ID", raising=False) - client = Stagehand(server="local", _local_stagehand_binary_path="/does/not/matter/in/test") + client = Stagehand( + server="local", + model_api_key="model_key", + _local_stagehand_binary_path="/does/not/matter/in/test", + ) client._sea_server = _DummySeaServer("http://127.0.0.1:43126") # type: ignore[attr-defined] def _post(*_args: object, **_kwargs: object) -> object: @@ -237,78 +249,38 @@ def _post(*_args: object, **_kwargs: object) -> object: ) -def test_async_local_mode_hydrates_sea_model_api_key_from_anthropic_env( - monkeypatch: pytest.MonkeyPatch, -) -> None: - _set_browserbase_env(monkeypatch) - _clear_model_api_key_envs(monkeypatch) - monkeypatch.setenv("ANTHROPIC_API_KEY", "anthropic_key") - - client = AsyncStagehand(server="local", _local_stagehand_binary_path="/does/not/matter/in/test") - - assert client._sea_server is not None - assert client._sea_server._config.model_api_key == "anthropic_key" # type: ignore[attr-defined] - - -def test_sync_local_mode_maps_explicit_local_openai_override_to_model_api_key( - monkeypatch: pytest.MonkeyPatch, -) -> None: - _set_required_env(monkeypatch) - - client = Stagehand( - server="local", - local_openai_api_key="override_key", - _local_stagehand_binary_path="/does/not/matter/in/test", - ) - - assert client._sea_server is not None - assert client._sea_server._config.model_api_key == "override_key" # type: ignore[attr-defined] - - client.close() - - -@pytest.mark.parametrize("env_var", _MODEL_API_KEY_ENV_VARS) -def test_local_mode_forwards_each_supported_env_var_to_sea_binary( +@pytest.mark.parametrize( + ("explicit_model_api_key", "expected_model_api_key"), + [ + (None, ""), + ("good1", "good1"), + ], +) +def test_local_mode_masks_inherited_model_api_key_envs_and_prefers_explicit_param( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, - env_var: str, + explicit_model_api_key: str | None, + expected_model_api_key: str, ) -> None: _set_browserbase_env(monkeypatch) - _clear_model_api_key_envs(monkeypatch) - monkeypatch.setenv(env_var, f"value-for-{env_var}") + monkeypatch.setenv("MODEL_API_KEY", "bad1") + monkeypatch.setenv("OPENAI_API_KEY", "bad2") captured_env: dict[str, str] = {} _install_fake_sea_runtime(monkeypatch, tmp_path, captured_env, port=43129) - client = Stagehand(server="local", _local_stagehand_binary_path="/does/not/matter/in/test") - assert client._sea_server is not None - - client._sea_server.ensure_running_sync() - - assert captured_env["MODEL_API_KEY"] == f"value-for-{env_var}" - client.close() - - -def test_local_mode_explicit_model_api_key_overrides_env_vars_for_sea_binary( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - _set_browserbase_env(monkeypatch) - _clear_model_api_key_envs(monkeypatch) - monkeypatch.setenv("GEMINI_API_KEY", "env-gemini-key") - monkeypatch.setenv("OPENAI_API_KEY", "env-openai-key") - - captured_env: dict[str, str] = {} - _install_fake_sea_runtime(monkeypatch, tmp_path, captured_env, port=43130) + client_kwargs: dict[str, object] = { + "server": "local", + "_local_stagehand_binary_path": "/does/not/matter/in/test", + } + if explicit_model_api_key is not None: + client_kwargs["model_api_key"] = explicit_model_api_key - client = Stagehand( - server="local", - _local_stagehand_binary_path="/does/not/matter/in/test", - model_api_key="explicit-model-key", - ) + client = Stagehand(**client_kwargs) assert client._sea_server is not None client._sea_server.ensure_running_sync() - assert captured_env["MODEL_API_KEY"] == "explicit-model-key" + assert captured_env["MODEL_API_KEY"] == expected_model_api_key + assert captured_env["OPENAI_API_KEY"] == "bad2" client.close() From e90a4ddab5a0c5c488630a8f58962b74773df4e0 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 1 Apr 2026 11:34:42 -0700 Subject: [PATCH 4/7] Add flow log env propagation test --- tests/test_local_server.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_local_server.py b/tests/test_local_server.py index f4eb22da..2d1ff3e2 100644 --- a/tests/test_local_server.py +++ b/tests/test_local_server.py @@ -284,3 +284,28 @@ def test_local_mode_masks_inherited_model_api_key_envs_and_prefers_explicit_para assert captured_env["MODEL_API_KEY"] == expected_model_api_key assert captured_env["OPENAI_API_KEY"] == "bad2" client.close() + + +def test_local_mode_forwards_flow_log_and_config_dir_env_to_sea_binary( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + _set_required_env(monkeypatch) + monkeypatch.setenv("BROWSERBASE_FLOW_LOGS", "1") + monkeypatch.setenv("BROWSERBASE_CONFIG_DIR", "./tmp") + + captured_env: dict[str, str] = {} + _install_fake_sea_runtime(monkeypatch, tmp_path, captured_env, port=43131) + + client = Stagehand( + server="local", + model_api_key="model_key", + _local_stagehand_binary_path="/does/not/matter/in/test", + ) + assert client._sea_server is not None + + client._sea_server.ensure_running_sync() + + assert captured_env["BROWSERBASE_FLOW_LOGS"] == "1" + assert captured_env["BROWSERBASE_CONFIG_DIR"] == "./tmp" + client.close() From dd3124dc0096e81153297b7ea0dae59488c761a0 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 1 Apr 2026 11:38:23 -0700 Subject: [PATCH 5/7] Clarify env var handling in Python SDK --- examples/local_example.py | 3 ++- src/stagehand/_client.py | 8 ++++++++ src/stagehand/lib/sea_server.py | 3 +++ tests/test_local_server.py | 2 ++ 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/examples/local_example.py b/examples/local_example.py index 5a8c952b..440c69ac 100644 --- a/examples/local_example.py +++ b/examples/local_example.py @@ -5,7 +5,7 @@ Required environment variables: - BROWSERBASE_API_KEY (can be any value in local mode) - BROWSERBASE_PROJECT_ID (can be any value in local mode) -- MODEL_API_KEY (used for client configuration even in local mode) +- MODEL_API_KEY (read by this example and passed explicitly to the client) Install the published wheel before running this script: @@ -45,6 +45,7 @@ def _stream_to_result(stream, label: str) -> object | None: def main() -> None: load_example_env() + # The example reads MODEL_API_KEY itself so the SDK configuration stays explicit. model_key = os.environ.get("MODEL_API_KEY") if not model_key: sys.exit("Set MODEL_API_KEY to run the local server.") diff --git a/src/stagehand/_client.py b/src/stagehand/_client.py index d740d700..7204dcf1 100644 --- a/src/stagehand/_client.py +++ b/src/stagehand/_client.py @@ -92,6 +92,10 @@ def __init__( This automatically infers the following arguments from their corresponding environment variables if they are not provided: - `browserbase_api_key` from `BROWSERBASE_API_KEY` - `browserbase_project_id` from `BROWSERBASE_PROJECT_ID` + + `model_api_key` is intentionally not inferred from any AI provider environment variable. + Pass it explicitly when you want the SDK to send `x-model-api-key` on remote requests or + to forward `MODEL_API_KEY` to the local SEA child process. """ self._server_mode: Literal["remote", "local"] = server self._local_stagehand_binary_path = _local_stagehand_binary_path @@ -366,6 +370,10 @@ def __init__( This automatically infers the following arguments from their corresponding environment variables if they are not provided: - `browserbase_api_key` from `BROWSERBASE_API_KEY` - `browserbase_project_id` from `BROWSERBASE_PROJECT_ID` + + `model_api_key` is intentionally not inferred from any AI provider environment variable. + Pass it explicitly when you want the SDK to send `x-model-api-key` on remote requests or + to forward `MODEL_API_KEY` to the local SEA child process. """ self._server_mode: Literal["remote", "local"] = server self._local_stagehand_binary_path = _local_stagehand_binary_path diff --git a/src/stagehand/lib/sea_server.py b/src/stagehand/lib/sea_server.py index ce16a60e..45242403 100644 --- a/src/stagehand/lib/sea_server.py +++ b/src/stagehand/lib/sea_server.py @@ -127,6 +127,9 @@ def _build_process_env(self, *, port: int) -> dict[str, str]: proc_env["HOST"] = self._config.host proc_env["PORT"] = str(port) proc_env["HEADLESS"] = "true" if self._config.headless else "false" + # Always set MODEL_API_KEY in the child env so the SDK constructor value wins + # over any inherited parent MODEL_API_KEY. An empty string preserves the + # "explicitly unset" case instead of silently reusing the parent's value. proc_env["MODEL_API_KEY"] = self._config.model_api_key or "" if self._config.chrome_path: proc_env["CHROME_PATH"] = self._config.chrome_path diff --git a/tests/test_local_server.py b/tests/test_local_server.py index 2d1ff3e2..6e0efb16 100644 --- a/tests/test_local_server.py +++ b/tests/test_local_server.py @@ -263,6 +263,8 @@ def test_local_mode_masks_inherited_model_api_key_envs_and_prefers_explicit_para expected_model_api_key: str, ) -> None: _set_browserbase_env(monkeypatch) + # Simulate a parent process with conflicting inherited env. The child SEA process + # should keep unrelated env intact while MODEL_API_KEY follows constructor intent. monkeypatch.setenv("MODEL_API_KEY", "bad1") monkeypatch.setenv("OPENAI_API_KEY", "bad2") From e4a085013c2179fc8f33d97c917a21fb704eb010 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 1 Apr 2026 11:42:20 -0700 Subject: [PATCH 6/7] Fix local server test typing for lint --- tests/test_local_server.py | 45 ++++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/tests/test_local_server.py b/tests/test_local_server.py index 6e0efb16..490ccbda 100644 --- a/tests/test_local_server.py +++ b/tests/test_local_server.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import json from pathlib import Path @@ -85,10 +86,27 @@ def _fake_popen( captured_env.update(env) return _DummyProcess() - monkeypatch.setattr(sea_server, "_pick_free_port", lambda _host: port) - monkeypatch.setattr(sea_server, "_terminate_process", lambda proc: setattr(proc, "_returncode", 0)) - monkeypatch.setattr(sea_server, "_wait_ready_sync", lambda **_kwargs: None) - monkeypatch.setattr(sea_server, "resolve_binary_path", lambda **_kwargs: binary_path) + def _fake_pick_free_port(_host: str) -> int: + return port + + def _fake_terminate_process(proc: _DummyProcess) -> None: + proc._returncode = 0 + + def _fake_wait_ready_sync(*, base_url: str, timeout_s: float) -> None: + del base_url, timeout_s + + def _fake_resolve_binary_path( + *, + _local_stagehand_binary_path: str | os.PathLike[str] | None = None, + version: str | None = None, + ) -> Path: + del _local_stagehand_binary_path, version + return binary_path + + monkeypatch.setattr(sea_server, "_pick_free_port", _fake_pick_free_port) + monkeypatch.setattr(sea_server, "_terminate_process", _fake_terminate_process) + monkeypatch.setattr(sea_server, "_wait_ready_sync", _fake_wait_ready_sync) + monkeypatch.setattr(sea_server, "resolve_binary_path", _fake_resolve_binary_path) monkeypatch.setattr(sea_server.subprocess, "Popen", _fake_popen) @@ -271,14 +289,17 @@ def test_local_mode_masks_inherited_model_api_key_envs_and_prefers_explicit_para captured_env: dict[str, str] = {} _install_fake_sea_runtime(monkeypatch, tmp_path, captured_env, port=43129) - client_kwargs: dict[str, object] = { - "server": "local", - "_local_stagehand_binary_path": "/does/not/matter/in/test", - } - if explicit_model_api_key is not None: - client_kwargs["model_api_key"] = explicit_model_api_key - - client = Stagehand(**client_kwargs) + if explicit_model_api_key is None: + client = Stagehand( + server="local", + _local_stagehand_binary_path="/does/not/matter/in/test", + ) + else: + client = Stagehand( + server="local", + model_api_key=explicit_model_api_key, + _local_stagehand_binary_path="/does/not/matter/in/test", + ) assert client._sea_server is not None client._sea_server.ensure_running_sync() From ec6ff4d306b12d110d7c6857dd8f2de09dfea2ec Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Wed, 1 Apr 2026 11:48:45 -0700 Subject: [PATCH 7/7] Force production NODE_ENV for SEA child --- src/stagehand/lib/sea_server.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/stagehand/lib/sea_server.py b/src/stagehand/lib/sea_server.py index 45242403..ef34aa62 100644 --- a/src/stagehand/lib/sea_server.py +++ b/src/stagehand/lib/sea_server.py @@ -120,8 +120,10 @@ def base_url(self) -> str | None: def _build_process_env(self, *, port: int) -> dict[str, str]: proc_env = dict(os.environ) - # Defaults that make the server boot under SEA (avoid pino-pretty transport) - proc_env.setdefault("NODE_ENV", "production") + # Force production mode so inherited NODE_ENV=development never reaches the + # SEA child process. Development mode breaks under SEA because pino-pretty + # is an optional dependency that is not present in the packaged binary. + proc_env["NODE_ENV"] = "production" # Server package expects BB_ENV to be set (see packages/server/src/lib/env.ts) proc_env.setdefault("BB_ENV", "local") proc_env["HOST"] = self._config.host