Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion examples/local_browser_playwright_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)...")
Expand Down
5 changes: 3 additions & 2 deletions examples/local_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -45,13 +45,14 @@ 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.")

client = Stagehand(
server="local",
local_openai_api_key=model_key,
model_api_key=model_key,
local_ready_timeout_s=30.0,
)

Expand Down
1 change: 0 additions & 1 deletion examples/local_server_multiregion_browser_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)...")
Expand Down
50 changes: 15 additions & 35 deletions src/stagehand/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -52,7 +52,7 @@ 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,
Expand All @@ -67,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,
Expand All @@ -93,7 +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` from `MODEL_API_KEY`

`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
Expand All @@ -102,7 +104,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:
Expand All @@ -113,12 +114,6 @@ 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")
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"
)
self.model_api_key = model_api_key

self._sea_server: SeaServerManager | None = None
Expand All @@ -127,14 +122,13 @@ 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
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=model_api_key,
chrome_path=local_chrome_path,
shutdown_on_close=local_shutdown_on_close,
),
Expand Down Expand Up @@ -210,7 +204,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
Expand All @@ -236,7 +230,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,
Expand Down Expand Up @@ -283,9 +276,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,
Expand Down Expand Up @@ -340,7 +330,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,
Expand All @@ -355,7 +345,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,
Expand All @@ -381,7 +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` from `MODEL_API_KEY`

`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
Expand All @@ -390,7 +382,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:
Expand All @@ -401,27 +392,20 @@ 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")
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"
)
self.model_api_key = model_api_key

self._sea_server: SeaServerManager | None = None
if server == "local":
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
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=model_api_key,
chrome_path=local_chrome_path,
shutdown_on_close=local_shutdown_on_close,
),
Expand Down Expand Up @@ -497,7 +481,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
Expand All @@ -523,7 +507,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,
Expand Down Expand Up @@ -570,9 +553,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,
Expand Down
50 changes: 23 additions & 27 deletions src/stagehand/lib/sea_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -118,6 +118,26 @@ 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)
# 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
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
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:
Expand Down Expand Up @@ -169,20 +189,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
Expand Down Expand Up @@ -221,18 +228,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
Expand Down
64 changes: 31 additions & 33 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
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,
Expand Down Expand Up @@ -464,22 +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(),
"MODEL_API_KEY": Omit(),
}
):
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")
with update_env(
BROWSERBASE_API_KEY=Omit(),
BROWSERBASE_PROJECT_ID=Omit(),
):
client2 = Stagehand(
base_url=base_url,
browserbase_api_key=None,
browserbase_project_id=None,
model_api_key=None,
_strict_response_validation=True,
)
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(
Expand Down Expand Up @@ -1512,22 +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(),
"MODEL_API_KEY": Omit(),
}
):
client2 = AsyncStagehand(
base_url=base_url,
browserbase_api_key=None,
browserbase_project_id=None,
model_api_key=None,
_strict_response_validation=True,
)
_ = client2
with update_env(
BROWSERBASE_API_KEY=Omit(),
BROWSERBASE_PROJECT_ID=Omit(),
):
client2 = AsyncStagehand(
base_url=base_url,
browserbase_api_key=None,
browserbase_project_id=None,
model_api_key=None,
_strict_response_validation=True,
)
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(
Expand Down
Loading
Loading