Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7ca6887
feat: add browser-scoped session client
rgarcia Apr 13, 2026
b2c7aac
fix: reserve internal browser request query params
rgarcia Apr 13, 2026
cfff5b4
fix: type-check browser-scoped helpers
rgarcia Apr 13, 2026
fc34859
chore: fix browser-scoped test import order
rgarcia Apr 13, 2026
8e8dde2
fix: satisfy browser-scoped lint checks
rgarcia Apr 13, 2026
53b17c8
feat: generate browser-scoped resource bindings
rgarcia Apr 13, 2026
0bdf85e
fix: quiet generator-script pyright noise
rgarcia Apr 13, 2026
b410245
fix: satisfy generated browser-scoped type checks
rgarcia Apr 13, 2026
a80716b
chore: keep browser-scoped generator lint clean
rgarcia Apr 13, 2026
ca5d188
docs: flesh out browser-scoped example
rgarcia Apr 21, 2026
dba503e
refactor: drop browser-scoped wrapper clients
rgarcia Apr 22, 2026
de0476f
refactor: simplify browser routing cache
rgarcia Apr 22, 2026
3ae9dab
refactor: rename browser routing subresources config
rgarcia Apr 22, 2026
622f844
refactor: clean up python browser routing diff
rgarcia Apr 22, 2026
694907a
fix: finish python browser routing cleanup
rgarcia Apr 23, 2026
9690923
fix: address python browser routing ci follow-ups
rgarcia Apr 23, 2026
3ce80e7
fix: normalize python browser request string bodies
rgarcia Apr 23, 2026
0647d5c
refactor: move python browser routing rollout to env
rgarcia Apr 24, 2026
f4c247b
fix: normalize browser route cache session IDs
rgarcia Apr 24, 2026
563de7d
refactor: sniff browser routes in response hooks
rgarcia Apr 24, 2026
a873a18
fix: evict deleted browser routes
rgarcia Apr 24, 2026
02a2f59
refactor: inline browser resource passthrough returns
rgarcia Apr 24, 2026
5328730
fix: sniff browser pool route cache updates
rgarcia Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions examples/browser_scoped.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Example: direct-to-VM browser routing for process exec and raw HTTP."""

from kernel import BrowserRoutingConfig, Kernel


def main() -> None:
with Kernel(browser_routing=BrowserRoutingConfig(enabled=True, direct_to_vm_subresources=("process",))) as client:
browser = client.browsers.create(headless=True)
try:
client.prime_browser_route_cache(browser)

client.browsers.process.exec(browser.session_id, command="uname", args=["-a"])

response = client.browsers.request(browser.session_id, "GET", "https://example.com")
print("status", response.status_code)

with client.browsers.stream(browser.session_id, "GET", "https://example.com") as streamed:
print("streamed-bytes", len(streamed.read()))
finally:
client.browsers.delete_by_id(browser.session_id)


if __name__ == "__main__":
main()
2 changes: 2 additions & 0 deletions src/kernel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from ._utils import file_from_path
from ._client import (
ENVIRONMENTS,
BrowserRoutingConfig,
Client,
Kernel,
Stream,
Expand Down Expand Up @@ -79,6 +80,7 @@
"RateLimitError",
"InternalServerError",
"Timeout",
"BrowserRoutingConfig",
"RequestOptions",
"Client",
"AsyncClient",
Expand Down
47 changes: 47 additions & 0 deletions src/kernel/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@
SyncAPIClient,
AsyncAPIClient,
)
from .lib.browser_scoped.routing import (
BrowserRouteCache,
BrowserRoutingConfig,
rewrite_direct_vm_options,
strip_direct_vm_auth,
)

if TYPE_CHECKING:
from .resources import (
Expand Down Expand Up @@ -64,6 +70,7 @@
"Transport",
"ProxiesTypes",
"RequestOptions",
"BrowserRoutingConfig",
"Kernel",
"AsyncKernel",
"Client",
Expand All @@ -79,8 +86,10 @@
class Kernel(SyncAPIClient):
# client options
api_key: str
browser_route_cache: BrowserRouteCache

_environment: Literal["production", "development"] | NotGiven
_browser_routing: BrowserRoutingConfig | None

def __init__(
self,
Expand All @@ -92,6 +101,7 @@ def __init__(
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
browser_routing: BrowserRoutingConfig | None = None,
# Configure a custom httpx client.
# We provide a `DefaultHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`.
# See the [httpx documentation](https://www.python-httpx.org/api/#client) for more details.
Expand All @@ -105,6 +115,7 @@ def __init__(
# outlining your use-case to help us decide if it should be
# part of our public interface in the future.
_strict_response_validation: bool = False,
_browser_route_cache: BrowserRouteCache | None = None,
) -> None:
"""Construct a new synchronous Kernel client instance.

Expand Down Expand Up @@ -154,6 +165,8 @@ def __init__(
custom_query=default_query,
_strict_response_validation=_strict_response_validation,
)
self.browser_route_cache = _browser_route_cache or BrowserRouteCache()
self._browser_routing = browser_routing

@cached_property
def deployments(self) -> DeploymentsResource:
Expand Down Expand Up @@ -266,6 +279,15 @@ def default_headers(self) -> dict[str, str | Omit]:
**self._custom_headers,
}

@override
def _prepare_options(self, options: Any) -> Any:
options = cast(Any, super()._prepare_options(options))
return rewrite_direct_vm_options(options, cache=self.browser_route_cache, config=self._browser_routing)

@override
def _prepare_request(self, request: httpx.Request) -> None:
strip_direct_vm_auth(request, cache=self.browser_route_cache)

def copy(
self,
*,
Expand Down Expand Up @@ -312,13 +334,18 @@ def copy(
max_retries=max_retries if is_given(max_retries) else self.max_retries,
default_headers=headers,
default_query=params,
browser_routing=self._browser_routing,
_browser_route_cache=self.browser_route_cache,
**_extra_kwargs,
)

# Alias for `copy` for nicer inline usage, e.g.
# client.with_options(timeout=10).foo.create(...)
with_options = copy

def prime_browser_route_cache(self, browser: Any) -> None:
self.browser_route_cache.prime(browser)

@override
def _make_status_error(
self,
Expand Down Expand Up @@ -356,8 +383,10 @@ def _make_status_error(
class AsyncKernel(AsyncAPIClient):
# client options
api_key: str
browser_route_cache: BrowserRouteCache

_environment: Literal["production", "development"] | NotGiven
_browser_routing: BrowserRoutingConfig | None

def __init__(
self,
Expand All @@ -369,6 +398,7 @@ def __init__(
max_retries: int = DEFAULT_MAX_RETRIES,
default_headers: Mapping[str, str] | None = None,
default_query: Mapping[str, object] | None = None,
browser_routing: BrowserRoutingConfig | None = None,
# Configure a custom httpx client.
# We provide a `DefaultAsyncHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`.
# See the [httpx documentation](https://www.python-httpx.org/api/#asyncclient) for more details.
Expand All @@ -382,6 +412,7 @@ def __init__(
# outlining your use-case to help us decide if it should be
# part of our public interface in the future.
_strict_response_validation: bool = False,
_browser_route_cache: BrowserRouteCache | None = None,
) -> None:
"""Construct a new async AsyncKernel client instance.

Expand Down Expand Up @@ -431,6 +462,8 @@ def __init__(
custom_query=default_query,
_strict_response_validation=_strict_response_validation,
)
self.browser_route_cache = _browser_route_cache or BrowserRouteCache()
self._browser_routing = browser_routing

@cached_property
def deployments(self) -> AsyncDeploymentsResource:
Expand Down Expand Up @@ -543,6 +576,15 @@ def default_headers(self) -> dict[str, str | Omit]:
**self._custom_headers,
}

@override
async def _prepare_options(self, options: Any) -> Any:
options = cast(Any, await super()._prepare_options(options))
return rewrite_direct_vm_options(options, cache=self.browser_route_cache, config=self._browser_routing)

@override
async def _prepare_request(self, request: httpx.Request) -> None:
strip_direct_vm_auth(request, cache=self.browser_route_cache)

def copy(
self,
*,
Expand Down Expand Up @@ -589,13 +631,18 @@ def copy(
max_retries=max_retries if is_given(max_retries) else self.max_retries,
default_headers=headers,
default_query=params,
browser_routing=self._browser_routing,
_browser_route_cache=self.browser_route_cache,
**_extra_kwargs,
)

# Alias for `copy` for nicer inline usage, e.g.
# client.with_options(timeout=10).foo.create(...)
with_options = copy

def prime_browser_route_cache(self, browser: Any) -> None:
self.browser_route_cache.prime(browser)

@override
def _make_status_error(
self,
Expand Down
1 change: 1 addition & 0 deletions src/kernel/lib/browser_scoped/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__all__: list[str] = []
101 changes: 101 additions & 0 deletions src/kernel/lib/browser_scoped/browser_session_kernel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Internal Kernel clones for browser session HTTP (base_url + /browser/kernel paths)."""

from __future__ import annotations

from typing import Any, Mapping, cast
from typing_extensions import override

from ..._client import Kernel, AsyncKernel
from ..._compat import model_copy
from ..._models import FinalRequestOptions


class _BrowserSessionKernel(Kernel):
"""Kernel clone whose HTTP base is the browser session; strips /browsers/{id} from paths."""

_scoped_session_id: str

def __init__(self, *, browser_session_id: str, **kwargs: Any) -> None:
self._scoped_session_id = browser_session_id
super().__init__(**kwargs)

@override
def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions:
options = super()._prepare_options(options)
url = options.url
prefix = f"/browsers/{self._scoped_session_id}/"
if not url.startswith(prefix):
return options
suffix = url[len(prefix) :].lstrip("/")
new_url = f"/{suffix}" if suffix else "/"
out = model_copy(options)
out.url = new_url
return out


class _BrowserSessionAsyncKernel(AsyncKernel):
_scoped_session_id: str

def __init__(self, *, browser_session_id: str, **kwargs: Any) -> None:
self._scoped_session_id = browser_session_id
super().__init__(**kwargs)

@override
async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions:
options = await super()._prepare_options(options)
url = options.url
prefix = f"/browsers/{self._scoped_session_id}/"
if not url.startswith(prefix):
return options
suffix = url[len(prefix) :].lstrip("/")
new_url = f"/{suffix}" if suffix else "/"
out = model_copy(options)
out.url = new_url
return out


def build_browser_session_kernel(
parent: Kernel, *, session_id: str, session_base_url: str, jwt: str
) -> _BrowserSessionKernel:
"""Build a sync client sharing the parent's httpx transport; requests use session_base_url."""
base_q_raw = getattr(parent, "_custom_query", None)
if isinstance(base_q_raw, Mapping):
base_q = {str(k): v for k, v in cast(Mapping[str, object], base_q_raw).items()}
else:
base_q = {}
dq = dict(base_q)
dq["jwt"] = jwt
return _BrowserSessionKernel(
browser_session_id=session_id,
api_key=parent.api_key,
base_url=session_base_url,
timeout=parent.timeout,
max_retries=parent.max_retries,
http_client=parent._client,
default_headers=dict(parent._custom_headers),
default_query=dq,
_strict_response_validation=getattr(parent, "_strict_response_validation", False),
)


def build_async_browser_session_kernel(
parent: AsyncKernel, *, session_id: str, session_base_url: str, jwt: str
) -> _BrowserSessionAsyncKernel:
base_q_raw = getattr(parent, "_custom_query", None)
if isinstance(base_q_raw, Mapping):
base_q = {str(k): v for k, v in cast(Mapping[str, object], base_q_raw).items()}
else:
base_q = {}
dq = dict(base_q)
dq["jwt"] = jwt
return _BrowserSessionAsyncKernel(
browser_session_id=session_id,
api_key=parent.api_key,
base_url=session_base_url,
timeout=parent.timeout,
max_retries=parent.max_retries,
http_client=parent._client,
default_headers=dict(parent._custom_headers),
default_query=dq,
_strict_response_validation=getattr(parent, "_strict_response_validation", False),
)
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Loading
Loading