Skip to content

Commit 99b6033

Browse files
authored
Fix Client(model_api_key=...) and remove OPENAI_API_KEY/MODEL_API_KEY env var auto loading (#328)
* fix dev tag for sea binary should never be used * Support provider env vars for model API keys * Require explicit model_api_key for Python SDK * Add flow log env propagation test * Clarify env var handling in Python SDK * Fix local server test typing for lint * Force production NODE_ENV for SEA child
1 parent f9cc8b2 commit 99b6033

File tree

8 files changed

+242
-105
lines changed

8 files changed

+242
-105
lines changed

examples/local_browser_playwright_example.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,6 @@ def main() -> None:
104104
browserbase_api_key=bb_api_key,
105105
browserbase_project_id=bb_project_id,
106106
model_api_key=model_api_key,
107-
local_openai_api_key=model_api_key,
108107
local_ready_timeout_s=30.0,
109108
) as client:
110109
print("⏳ Starting Stagehand session (local server + local browser)...")

examples/local_example.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
Required environment variables:
66
- BROWSERBASE_API_KEY (can be any value in local mode)
77
- BROWSERBASE_PROJECT_ID (can be any value in local mode)
8-
- MODEL_API_KEY (used for client configuration even in local mode)
8+
- MODEL_API_KEY (read by this example and passed explicitly to the client)
99
1010
1111
Install the published wheel before running this script:
@@ -45,13 +45,14 @@ def _stream_to_result(stream, label: str) -> object | None:
4545

4646
def main() -> None:
4747
load_example_env()
48+
# The example reads MODEL_API_KEY itself so the SDK configuration stays explicit.
4849
model_key = os.environ.get("MODEL_API_KEY")
4950
if not model_key:
5051
sys.exit("Set MODEL_API_KEY to run the local server.")
5152

5253
client = Stagehand(
5354
server="local",
54-
local_openai_api_key=model_key,
55+
model_api_key=model_key,
5556
local_ready_timeout_s=30.0,
5657
)
5758

examples/local_server_multiregion_browser_example.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@ def main() -> None:
7575
browserbase_api_key=bb_api_key,
7676
browserbase_project_id=bb_project_id,
7777
model_api_key=model_api_key,
78-
local_openai_api_key=model_api_key,
7978
local_ready_timeout_s=30.0,
8079
) as client:
8180
print("⏳ Starting Stagehand session (local server + Browserbase browser)...")

src/stagehand/_client.py

Lines changed: 15 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from ._models import FinalRequestOptions
2525
from ._version import __version__
2626
from ._streaming import Stream as Stream, AsyncStream as AsyncStream
27-
from ._exceptions import APIStatusError, StagehandError
27+
from ._exceptions import APIStatusError
2828
from ._base_client import (
2929
DEFAULT_MAX_RETRIES,
3030
SyncAPIClient,
@@ -52,7 +52,7 @@ class Stagehand(SyncAPIClient):
5252
# client options
5353
browserbase_api_key: str | None
5454
browserbase_project_id: str | None
55-
model_api_key: str
55+
model_api_key: str | None
5656

5757
def __init__(
5858
self,
@@ -67,7 +67,6 @@ def __init__(
6767
local_headless: bool = True,
6868
local_chrome_path: str | None = None,
6969
local_ready_timeout_s: float = 10.0,
70-
local_openai_api_key: str | None = None,
7170
local_shutdown_on_close: bool = True,
7271
base_url: str | httpx.URL | None = None,
7372
timeout: float | Timeout | None | NotGiven = not_given,
@@ -93,7 +92,10 @@ def __init__(
9392
This automatically infers the following arguments from their corresponding environment variables if they are not provided:
9493
- `browserbase_api_key` from `BROWSERBASE_API_KEY`
9594
- `browserbase_project_id` from `BROWSERBASE_PROJECT_ID`
96-
- `model_api_key` from `MODEL_API_KEY`
95+
96+
`model_api_key` is intentionally not inferred from any AI provider environment variable.
97+
Pass it explicitly when you want the SDK to send `x-model-api-key` on remote requests or
98+
to forward `MODEL_API_KEY` to the local SEA child process.
9799
"""
98100
self._server_mode: Literal["remote", "local"] = server
99101
self._local_stagehand_binary_path = _local_stagehand_binary_path
@@ -102,7 +104,6 @@ def __init__(
102104
self._local_headless = local_headless
103105
self._local_chrome_path = local_chrome_path
104106
self._local_ready_timeout_s = local_ready_timeout_s
105-
self._local_openai_api_key = local_openai_api_key
106107
self._local_shutdown_on_close = local_shutdown_on_close
107108

108109
if browserbase_api_key is None:
@@ -113,12 +114,6 @@ def __init__(
113114
self.browserbase_api_key = browserbase_api_key
114115
self.browserbase_project_id = browserbase_project_id
115116

116-
if model_api_key is None:
117-
model_api_key = os.environ.get("MODEL_API_KEY")
118-
if model_api_key is None:
119-
raise StagehandError(
120-
"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"
121-
)
122117
self.model_api_key = model_api_key
123118

124119
self._sea_server: SeaServerManager | None = None
@@ -127,14 +122,13 @@ def __init__(
127122
if base_url is None:
128123
base_url = "http://127.0.0.1"
129124

130-
openai_api_key = local_openai_api_key or os.environ.get("OPENAI_API_KEY") or model_api_key
131125
self._sea_server = SeaServerManager(
132126
config=SeaServerConfig(
133127
host=local_host,
134128
port=local_port,
135129
headless=local_headless,
136130
ready_timeout_s=local_ready_timeout_s,
137-
openai_api_key=openai_api_key,
131+
model_api_key=model_api_key,
138132
chrome_path=local_chrome_path,
139133
shutdown_on_close=local_shutdown_on_close,
140134
),
@@ -210,7 +204,7 @@ def _bb_project_id_auth(self) -> dict[str, str]:
210204
@property
211205
def _llm_model_api_key_auth(self) -> dict[str, str]:
212206
model_api_key = self.model_api_key
213-
return {"x-model-api-key": model_api_key}
207+
return {"x-model-api-key": model_api_key} if model_api_key else {}
214208

215209
@property
216210
@override
@@ -236,7 +230,6 @@ def copy(
236230
local_headless: bool | None = None,
237231
local_chrome_path: str | None = None,
238232
local_ready_timeout_s: float | None = None,
239-
local_openai_api_key: str | None = None,
240233
local_shutdown_on_close: bool | None = None,
241234
base_url: str | httpx.URL | None = None,
242235
timeout: float | Timeout | None | NotGiven = not_given,
@@ -283,9 +276,6 @@ def copy(
283276
local_ready_timeout_s=local_ready_timeout_s
284277
if local_ready_timeout_s is not None
285278
else self._local_ready_timeout_s,
286-
local_openai_api_key=local_openai_api_key
287-
if local_openai_api_key is not None
288-
else self._local_openai_api_key,
289279
local_shutdown_on_close=local_shutdown_on_close
290280
if local_shutdown_on_close is not None
291281
else self._local_shutdown_on_close,
@@ -340,7 +330,7 @@ class AsyncStagehand(AsyncAPIClient):
340330
# client options
341331
browserbase_api_key: str | None
342332
browserbase_project_id: str | None
343-
model_api_key: str
333+
model_api_key: str | None
344334

345335
def __init__(
346336
self,
@@ -355,7 +345,6 @@ def __init__(
355345
local_headless: bool = True,
356346
local_chrome_path: str | None = None,
357347
local_ready_timeout_s: float = 10.0,
358-
local_openai_api_key: str | None = None,
359348
local_shutdown_on_close: bool = True,
360349
base_url: str | httpx.URL | None = None,
361350
timeout: float | Timeout | None | NotGiven = not_given,
@@ -381,7 +370,10 @@ def __init__(
381370
This automatically infers the following arguments from their corresponding environment variables if they are not provided:
382371
- `browserbase_api_key` from `BROWSERBASE_API_KEY`
383372
- `browserbase_project_id` from `BROWSERBASE_PROJECT_ID`
384-
- `model_api_key` from `MODEL_API_KEY`
373+
374+
`model_api_key` is intentionally not inferred from any AI provider environment variable.
375+
Pass it explicitly when you want the SDK to send `x-model-api-key` on remote requests or
376+
to forward `MODEL_API_KEY` to the local SEA child process.
385377
"""
386378
self._server_mode: Literal["remote", "local"] = server
387379
self._local_stagehand_binary_path = _local_stagehand_binary_path
@@ -390,7 +382,6 @@ def __init__(
390382
self._local_headless = local_headless
391383
self._local_chrome_path = local_chrome_path
392384
self._local_ready_timeout_s = local_ready_timeout_s
393-
self._local_openai_api_key = local_openai_api_key
394385
self._local_shutdown_on_close = local_shutdown_on_close
395386

396387
if browserbase_api_key is None:
@@ -401,27 +392,20 @@ def __init__(
401392
self.browserbase_api_key = browserbase_api_key
402393
self.browserbase_project_id = browserbase_project_id
403394

404-
if model_api_key is None:
405-
model_api_key = os.environ.get("MODEL_API_KEY")
406-
if model_api_key is None:
407-
raise StagehandError(
408-
"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"
409-
)
410395
self.model_api_key = model_api_key
411396

412397
self._sea_server: SeaServerManager | None = None
413398
if server == "local":
414399
if base_url is None:
415400
base_url = "http://127.0.0.1"
416401

417-
openai_api_key = local_openai_api_key or os.environ.get("OPENAI_API_KEY") or model_api_key
418402
self._sea_server = SeaServerManager(
419403
config=SeaServerConfig(
420404
host=local_host,
421405
port=local_port,
422406
headless=local_headless,
423407
ready_timeout_s=local_ready_timeout_s,
424-
openai_api_key=openai_api_key,
408+
model_api_key=model_api_key,
425409
chrome_path=local_chrome_path,
426410
shutdown_on_close=local_shutdown_on_close,
427411
),
@@ -497,7 +481,7 @@ def _bb_project_id_auth(self) -> dict[str, str]:
497481
@property
498482
def _llm_model_api_key_auth(self) -> dict[str, str]:
499483
model_api_key = self.model_api_key
500-
return {"x-model-api-key": model_api_key}
484+
return {"x-model-api-key": model_api_key} if model_api_key else {}
501485

502486
@property
503487
@override
@@ -523,7 +507,6 @@ def copy(
523507
local_headless: bool | None = None,
524508
local_chrome_path: str | None = None,
525509
local_ready_timeout_s: float | None = None,
526-
local_openai_api_key: str | None = None,
527510
local_shutdown_on_close: bool | None = None,
528511
base_url: str | httpx.URL | None = None,
529512
timeout: float | Timeout | None | NotGiven = not_given,
@@ -570,9 +553,6 @@ def copy(
570553
local_ready_timeout_s=local_ready_timeout_s
571554
if local_ready_timeout_s is not None
572555
else self._local_ready_timeout_s,
573-
local_openai_api_key=local_openai_api_key
574-
if local_openai_api_key is not None
575-
else self._local_openai_api_key,
576556
local_shutdown_on_close=local_shutdown_on_close
577557
if local_shutdown_on_close is not None
578558
else self._local_shutdown_on_close,

src/stagehand/lib/sea_server.py

Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class SeaServerConfig:
2424
port: int
2525
headless: bool
2626
ready_timeout_s: float
27-
openai_api_key: str | None
27+
model_api_key: str | None
2828
chrome_path: str | None
2929
shutdown_on_close: bool
3030

@@ -118,6 +118,26 @@ def __init__(
118118
def base_url(self) -> str | None:
119119
return self._base_url
120120

121+
def _build_process_env(self, *, port: int) -> dict[str, str]:
122+
proc_env = dict(os.environ)
123+
# Force production mode so inherited NODE_ENV=development never reaches the
124+
# SEA child process. Development mode breaks under SEA because pino-pretty
125+
# is an optional dependency that is not present in the packaged binary.
126+
proc_env["NODE_ENV"] = "production"
127+
# Server package expects BB_ENV to be set (see packages/server/src/lib/env.ts)
128+
proc_env.setdefault("BB_ENV", "local")
129+
proc_env["HOST"] = self._config.host
130+
proc_env["PORT"] = str(port)
131+
proc_env["HEADLESS"] = "true" if self._config.headless else "false"
132+
# Always set MODEL_API_KEY in the child env so the SDK constructor value wins
133+
# over any inherited parent MODEL_API_KEY. An empty string preserves the
134+
# "explicitly unset" case instead of silently reusing the parent's value.
135+
proc_env["MODEL_API_KEY"] = self._config.model_api_key or ""
136+
if self._config.chrome_path:
137+
proc_env["CHROME_PATH"] = self._config.chrome_path
138+
proc_env["LIGHTHOUSE_CHROMIUM_PATH"] = self._config.chrome_path
139+
return proc_env
140+
121141
def ensure_running_sync(self) -> str:
122142
with self._lock:
123143
if self._proc is not None and self._proc.poll() is None and self._base_url is not None:
@@ -169,20 +189,7 @@ def _start_sync(self) -> tuple[str, subprocess.Popen[bytes]]:
169189

170190
port = _pick_free_port(self._config.host) if self._config.port == 0 else self._config.port
171191
base_url = _build_base_url(host=self._config.host, port=port)
172-
173-
proc_env = dict(os.environ)
174-
# Defaults that make the server boot under SEA (avoid pino-pretty transport)
175-
proc_env.setdefault("NODE_ENV", "production")
176-
# Server package expects BB_ENV to be set (see packages/server/src/lib/env.ts)
177-
proc_env.setdefault("BB_ENV", "local")
178-
proc_env["HOST"] = self._config.host
179-
proc_env["PORT"] = str(port)
180-
proc_env["HEADLESS"] = "true" if self._config.headless else "false"
181-
if self._config.openai_api_key:
182-
proc_env["OPENAI_API_KEY"] = self._config.openai_api_key
183-
if self._config.chrome_path:
184-
proc_env["CHROME_PATH"] = self._config.chrome_path
185-
proc_env["LIGHTHOUSE_CHROMIUM_PATH"] = self._config.chrome_path
192+
proc_env = self._build_process_env(port=port)
186193

187194
preexec_fn = None
188195
creationflags = 0
@@ -221,18 +228,7 @@ async def _start_async(self) -> tuple[str, subprocess.Popen[bytes]]:
221228

222229
port = _pick_free_port(self._config.host) if self._config.port == 0 else self._config.port
223230
base_url = _build_base_url(host=self._config.host, port=port)
224-
225-
proc_env = dict(os.environ)
226-
proc_env.setdefault("NODE_ENV", "production")
227-
proc_env.setdefault("BB_ENV", "local")
228-
proc_env["HOST"] = self._config.host
229-
proc_env["PORT"] = str(port)
230-
proc_env["HEADLESS"] = "true" if self._config.headless else "false"
231-
if self._config.openai_api_key:
232-
proc_env["OPENAI_API_KEY"] = self._config.openai_api_key
233-
if self._config.chrome_path:
234-
proc_env["CHROME_PATH"] = self._config.chrome_path
235-
proc_env["LIGHTHOUSE_CHROMIUM_PATH"] = self._config.chrome_path
231+
proc_env = self._build_process_env(port=port)
236232

237233
preexec_fn = None
238234
creationflags = 0

tests/test_client.py

Lines changed: 31 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from stagehand._utils import asyncify
2525
from stagehand._models import BaseModel, FinalRequestOptions
2626
from stagehand._streaming import Stream, AsyncStream
27-
from stagehand._exceptions import APIStatusError, StagehandError, APITimeoutError, APIResponseValidationError
27+
from stagehand._exceptions import APIStatusError, APITimeoutError, APIResponseValidationError
2828
from stagehand._base_client import (
2929
DEFAULT_TIMEOUT,
3030
HTTPX_DEFAULT_TIMEOUT,
@@ -464,22 +464,21 @@ def test_validate_headers(self) -> None:
464464
request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
465465
assert request.headers.get("x-model-api-key") == model_api_key
466466

467-
with pytest.raises(StagehandError):
468-
with update_env(
469-
**{
470-
"BROWSERBASE_API_KEY": Omit(),
471-
"BROWSERBASE_PROJECT_ID": Omit(),
472-
"MODEL_API_KEY": Omit(),
473-
}
474-
):
475-
client2 = Stagehand(
476-
base_url=base_url,
477-
browserbase_api_key=None,
478-
browserbase_project_id=None,
479-
model_api_key=None,
480-
_strict_response_validation=True,
481-
)
482-
client2.sessions.start(model_name="openai/gpt-5-nano")
467+
with update_env(
468+
BROWSERBASE_API_KEY=Omit(),
469+
BROWSERBASE_PROJECT_ID=Omit(),
470+
):
471+
client2 = Stagehand(
472+
base_url=base_url,
473+
browserbase_api_key=None,
474+
browserbase_project_id=None,
475+
model_api_key=None,
476+
_strict_response_validation=True,
477+
)
478+
request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
479+
assert request.headers.get("x-bb-api-key") is None
480+
assert request.headers.get("x-bb-project-id") is None
481+
assert request.headers.get("x-model-api-key") is None
483482

484483
def test_default_query_option(self) -> None:
485484
client = Stagehand(
@@ -1512,22 +1511,21 @@ def test_validate_headers(self) -> None:
15121511
request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
15131512
assert request.headers.get("x-model-api-key") == model_api_key
15141513

1515-
with pytest.raises(StagehandError):
1516-
with update_env(
1517-
**{
1518-
"BROWSERBASE_API_KEY": Omit(),
1519-
"BROWSERBASE_PROJECT_ID": Omit(),
1520-
"MODEL_API_KEY": Omit(),
1521-
}
1522-
):
1523-
client2 = AsyncStagehand(
1524-
base_url=base_url,
1525-
browserbase_api_key=None,
1526-
browserbase_project_id=None,
1527-
model_api_key=None,
1528-
_strict_response_validation=True,
1529-
)
1530-
_ = client2
1514+
with update_env(
1515+
BROWSERBASE_API_KEY=Omit(),
1516+
BROWSERBASE_PROJECT_ID=Omit(),
1517+
):
1518+
client2 = AsyncStagehand(
1519+
base_url=base_url,
1520+
browserbase_api_key=None,
1521+
browserbase_project_id=None,
1522+
model_api_key=None,
1523+
_strict_response_validation=True,
1524+
)
1525+
request = client2._build_request(FinalRequestOptions(method="get", url="/foo"))
1526+
assert request.headers.get("x-bb-api-key") is None
1527+
assert request.headers.get("x-bb-project-id") is None
1528+
assert request.headers.get("x-model-api-key") is None
15311529

15321530
async def test_default_query_option(self) -> None:
15331531
client = AsyncStagehand(

0 commit comments

Comments
 (0)