Skip to content

Commit f57a6ea

Browse files
committed
Require explicit model_api_key for Python SDK
1 parent 60469ef commit f57a6ea

File tree

5 files changed

+76
-227
lines changed

5 files changed

+76
-227
lines changed

examples/local_example.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def main() -> None:
5151

5252
client = Stagehand(
5353
server="local",
54+
model_api_key=model_key,
5455
local_ready_timeout_s=30.0,
5556
)
5657

src/stagehand/_client.py

Lines changed: 7 additions & 65 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,
@@ -47,42 +47,12 @@
4747
"AsyncClient",
4848
]
4949

50-
_MODEL_API_KEY_ENV_VARS: tuple[str, ...] = (
51-
"MODEL_API_KEY",
52-
"OPENAI_API_KEY",
53-
"ANTHROPIC_API_KEY",
54-
"GEMINI_API_KEY",
55-
"GOOGLE_GENERATIVE_AI_API_KEY",
56-
"GOOGLE_API_KEY",
57-
"GOOGLE_VERTEX_AI_API_KEY",
58-
"GROQ_API_KEY",
59-
"CEREBRAS_API_KEY",
60-
"TOGETHER_AI_API_KEY",
61-
"MISTRAL_API_KEY",
62-
"DEEPSEEK_API_KEY",
63-
"PERPLEXITY_API_KEY",
64-
"AZURE_API_KEY",
65-
"XAI_API_KEY",
66-
)
67-
68-
69-
def _resolve_model_api_key(model_api_key: str | None) -> str | None:
70-
if model_api_key is not None:
71-
return model_api_key
72-
73-
for env_var in _MODEL_API_KEY_ENV_VARS:
74-
value = os.environ.get(env_var)
75-
if value:
76-
return value
77-
78-
return None
79-
8050

8151
class Stagehand(SyncAPIClient):
8252
# client options
8353
browserbase_api_key: str | None
8454
browserbase_project_id: str | None
85-
model_api_key: str
55+
model_api_key: str | None
8656

8757
def __init__(
8858
self,
@@ -97,7 +67,6 @@ def __init__(
9767
local_headless: bool = True,
9868
local_chrome_path: str | None = None,
9969
local_ready_timeout_s: float = 10.0,
100-
local_openai_api_key: str | None = None,
10170
local_shutdown_on_close: bool = True,
10271
base_url: str | httpx.URL | None = None,
10372
timeout: float | Timeout | None | NotGiven = not_given,
@@ -123,7 +92,6 @@ def __init__(
12392
This automatically infers the following arguments from their corresponding environment variables if they are not provided:
12493
- `browserbase_api_key` from `BROWSERBASE_API_KEY`
12594
- `browserbase_project_id` from `BROWSERBASE_PROJECT_ID`
126-
- `model_api_key` from `MODEL_API_KEY` or a recognized provider API key env var
12795
"""
12896
self._server_mode: Literal["remote", "local"] = server
12997
self._local_stagehand_binary_path = _local_stagehand_binary_path
@@ -132,7 +100,6 @@ def __init__(
132100
self._local_headless = local_headless
133101
self._local_chrome_path = local_chrome_path
134102
self._local_ready_timeout_s = local_ready_timeout_s
135-
self._local_openai_api_key = local_openai_api_key
136103
self._local_shutdown_on_close = local_shutdown_on_close
137104

138105
if browserbase_api_key is None:
@@ -143,12 +110,6 @@ def __init__(
143110
self.browserbase_api_key = browserbase_api_key
144111
self.browserbase_project_id = browserbase_project_id
145112

146-
model_api_key = _resolve_model_api_key(model_api_key)
147-
if model_api_key is None:
148-
raise StagehandError(
149-
"The model_api_key client option must be set either by passing model_api_key to the client "
150-
f"or by setting one of the supported environment variables: {', '.join(_MODEL_API_KEY_ENV_VARS)}"
151-
)
152113
self.model_api_key = model_api_key
153114

154115
self._sea_server: SeaServerManager | None = None
@@ -157,14 +118,13 @@ def __init__(
157118
if base_url is None:
158119
base_url = "http://127.0.0.1"
159120

160-
local_model_api_key = local_openai_api_key or model_api_key
161121
self._sea_server = SeaServerManager(
162122
config=SeaServerConfig(
163123
host=local_host,
164124
port=local_port,
165125
headless=local_headless,
166126
ready_timeout_s=local_ready_timeout_s,
167-
model_api_key=local_model_api_key,
127+
model_api_key=model_api_key,
168128
chrome_path=local_chrome_path,
169129
shutdown_on_close=local_shutdown_on_close,
170130
),
@@ -240,7 +200,7 @@ def _bb_project_id_auth(self) -> dict[str, str]:
240200
@property
241201
def _llm_model_api_key_auth(self) -> dict[str, str]:
242202
model_api_key = self.model_api_key
243-
return {"x-model-api-key": model_api_key}
203+
return {"x-model-api-key": model_api_key} if model_api_key else {}
244204

245205
@property
246206
@override
@@ -266,7 +226,6 @@ def copy(
266226
local_headless: bool | None = None,
267227
local_chrome_path: str | None = None,
268228
local_ready_timeout_s: float | None = None,
269-
local_openai_api_key: str | None = None,
270229
local_shutdown_on_close: bool | None = None,
271230
base_url: str | httpx.URL | None = None,
272231
timeout: float | Timeout | None | NotGiven = not_given,
@@ -313,9 +272,6 @@ def copy(
313272
local_ready_timeout_s=local_ready_timeout_s
314273
if local_ready_timeout_s is not None
315274
else self._local_ready_timeout_s,
316-
local_openai_api_key=local_openai_api_key
317-
if local_openai_api_key is not None
318-
else self._local_openai_api_key,
319275
local_shutdown_on_close=local_shutdown_on_close
320276
if local_shutdown_on_close is not None
321277
else self._local_shutdown_on_close,
@@ -370,7 +326,7 @@ class AsyncStagehand(AsyncAPIClient):
370326
# client options
371327
browserbase_api_key: str | None
372328
browserbase_project_id: str | None
373-
model_api_key: str
329+
model_api_key: str | None
374330

375331
def __init__(
376332
self,
@@ -385,7 +341,6 @@ def __init__(
385341
local_headless: bool = True,
386342
local_chrome_path: str | None = None,
387343
local_ready_timeout_s: float = 10.0,
388-
local_openai_api_key: str | None = None,
389344
local_shutdown_on_close: bool = True,
390345
base_url: str | httpx.URL | None = None,
391346
timeout: float | Timeout | None | NotGiven = not_given,
@@ -411,7 +366,6 @@ def __init__(
411366
This automatically infers the following arguments from their corresponding environment variables if they are not provided:
412367
- `browserbase_api_key` from `BROWSERBASE_API_KEY`
413368
- `browserbase_project_id` from `BROWSERBASE_PROJECT_ID`
414-
- `model_api_key` from `MODEL_API_KEY` or a recognized provider API key env var
415369
"""
416370
self._server_mode: Literal["remote", "local"] = server
417371
self._local_stagehand_binary_path = _local_stagehand_binary_path
@@ -420,7 +374,6 @@ def __init__(
420374
self._local_headless = local_headless
421375
self._local_chrome_path = local_chrome_path
422376
self._local_ready_timeout_s = local_ready_timeout_s
423-
self._local_openai_api_key = local_openai_api_key
424377
self._local_shutdown_on_close = local_shutdown_on_close
425378

426379
if browserbase_api_key is None:
@@ -431,27 +384,20 @@ def __init__(
431384
self.browserbase_api_key = browserbase_api_key
432385
self.browserbase_project_id = browserbase_project_id
433386

434-
model_api_key = _resolve_model_api_key(model_api_key)
435-
if model_api_key is None:
436-
raise StagehandError(
437-
"The model_api_key client option must be set either by passing model_api_key to the client "
438-
f"or by setting one of the supported environment variables: {', '.join(_MODEL_API_KEY_ENV_VARS)}"
439-
)
440387
self.model_api_key = model_api_key
441388

442389
self._sea_server: SeaServerManager | None = None
443390
if server == "local":
444391
if base_url is None:
445392
base_url = "http://127.0.0.1"
446393

447-
local_model_api_key = local_openai_api_key or model_api_key
448394
self._sea_server = SeaServerManager(
449395
config=SeaServerConfig(
450396
host=local_host,
451397
port=local_port,
452398
headless=local_headless,
453399
ready_timeout_s=local_ready_timeout_s,
454-
model_api_key=local_model_api_key,
400+
model_api_key=model_api_key,
455401
chrome_path=local_chrome_path,
456402
shutdown_on_close=local_shutdown_on_close,
457403
),
@@ -527,7 +473,7 @@ def _bb_project_id_auth(self) -> dict[str, str]:
527473
@property
528474
def _llm_model_api_key_auth(self) -> dict[str, str]:
529475
model_api_key = self.model_api_key
530-
return {"x-model-api-key": model_api_key}
476+
return {"x-model-api-key": model_api_key} if model_api_key else {}
531477

532478
@property
533479
@override
@@ -553,7 +499,6 @@ def copy(
553499
local_headless: bool | None = None,
554500
local_chrome_path: str | None = None,
555501
local_ready_timeout_s: float | None = None,
556-
local_openai_api_key: str | None = None,
557502
local_shutdown_on_close: bool | None = None,
558503
base_url: str | httpx.URL | None = None,
559504
timeout: float | Timeout | None | NotGiven = not_given,
@@ -600,9 +545,6 @@ def copy(
600545
local_ready_timeout_s=local_ready_timeout_s
601546
if local_ready_timeout_s is not None
602547
else self._local_ready_timeout_s,
603-
local_openai_api_key=local_openai_api_key
604-
if local_openai_api_key is not None
605-
else self._local_openai_api_key,
606548
local_shutdown_on_close=local_shutdown_on_close
607549
if local_shutdown_on_close is not None
608550
else self._local_shutdown_on_close,

src/stagehand/lib/sea_server.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,7 @@ def _build_process_env(self, *, port: int) -> dict[str, str]:
127127
proc_env["HOST"] = self._config.host
128128
proc_env["PORT"] = str(port)
129129
proc_env["HEADLESS"] = "true" if self._config.headless else "false"
130-
if self._config.model_api_key:
131-
proc_env["MODEL_API_KEY"] = self._config.model_api_key
130+
proc_env["MODEL_API_KEY"] = self._config.model_api_key or ""
132131
if self._config.chrome_path:
133132
proc_env["CHROME_PATH"] = self._config.chrome_path
134133
proc_env["LIGHTHOUSE_CHROMIUM_PATH"] = self._config.chrome_path

tests/test_client.py

Lines changed: 21 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,11 @@
2020
from pydantic import ValidationError
2121

2222
from stagehand import Stagehand, AsyncStagehand, APIResponseValidationError
23-
from stagehand._client import _MODEL_API_KEY_ENV_VARS
2423
from stagehand._types import Omit
2524
from stagehand._utils import asyncify
2625
from stagehand._models import BaseModel, FinalRequestOptions
2726
from stagehand._streaming import Stream, AsyncStream
28-
from stagehand._exceptions import APIStatusError, StagehandError, APITimeoutError, APIResponseValidationError
27+
from stagehand._exceptions import APIStatusError, APITimeoutError, APIResponseValidationError
2928
from stagehand._base_client import (
3029
DEFAULT_TIMEOUT,
3130
HTTPX_DEFAULT_TIMEOUT,
@@ -46,10 +45,6 @@
4645
model_api_key = "My Model API Key"
4746

4847

49-
def _omit_model_api_key_env_vars() -> dict[str, Omit]:
50-
return {name: Omit() for name in _MODEL_API_KEY_ENV_VARS}
51-
52-
5348
def _get_params(client: BaseClient[Any, Any]) -> dict[str, str]:
5449
request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
5550
url = httpx.URL(request.url)
@@ -469,51 +464,21 @@ def test_validate_headers(self) -> None:
469464
request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
470465
assert request.headers.get("x-model-api-key") == model_api_key
471466

472-
with pytest.raises(StagehandError):
473-
with update_env(
474-
**{
475-
"BROWSERBASE_API_KEY": Omit(),
476-
"BROWSERBASE_PROJECT_ID": Omit(),
477-
**_omit_model_api_key_env_vars(),
478-
}
479-
):
480-
client2 = Stagehand(
481-
base_url=base_url,
482-
browserbase_api_key=None,
483-
browserbase_project_id=None,
484-
model_api_key=None,
485-
_strict_response_validation=True,
486-
)
487-
client2.sessions.start(model_name="openai/gpt-5-nano")
488-
489-
def test_model_api_key_falls_back_to_openai_env(self) -> None:
490-
with update_env(
491-
MODEL_API_KEY=Omit(),
492-
OPENAI_API_KEY="openai-key",
493-
):
494-
client = Stagehand(
495-
base_url=base_url,
496-
browserbase_api_key=browserbase_api_key,
497-
browserbase_project_id=browserbase_project_id,
498-
model_api_key=None,
499-
)
500-
501-
assert client.model_api_key == "openai-key"
502-
503-
def test_model_api_key_falls_back_to_gemini_env(self) -> None:
504467
with update_env(
505-
MODEL_API_KEY=Omit(),
506-
OPENAI_API_KEY=Omit(),
507-
GEMINI_API_KEY="gemini-key",
468+
BROWSERBASE_API_KEY=Omit(),
469+
BROWSERBASE_PROJECT_ID=Omit(),
508470
):
509-
client = Stagehand(
471+
client2 = Stagehand(
510472
base_url=base_url,
511-
browserbase_api_key=browserbase_api_key,
512-
browserbase_project_id=browserbase_project_id,
473+
browserbase_api_key=None,
474+
browserbase_project_id=None,
513475
model_api_key=None,
476+
_strict_response_validation=True,
514477
)
515-
516-
assert client.model_api_key == "gemini-key"
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
517482

518483
def test_default_query_option(self) -> None:
519484
client = Stagehand(
@@ -1546,51 +1511,21 @@ def test_validate_headers(self) -> None:
15461511
request = client._build_request(FinalRequestOptions(method="get", url="/foo"))
15471512
assert request.headers.get("x-model-api-key") == model_api_key
15481513

1549-
with pytest.raises(StagehandError):
1550-
with update_env(
1551-
**{
1552-
"BROWSERBASE_API_KEY": Omit(),
1553-
"BROWSERBASE_PROJECT_ID": Omit(),
1554-
**_omit_model_api_key_env_vars(),
1555-
}
1556-
):
1557-
client2 = AsyncStagehand(
1558-
base_url=base_url,
1559-
browserbase_api_key=None,
1560-
browserbase_project_id=None,
1561-
model_api_key=None,
1562-
_strict_response_validation=True,
1563-
)
1564-
_ = client2
1565-
1566-
async def test_model_api_key_falls_back_to_openai_env(self) -> None:
1567-
with update_env(
1568-
MODEL_API_KEY=Omit(),
1569-
OPENAI_API_KEY="openai-key",
1570-
):
1571-
client = AsyncStagehand(
1572-
base_url=base_url,
1573-
browserbase_api_key=browserbase_api_key,
1574-
browserbase_project_id=browserbase_project_id,
1575-
model_api_key=None,
1576-
)
1577-
1578-
assert client.model_api_key == "openai-key"
1579-
1580-
async def test_model_api_key_falls_back_to_gemini_env(self) -> None:
15811514
with update_env(
1582-
MODEL_API_KEY=Omit(),
1583-
OPENAI_API_KEY=Omit(),
1584-
GEMINI_API_KEY="gemini-key",
1515+
BROWSERBASE_API_KEY=Omit(),
1516+
BROWSERBASE_PROJECT_ID=Omit(),
15851517
):
1586-
client = AsyncStagehand(
1518+
client2 = AsyncStagehand(
15871519
base_url=base_url,
1588-
browserbase_api_key=browserbase_api_key,
1589-
browserbase_project_id=browserbase_project_id,
1520+
browserbase_api_key=None,
1521+
browserbase_project_id=None,
15901522
model_api_key=None,
1523+
_strict_response_validation=True,
15911524
)
1592-
1593-
assert client.model_api_key == "gemini-key"
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
15941529

15951530
async def test_default_query_option(self) -> None:
15961531
client = AsyncStagehand(

0 commit comments

Comments
 (0)