diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index dd7ced1..26b1ce2 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "0.49.0"
+ ".": "0.50.0"
}
\ No newline at end of file
diff --git a/.stats.yml b/.stats.yml
index ad1b0f2..f0ba92f 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
-configured_endpoints: 111
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-49a1a92e00d1eb87e91e8527275cb0705fce2edea30e70fea745f134dd451fbd.yml
-openapi_spec_hash: 0ffef6a95f9d9b1096180fc5e4c5b39c
-config_hash: 9818dd634f87b677410eefd013d7a179
+configured_endpoints: 112
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-930823e8b25b4644b74098ad5479840f64a329321aa236460f8a9562ae9051bf.yml
+openapi_spec_hash: 9f868e67df8fd2fec8d8fc3eb5ba0b26
+config_hash: 08d55086449943a8fec212b870061a3f
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cf3d652..0194846 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## 0.50.0 (2026-04-13)
+
+Full Changelog: [v0.49.0...v0.50.0](https://github.com/kernel/kernel-python-sdk/compare/v0.49.0...v0.50.0)
+
+### Features
+
+* add POST /browsers/{id}/curl and /curl/raw endpoints ([e91bc38](https://github.com/kernel/kernel-python-sdk/commit/e91bc387e5ef74c1b02f62e19e9ae31867296af4))
+
+
+### Bug Fixes
+
+* ensure file data are only sent as 1 parameter ([e566aa5](https://github.com/kernel/kernel-python-sdk/commit/e566aa50a9022d5b284c6334b3704df2a96643cc))
+
## 0.49.0 (2026-04-10)
Full Changelog: [v0.48.0...v0.49.0](https://github.com/kernel/kernel-python-sdk/compare/v0.48.0...v0.49.0)
diff --git a/api.md b/api.md
index 96c90c4..3dea16a 100644
--- a/api.md
+++ b/api.md
@@ -88,6 +88,7 @@ from kernel.types import (
BrowserRetrieveResponse,
BrowserUpdateResponse,
BrowserListResponse,
+ BrowserCurlResponse,
)
```
@@ -98,6 +99,7 @@ Methods:
- client.browsers.update(id, \*\*params) -> BrowserUpdateResponse
- client.browsers.list(\*\*params) -> SyncOffsetPagination[BrowserListResponse]
- client.browsers.delete(\*\*params) -> None
+- client.browsers.curl(id, \*\*params) -> BrowserCurlResponse
- client.browsers.delete_by_id(id) -> None
- client.browsers.load_extensions(id, \*\*params) -> None
diff --git a/pyproject.toml b/pyproject.toml
index 35efdc5..5516926 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "kernel"
-version = "0.49.0"
+version = "0.50.0"
description = "The official Python library for the kernel API"
dynamic = ["readme"]
license = "Apache-2.0"
diff --git a/src/kernel/_utils/_utils.py b/src/kernel/_utils/_utils.py
index eec7f4a..63b8cd6 100644
--- a/src/kernel/_utils/_utils.py
+++ b/src/kernel/_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/src/kernel/_version.py b/src/kernel/_version.py
index 443cf3f..81d0216 100644
--- a/src/kernel/_version.py
+++ b/src/kernel/_version.py
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
__title__ = "kernel"
-__version__ = "0.49.0" # x-release-please-version
+__version__ = "0.50.0" # x-release-please-version
diff --git a/src/kernel/resources/browsers/browsers.py b/src/kernel/resources/browsers/browsers.py
index 1e20142..3fc1049 100644
--- a/src/kernel/resources/browsers/browsers.py
+++ b/src/kernel/resources/browsers/browsers.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import typing_extensions
-from typing import Mapping, Iterable, Optional, cast
+from typing import Dict, Mapping, Iterable, Optional, cast
from typing_extensions import Literal
import httpx
@@ -25,6 +25,7 @@
AsyncFsResourceWithStreamingResponse,
)
from ...types import (
+ browser_curl_params,
browser_list_params,
browser_create_params,
browser_delete_params,
@@ -76,6 +77,7 @@
)
from ...pagination import SyncOffsetPagination, AsyncOffsetPagination
from ..._base_client import AsyncPaginator, make_request_options
+from ...types.browser_curl_response import BrowserCurlResponse
from ...types.browser_list_response import BrowserListResponse
from ...types.browser_create_response import BrowserCreateResponse
from ...types.browser_update_response import BrowserUpdateResponse
@@ -443,6 +445,70 @@ def delete(
cast_to=NoneType,
)
+ def curl(
+ self,
+ id: str,
+ *,
+ url: str,
+ body: str | Omit = omit,
+ headers: Dict[str, str] | Omit = omit,
+ method: Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] | Omit = omit,
+ response_encoding: Literal["utf8", "base64"] | Omit = omit,
+ timeout_ms: int | Omit = omit,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> BrowserCurlResponse:
+ """
+ Sends an HTTP request through Chrome's HTTP request stack, inheriting the
+ browser's TLS fingerprint, cookies, proxy configuration, and headers. Returns a
+ structured JSON response with status, headers, body, and timing.
+
+ Args:
+ url: Target URL (must be http or https).
+
+ body: Request body (for POST/PUT/PATCH).
+
+ headers: Custom headers merged with browser defaults.
+
+ method: HTTP method.
+
+ response_encoding: Encoding for the response body. Use base64 for binary content.
+
+ timeout_ms: Request timeout in milliseconds.
+
+ extra_headers: Send extra headers
+
+ extra_query: Add additional query parameters to the request
+
+ extra_body: Add additional JSON properties to the request
+
+ timeout: Override the client-level default timeout for this request, in seconds
+ """
+ if not id:
+ raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
+ return self._post(
+ path_template("/browsers/{id}/curl", id=id),
+ body=maybe_transform(
+ {
+ "url": url,
+ "body": body,
+ "headers": headers,
+ "method": method,
+ "response_encoding": response_encoding,
+ "timeout_ms": timeout_ms,
+ },
+ browser_curl_params.BrowserCurlParams,
+ ),
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=BrowserCurlResponse,
+ )
+
def delete_by_id(
self,
id: str,
@@ -881,6 +947,70 @@ async def delete(
cast_to=NoneType,
)
+ async def curl(
+ self,
+ id: str,
+ *,
+ url: str,
+ body: str | Omit = omit,
+ headers: Dict[str, str] | Omit = omit,
+ method: Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] | Omit = omit,
+ response_encoding: Literal["utf8", "base64"] | Omit = omit,
+ timeout_ms: int | Omit = omit,
+ # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
+ # The extra values given here take precedence over values defined on the client or passed to this method.
+ extra_headers: Headers | None = None,
+ extra_query: Query | None = None,
+ extra_body: Body | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = not_given,
+ ) -> BrowserCurlResponse:
+ """
+ Sends an HTTP request through Chrome's HTTP request stack, inheriting the
+ browser's TLS fingerprint, cookies, proxy configuration, and headers. Returns a
+ structured JSON response with status, headers, body, and timing.
+
+ Args:
+ url: Target URL (must be http or https).
+
+ body: Request body (for POST/PUT/PATCH).
+
+ headers: Custom headers merged with browser defaults.
+
+ method: HTTP method.
+
+ response_encoding: Encoding for the response body. Use base64 for binary content.
+
+ timeout_ms: Request timeout in milliseconds.
+
+ extra_headers: Send extra headers
+
+ extra_query: Add additional query parameters to the request
+
+ extra_body: Add additional JSON properties to the request
+
+ timeout: Override the client-level default timeout for this request, in seconds
+ """
+ if not id:
+ raise ValueError(f"Expected a non-empty value for `id` but received {id!r}")
+ return await self._post(
+ path_template("/browsers/{id}/curl", id=id),
+ body=await async_maybe_transform(
+ {
+ "url": url,
+ "body": body,
+ "headers": headers,
+ "method": method,
+ "response_encoding": response_encoding,
+ "timeout_ms": timeout_ms,
+ },
+ browser_curl_params.BrowserCurlParams,
+ ),
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=BrowserCurlResponse,
+ )
+
async def delete_by_id(
self,
id: str,
@@ -983,6 +1113,9 @@ def __init__(self, browsers: BrowsersResource) -> None:
browsers.delete, # pyright: ignore[reportDeprecated],
)
)
+ self.curl = to_raw_response_wrapper(
+ browsers.curl,
+ )
self.delete_by_id = to_raw_response_wrapper(
browsers.delete_by_id,
)
@@ -1041,6 +1174,9 @@ def __init__(self, browsers: AsyncBrowsersResource) -> None:
browsers.delete, # pyright: ignore[reportDeprecated],
)
)
+ self.curl = async_to_raw_response_wrapper(
+ browsers.curl,
+ )
self.delete_by_id = async_to_raw_response_wrapper(
browsers.delete_by_id,
)
@@ -1099,6 +1235,9 @@ def __init__(self, browsers: BrowsersResource) -> None:
browsers.delete, # pyright: ignore[reportDeprecated],
)
)
+ self.curl = to_streamed_response_wrapper(
+ browsers.curl,
+ )
self.delete_by_id = to_streamed_response_wrapper(
browsers.delete_by_id,
)
@@ -1157,6 +1296,9 @@ def __init__(self, browsers: AsyncBrowsersResource) -> None:
browsers.delete, # pyright: ignore[reportDeprecated],
)
)
+ self.curl = async_to_streamed_response_wrapper(
+ browsers.curl,
+ )
self.delete_by_id = async_to_streamed_response_wrapper(
browsers.delete_by_id,
)
diff --git a/src/kernel/types/__init__.py b/src/kernel/types/__init__.py
index 91e68d6..3838047 100644
--- a/src/kernel/types/__init__.py
+++ b/src/kernel/types/__init__.py
@@ -22,6 +22,7 @@
from .browser_pool_ref import BrowserPoolRef as BrowserPoolRef
from .app_list_response import AppListResponse as AppListResponse
from .proxy_check_params import ProxyCheckParams as ProxyCheckParams
+from .browser_curl_params import BrowserCurlParams as BrowserCurlParams
from .browser_list_params import BrowserListParams as BrowserListParams
from .browser_persistence import BrowserPersistence as BrowserPersistence
from .credential_provider import CredentialProvider as CredentialProvider
@@ -31,6 +32,7 @@
from .proxy_list_response import ProxyListResponse as ProxyListResponse
from .proxy_check_response import ProxyCheckResponse as ProxyCheckResponse
from .browser_create_params import BrowserCreateParams as BrowserCreateParams
+from .browser_curl_response import BrowserCurlResponse as BrowserCurlResponse
from .browser_delete_params import BrowserDeleteParams as BrowserDeleteParams
from .browser_list_response import BrowserListResponse as BrowserListResponse
from .browser_update_params import BrowserUpdateParams as BrowserUpdateParams
diff --git a/src/kernel/types/browser_curl_params.py b/src/kernel/types/browser_curl_params.py
new file mode 100644
index 0000000..750bd6d
--- /dev/null
+++ b/src/kernel/types/browser_curl_params.py
@@ -0,0 +1,28 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from __future__ import annotations
+
+from typing import Dict
+from typing_extensions import Literal, Required, TypedDict
+
+__all__ = ["BrowserCurlParams"]
+
+
+class BrowserCurlParams(TypedDict, total=False):
+ url: Required[str]
+ """Target URL (must be http or https)."""
+
+ body: str
+ """Request body (for POST/PUT/PATCH)."""
+
+ headers: Dict[str, str]
+ """Custom headers merged with browser defaults."""
+
+ method: Literal["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
+ """HTTP method."""
+
+ response_encoding: Literal["utf8", "base64"]
+ """Encoding for the response body. Use base64 for binary content."""
+
+ timeout_ms: int
+ """Request timeout in milliseconds."""
diff --git a/src/kernel/types/browser_curl_response.py b/src/kernel/types/browser_curl_response.py
new file mode 100644
index 0000000..1b288e4
--- /dev/null
+++ b/src/kernel/types/browser_curl_response.py
@@ -0,0 +1,23 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from typing import Dict, List
+
+from .._models import BaseModel
+
+__all__ = ["BrowserCurlResponse"]
+
+
+class BrowserCurlResponse(BaseModel):
+ """Structured response from the browser curl request."""
+
+ body: str
+ """Response body (UTF-8 string or base64 depending on request)."""
+
+ duration_ms: int
+ """Total request duration in milliseconds."""
+
+ headers: Dict[str, List[str]]
+ """Response headers (multi-value)."""
+
+ status: int
+ """HTTP status code from target."""
diff --git a/tests/api_resources/test_browsers.py b/tests/api_resources/test_browsers.py
index 72c7b02..96742c7 100644
--- a/tests/api_resources/test_browsers.py
+++ b/tests/api_resources/test_browsers.py
@@ -10,6 +10,7 @@
from kernel import Kernel, AsyncKernel
from tests.utils import assert_matches_type
from kernel.types import (
+ BrowserCurlResponse,
BrowserListResponse,
BrowserCreateResponse,
BrowserUpdateResponse,
@@ -276,6 +277,66 @@ def test_streaming_response_delete(self, client: Kernel) -> None:
assert cast(Any, response.is_closed) is True
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_method_curl(self, client: Kernel) -> None:
+ browser = client.browsers.curl(
+ id="id",
+ url="url",
+ )
+ assert_matches_type(BrowserCurlResponse, browser, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_method_curl_with_all_params(self, client: Kernel) -> None:
+ browser = client.browsers.curl(
+ id="id",
+ url="url",
+ body="body",
+ headers={"foo": "string"},
+ method="GET",
+ response_encoding="utf8",
+ timeout_ms=1000,
+ )
+ assert_matches_type(BrowserCurlResponse, browser, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_raw_response_curl(self, client: Kernel) -> None:
+ response = client.browsers.with_raw_response.curl(
+ id="id",
+ url="url",
+ )
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ browser = response.parse()
+ assert_matches_type(BrowserCurlResponse, browser, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_streaming_response_curl(self, client: Kernel) -> None:
+ with client.browsers.with_streaming_response.curl(
+ id="id",
+ url="url",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ browser = response.parse()
+ assert_matches_type(BrowserCurlResponse, browser, path=["response"])
+
+ assert cast(Any, response.is_closed) is True
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_path_params_curl(self, client: Kernel) -> None:
+ with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"):
+ client.browsers.with_raw_response.curl(
+ id="",
+ url="url",
+ )
+
@pytest.mark.skip(reason="Mock server tests are disabled")
@parametrize
def test_method_delete_by_id(self, client: Kernel) -> None:
@@ -641,6 +702,66 @@ async def test_streaming_response_delete(self, async_client: AsyncKernel) -> Non
assert cast(Any, response.is_closed) is True
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_method_curl(self, async_client: AsyncKernel) -> None:
+ browser = await async_client.browsers.curl(
+ id="id",
+ url="url",
+ )
+ assert_matches_type(BrowserCurlResponse, browser, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_method_curl_with_all_params(self, async_client: AsyncKernel) -> None:
+ browser = await async_client.browsers.curl(
+ id="id",
+ url="url",
+ body="body",
+ headers={"foo": "string"},
+ method="GET",
+ response_encoding="utf8",
+ timeout_ms=1000,
+ )
+ assert_matches_type(BrowserCurlResponse, browser, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_raw_response_curl(self, async_client: AsyncKernel) -> None:
+ response = await async_client.browsers.with_raw_response.curl(
+ id="id",
+ url="url",
+ )
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ browser = await response.parse()
+ assert_matches_type(BrowserCurlResponse, browser, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_streaming_response_curl(self, async_client: AsyncKernel) -> None:
+ async with async_client.browsers.with_streaming_response.curl(
+ id="id",
+ url="url",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ browser = await response.parse()
+ assert_matches_type(BrowserCurlResponse, browser, path=["response"])
+
+ assert cast(Any, response.is_closed) is True
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_path_params_curl(self, async_client: AsyncKernel) -> None:
+ with pytest.raises(ValueError, match=r"Expected a non-empty value for `id` but received ''"):
+ await async_client.browsers.with_raw_response.curl(
+ id="",
+ url="url",
+ )
+
@pytest.mark.skip(reason="Mock server tests are disabled")
@parametrize
async def test_method_delete_by_id(self, async_client: AsyncKernel) -> None:
diff --git a/tests/test_extract_files.py b/tests/test_extract_files.py
index e5cf4a1..14a3932 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",
[