Skip to content

Commit 0ffca19

Browse files
merge commit
2 parents 81032b2 + f1a28b5 commit 0ffca19

File tree

8 files changed

+568
-472
lines changed

8 files changed

+568
-472
lines changed

stagehand/api.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import json
2+
from typing import Any
3+
4+
import httpx
5+
6+
from .utils import convert_dict_keys_to_camel_case
7+
8+
__all__ = ["_create_session", "_execute"]
9+
10+
11+
async def _create_session(self):
12+
"""
13+
Create a new session by calling /sessions/start on the server.
14+
Depends on browserbase_api_key, browserbase_project_id, and model_api_key.
15+
"""
16+
if not self.browserbase_api_key:
17+
raise ValueError("browserbase_api_key is required to create a session.")
18+
if not self.browserbase_project_id:
19+
raise ValueError("browserbase_project_id is required to create a session.")
20+
if not self.model_api_key:
21+
raise ValueError("model_api_key is required to create a session.")
22+
23+
browserbase_session_create_params = (
24+
convert_dict_keys_to_camel_case(self.browserbase_session_create_params)
25+
if self.browserbase_session_create_params
26+
else None
27+
)
28+
29+
payload = {
30+
"modelName": self.model_name,
31+
"verbose": 2 if self.verbose == 3 else self.verbose,
32+
"domSettleTimeoutMs": self.dom_settle_timeout_ms,
33+
"browserbaseSessionCreateParams": (
34+
browserbase_session_create_params
35+
if browserbase_session_create_params
36+
else {
37+
"browserSettings": {
38+
"blockAds": True,
39+
"viewport": {
40+
"width": 1024,
41+
"height": 768,
42+
},
43+
},
44+
}
45+
),
46+
"proxies": True,
47+
}
48+
49+
# Add the new parameters if they have values
50+
if hasattr(self, "self_heal") and self.self_heal is not None:
51+
payload["selfHeal"] = self.self_heal
52+
53+
if (
54+
hasattr(self, "wait_for_captcha_solves")
55+
and self.wait_for_captcha_solves is not None
56+
):
57+
payload["waitForCaptchaSolves"] = self.wait_for_captcha_solves
58+
59+
if hasattr(self, "act_timeout_ms") and self.act_timeout_ms is not None:
60+
payload["actTimeoutMs"] = self.act_timeout_ms
61+
62+
if hasattr(self, "system_prompt") and self.system_prompt:
63+
payload["systemPrompt"] = self.system_prompt
64+
65+
if hasattr(self, "model_client_options") and self.model_client_options:
66+
payload["modelClientOptions"] = self.model_client_options
67+
68+
headers = {
69+
"x-bb-api-key": self.browserbase_api_key,
70+
"x-bb-project-id": self.browserbase_project_id,
71+
"x-model-api-key": self.model_api_key,
72+
"Content-Type": "application/json",
73+
"x-language": "python",
74+
}
75+
76+
client = self.httpx_client or httpx.AsyncClient(timeout=self.timeout_settings)
77+
async with client:
78+
resp = await client.post(
79+
f"{self.api_url}/sessions/start",
80+
json=payload,
81+
headers=headers,
82+
)
83+
if resp.status_code != 200:
84+
raise RuntimeError(f"Failed to create session: {resp.text}")
85+
data = resp.json()
86+
self.logger.debug(f"Session created: {data}")
87+
if not data.get("success") or "sessionId" not in data.get("data", {}):
88+
raise RuntimeError(f"Invalid response format: {resp.text}")
89+
90+
self.session_id = data["data"]["sessionId"]
91+
92+
93+
async def _execute(self, method: str, payload: dict[str, Any]) -> Any:
94+
"""
95+
Internal helper to call /sessions/{session_id}/{method} with the given method and payload.
96+
Streams line-by-line, returning the 'result' from the final message (if any).
97+
"""
98+
headers = {
99+
"x-bb-api-key": self.browserbase_api_key,
100+
"x-bb-project-id": self.browserbase_project_id,
101+
"Content-Type": "application/json",
102+
"Connection": "keep-alive",
103+
# Always enable streaming for better log handling
104+
"x-stream-response": "true",
105+
}
106+
if self.model_api_key:
107+
headers["x-model-api-key"] = self.model_api_key
108+
109+
# Convert snake_case keys to camelCase for the API
110+
modified_payload = convert_dict_keys_to_camel_case(payload)
111+
112+
client = self.httpx_client or httpx.AsyncClient(timeout=self.timeout_settings)
113+
114+
async with client:
115+
try:
116+
# Always use streaming for consistent log handling
117+
async with client.stream(
118+
"POST",
119+
f"{self.api_url}/sessions/{self.session_id}/{method}",
120+
json=modified_payload,
121+
headers=headers,
122+
) as response:
123+
if response.status_code != 200:
124+
error_text = await response.aread()
125+
error_message = error_text.decode("utf-8")
126+
self.logger.error(
127+
f"[HTTP ERROR] Status {response.status_code}: {error_message}"
128+
)
129+
raise RuntimeError(
130+
f"Request failed with status {response.status_code}: {error_message}"
131+
)
132+
result = None
133+
134+
async for line in response.aiter_lines():
135+
# Skip empty lines
136+
if not line.strip():
137+
continue
138+
139+
try:
140+
# Handle SSE-style messages that start with "data: "
141+
if line.startswith("data: "):
142+
line = line[len("data: ") :]
143+
144+
message = json.loads(line)
145+
# Handle different message types
146+
msg_type = message.get("type")
147+
148+
if msg_type == "system":
149+
status = message.get("data", {}).get("status")
150+
if status == "error":
151+
error_msg = message.get("data", {}).get(
152+
"error", "Unknown error"
153+
)
154+
self.logger.error(f"[ERROR] {error_msg}")
155+
raise RuntimeError(
156+
f"Server returned error: {error_msg}"
157+
)
158+
elif status == "finished":
159+
result = message.get("data", {}).get("result")
160+
elif msg_type == "log":
161+
# Process log message using _handle_log
162+
await self._handle_log(message)
163+
else:
164+
# Log any other message types
165+
self.logger.debug(f"[UNKNOWN] Message type: {msg_type}")
166+
except json.JSONDecodeError:
167+
self.logger.warning(f"Could not parse line as JSON: {line}")
168+
169+
# Return the final result
170+
return result
171+
except Exception as e:
172+
self.logger.error(f"[EXCEPTION] {str(e)}")
173+
raise

0 commit comments

Comments
 (0)