From feac39d88a841ff710d672b622f3280037589f66 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 04:41:09 +0000 Subject: [PATCH 01/13] fix(client): preserve hardcoded query params when merging with user params --- src/stagehand/_base_client.py | 4 +++ tests/test_client.py | 48 +++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/stagehand/_base_client.py b/src/stagehand/_base_client.py index 951feb82..9af0033b 100644 --- a/src/stagehand/_base_client.py +++ b/src/stagehand/_base_client.py @@ -540,6 +540,10 @@ def _build_request( files = cast(HttpxRequestFiles, ForceMultipartDict()) prepared_url = self._prepare_url(options.url) + # preserve hard-coded query params from the url + if params and prepared_url.query: + params = {**dict(prepared_url.params.items()), **params} + prepared_url = prepared_url.copy_with(raw_path=prepared_url.raw_path.split(b"?", 1)[0]) if "_" in prepared_url.host: # work around https://github.com/encode/httpx/discussions/2880 kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} diff --git a/tests/test_client.py b/tests/test_client.py index e962cdba..37ca5a76 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -505,6 +505,30 @@ def test_default_query_option(self) -> None: client.close() + def test_hardcoded_query_params_in_url(self, client: Stagehand) -> None: + request = client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + def test_request_extra_json(self, client: Stagehand) -> None: request = client._build_request( FinalRequestOptions( @@ -1552,6 +1576,30 @@ async def test_default_query_option(self) -> None: await client.close() + async def test_hardcoded_query_params_in_url(self, async_client: AsyncStagehand) -> None: + request = async_client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true")) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/foo?beta=true", + params={"limit": "10", "page": "abc"}, + ) + ) + url = httpx.URL(request.url) + assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"} + + request = async_client._build_request( + FinalRequestOptions( + method="get", + url="/files/a%2Fb?beta=true", + params={"limit": "10"}, + ) + ) + assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10" + def test_request_extra_json(self, client: Stagehand) -> None: request = client._build_request( FinalRequestOptions( From 9463fa49cb839abbb2c6a1adb0d053e5006216a7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 18:22:47 +0000 Subject: [PATCH 02/13] feat: Bedrock auth passthrough --- .stats.yml | 4 +- src/stagehand/resources/sessions.py | 10 + src/stagehand/types/model_config_param.py | 101 ++++++++- src/stagehand/types/session_execute_params.py | 6 - src/stagehand/types/session_start_params.py | 193 ++++++++++++++++++ src/stagehand/types/stream_event.py | 2 +- tests/api_resources/test_sessions.py | 142 ++++++++----- 7 files changed, 397 insertions(+), 61 deletions(-) diff --git a/.stats.yml b/.stats.yml index ade26eb1..f1d6429f 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-b969ce378479c79ee64c05127c0ed6c6ce2edbee017ecd037242fb618a5ebc9f.yml -openapi_spec_hash: a24aabaa5214effb679808b7f2be0ad4 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-7773ef4ca29c983daafb787ee918cfa6b5b12c5bbdc088308653f2737c26e51f.yml +openapi_spec_hash: 47fc8f2540be0b6374e4230c021072d9 config_hash: 0cc516caf1432087f40654336e0fa8cd diff --git a/src/stagehand/resources/sessions.py b/src/stagehand/resources/sessions.py index a3c3c4bd..d24424c6 100644 --- a/src/stagehand/resources/sessions.py +++ b/src/stagehand/resources/sessions.py @@ -927,6 +927,7 @@ def start( browserbase_session_id: str | Omit = omit, dom_settle_timeout_ms: float | Omit = omit, experimental: bool | Omit = omit, + model_client_options: session_start_params.ModelClientOptions | Omit = omit, self_heal: bool | Omit = omit, system_prompt: str | Omit = omit, verbose: Literal[0, 1, 2] | Omit = omit, @@ -957,6 +958,9 @@ def start( dom_settle_timeout_ms: Timeout in ms to wait for DOM to settle + model_client_options: Optional provider-specific configuration for the session model (for example + Bedrock region and credentials) + self_heal: Enable self-healing for failed actions system_prompt: Custom system prompt for AI operations @@ -997,6 +1001,7 @@ def start( "browserbase_session_id": browserbase_session_id, "dom_settle_timeout_ms": dom_settle_timeout_ms, "experimental": experimental, + "model_client_options": model_client_options, "self_heal": self_heal, "system_prompt": system_prompt, "verbose": verbose, @@ -1890,6 +1895,7 @@ async def start( browserbase_session_id: str | Omit = omit, dom_settle_timeout_ms: float | Omit = omit, experimental: bool | Omit = omit, + model_client_options: session_start_params.ModelClientOptions | Omit = omit, self_heal: bool | Omit = omit, system_prompt: str | Omit = omit, verbose: Literal[0, 1, 2] | Omit = omit, @@ -1920,6 +1926,9 @@ async def start( dom_settle_timeout_ms: Timeout in ms to wait for DOM to settle + model_client_options: Optional provider-specific configuration for the session model (for example + Bedrock region and credentials) + self_heal: Enable self-healing for failed actions system_prompt: Custom system prompt for AI operations @@ -1960,6 +1969,7 @@ async def start( "browserbase_session_id": browserbase_session_id, "dom_settle_timeout_ms": dom_settle_timeout_ms, "experimental": experimental, + "model_client_options": model_client_options, "self_heal": self_heal, "system_prompt": system_prompt, "verbose": verbose, diff --git a/src/stagehand/types/model_config_param.py b/src/stagehand/types/model_config_param.py index 3ed433f7..a725892c 100644 --- a/src/stagehand/types/model_config_param.py +++ b/src/stagehand/types/model_config_param.py @@ -2,12 +2,92 @@ from __future__ import annotations -from typing import Dict -from typing_extensions import Literal, Required, Annotated, TypedDict +from typing import Dict, Union +from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict from .._utils import PropertyInfo -__all__ = ["ModelConfigParam"] +__all__ = [ + "ModelConfigParam", + "ProviderOptions", + "ProviderOptionsBedrockAPIKeyProviderOptions", + "ProviderOptionsBedrockAwsCredentialsProviderOptions", + "ProviderOptionsGoogleVertexProviderOptions", + "ProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptions", + "ProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptionsCredentials", +] + + +class ProviderOptionsBedrockAPIKeyProviderOptions(TypedDict, total=False): + region: Required[str] + """AWS region for Amazon Bedrock""" + + +class ProviderOptionsBedrockAwsCredentialsProviderOptions(TypedDict, total=False): + access_key_id: Required[Annotated[str, PropertyInfo(alias="accessKeyId")]] + """AWS access key ID for Bedrock""" + + region: Required[str] + """AWS region for Amazon Bedrock""" + + secret_access_key: Required[Annotated[str, PropertyInfo(alias="secretAccessKey")]] + """AWS secret access key for Bedrock""" + + session_token: Annotated[str, PropertyInfo(alias="sessionToken")] + """Optional AWS session token for temporary credentials""" + + +class ProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptionsCredentials(TypedDict, total=False): + auth_provider_x509_cert_url: str + + auth_uri: str + + client_email: str + + client_id: str + + client_x509_cert_url: str + + private_key: str + + private_key_id: str + + project_id: str + + token_uri: str + + type: str + + universe_domain: str + + +class ProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptions(TypedDict, total=False): + """Optional Google auth options for Vertex AI""" + + credentials: ProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptionsCredentials + + +class ProviderOptionsGoogleVertexProviderOptions(TypedDict, total=False): + google_auth_options: Annotated[ + ProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptions, PropertyInfo(alias="googleAuthOptions") + ] + """Optional Google auth options for Vertex AI""" + + headers: Dict[str, str] + """Custom headers for Vertex AI requests""" + + location: str + """Google Cloud location for Vertex AI""" + + project: str + """Google Cloud project ID for Vertex AI""" + + +ProviderOptions: TypeAlias = Union[ + ProviderOptionsBedrockAPIKeyProviderOptions, + ProviderOptionsBedrockAwsCredentialsProviderOptions, + ProviderOptionsGoogleVertexProviderOptions, +] class ModelConfigParam(TypedDict, total=False): @@ -21,7 +101,20 @@ class ModelConfigParam(TypedDict, total=False): """Base URL for the model provider""" headers: Dict[str, str] - """Custom headers sent with every request to the model provider""" + """Custom headers for the model provider""" provider: Literal["openai", "anthropic", "google", "microsoft", "bedrock"] """AI provider for the model (or provide a baseURL endpoint instead)""" + + provider_options: Annotated[ProviderOptions, PropertyInfo(alias="providerOptions")] + """Provider-specific options passed through to the AI SDK provider constructor. + + For Bedrock: { region, accessKeyId, secretAccessKey, sessionToken }. For Vertex: + { project, location, googleAuthOptions }. + """ + + skip_api_key_fallback: Annotated[bool, PropertyInfo(alias="skipApiKeyFallback")] + """When true, hosted sessions will not copy x-model-api-key into model.apiKey. + + Use this when auth is carried through providerOptions instead of an API key. + """ diff --git a/src/stagehand/types/session_execute_params.py b/src/stagehand/types/session_execute_params.py index 944c3856..4b64c0b2 100644 --- a/src/stagehand/types/session_execute_params.py +++ b/src/stagehand/types/session_execute_params.py @@ -76,12 +76,6 @@ class ExecuteOptions(TypedDict, total=False): max_steps: Annotated[float, PropertyInfo(alias="maxSteps")] """Maximum number of steps the agent can take""" - tool_timeout: Annotated[float, PropertyInfo(alias="toolTimeout")] - """Timeout in milliseconds for each agent tool call""" - - use_search: Annotated[bool, PropertyInfo(alias="useSearch")] - """Whether to enable the web search tool powered by Browserbase Search API""" - class SessionExecuteParamsNonStreaming(SessionExecuteParamsBase, total=False): stream_response: Annotated[Literal[False], PropertyInfo(alias="streamResponse")] diff --git a/src/stagehand/types/session_start_params.py b/src/stagehand/types/session_start_params.py index 17f55997..3eeb7fca 100644 --- a/src/stagehand/types/session_start_params.py +++ b/src/stagehand/types/session_start_params.py @@ -24,6 +24,18 @@ "BrowserbaseSessionCreateParamsProxiesProxyConfigListBrowserbaseProxyConfig", "BrowserbaseSessionCreateParamsProxiesProxyConfigListBrowserbaseProxyConfigGeolocation", "BrowserbaseSessionCreateParamsProxiesProxyConfigListExternalProxyConfig", + "ModelClientOptions", + "ModelClientOptionsBedrockAPIKeyModelClientOptions", + "ModelClientOptionsBedrockAPIKeyModelClientOptionsProviderOptions", + "ModelClientOptionsBedrockAwsCredentialsModelClientOptions", + "ModelClientOptionsBedrockAwsCredentialsModelClientOptionsProviderOptions", + "ModelClientOptionsGenericModelClientOptions", + "ModelClientOptionsGenericModelClientOptionsProviderOptions", + "ModelClientOptionsGenericModelClientOptionsProviderOptionsBedrockAPIKeyProviderOptions", + "ModelClientOptionsGenericModelClientOptionsProviderOptionsBedrockAwsCredentialsProviderOptions", + "ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptions", + "ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptions", + "ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptionsCredentials", ] @@ -48,6 +60,12 @@ class SessionStartParams(TypedDict, total=False): experimental: bool + model_client_options: Annotated[ModelClientOptions, PropertyInfo(alias="modelClientOptions")] + """ + Optional provider-specific configuration for the session model (for example + Bedrock region and credentials) + """ + self_heal: Annotated[bool, PropertyInfo(alias="selfHeal")] """Enable self-healing for failed actions""" @@ -240,3 +258,178 @@ class BrowserbaseSessionCreateParams(TypedDict, total=False): timeout: float user_metadata: Annotated[Dict[str, object], PropertyInfo(alias="userMetadata")] + + +class ModelClientOptionsBedrockAPIKeyModelClientOptionsProviderOptions(TypedDict, total=False): + region: Required[str] + """AWS region for Amazon Bedrock""" + + +class ModelClientOptionsBedrockAPIKeyModelClientOptions(TypedDict, total=False): + api_key: Required[Annotated[str, PropertyInfo(alias="apiKey")]] + """Short-term Bedrock API key for bearer-token auth""" + + provider_options: Required[ + Annotated[ + ModelClientOptionsBedrockAPIKeyModelClientOptionsProviderOptions, PropertyInfo(alias="providerOptions") + ] + ] + + base_url: Annotated[str, PropertyInfo(alias="baseURL")] + """Base URL for the model provider""" + + headers: Dict[str, str] + """Custom headers for the model provider""" + + skip_api_key_fallback: Annotated[bool, PropertyInfo(alias="skipApiKeyFallback")] + """When true, hosted sessions will not copy x-model-api-key into model.apiKey. + + Use this when auth is carried through providerOptions instead of an API key. + """ + + +class ModelClientOptionsBedrockAwsCredentialsModelClientOptionsProviderOptions(TypedDict, total=False): + access_key_id: Required[Annotated[str, PropertyInfo(alias="accessKeyId")]] + """AWS access key ID for Bedrock""" + + region: Required[str] + """AWS region for Amazon Bedrock""" + + secret_access_key: Required[Annotated[str, PropertyInfo(alias="secretAccessKey")]] + """AWS secret access key for Bedrock""" + + session_token: Annotated[str, PropertyInfo(alias="sessionToken")] + """Optional AWS session token for temporary credentials""" + + +class ModelClientOptionsBedrockAwsCredentialsModelClientOptions(TypedDict, total=False): + provider_options: Required[ + Annotated[ + ModelClientOptionsBedrockAwsCredentialsModelClientOptionsProviderOptions, + PropertyInfo(alias="providerOptions"), + ] + ] + + base_url: Annotated[str, PropertyInfo(alias="baseURL")] + """Base URL for the model provider""" + + headers: Dict[str, str] + """Custom headers for the model provider""" + + skip_api_key_fallback: Annotated[bool, PropertyInfo(alias="skipApiKeyFallback")] + """When true, hosted sessions will not copy x-model-api-key into model.apiKey. + + Use this when auth is carried through providerOptions instead of an API key. + """ + + +class ModelClientOptionsGenericModelClientOptionsProviderOptionsBedrockAPIKeyProviderOptions(TypedDict, total=False): + region: Required[str] + """AWS region for Amazon Bedrock""" + + +class ModelClientOptionsGenericModelClientOptionsProviderOptionsBedrockAwsCredentialsProviderOptions( + TypedDict, total=False +): + access_key_id: Required[Annotated[str, PropertyInfo(alias="accessKeyId")]] + """AWS access key ID for Bedrock""" + + region: Required[str] + """AWS region for Amazon Bedrock""" + + secret_access_key: Required[Annotated[str, PropertyInfo(alias="secretAccessKey")]] + """AWS secret access key for Bedrock""" + + session_token: Annotated[str, PropertyInfo(alias="sessionToken")] + """Optional AWS session token for temporary credentials""" + + +class ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptionsCredentials( + TypedDict, total=False +): + auth_provider_x509_cert_url: str + + auth_uri: str + + client_email: str + + client_id: str + + client_x509_cert_url: str + + private_key: str + + private_key_id: str + + project_id: str + + token_uri: str + + type: str + + universe_domain: str + + +class ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptions( + TypedDict, total=False +): + """Optional Google auth options for Vertex AI""" + + credentials: ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptionsCredentials + + +class ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptions(TypedDict, total=False): + google_auth_options: Annotated[ + ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptions, + PropertyInfo(alias="googleAuthOptions"), + ] + """Optional Google auth options for Vertex AI""" + + headers: Dict[str, str] + """Custom headers for Vertex AI requests""" + + location: str + """Google Cloud location for Vertex AI""" + + project: str + """Google Cloud project ID for Vertex AI""" + + +ModelClientOptionsGenericModelClientOptionsProviderOptions: TypeAlias = Union[ + ModelClientOptionsGenericModelClientOptionsProviderOptionsBedrockAPIKeyProviderOptions, + ModelClientOptionsGenericModelClientOptionsProviderOptionsBedrockAwsCredentialsProviderOptions, + ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptions, +] + + +class ModelClientOptionsGenericModelClientOptions(TypedDict, total=False): + api_key: Annotated[str, PropertyInfo(alias="apiKey")] + """API key for the model provider""" + + base_url: Annotated[str, PropertyInfo(alias="baseURL")] + """Base URL for the model provider""" + + headers: Dict[str, str] + """Custom headers for the model provider""" + + provider_options: Annotated[ + ModelClientOptionsGenericModelClientOptionsProviderOptions, PropertyInfo(alias="providerOptions") + ] + """Provider-specific options passed through to the AI SDK provider constructor. + + For Bedrock: { region, accessKeyId, secretAccessKey, sessionToken }. For Vertex: + { project, location, googleAuthOptions }. + """ + + skip_api_key_fallback: Annotated[bool, PropertyInfo(alias="skipApiKeyFallback")] + """When true, hosted sessions will not copy x-model-api-key into model.apiKey. + + Use this when auth is carried through providerOptions instead of an API key. + """ + + +ModelClientOptions: TypeAlias = Union[ + ModelClientOptionsBedrockAPIKeyModelClientOptions, + ModelClientOptionsBedrockAwsCredentialsModelClientOptions, + ModelClientOptionsGenericModelClientOptions, +] diff --git a/src/stagehand/types/stream_event.py b/src/stagehand/types/stream_event.py index 6fd47585..3d5b21ee 100644 --- a/src/stagehand/types/stream_event.py +++ b/src/stagehand/types/stream_event.py @@ -32,7 +32,7 @@ class DataStreamEventLogDataOutput(BaseModel): class StreamEvent(BaseModel): """Server-Sent Event emitted during streaming responses. - Events are sent as `event: \ndata: \n\n`, where the JSON payload has the shape `{ data, type, id }`. + Events are sent as `data: \n\n`. Key order: data (with status first), type, id. """ id: str diff --git a/tests/api_resources/test_sessions.py b/tests/api_resources/test_sessions.py index 8dfddbe2..f6058ceb 100644 --- a/tests/api_resources/test_sessions.py +++ b/tests/api_resources/test_sessions.py @@ -44,11 +44,13 @@ def test_method_act_with_all_params_overload_1(self, client: Stagehand) -> None: frame_id="frameId", options={ "model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "timeout": 30000, "variables": { @@ -121,11 +123,13 @@ def test_method_act_with_all_params_overload_2(self, client: Stagehand) -> None: frame_id="frameId", options={ "model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "timeout": 30000, "variables": { @@ -250,19 +254,23 @@ def test_method_execute_with_all_params_overload_1(self, client: Stagehand) -> N agent_config={ "cua": True, "execution_model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "mode": "cua", "model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "provider": "openai", "system_prompt": "systemPrompt", @@ -271,8 +279,6 @@ def test_method_execute_with_all_params_overload_1(self, client: Stagehand) -> N "instruction": "Log in with username 'demo' and password 'test123', then navigate to settings", "highlight_cursor": True, "max_steps": 20, - "tool_timeout": 30000, - "use_search": True, }, frame_id="frameId", should_cache=True, @@ -348,19 +354,23 @@ def test_method_execute_with_all_params_overload_2(self, client: Stagehand) -> N agent_config={ "cua": True, "execution_model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "mode": "cua", "model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "provider": "openai", "system_prompt": "systemPrompt", @@ -369,8 +379,6 @@ def test_method_execute_with_all_params_overload_2(self, client: Stagehand) -> N "instruction": "Log in with username 'demo' and password 'test123', then navigate to settings", "highlight_cursor": True, "max_steps": 20, - "tool_timeout": 30000, - "use_search": True, }, stream_response=True, frame_id="frameId", @@ -444,11 +452,13 @@ def test_method_extract_with_all_params_overload_1(self, client: Stagehand) -> N instruction="Extract all product names and prices from the page", options={ "model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "selector": "#main-content", "timeout": 30000, @@ -512,11 +522,13 @@ def test_method_extract_with_all_params_overload_2(self, client: Stagehand) -> N instruction="Extract all product names and prices from the page", options={ "model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "selector": "#main-content", "timeout": 30000, @@ -642,11 +654,13 @@ def test_method_observe_with_all_params_overload_1(self, client: Stagehand) -> N instruction="Find all clickable navigation links", options={ "model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "selector": "nav", "timeout": 30000, @@ -716,11 +730,13 @@ def test_method_observe_with_all_params_overload_2(self, client: Stagehand) -> N instruction="Find all clickable navigation links", options={ "model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "selector": "nav", "timeout": 30000, @@ -912,6 +928,13 @@ def test_method_start_with_all_params(self, client: Stagehand) -> None: browserbase_session_id="browserbaseSessionID", dom_settle_timeout_ms=5000, experimental=True, + model_client_options={ + "api_key": "bedrock-short-term-api-key", + "provider_options": {"region": "us-east-1"}, + "base_url": "https://api.openai.com/v1", + "headers": {"X-Custom-Header": "value"}, + "skip_api_key_fallback": True, + }, self_heal=True, system_prompt="systemPrompt", verbose=1, @@ -970,11 +993,13 @@ async def test_method_act_with_all_params_overload_1(self, async_client: AsyncSt frame_id="frameId", options={ "model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "timeout": 30000, "variables": { @@ -1047,11 +1072,13 @@ async def test_method_act_with_all_params_overload_2(self, async_client: AsyncSt frame_id="frameId", options={ "model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "timeout": 30000, "variables": { @@ -1176,19 +1203,23 @@ async def test_method_execute_with_all_params_overload_1(self, async_client: Asy agent_config={ "cua": True, "execution_model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "mode": "cua", "model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "provider": "openai", "system_prompt": "systemPrompt", @@ -1197,8 +1228,6 @@ async def test_method_execute_with_all_params_overload_1(self, async_client: Asy "instruction": "Log in with username 'demo' and password 'test123', then navigate to settings", "highlight_cursor": True, "max_steps": 20, - "tool_timeout": 30000, - "use_search": True, }, frame_id="frameId", should_cache=True, @@ -1274,19 +1303,23 @@ async def test_method_execute_with_all_params_overload_2(self, async_client: Asy agent_config={ "cua": True, "execution_model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "mode": "cua", "model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "provider": "openai", "system_prompt": "systemPrompt", @@ -1295,8 +1328,6 @@ async def test_method_execute_with_all_params_overload_2(self, async_client: Asy "instruction": "Log in with username 'demo' and password 'test123', then navigate to settings", "highlight_cursor": True, "max_steps": 20, - "tool_timeout": 30000, - "use_search": True, }, stream_response=True, frame_id="frameId", @@ -1370,11 +1401,13 @@ async def test_method_extract_with_all_params_overload_1(self, async_client: Asy instruction="Extract all product names and prices from the page", options={ "model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "selector": "#main-content", "timeout": 30000, @@ -1438,11 +1471,13 @@ async def test_method_extract_with_all_params_overload_2(self, async_client: Asy instruction="Extract all product names and prices from the page", options={ "model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "selector": "#main-content", "timeout": 30000, @@ -1568,11 +1603,13 @@ async def test_method_observe_with_all_params_overload_1(self, async_client: Asy instruction="Find all clickable navigation links", options={ "model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "selector": "nav", "timeout": 30000, @@ -1642,11 +1679,13 @@ async def test_method_observe_with_all_params_overload_2(self, async_client: Asy instruction="Find all clickable navigation links", options={ "model": { - "model_name": "openai/gpt-5.4-mini", + "model_name": "openai/gpt-5-nano", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"foo": "string"}, + "headers": {"X-Custom-Header": "value"}, "provider": "openai", + "provider_options": {"region": "us-east-1"}, + "skip_api_key_fallback": True, }, "selector": "nav", "timeout": 30000, @@ -1838,6 +1877,13 @@ async def test_method_start_with_all_params(self, async_client: AsyncStagehand) browserbase_session_id="browserbaseSessionID", dom_settle_timeout_ms=5000, experimental=True, + model_client_options={ + "api_key": "bedrock-short-term-api-key", + "provider_options": {"region": "us-east-1"}, + "base_url": "https://api.openai.com/v1", + "headers": {"X-Custom-Header": "value"}, + "skip_api_key_fallback": True, + }, self_heal=True, system_prompt="systemPrompt", verbose=1, From 078ab5c76f13beee16e44d7eed5e3018f1cc5bd8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 23:32:51 +0000 Subject: [PATCH 03/13] feat: [STG-1798] feat: support Browserbase verified sessions --- .stats.yml | 6 +++--- src/stagehand/types/session_start_params.py | 8 ++++++++ tests/api_resources/test_sessions.py | 8 ++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.stats.yml b/.stats.yml index f1d6429f..6bde59e1 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-7773ef4ca29c983daafb787ee918cfa6b5b12c5bbdc088308653f2737c26e51f.yml -openapi_spec_hash: 47fc8f2540be0b6374e4230c021072d9 -config_hash: 0cc516caf1432087f40654336e0fa8cd +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-a8db51c6460b3daff67b35262517848a0d4e783c6805c2edd531b155a5db71dd.yml +openapi_spec_hash: c6e7127f211f946673d6389e1d8db1ba +config_hash: a962ae71493deb11a1c903256fb25386 diff --git a/src/stagehand/types/session_start_params.py b/src/stagehand/types/session_start_params.py index 3eeb7fca..7c66c693 100644 --- a/src/stagehand/types/session_start_params.py +++ b/src/stagehand/types/session_start_params.py @@ -193,6 +193,10 @@ class BrowserbaseSessionCreateParamsBrowserSettings(TypedDict, total=False): block_ads: Annotated[bool, PropertyInfo(alias="blockAds")] + captcha_image_selector: Annotated[str, PropertyInfo(alias="captchaImageSelector")] + + captcha_input_selector: Annotated[str, PropertyInfo(alias="captchaInputSelector")] + context: BrowserbaseSessionCreateParamsBrowserSettingsContext extension_id: Annotated[str, PropertyInfo(alias="extensionId")] @@ -201,10 +205,14 @@ class BrowserbaseSessionCreateParamsBrowserSettings(TypedDict, total=False): log_session: Annotated[bool, PropertyInfo(alias="logSession")] + os: Literal["windows", "mac", "linux", "mobile", "tablet"] + record_session: Annotated[bool, PropertyInfo(alias="recordSession")] solve_captchas: Annotated[bool, PropertyInfo(alias="solveCaptchas")] + verified: bool + viewport: BrowserbaseSessionCreateParamsBrowserSettingsViewport diff --git a/tests/api_resources/test_sessions.py b/tests/api_resources/test_sessions.py index f6058ceb..8fbfcead 100644 --- a/tests/api_resources/test_sessions.py +++ b/tests/api_resources/test_sessions.py @@ -891,6 +891,8 @@ def test_method_start_with_all_params(self, client: Stagehand) -> None: "browser_settings": { "advanced_stealth": True, "block_ads": True, + "captcha_image_selector": "captchaImageSelector", + "captcha_input_selector": "captchaInputSelector", "context": { "id": "id", "persist": True, @@ -910,8 +912,10 @@ def test_method_start_with_all_params(self, client: Stagehand) -> None: }, }, "log_session": True, + "os": "windows", "record_session": True, "solve_captchas": True, + "verified": True, "viewport": { "height": 0, "width": 0, @@ -1840,6 +1844,8 @@ async def test_method_start_with_all_params(self, async_client: AsyncStagehand) "browser_settings": { "advanced_stealth": True, "block_ads": True, + "captcha_image_selector": "captchaImageSelector", + "captcha_input_selector": "captchaInputSelector", "context": { "id": "id", "persist": True, @@ -1859,8 +1865,10 @@ async def test_method_start_with_all_params(self, async_client: AsyncStagehand) }, }, "log_session": True, + "os": "windows", "record_session": True, "solve_captchas": True, + "verified": True, "viewport": { "height": 0, "width": 0, From 3c0408675154c9f7d241c4e92e9cb82f0419d6b3 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:32:13 +0000 Subject: [PATCH 04/13] feat: Revert "[STG-1573] Add providerOptions for extensible model auth (#1822)" --- .stats.yml | 4 +- src/stagehand/resources/sessions.py | 10 - src/stagehand/types/model_config_param.py | 101 +-------- src/stagehand/types/session_execute_params.py | 6 + src/stagehand/types/session_start_params.py | 193 ------------------ src/stagehand/types/stream_event.py | 2 +- tests/api_resources/test_sessions.py | 142 +++++-------- 7 files changed, 61 insertions(+), 397 deletions(-) diff --git a/.stats.yml b/.stats.yml index 6bde59e1..d8428d42 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-a8db51c6460b3daff67b35262517848a0d4e783c6805c2edd531b155a5db71dd.yml -openapi_spec_hash: c6e7127f211f946673d6389e1d8db1ba +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-1c6caa2891a7f3bdfc0caab143f285badc9145220c9b29cd5e4cf1a9b3ac11cf.yml +openapi_spec_hash: 28c4b734a5309067c39bb4c4b709b9ab config_hash: a962ae71493deb11a1c903256fb25386 diff --git a/src/stagehand/resources/sessions.py b/src/stagehand/resources/sessions.py index d24424c6..a3c3c4bd 100644 --- a/src/stagehand/resources/sessions.py +++ b/src/stagehand/resources/sessions.py @@ -927,7 +927,6 @@ def start( browserbase_session_id: str | Omit = omit, dom_settle_timeout_ms: float | Omit = omit, experimental: bool | Omit = omit, - model_client_options: session_start_params.ModelClientOptions | Omit = omit, self_heal: bool | Omit = omit, system_prompt: str | Omit = omit, verbose: Literal[0, 1, 2] | Omit = omit, @@ -958,9 +957,6 @@ def start( dom_settle_timeout_ms: Timeout in ms to wait for DOM to settle - model_client_options: Optional provider-specific configuration for the session model (for example - Bedrock region and credentials) - self_heal: Enable self-healing for failed actions system_prompt: Custom system prompt for AI operations @@ -1001,7 +997,6 @@ def start( "browserbase_session_id": browserbase_session_id, "dom_settle_timeout_ms": dom_settle_timeout_ms, "experimental": experimental, - "model_client_options": model_client_options, "self_heal": self_heal, "system_prompt": system_prompt, "verbose": verbose, @@ -1895,7 +1890,6 @@ async def start( browserbase_session_id: str | Omit = omit, dom_settle_timeout_ms: float | Omit = omit, experimental: bool | Omit = omit, - model_client_options: session_start_params.ModelClientOptions | Omit = omit, self_heal: bool | Omit = omit, system_prompt: str | Omit = omit, verbose: Literal[0, 1, 2] | Omit = omit, @@ -1926,9 +1920,6 @@ async def start( dom_settle_timeout_ms: Timeout in ms to wait for DOM to settle - model_client_options: Optional provider-specific configuration for the session model (for example - Bedrock region and credentials) - self_heal: Enable self-healing for failed actions system_prompt: Custom system prompt for AI operations @@ -1969,7 +1960,6 @@ async def start( "browserbase_session_id": browserbase_session_id, "dom_settle_timeout_ms": dom_settle_timeout_ms, "experimental": experimental, - "model_client_options": model_client_options, "self_heal": self_heal, "system_prompt": system_prompt, "verbose": verbose, diff --git a/src/stagehand/types/model_config_param.py b/src/stagehand/types/model_config_param.py index a725892c..3ed433f7 100644 --- a/src/stagehand/types/model_config_param.py +++ b/src/stagehand/types/model_config_param.py @@ -2,92 +2,12 @@ from __future__ import annotations -from typing import Dict, Union -from typing_extensions import Literal, Required, Annotated, TypeAlias, TypedDict +from typing import Dict +from typing_extensions import Literal, Required, Annotated, TypedDict from .._utils import PropertyInfo -__all__ = [ - "ModelConfigParam", - "ProviderOptions", - "ProviderOptionsBedrockAPIKeyProviderOptions", - "ProviderOptionsBedrockAwsCredentialsProviderOptions", - "ProviderOptionsGoogleVertexProviderOptions", - "ProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptions", - "ProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptionsCredentials", -] - - -class ProviderOptionsBedrockAPIKeyProviderOptions(TypedDict, total=False): - region: Required[str] - """AWS region for Amazon Bedrock""" - - -class ProviderOptionsBedrockAwsCredentialsProviderOptions(TypedDict, total=False): - access_key_id: Required[Annotated[str, PropertyInfo(alias="accessKeyId")]] - """AWS access key ID for Bedrock""" - - region: Required[str] - """AWS region for Amazon Bedrock""" - - secret_access_key: Required[Annotated[str, PropertyInfo(alias="secretAccessKey")]] - """AWS secret access key for Bedrock""" - - session_token: Annotated[str, PropertyInfo(alias="sessionToken")] - """Optional AWS session token for temporary credentials""" - - -class ProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptionsCredentials(TypedDict, total=False): - auth_provider_x509_cert_url: str - - auth_uri: str - - client_email: str - - client_id: str - - client_x509_cert_url: str - - private_key: str - - private_key_id: str - - project_id: str - - token_uri: str - - type: str - - universe_domain: str - - -class ProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptions(TypedDict, total=False): - """Optional Google auth options for Vertex AI""" - - credentials: ProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptionsCredentials - - -class ProviderOptionsGoogleVertexProviderOptions(TypedDict, total=False): - google_auth_options: Annotated[ - ProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptions, PropertyInfo(alias="googleAuthOptions") - ] - """Optional Google auth options for Vertex AI""" - - headers: Dict[str, str] - """Custom headers for Vertex AI requests""" - - location: str - """Google Cloud location for Vertex AI""" - - project: str - """Google Cloud project ID for Vertex AI""" - - -ProviderOptions: TypeAlias = Union[ - ProviderOptionsBedrockAPIKeyProviderOptions, - ProviderOptionsBedrockAwsCredentialsProviderOptions, - ProviderOptionsGoogleVertexProviderOptions, -] +__all__ = ["ModelConfigParam"] class ModelConfigParam(TypedDict, total=False): @@ -101,20 +21,7 @@ class ModelConfigParam(TypedDict, total=False): """Base URL for the model provider""" headers: Dict[str, str] - """Custom headers for the model provider""" + """Custom headers sent with every request to the model provider""" provider: Literal["openai", "anthropic", "google", "microsoft", "bedrock"] """AI provider for the model (or provide a baseURL endpoint instead)""" - - provider_options: Annotated[ProviderOptions, PropertyInfo(alias="providerOptions")] - """Provider-specific options passed through to the AI SDK provider constructor. - - For Bedrock: { region, accessKeyId, secretAccessKey, sessionToken }. For Vertex: - { project, location, googleAuthOptions }. - """ - - skip_api_key_fallback: Annotated[bool, PropertyInfo(alias="skipApiKeyFallback")] - """When true, hosted sessions will not copy x-model-api-key into model.apiKey. - - Use this when auth is carried through providerOptions instead of an API key. - """ diff --git a/src/stagehand/types/session_execute_params.py b/src/stagehand/types/session_execute_params.py index 4b64c0b2..944c3856 100644 --- a/src/stagehand/types/session_execute_params.py +++ b/src/stagehand/types/session_execute_params.py @@ -76,6 +76,12 @@ class ExecuteOptions(TypedDict, total=False): max_steps: Annotated[float, PropertyInfo(alias="maxSteps")] """Maximum number of steps the agent can take""" + tool_timeout: Annotated[float, PropertyInfo(alias="toolTimeout")] + """Timeout in milliseconds for each agent tool call""" + + use_search: Annotated[bool, PropertyInfo(alias="useSearch")] + """Whether to enable the web search tool powered by Browserbase Search API""" + class SessionExecuteParamsNonStreaming(SessionExecuteParamsBase, total=False): stream_response: Annotated[Literal[False], PropertyInfo(alias="streamResponse")] diff --git a/src/stagehand/types/session_start_params.py b/src/stagehand/types/session_start_params.py index 7c66c693..5123c507 100644 --- a/src/stagehand/types/session_start_params.py +++ b/src/stagehand/types/session_start_params.py @@ -24,18 +24,6 @@ "BrowserbaseSessionCreateParamsProxiesProxyConfigListBrowserbaseProxyConfig", "BrowserbaseSessionCreateParamsProxiesProxyConfigListBrowserbaseProxyConfigGeolocation", "BrowserbaseSessionCreateParamsProxiesProxyConfigListExternalProxyConfig", - "ModelClientOptions", - "ModelClientOptionsBedrockAPIKeyModelClientOptions", - "ModelClientOptionsBedrockAPIKeyModelClientOptionsProviderOptions", - "ModelClientOptionsBedrockAwsCredentialsModelClientOptions", - "ModelClientOptionsBedrockAwsCredentialsModelClientOptionsProviderOptions", - "ModelClientOptionsGenericModelClientOptions", - "ModelClientOptionsGenericModelClientOptionsProviderOptions", - "ModelClientOptionsGenericModelClientOptionsProviderOptionsBedrockAPIKeyProviderOptions", - "ModelClientOptionsGenericModelClientOptionsProviderOptionsBedrockAwsCredentialsProviderOptions", - "ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptions", - "ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptions", - "ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptionsCredentials", ] @@ -60,12 +48,6 @@ class SessionStartParams(TypedDict, total=False): experimental: bool - model_client_options: Annotated[ModelClientOptions, PropertyInfo(alias="modelClientOptions")] - """ - Optional provider-specific configuration for the session model (for example - Bedrock region and credentials) - """ - self_heal: Annotated[bool, PropertyInfo(alias="selfHeal")] """Enable self-healing for failed actions""" @@ -266,178 +248,3 @@ class BrowserbaseSessionCreateParams(TypedDict, total=False): timeout: float user_metadata: Annotated[Dict[str, object], PropertyInfo(alias="userMetadata")] - - -class ModelClientOptionsBedrockAPIKeyModelClientOptionsProviderOptions(TypedDict, total=False): - region: Required[str] - """AWS region for Amazon Bedrock""" - - -class ModelClientOptionsBedrockAPIKeyModelClientOptions(TypedDict, total=False): - api_key: Required[Annotated[str, PropertyInfo(alias="apiKey")]] - """Short-term Bedrock API key for bearer-token auth""" - - provider_options: Required[ - Annotated[ - ModelClientOptionsBedrockAPIKeyModelClientOptionsProviderOptions, PropertyInfo(alias="providerOptions") - ] - ] - - base_url: Annotated[str, PropertyInfo(alias="baseURL")] - """Base URL for the model provider""" - - headers: Dict[str, str] - """Custom headers for the model provider""" - - skip_api_key_fallback: Annotated[bool, PropertyInfo(alias="skipApiKeyFallback")] - """When true, hosted sessions will not copy x-model-api-key into model.apiKey. - - Use this when auth is carried through providerOptions instead of an API key. - """ - - -class ModelClientOptionsBedrockAwsCredentialsModelClientOptionsProviderOptions(TypedDict, total=False): - access_key_id: Required[Annotated[str, PropertyInfo(alias="accessKeyId")]] - """AWS access key ID for Bedrock""" - - region: Required[str] - """AWS region for Amazon Bedrock""" - - secret_access_key: Required[Annotated[str, PropertyInfo(alias="secretAccessKey")]] - """AWS secret access key for Bedrock""" - - session_token: Annotated[str, PropertyInfo(alias="sessionToken")] - """Optional AWS session token for temporary credentials""" - - -class ModelClientOptionsBedrockAwsCredentialsModelClientOptions(TypedDict, total=False): - provider_options: Required[ - Annotated[ - ModelClientOptionsBedrockAwsCredentialsModelClientOptionsProviderOptions, - PropertyInfo(alias="providerOptions"), - ] - ] - - base_url: Annotated[str, PropertyInfo(alias="baseURL")] - """Base URL for the model provider""" - - headers: Dict[str, str] - """Custom headers for the model provider""" - - skip_api_key_fallback: Annotated[bool, PropertyInfo(alias="skipApiKeyFallback")] - """When true, hosted sessions will not copy x-model-api-key into model.apiKey. - - Use this when auth is carried through providerOptions instead of an API key. - """ - - -class ModelClientOptionsGenericModelClientOptionsProviderOptionsBedrockAPIKeyProviderOptions(TypedDict, total=False): - region: Required[str] - """AWS region for Amazon Bedrock""" - - -class ModelClientOptionsGenericModelClientOptionsProviderOptionsBedrockAwsCredentialsProviderOptions( - TypedDict, total=False -): - access_key_id: Required[Annotated[str, PropertyInfo(alias="accessKeyId")]] - """AWS access key ID for Bedrock""" - - region: Required[str] - """AWS region for Amazon Bedrock""" - - secret_access_key: Required[Annotated[str, PropertyInfo(alias="secretAccessKey")]] - """AWS secret access key for Bedrock""" - - session_token: Annotated[str, PropertyInfo(alias="sessionToken")] - """Optional AWS session token for temporary credentials""" - - -class ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptionsCredentials( - TypedDict, total=False -): - auth_provider_x509_cert_url: str - - auth_uri: str - - client_email: str - - client_id: str - - client_x509_cert_url: str - - private_key: str - - private_key_id: str - - project_id: str - - token_uri: str - - type: str - - universe_domain: str - - -class ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptions( - TypedDict, total=False -): - """Optional Google auth options for Vertex AI""" - - credentials: ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptionsCredentials - - -class ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptions(TypedDict, total=False): - google_auth_options: Annotated[ - ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptionsGoogleAuthOptions, - PropertyInfo(alias="googleAuthOptions"), - ] - """Optional Google auth options for Vertex AI""" - - headers: Dict[str, str] - """Custom headers for Vertex AI requests""" - - location: str - """Google Cloud location for Vertex AI""" - - project: str - """Google Cloud project ID for Vertex AI""" - - -ModelClientOptionsGenericModelClientOptionsProviderOptions: TypeAlias = Union[ - ModelClientOptionsGenericModelClientOptionsProviderOptionsBedrockAPIKeyProviderOptions, - ModelClientOptionsGenericModelClientOptionsProviderOptionsBedrockAwsCredentialsProviderOptions, - ModelClientOptionsGenericModelClientOptionsProviderOptionsGoogleVertexProviderOptions, -] - - -class ModelClientOptionsGenericModelClientOptions(TypedDict, total=False): - api_key: Annotated[str, PropertyInfo(alias="apiKey")] - """API key for the model provider""" - - base_url: Annotated[str, PropertyInfo(alias="baseURL")] - """Base URL for the model provider""" - - headers: Dict[str, str] - """Custom headers for the model provider""" - - provider_options: Annotated[ - ModelClientOptionsGenericModelClientOptionsProviderOptions, PropertyInfo(alias="providerOptions") - ] - """Provider-specific options passed through to the AI SDK provider constructor. - - For Bedrock: { region, accessKeyId, secretAccessKey, sessionToken }. For Vertex: - { project, location, googleAuthOptions }. - """ - - skip_api_key_fallback: Annotated[bool, PropertyInfo(alias="skipApiKeyFallback")] - """When true, hosted sessions will not copy x-model-api-key into model.apiKey. - - Use this when auth is carried through providerOptions instead of an API key. - """ - - -ModelClientOptions: TypeAlias = Union[ - ModelClientOptionsBedrockAPIKeyModelClientOptions, - ModelClientOptionsBedrockAwsCredentialsModelClientOptions, - ModelClientOptionsGenericModelClientOptions, -] diff --git a/src/stagehand/types/stream_event.py b/src/stagehand/types/stream_event.py index 3d5b21ee..6fd47585 100644 --- a/src/stagehand/types/stream_event.py +++ b/src/stagehand/types/stream_event.py @@ -32,7 +32,7 @@ class DataStreamEventLogDataOutput(BaseModel): class StreamEvent(BaseModel): """Server-Sent Event emitted during streaming responses. - Events are sent as `data: \n\n`. Key order: data (with status first), type, id. + Events are sent as `event: \ndata: \n\n`, where the JSON payload has the shape `{ data, type, id }`. """ id: str diff --git a/tests/api_resources/test_sessions.py b/tests/api_resources/test_sessions.py index 8fbfcead..83a69942 100644 --- a/tests/api_resources/test_sessions.py +++ b/tests/api_resources/test_sessions.py @@ -44,13 +44,11 @@ def test_method_act_with_all_params_overload_1(self, client: Stagehand) -> None: frame_id="frameId", options={ "model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "timeout": 30000, "variables": { @@ -123,13 +121,11 @@ def test_method_act_with_all_params_overload_2(self, client: Stagehand) -> None: frame_id="frameId", options={ "model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "timeout": 30000, "variables": { @@ -254,23 +250,19 @@ def test_method_execute_with_all_params_overload_1(self, client: Stagehand) -> N agent_config={ "cua": True, "execution_model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "mode": "cua", "model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "provider": "openai", "system_prompt": "systemPrompt", @@ -279,6 +271,8 @@ def test_method_execute_with_all_params_overload_1(self, client: Stagehand) -> N "instruction": "Log in with username 'demo' and password 'test123', then navigate to settings", "highlight_cursor": True, "max_steps": 20, + "tool_timeout": 30000, + "use_search": True, }, frame_id="frameId", should_cache=True, @@ -354,23 +348,19 @@ def test_method_execute_with_all_params_overload_2(self, client: Stagehand) -> N agent_config={ "cua": True, "execution_model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "mode": "cua", "model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "provider": "openai", "system_prompt": "systemPrompt", @@ -379,6 +369,8 @@ def test_method_execute_with_all_params_overload_2(self, client: Stagehand) -> N "instruction": "Log in with username 'demo' and password 'test123', then navigate to settings", "highlight_cursor": True, "max_steps": 20, + "tool_timeout": 30000, + "use_search": True, }, stream_response=True, frame_id="frameId", @@ -452,13 +444,11 @@ def test_method_extract_with_all_params_overload_1(self, client: Stagehand) -> N instruction="Extract all product names and prices from the page", options={ "model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "selector": "#main-content", "timeout": 30000, @@ -522,13 +512,11 @@ def test_method_extract_with_all_params_overload_2(self, client: Stagehand) -> N instruction="Extract all product names and prices from the page", options={ "model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "selector": "#main-content", "timeout": 30000, @@ -654,13 +642,11 @@ def test_method_observe_with_all_params_overload_1(self, client: Stagehand) -> N instruction="Find all clickable navigation links", options={ "model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "selector": "nav", "timeout": 30000, @@ -730,13 +716,11 @@ def test_method_observe_with_all_params_overload_2(self, client: Stagehand) -> N instruction="Find all clickable navigation links", options={ "model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "selector": "nav", "timeout": 30000, @@ -932,13 +916,6 @@ def test_method_start_with_all_params(self, client: Stagehand) -> None: browserbase_session_id="browserbaseSessionID", dom_settle_timeout_ms=5000, experimental=True, - model_client_options={ - "api_key": "bedrock-short-term-api-key", - "provider_options": {"region": "us-east-1"}, - "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, - "skip_api_key_fallback": True, - }, self_heal=True, system_prompt="systemPrompt", verbose=1, @@ -997,13 +974,11 @@ async def test_method_act_with_all_params_overload_1(self, async_client: AsyncSt frame_id="frameId", options={ "model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "timeout": 30000, "variables": { @@ -1076,13 +1051,11 @@ async def test_method_act_with_all_params_overload_2(self, async_client: AsyncSt frame_id="frameId", options={ "model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "timeout": 30000, "variables": { @@ -1207,23 +1180,19 @@ async def test_method_execute_with_all_params_overload_1(self, async_client: Asy agent_config={ "cua": True, "execution_model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "mode": "cua", "model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "provider": "openai", "system_prompt": "systemPrompt", @@ -1232,6 +1201,8 @@ async def test_method_execute_with_all_params_overload_1(self, async_client: Asy "instruction": "Log in with username 'demo' and password 'test123', then navigate to settings", "highlight_cursor": True, "max_steps": 20, + "tool_timeout": 30000, + "use_search": True, }, frame_id="frameId", should_cache=True, @@ -1307,23 +1278,19 @@ async def test_method_execute_with_all_params_overload_2(self, async_client: Asy agent_config={ "cua": True, "execution_model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "mode": "cua", "model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "provider": "openai", "system_prompt": "systemPrompt", @@ -1332,6 +1299,8 @@ async def test_method_execute_with_all_params_overload_2(self, async_client: Asy "instruction": "Log in with username 'demo' and password 'test123', then navigate to settings", "highlight_cursor": True, "max_steps": 20, + "tool_timeout": 30000, + "use_search": True, }, stream_response=True, frame_id="frameId", @@ -1405,13 +1374,11 @@ async def test_method_extract_with_all_params_overload_1(self, async_client: Asy instruction="Extract all product names and prices from the page", options={ "model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "selector": "#main-content", "timeout": 30000, @@ -1475,13 +1442,11 @@ async def test_method_extract_with_all_params_overload_2(self, async_client: Asy instruction="Extract all product names and prices from the page", options={ "model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "selector": "#main-content", "timeout": 30000, @@ -1607,13 +1572,11 @@ async def test_method_observe_with_all_params_overload_1(self, async_client: Asy instruction="Find all clickable navigation links", options={ "model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "selector": "nav", "timeout": 30000, @@ -1683,13 +1646,11 @@ async def test_method_observe_with_all_params_overload_2(self, async_client: Asy instruction="Find all clickable navigation links", options={ "model": { - "model_name": "openai/gpt-5-nano", + "model_name": "openai/gpt-5.4-mini", "api_key": "sk-some-openai-api-key", "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, + "headers": {"foo": "string"}, "provider": "openai", - "provider_options": {"region": "us-east-1"}, - "skip_api_key_fallback": True, }, "selector": "nav", "timeout": 30000, @@ -1885,13 +1846,6 @@ async def test_method_start_with_all_params(self, async_client: AsyncStagehand) browserbase_session_id="browserbaseSessionID", dom_settle_timeout_ms=5000, experimental=True, - model_client_options={ - "api_key": "bedrock-short-term-api-key", - "provider_options": {"region": "us-east-1"}, - "base_url": "https://api.openai.com/v1", - "headers": {"X-Custom-Header": "value"}, - "skip_api_key_fallback": True, - }, self_heal=True, system_prompt="systemPrompt", verbose=1, From b8706575ab0f95b9e6781ee3685f9b79e0fe6036 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 11 Apr 2026 07:53:15 +0000 Subject: [PATCH 05/13] fix: ensure file data are only sent as 1 parameter --- src/stagehand/_utils/_utils.py | 5 +++-- tests/test_extract_files.py | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/stagehand/_utils/_utils.py b/src/stagehand/_utils/_utils.py index 1c50ff6a..b49804f3 100644 --- a/src/stagehand/_utils/_utils.py +++ b/src/stagehand/_utils/_utils.py @@ -86,8 +86,9 @@ def _extract_items( index += 1 if is_dict(obj): try: - # We are at the last entry in the path so we must remove the field - if (len(path)) == index: + # Remove the field if there are no more dict keys in the path, + # only "" traversal markers or end. + if all(p == "" for p in path[index:]): item = obj.pop(key) else: item = obj[key] diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py index 0d751b46..873eaf74 100644 --- a/tests/test_extract_files.py +++ b/tests/test_extract_files.py @@ -35,6 +35,15 @@ def test_multiple_files() -> None: assert query == {"documents": [{}, {}]} +def test_top_level_file_array() -> None: + query = {"files": [b"file one", b"file two"], "title": "hello"} + assert extract_files(query, paths=[["files", ""]]) == [ + ("files[]", b"file one"), + ("files[]", b"file two"), + ] + assert query == {"title": "hello"} + + @pytest.mark.parametrize( "query,paths,expected", [ From 69d396fa404d0ad3905786dcf6f56be8be7ab9b2 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 07:17:51 +0000 Subject: [PATCH 06/13] perf(client): optimize file structure copying in multipart requests --- src/stagehand/_files.py | 56 +++++++++++++++++- src/stagehand/_utils/__init__.py | 1 - src/stagehand/_utils/_utils.py | 15 ----- tests/test_deepcopy.py | 58 ------------------- tests/test_files.py | 99 +++++++++++++++++++++++++++++++- 5 files changed, 151 insertions(+), 78 deletions(-) delete mode 100644 tests/test_deepcopy.py diff --git a/src/stagehand/_files.py b/src/stagehand/_files.py index cc14c14f..0fdce17b 100644 --- a/src/stagehand/_files.py +++ b/src/stagehand/_files.py @@ -3,8 +3,8 @@ import io import os import pathlib -from typing import overload -from typing_extensions import TypeGuard +from typing import Sequence, cast, overload +from typing_extensions import TypeVar, TypeGuard import anyio @@ -17,7 +17,9 @@ HttpxFileContent, HttpxRequestFiles, ) -from ._utils import is_tuple_t, is_mapping_t, is_sequence_t +from ._utils import is_list, is_mapping, is_tuple_t, is_mapping_t, is_sequence_t + +_T = TypeVar("_T") def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]: @@ -121,3 +123,51 @@ async def async_read_file_content(file: FileContent) -> HttpxFileContent: return await anyio.Path(file).read_bytes() return file + + +def deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]]) -> _T: + """Copy only the containers along the given paths. + + Used to guard against mutation by extract_files without copying the entire structure. + Only dicts and lists that lie on a path are copied; everything else + is returned by reference. + + For example, given paths=[["foo", "files", "file"]] and the structure: + { + "foo": { + "bar": {"baz": {}}, + "files": {"file": } + } + } + The root dict, "foo", and "files" are copied (they lie on the path). + "bar" and "baz" are returned by reference (off the path). + """ + return _deepcopy_with_paths(item, paths, 0) + + +def _deepcopy_with_paths(item: _T, paths: Sequence[Sequence[str]], index: int) -> _T: + if not paths: + return item + if is_mapping(item): + key_to_paths: dict[str, list[Sequence[str]]] = {} + for path in paths: + if index < len(path): + key_to_paths.setdefault(path[index], []).append(path) + + # if no path continues through this mapping, it won't be mutated and copying it is redundant + if not key_to_paths: + return item + + result = dict(item) + for key, subpaths in key_to_paths.items(): + if key in result: + result[key] = _deepcopy_with_paths(result[key], subpaths, index + 1) + return cast(_T, result) + if is_list(item): + array_paths = [path for path in paths if index < len(path) and path[index] == ""] + + # if no path expects a list here, nothing will be mutated inside it - return by reference + if not array_paths: + return cast(_T, item) + return cast(_T, [_deepcopy_with_paths(entry, array_paths, index + 1) for entry in item]) + return item diff --git a/src/stagehand/_utils/__init__.py b/src/stagehand/_utils/__init__.py index 10cb66d2..1c090e51 100644 --- a/src/stagehand/_utils/__init__.py +++ b/src/stagehand/_utils/__init__.py @@ -24,7 +24,6 @@ coerce_integer as coerce_integer, file_from_path as file_from_path, strip_not_given as strip_not_given, - deepcopy_minimal as deepcopy_minimal, get_async_library as get_async_library, maybe_coerce_float as maybe_coerce_float, get_required_header as get_required_header, diff --git a/src/stagehand/_utils/_utils.py b/src/stagehand/_utils/_utils.py index b49804f3..fe053557 100644 --- a/src/stagehand/_utils/_utils.py +++ b/src/stagehand/_utils/_utils.py @@ -177,21 +177,6 @@ def is_iterable(obj: object) -> TypeGuard[Iterable[object]]: return isinstance(obj, Iterable) -def deepcopy_minimal(item: _T) -> _T: - """Minimal reimplementation of copy.deepcopy() that will only copy certain object types: - - - mappings, e.g. `dict` - - list - - This is done for performance reasons. - """ - if is_mapping(item): - return cast(_T, {k: deepcopy_minimal(v) for k, v in item.items()}) - if is_list(item): - return cast(_T, [deepcopy_minimal(entry) for entry in item]) - return item - - # copied from https://github.com/Rapptz/RoboDanny def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> str: size = len(seq) diff --git a/tests/test_deepcopy.py b/tests/test_deepcopy.py deleted file mode 100644 index 889b2d4e..00000000 --- a/tests/test_deepcopy.py +++ /dev/null @@ -1,58 +0,0 @@ -from stagehand._utils import deepcopy_minimal - - -def assert_different_identities(obj1: object, obj2: object) -> None: - assert obj1 == obj2 - assert id(obj1) != id(obj2) - - -def test_simple_dict() -> None: - obj1 = {"foo": "bar"} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_dict() -> None: - obj1 = {"foo": {"bar": True}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - - -def test_complex_nested_dict() -> None: - obj1 = {"foo": {"bar": [{"hello": "world"}]}} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1["foo"], obj2["foo"]) - assert_different_identities(obj1["foo"]["bar"], obj2["foo"]["bar"]) - assert_different_identities(obj1["foo"]["bar"][0], obj2["foo"]["bar"][0]) - - -def test_simple_list() -> None: - obj1 = ["a", "b", "c"] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - - -def test_nested_list() -> None: - obj1 = ["a", [1, 2, 3]] - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert_different_identities(obj1[1], obj2[1]) - - -class MyObject: ... - - -def test_ignores_other_types() -> None: - # custom classes - my_obj = MyObject() - obj1 = {"foo": my_obj} - obj2 = deepcopy_minimal(obj1) - assert_different_identities(obj1, obj2) - assert obj1["foo"] is my_obj - - # tuples - obj3 = ("a", "b") - obj4 = deepcopy_minimal(obj3) - assert obj3 is obj4 diff --git a/tests/test_files.py b/tests/test_files.py index 683b85ee..64449177 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -4,7 +4,8 @@ import pytest from dirty_equals import IsDict, IsList, IsBytes, IsTuple -from stagehand._files import to_httpx_files, async_to_httpx_files +from stagehand._files import to_httpx_files, deepcopy_with_paths, async_to_httpx_files +from stagehand._utils import extract_files readme_path = Path(__file__).parent.parent.joinpath("README.md") @@ -49,3 +50,99 @@ def test_string_not_allowed() -> None: "file": "foo", # type: ignore } ) + + +def assert_different_identities(obj1: object, obj2: object) -> None: + assert obj1 == obj2 + assert obj1 is not obj2 + + +class TestDeepcopyWithPaths: + def test_copies_top_level_dict(self) -> None: + original = {"file": b"data", "other": "value"} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + + def test_file_value_is_same_reference(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes} + result = deepcopy_with_paths(original, [["file"]]) + assert_different_identities(result, original) + assert result["file"] is file_bytes + + def test_list_popped_wholesale(self) -> None: + files = [b"f1", b"f2"] + original = {"files": files, "title": "t"} + result = deepcopy_with_paths(original, [["files", ""]]) + assert_different_identities(result, original) + result_files = result["files"] + assert isinstance(result_files, list) + assert_different_identities(result_files, files) + + def test_nested_array_path_copies_list_and_elements(self) -> None: + elem1 = {"file": b"f1", "extra": 1} + elem2 = {"file": b"f2", "extra": 2} + original = {"items": [elem1, elem2]} + result = deepcopy_with_paths(original, [["items", "", "file"]]) + assert_different_identities(result, original) + result_items = result["items"] + assert isinstance(result_items, list) + assert_different_identities(result_items, original["items"]) + assert_different_identities(result_items[0], elem1) + assert_different_identities(result_items[1], elem2) + + def test_empty_paths_returns_same_object(self) -> None: + original = {"foo": "bar"} + result = deepcopy_with_paths(original, []) + assert result is original + + def test_multiple_paths(self) -> None: + f1 = b"file1" + f2 = b"file2" + original = {"a": f1, "b": f2, "c": "unchanged"} + result = deepcopy_with_paths(original, [["a"], ["b"]]) + assert_different_identities(result, original) + assert result["a"] is f1 + assert result["b"] is f2 + assert result["c"] is original["c"] + + def test_extract_files_does_not_mutate_original_top_level(self) -> None: + file_bytes = b"contents" + original = {"file": file_bytes, "other": "value"} + + copied = deepcopy_with_paths(original, [["file"]]) + extracted = extract_files(copied, paths=[["file"]]) + + assert extracted == [("file", file_bytes)] + assert original == {"file": file_bytes, "other": "value"} + assert copied == {"other": "value"} + + def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None: + file1 = b"f1" + file2 = b"f2" + original = { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + + copied = deepcopy_with_paths(original, [["items", "", "file"]]) + extracted = extract_files(copied, paths=[["items", "", "file"]]) + + assert extracted == [("items[][file]", file1), ("items[][file]", file2)] + assert original == { + "items": [ + {"file": file1, "extra": 1}, + {"file": file2, "extra": 2}, + ], + "title": "example", + } + assert copied == { + "items": [ + {"extra": 1}, + {"extra": 2}, + ], + "title": "example", + } From 9bc61e12f1599b14dd8b2a0c38a50c089512e9c7 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 03:48:36 +0000 Subject: [PATCH 07/13] chore(internal): more robust bootstrap script --- scripts/bootstrap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/bootstrap b/scripts/bootstrap index 4638ec69..5a23841b 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,7 +4,7 @@ set -e cd "$(dirname "$0")/.." -if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then brew bundle check >/dev/null 2>&1 || { echo -n "==> Install Homebrew dependencies? (y/N): " read -r response From 852600a4c6169df6497fc4d77a06abae8812e375 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 03:49:27 +0000 Subject: [PATCH 08/13] fix: use correct field name format for multipart file arrays --- src/stagehand/_qs.py | 8 ++----- src/stagehand/_types.py | 3 +++ src/stagehand/_utils/_utils.py | 42 +++++++++++++++++++++++++++------- tests/test_extract_files.py | 28 +++++++++++++++++++---- tests/test_files.py | 2 +- 5 files changed, 63 insertions(+), 20 deletions(-) diff --git a/src/stagehand/_qs.py b/src/stagehand/_qs.py index de8c99bc..4127c19c 100644 --- a/src/stagehand/_qs.py +++ b/src/stagehand/_qs.py @@ -2,17 +2,13 @@ from typing import Any, List, Tuple, Union, Mapping, TypeVar from urllib.parse import parse_qs, urlencode -from typing_extensions import Literal, get_args +from typing_extensions import get_args -from ._types import NotGiven, not_given +from ._types import NotGiven, ArrayFormat, NestedFormat, not_given from ._utils import flatten _T = TypeVar("_T") - -ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] -NestedFormat = Literal["dots", "brackets"] - PrimitiveData = Union[str, int, float, bool, None] # this should be Data = Union[PrimitiveData, "List[Data]", "Tuple[Data]", "Mapping[str, Data]"] # https://github.com/microsoft/pyright/issues/3555 diff --git a/src/stagehand/_types.py b/src/stagehand/_types.py index d25d0560..6751e5d0 100644 --- a/src/stagehand/_types.py +++ b/src/stagehand/_types.py @@ -47,6 +47,9 @@ ModelT = TypeVar("ModelT", bound=pydantic.BaseModel) _T = TypeVar("_T") +ArrayFormat = Literal["comma", "repeat", "indices", "brackets"] +NestedFormat = Literal["dots", "brackets"] + # Approximates httpx internal ProxiesTypes and RequestFiles types # while adding support for `PathLike` instances diff --git a/src/stagehand/_utils/_utils.py b/src/stagehand/_utils/_utils.py index fe053557..6e1fbd53 100644 --- a/src/stagehand/_utils/_utils.py +++ b/src/stagehand/_utils/_utils.py @@ -17,11 +17,11 @@ ) from pathlib import Path from datetime import date, datetime -from typing_extensions import TypeGuard +from typing_extensions import TypeGuard, get_args import sniffio -from .._types import Omit, NotGiven, FileTypes, HeadersLike +from .._types import Omit, NotGiven, FileTypes, ArrayFormat, HeadersLike _T = TypeVar("_T") _TupleT = TypeVar("_TupleT", bound=Tuple[object, ...]) @@ -40,25 +40,45 @@ def extract_files( query: Mapping[str, object], *, paths: Sequence[Sequence[str]], + array_format: ArrayFormat = "brackets", ) -> list[tuple[str, FileTypes]]: """Recursively extract files from the given dictionary based on specified paths. A path may look like this ['foo', 'files', '', 'data']. + ``array_format`` controls how ```` segments contribute to the emitted + field name. Supported values: ``"brackets"`` (``foo[]``), ``"repeat"`` and + ``"comma"`` (``foo``), ``"indices"`` (``foo[0]``, ``foo[1]``). + Note: this mutates the given dictionary. """ files: list[tuple[str, FileTypes]] = [] for path in paths: - files.extend(_extract_items(query, path, index=0, flattened_key=None)) + files.extend(_extract_items(query, path, index=0, flattened_key=None, array_format=array_format)) return files +def _array_suffix(array_format: ArrayFormat, array_index: int) -> str: + if array_format == "brackets": + return "[]" + if array_format == "indices": + return f"[{array_index}]" + if array_format == "repeat" or array_format == "comma": + # Both repeat the bare field name for each file part; there is no + # meaningful way to comma-join binary parts. + return "" + raise NotImplementedError( + f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}" + ) + + def _extract_items( obj: object, path: Sequence[str], *, index: int, flattened_key: str | None, + array_format: ArrayFormat, ) -> list[tuple[str, FileTypes]]: try: key = path[index] @@ -75,9 +95,11 @@ def _extract_items( if is_list(obj): files: list[tuple[str, FileTypes]] = [] - for entry in obj: - assert_is_file_content(entry, key=flattened_key + "[]" if flattened_key else "") - files.append((flattened_key + "[]", cast(FileTypes, entry))) + for array_index, entry in enumerate(obj): + suffix = _array_suffix(array_format, array_index) + emitted_key = (flattened_key + suffix) if flattened_key else suffix + assert_is_file_content(entry, key=emitted_key) + files.append((emitted_key, cast(FileTypes, entry))) return files assert_is_file_content(obj, key=flattened_key) @@ -106,6 +128,7 @@ def _extract_items( path, index=index, flattened_key=flattened_key, + array_format=array_format, ) elif is_list(obj): if key != "": @@ -117,9 +140,12 @@ def _extract_items( item, path, index=index, - flattened_key=flattened_key + "[]" if flattened_key is not None else "[]", + flattened_key=( + (flattened_key if flattened_key is not None else "") + _array_suffix(array_format, array_index) + ), + array_format=array_format, ) - for item in obj + for array_index, item in enumerate(obj) ] ) diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py index 873eaf74..79883d4f 100644 --- a/tests/test_extract_files.py +++ b/tests/test_extract_files.py @@ -4,7 +4,7 @@ import pytest -from stagehand._types import FileTypes +from stagehand._types import FileTypes, ArrayFormat from stagehand._utils import extract_files @@ -37,10 +37,7 @@ def test_multiple_files() -> None: def test_top_level_file_array() -> None: query = {"files": [b"file one", b"file two"], "title": "hello"} - assert extract_files(query, paths=[["files", ""]]) == [ - ("files[]", b"file one"), - ("files[]", b"file two"), - ] + assert extract_files(query, paths=[["files", ""]]) == [("files[]", b"file one"), ("files[]", b"file two")] assert query == {"title": "hello"} @@ -71,3 +68,24 @@ def test_ignores_incorrect_paths( expected: list[tuple[str, FileTypes]], ) -> None: assert extract_files(query, paths=paths) == expected + + +@pytest.mark.parametrize( + "array_format,expected_top_level,expected_nested", + [ + ("brackets", [("files[]", b"a"), ("files[]", b"b")], [("items[][file]", b"a"), ("items[][file]", b"b")]), + ("repeat", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]), + ("comma", [("files", b"a"), ("files", b"b")], [("items[file]", b"a"), ("items[file]", b"b")]), + ("indices", [("files[0]", b"a"), ("files[1]", b"b")], [("items[0][file]", b"a"), ("items[1][file]", b"b")]), + ], +) +def test_array_format_controls_file_field_names( + array_format: ArrayFormat, + expected_top_level: list[tuple[str, FileTypes]], + expected_nested: list[tuple[str, FileTypes]], +) -> None: + top_level = {"files": [b"a", b"b"]} + assert extract_files(top_level, paths=[["files", ""]], array_format=array_format) == expected_top_level + + nested = {"items": [{"file": b"a"}, {"file": b"b"}]} + assert extract_files(nested, paths=[["items", "", "file"]], array_format=array_format) == expected_nested diff --git a/tests/test_files.py b/tests/test_files.py index 64449177..5e31d7cd 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -131,7 +131,7 @@ def test_extract_files_does_not_mutate_original_nested_array_path(self) -> None: copied = deepcopy_with_paths(original, [["items", "", "file"]]) extracted = extract_files(copied, paths=[["items", "", "file"]]) - assert extracted == [("items[][file]", file1), ("items[][file]", file2)] + assert [entry for _, entry in extracted] == [file1, file2] assert original == { "items": [ {"file": file1, "extra": 1}, From b1c61276ff9cecf89c078e1d97a6b927d8d308e1 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 03:51:34 +0000 Subject: [PATCH 09/13] feat: support setting headers via env --- src/stagehand/_client.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/stagehand/_client.py b/src/stagehand/_client.py index affa6420..0804b9f3 100644 --- a/src/stagehand/_client.py +++ b/src/stagehand/_client.py @@ -22,7 +22,11 @@ RequestOptions, not_given, ) -from ._utils import is_given, get_async_library +from ._utils import ( + is_given, + is_mapping_t, + get_async_library, +) from ._compat import cached_property from ._models import FinalRequestOptions from ._version import __version__ @@ -161,6 +165,15 @@ def __init__( ) ### + custom_headers_env = os.environ.get("STAGEHAND_CUSTOM_HEADERS") + if custom_headers_env is not None: + parsed: dict[str, str] = {} + for line in custom_headers_env.split("\n"): + colon = line.find(":") + if colon >= 0: + parsed[line[:colon].strip()] = line[colon + 1 :].strip() + default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + super().__init__( version=__version__, base_url=base_url, @@ -447,6 +460,15 @@ def __init__( ) ### + custom_headers_env = os.environ.get("STAGEHAND_CUSTOM_HEADERS") + if custom_headers_env is not None: + parsed: dict[str, str] = {} + for line in custom_headers_env.split("\n"): + colon = line.find(":") + if colon >= 0: + parsed[line[:colon].strip()] = line[colon + 1 :].strip() + default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})} + super().__init__( version=__version__, base_url=base_url, From 57f0c9bc7d97d12beebf7132ff62d8047b0e08bb Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 06:01:15 +0000 Subject: [PATCH 10/13] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index d8428d42..0bd171d8 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-1c6caa2891a7f3bdfc0caab143f285badc9145220c9b29cd5e4cf1a9b3ac11cf.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase/stagehand-1c6caa2891a7f3bdfc0caab143f285badc9145220c9b29cd5e4cf1a9b3ac11cf.yml openapi_spec_hash: 28c4b734a5309067c39bb4c4b709b9ab config_hash: a962ae71493deb11a1c903256fb25386 From 4b22fcea8654bbaeea9041f801125438b94ddaf4 Mon Sep 17 00:00:00 2001 From: monadoid Date: Thu, 30 Apr 2026 18:42:12 +0200 Subject: [PATCH 11/13] [STG-1808] Prefer STAGEHAND_API_URL env fallback --- README.md | 4 +- examples/env.py | 3 -- examples/local_browser_playwright_example.py | 2 +- ...ocal_server_multiregion_browser_example.py | 2 +- examples/playwright_page_example.py | 2 +- examples/pydoll_tab_example.py | 2 +- examples/remote_browser_playwright_example.py | 2 +- src/stagehand/_custom/sea_server.py | 2 +- tests/test_client.py | 50 ++++++++++++++++++- 9 files changed, 56 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index bd9c0524..0a3957d9 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ See this table for the available options: | `browserbase_api_key` | `BROWSERBASE_API_KEY` | true | - | | `browserbase_project_id` | `BROWSERBASE_PROJECT_ID` | true | - | | `model_api_key` | `MODEL_API_KEY` | true | - | -| `base_url` | `STAGEHAND_BASE_URL` | false | `"https://api.stagehand.browserbase.com"` | +| `base_url` | `STAGEHAND_API_URL` | false | `"https://api.stagehand.browserbase.com"` | Keyword arguments take precedence over environment variables. @@ -662,7 +662,7 @@ import httpx from stagehand import Stagehand, DefaultHttpxClient client = Stagehand( - # Or use the `STAGEHAND_BASE_URL` env var + # Or use the `STAGEHAND_API_URL` env var base_url="http://my.test.server.example.com:8083", http_client=DefaultHttpxClient( proxy="http://my.test.proxy.example.com", diff --git a/examples/env.py b/examples/env.py index ea047de1..f7478eda 100644 --- a/examples/env.py +++ b/examples/env.py @@ -44,9 +44,6 @@ def load_example_env() -> None: + " (from examples/.env)" ) - # Normalize for SDKs that expect STAGEHAND_BASE_URL - os.environ.setdefault("STAGEHAND_BASE_URL", os.environ["STAGEHAND_API_URL"]) - # Use the repo-local SEA binary when available (avoid global installs). sea_binary = env_path.parent.parent / "bin" / "sea" / "stagehand-darwin-arm64" if sea_binary.exists(): diff --git a/examples/local_browser_playwright_example.py b/examples/local_browser_playwright_example.py index d3f208df..8ce9ef3b 100644 --- a/examples/local_browser_playwright_example.py +++ b/examples/local_browser_playwright_example.py @@ -15,7 +15,7 @@ - BROWSERBASE_PROJECT_ID (can be any value in local mode) Optional: -- STAGEHAND_BASE_URL (defaults to http://127.0.0.1:3000) +- STAGEHAND_API_URL (defaults to http://127.0.0.1:3000) """ from __future__ import annotations diff --git a/examples/local_server_multiregion_browser_example.py b/examples/local_server_multiregion_browser_example.py index 2bb1ed61..d735c233 100644 --- a/examples/local_server_multiregion_browser_example.py +++ b/examples/local_server_multiregion_browser_example.py @@ -14,7 +14,7 @@ - BROWSERBASE_PROJECT_ID Optional: -- STAGEHAND_BASE_URL (defaults to http://127.0.0.1:3000 when server="local") +- STAGEHAND_API_URL (defaults to http://127.0.0.1:3000 when server="local") """ from __future__ import annotations diff --git a/examples/playwright_page_example.py b/examples/playwright_page_example.py index 1f032ee6..cbc67dbe 100644 --- a/examples/playwright_page_example.py +++ b/examples/playwright_page_example.py @@ -13,7 +13,7 @@ - BROWSERBASE_PROJECT_ID Optional: -- STAGEHAND_BASE_URL (defaults to https://api.stagehand.browserbase.com) +- STAGEHAND_API_URL (defaults to https://api.stagehand.browserbase.com) """ from __future__ import annotations diff --git a/examples/pydoll_tab_example.py b/examples/pydoll_tab_example.py index a7358edd..12a710cd 100644 --- a/examples/pydoll_tab_example.py +++ b/examples/pydoll_tab_example.py @@ -15,7 +15,7 @@ - BROWSERBASE_PROJECT_ID Optional: -- STAGEHAND_BASE_URL (defaults to https://api.stagehand.browserbase.com) +- STAGEHAND_API_URL (defaults to https://api.stagehand.browserbase.com) Notes: - This example requires Python 3.10+ because `pydoll-python` requires Python 3.10+. diff --git a/examples/remote_browser_playwright_example.py b/examples/remote_browser_playwright_example.py index 22e119cd..08ef502a 100644 --- a/examples/remote_browser_playwright_example.py +++ b/examples/remote_browser_playwright_example.py @@ -15,7 +15,7 @@ - BROWSERBASE_PROJECT_ID Optional: -- STAGEHAND_BASE_URL (defaults to https://api.stagehand.browserbase.com) +- STAGEHAND_API_URL (defaults to https://api.stagehand.browserbase.com) """ from __future__ import annotations diff --git a/src/stagehand/_custom/sea_server.py b/src/stagehand/_custom/sea_server.py index e31f7b4a..a72ed591 100644 --- a/src/stagehand/_custom/sea_server.py +++ b/src/stagehand/_custom/sea_server.py @@ -329,7 +329,7 @@ def configure_client_base_url( return base_url if base_url is None: - base_url = os.environ.get("STAGEHAND_BASE_URL") + base_url = os.environ.get("STAGEHAND_API_URL") or os.environ.get("STAGEHAND_BASE_URL") if base_url is None: base_url = "https://api.stagehand.browserbase.com" return base_url diff --git a/tests/test_client.py b/tests/test_client.py index 37ca5a76..2b5bec70 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -795,7 +795,7 @@ def test_base_url_setter(self) -> None: client.close() def test_base_url_env(self) -> None: - with update_env(STAGEHAND_BASE_URL="http://localhost:5000/from/env"): + with update_env(STAGEHAND_API_URL="http://localhost:5000/from/env"): client = Stagehand( browserbase_api_key=browserbase_api_key, browserbase_project_id=browserbase_project_id, @@ -804,6 +804,29 @@ def test_base_url_env(self) -> None: ) assert client.base_url == "http://localhost:5000/from/env/" + def test_base_url_legacy_env(self) -> None: + with update_env(STAGEHAND_BASE_URL="http://localhost:5000/from/legacy/env"): + client = Stagehand( + browserbase_api_key=browserbase_api_key, + browserbase_project_id=browserbase_project_id, + model_api_key=model_api_key, + _strict_response_validation=True, + ) + assert client.base_url == "http://localhost:5000/from/legacy/env/" + + def test_base_url_env_prefers_api_url(self) -> None: + with update_env( + STAGEHAND_API_URL="http://localhost:5000/from/api/env", + STAGEHAND_BASE_URL="http://localhost:5000/from/base/env", + ): + client = Stagehand( + browserbase_api_key=browserbase_api_key, + browserbase_project_id=browserbase_project_id, + model_api_key=model_api_key, + _strict_response_validation=True, + ) + assert client.base_url == "http://localhost:5000/from/api/env/" + @pytest.mark.parametrize( "client", [ @@ -1870,7 +1893,7 @@ async def test_base_url_setter(self) -> None: await client.close() async def test_base_url_env(self) -> None: - with update_env(STAGEHAND_BASE_URL="http://localhost:5000/from/env"): + with update_env(STAGEHAND_API_URL="http://localhost:5000/from/env"): client = AsyncStagehand( browserbase_api_key=browserbase_api_key, browserbase_project_id=browserbase_project_id, @@ -1879,6 +1902,29 @@ async def test_base_url_env(self) -> None: ) assert client.base_url == "http://localhost:5000/from/env/" + async def test_base_url_legacy_env(self) -> None: + with update_env(STAGEHAND_BASE_URL="http://localhost:5000/from/legacy/env"): + client = AsyncStagehand( + browserbase_api_key=browserbase_api_key, + browserbase_project_id=browserbase_project_id, + model_api_key=model_api_key, + _strict_response_validation=True, + ) + assert client.base_url == "http://localhost:5000/from/legacy/env/" + + async def test_base_url_env_prefers_api_url(self) -> None: + with update_env( + STAGEHAND_API_URL="http://localhost:5000/from/api/env", + STAGEHAND_BASE_URL="http://localhost:5000/from/base/env", + ): + client = AsyncStagehand( + browserbase_api_key=browserbase_api_key, + browserbase_project_id=browserbase_project_id, + model_api_key=model_api_key, + _strict_response_validation=True, + ) + assert client.base_url == "http://localhost:5000/from/api/env/" + @pytest.mark.parametrize( "client", [ From 97016ef0682118b7f273791083a3f6bfdc019bb8 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 02:00:22 +0000 Subject: [PATCH 12/13] codegen metadata --- .stats.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.stats.yml b/.stats.yml index 0bd171d8..b17eb0a6 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 8 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase/stagehand-1c6caa2891a7f3bdfc0caab143f285badc9145220c9b29cd5e4cf1a9b3ac11cf.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase/stagehand-dbbff1a35360850898f7d60588e257faeac145a73cfcae634cfeb1b70109b6af.yml openapi_spec_hash: 28c4b734a5309067c39bb4c4b709b9ab config_hash: a962ae71493deb11a1c903256fb25386 From d6093ba09aa715661b7c59d9796a29116b60a067 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 02:01:01 +0000 Subject: [PATCH 13/13] release: 3.20.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ pyproject.toml | 2 +- src/stagehand/_version.py | 2 +- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2ebc4e90..f80372ae 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.19.5" + ".": "3.20.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index bbaf6ba1..85b68b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 3.20.0 (2026-05-01) + +Full Changelog: [v3.19.5...v3.20.0](https://github.com/browserbase/stagehand-python/compare/v3.19.5...v3.20.0) + +### Features + +* [STG-1798] feat: support Browserbase verified sessions ([078ab5c](https://github.com/browserbase/stagehand-python/commit/078ab5c76f13beee16e44d7eed5e3018f1cc5bd8)) +* Bedrock auth passthrough ([9463fa4](https://github.com/browserbase/stagehand-python/commit/9463fa49cb839abbb2c6a1adb0d053e5006216a7)) +* Revert "[STG-1573] Add providerOptions for extensible model auth ([#1822](https://github.com/browserbase/stagehand-python/issues/1822))" ([3c04086](https://github.com/browserbase/stagehand-python/commit/3c0408675154c9f7d241c4e92e9cb82f0419d6b3)) +* support setting headers via env ([b1c6127](https://github.com/browserbase/stagehand-python/commit/b1c61276ff9cecf89c078e1d97a6b927d8d308e1)) + + +### Bug Fixes + +* **client:** preserve hardcoded query params when merging with user params ([feac39d](https://github.com/browserbase/stagehand-python/commit/feac39d88a841ff710d672b622f3280037589f66)) +* ensure file data are only sent as 1 parameter ([b870657](https://github.com/browserbase/stagehand-python/commit/b8706575ab0f95b9e6781ee3685f9b79e0fe6036)) +* use correct field name format for multipart file arrays ([852600a](https://github.com/browserbase/stagehand-python/commit/852600a4c6169df6497fc4d77a06abae8812e375)) + + +### Performance Improvements + +* **client:** optimize file structure copying in multipart requests ([69d396f](https://github.com/browserbase/stagehand-python/commit/69d396fa404d0ad3905786dcf6f56be8be7ab9b2)) + + +### Chores + +* **internal:** more robust bootstrap script ([9bc61e1](https://github.com/browserbase/stagehand-python/commit/9bc61e12f1599b14dd8b2a0c38a50c089512e9c7)) + ## 3.19.5 (2026-04-03) Full Changelog: [v3.19.4...v3.19.5](https://github.com/browserbase/stagehand-python/compare/v3.19.4...v3.19.5) diff --git a/pyproject.toml b/pyproject.toml index 96fa8963..d3411cde 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stagehand" -version = "3.19.5" +version = "3.20.0" description = "The official Python library for the stagehand API" dynamic = ["readme"] license = "MIT" diff --git a/src/stagehand/_version.py b/src/stagehand/_version.py index 15e655a3..b5e0b8a6 100644 --- a/src/stagehand/_version.py +++ b/src/stagehand/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "stagehand" -__version__ = "3.19.5" # x-release-please-version +__version__ = "3.20.0" # x-release-please-version