refactor(plugins/browser): shared BaseCloudBrowserProvider for browser_use/browserbase/firecrawl
This commit is contained in:
161
plugins/browser/_common.py
Normal file
161
plugins/browser/_common.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Shared base for the bundled cloud-browser provider plugins.
|
||||
|
||||
Every vendor (Browserbase, Browser Use, Firecrawl) speaks the same REST shape:
|
||||
POST to create a session, one request to release it, credentials from
|
||||
:func:`agent.secret_scope.get_secret`. :class:`CloudBrowserProvider` owns that
|
||||
lifecycle; subclasses supply class attributes and small hooks. Log messages are
|
||||
rendered with the vendor label so the emitted text matches the pre-refactor
|
||||
per-vendor modules, and they are emitted on the subclass module's logger.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from agent.browser_provider import BrowserProvider
|
||||
|
||||
_CLOSE_OK = {200, 201, 204}
|
||||
|
||||
|
||||
class CloudBrowserProvider(BrowserProvider):
|
||||
"""REST cloud-browser provider driven by class attributes + hooks.
|
||||
|
||||
Subclasses set ``provider_id`` / ``label``, ``release_method`` /
|
||||
``release_path`` (``{session_id}`` placeholder, appended to
|
||||
``config["base_url"]``), implement ``_get_config_or_none()`` and
|
||||
``_headers(config)``, and write ``create_session`` on top of
|
||||
:meth:`_post_create` / :meth:`_check_created` / :meth:`_session_name`.
|
||||
"""
|
||||
|
||||
provider_id: str
|
||||
label: str
|
||||
release_method: str
|
||||
release_path: str
|
||||
missing_credentials_error: str = ""
|
||||
# Setup-picker metadata; ``setup_tag=None`` hides the provider from the picker.
|
||||
setup_tag: Optional[str] = None
|
||||
setup_env_vars: List[Dict[str, str]] = []
|
||||
# Rendered noun in "Failed to create <label> session"; Firecrawl says "browser session".
|
||||
create_label_suffix: str = ""
|
||||
# Browserbase's close warning historically omits the vendor name.
|
||||
close_fail_fmt: Optional[str] = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.provider_id
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return self.label
|
||||
|
||||
@property
|
||||
def _log(self) -> logging.Logger:
|
||||
return logging.getLogger(type(self).__module__)
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._get_config_or_none() is not None
|
||||
|
||||
# -- config / request hooks ------------------------------------------
|
||||
|
||||
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
def _get_config(self) -> Dict[str, Any]:
|
||||
config = self._get_config_or_none()
|
||||
if config is None:
|
||||
raise ValueError(self.missing_credentials_error)
|
||||
return config
|
||||
|
||||
def _headers(self, config: Dict[str, Any]) -> Dict[str, str]:
|
||||
raise NotImplementedError
|
||||
|
||||
def _release_headers(self, config: Dict[str, Any]) -> Dict[str, str]:
|
||||
return self._headers(config)
|
||||
|
||||
def _release_body(self, config: Dict[str, Any]) -> Optional[Dict[str, object]]:
|
||||
return None
|
||||
|
||||
def _release(self, config: Dict[str, Any], session_id: str, timeout: int) -> requests.Response:
|
||||
kwargs: Dict[str, Any] = {"headers": self._release_headers(config), "timeout": timeout}
|
||||
body = self._release_body(config)
|
||||
if body is not None:
|
||||
kwargs["json"] = body
|
||||
url = f"{config['base_url']}{self.release_path.format(session_id=session_id)}"
|
||||
return getattr(requests, self.release_method)(url, **kwargs)
|
||||
|
||||
# -- create helpers ---------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _session_name(task_id: str) -> str:
|
||||
return f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
def _post_create(
|
||||
self, url: str, headers: Dict[str, str], payload: Dict[str, object], *, wrap_errors: bool = True
|
||||
) -> requests.Response:
|
||||
"""POST the create request; wrap network failures into RuntimeError unless
|
||||
the caller (managed gateway) needs the raw exception to retry."""
|
||||
try:
|
||||
return requests.post(url, headers=headers, json=payload, timeout=30)
|
||||
except requests.RequestException as exc:
|
||||
if not wrap_errors:
|
||||
raise
|
||||
raise RuntimeError(f"{self.label} API connection failed: {exc}") from exc
|
||||
|
||||
def _check_created(self, response: requests.Response) -> None:
|
||||
if not response.ok:
|
||||
raise RuntimeError(
|
||||
f"Failed to create {self.label}{self.create_label_suffix} session: "
|
||||
f"{response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
# -- lifecycle --------------------------------------------------------
|
||||
|
||||
def close_session(self, session_id: str) -> bool:
|
||||
try:
|
||||
config = self._get_config()
|
||||
except ValueError:
|
||||
self._log.warning("Cannot close %s session %s — missing credentials", self.label, session_id)
|
||||
return False
|
||||
try:
|
||||
response = self._release(config, session_id, timeout=10)
|
||||
if response.status_code in _CLOSE_OK:
|
||||
self._log.debug("Successfully closed %s session %s", self.label, session_id)
|
||||
return True
|
||||
self._log.warning(
|
||||
self.close_fail_fmt or f"Failed to close {self.label} session %s: HTTP %s - %s",
|
||||
session_id,
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
self._log.error("Exception closing %s session %s: %s", self.label, session_id, e)
|
||||
return False
|
||||
|
||||
def emergency_cleanup(self, session_id: str) -> None:
|
||||
config = self._get_config_or_none()
|
||||
if config is None:
|
||||
self._log.warning(
|
||||
"Cannot emergency-cleanup %s session %s — missing credentials", self.label, session_id
|
||||
)
|
||||
return
|
||||
try:
|
||||
self._release(config, session_id, timeout=5)
|
||||
except Exception as e:
|
||||
self._log.debug("Emergency cleanup failed for %s session %s: %s", self.label, session_id, e)
|
||||
|
||||
def get_setup_schema(self) -> Optional[Dict[str, Any]]:
|
||||
if self.setup_tag is None:
|
||||
return None
|
||||
return {
|
||||
"name": self.label,
|
||||
"badge": "paid",
|
||||
"tag": self.setup_tag,
|
||||
"env_vars": [dict(v) for v in self.setup_env_vars],
|
||||
# Cloud-scoped hook: installs the agent-browser CLI only (the vendor hosts Chromium).
|
||||
"post_setup": "browserbase",
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
"""Browser Use cloud browser plugin — bundled, auto-loaded.
|
||||
|
||||
Mirrors the ``plugins/web/<vendor>/`` layout: ``provider.py`` holds the
|
||||
provider class; ``__init__.py::register`` instantiates and registers it.
|
||||
"""
|
||||
"""Browser Use cloud browser plugin — bundled, auto-loaded."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,5 +6,4 @@ from plugins.browser.browser_use.provider import BrowserUseBrowserProvider
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register the Browser Use provider with the plugin context."""
|
||||
ctx.register_browser_provider(BrowserUseBrowserProvider())
|
||||
|
||||
@@ -1,50 +1,32 @@
|
||||
"""Browser Use cloud browser provider — plugin form.
|
||||
|
||||
Subclasses :class:`agent.browser_provider.BrowserProvider` (the plugin-facing
|
||||
ABC introduced in PR #25214). The legacy in-tree module
|
||||
``tools.browser_providers.browser_use`` was removed in the same PR; this file
|
||||
is now the canonical implementation.
|
||||
"""Browser Use cloud browser provider (plugin form).
|
||||
|
||||
Browser Use is the only browser backend with dual auth: a direct
|
||||
``BROWSER_USE_API_KEY`` for self-billed users, or the managed Nous tool
|
||||
gateway (which Hermes uses to bill Browser Use sessions to a Nous
|
||||
subscription). The dispatch order — direct API key first, managed gateway
|
||||
second — preserves the pre-migration behaviour in
|
||||
``tools.browser_providers.browser_use.BrowserUseProvider._get_config_or_none``.
|
||||
``BROWSER_USE_API_KEY`` for self-billed users, or the managed Nous tool gateway
|
||||
(bills sessions to a Nous subscription). Dispatch order: direct key first,
|
||||
managed gateway second, unless ``tool_gateway.browser: gateway`` flips it.
|
||||
|
||||
Config keys this provider responds to::
|
||||
|
||||
browser:
|
||||
cloud_provider: "browser-use" # explicit selection
|
||||
tool_gateway:
|
||||
browser: "gateway" # optional: prefer managed gateway
|
||||
# even when BROWSER_USE_API_KEY is set
|
||||
|
||||
Auth env vars (one of)::
|
||||
|
||||
BROWSER_USE_API_KEY=... # https://browser-use.com
|
||||
# OR a managed Nous gateway entry (configured via 'hermes setup')
|
||||
Config: ``browser.cloud_provider: "browser-use"``; ``tool_gateway.browser``.
|
||||
Auth: ``BROWSER_USE_API_KEY`` (https://browser-use.com) or a managed Nous
|
||||
gateway entry configured via ``hermes setup``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from agent.browser_provider import BrowserProvider
|
||||
from agent.secret_scope import get_secret
|
||||
from plugins.browser._common import CloudBrowserProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Idempotency tracking for managed-mode session creation. The managed Nous
|
||||
# gateway returns 409 "already in progress" on retried POSTs; we forward the
|
||||
# original idempotency key so the gateway can deduplicate. Cleared on
|
||||
# success or terminal failure.
|
||||
# Idempotency keys for managed-mode session creation: the gateway returns 409
|
||||
# "already in progress" on retried POSTs, so we forward the original key.
|
||||
# Cleared on success or terminal failure.
|
||||
_pending_create_keys: Dict[str, str] = {}
|
||||
_pending_create_keys_lock = threading.Lock()
|
||||
|
||||
@@ -58,7 +40,6 @@ def _get_or_create_pending_create_key(task_id: str) -> str:
|
||||
existing = _pending_create_keys.get(task_id)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
created = f"browser-use-session-create:{uuid.uuid4().hex}"
|
||||
_pending_create_keys[task_id] = created
|
||||
return created
|
||||
@@ -70,80 +51,50 @@ def _clear_pending_create_key(task_id: str) -> None:
|
||||
|
||||
|
||||
def _should_preserve_pending_create_key(response: requests.Response) -> bool:
|
||||
"""Decide whether to keep the idempotency key after a failed create.
|
||||
|
||||
Preserve the key when the failure looks retryable (5xx) OR when the
|
||||
gateway reports the original request is still in flight (409 "already
|
||||
in progress") — in either case, retrying with the same key lets the
|
||||
gateway deduplicate.
|
||||
|
||||
Drop the key on any other 4xx (auth failure, bad request, etc.) — those
|
||||
won't succeed by being retried.
|
||||
"""
|
||||
"""Keep the idempotency key when the failure is retryable: any 5xx, or a 409
|
||||
whose error message says the original request is still in flight. Any other
|
||||
4xx (auth, bad request) will not succeed on retry, so the key is dropped."""
|
||||
if response.status_code >= 500:
|
||||
return True
|
||||
|
||||
if response.status_code != 409:
|
||||
return False
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
|
||||
error = payload.get("error")
|
||||
if not isinstance(error, dict):
|
||||
return False
|
||||
|
||||
message = str(error.get("message") or "").lower()
|
||||
return "already in progress" in message
|
||||
return "already in progress" in str(error.get("message") or "").lower()
|
||||
|
||||
|
||||
class BrowserUseBrowserProvider(BrowserProvider):
|
||||
"""Browser Use (https://browser-use.com) cloud browser backend.
|
||||
class BrowserUseBrowserProvider(CloudBrowserProvider):
|
||||
"""Browser Use (https://browser-use.com) cloud browser backend."""
|
||||
|
||||
Dual auth: prefers a direct BROWSER_USE_API_KEY when set, falling back
|
||||
to the managed Nous tool gateway when ``tool_gateway.browser`` config
|
||||
routes through it. Setting ``tool_gateway.browser: gateway`` flips the
|
||||
order so managed billing wins even when BROWSER_USE_API_KEY is present.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "browser-use"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "Browser Use"
|
||||
provider_id = "browser-use"
|
||||
label = "Browser Use"
|
||||
release_method = "patch"
|
||||
release_path = "/browsers/{session_id}"
|
||||
# Hidden from the hermes tools picker: the "Browser Use" row activates the
|
||||
# CLI backend (tools/browser_use_cli.py). This provider stays registered for
|
||||
# the Nous gateway path and un-migrated legacy cloud_provider configs.
|
||||
setup_tag = None
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._get_config_or_none(refresh_token=False) is not None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Config resolution (direct API key OR managed Nous gateway)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_config_or_none(self, *, refresh_token: bool = True) -> Optional[Dict[str, Any]]:
|
||||
# Import here to avoid a hard dependency at module-import time —
|
||||
# managed_tool_gateway pulls in the Nous auth stack which can be
|
||||
# heavy and is not needed for direct-API-key users.
|
||||
from tools.managed_tool_gateway import (
|
||||
peek_nous_access_token,
|
||||
resolve_managed_tool_gateway,
|
||||
)
|
||||
from tools.tool_backend_helpers import (
|
||||
NOUS_MANAGED_PROVIDER,
|
||||
read_selection,
|
||||
)
|
||||
# Lazy import: managed_tool_gateway pulls in the Nous auth stack, which
|
||||
# direct-API-key users never need.
|
||||
from tools.managed_tool_gateway import peek_nous_access_token, resolve_managed_tool_gateway
|
||||
from tools.tool_backend_helpers import NOUS_MANAGED_PROVIDER, read_selection
|
||||
|
||||
def _managed_config() -> Optional[Dict[str, Any]]:
|
||||
# Keep availability scans off the synchronous OAuth refresh path.
|
||||
managed = resolve_managed_tool_gateway(
|
||||
"browser-use",
|
||||
token_reader=None if refresh_token else peek_nous_access_token,
|
||||
"browser-use", token_reader=None if refresh_token else peek_nous_access_token
|
||||
)
|
||||
if managed is None:
|
||||
return None
|
||||
@@ -155,73 +106,45 @@ class BrowserUseBrowserProvider(BrowserProvider):
|
||||
|
||||
api_key = get_secret("BROWSER_USE_API_KEY")
|
||||
selected = read_selection("browser")
|
||||
direct = {"api_key": api_key, "base_url": _BASE_URL, "managed_mode": False}
|
||||
|
||||
# Strict selection: "nous" (or legacy use_gateway: true) → managed
|
||||
# gateway ONLY; any other stored browser selection → direct API key
|
||||
# ONLY (no silent managed fallback); never-configured → legacy
|
||||
# behavior (direct key when present, else managed gateway).
|
||||
# Strict selection: "nous" (or legacy use_gateway: true) → managed gateway
|
||||
# ONLY; any other stored selection → direct key ONLY (no silent managed
|
||||
# fallback); never-configured → direct key when present, else managed.
|
||||
if selected == NOUS_MANAGED_PROVIDER:
|
||||
return _managed_config()
|
||||
if selected is not None:
|
||||
if api_key:
|
||||
return {
|
||||
"api_key": api_key,
|
||||
"base_url": _BASE_URL,
|
||||
"managed_mode": False,
|
||||
}
|
||||
return None
|
||||
if api_key:
|
||||
return {
|
||||
"api_key": api_key,
|
||||
"base_url": _BASE_URL,
|
||||
"managed_mode": False,
|
||||
}
|
||||
return _managed_config()
|
||||
return direct if api_key else None
|
||||
return direct if api_key else _managed_config()
|
||||
|
||||
def _get_config(self) -> Dict[str, Any]:
|
||||
from tools.tool_backend_helpers import (
|
||||
NOUS_MANAGED_PROVIDER,
|
||||
managed_nous_tools_enabled,
|
||||
read_selection,
|
||||
selection_error,
|
||||
NOUS_MANAGED_PROVIDER, managed_nous_tools_enabled, read_selection, selection_error,
|
||||
)
|
||||
|
||||
config = self._get_config_or_none()
|
||||
if config is None:
|
||||
selected = read_selection("browser")
|
||||
if selected == NOUS_MANAGED_PROVIDER:
|
||||
raise ValueError(selection_error(
|
||||
"browser",
|
||||
NOUS_MANAGED_PROVIDER,
|
||||
"the Nous Tool Gateway is not available (not entitled or "
|
||||
"unreachable)",
|
||||
))
|
||||
if selected is not None:
|
||||
raise ValueError(selection_error(
|
||||
"browser",
|
||||
selected,
|
||||
"BROWSER_USE_API_KEY is not set",
|
||||
))
|
||||
message = (
|
||||
"Browser Use requires a direct BROWSER_USE_API_KEY credential."
|
||||
if config is not None:
|
||||
return config
|
||||
selected = read_selection("browser")
|
||||
if selected == NOUS_MANAGED_PROVIDER:
|
||||
raise ValueError(selection_error(
|
||||
"browser", NOUS_MANAGED_PROVIDER,
|
||||
"the Nous Tool Gateway is not available (not entitled or unreachable)",
|
||||
))
|
||||
if selected is not None:
|
||||
raise ValueError(selection_error("browser", selected, "BROWSER_USE_API_KEY is not set"))
|
||||
if managed_nous_tools_enabled():
|
||||
raise ValueError(
|
||||
"Browser Use requires either a direct BROWSER_USE_API_KEY "
|
||||
"credential or a managed Browser Use gateway configuration."
|
||||
)
|
||||
if managed_nous_tools_enabled():
|
||||
message = (
|
||||
"Browser Use requires either a direct BROWSER_USE_API_KEY "
|
||||
"credential or a managed Browser Use gateway configuration."
|
||||
)
|
||||
raise ValueError(message)
|
||||
return config
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
raise ValueError("Browser Use requires a direct BROWSER_USE_API_KEY credential.")
|
||||
|
||||
def _headers(self, config: Dict[str, Any]) -> Dict[str, str]:
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"X-Browser-Use-API-Key": config["api_key"],
|
||||
}
|
||||
return {"Content-Type": "application/json", "X-Browser-Use-API-Key": config["api_key"]}
|
||||
|
||||
def _release_body(self, config: Dict[str, Any]) -> Dict[str, object]:
|
||||
return {"action": "stop"}
|
||||
|
||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||
config = self._get_config()
|
||||
@@ -232,120 +155,34 @@ class BrowserUseBrowserProvider(BrowserProvider):
|
||||
headers["X-Idempotency-Key"] = _get_or_create_pending_create_key(task_id)
|
||||
|
||||
# Keep gateway-backed sessions short so billing authorization does not
|
||||
# default to a long Browser-Use timeout when Hermes only needs a task-
|
||||
# scoped ephemeral browser.
|
||||
# default to a long Browser-Use timeout for a task-scoped browser.
|
||||
payload = (
|
||||
{
|
||||
"timeout": _DEFAULT_MANAGED_TIMEOUT_MINUTES,
|
||||
"proxyCountryCode": _DEFAULT_MANAGED_PROXY_COUNTRY_CODE,
|
||||
}
|
||||
if managed_mode
|
||||
else {}
|
||||
{"timeout": _DEFAULT_MANAGED_TIMEOUT_MINUTES, "proxyCountryCode": _DEFAULT_MANAGED_PROXY_COUNTRY_CODE}
|
||||
if managed_mode else {}
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{config['base_url']}/browsers",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=30,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
# Managed mode: propagate raw so callers can retry with the
|
||||
# preserved idempotency key. Direct mode: wrap network failures
|
||||
# into a clean RuntimeError for end users.
|
||||
if managed_mode:
|
||||
raise
|
||||
raise RuntimeError(
|
||||
f"Browser Use API connection failed: {exc}"
|
||||
) from exc
|
||||
|
||||
if not response.ok:
|
||||
if managed_mode and not _should_preserve_pending_create_key(response):
|
||||
_clear_pending_create_key(task_id)
|
||||
raise RuntimeError(
|
||||
f"Failed to create Browser Use session: "
|
||||
f"{response.status_code} {response.text}"
|
||||
)
|
||||
# Managed mode propagates network errors raw so callers can retry with
|
||||
# the preserved idempotency key; direct mode wraps them.
|
||||
response = self._post_create(
|
||||
f"{config['base_url']}/browsers", headers, payload, wrap_errors=not managed_mode
|
||||
)
|
||||
if not response.ok and managed_mode and not _should_preserve_pending_create_key(response):
|
||||
_clear_pending_create_key(task_id)
|
||||
self._check_created(response)
|
||||
|
||||
session_data = response.json()
|
||||
if managed_mode:
|
||||
_clear_pending_create_key(task_id)
|
||||
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
||||
external_call_id = (
|
||||
response.headers.get("x-external-call-id") if managed_mode else None
|
||||
)
|
||||
|
||||
session_name = self._session_name(task_id)
|
||||
logger.info("Created Browser Use session %s", session_name)
|
||||
|
||||
cdp_url = session_data.get("cdpUrl") or session_data.get("connectUrl") or ""
|
||||
|
||||
return {
|
||||
"session_name": session_name,
|
||||
"bb_session_id": session_data["id"],
|
||||
"cdp_url": cdp_url,
|
||||
# Browser Use sessions have a fixed server-side lifetime. Preserve
|
||||
# the authority returned by the API so the dispatcher can retire an
|
||||
# expired CDP endpoint instead of reconnecting to it indefinitely.
|
||||
"cdp_url": session_data.get("cdpUrl") or session_data.get("connectUrl") or "",
|
||||
# Sessions have a fixed server-side lifetime; preserve the API's
|
||||
# authority so the dispatcher retires an expired CDP endpoint.
|
||||
"expires_at": session_data.get("timeoutAt"),
|
||||
"features": {"browser_use": True},
|
||||
"external_call_id": external_call_id,
|
||||
"external_call_id": response.headers.get("x-external-call-id") if managed_mode else None,
|
||||
}
|
||||
|
||||
def close_session(self, session_id: str) -> bool:
|
||||
try:
|
||||
config = self._get_config()
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Cannot close Browser Use session %s — missing credentials", session_id
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
response = requests.patch(
|
||||
f"{config['base_url']}/browsers/{session_id}",
|
||||
headers=self._headers(config),
|
||||
json={"action": "stop"},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code in {200, 201, 204}:
|
||||
logger.debug("Successfully closed Browser Use session %s", session_id)
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to close Browser Use session %s: HTTP %s - %s",
|
||||
session_id,
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Exception closing Browser Use session %s: %s", session_id, e)
|
||||
return False
|
||||
|
||||
def emergency_cleanup(self, session_id: str) -> None:
|
||||
config = self._get_config_or_none()
|
||||
if config is None:
|
||||
logger.warning(
|
||||
"Cannot emergency-cleanup Browser Use session %s — missing credentials",
|
||||
session_id,
|
||||
)
|
||||
return
|
||||
try:
|
||||
requests.patch(
|
||||
f"{config['base_url']}/browsers/{session_id}",
|
||||
headers=self._headers(config),
|
||||
json={"action": "stop"},
|
||||
timeout=5,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Emergency cleanup failed for Browser Use session %s: %s", session_id, e
|
||||
)
|
||||
|
||||
def get_setup_schema(self) -> Optional[Dict[str, Any]]:
|
||||
# Hidden from the hermes tools picker: the "Browser Use" row now
|
||||
# activates the CLI-based backend (tools/browser_use_cli.py). This
|
||||
# provider stays registered for the Nous gateway path and un-migrated
|
||||
# legacy cloud_provider configs.
|
||||
return None
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
"""Browserbase cloud browser plugin — bundled, auto-loaded.
|
||||
|
||||
Mirrors the ``plugins/web/<vendor>/`` and ``plugins/image_gen/openai/``
|
||||
layout: ``provider.py`` holds the provider class; ``__init__.py::register``
|
||||
instantiates and registers it via the plugin context.
|
||||
"""
|
||||
"""Browserbase cloud browser plugin — bundled, auto-loaded."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,5 +6,4 @@ from plugins.browser.browserbase.provider import BrowserbaseBrowserProvider
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register the Browserbase provider with the plugin context."""
|
||||
ctx.register_browser_provider(BrowserbaseBrowserProvider())
|
||||
|
||||
@@ -1,70 +1,44 @@
|
||||
"""Browserbase cloud browser provider — plugin form.
|
||||
"""Browserbase cloud browser provider (plugin form).
|
||||
|
||||
Subclasses :class:`agent.browser_provider.BrowserProvider` (the plugin-facing
|
||||
ABC introduced in PR #25214). The legacy in-tree module
|
||||
``tools.browser_providers.browserbase`` was removed in the same PR; this file
|
||||
is now the canonical implementation.
|
||||
Direct ``BROWSERBASE_API_KEY`` + ``BROWSERBASE_PROJECT_ID`` credentials only;
|
||||
the Nous subscription routes through Browser Use instead.
|
||||
|
||||
Browserbase requires direct ``BROWSERBASE_API_KEY`` and ``BROWSERBASE_PROJECT_ID``
|
||||
credentials. Managed Nous gateway support has been removed — the Nous
|
||||
subscription now routes through Browser Use instead (see
|
||||
``plugins/browser/browser_use/``).
|
||||
|
||||
Config keys this provider responds to::
|
||||
|
||||
browser:
|
||||
cloud_provider: "browserbase"
|
||||
|
||||
Auth env vars::
|
||||
|
||||
BROWSERBASE_API_KEY=... # https://browserbase.com
|
||||
BROWSERBASE_PROJECT_ID=...
|
||||
|
||||
Optional feature knobs::
|
||||
|
||||
BROWSERBASE_BASE_URL=... # default https://api.browserbase.com
|
||||
BROWSERBASE_PROXIES=true # default true
|
||||
BROWSERBASE_ADVANCED_STEALTH=false
|
||||
BROWSERBASE_KEEP_ALIVE=true # default true
|
||||
BROWSERBASE_SESSION_TIMEOUT=... (seconds, integer, max 21600 = 6h)
|
||||
Config: ``browser.cloud_provider: "browserbase"``. Optional knobs:
|
||||
``BROWSERBASE_BASE_URL`` (default https://api.browserbase.com),
|
||||
``BROWSERBASE_PROXIES`` (default true), ``BROWSERBASE_ADVANCED_STEALTH``
|
||||
(default false), ``BROWSERBASE_KEEP_ALIVE`` (default true),
|
||||
``BROWSERBASE_SESSION_TIMEOUT`` (seconds, integer, max 21600 = 6h).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from agent.browser_provider import BrowserProvider
|
||||
from agent.secret_scope import get_secret
|
||||
from plugins.browser._common import CloudBrowserProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BrowserbaseBrowserProvider(BrowserProvider):
|
||||
"""Browserbase (https://browserbase.com) cloud browser backend.
|
||||
class BrowserbaseBrowserProvider(CloudBrowserProvider):
|
||||
"""Browserbase (https://browserbase.com) cloud browser backend."""
|
||||
|
||||
Direct credentials only — managed-Nous-gateway support lives on the
|
||||
Browser Use provider now.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "browserbase"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "Browserbase"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._get_config_or_none() is not None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Config resolution
|
||||
# ------------------------------------------------------------------
|
||||
provider_id = "browserbase"
|
||||
label = "Browserbase"
|
||||
release_method = "post"
|
||||
release_path = "/v1/sessions/{session_id}"
|
||||
missing_credentials_error = (
|
||||
"Browserbase requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID "
|
||||
"environment variables."
|
||||
)
|
||||
close_fail_fmt = "Failed to close session %s: HTTP %s - %s"
|
||||
setup_tag = "Cloud browser with stealth and proxies"
|
||||
setup_env_vars = [
|
||||
{"key": "BROWSERBASE_API_KEY", "prompt": "Browserbase API key", "url": "https://browserbase.com"},
|
||||
{"key": "BROWSERBASE_PROJECT_ID", "prompt": "Browserbase project ID"},
|
||||
]
|
||||
|
||||
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
|
||||
api_key = get_secret("BROWSERBASE_API_KEY")
|
||||
@@ -73,140 +47,80 @@ class BrowserbaseBrowserProvider(BrowserProvider):
|
||||
return {
|
||||
"api_key": api_key,
|
||||
"project_id": project_id,
|
||||
"base_url": os.environ.get(
|
||||
"BROWSERBASE_BASE_URL", "https://api.browserbase.com"
|
||||
).rstrip("/"),
|
||||
"base_url": os.environ.get("BROWSERBASE_BASE_URL", "https://api.browserbase.com").rstrip("/"),
|
||||
}
|
||||
return None
|
||||
|
||||
def _get_config(self) -> Dict[str, Any]:
|
||||
config = self._get_config_or_none()
|
||||
if config is None:
|
||||
raise ValueError(
|
||||
"Browserbase requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID "
|
||||
"environment variables."
|
||||
)
|
||||
return config
|
||||
def _headers(self, config: Dict[str, Any]) -> Dict[str, str]:
|
||||
return {"Content-Type": "application/json", "X-BB-API-Key": config["api_key"]}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
def _release_headers(self, config: Dict[str, Any]) -> Dict[str, str]:
|
||||
return {"X-BB-API-Key": config["api_key"], "Content-Type": "application/json"}
|
||||
|
||||
def _release_body(self, config: Dict[str, Any]) -> Dict[str, object]:
|
||||
return {"projectId": config["project_id"], "status": "REQUEST_RELEASE"}
|
||||
|
||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||
config = self._get_config()
|
||||
|
||||
# Optional env-var knobs
|
||||
enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false"
|
||||
enable_advanced_stealth = (
|
||||
os.environ.get("BROWSERBASE_ADVANCED_STEALTH", "false").lower() == "true"
|
||||
)
|
||||
enable_keep_alive = (
|
||||
os.environ.get("BROWSERBASE_KEEP_ALIVE", "true").lower() != "false"
|
||||
)
|
||||
enable_advanced_stealth = os.environ.get("BROWSERBASE_ADVANCED_STEALTH", "false").lower() == "true"
|
||||
enable_keep_alive = os.environ.get("BROWSERBASE_KEEP_ALIVE", "true").lower() != "false"
|
||||
custom_timeout_ms = os.environ.get("BROWSERBASE_SESSION_TIMEOUT")
|
||||
|
||||
features_enabled = {
|
||||
"basic_stealth": True,
|
||||
"proxies": False,
|
||||
"advanced_stealth": False,
|
||||
"keep_alive": False,
|
||||
"custom_timeout": False,
|
||||
}
|
||||
|
||||
session_config: Dict[str, object] = {"projectId": config["project_id"]}
|
||||
|
||||
if enable_keep_alive:
|
||||
session_config["keepAlive"] = True
|
||||
|
||||
if custom_timeout_ms:
|
||||
try:
|
||||
timeout_val = int(custom_timeout_ms)
|
||||
if timeout_val > 0:
|
||||
session_config["timeout"] = timeout_val
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Invalid BROWSERBASE_SESSION_TIMEOUT value: %s", custom_timeout_ms
|
||||
)
|
||||
|
||||
logger.warning("Invalid BROWSERBASE_SESSION_TIMEOUT value: %s", custom_timeout_ms)
|
||||
if enable_proxies:
|
||||
session_config["proxies"] = True
|
||||
|
||||
if enable_advanced_stealth:
|
||||
session_config["browserSettings"] = {"advancedStealth": True}
|
||||
|
||||
# --- Create session via API ---
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
}
|
||||
url = f"{config['base_url']}/v1/sessions"
|
||||
headers = self._headers(config)
|
||||
response = self._post_create(url, headers, session_config)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{config['base_url']}/v1/sessions",
|
||||
headers=headers,
|
||||
json=session_config,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
proxies_fallback = False
|
||||
keepalive_fallback = False
|
||||
|
||||
# Handle 402 — paid features unavailable
|
||||
if response.status_code == 402:
|
||||
if enable_keep_alive:
|
||||
keepalive_fallback = True
|
||||
logger.warning(
|
||||
"keepAlive may require paid plan (402), retrying without it. "
|
||||
"Sessions may timeout during long operations."
|
||||
)
|
||||
session_config.pop("keepAlive", None)
|
||||
response = requests.post(
|
||||
f"{config['base_url']}/v1/sessions",
|
||||
headers=headers,
|
||||
json=session_config,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if response.status_code == 402 and enable_proxies:
|
||||
proxies_fallback = True
|
||||
logger.warning(
|
||||
"Proxies unavailable (402), retrying without proxies. "
|
||||
"Bot detection may be less effective."
|
||||
)
|
||||
session_config.pop("proxies", None)
|
||||
response = requests.post(
|
||||
f"{config['base_url']}/v1/sessions",
|
||||
headers=headers,
|
||||
json=session_config,
|
||||
timeout=30,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise RuntimeError(
|
||||
f"Browserbase API connection failed: {exc}"
|
||||
) from exc
|
||||
|
||||
if not response.ok:
|
||||
raise RuntimeError(
|
||||
f"Failed to create Browserbase session: "
|
||||
f"{response.status_code} {response.text}"
|
||||
)
|
||||
# 402 — paid features unavailable: drop keepAlive, then proxies, and retry.
|
||||
proxies_fallback = False
|
||||
keepalive_fallback = False
|
||||
if response.status_code == 402:
|
||||
if enable_keep_alive:
|
||||
keepalive_fallback = True
|
||||
logger.warning(
|
||||
"keepAlive may require paid plan (402), retrying without it. "
|
||||
"Sessions may timeout during long operations."
|
||||
)
|
||||
session_config.pop("keepAlive", None)
|
||||
response = self._post_create(url, headers, session_config)
|
||||
if response.status_code == 402 and enable_proxies:
|
||||
proxies_fallback = True
|
||||
logger.warning(
|
||||
"Proxies unavailable (402), retrying without proxies. "
|
||||
"Bot detection may be less effective."
|
||||
)
|
||||
session_config.pop("proxies", None)
|
||||
response = self._post_create(url, headers, session_config)
|
||||
self._check_created(response)
|
||||
|
||||
session_data = response.json()
|
||||
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
if enable_proxies and not proxies_fallback:
|
||||
features_enabled["proxies"] = True
|
||||
if enable_advanced_stealth:
|
||||
features_enabled["advanced_stealth"] = True
|
||||
if enable_keep_alive and not keepalive_fallback:
|
||||
features_enabled["keep_alive"] = True
|
||||
if custom_timeout_ms and "timeout" in session_config:
|
||||
features_enabled["custom_timeout"] = True
|
||||
session_name = self._session_name(task_id)
|
||||
|
||||
features_enabled = {
|
||||
"basic_stealth": True,
|
||||
"proxies": enable_proxies and not proxies_fallback,
|
||||
"advanced_stealth": enable_advanced_stealth,
|
||||
"keep_alive": enable_keep_alive and not keepalive_fallback,
|
||||
"custom_timeout": bool(custom_timeout_ms) and "timeout" in session_config,
|
||||
}
|
||||
feature_str = ", ".join(k for k, v in features_enabled.items() if v)
|
||||
logger.info(
|
||||
"Created Browserbase session %s with features: %s", session_name, feature_str
|
||||
)
|
||||
logger.info("Created Browserbase session %s with features: %s", session_name, feature_str)
|
||||
|
||||
return {
|
||||
"session_name": session_name,
|
||||
@@ -214,87 +128,3 @@ class BrowserbaseBrowserProvider(BrowserProvider):
|
||||
"cdp_url": session_data["connectUrl"],
|
||||
"features": features_enabled,
|
||||
}
|
||||
|
||||
def close_session(self, session_id: str) -> bool:
|
||||
try:
|
||||
config = self._get_config()
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Cannot close Browserbase session %s — missing credentials", session_id
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{config['base_url']}/v1/sessions/{session_id}",
|
||||
headers={
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"projectId": config["project_id"],
|
||||
"status": "REQUEST_RELEASE",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code in {200, 201, 204}:
|
||||
logger.debug("Successfully closed Browserbase session %s", session_id)
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to close session %s: HTTP %s - %s",
|
||||
session_id,
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Exception closing Browserbase session %s: %s", session_id, e)
|
||||
return False
|
||||
|
||||
def emergency_cleanup(self, session_id: str) -> None:
|
||||
config = self._get_config_or_none()
|
||||
if config is None:
|
||||
logger.warning(
|
||||
"Cannot emergency-cleanup Browserbase session %s — missing credentials",
|
||||
session_id,
|
||||
)
|
||||
return
|
||||
try:
|
||||
requests.post(
|
||||
f"{config['base_url']}/v1/sessions/{session_id}",
|
||||
headers={
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"projectId": config["project_id"],
|
||||
"status": "REQUEST_RELEASE",
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Emergency cleanup failed for Browserbase session %s: %s", session_id, e
|
||||
)
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "Browserbase",
|
||||
"badge": "paid",
|
||||
"tag": "Cloud browser with stealth and proxies",
|
||||
"env_vars": [
|
||||
{
|
||||
"key": "BROWSERBASE_API_KEY",
|
||||
"prompt": "Browserbase API key",
|
||||
"url": "https://browserbase.com",
|
||||
},
|
||||
{
|
||||
"key": "BROWSERBASE_PROJECT_ID",
|
||||
"prompt": "Browserbase project ID",
|
||||
},
|
||||
],
|
||||
# Cloud-scoped hook: installs the agent-browser CLI only (no
|
||||
# local Chromium — Browserbase hosts the browser).
|
||||
"post_setup": "browserbase",
|
||||
}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
"""Firecrawl cloud browser plugin — bundled, auto-loaded.
|
||||
|
||||
Distinct from ``plugins/web/firecrawl/`` (the web search/extract/crawl
|
||||
plugin); both share the FIRECRAWL_API_KEY but speak to different endpoints
|
||||
(``/v2/browser`` here vs ``/v2/search`` / ``/v2/scrape`` / ``/v2/crawl``
|
||||
over there).
|
||||
"""
|
||||
"""Firecrawl cloud browser plugin — bundled, auto-loaded (distinct from plugins/web/firecrawl/)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,5 +6,4 @@ from plugins.browser.firecrawl.provider import FirecrawlBrowserProvider
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register the Firecrawl cloud-browser provider with the plugin context."""
|
||||
ctx.register_browser_provider(FirecrawlBrowserProvider())
|
||||
|
||||
@@ -1,82 +1,61 @@
|
||||
"""Firecrawl cloud browser provider — plugin form.
|
||||
"""Firecrawl cloud browser provider (plugin form).
|
||||
|
||||
Subclasses :class:`agent.browser_provider.BrowserProvider` (the plugin-facing
|
||||
ABC introduced in PR #25214). The legacy in-tree module
|
||||
``tools.browser_providers.firecrawl`` was removed in the same PR; this file
|
||||
is now the canonical implementation.
|
||||
Cloud-browser path only (``/v2/browser``) — distinct from the firecrawl WEB
|
||||
plugin at ``plugins/web/firecrawl/`` (search/extract/crawl on ``/v2/*``); both
|
||||
share ``FIRECRAWL_API_KEY``.
|
||||
|
||||
This is the cloud-browser path — distinct from the firecrawl WEB plugin at
|
||||
``plugins/web/firecrawl/`` which handles search/extract/crawl on
|
||||
``/v2/search`` / ``/v2/scrape`` / ``/v2/crawl``. The two plugins share the
|
||||
``FIRECRAWL_API_KEY`` env var but talk to different endpoints (this one
|
||||
hits ``/v2/browser``).
|
||||
|
||||
Config keys this provider responds to::
|
||||
|
||||
browser:
|
||||
cloud_provider: "firecrawl" # explicit selection only — not in the
|
||||
# legacy auto-detect walk
|
||||
|
||||
Auth env vars::
|
||||
|
||||
FIRECRAWL_API_KEY=... # https://firecrawl.dev
|
||||
FIRECRAWL_API_URL=... # optional override (default https://api.firecrawl.dev)
|
||||
FIRECRAWL_BROWSER_TTL=... # optional, default 300 seconds
|
||||
Config: ``browser.cloud_provider: "firecrawl"`` (explicit selection only — not
|
||||
in the legacy auto-detect walk). Env: ``FIRECRAWL_API_KEY``
|
||||
(https://firecrawl.dev), ``FIRECRAWL_API_URL`` (default
|
||||
https://api.firecrawl.dev), ``FIRECRAWL_BROWSER_TTL`` (default 300 seconds).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from agent.browser_provider import BrowserProvider
|
||||
from agent.secret_scope import get_secret
|
||||
from plugins.browser._common import CloudBrowserProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BASE_URL = "https://api.firecrawl.dev"
|
||||
|
||||
|
||||
class FirecrawlBrowserProvider(BrowserProvider):
|
||||
"""Firecrawl (https://firecrawl.dev) cloud browser backend.
|
||||
class FirecrawlBrowserProvider(CloudBrowserProvider):
|
||||
"""Firecrawl (https://firecrawl.dev) cloud browser backend."""
|
||||
|
||||
Cloud-browser path only — search/extract/crawl live in the separate
|
||||
``plugins/web/firecrawl/`` plugin.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "firecrawl"
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return "Firecrawl"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return bool(get_secret("FIRECRAWL_API_KEY"))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
provider_id = "firecrawl"
|
||||
label = "Firecrawl"
|
||||
release_method = "delete"
|
||||
release_path = "/v2/browser/{session_id}"
|
||||
create_label_suffix = " browser"
|
||||
setup_tag = "Cloud browser with remote execution"
|
||||
setup_env_vars = [
|
||||
{"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"},
|
||||
]
|
||||
|
||||
def _api_url(self) -> str:
|
||||
return os.environ.get("FIRECRAWL_API_URL", _BASE_URL)
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
|
||||
return {"base_url": self._api_url()} if get_secret("FIRECRAWL_API_KEY") else None
|
||||
|
||||
def _get_config(self) -> Dict[str, Any]:
|
||||
# Never raises: a missing key surfaces from _headers() inside the request
|
||||
# try-block, so close_session logs it as an exception (legacy behaviour).
|
||||
return {"base_url": self._api_url()}
|
||||
|
||||
def _headers(self, config: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
|
||||
api_key = get_secret("FIRECRAWL_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"FIRECRAWL_API_KEY environment variable is required. "
|
||||
"Get your key at https://firecrawl.dev"
|
||||
)
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
}
|
||||
return {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
|
||||
|
||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||
try:
|
||||
@@ -84,29 +63,11 @@ class FirecrawlBrowserProvider(BrowserProvider):
|
||||
except (ValueError, TypeError):
|
||||
ttl = 300
|
||||
|
||||
body: Dict[str, object] = {"ttl": ttl}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self._api_url()}/v2/browser",
|
||||
headers=self._headers(),
|
||||
json=body,
|
||||
timeout=30,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise RuntimeError(
|
||||
f"Firecrawl API connection failed: {exc}"
|
||||
) from exc
|
||||
|
||||
if not response.ok:
|
||||
raise RuntimeError(
|
||||
f"Failed to create Firecrawl browser session: "
|
||||
f"{response.status_code} {response.text}"
|
||||
)
|
||||
response = self._post_create(f"{self._api_url()}/v2/browser", self._headers(), {"ttl": ttl})
|
||||
self._check_created(response)
|
||||
|
||||
data = response.json()
|
||||
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
session_name = self._session_name(task_id)
|
||||
logger.info("Created Firecrawl browser session %s", session_name)
|
||||
|
||||
return {
|
||||
@@ -115,60 +76,3 @@ class FirecrawlBrowserProvider(BrowserProvider):
|
||||
"cdp_url": data["cdpUrl"],
|
||||
"features": {"firecrawl": True},
|
||||
}
|
||||
|
||||
def close_session(self, session_id: str) -> bool:
|
||||
try:
|
||||
response = requests.delete(
|
||||
f"{self._api_url()}/v2/browser/{session_id}",
|
||||
headers=self._headers(),
|
||||
timeout=10,
|
||||
)
|
||||
if response.status_code in {200, 201, 204}:
|
||||
logger.debug("Successfully closed Firecrawl session %s", session_id)
|
||||
return True
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to close Firecrawl session %s: HTTP %s - %s",
|
||||
session_id,
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error("Exception closing Firecrawl session %s: %s", session_id, e)
|
||||
return False
|
||||
|
||||
def emergency_cleanup(self, session_id: str) -> None:
|
||||
if not self.is_available():
|
||||
logger.warning(
|
||||
"Cannot emergency-cleanup Firecrawl session %s — missing credentials",
|
||||
session_id,
|
||||
)
|
||||
return
|
||||
try:
|
||||
requests.delete(
|
||||
f"{self._api_url()}/v2/browser/{session_id}",
|
||||
headers=self._headers(),
|
||||
timeout=5,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Emergency cleanup failed for Firecrawl session %s: %s", session_id, e
|
||||
)
|
||||
|
||||
def get_setup_schema(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "Firecrawl",
|
||||
"badge": "paid",
|
||||
"tag": "Cloud browser with remote execution",
|
||||
"env_vars": [
|
||||
{
|
||||
"key": "FIRECRAWL_API_KEY",
|
||||
"prompt": "Firecrawl API key",
|
||||
"url": "https://firecrawl.dev",
|
||||
},
|
||||
],
|
||||
# Cloud-scoped hook: installs the agent-browser CLI only (no
|
||||
# local Chromium — Firecrawl hosts the browser).
|
||||
"post_setup": "browserbase",
|
||||
}
|
||||
|
||||
@@ -154,6 +154,9 @@ def _install_fake_tools_package():
|
||||
sys.modules[f"plugins.browser.{_name}.provider"] = types.SimpleNamespace(
|
||||
**{_classname: _provider_stub_cls},
|
||||
)
|
||||
# The fake ``plugins.browser`` package has an empty __path__, so the shared
|
||||
# base the real vendor modules import must be loaded by path as well.
|
||||
_load_plugin_module("plugins.browser._common", "browser/_common.py")
|
||||
|
||||
sys.modules["tools.managed_tool_gateway"] = _load_tool_module(
|
||||
"tools.managed_tool_gateway",
|
||||
|
||||
Reference in New Issue
Block a user