From a32962e54563d7073726b4463d70017fc2a40331 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Mon, 16 Mar 2026 17:31:39 +0000
Subject: [PATCH 01/15] fix(pydantic): do not pass `by_alias` unless set
---
src/tabstack/_compat.py | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/src/tabstack/_compat.py b/src/tabstack/_compat.py
index 786ff42..e6690a4 100644
--- a/src/tabstack/_compat.py
+++ b/src/tabstack/_compat.py
@@ -2,7 +2,7 @@
from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast, overload
from datetime import date, datetime
-from typing_extensions import Self, Literal
+from typing_extensions import Self, Literal, TypedDict
import pydantic
from pydantic.fields import FieldInfo
@@ -131,6 +131,10 @@ def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str:
return model.model_dump_json(indent=indent)
+class _ModelDumpKwargs(TypedDict, total=False):
+ by_alias: bool
+
+
def model_dump(
model: pydantic.BaseModel,
*,
@@ -142,6 +146,9 @@ def model_dump(
by_alias: bool | None = None,
) -> dict[str, Any]:
if (not PYDANTIC_V1) or hasattr(model, "model_dump"):
+ kwargs: _ModelDumpKwargs = {}
+ if by_alias is not None:
+ kwargs["by_alias"] = by_alias
return model.model_dump(
mode=mode,
exclude=exclude,
@@ -149,7 +156,7 @@ def model_dump(
exclude_defaults=exclude_defaults,
# warnings are not supported in Pydantic v1
warnings=True if PYDANTIC_V1 else warnings,
- by_alias=by_alias,
+ **kwargs,
)
return cast(
"dict[str, Any]",
From 194650472a951f76bd180e1dd08507fcf2670577 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Mon, 16 Mar 2026 19:01:51 +0000
Subject: [PATCH 02/15] fix(deps): bump minimum typing-extensions version
---
pyproject.toml | 2 +-
uv.lock | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 4a373c9..3822de4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -11,7 +11,7 @@ authors = [
dependencies = [
"httpx>=0.23.0, <1",
"pydantic>=1.9.0, <3",
- "typing-extensions>=4.10, <5",
+ "typing-extensions>=4.14, <5",
"anyio>=3.5.0, <5",
"distro>=1.7.0, <2",
"sniffio",
diff --git a/uv.lock b/uv.lock
index 64a2d52..6535369 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1385,7 +1385,7 @@ requires-dist = [
{ name = "httpx-aiohttp", marker = "extra == 'aiohttp'", specifier = ">=0.1.9" },
{ name = "pydantic", specifier = ">=1.9.0,<3" },
{ name = "sniffio" },
- { name = "typing-extensions", specifier = ">=4.10,<5" },
+ { name = "typing-extensions", specifier = ">=4.14,<5" },
]
provides-extras = ["aiohttp"]
From 8458777f42fdbdc611d213513b1e7f026886ba42 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Mon, 16 Mar 2026 20:45:48 +0000
Subject: [PATCH 03/15] chore(internal): tweak CI branches
---
.github/workflows/ci.yml | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7e8b7f4..fc69dca 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,12 +1,14 @@
name: CI
on:
push:
- branches-ignore:
- - 'generated'
- - 'codegen/**'
- - 'integrated/**'
- - 'stl-preview-head/**'
- - 'stl-preview-base/**'
+ branches:
+ - '**'
+ - '!integrated/**'
+ - '!stl-preview-head/**'
+ - '!stl-preview-base/**'
+ - '!generated'
+ - '!codegen/**'
+ - 'codegen/stl/**'
pull_request:
branches-ignore:
- 'stl-preview-head/**'
From f4632c74a42a3ea56998e6f9d9afacace4bd4a5c Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 19 Mar 2026 15:36:53 +0000
Subject: [PATCH 04/15] fix: sanitize endpoint path params
---
src/tabstack/_utils/__init__.py | 1 +
src/tabstack/_utils/_path.py | 127 ++++++++++++++++++++++++++++++++
tests/test_utils/test_path.py | 89 ++++++++++++++++++++++
3 files changed, 217 insertions(+)
create mode 100644 src/tabstack/_utils/_path.py
create mode 100644 tests/test_utils/test_path.py
diff --git a/src/tabstack/_utils/__init__.py b/src/tabstack/_utils/__init__.py
index dc64e29..10cb66d 100644
--- a/src/tabstack/_utils/__init__.py
+++ b/src/tabstack/_utils/__init__.py
@@ -1,3 +1,4 @@
+from ._path import path_template as path_template
from ._sync import asyncify as asyncify
from ._proxy import LazyProxy as LazyProxy
from ._utils import (
diff --git a/src/tabstack/_utils/_path.py b/src/tabstack/_utils/_path.py
new file mode 100644
index 0000000..4d6e1e4
--- /dev/null
+++ b/src/tabstack/_utils/_path.py
@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+import re
+from typing import (
+ Any,
+ Mapping,
+ Callable,
+)
+from urllib.parse import quote
+
+# Matches '.' or '..' where each dot is either literal or percent-encoded (%2e / %2E).
+_DOT_SEGMENT_RE = re.compile(r"^(?:\.|%2[eE]){1,2}$")
+
+_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
+
+
+def _quote_path_segment_part(value: str) -> str:
+ """Percent-encode `value` for use in a URI path segment.
+
+ Considers characters not in `pchar` set from RFC 3986 §3.3 to be unsafe.
+ https://datatracker.ietf.org/doc/html/rfc3986#section-3.3
+ """
+ # quote() already treats unreserved characters (letters, digits, and -._~)
+ # as safe, so we only need to add sub-delims, ':', and '@'.
+ # Notably, unlike the default `safe` for quote(), / is unsafe and must be quoted.
+ return quote(value, safe="!$&'()*+,;=:@")
+
+
+def _quote_query_part(value: str) -> str:
+ """Percent-encode `value` for use in a URI query string.
+
+ Considers &, = and characters not in `query` set from RFC 3986 §3.4 to be unsafe.
+ https://datatracker.ietf.org/doc/html/rfc3986#section-3.4
+ """
+ return quote(value, safe="!$'()*+,;:@/?")
+
+
+def _quote_fragment_part(value: str) -> str:
+ """Percent-encode `value` for use in a URI fragment.
+
+ Considers characters not in `fragment` set from RFC 3986 §3.5 to be unsafe.
+ https://datatracker.ietf.org/doc/html/rfc3986#section-3.5
+ """
+ return quote(value, safe="!$&'()*+,;=:@/?")
+
+
+def _interpolate(
+ template: str,
+ values: Mapping[str, Any],
+ quoter: Callable[[str], str],
+) -> str:
+ """Replace {name} placeholders in `template`, quoting each value with `quoter`.
+
+ Placeholder names are looked up in `values`.
+
+ Raises:
+ KeyError: If a placeholder is not found in `values`.
+ """
+ # re.split with a capturing group returns alternating
+ # [text, name, text, name, ..., text] elements.
+ parts = _PLACEHOLDER_RE.split(template)
+
+ for i in range(1, len(parts), 2):
+ name = parts[i]
+ if name not in values:
+ raise KeyError(f"a value for placeholder {{{name}}} was not provided")
+ val = values[name]
+ if val is None:
+ parts[i] = "null"
+ elif isinstance(val, bool):
+ parts[i] = "true" if val else "false"
+ else:
+ parts[i] = quoter(str(values[name]))
+
+ return "".join(parts)
+
+
+def path_template(template: str, /, **kwargs: Any) -> str:
+ """Interpolate {name} placeholders in `template` from keyword arguments.
+
+ Args:
+ template: The template string containing {name} placeholders.
+ **kwargs: Keyword arguments to interpolate into the template.
+
+ Returns:
+ The template with placeholders interpolated and percent-encoded.
+
+ Safe characters for percent-encoding are dependent on the URI component.
+ Placeholders in path and fragment portions are percent-encoded where the `segment`
+ and `fragment` sets from RFC 3986 respectively are considered safe.
+ Placeholders in the query portion are percent-encoded where the `query` set from
+ RFC 3986 §3.3 is considered safe except for = and & characters.
+
+ Raises:
+ KeyError: If a placeholder is not found in `kwargs`.
+ ValueError: If resulting path contains /./ or /../ segments (including percent-encoded dot-segments).
+ """
+ # Split the template into path, query, and fragment portions.
+ fragment_template: str | None = None
+ query_template: str | None = None
+
+ rest = template
+ if "#" in rest:
+ rest, fragment_template = rest.split("#", 1)
+ if "?" in rest:
+ rest, query_template = rest.split("?", 1)
+ path_template = rest
+
+ # Interpolate each portion with the appropriate quoting rules.
+ path_result = _interpolate(path_template, kwargs, _quote_path_segment_part)
+
+ # Reject dot-segments (. and ..) in the final assembled path. The check
+ # runs after interpolation so that adjacent placeholders or a mix of static
+ # text and placeholders that together form a dot-segment are caught.
+ # Also reject percent-encoded dot-segments to protect against incorrectly
+ # implemented normalization in servers/proxies.
+ for segment in path_result.split("/"):
+ if _DOT_SEGMENT_RE.match(segment):
+ raise ValueError(f"Constructed path {path_result!r} contains dot-segment {segment!r} which is not allowed")
+
+ result = path_result
+ if query_template is not None:
+ result += "?" + _interpolate(query_template, kwargs, _quote_query_part)
+ if fragment_template is not None:
+ result += "#" + _interpolate(fragment_template, kwargs, _quote_fragment_part)
+
+ return result
diff --git a/tests/test_utils/test_path.py b/tests/test_utils/test_path.py
new file mode 100644
index 0000000..56754f6
--- /dev/null
+++ b/tests/test_utils/test_path.py
@@ -0,0 +1,89 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from tabstack._utils._path import path_template
+
+
+@pytest.mark.parametrize(
+ "template, kwargs, expected",
+ [
+ ("/v1/{id}", dict(id="abc"), "/v1/abc"),
+ ("/v1/{a}/{b}", dict(a="x", b="y"), "/v1/x/y"),
+ ("/v1/{a}{b}/path/{c}?val={d}#{e}", dict(a="x", b="y", c="z", d="u", e="v"), "/v1/xy/path/z?val=u#v"),
+ ("/{w}/{w}", dict(w="echo"), "/echo/echo"),
+ ("/v1/static", {}, "/v1/static"),
+ ("", {}, ""),
+ ("/v1/?q={n}&count=10", dict(n=42), "/v1/?q=42&count=10"),
+ ("/v1/{v}", dict(v=None), "/v1/null"),
+ ("/v1/{v}", dict(v=True), "/v1/true"),
+ ("/v1/{v}", dict(v=False), "/v1/false"),
+ ("/v1/{v}", dict(v=".hidden"), "/v1/.hidden"), # dot prefix ok
+ ("/v1/{v}", dict(v="file.txt"), "/v1/file.txt"), # dot in middle ok
+ ("/v1/{v}", dict(v="..."), "/v1/..."), # triple dot ok
+ ("/v1/{a}{b}", dict(a=".", b="txt"), "/v1/.txt"), # dot var combining with adjacent to be ok
+ ("/items?q={v}#{f}", dict(v=".", f=".."), "/items?q=.#.."), # dots in query/fragment are fine
+ (
+ "/v1/{a}?query={b}",
+ dict(a="../../other/endpoint", b="a&bad=true"),
+ "/v1/..%2F..%2Fother%2Fendpoint?query=a%26bad%3Dtrue",
+ ),
+ ("/v1/{val}", dict(val="a/b/c"), "/v1/a%2Fb%2Fc"),
+ ("/v1/{val}", dict(val="a/b/c?query=value"), "/v1/a%2Fb%2Fc%3Fquery=value"),
+ ("/v1/{val}", dict(val="a/b/c?query=value&bad=true"), "/v1/a%2Fb%2Fc%3Fquery=value&bad=true"),
+ ("/v1/{val}", dict(val="%20"), "/v1/%2520"), # escapes escape sequences in input
+ # Query: slash and ? are safe, # is not
+ ("/items?q={v}", dict(v="a/b"), "/items?q=a/b"),
+ ("/items?q={v}", dict(v="a?b"), "/items?q=a?b"),
+ ("/items?q={v}", dict(v="a#b"), "/items?q=a%23b"),
+ ("/items?q={v}", dict(v="a b"), "/items?q=a%20b"),
+ # Fragment: slash and ? are safe
+ ("/docs#{v}", dict(v="a/b"), "/docs#a/b"),
+ ("/docs#{v}", dict(v="a?b"), "/docs#a?b"),
+ # Path: slash, ? and # are all encoded
+ ("/v1/{v}", dict(v="a/b"), "/v1/a%2Fb"),
+ ("/v1/{v}", dict(v="a?b"), "/v1/a%3Fb"),
+ ("/v1/{v}", dict(v="a#b"), "/v1/a%23b"),
+ # same var encoded differently by component
+ (
+ "/v1/{v}?q={v}#{v}",
+ dict(v="a/b?c#d"),
+ "/v1/a%2Fb%3Fc%23d?q=a/b?c%23d#a/b?c%23d",
+ ),
+ ("/v1/{val}", dict(val="x?admin=true"), "/v1/x%3Fadmin=true"), # query injection
+ ("/v1/{val}", dict(val="x#admin"), "/v1/x%23admin"), # fragment injection
+ ],
+)
+def test_interpolation(template: str, kwargs: dict[str, Any], expected: str) -> None:
+ assert path_template(template, **kwargs) == expected
+
+
+def test_missing_kwarg_raises_key_error() -> None:
+ with pytest.raises(KeyError, match="org_id"):
+ path_template("/v1/{org_id}")
+
+
+@pytest.mark.parametrize(
+ "template, kwargs",
+ [
+ ("{a}/path", dict(a=".")),
+ ("{a}/path", dict(a="..")),
+ ("/v1/{a}", dict(a=".")),
+ ("/v1/{a}", dict(a="..")),
+ ("/v1/{a}/path", dict(a=".")),
+ ("/v1/{a}/path", dict(a="..")),
+ ("/v1/{a}{b}", dict(a=".", b=".")), # adjacent vars → ".."
+ ("/v1/{a}.", dict(a=".")), # var + static → ".."
+ ("/v1/{a}{b}", dict(a="", b=".")), # empty + dot → "."
+ ("/v1/%2e/{x}", dict(x="ok")), # encoded dot in static text
+ ("/v1/%2e./{x}", dict(x="ok")), # mixed encoded ".." in static
+ ("/v1/.%2E/{x}", dict(x="ok")), # mixed encoded ".." in static
+ ("/v1/{v}?q=1", dict(v="..")),
+ ("/v1/{v}#frag", dict(v="..")),
+ ],
+)
+def test_dot_segment_rejected(template: str, kwargs: dict[str, Any]) -> None:
+ with pytest.raises(ValueError, match="dot-segment"):
+ path_template(template, **kwargs)
From e39102cd8c44fbbaec52a5bed74a11deab25b97f Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Mon, 23 Mar 2026 12:38:13 +0000
Subject: [PATCH 05/15] chore(internal): update gitignore
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index 95ceb18..3824f4c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.prism.log
+.stdy.log
_dev
__pycache__
From 97108400820fbe715dcb922c997b87b612bf588e Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 24 Mar 2026 15:14:21 +0000
Subject: [PATCH 06/15] chore(ci): skip lint on metadata-only changes
Note that we still want to run tests, as these depend on the metadata.
---
.github/workflows/ci.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fc69dca..f4158ca 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,7 +19,7 @@ jobs:
timeout-minutes: 10
name: lint
runs-on: ${{ github.repository == 'stainless-sdks/tabstack-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
- if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
+ if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata')
steps:
- uses: actions/checkout@v6
@@ -35,7 +35,7 @@ jobs:
run: ./scripts/lint
build:
- if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
+ if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata')
timeout-minutes: 10
name: build
permissions:
From 97a9bb7241ad0c7a98483d81f74bbb5ee40e1b93 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Thu, 26 Mar 2026 19:23:36 +0000
Subject: [PATCH 07/15] feat(internal): implement indices array format for
query and form serialization
---
src/tabstack/_qs.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/tabstack/_qs.py b/src/tabstack/_qs.py
index ada6fd3..de8c99b 100644
--- a/src/tabstack/_qs.py
+++ b/src/tabstack/_qs.py
@@ -101,7 +101,10 @@ def _stringify_item(
items.extend(self._stringify_item(key, item, opts))
return items
elif array_format == "indices":
- raise NotImplementedError("The array indices format is not supported yet")
+ items = []
+ for i, item in enumerate(value):
+ items.extend(self._stringify_item(f"{key}[{i}]", item, opts))
+ return items
elif array_format == "brackets":
items = []
key = key + "[]"
From ed80ce06975480e8466b18b75397ee3c761f6398 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Mon, 30 Mar 2026 18:01:55 +0000
Subject: [PATCH 08/15] feat(api): api update
---
.stats.yml | 4 ++--
src/tabstack/resources/generate.py | 4 ++--
src/tabstack/types/generate_json_params.py | 2 +-
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 62b5e49..d628251 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 5
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/mozilla%2Ftabstack-8f80078ef30bc395d44843f9ce076188ba43a297e158d5f3672c45dc814ffcf0.yml
-openapi_spec_hash: c615a817dcb27c55bd15b9b9346669c2
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/mozilla%2Ftabstack-4f4bec25bf55411f39f7c98b8bfd9abc76f886581f058ee4dd533ba52165b642.yml
+openapi_spec_hash: 7683e4ecea241c97066124b3ba833843
config_hash: 6b388b673b2a48895ab10da245fb6771
diff --git a/src/tabstack/resources/generate.py b/src/tabstack/resources/generate.py
index 36f4952..b9a2996 100644
--- a/src/tabstack/resources/generate.py
+++ b/src/tabstack/resources/generate.py
@@ -64,7 +64,7 @@ def json(
instructions. Use this to generate new content, summaries, or restructured data.
Args:
- instructions: Instructions describing how to transform the data
+ instructions: Instructions describing how to transform the data. Maximum 20,000 characters.
json_schema: JSON schema defining the structure of the transformed output
@@ -147,7 +147,7 @@ async def json(
instructions. Use this to generate new content, summaries, or restructured data.
Args:
- instructions: Instructions describing how to transform the data
+ instructions: Instructions describing how to transform the data. Maximum 20,000 characters.
json_schema: JSON schema defining the structure of the transformed output
diff --git a/src/tabstack/types/generate_json_params.py b/src/tabstack/types/generate_json_params.py
index bbe9b76..486e3ed 100644
--- a/src/tabstack/types/generate_json_params.py
+++ b/src/tabstack/types/generate_json_params.py
@@ -9,7 +9,7 @@
class GenerateJsonParams(TypedDict, total=False):
instructions: Required[str]
- """Instructions describing how to transform the data"""
+ """Instructions describing how to transform the data. Maximum 20,000 characters."""
json_schema: Required[object]
"""JSON schema defining the structure of the transformed output"""
From 2cda6aeb86a50115ba347549452c57700e810679 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Tue, 7 Apr 2026 16:08:50 +0000
Subject: [PATCH 09/15] fix(client): preserve hardcoded query params when
merging with user params
---
src/tabstack/_base_client.py | 4 +++
tests/test_client.py | 48 ++++++++++++++++++++++++++++++++++++
2 files changed, 52 insertions(+)
diff --git a/src/tabstack/_base_client.py b/src/tabstack/_base_client.py
index 8df444d..6d03d50 100644
--- a/src/tabstack/_base_client.py
+++ b/src/tabstack/_base_client.py
@@ -540,6 +540,10 @@ def _build_request(
files = cast(HttpxRequestFiles, ForceMultipartDict())
prepared_url = self._prepare_url(options.url)
+ # preserve hard-coded query params from the url
+ if params and prepared_url.query:
+ params = {**dict(prepared_url.params.items()), **params}
+ prepared_url = prepared_url.copy_with(raw_path=prepared_url.raw_path.split(b"?", 1)[0])
if "_" in prepared_url.host:
# work around https://github.com/encode/httpx/discussions/2880
kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")}
diff --git a/tests/test_client.py b/tests/test_client.py
index f6e8193..4e4f04c 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -429,6 +429,30 @@ def test_default_query_option(self) -> None:
client.close()
+ def test_hardcoded_query_params_in_url(self, client: Tabstack) -> None:
+ request = client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true"))
+ url = httpx.URL(request.url)
+ assert dict(url.params) == {"beta": "true"}
+
+ request = client._build_request(
+ FinalRequestOptions(
+ method="get",
+ url="/foo?beta=true",
+ params={"limit": "10", "page": "abc"},
+ )
+ )
+ url = httpx.URL(request.url)
+ assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"}
+
+ request = client._build_request(
+ FinalRequestOptions(
+ method="get",
+ url="/files/a%2Fb?beta=true",
+ params={"limit": "10"},
+ )
+ )
+ assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10"
+
def test_request_extra_json(self, client: Tabstack) -> None:
request = client._build_request(
FinalRequestOptions(
@@ -1332,6 +1356,30 @@ async def test_default_query_option(self) -> None:
await client.close()
+ async def test_hardcoded_query_params_in_url(self, async_client: AsyncTabstack) -> None:
+ request = async_client._build_request(FinalRequestOptions(method="get", url="/foo?beta=true"))
+ url = httpx.URL(request.url)
+ assert dict(url.params) == {"beta": "true"}
+
+ request = async_client._build_request(
+ FinalRequestOptions(
+ method="get",
+ url="/foo?beta=true",
+ params={"limit": "10", "page": "abc"},
+ )
+ )
+ url = httpx.URL(request.url)
+ assert dict(url.params) == {"beta": "true", "limit": "10", "page": "abc"}
+
+ request = async_client._build_request(
+ FinalRequestOptions(
+ method="get",
+ url="/files/a%2Fb?beta=true",
+ params={"limit": "10"},
+ )
+ )
+ assert request.url.raw_path == b"/files/a%2Fb?beta=true&limit=10"
+
def test_request_extra_json(self, client: Tabstack) -> None:
request = client._build_request(
FinalRequestOptions(
From cda5c19baedee455072a8071cb2741714a9ded19 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Wed, 8 Apr 2026 17:21:00 +0000
Subject: [PATCH 10/15] feat(api): api update
---
.stats.yml | 4 ++--
src/tabstack/resources/agent.py | 8 ++++++++
src/tabstack/types/agent_automate_params.py | 3 +++
tests/api_resources/test_agent.py | 2 ++
4 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index d628251..e506e2d 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 5
-openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/mozilla%2Ftabstack-4f4bec25bf55411f39f7c98b8bfd9abc76f886581f058ee4dd533ba52165b642.yml
-openapi_spec_hash: 7683e4ecea241c97066124b3ba833843
+openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/mozilla%2Ftabstack-ab135cad4c10edc98bbe45d18a1ca23d2d4aa4b2ba2e4554e6991e322ce46231.yml
+openapi_spec_hash: 3e8185dfbbd4e83d5f05ded9ce9c5b06
config_hash: 6b388b673b2a48895ab10da245fb6771
diff --git a/src/tabstack/resources/agent.py b/src/tabstack/resources/agent.py
index 0cf85d7..73a5770 100644
--- a/src/tabstack/resources/agent.py
+++ b/src/tabstack/resources/agent.py
@@ -52,6 +52,7 @@ def automate(
data: object | Omit = omit,
geo_target: agent_automate_params.GeoTarget | Omit = omit,
guardrails: str | Omit = omit,
+ interactive: bool | Omit = omit,
max_iterations: int | Omit = omit,
max_validation_attempts: int | Omit = omit,
url: str | Omit = omit,
@@ -93,6 +94,8 @@ def automate(
guardrails: Safety constraints for execution
+ interactive: Enable interactive mode to allow human-in-the-loop input during task execution
+
max_iterations: Maximum task iterations
max_validation_attempts: Maximum validation attempts
@@ -116,6 +119,7 @@ def automate(
"data": data,
"geo_target": geo_target,
"guardrails": guardrails,
+ "interactive": interactive,
"max_iterations": max_iterations,
"max_validation_attempts": max_validation_attempts,
"url": url,
@@ -231,6 +235,7 @@ async def automate(
data: object | Omit = omit,
geo_target: agent_automate_params.GeoTarget | Omit = omit,
guardrails: str | Omit = omit,
+ interactive: bool | Omit = omit,
max_iterations: int | Omit = omit,
max_validation_attempts: int | Omit = omit,
url: str | Omit = omit,
@@ -272,6 +277,8 @@ async def automate(
guardrails: Safety constraints for execution
+ interactive: Enable interactive mode to allow human-in-the-loop input during task execution
+
max_iterations: Maximum task iterations
max_validation_attempts: Maximum validation attempts
@@ -295,6 +302,7 @@ async def automate(
"data": data,
"geo_target": geo_target,
"guardrails": guardrails,
+ "interactive": interactive,
"max_iterations": max_iterations,
"max_validation_attempts": max_validation_attempts,
"url": url,
diff --git a/src/tabstack/types/agent_automate_params.py b/src/tabstack/types/agent_automate_params.py
index 350bb30..3d63b9c 100644
--- a/src/tabstack/types/agent_automate_params.py
+++ b/src/tabstack/types/agent_automate_params.py
@@ -22,6 +22,9 @@ class AgentAutomateParams(TypedDict, total=False):
guardrails: str
"""Safety constraints for execution"""
+ interactive: bool
+ """Enable interactive mode to allow human-in-the-loop input during task execution"""
+
max_iterations: Annotated[int, PropertyInfo(alias="maxIterations")]
"""Maximum task iterations"""
diff --git a/tests/api_resources/test_agent.py b/tests/api_resources/test_agent.py
index cabea82..a17f0a2 100644
--- a/tests/api_resources/test_agent.py
+++ b/tests/api_resources/test_agent.py
@@ -31,6 +31,7 @@ def test_method_automate_with_all_params(self, client: Tabstack) -> None:
data={},
geo_target={"country": "US"},
guardrails="browse and extract only, don't interact with repositories",
+ interactive=False,
max_iterations=50,
max_validation_attempts=3,
url="https://github.com/trending",
@@ -128,6 +129,7 @@ async def test_method_automate_with_all_params(self, async_client: AsyncTabstack
data={},
geo_target={"country": "US"},
guardrails="browse and extract only, don't interact with repositories",
+ interactive=False,
max_iterations=50,
max_validation_attempts=3,
url="https://github.com/trending",
From f5f587ab07a757e4ada42e6bc3c802f29591c536 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Wed, 8 Apr 2026 17:49:58 +0000
Subject: [PATCH 11/15] chore: configure new SDK language
---
.stats.yml | 2 +-
README.md | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index e506e2d..d2b5a79 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 5
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/mozilla%2Ftabstack-ab135cad4c10edc98bbe45d18a1ca23d2d4aa4b2ba2e4554e6991e322ce46231.yml
openapi_spec_hash: 3e8185dfbbd4e83d5f05ded9ce9c5b06
-config_hash: 6b388b673b2a48895ab10da245fb6771
+config_hash: 448fb94cb4e335d289d02a77f66badc0
diff --git a/README.md b/README.md
index a3d18fc..3933628 100644
--- a/README.md
+++ b/README.md
@@ -13,8 +13,8 @@ It is generated with [Stainless](https://www.stainless.com/).
Use the Tabstack MCP Server to enable AI assistants to interact with this API, allowing them to explore endpoints, make test requests, and use documentation to help integrate this SDK into your application.
-[](https://cursor.com/en-US/install-mcp?name=%40tabstack%2Fmcp&config=eyJuYW1lIjoiQHRhYnN0YWNrL21jcCIsInRyYW5zcG9ydCI6Imh0dHAiLCJ1cmwiOiJodHRwczovL3RhYnN0YWNrLnN0bG1jcC5jb20iLCJoZWFkZXJzIjp7IngtdGFic3RhY2stYXBpLWtleSI6Ik15IEFQSSBLZXkifX0)
-[](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22%40tabstack%2Fmcp%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Ftabstack.stlmcp.com%22%2C%22headers%22%3A%7B%22x-tabstack-api-key%22%3A%22My%20API%20Key%22%7D%7D)
+[](https://cursor.com/en-US/install-mcp?name=%40tabstack%2Fsdk-mcp&config=eyJuYW1lIjoiQHRhYnN0YWNrL3Nkay1tY3AiLCJ0cmFuc3BvcnQiOiJodHRwIiwidXJsIjoiaHR0cHM6Ly90YWJzdGFjay5zdGxtY3AuY29tIiwiaGVhZGVycyI6eyJ4LXRhYnN0YWNrLWFwaS1rZXkiOiJNeSBBUEkgS2V5In19)
+[](https://vscode.stainless.com/mcp/%7B%22name%22%3A%22%40tabstack%2Fsdk-mcp%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Ftabstack.stlmcp.com%22%2C%22headers%22%3A%7B%22x-tabstack-api-key%22%3A%22My%20API%20Key%22%7D%7D)
> Note: You may need to set environment variables in your MCP client.
From f3b1f16204c0d724193815c5e34100831f0a2653 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Wed, 8 Apr 2026 18:51:27 +0000
Subject: [PATCH 12/15] feat(api): add input endpoint
---
.stats.yml | 4 +-
api.md | 3 +-
src/tabstack/resources/agent.py | 132 +++++++++++++++++-
src/tabstack/types/__init__.py | 2 +
.../types/agent_automate_input_params.py | 22 +++
.../types/agent_automate_input_response.py | 11 ++
tests/api_resources/test_agent.py | 118 ++++++++++++++++
7 files changed, 287 insertions(+), 5 deletions(-)
create mode 100644 src/tabstack/types/agent_automate_input_params.py
create mode 100644 src/tabstack/types/agent_automate_input_response.py
diff --git a/.stats.yml b/.stats.yml
index d2b5a79..560dde2 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
-configured_endpoints: 5
+configured_endpoints: 6
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/mozilla%2Ftabstack-ab135cad4c10edc98bbe45d18a1ca23d2d4aa4b2ba2e4554e6991e322ce46231.yml
openapi_spec_hash: 3e8185dfbbd4e83d5f05ded9ce9c5b06
-config_hash: 448fb94cb4e335d289d02a77f66badc0
+config_hash: 64a326d972276ffd4a067a2f21f49681
diff --git a/api.md b/api.md
index 48a3a76..51b78b1 100644
--- a/api.md
+++ b/api.md
@@ -3,12 +3,13 @@
Types:
```python
-from tabstack.types import AutomateEvent, ResearchEvent
+from tabstack.types import AutomateEvent, ResearchEvent, AgentAutomateInputResponse
```
Methods:
- client.agent.automate(\*\*params) -> AutomateEvent
+- client.agent.automate_input(request_id, \*\*params) -> AgentAutomateInputResponse
- client.agent.research(\*\*params) -> ResearchEvent
# Extract
diff --git a/src/tabstack/resources/agent.py b/src/tabstack/resources/agent.py
index 73a5770..b8ba20f 100644
--- a/src/tabstack/resources/agent.py
+++ b/src/tabstack/resources/agent.py
@@ -2,13 +2,14 @@
from __future__ import annotations
+from typing import Iterable
from typing_extensions import Literal
import httpx
-from ..types import agent_automate_params, agent_research_params
+from ..types import agent_automate_params, agent_research_params, agent_automate_input_params
from .._types import Body, Omit, Query, Headers, NotGiven, omit, not_given
-from .._utils import maybe_transform, async_maybe_transform
+from .._utils import path_template, maybe_transform, async_maybe_transform
from .._compat import cached_property
from .._resource import SyncAPIResource, AsyncAPIResource
from .._response import (
@@ -21,6 +22,7 @@
from .._base_client import make_request_options
from ..types.automate_event import AutomateEvent
from ..types.research_event import ResearchEvent
+from ..types.agent_automate_input_response import AgentAutomateInputResponse
__all__ = ["AgentResource", "AsyncAgentResource"]
@@ -134,6 +136,63 @@ def automate(
stream_cls=Stream[AutomateEvent],
)
+ def automate_input(
+ self,
+ request_id: str,
+ *,
+ cancelled: bool | Omit = omit,
+ fields: Iterable[agent_automate_input_params.Field] | 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,
+ ) -> AgentAutomateInputResponse:
+ """
+ Submit a response to an interactive form data request from an in-progress
+ automation task. When the AI agent encounters a form requiring user data, it
+ emits an `interactive:form_data:request` or `interactive:form_data:error` SSE
+ event containing a `requestId`. Use this endpoint to provide the requested data
+ or cancel the request.
+
+ **Lifecycle:**
+
+ - Input requests expire after 2 minutes by default
+ - Expired or already-answered requests return `410 Gone`
+ - Successful submissions return `202 Accepted` (fire-and-forget from caller's
+ perspective)
+
+ Args:
+ cancelled: Set to true to cancel/decline the request
+
+ fields: Field values as array of {ref, value} pairs (required when not cancelled)
+
+ 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 request_id:
+ raise ValueError(f"Expected a non-empty value for `request_id` but received {request_id!r}")
+ return self._post(
+ path_template("/automate/{request_id}/input", request_id=request_id),
+ body=maybe_transform(
+ {
+ "cancelled": cancelled,
+ "fields": fields,
+ },
+ agent_automate_input_params.AgentAutomateInputParams,
+ ),
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=AgentAutomateInputResponse,
+ )
+
def research(
self,
*,
@@ -317,6 +376,63 @@ async def automate(
stream_cls=AsyncStream[AutomateEvent],
)
+ async def automate_input(
+ self,
+ request_id: str,
+ *,
+ cancelled: bool | Omit = omit,
+ fields: Iterable[agent_automate_input_params.Field] | 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,
+ ) -> AgentAutomateInputResponse:
+ """
+ Submit a response to an interactive form data request from an in-progress
+ automation task. When the AI agent encounters a form requiring user data, it
+ emits an `interactive:form_data:request` or `interactive:form_data:error` SSE
+ event containing a `requestId`. Use this endpoint to provide the requested data
+ or cancel the request.
+
+ **Lifecycle:**
+
+ - Input requests expire after 2 minutes by default
+ - Expired or already-answered requests return `410 Gone`
+ - Successful submissions return `202 Accepted` (fire-and-forget from caller's
+ perspective)
+
+ Args:
+ cancelled: Set to true to cancel/decline the request
+
+ fields: Field values as array of {ref, value} pairs (required when not cancelled)
+
+ 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 request_id:
+ raise ValueError(f"Expected a non-empty value for `request_id` but received {request_id!r}")
+ return await self._post(
+ path_template("/automate/{request_id}/input", request_id=request_id),
+ body=await async_maybe_transform(
+ {
+ "cancelled": cancelled,
+ "fields": fields,
+ },
+ agent_automate_input_params.AgentAutomateInputParams,
+ ),
+ options=make_request_options(
+ extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ ),
+ cast_to=AgentAutomateInputResponse,
+ )
+
async def research(
self,
*,
@@ -398,6 +514,9 @@ def __init__(self, agent: AgentResource) -> None:
self.automate = to_raw_response_wrapper(
agent.automate,
)
+ self.automate_input = to_raw_response_wrapper(
+ agent.automate_input,
+ )
self.research = to_raw_response_wrapper(
agent.research,
)
@@ -410,6 +529,9 @@ def __init__(self, agent: AsyncAgentResource) -> None:
self.automate = async_to_raw_response_wrapper(
agent.automate,
)
+ self.automate_input = async_to_raw_response_wrapper(
+ agent.automate_input,
+ )
self.research = async_to_raw_response_wrapper(
agent.research,
)
@@ -422,6 +544,9 @@ def __init__(self, agent: AgentResource) -> None:
self.automate = to_streamed_response_wrapper(
agent.automate,
)
+ self.automate_input = to_streamed_response_wrapper(
+ agent.automate_input,
+ )
self.research = to_streamed_response_wrapper(
agent.research,
)
@@ -434,6 +559,9 @@ def __init__(self, agent: AsyncAgentResource) -> None:
self.automate = async_to_streamed_response_wrapper(
agent.automate,
)
+ self.automate_input = async_to_streamed_response_wrapper(
+ agent.automate_input,
+ )
self.research = async_to_streamed_response_wrapper(
agent.research,
)
diff --git a/src/tabstack/types/__init__.py b/src/tabstack/types/__init__.py
index a0f24b4..feec32c 100644
--- a/src/tabstack/types/__init__.py
+++ b/src/tabstack/types/__init__.py
@@ -12,3 +12,5 @@
from .generate_json_response import GenerateJsonResponse as GenerateJsonResponse
from .extract_markdown_params import ExtractMarkdownParams as ExtractMarkdownParams
from .extract_markdown_response import ExtractMarkdownResponse as ExtractMarkdownResponse
+from .agent_automate_input_params import AgentAutomateInputParams as AgentAutomateInputParams
+from .agent_automate_input_response import AgentAutomateInputResponse as AgentAutomateInputResponse
diff --git a/src/tabstack/types/agent_automate_input_params.py b/src/tabstack/types/agent_automate_input_params.py
new file mode 100644
index 0000000..07adcc1
--- /dev/null
+++ b/src/tabstack/types/agent_automate_input_params.py
@@ -0,0 +1,22 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from __future__ import annotations
+
+from typing import Iterable
+from typing_extensions import TypedDict
+
+__all__ = ["AgentAutomateInputParams", "Field"]
+
+
+class AgentAutomateInputParams(TypedDict, total=False):
+ cancelled: bool
+ """Set to true to cancel/decline the request"""
+
+ fields: Iterable[Field]
+ """Field values as array of {ref, value} pairs (required when not cancelled)"""
+
+
+class Field(TypedDict, total=False):
+ ref: str
+
+ value: str
diff --git a/src/tabstack/types/agent_automate_input_response.py b/src/tabstack/types/agent_automate_input_response.py
new file mode 100644
index 0000000..63e05da
--- /dev/null
+++ b/src/tabstack/types/agent_automate_input_response.py
@@ -0,0 +1,11 @@
+# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
+
+from typing import Optional
+
+from .._models import BaseModel
+
+__all__ = ["AgentAutomateInputResponse"]
+
+
+class AgentAutomateInputResponse(BaseModel):
+ status: Optional[str] = None
diff --git a/tests/api_resources/test_agent.py b/tests/api_resources/test_agent.py
index a17f0a2..e96224c 100644
--- a/tests/api_resources/test_agent.py
+++ b/tests/api_resources/test_agent.py
@@ -8,6 +8,10 @@
import pytest
from tabstack import Tabstack, AsyncTabstack
+from tests.utils import assert_matches_type
+from tabstack.types import (
+ AgentAutomateInputResponse,
+)
base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010")
@@ -63,6 +67,63 @@ def test_streaming_response_automate(self, client: Tabstack) -> None:
assert cast(Any, response.is_closed) is True
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_method_automate_input(self, client: Tabstack) -> None:
+ agent = client.agent.automate_input(
+ request_id="requestID",
+ )
+ assert_matches_type(AgentAutomateInputResponse, agent, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_method_automate_input_with_all_params(self, client: Tabstack) -> None:
+ agent = client.agent.automate_input(
+ request_id="requestID",
+ cancelled=True,
+ fields=[
+ {
+ "ref": "E42",
+ "value": "user@example.com",
+ }
+ ],
+ )
+ assert_matches_type(AgentAutomateInputResponse, agent, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_raw_response_automate_input(self, client: Tabstack) -> None:
+ response = client.agent.with_raw_response.automate_input(
+ request_id="requestID",
+ )
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ agent = response.parse()
+ assert_matches_type(AgentAutomateInputResponse, agent, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_streaming_response_automate_input(self, client: Tabstack) -> None:
+ with client.agent.with_streaming_response.automate_input(
+ request_id="requestID",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ agent = response.parse()
+ assert_matches_type(AgentAutomateInputResponse, agent, path=["response"])
+
+ assert cast(Any, response.is_closed) is True
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ def test_path_params_automate_input(self, client: Tabstack) -> None:
+ with pytest.raises(ValueError, match=r"Expected a non-empty value for `request_id` but received ''"):
+ client.agent.with_raw_response.automate_input(
+ request_id="",
+ )
+
@pytest.mark.skip(reason="Mock server tests are disabled")
@parametrize
def test_method_research(self, client: Tabstack) -> None:
@@ -161,6 +222,63 @@ async def test_streaming_response_automate(self, async_client: AsyncTabstack) ->
assert cast(Any, response.is_closed) is True
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_method_automate_input(self, async_client: AsyncTabstack) -> None:
+ agent = await async_client.agent.automate_input(
+ request_id="requestID",
+ )
+ assert_matches_type(AgentAutomateInputResponse, agent, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_method_automate_input_with_all_params(self, async_client: AsyncTabstack) -> None:
+ agent = await async_client.agent.automate_input(
+ request_id="requestID",
+ cancelled=True,
+ fields=[
+ {
+ "ref": "E42",
+ "value": "user@example.com",
+ }
+ ],
+ )
+ assert_matches_type(AgentAutomateInputResponse, agent, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_raw_response_automate_input(self, async_client: AsyncTabstack) -> None:
+ response = await async_client.agent.with_raw_response.automate_input(
+ request_id="requestID",
+ )
+
+ assert response.is_closed is True
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+ agent = await response.parse()
+ assert_matches_type(AgentAutomateInputResponse, agent, path=["response"])
+
+ @pytest.mark.skip(reason="Mock server tests are disabled")
+ @parametrize
+ async def test_streaming_response_automate_input(self, async_client: AsyncTabstack) -> None:
+ async with async_client.agent.with_streaming_response.automate_input(
+ request_id="requestID",
+ ) as response:
+ assert not response.is_closed
+ assert response.http_request.headers.get("X-Stainless-Lang") == "python"
+
+ agent = await response.parse()
+ assert_matches_type(AgentAutomateInputResponse, agent, 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_automate_input(self, async_client: AsyncTabstack) -> None:
+ with pytest.raises(ValueError, match=r"Expected a non-empty value for `request_id` but received ''"):
+ await async_client.agent.with_raw_response.automate_input(
+ request_id="",
+ )
+
@pytest.mark.skip(reason="Mock server tests are disabled")
@parametrize
async def test_method_research(self, async_client: AsyncTabstack) -> None:
From 6b6ba7b5b6a6aba119a6caf6aec3af5ff5c6350b Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Wed, 8 Apr 2026 20:03:59 +0000
Subject: [PATCH 13/15] feat(api): better handling of SSE events
---
.stats.yml | 2 +-
src/tabstack/_base_client.py | 4 ++++
src/tabstack/_models.py | 2 ++
src/tabstack/_streaming.py | 16 ++++++++++++++--
src/tabstack/_types.py | 1 +
src/tabstack/resources/agent.py | 24 ++++++++++++++++++++----
6 files changed, 42 insertions(+), 7 deletions(-)
diff --git a/.stats.yml b/.stats.yml
index 560dde2..63f86b6 100644
--- a/.stats.yml
+++ b/.stats.yml
@@ -1,4 +1,4 @@
configured_endpoints: 6
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/mozilla%2Ftabstack-ab135cad4c10edc98bbe45d18a1ca23d2d4aa4b2ba2e4554e6991e322ce46231.yml
openapi_spec_hash: 3e8185dfbbd4e83d5f05ded9ce9c5b06
-config_hash: 64a326d972276ffd4a067a2f21f49681
+config_hash: 57c64e5e8fe99c1bd7af536d82af4ad9
diff --git a/src/tabstack/_base_client.py b/src/tabstack/_base_client.py
index 6d03d50..8a3f478 100644
--- a/src/tabstack/_base_client.py
+++ b/src/tabstack/_base_client.py
@@ -1956,6 +1956,7 @@ def make_request_options(
idempotency_key: str | None = None,
timeout: float | httpx.Timeout | None | NotGiven = not_given,
post_parser: PostParser | NotGiven = not_given,
+ synthesize_event_and_data: bool | None = None,
) -> RequestOptions:
"""Create a dict of type RequestOptions without keys of NotGiven values."""
options: RequestOptions = {}
@@ -1981,6 +1982,9 @@ def make_request_options(
# internal
options["post_parser"] = post_parser # type: ignore
+ if synthesize_event_and_data is not None:
+ options["synthesize_event_and_data"] = synthesize_event_and_data
+
return options
diff --git a/src/tabstack/_models.py b/src/tabstack/_models.py
index 29070e0..f75ae33 100644
--- a/src/tabstack/_models.py
+++ b/src/tabstack/_models.py
@@ -804,6 +804,7 @@ class FinalRequestOptionsInput(TypedDict, total=False):
json_data: Body
extra_json: AnyMapping
follow_redirects: bool
+ synthesize_event_and_data: bool
@final
@@ -818,6 +819,7 @@ class FinalRequestOptions(pydantic.BaseModel):
idempotency_key: Union[str, None] = None
post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven()
follow_redirects: Union[bool, None] = None
+ synthesize_event_and_data: Optional[bool] = None
content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None
# It should be noted that we cannot use `json` here as that would override
diff --git a/src/tabstack/_streaming.py b/src/tabstack/_streaming.py
index 3377de8..2b58d3a 100644
--- a/src/tabstack/_streaming.py
+++ b/src/tabstack/_streaming.py
@@ -59,7 +59,13 @@ def __stream__(self) -> Iterator[_T]:
try:
for sse in iterator:
- yield process_data(data=sse.json(), cast_to=cast_to, response=response)
+ yield process_data(
+ data={"data": sse.json(), "event": sse.event}
+ if self._options is not None and self._options.synthesize_event_and_data
+ else sse.json(),
+ cast_to=cast_to,
+ response=response,
+ )
finally:
# Ensure the response is closed even if the consumer doesn't read all data
response.close()
@@ -125,7 +131,13 @@ async def __stream__(self) -> AsyncIterator[_T]:
try:
async for sse in iterator:
- yield process_data(data=sse.json(), cast_to=cast_to, response=response)
+ yield process_data(
+ data={"data": sse.json(), "event": sse.event}
+ if self._options is not None and self._options.synthesize_event_and_data
+ else sse.json(),
+ cast_to=cast_to,
+ response=response,
+ )
finally:
# Ensure the response is closed even if the consumer doesn't read all data
await response.aclose()
diff --git a/src/tabstack/_types.py b/src/tabstack/_types.py
index bf959ef..818a79d 100644
--- a/src/tabstack/_types.py
+++ b/src/tabstack/_types.py
@@ -121,6 +121,7 @@ class RequestOptions(TypedDict, total=False):
extra_json: AnyMapping
idempotency_key: str
follow_redirects: bool
+ synthesize_event_and_data: bool
# Sentinel class used until PEP 0661 is accepted
diff --git a/src/tabstack/resources/agent.py b/src/tabstack/resources/agent.py
index b8ba20f..1ae07c4 100644
--- a/src/tabstack/resources/agent.py
+++ b/src/tabstack/resources/agent.py
@@ -129,7 +129,11 @@ def automate(
agent_automate_params.AgentAutomateParams,
),
options=make_request_options(
- extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ synthesize_event_and_data=True,
),
cast_to=AutomateEvent,
stream=True,
@@ -259,7 +263,11 @@ def research(
agent_research_params.AgentResearchParams,
),
options=make_request_options(
- extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ synthesize_event_and_data=True,
),
cast_to=ResearchEvent,
stream=True,
@@ -369,7 +377,11 @@ async def automate(
agent_automate_params.AgentAutomateParams,
),
options=make_request_options(
- extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ synthesize_event_and_data=True,
),
cast_to=AutomateEvent,
stream=True,
@@ -499,7 +511,11 @@ async def research(
agent_research_params.AgentResearchParams,
),
options=make_request_options(
- extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ synthesize_event_and_data=True,
),
cast_to=ResearchEvent,
stream=True,
From 10f77bcad1e0ea05151f291bf55b17a04512465a Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 10 Apr 2026 14:54:12 +0000
Subject: [PATCH 14/15] fix: ensure file data are only sent as 1 parameter
---
src/tabstack/_utils/_utils.py | 5 +++--
tests/test_extract_files.py | 9 +++++++++
2 files changed, 12 insertions(+), 2 deletions(-)
diff --git a/src/tabstack/_utils/_utils.py b/src/tabstack/_utils/_utils.py
index eec7f4a..63b8cd6 100644
--- a/src/tabstack/_utils/_utils.py
+++ b/src/tabstack/_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/tests/test_extract_files.py b/tests/test_extract_files.py
index 6706d51..a11b04c 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",
[
From a26248d7a174b09e0987558fa84cd8f2dd708492 Mon Sep 17 00:00:00 2001
From: "stainless-app[bot]"
<142633134+stainless-app[bot]@users.noreply.github.com>
Date: Fri, 10 Apr 2026 14:54:31 +0000
Subject: [PATCH 15/15] release: 2.4.0
---
.release-please-manifest.json | 2 +-
CHANGELOG.md | 29 +++++++++++++++++++++++++++++
pyproject.toml | 2 +-
src/tabstack/_version.py | 2 +-
4 files changed, 32 insertions(+), 3 deletions(-)
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index 75ec52f..b44b287 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "2.3.0"
+ ".": "2.4.0"
}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55679a4..aed943f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,34 @@
# Changelog
+## 2.4.0 (2026-04-10)
+
+Full Changelog: [v2.3.0...v2.4.0](https://github.com/Mozilla-Ocho/tabstack-python/compare/v2.3.0...v2.4.0)
+
+### Features
+
+* **api:** add input endpoint ([f3b1f16](https://github.com/Mozilla-Ocho/tabstack-python/commit/f3b1f16204c0d724193815c5e34100831f0a2653))
+* **api:** api update ([cda5c19](https://github.com/Mozilla-Ocho/tabstack-python/commit/cda5c19baedee455072a8071cb2741714a9ded19))
+* **api:** api update ([ed80ce0](https://github.com/Mozilla-Ocho/tabstack-python/commit/ed80ce06975480e8466b18b75397ee3c761f6398))
+* **api:** better handling of SSE events ([6b6ba7b](https://github.com/Mozilla-Ocho/tabstack-python/commit/6b6ba7b5b6a6aba119a6caf6aec3af5ff5c6350b))
+* **internal:** implement indices array format for query and form serialization ([97a9bb7](https://github.com/Mozilla-Ocho/tabstack-python/commit/97a9bb7241ad0c7a98483d81f74bbb5ee40e1b93))
+
+
+### Bug Fixes
+
+* **client:** preserve hardcoded query params when merging with user params ([2cda6ae](https://github.com/Mozilla-Ocho/tabstack-python/commit/2cda6aeb86a50115ba347549452c57700e810679))
+* **deps:** bump minimum typing-extensions version ([1946504](https://github.com/Mozilla-Ocho/tabstack-python/commit/194650472a951f76bd180e1dd08507fcf2670577))
+* ensure file data are only sent as 1 parameter ([10f77bc](https://github.com/Mozilla-Ocho/tabstack-python/commit/10f77bcad1e0ea05151f291bf55b17a04512465a))
+* **pydantic:** do not pass `by_alias` unless set ([a32962e](https://github.com/Mozilla-Ocho/tabstack-python/commit/a32962e54563d7073726b4463d70017fc2a40331))
+* sanitize endpoint path params ([f4632c7](https://github.com/Mozilla-Ocho/tabstack-python/commit/f4632c74a42a3ea56998e6f9d9afacace4bd4a5c))
+
+
+### Chores
+
+* **ci:** skip lint on metadata-only changes ([9710840](https://github.com/Mozilla-Ocho/tabstack-python/commit/97108400820fbe715dcb922c997b87b612bf588e))
+* configure new SDK language ([f5f587a](https://github.com/Mozilla-Ocho/tabstack-python/commit/f5f587ab07a757e4ada42e6bc3c802f29591c536))
+* **internal:** tweak CI branches ([8458777](https://github.com/Mozilla-Ocho/tabstack-python/commit/8458777f42fdbdc611d213513b1e7f026886ba42))
+* **internal:** update gitignore ([e39102c](https://github.com/Mozilla-Ocho/tabstack-python/commit/e39102cd8c44fbbaec52a5bed74a11deab25b97f))
+
## 2.3.0 (2026-03-12)
Full Changelog: [v2.2.0...v2.3.0](https://github.com/Mozilla-Ocho/tabstack-python/compare/v2.2.0...v2.3.0)
diff --git a/pyproject.toml b/pyproject.toml
index 3822de4..c1383dd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "tabstack"
-version = "2.3.0"
+version = "2.4.0"
description = "The official Python library for the tabstack API"
dynamic = ["readme"]
license = "Apache-2.0"
diff --git a/src/tabstack/_version.py b/src/tabstack/_version.py
index 42120a2..1154ef9 100644
--- a/src/tabstack/_version.py
+++ b/src/tabstack/_version.py
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
__title__ = "tabstack"
-__version__ = "2.3.0" # x-release-please-version
+__version__ = "2.4.0" # x-release-please-version