diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml index dba7811..aba0c08 100644 --- a/.github/workflows/release-doctor.yml +++ b/.github/workflows/release-doctor.yml @@ -2,7 +2,7 @@ name: Release Doctor on: pull_request: branches: - - stainless + - main workflow_dispatch: jobs: diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 30f01a3..3cf104e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "3.4.8" + ".": "3.5.0" } diff --git a/.stats.yml b/.stats.yml index b1b40bd..da47671 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-089c8670f1d7c2e9fa8e5c97010db7c24b8f162eb7cfe76ffa41d70fa46efe2f.yml -openapi_spec_hash: 7a226aee8f3f2ab16febbe6bb35e1657 -config_hash: 242651c4871c2869ba3c2e3d337505b9 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/browserbase%2Fstagehand-43e6dd4ce19381de488d296e9036fea15bfea9a6f946cf8ccf4e02aecc8fb765.yml +openapi_spec_hash: f736e7a8acea0d73e1031c86ea803246 +config_hash: b375728ccf7d33287335852f4f59c293 diff --git a/CHANGELOG.md b/CHANGELOG.md index 76e9490..aba93aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 3.5.0 (2026-01-29) + +Full Changelog: [v3.4.8...v3.5.0](https://github.com/browserbase/stagehand-python/compare/v3.4.8...v3.5.0) + +### Features + +* add auto-bedrock support based on bedrock/provider.model-name ([eaded9f](https://github.com/browserbase/stagehand-python/commit/eaded9ffb050c297b86223c333044d8c22dd3cf4)) +* Update stainless.yml for project and publish settings ([f90c553](https://github.com/browserbase/stagehand-python/commit/f90c55378c03c18215d1cdc153f84d587e5048b0)) + + +### Bug Fixes + +* **docs:** fix mcp installation instructions for remote servers ([85f8584](https://github.com/browserbase/stagehand-python/commit/85f85840c9e9de4c0c1b07ec1ef41936788ea88b)) + + +### Chores + +* **internal:** version bump ([d227b02](https://github.com/browserbase/stagehand-python/commit/d227b0213aa729243fbc56d818a808536b98b191)) +* update SDK settings ([879b799](https://github.com/browserbase/stagehand-python/commit/879b7990e8095ca106bf9553159d6c7a01936ec9)) + ## 3.4.8 (2026-01-27) Full Changelog: [v3.4.7...v3.4.8](https://github.com/browserbase/stagehand-python/compare/v3.4.7...v3.4.8) diff --git a/README.md b/README.md index 1a19352..07d0fab 100644 --- a/README.md +++ b/README.md @@ -606,9 +606,9 @@ session = response.parse() # get the object that `sessions.start()` would have print(session.data) ``` -These methods return an [`APIResponse`](https://github.com/browserbase/stagehand-python/tree/stainless/src/stagehand/_response.py) object. +These methods return an [`APIResponse`](https://github.com/browserbase/stagehand-python/tree/main/src/stagehand/_response.py) object. -The async client returns an [`AsyncAPIResponse`](https://github.com/browserbase/stagehand-python/tree/stainless/src/stagehand/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. +The async client returns an [`AsyncAPIResponse`](https://github.com/browserbase/stagehand-python/tree/main/src/stagehand/_response.py) with the same structure, the only difference being `await`able methods for reading the response content. #### `.with_streaming_response` diff --git a/pyproject.toml b/pyproject.toml index 9a45340..3ee7688 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "stagehand" -version = "3.4.8" +version = "3.5.0" description = "The official Python library for the stagehand API" dynamic = ["readme"] license = "MIT" @@ -122,7 +122,7 @@ path = "README.md" [[tool.hatch.metadata.hooks.fancy-pypi-readme.substitutions]] # replace relative links with absolute links pattern = '\[(.+?)\]\(((?!https?://)\S+?)\)' -replacement = '[\1](https://github.com/browserbase/stagehand-python/tree/stainless/\g<2>)' +replacement = '[\1](https://github.com/browserbase/stagehand-python/tree/main/\g<2>)' [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/stagehand/_client.py b/src/stagehand/_client.py index 7fbf6c2..3997b5e 100644 --- a/src/stagehand/_client.py +++ b/src/stagehand/_client.py @@ -3,7 +3,6 @@ from __future__ import annotations import os -import datetime from typing import TYPE_CHECKING, Any, Mapping from typing_extensions import Self, Literal, override @@ -220,7 +219,6 @@ def default_headers(self) -> dict[str, str | Omit]: **super().default_headers, "x-language": "python", "x-sdk-version": __version__, - "x-sent-at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"), "X-Stainless-Async": "false", **self._custom_headers, } @@ -236,6 +234,7 @@ def copy( local_host: str | None = None, local_port: int | None = None, local_headless: bool | None = None, + local_chrome_path: str | None = None, local_ready_timeout_s: float | None = None, local_openai_api_key: str | None = None, local_shutdown_on_close: bool | None = None, @@ -280,6 +279,7 @@ def copy( local_host=local_host or self._local_host, local_port=local_port if local_port is not None else self._local_port, local_headless=local_headless if local_headless is not None else self._local_headless, + local_chrome_path=local_chrome_path if local_chrome_path is not None else self._local_chrome_path, local_ready_timeout_s=local_ready_timeout_s if local_ready_timeout_s is not None else self._local_ready_timeout_s, @@ -506,7 +506,6 @@ def default_headers(self) -> dict[str, str | Omit]: **super().default_headers, "x-language": "python", "x-sdk-version": __version__, - "x-sent-at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"), "X-Stainless-Async": f"async:{get_async_library()}", **self._custom_headers, } @@ -522,6 +521,7 @@ def copy( local_host: str | None = None, local_port: int | None = None, local_headless: bool | None = None, + local_chrome_path: str | None = None, local_ready_timeout_s: float | None = None, local_openai_api_key: str | None = None, local_shutdown_on_close: bool | None = None, @@ -566,6 +566,7 @@ def copy( local_host=local_host or self._local_host, local_port=local_port if local_port is not None else self._local_port, local_headless=local_headless if local_headless is not None else self._local_headless, + local_chrome_path=local_chrome_path if local_chrome_path is not None else self._local_chrome_path, local_ready_timeout_s=local_ready_timeout_s if local_ready_timeout_s is not None else self._local_ready_timeout_s, diff --git a/src/stagehand/_models.py b/src/stagehand/_models.py index 29070e0..47024dd 100644 --- a/src/stagehand/_models.py +++ b/src/stagehand/_models.py @@ -857,12 +857,15 @@ def construct( # type: ignore _fields_set: set[str] | None = None, **values: Unpack[FinalRequestOptionsInput], ) -> FinalRequestOptions: - kwargs: dict[str, Any] = { + kwargs: dict[str, Any] = {} + for key, value in values.items(): + if key == "headers" and is_mapping(value): + # Preserve Omit() for headers so callers can explicitly remove defaults. + kwargs[key] = {k: v for k, v in value.items() if not isinstance(v, NotGiven)} + continue # we unconditionally call `strip_not_given` on any value # as it will just ignore any non-mapping types - key: strip_not_given(value) - for key, value in values.items() - } + kwargs[key] = strip_not_given(value) if PYDANTIC_V1: return cast(FinalRequestOptions, super().construct(_fields_set, **kwargs)) # pyright: ignore[reportDeprecated] return super().model_construct(_fields_set, **kwargs) diff --git a/src/stagehand/_streaming.py b/src/stagehand/_streaming.py index 7790c9c..2a03e56 100644 --- a/src/stagehand/_streaming.py +++ b/src/stagehand/_streaming.py @@ -56,7 +56,7 @@ def __stream__(self) -> Iterator[_T]: try: for sse in iterator: - if sse.data.startswith("finished"): + if sse.data.startswith('{"data":{"status":"finished"'): break if sse.data.startswith("error"): @@ -139,7 +139,7 @@ async def __stream__(self) -> AsyncIterator[_T]: try: async for sse in iterator: - if sse.data.startswith("finished"): + if sse.data.startswith('{"data":{"status":"finished"'): break if sse.data.startswith("error"): diff --git a/src/stagehand/_version.py b/src/stagehand/_version.py index eeba836..33317dc 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.4.8" # x-release-please-version +__version__ = "3.5.0" # x-release-please-version diff --git a/src/stagehand/resources/sessions_helpers.py b/src/stagehand/resources/sessions_helpers.py index ae54ee3..8eafd75 100644 --- a/src/stagehand/resources/sessions_helpers.py +++ b/src/stagehand/resources/sessions_helpers.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Union -from datetime import datetime from typing_extensions import Literal, override import httpx @@ -79,7 +77,6 @@ def start( system_prompt: str | Omit = omit, verbose: Literal[0, 1, 2] | Omit = omit, wait_for_captcha_solves: bool | Omit = omit, - x_sent_at: Union[str, datetime] | Omit = omit, x_stream_response: Literal["true", "false"] | Omit = omit, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -98,7 +95,6 @@ def start( system_prompt=system_prompt, verbose=verbose, wait_for_captcha_solves=wait_for_captcha_solves, - x_sent_at=x_sent_at, x_stream_response=x_stream_response, extra_headers=extra_headers, extra_query=extra_query, @@ -134,7 +130,6 @@ async def start( system_prompt: str | Omit = omit, verbose: Literal[0, 1, 2] | Omit = omit, wait_for_captcha_solves: bool | Omit = omit, - x_sent_at: Union[str, datetime] | Omit = omit, x_stream_response: Literal["true", "false"] | Omit = omit, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -153,7 +148,6 @@ async def start( system_prompt=system_prompt, verbose=verbose, wait_for_captcha_solves=wait_for_captcha_solves, - x_sent_at=x_sent_at, x_stream_response=x_stream_response, extra_headers=extra_headers, extra_query=extra_query, diff --git a/src/stagehand/session.py b/src/stagehand/session.py index 37cb92a..1224cb0 100644 --- a/src/stagehand/session.py +++ b/src/stagehand/session.py @@ -3,8 +3,7 @@ from __future__ import annotations import inspect -from typing import TYPE_CHECKING, Any, Union, cast -from datetime import datetime +from typing import TYPE_CHECKING, Any, cast from typing_extensions import Unpack, Literal, Protocol import httpx @@ -248,7 +247,6 @@ def execute( def end( self, *, - x_sent_at: Union[str, datetime] | Omit = omit, x_stream_response: Literal["true", "false"] | Omit = omit, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -257,7 +255,6 @@ def end( ) -> SessionEndResponse: return self._client.sessions.end( id=self.id, - x_sent_at=x_sent_at, x_stream_response=x_stream_response, extra_headers=extra_headers, extra_query=extra_query, @@ -385,7 +382,6 @@ async def execute( async def end( self, *, - x_sent_at: Union[str, datetime] | Omit = omit, x_stream_response: Literal["true", "false"] | Omit = omit, extra_headers: Headers | None = None, extra_query: Query | None = None, @@ -394,7 +390,6 @@ async def end( ) -> SessionEndResponse: return await self._client.sessions.end( id=self.id, - x_sent_at=x_sent_at, x_stream_response=x_stream_response, extra_headers=extra_headers, extra_query=extra_query, diff --git a/src/stagehand/types/session_execute_params.py b/src/stagehand/types/session_execute_params.py index 81d3b4a..7a70294 100644 --- a/src/stagehand/types/session_execute_params.py +++ b/src/stagehand/types/session_execute_params.py @@ -34,7 +34,13 @@ class SessionExecuteParamsBase(TypedDict, total=False): class AgentConfig(TypedDict, total=False): cua: bool - """Enable Computer Use Agent mode""" + """Deprecated. + + Use mode: 'cua' instead. If both are provided, mode takes precedence. + """ + + mode: Literal["dom", "hybrid", "cua"] + """Tool mode for the agent (dom, hybrid, cua). If set, overrides cua.""" model: ModelConfigParam """ diff --git a/tests/api_resources/test_sessions.py b/tests/api_resources/test_sessions.py index b73ae7b..a4ca786 100644 --- a/tests/api_resources/test_sessions.py +++ b/tests/api_resources/test_sessions.py @@ -43,7 +43,7 @@ def test_method_act_with_all_params_overload_1(self, client: Stagehand) -> None: input="Click the login button", frame_id="frameId", options={ - "model": "openai/gpt-5-nano", + "model": {"model_name": "openai/gpt-5-nano"}, "timeout": 30000, "variables": {"username": "john_doe"}, }, @@ -108,7 +108,7 @@ def test_method_act_with_all_params_overload_2(self, client: Stagehand) -> None: stream_response=True, frame_id="frameId", options={ - "model": "openai/gpt-5-nano", + "model": {"model_name": "openai/gpt-5-nano"}, "timeout": 30000, "variables": {"username": "john_doe"}, }, @@ -225,7 +225,8 @@ def test_method_execute_with_all_params_overload_1(self, client: Stagehand) -> N id="c4dbf3a9-9a58-4b22-8a1c-9f20f9f9e123", agent_config={ "cua": True, - "model": "openai/gpt-5-nano", + "mode": "cua", + "model": {"model_name": "openai/gpt-5-nano"}, "provider": "openai", "system_prompt": "systemPrompt", }, @@ -307,7 +308,8 @@ def test_method_execute_with_all_params_overload_2(self, client: Stagehand) -> N id="c4dbf3a9-9a58-4b22-8a1c-9f20f9f9e123", agent_config={ "cua": True, - "model": "openai/gpt-5-nano", + "mode": "cua", + "model": {"model_name": "openai/gpt-5-nano"}, "provider": "openai", "system_prompt": "systemPrompt", }, @@ -387,7 +389,7 @@ def test_method_extract_with_all_params_overload_1(self, client: Stagehand) -> N frame_id="frameId", instruction="Extract all product names and prices from the page", options={ - "model": "openai/gpt-5-nano", + "model": {"model_name": "openai/gpt-5-nano"}, "selector": "#main-content", "timeout": 30000, }, @@ -449,7 +451,7 @@ def test_method_extract_with_all_params_overload_2(self, client: Stagehand) -> N frame_id="frameId", instruction="Extract all product names and prices from the page", options={ - "model": "openai/gpt-5-nano", + "model": {"model_name": "openai/gpt-5-nano"}, "selector": "#main-content", "timeout": 30000, }, @@ -573,7 +575,7 @@ def test_method_observe_with_all_params_overload_1(self, client: Stagehand) -> N frame_id="frameId", instruction="Find all clickable navigation links", options={ - "model": "openai/gpt-5-nano", + "model": {"model_name": "openai/gpt-5-nano"}, "selector": "nav", "timeout": 30000, }, @@ -634,7 +636,7 @@ def test_method_observe_with_all_params_overload_2(self, client: Stagehand) -> N frame_id="frameId", instruction="Find all clickable navigation links", options={ - "model": "openai/gpt-5-nano", + "model": {"model_name": "openai/gpt-5-nano"}, "selector": "nav", "timeout": 30000, }, @@ -874,7 +876,7 @@ async def test_method_act_with_all_params_overload_1(self, async_client: AsyncSt input="Click the login button", frame_id="frameId", options={ - "model": "openai/gpt-5-nano", + "model": {"model_name": "openai/gpt-5-nano"}, "timeout": 30000, "variables": {"username": "john_doe"}, }, @@ -939,7 +941,7 @@ async def test_method_act_with_all_params_overload_2(self, async_client: AsyncSt stream_response=True, frame_id="frameId", options={ - "model": "openai/gpt-5-nano", + "model": {"model_name": "openai/gpt-5-nano"}, "timeout": 30000, "variables": {"username": "john_doe"}, }, @@ -1056,7 +1058,8 @@ async def test_method_execute_with_all_params_overload_1(self, async_client: Asy id="c4dbf3a9-9a58-4b22-8a1c-9f20f9f9e123", agent_config={ "cua": True, - "model": "openai/gpt-5-nano", + "mode": "cua", + "model": {"model_name": "openai/gpt-5-nano"}, "provider": "openai", "system_prompt": "systemPrompt", }, @@ -1138,7 +1141,8 @@ async def test_method_execute_with_all_params_overload_2(self, async_client: Asy id="c4dbf3a9-9a58-4b22-8a1c-9f20f9f9e123", agent_config={ "cua": True, - "model": "openai/gpt-5-nano", + "mode": "cua", + "model": {"model_name": "openai/gpt-5-nano"}, "provider": "openai", "system_prompt": "systemPrompt", }, @@ -1218,7 +1222,7 @@ async def test_method_extract_with_all_params_overload_1(self, async_client: Asy frame_id="frameId", instruction="Extract all product names and prices from the page", options={ - "model": "openai/gpt-5-nano", + "model": {"model_name": "openai/gpt-5-nano"}, "selector": "#main-content", "timeout": 30000, }, @@ -1280,7 +1284,7 @@ async def test_method_extract_with_all_params_overload_2(self, async_client: Asy frame_id="frameId", instruction="Extract all product names and prices from the page", options={ - "model": "openai/gpt-5-nano", + "model": {"model_name": "openai/gpt-5-nano"}, "selector": "#main-content", "timeout": 30000, }, @@ -1404,7 +1408,7 @@ async def test_method_observe_with_all_params_overload_1(self, async_client: Asy frame_id="frameId", instruction="Find all clickable navigation links", options={ - "model": "openai/gpt-5-nano", + "model": {"model_name": "openai/gpt-5-nano"}, "selector": "nav", "timeout": 30000, }, @@ -1465,7 +1469,7 @@ async def test_method_observe_with_all_params_overload_2(self, async_client: Asy frame_id="frameId", instruction="Find all clickable navigation links", options={ - "model": "openai/gpt-5-nano", + "model": {"model_name": "openai/gpt-5-nano"}, "selector": "nav", "timeout": 30000, }, diff --git a/tests/test_local_server.py b/tests/test_local_server.py index 997d0bd..0f6dcd2 100644 --- a/tests/test_local_server.py +++ b/tests/test_local_server.py @@ -105,7 +105,8 @@ def test_local_server_requires_browserbase_keys_for_browserbase_sessions( _set_required_env(monkeypatch) monkeypatch.delenv("BROWSERBASE_API_KEY", raising=False) monkeypatch.delenv("BROWSERBASE_PROJECT_ID", raising=False) - client = Stagehand(server="local") + client = Stagehand(server="local", _local_stagehand_binary_path="/does/not/matter/in/test") + client._sea_server = _DummySeaServer("http://127.0.0.1:43125") # type: ignore[attr-defined] with pytest.raises(StagehandError): client.sessions.start(model_name="openai/gpt-5-nano") @@ -116,12 +117,14 @@ def test_local_server_allows_local_browser_without_browserbase_keys( _set_required_env(monkeypatch) monkeypatch.delenv("BROWSERBASE_API_KEY", raising=False) monkeypatch.delenv("BROWSERBASE_PROJECT_ID", raising=False) - client = Stagehand(server="local") + client = Stagehand(server="local", _local_stagehand_binary_path="/does/not/matter/in/test") + client._sea_server = _DummySeaServer("http://127.0.0.1:43126") # type: ignore[attr-defined] def _post(*_args: object, **_kwargs: object) -> object: raise RuntimeError("post called") - client._post = _post # type: ignore[method-assign] + client.sessions._post = _post # type: ignore[method-assign] + client.base_url = httpx.URL("http://127.0.0.1:43126") with pytest.raises(RuntimeError, match="post called"): client.sessions.start(