Release v0.5.7

This commit is contained in:
typesafe-public-bot[bot] authored and GitHub committed 2026-09-11 22:56:28 +00:00
1 parent 4e2be3e8ed
commit f307bce731
63 files changed
+6382

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
"""Smoke-test wheel and sdist installations."""
# ruff: noqa: INP001, S603, S607 - Standalone helper using explicit uv arguments.
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
def check_distributions(distributions: Path, version: str) -> None:
"""Check both distributions in isolated environments."""
wheels, sdists = list(distributions.glob("*.whl")), list(distributions.glob("*.tar.gz"))
if len(wheels) != 1 or len(sdists) != 1:
raise ValueError("Expected exactly one wheel and one source distribution")
with tempfile.TemporaryDirectory(prefix="typesafe-distributions-") as temporary:
consumer = Path(temporary)
installation_check = consumer / "check_installed_distribution.py"
shutil.copyfile(Path(__file__).with_name("check_installed_distribution.py"), installation_check)
subprocess.run(
["uv", "build", "--wheel", str(sdists[0].resolve()), "--out-dir", str(consumer / "rebuilt")],
cwd=consumer,
check=True,
)
rebuilt = list((consumer / "rebuilt").glob("*.whl"))
if len(rebuilt) != 1:
raise ValueError("Expected one wheel rebuilt from the source distribution")
for wheel in [wheels[0].resolve(), rebuilt[0]]:
subprocess.run(
[
"uv",
"run",
"--isolated",
"--no-project",
"--python",
sys.executable,
"--with",
str(wheel),
"python",
str(installation_check),
version,
],
cwd=consumer,
check=True,
)
if __name__ == "__main__":
check_distributions(Path(sys.argv[1]), sys.argv[2])
@@ -0,0 +1,26 @@
"""Check an installed SDK distribution."""
# ruff: noqa: INP001 - Standalone CI script.
import asyncio
import sys
from importlib.metadata import version
import typesafe_sdk
from typesafe_sdk import AsyncTypeSafeClient, TypeSafeClient
async def main() -> None:
"""Check the installed version and initialize both clients."""
if version("typesafe-sdk") != sys.argv[1]:
raise RuntimeError("Installed distribution version does not match the release")
if typesafe_sdk.__version__ != sys.argv[1]:
raise RuntimeError("Exported SDK version does not match the release")
with TypeSafeClient(api_key="release-smoke-test"):
pass
async with AsyncTypeSafeClient(api_key="release-smoke-test"):
pass
if __name__ == "__main__":
asyncio.run(main())
+43
View File
@@ -0,0 +1,43 @@
"""Extract a tagged release's title and notes from the changelog."""
# ruff: noqa: INP001 - Standalone CI script.
import os
import re
import sys
from datetime import date
from pathlib import Path
def release_notes(changelog: str, tag: str) -> tuple[str, str]:
"""Return the latest release heading and body, requiring a unique entry matching the tag."""
sections = re.split(r"^## ", changelog, flags=re.MULTILINE)[1:]
sections = [section for section in sections if section.partition("\n")[0] != "Unreleased"]
matches = [section for section in sections if section.partition("\n")[0].startswith(f"{tag} (")]
if len(matches) != 1:
raise ValueError(f"Expected exactly one changelog entry for {tag}, found {len(matches)}")
if matches[0] != sections[0]:
raise ValueError(f"Tag {tag} must match the latest changelog entry")
title, _, body = matches[0].partition("\n")
match = re.fullmatch(rf"{re.escape(tag)} \((\d{{4}}-\d{{2}}-\d{{2}})\)", title)
if match is None:
raise ValueError(f"Invalid release heading: {title}")
date.fromisoformat(match[1])
if not body.strip():
raise ValueError(f"Empty release notes for {tag}")
return title, body.strip() + "\n"
def main() -> None:
"""Validate the tag and write notes and GitHub Actions outputs."""
tag, version = sys.argv[1:]
if re.fullmatch(r"v\d+\.\d+\.\d+", tag) is None or tag != f"v{version}":
raise ValueError(f"Tag {tag} does not match package version {version} or the vX.Y.Z format")
title, notes = release_notes(Path("docs/changelog.md").read_text(), tag)
Path("release-notes.md").write_text(notes)
with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output:
output.write(f"title={title}\n")
if __name__ == "__main__":
main()
+114
View File
@@ -0,0 +1,114 @@
name: Publish
on:
push:
tags: ["v[0-9]*"]
workflow_dispatch:
inputs:
target:
description: Release target
type: choice
options: [dry-run, testpypi, pypi]
default: dry-run
concurrency:
group: publish-${{ github.event_name == 'push' && 'pypi' || inputs.target }}
cancel-in-progress: false
permissions: {}
jobs:
build:
name: Build public distributions
if: github.repository == 'typesafe-ai/typesafe-sdk-python'
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
title: ${{ steps.notes.outputs.title }}
tag: ${{ steps.notes.outputs.tag }}
target: ${{ github.event_name == 'push' && 'pypi' || inputs.target }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
fetch-depth: 0
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: "0.12.5"
enable-cache: false
- name: Validate release and extract changelog notes
id: notes
env:
TARGET: ${{ github.event_name == 'push' && 'pypi' || inputs.target }}
REPOSITORY_PRIVATE: ${{ github.event.repository.private }}
run: |
case "$TARGET" in
dry-run|testpypi|pypi) ;;
*) echo 'Unknown publishing target'; exit 1 ;;
esac
TAG="v$(uv version --short)"
if [ "$TARGET" != dry-run ]; then
test "$REPOSITORY_PRIVATE" = false || { echo 'Use dry-run until this repository is public'; exit 1; }
git merge-base --is-ancestor HEAD origin/main || { echo 'Publish only source merged into public main'; exit 1; }
fi
if [ "$TARGET" = pypi ]; then
test "$GITHUB_REF_TYPE" = tag || { echo 'Select a public version tag for PyPI'; exit 1; }
fi
if [ "$GITHUB_REF_TYPE" = tag ]; then TAG="$GITHUB_REF_NAME"; fi
uv run --no-project .github/scripts/release_notes.py "$TAG" "$(uv version --short)"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Build and test distributions
run: |
uv build
uv run --no-project .github/scripts/check_distributions.py dist "$(uv version --short)"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release
path: |
dist/
release-notes.md
if-no-files-found: error
publish:
name: Publish to ${{ needs.build.outputs.target }}
needs: build
if: needs.build.outputs.target != 'dry-run'
runs-on: ubuntu-latest
environment:
name: ${{ needs.build.outputs.target }}
permissions:
id-token: write # Trusted Publishing and attestations.
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release
- name: Publish tested distributions
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
repository-url: ${{ needs.build.outputs.target == 'testpypi' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }}
github-release:
name: GitHub release
needs: [build, publish]
if: needs.build.outputs.target == 'pypi'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release
- name: Create or update GitHub release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RELEASE_TITLE: ${{ needs.build.outputs.title }}
RELEASE_TAG: ${{ needs.build.outputs.tag }}
run: |
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
gh release edit "$RELEASE_TAG" --title "$RELEASE_TITLE" --notes-file release-notes.md
else
gh release create "$RELEASE_TAG" --verify-tag --title "$RELEASE_TITLE" --notes-file release-notes.md
fi
gh release upload "$RELEASE_TAG" dist/* --clobber
+29
View File
@@ -0,0 +1,29 @@
.env
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
.pytest_cache/
.ruff_cache/
.cache/
site/
# Devenv
.devenv*
devenv.local.nix
devenv.local.yaml
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml
# Release automation output
release-notes.md
+1
View File
@@ -0,0 +1 @@
3.14
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+10
View File
@@ -0,0 +1,10 @@
---
title: Changelog
icon: lucide/history
---
# Changelog
## v0.5.7 (2026-09-14)
This is the initial public release of TypeSafe Python SDK. Learn more in the [documentation](https://docs.typesafe.ai/sdk/python).
+86
View File
@@ -0,0 +1,86 @@
[project]
name = "typesafe-sdk"
version = "0.5.7"
description = "Python SDK for TypeSafe AI API."
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
authors = [
{ name = "TypeSafe AI", email = "support@typesafe.ai" }
]
maintainers = [
{ name = "Daniel Gafni", email = "daniel@typesafe.ai" }
]
requires-python = ">=3.10"
keywords = ["typesafe", "sdk", "api", "ai"]
classifiers = [
"Typing :: Typed",
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"Operating System :: POSIX",
"Operating System :: MacOS",
"Operating System :: POSIX :: Linux",
"Operating System :: Microsoft :: Windows",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Software Development :: Libraries :: Python Modules",
"License :: OSI Approved :: MIT License",
]
dependencies = [
"httpx2>=2.0.0",
"msgspec>=0.21.1",
"tenacity>=9.0.0",
"typing-extensions>=4.13.0",
]
[project.urls]
Homepage = "https://typesafe.ai"
Documentation = "https://docs.typesafe.ai/sdk/python/"
Changelog = "https://docs.typesafe.ai/sdk/python/changelog/"
Repository = "https://github.com/typesafe-ai/typesafe-sdk-python"
Issues = "https://github.com/typesafe-ai/typesafe-sdk-python/issues"
[build-system]
requires = ["uv_build>=0.12.5,<0.13"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"gitpython>=3.1.62",
"pyrefly>=1.2.0",
"pytest>=9.1.1",
"pytest-asyncio>=1.4.0",
"ruff>=0.16.6",
"sybil>=9.3.0",
"syrupy>=6.0.0",
"tach>=0.35.0",
]
docs = [
"mkdocstrings[python]>=1.0.6",
"ruff>=0.16.6",
"zensical>=0.0.60",
]
codegen = [
"datamodel-code-generator>=0.79.0",
]
[tool.pytest.ini_options]
addopts = "-vv -ra --capture=tee-sys -p no:tach"
log_cli = true
log_cli_level = "INFO"
log_level = "INFO"
asyncio_mode = "auto"
testpaths = ["tests"]
markers = ["integration: live API tests requiring TYPESAFE_API_KEY"]
[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
publish-url = "https://test.pypi.org/legacy/"
explicit = true
+9
View File
@@ -0,0 +1,9 @@
preset = "strict"
project-includes = [
"src",
"tests",
]
# Negative fixtures are checked by pytest with --expectations.
project-excludes = ["tests/typing/negative"]
python-version = "3.10"
search-path = ["src", "."]
+89
View File
@@ -0,0 +1,89 @@
"""Python clients and public types for TypeSafe AI."""
from typesafe_sdk import _core as _core
from typesafe_sdk import constants as constants
from typesafe_sdk._core.client.aio.client import AsyncTypeSafeClient
from typesafe_sdk._core.client.aio.models import AsyncModels
from typesafe_sdk._core.client.sync.client import TypeSafeClient
from typesafe_sdk._core.client.sync.models import Models
from typesafe_sdk._core.errors import (
TypeSafeAPIConnectionError,
TypeSafeAPIError,
TypeSafeAPIResponseValidationError,
TypeSafeAPITimeoutError,
TypeSafeAuthenticationError,
TypeSafeBadRequestError,
TypeSafeError,
TypeSafeInternalServerError,
TypeSafeNotFoundError,
TypeSafePermissionDeniedError,
TypeSafeRateLimitError,
TypeSafeUnprocessableEntityError,
)
from typesafe_sdk._core.json_types import JSONValue
from typesafe_sdk._core.question_types import (
Choice,
ChoiceModel,
Noul,
NoulCriteria,
NoulModel,
Question,
QuestionModel,
Questions,
Score,
ScoreModel,
)
from typesafe_sdk._core.response_types import (
Answer,
ChoiceAnswer,
ListModelsResponse,
ModelMetadata,
NoulAnswer,
ScoreAnswer,
SystemOneResponse,
Usage,
)
from typesafe_sdk._core.retry import RetryPolicy
from typesafe_sdk._version import __version__ as __version__
__all__ = [
"Answer",
"AsyncModels",
"AsyncTypeSafeClient",
"Choice",
"ChoiceAnswer",
"ChoiceModel",
"JSONValue",
"ListModelsResponse",
"ModelMetadata",
"Models",
"Noul",
"NoulAnswer",
"NoulCriteria",
"NoulModel",
"Question",
"QuestionModel",
"Questions",
"RetryPolicy",
"Score",
"ScoreAnswer",
"ScoreModel",
"SystemOneResponse",
"TypeSafeAPIConnectionError",
"TypeSafeAPIError",
"TypeSafeAPIResponseValidationError",
"TypeSafeAPITimeoutError",
"TypeSafeAuthenticationError",
"TypeSafeBadRequestError",
"TypeSafeClient",
"TypeSafeError",
"TypeSafeInternalServerError",
"TypeSafeNotFoundError",
"TypeSafePermissionDeniedError",
"TypeSafeRateLimitError",
"TypeSafeUnprocessableEntityError",
"Usage",
"constants",
]
del _core
+1
View File
@@ -0,0 +1 @@
"""Private implementation details."""
@@ -0,0 +1 @@
"""Synchronous and asynchronous clients and their resource accessors."""
@@ -0,0 +1 @@
"""Asynchronous client and resource accessors."""
+194
View File
@@ -0,0 +1,194 @@
"""Asynchronous API client."""
from collections.abc import Mapping
from functools import cached_property
from types import TracebackType
import httpx2
from typing_extensions import Self
from typesafe_sdk._core.client.aio.models import AsyncModels
from typesafe_sdk._core.config import Config
from typesafe_sdk._core.endpoints import prepare_system_one
from typesafe_sdk._core.json_types import JSONValue
from typesafe_sdk._core.question_types import Question
from typesafe_sdk._core.response_types import SystemOneResponse
from typesafe_sdk._core.retry import RetryPolicy, build_tenacity_async
from typesafe_sdk._core.transport import Request, ResponseT, send_async
class AsyncTypeSafeClient:
def __init__(
self,
*,
api_key: str | None = None,
model: str | None = None,
retry: RetryPolicy | None = None,
timeout: float | httpx2.Timeout | None = None,
headers: Mapping[str, str] | None = None,
transport: httpx2.AsyncBaseTransport | None = None,
http_client: httpx2.AsyncClient | None = None,
base_url: str | None = None,
) -> None:
"""Create an asynchronous HTTP client for [TypeSafe AI API](https://typesafe.ai).
Explicit options take precedence over environment variables; empty or whitespace-only
environment values are ignored.
!!! tip "Logging setup"
The SDK logs to the `typesafe_sdk` logger; configure it through standard logging, or set
`TYPESAFE_LOG_LEVEL` (`debug`, `info`, ...) for a quick default. Secret headers are
redacted from log output; request and response bodies are not.
Args:
api_key: Required API key; may be set via the `TYPESAFE_API_KEY` environment variable.
model: Model name; may be set via the `TYPESAFE_DEFAULT_MODEL` environment variable.
retry: A `RetryPolicy` controlling retry behavior; see `RetryPolicy` for the available options and their
defaults. Pass `RetryPolicy(max_retries=0)` to disable retries.
timeout: Timeout for HTTP operations. Inherits `http_client.timeout` when supplied, otherwise the SDK default.
headers: Additional request headers to set.
transport: Optional custom HTTP transport, closed when this SDK client closes.
http_client: Optional `httpx2.AsyncClient`; mutually exclusive with `transport`.
Closed when this SDK client closes.
base_url: API root; may be set via the `TYPESAFE_BASE_URL` environment variable.
Raises:
TypeSafeError: The API key is missing or the timeout is invalid.
ValueError: Both `transport` and `http_client` are supplied.
Examples:
```python
import asyncio
from typesafe_sdk import AsyncTypeSafeClient, Choice, Noul
async def main() -> None:
async with AsyncTypeSafeClient() as client:
result = await client.system_one(
state="I was charged twice. Please help.",
questions={
"billing": Noul(instructions="Is this about billing?"),
"tone": Choice(
instructions="What is the tone?",
criteria={"calm": None, "angry": None},
),
},
)
assert 0 <= result.nouls["billing"].noul <= 1
assert result.choices["tone"].choice in {"calm", "angry"}
asyncio.run(main())
```
"""
if transport is not None and http_client is not None:
raise ValueError("transport and http_client are mutually exclusive.")
if timeout is None and http_client is not None:
timeout = http_client.timeout
self._config = Config.resolve(api_key, base_url, model, timeout, headers)
self._retry = build_tenacity_async(retry)
self._http_client = httpx2.AsyncClient(timeout=self._config.timeout, transport=transport) if http_client is None else http_client
@cached_property
def models(self) -> AsyncModels:
"""An accessor for the Models API resource.
Examples:
```python
async def main() -> None:
async with AsyncTypeSafeClient() as client:
models = await client.models.list()
```
"""
return AsyncModels(self._config, self._http_client, self._retry)
async def system_one(
self,
state: str | dict[str, JSONValue | None] | list[JSONValue | None],
questions: Mapping[str, Question],
*,
model: str | None = None,
retry: RetryPolicy | None = None,
timeout: float | httpx2.Timeout | None = None,
extra_headers: Mapping[str, str] | None = None,
extra_body: Mapping[str, JSONValue | None] | None = None,
) -> SystemOneResponse:
"""Answer named questions about text or structured state.
Args:
state: Text, a JSON object, or an array to evaluate; not `None`.
questions: Nonempty mapping of names to question objects or raw dictionaries.
model: Model override; `None` inherits the client default.
retry: An optional retry policy to override the client-level value for this call only.
timeout: An optional timeout for http operations to override the client-level value for this call only, in seconds.
extra_headers: Additional request headers to set.
extra_body: Additional top-level request-body fields, shallow-merged over the body after
`state`, `model`, and `questions` are set. Merging is last-write-wins: a key that
collides with `state`, `model`, or `questions` overrides it, and object values are
replaced rather than deep-merged.
Returns:
Answers keyed by question name, with model and token usage details.
Raises:
TypeSafeError: Questions are empty or score criteria are empty or not indexed consecutively from zero.
TypeSafeAPIError: The server returns an unsuccessful HTTP response after any retries.
TypeSafeAPIConnectionError: The request cannot connect or times out after any retries.
Examples:
Create questions with named arguments:
```python
async def main() -> None:
async with AsyncTypeSafeClient() as client:
result = await client.system_one(
state="I was charged twice. Please help.",
questions={
"billing": Noul(instructions="Is this about billing?"),
"tone": Choice(
instructions="What is the tone?",
criteria={"calm": None, "angry": None},
),
},
)
assert 0 <= result.nouls["billing"].noul <= 1
assert result.choices["tone"].choice in {"calm", "angry"}
```
Pass questions as dictionaries:
```python
async def main() -> None:
async with AsyncTypeSafeClient() as client:
result = await client.system_one(
state={"message": "I was charged twice. Please help."},
questions={
"billing": {"type": "noul", "instructions": "Is this about billing?"},
"tone": {
"type": "choice",
"instructions": "What is the tone?",
"criteria": {"calm": None, "angry": None},
},
},
)
assert 0 <= result.nouls["billing"].noul <= 1
assert result.choices["tone"].choice in {"calm", "angry"}
```
"""
return await self._request(prepare_system_one(self._config, state, questions, model, extra_body, timeout, extra_headers), retry=retry)
async def _request(self, request: Request[ResponseT], *, retry: RetryPolicy | None = None) -> ResponseT:
return await send_async(self._http_client, self._retry, request, retry)
async def aclose(self) -> None:
"""Release network resources and close the underlying HTTP client, including a supplied one."""
await self._http_client.aclose()
async def __aenter__(self) -> Self:
"""Enter the asynchronous client context."""
return self
async def __aexit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None:
"""Close the asynchronous client context."""
await self.aclose()
@@ -0,0 +1,56 @@
"""Model resource for the asynchronous client, exposed as `AsyncTypeSafeClient.models`."""
from collections.abc import Mapping
import httpx2
from tenacity import AsyncRetrying
from typesafe_sdk._core.config import Config
from typesafe_sdk._core.endpoints import prepare_models
from typesafe_sdk._core.response_types import ListModelsResponse
from typesafe_sdk._core.retry import RetryPolicy
from typesafe_sdk._core.transport import send_async
class AsyncModels:
"""Access to the models available to the account, reached through `AsyncTypeSafeClient.models`."""
def __init__(self, config: Config, http_client: httpx2.AsyncClient, retry: AsyncRetrying) -> None:
self._config = config
self._http_client = http_client
self._retry = retry
async def list(
self,
*,
retry: RetryPolicy | None = None,
timeout: float | httpx2.Timeout | None = None,
extra_headers: Mapping[str, str] | None = None,
) -> ListModelsResponse:
"""List the models available to the account.
Args:
retry: An optional retry policy to override the client-level value for this call only.
timeout: Per-operation timeout override; `None` inherits the client setting.
extra_headers: Overrides for additional request headers; authentication, SDK identification,
and `Accept` remain protected.
Returns:
A `ListModelsResponse` whose `models` holds each model's name, description,
and release date.
Raises:
TypeSafeAPIError: The server returns an unsuccessful HTTP response after any retries.
TypeSafeAPIConnectionError: The request cannot connect or times out after any retries.
Examples:
```python
from typesafe_sdk import AsyncTypeSafeClient
async def main() -> None:
async with AsyncTypeSafeClient() as client:
models = await client.models.list()
```
"""
return await send_async(self._http_client, self._retry, prepare_models(self._config, timeout, extra_headers), retry)
@@ -0,0 +1 @@
"""Synchronous client and resource accessors."""
@@ -0,0 +1,184 @@
"""Synchronous API client."""
from collections.abc import Mapping
from functools import cached_property
from types import TracebackType
import httpx2
from typing_extensions import Self
from typesafe_sdk._core.client.sync.models import Models
from typesafe_sdk._core.config import Config
from typesafe_sdk._core.endpoints import prepare_system_one
from typesafe_sdk._core.json_types import JSONValue
from typesafe_sdk._core.question_types import Question
from typesafe_sdk._core.response_types import SystemOneResponse
from typesafe_sdk._core.retry import RetryPolicy, build_tenacity
from typesafe_sdk._core.transport import Request, ResponseT, send
class TypeSafeClient:
def __init__(
self,
*,
api_key: str | None = None,
model: str | None = None,
retry: RetryPolicy | None = None,
timeout: float | httpx2.Timeout | None = None,
headers: Mapping[str, str] | None = None,
transport: httpx2.BaseTransport | None = None,
http_client: httpx2.Client | None = None,
base_url: str | None = None,
) -> None:
"""Create an HTTP client for [TypeSafe AI API](https://typesafe.ai).
Explicit options take precedence over environment variables; empty or whitespace-only
environment values are ignored.
!!! tip "Logging setup"
The SDK logs to the `typesafe_sdk` logger; configure it through standard logging, or set
`TYPESAFE_LOG_LEVEL` (`debug`, `info`, ...) for a quick default. Secret headers are
redacted from log output; request and response bodies are not.
Args:
api_key: Required API key; may be set via the `TYPESAFE_API_KEY` environment variable.
model: Model name; may be set via the `TYPESAFE_DEFAULT_MODEL` environment variable.
retry: A `RetryPolicy` controlling retry behavior; see `RetryPolicy` for the available options and their
defaults. Pass `RetryPolicy(max_retries=0)` to disable retries.
timeout: Timeout for HTTP operations. Inherits `http_client.timeout` when supplied, otherwise the SDK default.
headers: Additional request headers to set.
transport: Optional custom HTTP transport, closed when this SDK client closes.
http_client: Optional `httpx2.Client`; mutually exclusive with `transport`.
Closed when this SDK client closes.
base_url: API root; may be set via the `TYPESAFE_BASE_URL` environment variable.
Raises:
TypeSafeError: The API key is missing or the timeout is invalid.
ValueError: Both `transport` and `http_client` are supplied.
Examples:
```python
from typesafe_sdk import Choice, Noul, TypeSafeClient
with TypeSafeClient() as client:
result = client.system_one(
state="I was charged twice. Please help.",
questions={
"billing": Noul(instructions="Is this about billing?"),
"tone": Choice(
instructions="What is the tone?",
criteria={"calm": None, "angry": None},
),
},
)
assert 0 <= result.nouls["billing"].noul <= 1
assert result.choices["tone"].choice in {"calm", "angry"}
```
"""
if transport is not None and http_client is not None:
raise ValueError("transport and http_client are mutually exclusive.")
if timeout is None and http_client is not None:
timeout = http_client.timeout
self._config = Config.resolve(api_key, base_url, model, timeout, headers)
self._retry = build_tenacity(retry)
self._http_client = httpx2.Client(timeout=self._config.timeout, transport=transport) if http_client is None else http_client
@cached_property
def models(self) -> Models:
"""An accessor for the Models API resource.
Examples:
```python
with TypeSafeClient() as client:
models = client.models.list()
```
"""
return Models(self._config, self._http_client, self._retry)
def system_one(
self,
state: str | dict[str, JSONValue | None] | list[JSONValue | None],
questions: Mapping[str, Question],
*,
model: str | None = None,
retry: RetryPolicy | None = None,
timeout: float | httpx2.Timeout | None = None,
extra_headers: Mapping[str, str] | None = None,
extra_body: Mapping[str, JSONValue | None] | None = None,
) -> SystemOneResponse:
"""Answer named questions about text or structured state.
Args:
state: Text, a JSON object, or an array to evaluate; not `None`.
questions: Nonempty mapping of names to question objects or raw dictionaries.
model: Model override; `None` inherits the client default.
retry: An optional retry policy to override the client-level value for this call only.
timeout: An optional timeout for http operations to override the client-level value for this call only, in seconds.
extra_headers: Additional request headers to set.
extra_body: Additional top-level request-body fields, shallow-merged over the body after
`state`, `model`, and `questions` are set. Merging is last-write-wins: a key that
collides with `state`, `model`, or `questions` overrides it, and object values are
replaced rather than deep-merged.
Returns:
Answers keyed by question name, with model and token usage details.
Raises:
TypeSafeError: Questions are empty or score criteria are empty or not indexed consecutively from zero.
TypeSafeAPIError: The server returns an unsuccessful HTTP response after any retries.
TypeSafeAPIConnectionError: The request cannot connect or times out after any retries.
Examples:
Create questions with named arguments:
```python
with TypeSafeClient() as client:
result = client.system_one(
state="I was charged twice. Please help.",
questions={
"billing": Noul(instructions="Is this about billing?"),
"tone": Choice(
instructions="What is the tone?",
criteria={"calm": None, "angry": None},
),
},
)
assert 0 <= result.nouls["billing"].noul <= 1
assert result.choices["tone"].choice in {"calm", "angry"}
```
Pass questions as dictionaries:
```python
with TypeSafeClient() as client:
result = client.system_one(
state={"message": "I was charged twice. Please help."},
questions={
"billing": {"type": "noul", "instructions": "Is this about billing?"},
"tone": {
"type": "choice",
"instructions": "What is the tone?",
"criteria": {"calm": None, "angry": None},
},
},
)
assert 0 <= result.nouls["billing"].noul <= 1
assert result.choices["tone"].choice in {"calm", "angry"}
```
"""
return self._request(prepare_system_one(self._config, state, questions, model, extra_body, timeout, extra_headers), retry=retry)
def _request(self, request: Request[ResponseT], *, retry: RetryPolicy | None = None) -> ResponseT:
return send(self._http_client, self._retry, request, retry)
def close(self) -> None:
"""Release network resources and close the underlying HTTP client, including a supplied one."""
self._http_client.close()
def __enter__(self) -> Self:
"""Enter the client context."""
return self
def __exit__(self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None) -> None:
"""Close the client context."""
self.close()
@@ -0,0 +1,54 @@
"""Model resource for the synchronous client, exposed as `TypeSafeClient.models`."""
from collections.abc import Mapping
import httpx2
from tenacity import Retrying
from typesafe_sdk._core.config import Config
from typesafe_sdk._core.endpoints import prepare_models
from typesafe_sdk._core.response_types import ListModelsResponse
from typesafe_sdk._core.retry import RetryPolicy
from typesafe_sdk._core.transport import send
class Models:
"""Access to the models available to the account, reached through `TypeSafeClient.models`."""
def __init__(self, config: Config, http_client: httpx2.Client, retry: Retrying) -> None:
self._config = config
self._http_client = http_client
self._retry = retry
def list(
self,
*,
retry: RetryPolicy | None = None,
timeout: float | httpx2.Timeout | None = None,
extra_headers: Mapping[str, str] | None = None,
) -> ListModelsResponse:
"""List the models available to the account.
Args:
retry: An optional retry policy to override the client-level value for this call only.
timeout: Per-operation timeout override; `None` inherits the client setting.
extra_headers: Overrides for additional request headers; authentication, SDK identification,
and `Accept` remain protected.
Returns:
A `ListModelsResponse` whose `models` holds each model's name, description,
and release date.
Raises:
TypeSafeAPIError: The server returns an unsuccessful HTTP response after any retries.
TypeSafeAPIConnectionError: The request cannot connect or times out after any retries.
Examples:
```python
from typesafe_sdk import TypeSafeClient
with TypeSafeClient() as client:
models = client.models.list()
```
"""
return send(self._http_client, self._retry, prepare_models(self._config, timeout, extra_headers), retry)
+61
View File
@@ -0,0 +1,61 @@
"""Configuration resolution."""
import math
import os
from collections.abc import Mapping
from dataclasses import dataclass, field
import httpx2
from typesafe_sdk._core.errors import TypeSafeError
from typesafe_sdk.constants import (
API_KEY_ENV,
BASE_URL_ENV,
DEFAULT_BASE_URL,
DEFAULT_MODEL,
DEFAULT_MODEL_ENV,
DEFAULT_TIMEOUT,
)
def _resolve_env(value: str | None, env: str, default: str | None = None) -> str | None:
return value if value is not None else os.environ.get(env, "").strip() or default
def resolve_timeout(timeout: float | httpx2.Timeout) -> float | httpx2.Timeout:
if not isinstance(timeout, httpx2.Timeout) and (not math.isfinite(timeout) or timeout <= 0):
raise TypeSafeError("timeout must be a positive, finite number of seconds.")
return timeout
@dataclass
class Config:
api_key: str = field(repr=False)
base_url: str
default_model: str
timeout: float | httpx2.Timeout
default_headers: httpx2.Headers = field(repr=False)
@classmethod
def resolve(
cls,
api_key: str | None,
base_url: str | None,
default_model: str | None,
timeout: float | httpx2.Timeout | None,
default_headers: Mapping[str, str] | None,
) -> "Config":
key = _resolve_env(api_key, API_KEY_ENV)
if key is None:
raise TypeSafeError(f"No API key was provided. Pass api_key or set the {API_KEY_ENV} environment variable.")
resolved_base_url = _resolve_env(base_url, BASE_URL_ENV, DEFAULT_BASE_URL)
resolved_model = _resolve_env(default_model, DEFAULT_MODEL_ENV, DEFAULT_MODEL)
assert resolved_base_url is not None, "DEFAULT_BASE_URL default guarantees a value" # noqa: S101 - narrows the non-None default.
assert resolved_model is not None, "DEFAULT_MODEL default guarantees a value" # noqa: S101 - narrows the non-None default.
return cls(
key,
resolved_base_url.rstrip("/"),
resolved_model,
resolve_timeout(DEFAULT_TIMEOUT if timeout is None else timeout),
httpx2.Headers(default_headers),
)
+21
View File
@@ -0,0 +1,21 @@
"""Internal protocol and logging constants."""
MAX_ERROR_BODY_LENGTH = 200
SYSTEM_ONE_PATH = "/v1/systemone"
MODELS_PATH = "/v1/models"
SDK_NAME = "typesafe-sdk"
LOGGER_NAME = "typesafe_sdk"
JSON_CONTENT_TYPE = "application/json"
AUTHORIZATION_HEADER = "Authorization"
ACCEPT_HEADER = "Accept"
CONTENT_TYPE_HEADER = "Content-Type"
USER_AGENT_HEADER = "User-Agent"
SDK_HEADER = "X-TypeSafe-SDK"
RUNTIME_HEADER = "X-TypeSafe-Runtime"
RETRY_COUNT_HEADER = "X-TypeSafe-Retry-Count"
REQUEST_ID_HEADER = "x-typesafe-request-id"
RETRY_AFTER_HEADER = "retry-after"
RETRY_AFTER_MS_HEADER = "retry-after-ms"
SECRET_HEADERS = frozenset({"authorization", "proxy-authorization", "x-api-key", "api-key", "cookie", "set-cookie"})
+37
View File
@@ -0,0 +1,37 @@
"""Endpoint-specific request builders, layered over the generic `transport.prepare`."""
from collections.abc import Mapping
from typing import Any
import httpx2
from typesafe_sdk._core.config import Config
from typesafe_sdk._core.constants import MODELS_PATH, SYSTEM_ONE_PATH
from typesafe_sdk._core.json_types import JSONValue
from typesafe_sdk._core.question_types import Question
from typesafe_sdk._core.questions import normalize_questions
from typesafe_sdk._core.response_types import ListModelsResponse, SystemOneResponse
from typesafe_sdk._core.transport import Request, prepare
def prepare_system_one(
config: Config,
state: str | dict[str, JSONValue | None] | list[JSONValue | None],
questions: Mapping[str, Question],
model: str | None,
extra_body: Mapping[str, JSONValue | None] | None,
timeout: float | httpx2.Timeout | None,
headers: Mapping[str, str] | None,
) -> Request[SystemOneResponse]:
body: dict[str, Any] = {
"state": state,
"model": config.default_model if model is None else model,
"questions": normalize_questions(questions),
}
if extra_body is not None:
body.update(extra_body)
return prepare(config, "POST", SYSTEM_ONE_PATH, body, timeout, headers, SystemOneResponse)
def prepare_models(config: Config, timeout: float | httpx2.Timeout | None, headers: Mapping[str, str] | None) -> Request[ListModelsResponse]:
return prepare(config, "GET", MODELS_PATH, None, timeout, headers, ListModelsResponse)
+167
View File
@@ -0,0 +1,167 @@
"""Exceptions and server message extraction."""
import math
import time
from email.utils import parsedate_to_datetime
from http import HTTPStatus
from typing import Any
import httpx2
from typesafe_sdk._core.constants import MAX_ERROR_BODY_LENGTH, REQUEST_ID_HEADER, RETRY_AFTER_HEADER, RETRY_AFTER_MS_HEADER
from typesafe_sdk._core.json import serialize
def parse_retry_after(headers: httpx2.Headers) -> float | None:
for name, multiplier in ((RETRY_AFTER_MS_HEADER, 1), (RETRY_AFTER_HEADER, 1000)):
raw = headers.get(name)
if raw is None:
continue
try:
value = float(raw.strip() or "0")
except ValueError:
if name == RETRY_AFTER_HEADER:
try:
return max(0.0, (parsedate_to_datetime(raw).timestamp() - time.time()) * 1000)
except (ValueError, TypeError, OverflowError):
pass
else:
if math.isfinite(value):
if value >= 0:
return value * multiplier
elif name == RETRY_AFTER_HEADER:
return None
return None
def extract_message(body: Any) -> str | None:
if isinstance(body, str):
return body or None
elif not isinstance(body, dict):
return None
error, message, detail = body.get("error"), body.get("message"), body.get("detail")
if isinstance(error, str):
return error
elif isinstance(error, dict) and isinstance(error.get("message"), str):
return error["message"]
elif isinstance(message, str):
return message
elif isinstance(detail, str):
return detail
elif isinstance(detail, dict) and isinstance(detail.get("message"), str):
return detail["message"]
elif isinstance(detail, list):
parts = []
for entry in detail:
if not isinstance(entry, dict) or not isinstance(entry.get("msg"), str):
continue
location = entry.get("loc")
path = ".".join(str(item) for item in location if item != "body") if isinstance(location, list) else ""
parts.append(f"{path}: {entry['msg']}" if path else entry["msg"])
return "; ".join(parts) or None
return None
class TypeSafeError(Exception):
"""Base exception for SDK failures."""
class TypeSafeAPIError(TypeSafeError):
"""An unsuccessful HTTP response with its body and request metadata."""
def __init__(self, status: int, body: Any, headers: httpx2.Headers, message: str | None = None) -> None:
"""Describe an HTTP failure with an optional message override."""
self.status = status
"""HTTP response status code."""
self.body = body
"""The server's JSON error body, plain response text, or `None` for an empty body."""
self.headers = headers
"""HTTP response headers."""
if message is None:
detail = extract_message(body)
if detail:
message = f"{status} {detail}"
elif body is None:
message = f"{status} status code (no body)"
else:
raw = body if isinstance(body, str) else serialize(body).decode()
message = f"{status} {raw[:MAX_ERROR_BODY_LENGTH] + '…' if len(raw) > MAX_ERROR_BODY_LENGTH else raw}"
super().__init__(message)
@property
def request_id(self) -> str | None:
"""The `x-typesafe-request-id` response header, or `None` if absent."""
return self.headers.get(REQUEST_ID_HEADER)
class TypeSafeBadRequestError(TypeSafeAPIError):
"""The request was invalid (400)."""
class TypeSafeAuthenticationError(TypeSafeAPIError):
"""Authentication failed (401)."""
class TypeSafePermissionDeniedError(TypeSafeAPIError):
"""Access was denied (403)."""
class TypeSafeNotFoundError(TypeSafeAPIError):
"""The resource was not found (404)."""
class TypeSafeUnprocessableEntityError(TypeSafeAPIError):
"""The request failed server validation (422)."""
class TypeSafeRateLimitError(TypeSafeAPIError):
"""The rate limit was exceeded (429)."""
def __init__(self, status: int, body: object, headers: httpx2.Headers, message: str | None = None) -> None:
"""Describe a rate-limit response with an optional message override."""
super().__init__(status, body, headers, message)
self.retry_after_ms = parse_retry_after(headers)
"""The server's requested wait in milliseconds, or `None` if unavailable."""
class TypeSafeInternalServerError(TypeSafeAPIError):
"""The server failed to process the request (5xx)."""
class TypeSafeAPIConnectionError(TypeSafeError, ConnectionError):
"""A request failed without an HTTP response."""
class TypeSafeAPITimeoutError(TypeSafeAPIConnectionError, TimeoutError):
"""A request exceeded its configured timeout."""
def __init__(self, timeout: float | httpx2.Timeout) -> None:
"""Describe a request timeout with its configured timeout setting."""
self.timeout = timeout
"""The timeout setting used for the request, in seconds or as an `httpx2.Timeout`."""
super().__init__(f"Request timed out (timeout={timeout}).")
class TypeSafeAPIResponseValidationError(TypeSafeAPIError):
"""A successful HTTP response whose body was missing or structurally invalid required data."""
def __init__(self, status: int, body: Any, headers: httpx2.Headers, field_path: str) -> None:
"""Describe an unparseable response, naming the first missing or structurally invalid field."""
self.field_path = field_path
"""Dotted path to the offending field, such as `answers.tone.confidence`."""
super().__init__(status, body, headers, f"Invalid response data at {field_path!r}.")
STATUS_ERROR_TYPES: dict[int, type[TypeSafeAPIError]] = {
400: TypeSafeBadRequestError,
401: TypeSafeAuthenticationError,
403: TypeSafePermissionDeniedError,
404: TypeSafeNotFoundError,
422: TypeSafeUnprocessableEntityError,
429: TypeSafeRateLimitError,
}
def api_error(status: int, body: object, headers: httpx2.Headers) -> TypeSafeAPIError:
error_type = STATUS_ERROR_TYPES.get(status, TypeSafeInternalServerError if status >= HTTPStatus.INTERNAL_SERVER_ERROR else TypeSafeAPIError)
return error_type(status, body, headers)
+18
View File
@@ -0,0 +1,18 @@
"""JSON encoding and lenient response decoding."""
from typing import Any
import msgspec
def serialize(value: object) -> bytes:
return msgspec.json.encode(value)
def deserialize(content: bytes) -> Any:
if not content:
return None
try:
return msgspec.json.decode(content)
except msgspec.DecodeError:
return content.decode("utf-8", errors="replace")
+6
View File
@@ -0,0 +1,6 @@
"""Shared JSON value types."""
from typing import TypeAlias
JSONValue: TypeAlias = str | int | float | bool | list["JSONValue | None"] | dict[str, "JSONValue | None"]
"""A non-null JSON value, with null permitted inside arrays and objects."""
+64
View File
@@ -0,0 +1,64 @@
"""Standard-library logging with credential redaction.
The SDK emits to the ``typesafe_sdk`` logger and never configures handlers or levels beyond an
optional convenience: set ``TYPESAFE_LOG_LEVEL`` (``debug``/``info``/...) and the level is applied
once at import. Otherwise configure the ``typesafe_sdk`` logger through standard logging as usual.
"""
import logging
import os
from typing_extensions import override
from typesafe_sdk._core.constants import LOGGER_NAME, SECRET_HEADERS
from typesafe_sdk.constants import LOG_LEVEL_ENV
logger = logging.getLogger(LOGGER_NAME)
LOG_LEVELS = {
"debug": logging.DEBUG,
"info": logging.INFO,
"warn": logging.WARNING,
"warning": logging.WARNING,
"error": logging.ERROR,
"off": logging.CRITICAL + 1,
}
def _is_secret(name: str) -> bool:
lowered = name.lower()
return lowered in SECRET_HEADERS or "token" in lowered or "secret" in lowered
def _redact(headers: dict[str, str]) -> dict[str, str]:
return {name: "***" if _is_secret(name) else value for name, value in headers.items()}
class SensitiveHeadersFilter(logging.Filter):
"""Redact credential-bearing headers from a record before it reaches any handler.
Log calls pass headers as the ``headers`` key of a mapping-style ``args``; this filter is the
single redaction point, so no call site can leak a secret header.
"""
@override
def filter(self, record: logging.LogRecord) -> bool:
args = record.args
if isinstance(args, dict):
headers = args.get("headers")
if isinstance(headers, dict):
record.args = {**args, "headers": _redact(headers)}
return True
def setup_logging() -> None:
"""Apply ``TYPESAFE_LOG_LEVEL`` to the ``typesafe_sdk`` logger if it names a known level."""
level = (os.environ.get(LOG_LEVEL_ENV) or "").strip().lower()
if level in LOG_LEVELS:
logger.setLevel(LOG_LEVELS[level])
logger.addHandler(logging.NullHandler())
logger.addFilter(SensitiveHeadersFilter())
setup_logging()
+96
View File
@@ -0,0 +1,96 @@
"""Question objects and raw input models."""
from collections.abc import Mapping
from typing import Literal, TypeAlias
from typing_extensions import NotRequired, TypedDict
from typesafe_sdk._core.json_types import JSONValue
from typesafe_sdk._schemas import models as wire
class NoulCriteria(TypedDict, total=False, extra_items=JSONValue | None):
"""Optional descriptions of the yes and no outcomes."""
true: str | dict[str, JSONValue | None] | list[JSONValue | None] | None
"""Description of the yes outcome as text, a JSON object, or an array; `None` leaves it undescribed."""
false: str | dict[str, JSONValue | None] | list[JSONValue | None] | None
"""Description of the no outcome as text, a JSON object, or an array; `None` leaves it undescribed."""
class NoulModel(TypedDict, extra_items=JSONValue | None):
"""A yes/no question dictionary with `type="noul"`, allowing extra JSON fields."""
type: Literal["noul"]
instructions: NotRequired[str | dict[str, JSONValue | None] | list[JSONValue | None] | None]
"""The question to ask, expressed as text, a JSON object, or an array; optional."""
criteria: NotRequired[NoulCriteria | None]
"""Optional descriptions of the yes and no outcomes."""
class ChoiceModel(TypedDict, extra_items=JSONValue | None):
"""A choice question dictionary with `type="choice"`, allowing extra JSON fields."""
type: Literal["choice"]
instructions: NotRequired[str | dict[str, JSONValue | None] | list[JSONValue | None] | None]
"""The question to ask, expressed as text, a JSON object, or an array; optional."""
criteria: dict[str, str | dict[str, JSONValue | None] | list[JSONValue | None] | None]
"""Labels mapped to text, object, or array descriptions, or `None` for undescribed labels."""
class ScoreModel(TypedDict, extra_items=JSONValue | None):
"""A score question dictionary with `type="score"`, allowing extra JSON fields."""
type: Literal["score"]
instructions: NotRequired[str | dict[str, JSONValue | None] | list[JSONValue | None] | None]
"""The question to ask, expressed as text, a JSON object, or an array; optional."""
criteria: (
list[str | dict[str, JSONValue | None] | list[JSONValue | None]] | dict[int, str | dict[str, JSONValue | None] | list[JSONValue | None]]
)
"""Nonempty text, object, or array descriptions indexed from zero, with no gaps in dictionary keys."""
class Noul(wire.NoulQuestion, kw_only=True, omit_defaults=True):
"""A yes/no question with optional descriptions for either outcome.
See the [noul primitive](https://docs.typesafe.ai/primitives/noul) for details.
"""
instructions: str | dict[str, JSONValue | None] | list[JSONValue | None] | None = None # pyrefly: ignore[bad-override-mutable-attribute]
"""The question to ask, expressed as text, a JSON object, or an array; optional."""
criteria: NoulCriteria | None = None # pyrefly: ignore[bad-override-mutable-attribute]
"""Optional descriptions of the yes and no outcomes."""
class Choice(wire.ChoiceQuestion, kw_only=True, omit_defaults=True):
"""A question that selects between named alternatives.
See the [choice primitive](https://docs.typesafe.ai/primitives/choice) for details.
"""
criteria: dict[str, str | dict[str, JSONValue | None] | list[JSONValue | None] | None]
"""Labels mapped to text, object, or array descriptions, or `None` for undescribed labels."""
instructions: str | dict[str, JSONValue | None] | list[JSONValue | None] | None = None # pyrefly: ignore[bad-override-mutable-attribute]
"""The question to ask, expressed as text, a JSON object, or an array; optional."""
class Score(wire.ScoreQuestion, kw_only=True, omit_defaults=True):
"""A question that assigns a score using an ordered rubric.
See the [score primitive](https://docs.typesafe.ai/primitives/score) for details.
"""
criteria: ( # pyrefly: ignore[bad-override-mutable-attribute]
list[str | dict[str, JSONValue | None] | list[JSONValue | None]] | dict[int, str | dict[str, JSONValue | None] | list[JSONValue | None]]
)
"""Nonempty text, object, or array descriptions indexed from zero, with no gaps in dictionary keys."""
instructions: str | dict[str, JSONValue | None] | list[JSONValue | None] | None = None # pyrefly: ignore[bad-override-mutable-attribute]
"""The question to ask, expressed as text, a JSON object, or an array; optional."""
QuestionModel: TypeAlias = NoulModel | ChoiceModel | ScoreModel
"""A question dictionary identified by its `type` key."""
Question: TypeAlias = Noul | Choice | Score | QuestionModel
"""A question object or question dictionary."""
Questions: TypeAlias = Mapping[str, Question]
"""Question inputs keyed by the names used to identify their answers."""
+49
View File
@@ -0,0 +1,49 @@
"""Question normalization before JSON encoding."""
from collections.abc import Mapping
from typing import cast
import msgspec
from typesafe_sdk._core.errors import TypeSafeError
from typesafe_sdk._core.json_types import JSONValue
from typesafe_sdk._core.question_types import Choice, Noul, Question, Score, ScoreModel
def normalize_questions(questions: Mapping[str, Question]) -> dict[str, Question]:
if not questions:
raise TypeSafeError("At least one question is required.")
result: dict[str, Question] = {}
for name, question in questions.items():
result[name] = question
if isinstance(question, Score):
criteria = _normalize_score_criteria(name, question.criteria)
if criteria is not question.criteria:
result[name] = msgspec.structs.replace(question, criteria=criteria)
elif not isinstance(question, (Noul, Choice)):
if not isinstance(question, dict) or not isinstance(question.get("type"), str) or not question["type"]:
raise TypeSafeError(f'Question "{name}" must be a question object or a dictionary with a nonempty string "type".')
if question["type"] in ("choice", "score") and "criteria" not in question:
raise TypeSafeError(f'Question "{name}" requires "criteria".')
if question["type"] == "score":
criteria = _normalize_score_criteria(name, question["criteria"])
if criteria is not question["criteria"]:
result[name] = cast(ScoreModel, {**question, "criteria": criteria})
return result
def _normalize_score_criteria(
name: str,
criteria: list[str | dict[str, JSONValue | None] | list[JSONValue | None]]
| dict[int, str | dict[str, JSONValue | None] | list[JSONValue | None]],
) -> list[str | dict[str, JSONValue | None] | list[JSONValue | None]]:
"""Validate a score question's criteria and normalize a keyed dict into a gap-free ordered list."""
if not criteria:
raise TypeSafeError(f'Score question "{name}" has no criteria; at least one score is required.')
if isinstance(criteria, dict):
if any(type(key) is not int or key < 0 for key in criteria):
raise TypeSafeError(f'Score question "{name}" keys must be non-negative integers.')
if sorted(criteria) != list(range(len(criteria))):
raise TypeSafeError(f'Score question "{name}" defines scores {sorted(criteria)}, but scores must run from 0 with no gaps.')
return [criteria[index] for index in range(len(criteria))]
return criteria
+181
View File
@@ -0,0 +1,181 @@
"""Ergonomic answer objects and response metadata, built on the generated wire schemas.
The wire structs in `typesafe_sdk._schemas.models` mirror the OpenAPI schema. The public answer types
subclass them, adding immutability and integer-keyed score maps, and let msgspec decode straight into
the public types — no wrapping layer. Because the wire answer structs are tagged, the public `Answer`
union decodes in one call; per-answer dispatch is kept only to skip answer kinds a future API adds.
"""
from functools import cached_property
from typing import Any, TypeAlias
import httpx2
import msgspec
from msgspec import field
from typing_extensions import Self, override
from typesafe_sdk._core.logging import logger
from typesafe_sdk._core.schemas.base import Response, field_path, validation_error
from typesafe_sdk._schemas import models as wire
from typesafe_sdk._schemas.models import ModelMetadata
class NoulAnswer(wire.NoulAnswer, kw_only=True):
"""A yes/no answer."""
noul: float
"""Probability of a yes answer, from zero to one."""
class ChoiceAnswer(wire.ChoiceAnswer, kw_only=True):
"""A selected label and its probabilities."""
choice: str
"""The selected label."""
confidence: float
"""Reported confidence in the selected label."""
probabilities: dict[str, float]
"""Probabilities keyed by label."""
class ScoreAnswer(wire.ScoreAnswer, kw_only=True):
"""An expected score with its rubric and probabilities."""
score: float
"""Expected score, which may fall between the integer rubric levels."""
confidence: float
"""Reported confidence in the score."""
# JSON object keys are strings; `dict[int, ...]` tells msgspec to coerce them to the integer score
# levels at decode time. `Any` (not the recursive `JSONValue`) keeps the nested values decodable.
legend: dict[int, str | dict[str, Any] | list[Any]] # pyrefly: ignore[bad-override-mutable-attribute]
"""Rubric descriptions keyed by integer score."""
probabilities: dict[int, float] # pyrefly: ignore[bad-override-mutable-attribute]
"""Probabilities keyed by integer score."""
Answer: TypeAlias = NoulAnswer | ChoiceAnswer | ScoreAnswer
"""An answer to a single question, identified by its `type`."""
# The OpenAPI Usage schema still requires billing_units, which the API does not return.
class Usage(msgspec.Struct, kw_only=True):
"""Token counts for a request, when reported by the API."""
input_tokens: int | None = None
"""Number of input tokens used, or `None` when the API did not report it."""
output_tokens: int | None = None
"""Number of output tokens used, or `None` when the API did not report it."""
class _AnswerTag(msgspec.Struct):
"""Reads only an answer's discriminator so it can be routed to the right struct."""
type: str
class _SystemOneBody(msgspec.Struct, kw_only=True):
"""Fast-path decode target: the whole tagged response, straight into the public types."""
model: str
usage: Usage
answers: dict[str, Answer]
class _SystemOneRawBody(msgspec.Struct, kw_only=True):
"""Dispatch decode target; answers stay raw so unknown tags can be skipped one by one."""
model: str
usage: Usage
answers: dict[str, msgspec.Raw]
_ANSWER_TYPES: dict[str, type[Answer]] = {
"noul": NoulAnswer,
"choice": ChoiceAnswer,
"score": ScoreAnswer,
}
# One-shot decoder for the whole tagged response. The `Answer` structs carry a `tag_field`, so the
# public union decodes in a single call; per-answer dispatch (below) handles only forward-compat
# unknown types and precise error paths.
_RESPONSE_DECODER = msgspec.json.Decoder(_SystemOneBody)
class SystemOneResponse(Response, frozen=True, kw_only=True):
"""Answers grouped by question type with model and usage metadata."""
model: str
"""The model used to answer the request."""
usage: Usage
"""Token usage for the request."""
answers: dict[str, Answer] = field(default_factory=dict)
"""All answer objects keyed by question name."""
@classmethod
@override
def _decode(cls, response: httpx2.Response) -> Self:
native = cls._decode_native(response)
return native if native is not None else cls._decode_by_dispatch(response)
@classmethod
def _decode_native(cls, response: httpx2.Response) -> Self | None:
"""Fast path: decode the whole tagged response in one msgspec call.
Returns `None` — deferring to per-answer dispatch — when an answer type is unknown or a field
is malformed. Dispatch then skips unknown types (the common forward-compat case) or re-raises
with a precise field path.
"""
try:
body = _RESPONSE_DECODER.decode(response.content)
except msgspec.ValidationError:
return None
return cls(model=body.model, usage=body.usage, answers=body.answers)
@classmethod
def _decode_by_dispatch(cls, response: httpx2.Response) -> Self:
body = msgspec.json.decode(response.content, type=_SystemOneRawBody)
answers: dict[str, Answer] = {}
for name, raw in body.answers.items():
try:
tag = msgspec.json.decode(raw, type=_AnswerTag).type
except msgspec.ValidationError as error:
raise validation_error(response, f"answers.{name}.type") from error
answer_type = _ANSWER_TYPES.get(tag)
if answer_type is None:
# Forward-compat: ignore answer types this SDK version does not model. The raw payload
# is still available through `response.raw_http_response`.
logger.warning("Ignoring answer %r with unrecognized type %r", name, tag)
continue
try:
answers[name] = msgspec.json.decode(raw, type=answer_type)
except msgspec.ValidationError as error:
raise validation_error(response, field_path(("answers", name), error)) from error
return cls(model=body.model, usage=body.usage, answers=answers)
@cached_property
def nouls(self) -> dict[str, NoulAnswer]:
"""Yes/no answers keyed by question name."""
return {name: answer for name, answer in self.answers.items() if isinstance(answer, NoulAnswer)}
@cached_property
def choices(self) -> dict[str, ChoiceAnswer]:
"""Choice answers keyed by question name."""
return {name: answer for name, answer in self.answers.items() if isinstance(answer, ChoiceAnswer)}
@cached_property
def scores(self) -> dict[str, ScoreAnswer]:
"""Score answers keyed by question name."""
return {name: answer for name, answer in self.answers.items() if isinstance(answer, ScoreAnswer)}
class ListModelsResponse(Response, frozen=True, kw_only=True):
"""The models available to the account."""
models: tuple[ModelMetadata, ...]
"""The available models."""
@classmethod
@override
def _decode(cls, response: httpx2.Response) -> Self:
body = msgspec.json.decode(response.content, type=wire.ModelMetadataList)
return cls(models=tuple(body.models))
+126
View File
@@ -0,0 +1,126 @@
"""RetryPolicy configuration and the Tenacity policies built from it."""
import math
import random
from collections.abc import Callable
from dataclasses import dataclass, field
from tenacity import AsyncRetrying, RetryCallState, Retrying, retry_if_exception, stop_after_attempt, stop_before_delay
from tenacity.stop import stop_base
from typesafe_sdk._core.config import resolve_timeout
from typesafe_sdk._core.errors import TypeSafeAPIConnectionError, TypeSafeAPIError, TypeSafeAPITimeoutError, parse_retry_after
# `build_tenacity`/`build_tenacity_async` are internal build seams and stay out of the public docs.
__all__ = ["RetryPolicy"]
def _retry_after(state: RetryCallState) -> float | None:
error = state.outcome.exception() if state.outcome is not None else None
if isinstance(error, TypeSafeAPIError):
delay = parse_retry_after(error.headers)
if delay is not None:
return delay / 1000
return None
def _backoff(attempt: int, initial: float, maximum: float, jitter: float) -> float:
exponent = min(attempt - 1, math.ceil(math.log2(maximum / initial)))
exponential = min(initial * 2**exponent, maximum)
return math.floor(exponential * (1 - random.random() * jitter) * 1000 + 0.5) / 1000 # noqa: S311 - Backoff jitter, not cryptography.
@dataclass(frozen=True)
class RetryPolicy:
"""Configuration for SDK retry behavior.
Examples:
```python
from typesafe_sdk import RetryPolicy, TypeSafeClient
client = TypeSafeClient(
retry=RetryPolicy(
max_retries=3, timeout=10.0, http_statuses={429, 500, 502, 503, 504}
)
)
```
"""
max_retries: int = 2
"""Maximum retries after the initial attempt; `0` disables retries."""
backoff_initial: float = 0.5
"""First backoff delay in seconds, doubled each attempt up to `backoff_max`."""
backoff_max: float = 5.0
"""Maximum backoff delay in seconds."""
backoff_jitter: float = 0.25
"""Fraction of each backoff delay randomly subtracted, between 0 and 1."""
http_statuses: set[int] = field(default_factory=lambda: {408, 429, *range(500, 600)})
"""HTTP status codes that are retried."""
respect_retry_after: bool = True
"""Whether to honor `Retry-After` and `retry-after-ms` response headers."""
api_connection_error: bool = True
"""Whether to retry `TypeSafeAPIConnectionError`, raised when the request cannot reach or read from the server."""
api_timeout_error: bool = True
"""Whether to retry `TypeSafeAPITimeoutError`, raised when the request exceeds its timeout."""
exceptions: set[type[BaseException]] = field(default_factory=set)
"""Additional exception types that trigger a retry, on top of the built-in rules."""
predicate: Callable[[BaseException], bool] | None = None
"""An optional predicate called with the raised exception; returning `True` triggers a retry in addition to the other rules."""
timeout: float | None = 30.0
"""Total retry budget in seconds per SDK call, including the initial attempt and delays; `None` disables the limit.
Stops before a retry whose delay would reach or exceed the budget, re-raising the last error.
"""
def __post_init__(self) -> None:
"""Validate the optional retry timeout."""
if self.timeout is not None:
resolve_timeout(self.timeout)
def _retryable(self, error: BaseException) -> bool:
if isinstance(error, TypeSafeAPITimeoutError):
builtin = self.api_timeout_error
elif isinstance(error, TypeSafeAPIConnectionError):
builtin = self.api_connection_error
elif isinstance(error, TypeSafeAPIError):
builtin = error.status in self.http_statuses
else:
builtin = False
return builtin or isinstance(error, tuple(self.exceptions)) or (self.predicate is not None and self.predicate(error))
def _wait(self, state: RetryCallState) -> float:
if self.respect_retry_after:
delay = _retry_after(state)
if delay is not None:
return delay
return _backoff(state.attempt_number, self.backoff_initial, self.backoff_max, self.backoff_jitter)
def _stop(self) -> stop_base:
attempts = stop_after_attempt(self.max_retries + 1)
return attempts if self.timeout is None else attempts | stop_before_delay(self.timeout)
def _build_tenacity(self) -> Retrying:
return Retrying(stop=self._stop(), wait=self._wait, retry=retry_if_exception(self._retryable), reraise=True)
def _build_tenacity_async(self) -> AsyncRetrying:
return AsyncRetrying(stop=self._stop(), wait=self._wait, retry=retry_if_exception(self._retryable), reraise=True)
def build_tenacity(policy: "RetryPolicy | None") -> Retrying:
"""Build the synchronous Tenacity policy, falling back to default retry behavior when `policy` is None."""
return (policy or RetryPolicy())._build_tenacity() # noqa: SLF001 - internal build seam.
def build_tenacity_async(policy: "RetryPolicy | None") -> AsyncRetrying:
"""Build the asynchronous Tenacity policy, falling back to default retry behavior when `policy` is None."""
return (policy or RetryPolicy())._build_tenacity_async() # noqa: SLF001 - internal build seam.
@@ -0,0 +1 @@
"""msgspec structs for API responses."""
+84
View File
@@ -0,0 +1,84 @@
"""Base msgspec structs shared by the response schemas."""
import re
from functools import cached_property
import httpx2
import msgspec
from msgspec.structs import force_setattr
from typing_extensions import Self
from typesafe_sdk._core.constants import REQUEST_ID_HEADER
from typesafe_sdk._core.errors import TypeSafeAPIResponseValidationError, TypeSafeError, api_error
from typesafe_sdk._core.json import deserialize
# Unknown fields in a response are ignored rather than rejected, so a newer server never breaks
# an older client.
class Schema(msgspec.Struct, frozen=True, kw_only=True, forbid_unknown_fields=False):
"""Base type for response objects. Instances are immutable."""
_ERROR_AT = re.compile(r"`\$(?P<path>[^`]*)`")
_ERROR_MISSING = re.compile(r"missing required field `(?P<field>[^`]+)`")
def field_path(prefix: tuple[str, ...], error: msgspec.DecodeError) -> str:
"""Translate a msgspec decode error's location into the SDK's dotted `field_path`."""
message = str(error)
segments: list[str] = []
at = _ERROR_AT.search(message)
if at is not None:
segments = [segment for segment in at.group("path").split(".") if segment]
elif (missing := _ERROR_MISSING.search(message)) is not None:
segments = [missing.group("field")]
return ".".join((*prefix, *segments))
def validation_error(response: httpx2.Response, path: str) -> TypeSafeAPIResponseValidationError:
"""Build a `TypeSafeAPIResponseValidationError` locating a bad field in `response`."""
return TypeSafeAPIResponseValidationError(response.status_code, deserialize(response.content), response.headers, path)
# dict=True gives the struct a __dict__ so subclasses can memoize derived views with cached_property.
class Response(Schema, frozen=True, kw_only=True, dict=True):
"""A response object that also exposes the originating HTTP response via ``raw_http_response`` and ``request_id``."""
_request_id: str | None = None
_raw: httpx2.Response | None = None
@classmethod
def from_http_response(cls, response: httpx2.Response) -> Self:
"""Parse an HTTP response into this response type, attaching the raw response.
A non-success status raises the matching `TypeSafeAPIError`; a body that does not
match the schema raises a `TypeSafeAPIResponseValidationError`.
"""
if not response.is_success:
raise api_error(response.status_code, deserialize(response.content), response.headers)
try:
result = cls._decode(response)
except msgspec.DecodeError as error:
raise validation_error(response, field_path((), error)) from error
force_setattr(result, "_request_id", response.headers.get(REQUEST_ID_HEADER))
force_setattr(result, "_raw", response)
return result
@classmethod
def _decode(cls, response: httpx2.Response) -> Self:
"""Build the response from its HTTP body. Subclasses implement the type-specific decode."""
raise NotImplementedError
@cached_property
def request_id(self) -> str:
"""The ``x-typesafe-request-id`` response header."""
if self._request_id is None:
raise TypeSafeError("The response did not include a request ID.")
return self._request_id
@property
def raw_http_response(self) -> httpx2.Response:
"""The underlying `httpx2.Response`, exposing status, headers, and body."""
if self._raw is None:
raise TypeSafeError("The response was not created from a raw HTTP response.")
return self._raw
+169
View File
@@ -0,0 +1,169 @@
"""Shared HTTP request preparation, logging, and dispatch to response types."""
import logging
import platform
import sys
import time
from collections.abc import Generator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Generic, TypeVar
import httpx2
import msgspec
from tenacity import AsyncRetrying, Retrying
from typesafe_sdk._core.config import Config, resolve_timeout
from typesafe_sdk._core.constants import (
ACCEPT_HEADER,
AUTHORIZATION_HEADER,
CONTENT_TYPE_HEADER,
JSON_CONTENT_TYPE,
REQUEST_ID_HEADER,
RETRY_COUNT_HEADER,
RUNTIME_HEADER,
SDK_HEADER,
SDK_NAME,
USER_AGENT_HEADER,
)
from typesafe_sdk._core.errors import TypeSafeAPIConnectionError, TypeSafeAPITimeoutError, TypeSafeError
from typesafe_sdk._core.json import serialize
from typesafe_sdk._core.logging import logger
from typesafe_sdk._core.retry import RetryPolicy, build_tenacity, build_tenacity_async
from typesafe_sdk._core.schemas.base import Response
from typesafe_sdk._version import __version__
RUNTIME = f"python/{platform.python_version()} ({sys.platform}; {platform.machine()})"
ResponseT = TypeVar("ResponseT", bound=Response)
@dataclass(frozen=True, repr=False)
class Request(Generic[ResponseT]):
"""An immutable description of a single HTTP request to send."""
method: str
url: str
headers: httpx2.Headers
content: bytes | None
timeout: float | httpx2.Timeout
response_type: type[ResponseT]
class RequestState(Generic[ResponseT]):
"""Per-send mutable state: tracks attempt count and timing across retries of one `Request`."""
def __init__(self, request: Request[ResponseT]) -> None:
self._request = request
self.attempts = 0
self.started = 0.0
def _log_wire(self, arrow: str, headers: httpx2.Headers, body: bytes | None) -> None:
# Headers go through `args["headers"]` so SensitiveHeadersFilter redacts them.
if logger.isEnabledFor(logging.DEBUG):
logger.debug(
"%(method)s %(url)s %(arrow)s headers=%(headers)s body=%(body)r",
{"method": self._request.method, "url": self._request.url, "arrow": arrow, "headers": dict(headers), "body": body},
)
@contextmanager
def attempt(self) -> Generator[httpx2.Headers, None, None]:
request = self._request
headers = httpx2.Headers(request.headers)
if self.attempts:
headers[RETRY_COUNT_HEADER] = str(self.attempts)
logger.info("%s %s retry %s", request.method, request.url, self.attempts)
self.attempts += 1
self.started = time.monotonic()
self._log_wire("->", headers, request.content)
try:
yield headers
except httpx2.RequestError as error:
logger.info("%s %s <- %s", request.method, request.url, type(error).__name__)
if isinstance(error, httpx2.TimeoutException):
raise TypeSafeAPITimeoutError(request.timeout) from error
raise TypeSafeAPIConnectionError(f"Connection error: {error}") from error
def parse(self, response: httpx2.Response) -> ResponseT:
request = self._request
logger.info(
"%s %s <- %s in %.0fms (request %s)",
request.method,
request.url,
response.status_code,
(time.monotonic() - self.started) * 1000,
response.headers.get(REQUEST_ID_HEADER, "-"),
)
self._log_wire("<-", response.headers, response.content)
return request.response_type.from_http_response(response)
def prepare(
config: Config,
method: str,
path: str,
body: object,
timeout: float | httpx2.Timeout | None,
headers: Mapping[str, str] | None,
response_type: type[ResponseT],
) -> Request[ResponseT]:
merged = httpx2.Headers(config.default_headers)
merged.update(headers or {})
merged.pop(RETRY_COUNT_HEADER, None)
merged.update(
{
AUTHORIZATION_HEADER: f"Bearer {config.api_key}",
ACCEPT_HEADER: JSON_CONTENT_TYPE,
USER_AGENT_HEADER: f"{SDK_NAME}/{__version__}",
SDK_HEADER: f"{SDK_NAME}/{__version__}",
RUNTIME_HEADER: RUNTIME,
}
)
content: bytes | None = None
if body is not None:
content = _encode_body(body)
merged[CONTENT_TYPE_HEADER] = JSON_CONTENT_TYPE
return Request(
method,
config.base_url + path,
merged,
content,
resolve_timeout(config.timeout if timeout is None else timeout),
response_type,
)
def _encode_body(body: object) -> bytes:
try:
return serialize(body)
except (msgspec.EncodeError, TypeError) as error:
raise TypeSafeError("The request body could not be encoded as JSON") from error
def send(http_client: httpx2.Client, retry: Retrying, request: Request[ResponseT], override: RetryPolicy | None = None) -> ResponseT:
state = RequestState(request)
def attempt() -> ResponseT:
with state.attempt() as headers:
response = http_client.request(
request.method, request.url, content=request.content, headers=headers, timeout=request.timeout, auth=None
)
return state.parse(response)
policy = retry if override is None else build_tenacity(override)
return policy.copy()(attempt)
async def send_async(
http_client: httpx2.AsyncClient, retry: AsyncRetrying, request: Request[ResponseT], override: RetryPolicy | None = None
) -> ResponseT:
state = RequestState(request)
async def attempt() -> ResponseT:
with state.attempt() as headers:
response = await http_client.request(
request.method, request.url, content=request.content, headers=headers, timeout=request.timeout, auth=None
)
return state.parse(response)
policy = retry if override is None else build_tenacity_async(override)
return await policy.copy()(attempt)
+6
View File
@@ -0,0 +1,6 @@
"""Wire schemas generated from the API's OpenAPI spec.
`models.py` is generated by `just codegen` (datamodel-code-generator, msgspec output) and must not
be edited by hand. The structs model types and JSON parsing only; they carry no validation
constraints, since the API's own output is trusted.
"""
+89
View File
@@ -0,0 +1,89 @@
# generated by datamodel-codegen:
# filename: https://api.typesafe.ai/openapi.json
from __future__ import annotations
from typing import Any, TypeAlias
from msgspec import Struct, UNSET, UnsetType
class ChoiceAnswer(Struct, tag_field="type", tag="choice"):
choice: str
confidence: float
probabilities: dict[str, float]
class ChoiceQuestion(Struct, tag_field="type", tag="choice"):
criteria: dict[str, str | dict[str, Any] | list[Any] | None]
instructions: str | dict[str, Any] | list[Any] | None | UnsetType = UNSET
class ModelMetadata(Struct):
name: str
description: str
release_date: str
class ModelMetadataList(Struct):
models: list[ModelMetadata]
class NoulAnswer(Struct, tag_field="type", tag="noul"):
noul: float
class NoulCriteria(Struct):
true: str | dict[str, Any] | list[Any] | None | UnsetType = UNSET
false: str | dict[str, Any] | list[Any] | None | UnsetType = UNSET
class NoulQuestion(Struct, tag_field="type", tag="noul"):
instructions: str | dict[str, Any] | list[Any] | None | UnsetType = UNSET
criteria: NoulCriteria | None | UnsetType = UNSET
class ScoreAnswer(Struct, tag_field="type", tag="score"):
score: float
confidence: float
legend: dict[str, str | dict[str, Any] | list[Any]]
probabilities: dict[str, float]
class ScoreQuestion(Struct, tag_field="type", tag="score"):
criteria: list[str | dict[str, Any] | list[Any]]
instructions: str | dict[str, Any] | list[Any] | None | UnsetType = UNSET
class Usage(Struct):
billing_units: int
input_tokens: int | None | UnsetType = UNSET
output_tokens: int | None | UnsetType = UNSET
class ValidationError(Struct):
loc: list[str | int]
msg: str
type: str
input: Any | UnsetType = UNSET
ctx: dict[str, Any] | UnsetType = UNSET
Answer: TypeAlias = NoulAnswer | ScoreAnswer | ChoiceAnswer
class HTTPValidationError(Struct):
detail: list[ValidationError] | UnsetType = UNSET
Question: TypeAlias = NoulQuestion | ChoiceQuestion | ScoreQuestion
class SystemOneRequest(Struct):
state: str | dict[str, Any] | list[Any]
model: str
questions: dict[str, Question]
class SystemOneResponse(Struct):
model: str
answers: dict[str, Answer]
usage: Usage
+5
View File
@@ -0,0 +1,5 @@
"""Installed SDK version."""
from importlib.metadata import version
__version__ = version("typesafe-sdk")
+22
View File
@@ -0,0 +1,22 @@
"""Public environment-variable names and client defaults."""
API_KEY_ENV = "TYPESAFE_API_KEY"
"""Environment variable for the API key."""
BASE_URL_ENV = "TYPESAFE_BASE_URL"
"""Environment variable for the API base URL."""
DEFAULT_MODEL_ENV = "TYPESAFE_DEFAULT_MODEL"
"""Environment variable for the default model."""
LOG_LEVEL_ENV = "TYPESAFE_LOG_LEVEL"
"""Environment variable for the logging level."""
DEFAULT_BASE_URL = "https://api.typesafe.ai"
"""Default API base URL."""
DEFAULT_MODEL = "jev-latest"
"""Default model name."""
DEFAULT_TIMEOUT = 10.0
"""Default timeout in seconds for each HTTP operation."""
View File
Whitespace-only changes.
View File
Whitespace-only changes.
@@ -0,0 +1,77 @@
# serializer version: 1
# name: test_constructor_kwargs[AsyncTypeSafeClient]
list([
'api_key',
'model',
'retry',
'timeout',
'headers',
'transport',
'http_client',
'base_url',
])
# ---
# name: test_constructor_kwargs[TypeSafeClient]
list([
'api_key',
'model',
'retry',
'timeout',
'headers',
'transport',
'http_client',
'base_url',
])
# ---
# name: test_public_members[typesafe_sdk.constants]
list([
'API_KEY_ENV',
'BASE_URL_ENV',
'DEFAULT_BASE_URL',
'DEFAULT_MODEL',
'DEFAULT_MODEL_ENV',
'DEFAULT_TIMEOUT',
'LOG_LEVEL_ENV',
])
# ---
# name: test_public_members[typesafe_sdk]
list([
'Answer',
'AsyncModels',
'AsyncTypeSafeClient',
'Choice',
'ChoiceAnswer',
'ChoiceModel',
'JSONValue',
'ListModelsResponse',
'ModelMetadata',
'Models',
'Noul',
'NoulAnswer',
'NoulCriteria',
'NoulModel',
'Question',
'QuestionModel',
'Questions',
'RetryPolicy',
'Score',
'ScoreAnswer',
'ScoreModel',
'SystemOneResponse',
'TypeSafeAPIConnectionError',
'TypeSafeAPIError',
'TypeSafeAPIResponseValidationError',
'TypeSafeAPITimeoutError',
'TypeSafeAuthenticationError',
'TypeSafeBadRequestError',
'TypeSafeClient',
'TypeSafeError',
'TypeSafeInternalServerError',
'TypeSafeNotFoundError',
'TypeSafePermissionDeniedError',
'TypeSafeRateLimitError',
'TypeSafeUnprocessableEntityError',
'Usage',
'constants',
])
# ---
+100
View File
@@ -0,0 +1,100 @@
import os
from collections.abc import AsyncIterator, Callable, Mapping
import httpx2
import pytest
from typesafe_sdk import AsyncTypeSafeClient, RetryPolicy, TypeSafeClient
from typesafe_sdk.constants import API_KEY_ENV, BASE_URL_ENV, DEFAULT_MODEL_ENV, LOG_LEVEL_ENV
Client = TypeSafeClient | AsyncTypeSafeClient
class ClientFactory:
def __init__(self, async_mode: bool) -> None:
self.async_mode = async_mode
self.instances: list[Client] = []
def __call__(
self,
handler: Callable[[httpx2.Request], httpx2.Response],
*,
retries: bool = False,
api_key: str | None = "test-key",
model: str | None = None,
retry: RetryPolicy | None = None,
timeout: float | httpx2.Timeout | None = None,
headers: Mapping[str, str] | None = None,
transport: httpx2.BaseTransport | httpx2.AsyncBaseTransport | None = None,
http_client: httpx2.Client | httpx2.AsyncClient | None = None,
base_url: str | None = None,
) -> Client:
if transport is None and http_client is None:
transport = httpx2.MockTransport(handler)
if retry is None and not retries:
retry = RetryPolicy(max_retries=0)
client: Client
if self.async_mode:
assert transport is None or isinstance(transport, httpx2.AsyncBaseTransport)
assert http_client is None or isinstance(http_client, httpx2.AsyncClient)
client = AsyncTypeSafeClient(
api_key=api_key,
model=model,
retry=retry,
timeout=timeout,
headers=headers,
transport=transport,
http_client=http_client,
base_url=base_url,
)
else:
assert transport is None or isinstance(transport, httpx2.BaseTransport)
assert http_client is None or isinstance(http_client, httpx2.Client)
client = TypeSafeClient(
api_key=api_key,
model=model,
retry=retry,
timeout=timeout,
headers=headers,
transport=transport,
http_client=http_client,
base_url=base_url,
)
self.instances.append(client)
return client
@pytest.fixture(params=[False, True], ids=["sync", "async"])
async def clients(request: pytest.FixtureRequest) -> AsyncIterator[ClientFactory]:
factory = ClientFactory(request.param)
yield factory
for client in factory.instances:
if isinstance(client, AsyncTypeSafeClient):
await client.aclose()
else:
client.close()
@pytest.fixture(autouse=True)
def clean_env(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None:
if request.node.get_closest_marker("integration") is None:
for name in (API_KEY_ENV, BASE_URL_ENV, DEFAULT_MODEL_ENV, LOG_LEVEL_ENV):
monkeypatch.delenv(name, raising=False)
@pytest.fixture
def live_api_key() -> str:
key = os.environ.get(API_KEY_ENV, "").strip()
if not key:
pytest.skip(f"{API_KEY_ENV} is not set")
return key
@pytest.fixture(params=["sync", "async"])
async def live_client(request: pytest.FixtureRequest, live_api_key: str) -> AsyncIterator[Client]:
if request.param == "async":
async with AsyncTypeSafeClient(api_key=live_api_key, timeout=120) as client:
yield client
else:
with TypeSafeClient(api_key=live_api_key, timeout=120) as client:
yield client
+53
View File
@@ -0,0 +1,53 @@
from collections.abc import Mapping
import httpx2
from typing_extensions import override
from typesafe_sdk import AsyncTypeSafeClient, JSONValue, ModelMetadata, Questions, RetryPolicy, SystemOneResponse, TypeSafeClient
class TrackingTransport(httpx2.MockTransport):
close_calls = 0
aclose_calls = 0
@override
def close(self) -> None:
self.close_calls += 1
@override
async def aclose(self) -> None:
self.aclose_calls += 1
async def models(
client: TypeSafeClient | AsyncTypeSafeClient,
*,
retry: RetryPolicy | None = None,
timeout: float | httpx2.Timeout | None = None,
extra_headers: Mapping[str, str] | None = None,
) -> tuple[ModelMetadata, ...]:
if isinstance(client, AsyncTypeSafeClient):
return (await client.models.list(retry=retry, timeout=timeout, extra_headers=extra_headers)).models
else:
return client.models.list(retry=retry, timeout=timeout, extra_headers=extra_headers).models
async def system_one(
client: TypeSafeClient | AsyncTypeSafeClient,
*,
state: str | dict[str, JSONValue | None] | list[JSONValue | None],
questions: Questions,
model: str | None = None,
extra_body: Mapping[str, JSONValue | None] | None = None,
retry: RetryPolicy | None = None,
timeout: float | httpx2.Timeout | None = None,
extra_headers: Mapping[str, str] | None = None,
) -> SystemOneResponse:
if isinstance(client, AsyncTypeSafeClient):
return await client.system_one(
state, questions, model=model, extra_body=extra_body, retry=retry, timeout=timeout, extra_headers=extra_headers
)
else:
return client.system_one(
state, questions, model=model, extra_body=extra_body, retry=retry, timeout=timeout, extra_headers=extra_headers
)
+597
View File
@@ -0,0 +1,597 @@
import asyncio
import logging
from importlib.metadata import version
from typing import Any, cast
import httpx2
import msgspec
import pytest
from typing_extensions import assert_type
from tests.conftest import ClientFactory
from tests.helpers import TrackingTransport, models, system_one
from typesafe_sdk import (
AsyncTypeSafeClient,
Choice,
ChoiceAnswer,
JSONValue,
ModelMetadata,
Noul,
NoulAnswer,
QuestionModel,
Questions,
Score,
ScoreAnswer,
TypeSafeAPIConnectionError,
TypeSafeAPIError,
TypeSafeAPIResponseValidationError,
TypeSafeAPITimeoutError,
TypeSafeAuthenticationError,
TypeSafeBadRequestError,
TypeSafeClient,
TypeSafeError,
TypeSafeInternalServerError,
TypeSafeNotFoundError,
TypeSafePermissionDeniedError,
TypeSafeRateLimitError,
TypeSafeUnprocessableEntityError,
Usage,
)
RESULT = {
"model": "jev-latest",
"usage": {"input_tokens": 12, "output_tokens": 3},
"answers": {
"spam": {"type": "noul", "noul": 0.98},
"tone": {"type": "choice", "choice": "friendly", "confidence": 0.9, "probabilities": {"friendly": 0.9, "hostile": 0.1}},
"quality": {
"type": "score",
"score": 1.7,
"confidence": 0.8,
"legend": {"0": "bad", "1": "ok", "2": "great"},
"probabilities": {"0": 0.1, "1": 0.1, "2": 0.8},
},
},
}
CARD = {"name": "jev-latest", "description": "Fast model", "release_date": "2026-08-01"}
@pytest.mark.parametrize("score_map", [False, True])
@pytest.mark.parametrize("question_form", ["dataclass", "raw", "mixed"])
async def test_round_trip(clients: ClientFactory, score_map: bool, question_form: str) -> None:
criteria: (
dict[int, str | dict[str, JSONValue | None] | list[JSONValue | None]] | list[str | dict[str, JSONValue | None] | list[JSONValue | None]]
)
if score_map:
criteria = {2: "great", 0: "bad", 1: "ok"}
else:
criteria = ["bad", "ok", "great"]
raw: dict[str, QuestionModel] = {
"spam": {"type": "noul", "instructions": "Spam?"},
"tone": {"type": "choice", "instructions": "Tone?", "criteria": {"friendly": None, "hostile": None}},
"quality": {"type": "score", "instructions": "Quality?", "criteria": criteria},
}
questions: Questions
if question_form == "raw":
questions = raw
else:
questions = {
"spam": Noul(instructions="Spam?") if question_form == "dataclass" else raw["spam"],
"tone": Choice(instructions="Tone?", criteria={"friendly": None, "hostile": None}),
"quality": Score(instructions="Quality?", criteria=criteria),
}
expected: dict[str, JSONValue | None] = {
"state": {"document": "Hello 🌍"},
"model": "jev-latest",
"questions": {
"spam": {"type": "noul", "instructions": "Spam?"},
"tone": {"type": "choice", "instructions": "Tone?", "criteria": {"friendly": None, "hostile": None}},
"quality": {"type": "score", "instructions": "Quality?", "criteria": ["bad", "ok", "great"]},
},
}
def handler(request: httpx2.Request) -> httpx2.Response:
assert request.method == "POST"
assert str(request.url) == "https://api.typesafe.ai/v1/systemone"
assert msgspec.json.decode(request.content) == expected
assert request.headers["content-type"] == "application/json"
return httpx2.Response(200, content=msgspec.json.encode(RESULT))
result = await system_one(
clients(handler),
state={"document": "Hello 🌍"},
questions=questions,
)
assert "nouls" not in result.__dict__
assert "choices" not in result.__dict__
assert "scores" not in result.__dict__
assert_type(result.nouls, dict[str, NoulAnswer])
assert_type(result.choices, dict[str, ChoiceAnswer])
assert_type(result.scores, dict[str, ScoreAnswer])
assert result.nouls is result.nouls
assert result.choices is result.choices
assert result.scores is result.scores
assert result.model == "jev-latest"
assert result.usage == Usage(input_tokens=12, output_tokens=3)
assert set(result.nouls) == {"spam"}
assert set(result.choices) == {"tone"}
assert set(result.scores) == {"quality"}
assert result.nouls["spam"].noul == 0.98
assert result.choices["tone"].choice == "friendly"
assert result.choices["tone"].confidence == 0.9
assert result.choices["tone"].probabilities == {"friendly": 0.9, "hostile": 0.1}
assert result.scores["quality"].score == 1.7
assert result.scores["quality"].confidence == 0.8
assert result.scores["quality"].legend == {0: "bad", 1: "ok", 2: "great"}
assert result.scores["quality"].probabilities == {0: 0.1, 1: 0.1, 2: 0.8}
assert result.answers["quality"] is result.scores["quality"]
assert result.answers["spam"] is result.nouls["spam"]
assert result.answers["tone"] is result.choices["tone"]
assert set(result.answers) == {"spam", "tone", "quality"}
with pytest.raises(AttributeError):
cast(Any, result).model = "other"
async def test_extra_body_shallow_override(clients: ClientFactory) -> None:
expected = {
"state": "hi",
"model": "override-model",
"questions": {"q": {"type": "noul", "instructions": "?"}},
"beam_width": 4,
"nullable": None,
}
def handler(request: httpx2.Request) -> httpx2.Response:
assert msgspec.json.decode(request.content) == expected
return httpx2.Response(200, content=msgspec.json.encode(RESULT))
await system_one(
clients(handler),
state="hi",
questions={"q": {"type": "noul", "instructions": "?"}},
model="call-model",
extra_body={"model": "override-model", "beam_width": 4, "nullable": None},
)
async def test_unserializable_request_body_raises(clients: ClientFactory) -> None:
def handler(request: httpx2.Request) -> httpx2.Response:
pytest.fail("An unencodable request body reached the network")
with pytest.raises(TypeSafeError, match="could not be encoded as JSON"):
await system_one(
clients(handler),
state="x",
questions={"q": {"type": "noul", "instructions": "?"}},
extra_body=cast(Any, {"bad": object()}),
)
async def test_raw_question_passthrough(clients: ClientFactory) -> None:
questions: Questions = {
"q": {"type": "noul", "instructions": "Spam?", "weight": 3, "nested": {"k": None}},
"choice": {"type": "choice", "criteria": {"a": None}, "weight": 2},
"score": {"type": "score", "criteria": ["good"], "weight": 1},
}
expected = {"state": "hi", "model": "jev-latest", "questions": questions}
def handler(request: httpx2.Request) -> httpx2.Response:
assert msgspec.json.decode(request.content) == expected
return httpx2.Response(200, content=msgspec.json.encode(RESULT))
await system_one(clients(handler), state="hi", questions=questions)
@pytest.mark.parametrize(
"question",
[
{"type": "noul", "instructions": 1},
{"type": "choice", "criteria": ["invalid", "shape"]},
Choice(criteria=cast(Any, ["invalid", "shape"])),
],
)
async def test_question_schema_validation_is_left_to_api(clients: ClientFactory, question: Any) -> None:
def handler(request: httpx2.Request) -> httpx2.Response:
assert msgspec.json.decode(request.content)["questions"]["q"] == msgspec.to_builtins(question)
return httpx2.Response(422, json={"detail": "Invalid question"})
with pytest.raises(TypeSafeUnprocessableEntityError, match="Invalid question"):
await system_one(clients(handler), state="x", questions={"q": question})
async def test_rich_descriptions(clients: ClientFactory) -> None:
criteria = {"summary": "duplicated", "examples": ["charged twice"]}
expected = {
"state": "a ticket",
"model": "custom",
"questions": {
"duplicate": {"type": "noul", "instructions": {"question": "Duplicate?"}, "criteria": {"true": criteria}},
"team": {"type": "choice", "instructions": "Team?", "criteria": {"billing": criteria, "other": None}},
"risk": {"type": "score", "instructions": "Risk?", "criteria": [criteria]},
},
}
def handler(request: httpx2.Request) -> httpx2.Response:
assert msgspec.json.decode(request.content) == expected
return httpx2.Response(
200,
json={
"model": "custom",
"usage": {"input_tokens": 1, "output_tokens": 1},
"answers": {
"risk": {"type": "score", "score": 0, "confidence": 1, "legend": {"0": criteria}, "probabilities": {"0": 1}},
},
},
)
result = await system_one(
clients(handler),
state="a ticket",
model="custom",
questions={
"duplicate": {
"type": "noul",
"instructions": {"question": "Duplicate?"},
"criteria": {"true": {"summary": "duplicated", "examples": ["charged twice"]}},
},
"team": Choice(
instructions="Team?",
criteria={"billing": {"summary": "duplicated", "examples": ["charged twice"]}, "other": None},
),
"risk": Score(instructions="Risk?", criteria=[{"summary": "duplicated", "examples": ["charged twice"]}]),
},
)
assert result.scores["risk"].legend[0] == criteria
async def test_models_shape(clients: ClientFactory) -> None:
def handler(request: httpx2.Request) -> httpx2.Response:
assert request.method == "GET"
assert request.url.path == "/v1/models"
assert request.content == b""
return httpx2.Response(200, content=msgspec.json.encode({"models": [CARD]}))
assert await models(clients(handler)) == (ModelMetadata(**CARD),)
async def test_models_ignore_unknown_fields(clients: ClientFactory) -> None:
card = {**CARD, "context_window": 128000, "pricing": None}
client = clients(lambda request: httpx2.Response(200, content=msgspec.json.encode({"models": [card]})))
response = await client.models.list() if isinstance(client, AsyncTypeSafeClient) else client.models.list()
[model] = response.models
assert model.name == "jev-latest"
# Unmodeled fields are dropped from the card but remain available via the raw response.
assert response.raw_http_response.json()["models"][0]["context_window"] == 128000
@pytest.mark.parametrize("body", [None, {}, {"models": "bad"}, {"models": [{"name": "x"}]}])
async def test_invalid_models_response(clients: ClientFactory, body: object) -> None:
with pytest.raises(TypeSafeAPIResponseValidationError):
await models(clients(lambda request: httpx2.Response(200, json=body)))
@pytest.mark.parametrize(
"questions,match",
[
({}, "At least one question"),
({"rating": Score(instructions="?", criteria=[])}, '"rating" has no criteria'),
({"rating": Score(instructions="?", criteria={})}, '"rating" has no criteria'),
({"rating": Score(instructions="?", criteria={0: "bad", 2: "good"})}, '"rating".*no gaps'),
({"rating": Score(instructions="?", criteria={1: "bad"})}, '"rating".*no gaps'),
({"rating": Score(instructions="?", criteria={-1: "bad"})}, '"rating".*non-negative integers'),
],
)
async def test_validation_before_network(clients: ClientFactory, questions: Any, match: str) -> None:
def handler(request: httpx2.Request) -> httpx2.Response:
pytest.fail("Invalid questions reached the network")
with pytest.raises(TypeSafeError, match=match):
await system_one(clients(handler), state="x", questions=questions)
@pytest.mark.parametrize(
"status,error",
[
(400, TypeSafeBadRequestError),
(401, TypeSafeAuthenticationError),
(403, TypeSafePermissionDeniedError),
(404, TypeSafeNotFoundError),
(422, TypeSafeUnprocessableEntityError),
(429, TypeSafeRateLimitError),
(500, TypeSafeInternalServerError),
(503, TypeSafeInternalServerError),
(408, TypeSafeAPIError),
(409, TypeSafeAPIError),
(302, TypeSafeAPIError),
],
)
async def test_error_mapping(clients: ClientFactory, status: int, error: type[TypeSafeAPIError]) -> None:
body = {"detail": {"message": "Server explanation"}}
with pytest.raises(error) as caught:
await models(
clients(lambda request: httpx2.Response(status, json=body, headers={"x-typesafe-request-id": "req_123", "retry-after-ms": "125"}))
)
assert type(caught.value) is error
assert caught.value.status == status
assert caught.value.body == body
assert caught.value.request_id == "req_123"
assert str(caught.value) == f"{status} Server explanation"
assert caught.value.headers["retry-after-ms"] == "125"
if isinstance(caught.value, TypeSafeRateLimitError):
assert caught.value.retry_after_ms == 125
@pytest.mark.parametrize(
"body,message",
[
({"error": "error", "message": "message", "detail": "detail"}, "error"),
({"error": {"message": "nested error"}, "message": "message"}, "nested error"),
({"message": "message", "detail": "detail"}, "message"),
({"detail": "detail"}, "detail"),
({"detail": {"message": "nested detail"}}, "nested detail"),
(
{"detail": [{"loc": ["body", "questions", "q", "score", "criteria", 0], "msg": "Invalid"}, {"msg": "Missing"}, {}]},
"questions.q.score.criteria.0: Invalid; Missing",
),
("plain text", "plain text"),
({"unexpected": True}, '{"unexpected":true}'),
],
)
async def test_error_messages(clients: ClientFactory, body: object, message: str) -> None:
content = body.encode() if isinstance(body, str) else msgspec.json.encode(body)
with pytest.raises(TypeSafeAPIError, match="400") as caught:
await models(clients(lambda request: httpx2.Response(400, content=content)))
assert str(caught.value) == f"400 {message}"
@pytest.mark.parametrize(
"transport_error,sdk_error",
[
(httpx2.ConnectError, TypeSafeAPIConnectionError),
(httpx2.ReadError, TypeSafeAPIConnectionError),
(httpx2.RemoteProtocolError, TypeSafeAPIConnectionError),
(httpx2.ConnectTimeout, TypeSafeAPITimeoutError),
(httpx2.ReadTimeout, TypeSafeAPITimeoutError),
],
)
async def test_transport_errors(clients: ClientFactory, transport_error: type[httpx2.RequestError], sdk_error: type[TypeSafeError]) -> None:
def handler(request: httpx2.Request) -> httpx2.Response:
raise transport_error("failed", request=request)
with pytest.raises(sdk_error) as caught:
await models(clients(handler), timeout=1.25)
assert isinstance(caught.value.__cause__, transport_error)
if isinstance(caught.value, TypeSafeAPITimeoutError):
assert caught.value.timeout == 1.25
@pytest.mark.parametrize("timeout", [None, 2.0, httpx2.Timeout(3.0, read=9.0), httpx2.Timeout(None)])
async def test_system_one_timeout_override(clients: ClientFactory, timeout: float | httpx2.Timeout | None) -> None:
requests: list[httpx2.Request] = []
def handler(request: httpx2.Request) -> httpx2.Response:
requests.append(request)
return httpx2.Response(200, json=RESULT)
client = clients(handler, timeout=7.0)
await system_one(client, state="hello", questions={"q": Noul(instructions="?")}, timeout=timeout)
await system_one(client, state="hello", questions={"q": Noul(instructions="?")})
expected = httpx2.Timeout(7.0 if timeout is None else timeout).as_dict()
assert requests[0].extensions["timeout"] == expected
assert requests[1].extensions["timeout"] == httpx2.Timeout(7.0).as_dict()
async def test_headers_timeout_and_logging(clients: ClientFactory, caplog: pytest.LogCaptureFixture) -> None:
protected = {
"authorization": "injected-secret",
"accept": "text/plain",
"user-agent": "wrong",
"x-typesafe-sdk": "wrong",
"x-typesafe-runtime": "wrong",
}
def handler(request: httpx2.Request) -> httpx2.Response:
assert str(request.url) == "https://example.test/prefix/v1/systemone"
assert request.headers["authorization"] == "Bearer test-key"
assert request.headers["accept"] == "application/json"
assert request.headers["user-agent"] == f"typesafe-sdk/{version('typesafe-sdk')}"
assert request.headers["x-typesafe-sdk"] == request.headers["user-agent"]
assert request.headers["x-typesafe-runtime"].startswith("python/")
assert "x-typesafe-retry-count" not in request.headers
assert request.headers["x-team"] == "call"
assert request.headers["x-default"] == "kept"
assert request.headers["content-type"] == "application/json"
assert request.extensions["timeout"] == {"connect": 2.0, "read": 2.0, "write": 2.0, "pool": 2.0}
return httpx2.Response(200, json=RESULT, headers={"set-cookie": "response-secret", "x-typesafe-request-id": "req_log"})
with caplog.at_level(logging.DEBUG, logger="typesafe_sdk"):
client = clients(
handler,
base_url="https://example.test/prefix///",
timeout=7,
headers={**protected, "X-Team": "default", "X-Default": "kept", "X-API-Key": "key-secret", "cookie": "cookie-secret"},
)
await system_one(
client,
state="hello",
questions={"q": {"type": "noul", "instructions": "?"}},
timeout=2.0,
extra_headers={**protected, "x-team": "call", "x-typesafe-retry-count": "99", "content-type": "wrong"},
)
for secret in ("test-key", "injected-secret", "key-secret", "cookie-secret", "response-secret"):
assert secret not in caplog.text
assert "req_log" in caplog.text
assert "hello" in caplog.text
async def test_http_client_settings(clients: ClientFactory) -> None:
requests: list[httpx2.Request] = []
async def async_hook(request: httpx2.Request) -> None:
requests.append(request)
def handler(request: httpx2.Request) -> httpx2.Response:
assert str(request.url).startswith("https://api.typesafe.ai/v1/")
assert request.headers["authorization"] == "Bearer test-key"
assert request.headers["accept"] == "application/json"
assert request.headers["user-agent"].startswith("typesafe-sdk/")
assert request.headers["x-typesafe-sdk"] == request.headers["user-agent"]
assert request.headers["x-typesafe-runtime"].startswith("python/")
assert request.headers["x-http-default"] == "kept"
assert request.headers["x-sdk-default"] == "sdk"
assert request.headers["x-call"] == "call"
if request.method == "POST":
assert request.headers["content-type"] == "application/json"
return httpx2.Response(200, json=RESULT if request.method == "POST" else {"models": []})
headers = {
"authorization": "wrong",
"accept": "text/plain",
"content-type": "text/plain",
"user-agent": "wrong",
"x-typesafe-sdk": "wrong",
"x-typesafe-runtime": "wrong",
"x-http-default": "kept",
"x-sdk-default": "http",
"x-call": "http",
}
http_client = (
httpx2.AsyncClient(
transport=httpx2.MockTransport(handler),
event_hooks={"request": [async_hook]},
headers=headers,
auth=("wrong", "credentials"),
base_url="https://wrong.test",
)
if clients.async_mode
else httpx2.Client(
transport=httpx2.MockTransport(handler),
event_hooks={"request": [requests.append]},
headers=headers,
auth=("wrong", "credentials"),
base_url="https://wrong.test",
)
)
original_headers = httpx2.Headers(http_client.headers)
original_base_url = http_client.base_url
client = clients(handler, http_client=http_client, headers={"x-sdk-default": "sdk", "x-call": "sdk"})
assert client._http_client is http_client
assert await models(client, extra_headers={"x-call": "call"}) == ()
await system_one(client, state="x", questions={"q": Noul(instructions="?")}, extra_headers={"x-call": "call"})
assert [request.method for request in requests] == ["GET", "POST"]
assert http_client.headers == original_headers
assert http_client.base_url == original_base_url
@pytest.mark.parametrize("use_context", [False, True])
@pytest.mark.parametrize("supply_http_client", [False, True])
async def test_supplied_network_resources_closed(clients: ClientFactory, use_context: bool, supply_http_client: bool) -> None:
def handler(request: httpx2.Request) -> httpx2.Response:
assert str(request.url) == "https://api.typesafe.ai/v1/models"
assert request.headers["authorization"] == "Bearer test-key"
return httpx2.Response(200, json={"models": []})
transport = TrackingTransport(handler)
if supply_http_client:
http_client = httpx2.AsyncClient(transport=transport) if clients.async_mode else httpx2.Client(transport=transport)
client = clients(handler, http_client=http_client)
assert client._http_client is http_client
else:
client = clients(handler, transport=transport)
assert await models(client) == ()
if isinstance(client, AsyncTypeSafeClient):
if use_context:
async with client:
assert (await client.models.list()).models == ()
else:
await client.aclose()
await client.aclose()
assert transport.aclose_calls == 1
assert transport.close_calls == 0
else:
if use_context:
with client:
assert client.models.list().models == ()
else:
client.close()
client.close()
assert transport.close_calls == 1
assert transport.aclose_calls == 0
assert client._http_client.is_closed
with pytest.raises(RuntimeError, match="closed"):
await models(client)
async def test_owned_http_client_closed() -> None:
with TypeSafeClient(api_key="test") as client:
assert not client._http_client.is_closed
assert client._http_client.timeout == httpx2.Timeout(10.0)
assert client._http_client.is_closed
async with AsyncTypeSafeClient(api_key="test") as async_client:
assert not async_client._http_client.is_closed
assert async_client._http_client.timeout == httpx2.Timeout(10.0)
assert async_client._http_client.is_closed
@pytest.mark.parametrize("kind", [ValueError, asyncio.CancelledError])
async def test_exceptional_context_closes_http_client(clients: ClientFactory, kind: type[BaseException]) -> None:
failure = kind("original failure")
def handler(request: httpx2.Request) -> httpx2.Response:
raise failure
transport = TrackingTransport(handler)
http_client = httpx2.AsyncClient(transport=transport) if clients.async_mode else httpx2.Client(transport=transport)
client = clients(handler, http_client=http_client)
assert client._http_client is http_client
with pytest.raises(kind) as caught: # noqa: PT012 - Exercise context-manager cleanup while propagating the exception.
if isinstance(client, AsyncTypeSafeClient):
async with client:
await client.models.list()
else:
with client:
client.models.list()
assert caught.value is failure
assert transport.aclose_calls == int(clients.async_mode)
assert transport.close_calls == int(not clients.async_mode)
assert client._http_client.is_closed
async def test_task_cancellation_closes_context() -> None:
started = asyncio.Event()
async def handler(request: httpx2.Request) -> httpx2.Response:
started.set()
await asyncio.Event().wait()
raise AssertionError("Cancelled request returned")
transport = TrackingTransport(handler)
client = AsyncTypeSafeClient(api_key="test", http_client=httpx2.AsyncClient(transport=transport))
async def run() -> None:
async with client:
await client.system_one("x", {"q": Noul(instructions="?")})
task = asyncio.create_task(run())
try:
await asyncio.wait_for(started.wait(), timeout=1)
finally:
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert transport.aclose_calls == 1
assert client._http_client.is_closed
async def test_cancellation_propagates() -> None:
attempts = 0
def handler(request: httpx2.Request) -> httpx2.Response:
nonlocal attempts
attempts += 1
raise asyncio.CancelledError
async with AsyncTypeSafeClient(api_key="test", http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler))) as client:
with pytest.raises(asyncio.CancelledError):
await client.models.list()
assert attempts == 1
+134
View File
@@ -0,0 +1,134 @@
import httpx2
import msgspec
import pytest
from tests.conftest import ClientFactory
from tests.helpers import models, system_one
from tests.test_clients import RESULT
from typesafe_sdk import AsyncTypeSafeClient, TypeSafeClient, TypeSafeError
async def test_transport_and_http_client_mutually_exclusive(clients: ClientFactory) -> None:
def handler(request: httpx2.Request) -> httpx2.Response:
pytest.fail("Unexpected request")
transport = httpx2.MockTransport(handler)
http_client = httpx2.AsyncClient(transport=transport) if clients.async_mode else httpx2.Client(transport=transport)
try:
with pytest.raises(ValueError, match="transport and http_client are mutually exclusive"):
clients(handler, transport=transport, http_client=http_client)
assert not http_client.is_closed
finally:
if isinstance(http_client, httpx2.AsyncClient):
await http_client.aclose()
else:
http_client.close()
@pytest.mark.parametrize("model", [None, "request-model"])
async def test_model_override(clients: ClientFactory, model: str | None) -> None:
def handler(request: httpx2.Request) -> httpx2.Response:
assert msgspec.json.decode(request.content)["model"] == (model or "client-model")
return httpx2.Response(200, json=RESULT)
await system_one(
clients(handler, model="client-model"),
state="hello",
questions={"q": {"type": "noul", "instructions": "?"}},
model=model,
)
@pytest.mark.parametrize("source", ["default", "env", "constructor"])
async def test_resolution(clients: ClientFactory, monkeypatch: pytest.MonkeyPatch, source: str) -> None:
if source != "default":
monkeypatch.setenv("TYPESAFE_API_KEY", " env-key ")
monkeypatch.setenv("TYPESAFE_BASE_URL", " https://env.test/// ")
monkeypatch.setenv("TYPESAFE_DEFAULT_MODEL", " env-model ")
expected_key = "code-key" if source == "constructor" else "env-key" if source == "env" else "test-key"
expected_url = "https://code.test" if source == "constructor" else "https://env.test" if source == "env" else "https://api.typesafe.ai"
expected_model = "code-model" if source == "constructor" else "env-model" if source == "env" else "jev-latest"
def handler(request: httpx2.Request) -> httpx2.Response:
assert request.headers["authorization"] == f"Bearer {expected_key}"
assert str(request.url) == expected_url + "/v1/systemone"
assert msgspec.json.decode(request.content)["model"] == expected_model
assert request.extensions["timeout"] == {"connect": 10.0, "read": 10.0, "write": 10.0, "pool": 10.0}
return httpx2.Response(200, json=RESULT)
if source == "constructor":
client = clients(handler, api_key="code-key", base_url="https://code.test///", model="code-model")
elif source == "env":
client = clients(handler, api_key=None)
else:
client = clients(handler)
await system_one(client, state="hello", questions={"q": {"type": "noul", "instructions": "?"}})
@pytest.mark.parametrize("client_type", [TypeSafeClient, AsyncTypeSafeClient])
@pytest.mark.parametrize("key", [None, "", " \t\n "])
def test_missing_key(client_type: type[TypeSafeClient] | type[AsyncTypeSafeClient], monkeypatch: pytest.MonkeyPatch, key: str | None) -> None:
if key is not None:
monkeypatch.setenv("TYPESAFE_API_KEY", key)
with pytest.raises(TypeSafeError, match="TYPESAFE_API_KEY"):
client_type()
async def test_empty_env_unset(clients: ClientFactory, monkeypatch: pytest.MonkeyPatch) -> None:
for name in ("TYPESAFE_BASE_URL", "TYPESAFE_DEFAULT_MODEL", "TYPESAFE_LOG_LEVEL"):
monkeypatch.setenv(name, " \t ")
def handler(request: httpx2.Request) -> httpx2.Response:
assert str(request.url) == "https://api.typesafe.ai/v1/systemone"
assert msgspec.json.decode(request.content)["model"] == "jev-latest"
return httpx2.Response(200, json=RESULT)
await system_one(clients(handler), state="x", questions={"q": {"type": "noul", "instructions": "?"}})
@pytest.mark.parametrize("value", [0, -1, float("inf"), float("nan")])
async def test_invalid_timeout(clients: ClientFactory, value: float) -> None:
with pytest.raises(TypeSafeError, match="timeout"):
clients(lambda request: httpx2.Response(200), timeout=value)
client = clients(lambda request: pytest.fail("Unexpected request"))
with pytest.raises(TypeSafeError, match="timeout"):
await models(client, timeout=value)
async def test_timeout_object(clients: ClientFactory) -> None:
timeout = httpx2.Timeout(7.0, connect=1.0)
def handler(request: httpx2.Request) -> httpx2.Response:
assert request.extensions["timeout"] == {"connect": 1.0, "read": 7.0, "write": 7.0, "pool": 7.0}
return httpx2.Response(200, json={"models": []})
assert await models(clients(handler, timeout=timeout)) == ()
@pytest.mark.parametrize("http_timeout", [httpx2.Timeout(17.0, connect=1.0), httpx2.Timeout(None)])
@pytest.mark.parametrize("sdk_timeout,call_timeout", [(None, None), (3.0, None), (None, 2.0), (3.0, httpx2.Timeout(None))])
async def test_http_client_timeout_precedence(
clients: ClientFactory,
http_timeout: httpx2.Timeout,
sdk_timeout: float | None,
call_timeout: float | httpx2.Timeout | None,
) -> None:
requests: list[httpx2.Request] = []
def handler(request: httpx2.Request) -> httpx2.Response:
requests.append(request)
return httpx2.Response(200, json={"models": []})
http_client = (
httpx2.AsyncClient(transport=httpx2.MockTransport(handler), timeout=http_timeout)
if clients.async_mode
else httpx2.Client(transport=httpx2.MockTransport(handler), timeout=http_timeout)
)
client = clients(handler, http_client=http_client, timeout=sdk_timeout)
assert await models(client, timeout=call_timeout) == ()
assert await models(client) == ()
default = http_timeout if sdk_timeout is None else httpx2.Timeout(sdk_timeout)
expected = default if call_timeout is None else httpx2.Timeout(call_timeout)
assert requests[0].extensions["timeout"] == expected.as_dict()
assert requests[1].extensions["timeout"] == default.as_dict()
assert http_client.timeout == http_timeout
+47
View File
@@ -0,0 +1,47 @@
"""Execute Markdown and Python docstring examples against the configured API."""
import doctest
import importlib
from pathlib import Path
import pytest
from sybil import Document
from sybil.document import PythonDocStringDocument
from sybil.evaluators.python import PythonEvaluator
from sybil.parsers.markdown import CodeBlockParser, PythonCodeBlockParser
import typesafe_sdk
ROOT = Path(__file__).resolve().parents[1]
MARKDOWN_FILES = [ROOT / "README.md", *sorted((ROOT / "docs").rglob("*.md"))]
SOURCE_FILES = sorted((ROOT / "src" / "typesafe_sdk").rglob("*.py"))
def code_parsers() -> tuple[PythonCodeBlockParser, CodeBlockParser]:
"""Parse both ```python and ```py fenced blocks with the same Python evaluator."""
return PythonCodeBlockParser(), CodeBlockParser("py", PythonEvaluator())
@pytest.mark.integration
@pytest.mark.usefixtures("live_api_key")
@pytest.mark.parametrize("path", MARKDOWN_FILES, ids=lambda path: str(path.relative_to(ROOT)))
def test_markdown(path: Path) -> None:
document = Document.parse(str(path), *code_parsers())
for example in document.examples():
example.evaluate()
@pytest.mark.parametrize("path", SOURCE_FILES, ids=lambda path: str(path.relative_to(ROOT)))
@pytest.mark.integration
@pytest.mark.usefixtures("live_api_key")
def test_python_doctests(path: Path) -> None:
document = PythonDocStringDocument.parse(str(path), *code_parsers())
# Docstring examples are shown on the documented symbol's own page, so they may reference public
# names (like the client) without importing them; standalone Markdown pages still import everything.
document.namespace.update({export: getattr(typesafe_sdk, export) for export in typesafe_sdk.__all__})
for example in document.examples():
example.evaluate()
parts = path.relative_to(ROOT / "src").with_suffix("").parts
name = ".".join(parts[:-1] if parts[-1] == "__init__" else parts)
result = doctest.testmod(importlib.import_module(name))
assert result.failed == 0
+63
View File
@@ -0,0 +1,63 @@
import httpx2
import pytest
from tests.conftest import ClientFactory
from tests.helpers import models
from typesafe_sdk import (
TypeSafeAPIError,
TypeSafeAuthenticationError,
TypeSafeBadRequestError,
TypeSafeInternalServerError,
TypeSafeNotFoundError,
TypeSafePermissionDeniedError,
TypeSafeRateLimitError,
TypeSafeUnprocessableEntityError,
)
@pytest.mark.parametrize(
"error_type",
[
TypeSafeAPIError,
TypeSafeBadRequestError,
TypeSafeAuthenticationError,
TypeSafePermissionDeniedError,
TypeSafeNotFoundError,
TypeSafeUnprocessableEntityError,
TypeSafeRateLimitError,
TypeSafeInternalServerError,
],
)
@pytest.mark.parametrize("message", ["A custom explanation", ""])
def test_message_override(error_type: type[TypeSafeAPIError], message: str) -> None:
headers = httpx2.Headers({"retry-after-ms": "125"})
body = {"message": "Server explanation"}
error = error_type(429, body, headers, message=message)
assert str(error) == message
assert error.status == 429
assert error.body is body
assert error.headers is headers
assert error.request_id is None
if isinstance(error, TypeSafeRateLimitError):
assert error.retry_after_ms == 125
@pytest.mark.parametrize(
"body,message",
[
(b"", "400 status code (no body)"),
(b"null", "400 status code (no body)"),
(b"[]", "400 []"),
(b"42", "400 42"),
(b"not JSON: \xff", "400 not JSON: \ufffd"),
pytest.param(b"x" * 201, "400 " + "x" * 201, id="long-plain-message"),
pytest.param(b'{"unknown":"' + b"x" * 201 + b'"}', '400 {"unknown":"' + "x" * 188 + "…", id="long-unstructured-body"),
(b'{"error":"","message":"ignored"}', '400 {"error":"","message":"ignored"}'),
(b'{"detail":[null,42,{"msg":4}]}', '400 {"detail":[null,42,{"msg":4}]}'),
],
)
async def test_error_body_edge_cases(clients: ClientFactory, body: bytes, message: str) -> None:
with pytest.raises(TypeSafeAPIError) as caught:
await models(clients(lambda request: httpx2.Response(400, content=body)))
assert str(caught.value) == message
assert caught.value.request_id is None
+42
View File
@@ -0,0 +1,42 @@
import pytest
from tests.conftest import Client
from tests.helpers import models, system_one
from typesafe_sdk import Choice, Score
pytestmark = pytest.mark.integration
async def test_live_models(live_client: Client) -> None:
available = await models(live_client)
assert available
for model in available:
assert isinstance(model.name, str)
assert isinstance(model.description, str)
assert isinstance(model.release_date, str)
async def test_live_questions(live_client: Client) -> None:
result = await system_one(
live_client,
state={"subject": "Charged twice this month", "body": "I see two charges of $49. I only have one account. Please fix this ASAP."},
questions={
"billing": {
"type": "noul",
"instructions": "Is this ticket about billing?",
"criteria": {"true": {"meaning": "Payments or invoices", "examples": ["charged twice"]}},
},
"tone": Choice(instructions="What is the customer's tone?", criteria={"calm": None, "frustrated": None, "angry": None}),
"urgency": Score(instructions="How urgent is this ticket?", criteria={0: "can wait", 1: "this week", 2: "today"}),
},
)
assert result.model
assert result.usage.input_tokens is None or result.usage.input_tokens > 0
assert result.usage.output_tokens is None or result.usage.output_tokens >= 0
assert 0 <= result.nouls["billing"].noul <= 1
assert result.choices["tone"].choice in {"calm", "frustrated", "angry"}
assert sum(result.choices["tone"].probabilities.values()) == pytest.approx(1, abs=0.1)
assert 0 <= result.scores["urgency"].score <= 2
assert result.scores["urgency"].legend == {0: "can wait", 1: "this week", 2: "today"}
assert set(result.scores["urgency"].probabilities) == {0, 1, 2}
assert sum(result.scores["urgency"].probabilities.values()) == pytest.approx(1, abs=0.1)
+112
View File
@@ -0,0 +1,112 @@
import logging
import httpx2
import pytest
from tests.conftest import ClientFactory
from tests.helpers import models
from typesafe_sdk import RetryPolicy, TypeSafeAPIError
from typesafe_sdk._core.logging import logger, setup_logging
@pytest.mark.parametrize("status", [200, 400, 429])
@pytest.mark.parametrize(
"header",
[
"Authorization",
"Proxy-Authorization",
"X-API-Key",
"API-Key",
"Cookie",
"Set-Cookie",
"X-Access-Token",
"X-Client-Secret",
"x-MiXeD-ToKeN",
],
)
async def test_secret_headers_redacted(
clients: ClientFactory,
caplog: pytest.LogCaptureFixture,
status: int,
header: str,
) -> None:
attempts = 0
def handler(request: httpx2.Request) -> httpx2.Response:
nonlocal attempts
attempts += 1
return httpx2.Response(
status,
json={"models": []} if status == 200 else {"message": "failure"},
headers={header: "response-credential", "x-visible": "response-visible"},
)
policy = RetryPolicy(backoff_initial=0.001, backoff_max=0.001)
with caplog.at_level(logging.DEBUG, logger="typesafe_sdk"):
client = clients(
handler,
api_key="auth-credential",
headers={header: "request-credential", "x-visible": "request-visible"},
retry=policy,
)
if status == 200:
await models(client)
else:
with pytest.raises(TypeSafeAPIError):
await models(client)
assert attempts == (3 if status == 429 else 1)
assert "request-visible" in caplog.text
assert "response-visible" in caplog.text
assert "***" in caplog.text
for secret in ("auth-credential", "request-credential", "response-credential"):
assert secret not in caplog.text
if status == 429:
assert "retry 1" in caplog.text
assert "retry 2" in caplog.text
@pytest.mark.parametrize(
"level,expected",
[
(logging.DEBUG, {logging.DEBUG, logging.INFO}),
(logging.INFO, {logging.INFO}),
(logging.WARNING, set()),
],
)
async def test_logger_level_controls_output(
clients: ClientFactory,
caplog: pytest.LogCaptureFixture,
level: int,
expected: set[int],
) -> None:
with caplog.at_level(level, logger="typesafe_sdk"):
await models(clients(lambda request: httpx2.Response(200, json={"models": []})))
records = [record for record in caplog.records if record.name == "typesafe_sdk"]
assert {record.levelno for record in records} == expected
if logging.INFO in expected:
summaries = [record.getMessage() for record in records if record.levelno == logging.INFO]
assert len(summaries) == 1
assert "GET" in summaries[0]
if logging.DEBUG not in expected:
assert all("headers=" not in record.getMessage() for record in records)
@pytest.mark.parametrize(
"value,expected",
[
("debug", logging.DEBUG),
("info", logging.INFO),
("off", logging.CRITICAL + 1),
("bogus", logging.NOTSET),
("", logging.NOTSET),
],
)
def test_setup_logging_from_env(monkeypatch: pytest.MonkeyPatch, value: str, expected: int) -> None:
original = logger.level
try:
logger.setLevel(logging.NOTSET)
monkeypatch.setenv("TYPESAFE_LOG_LEVEL", value)
setup_logging()
assert logger.level == expected
finally:
logger.setLevel(original)
+25
View File
@@ -0,0 +1,25 @@
import inspect
from types import ModuleType
import pytest
from syrupy.assertion import SnapshotAssertion
import typesafe_sdk
from typesafe_sdk import AsyncTypeSafeClient, TypeSafeClient
@pytest.mark.parametrize("module", [typesafe_sdk, typesafe_sdk.constants], ids=lambda module: module.__name__)
def test_public_members(module: ModuleType, snapshot: SnapshotAssertion) -> None:
assert sorted(name for name in vars(module) if not name.startswith("_")) == snapshot
def test_package_exports() -> None:
assert not any(name.startswith("_") for name in typesafe_sdk.__all__)
@pytest.mark.parametrize("client_type", [AsyncTypeSafeClient, TypeSafeClient])
def test_constructor_kwargs(client_type: type[AsyncTypeSafeClient] | type[TypeSafeClient], snapshot: SnapshotAssertion) -> None:
parameters = inspect.signature(client_type).parameters
assert list(parameters) == snapshot
assert all(parameter.kind is inspect.Parameter.KEYWORD_ONLY for parameter in parameters.values())
assert all(parameter.default is None for parameter in parameters.values())
+324
View File
@@ -0,0 +1,324 @@
"""Test snapshot filtering and release retries."""
import base64
import importlib
import os
import subprocess
import sys
from pathlib import Path
from types import ModuleType
from typing import Any
import pytest
SCRIPTS = Path(__file__).resolve().parents[1] / ".github/scripts"
INCLUDES = "pyproject.toml\nsrc/\ndocs/changelog.md\n.github/workflows/publish.yml\n"
pytestmark = pytest.mark.skipif(
not (SCRIPTS / "sync_public.py").is_file(), reason="Public sync tooling is only available in the dev repository"
)
def git(repo: Path, *args: str) -> str:
return subprocess.check_output( # noqa: S603 - Explicit Git arguments in disposable fixtures.
["git", *args], # noqa: S607 - Git is supplied by CI.
cwd=repo,
text=True,
stderr=subprocess.PIPE,
).strip()
def write(repo: Path, path: str, content: str) -> None:
file = repo / path
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text(content)
def commit(repo: Path, message: str = "private work") -> None:
git(repo, "add", ".")
git(repo, "commit", "--allow-empty", "-qm", message)
def set_version(repo: Path, version: str) -> None:
write(repo, "pyproject.toml", f'[project]\nname = "typesafe-sdk"\nversion = "{version}"\n')
write(repo, "docs/changelog.md", f"## v{version} (2026-09-11)\n\nReviewed release notes.\n")
def run_script(source: Path, script: str, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run( # noqa: S603 - Repository-owned script with explicit fixture paths.
[sys.executable, str(SCRIPTS / script), *args],
cwd=source,
text=True,
capture_output=True,
check=False,
env={**os.environ, "UV_NO_CACHE": "1"},
timeout=30,
)
@pytest.fixture
def repos(tmp_path: Path) -> tuple[Path, Path, Path]:
source, destination, remote = tmp_path / "dev", tmp_path / "public", tmp_path / "remote.git"
for repo in [source, destination]:
repo.mkdir()
git(repo, "init", "-q", "--initial-branch=main")
git(repo, "config", "user.name", "Test")
git(repo, "config", "user.email", "test@example.com")
git(repo, "config", "commit.gpgsign", "false")
git(tmp_path, "init", "--bare", "--initial-branch=main", str(remote))
git(destination, "remote", "add", "origin", str(remote))
set_version(source, "42.0.0")
write(source, ".releaseinclude", INCLUDES)
write(source, "src/typesafe_sdk/__init__.py", "# public source\n")
write(source, ".github/workflows/publish.yml", "name: Publish\n")
write(source, ".github/workflows/CI.yml", "private CI\n")
write(source, ".dagger/private.py", "private infrastructure\n")
write(source, "RELEASING.md", "private instructions\n")
commit(source)
commit(source, "more private history")
return source, destination, remote
def sync(source: Path, destination: Path, tag: str = "v42.0.0") -> subprocess.CompletedProcess[str]:
return run_script(source, "sync_public.py", str(destination), tag, "--dry-run")
@pytest.fixture
def signer(repos: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch) -> ModuleType:
"""Emulate GitHub's Git object API in the bare remote; use real Git for fetch/push."""
_, destination, remote = repos
monkeypatch.syspath_prepend(str(SCRIPTS))
module = importlib.import_module("sync_public")
def blob(content: bytes) -> str:
return (
subprocess.check_output(
["git", "hash-object", "-w", "--stdin"], # noqa: S607 - Git is supplied by CI.
input=content,
cwd=remote,
)
.decode()
.strip()
)
def api(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
if endpoint == "blobs":
assert payload["encoding"] == "base64"
return {"sha": blob(base64.b64decode(payload["content"]))}
if endpoint == "trees":
git(remote, "read-tree", "--empty")
for entry in payload["tree"]:
sha = blob(entry["content"].encode("utf-8")) if "content" in entry else entry["sha"]
git(remote, "update-index", "--add", "--cacheinfo", entry["mode"], sha, entry["path"])
return {"sha": git(remote, "write-tree")}
assert endpoint == "commits"
# GitHub only signs when author, committer, and signature are omitted.
assert set(payload) == {"message", "tree", "parents"}
# The API creates the release commit directly, without an interim local commit.
head = module.Repo(destination).head
assert (head.commit.hexsha if head.is_valid() else None) == (payload["parents"][0] if payload["parents"] else None)
parents = [arg for parent in payload["parents"] for arg in ["-p", parent]]
sha = git(
remote,
"-c",
"user.name=release-app[bot]",
"-c",
"user.email=release-app[bot]@users.noreply.github.com",
"commit-tree",
payload["tree"],
*parents,
"-m",
payload["message"],
)
return {"sha": sha, "verification": {"verified": True, "reason": "valid"}}
monkeypatch.setattr(module, "api", api)
return module
@pytest.mark.parametrize("with_history", [False, True])
def test_sign_snapshot_and_push(repos: tuple[Path, Path, Path], signer: ModuleType, with_history: bool) -> None:
source, destination, remote = repos
if with_history:
write(destination, "obsolete.txt", "delete me")
commit(destination, "public history")
git(destination, "push", "origin", "main")
write(source, "src/run.sh", "#!/bin/sh\necho 'héllo'\n")
(source / "src/run.sh").chmod(0o755)
(source / "src/image.bin").write_bytes(b"\x00\xff\x80\r\n")
commit(source)
parent = git(destination, "rev-parse", "HEAD") if with_history else ""
remote_refs = git(remote, "show-ref", "--head") if with_history else ""
assert signer.sync_public(source, destination, "v42.0.0", dry_run=False)
signed = git(destination, "rev-parse", "HEAD")
assert git(destination, "log", "-1", "--format=%P") == parent
assert git(destination, "rev-list", "--count", "HEAD") == ("2" if with_history else "1")
assert git(destination, "log", "-1", "--format=%s") == "Release v42.0.0"
assert (destination / "src/run.sh").read_text() == "#!/bin/sh\necho 'héllo'\n"
assert (destination / "src/run.sh").stat().st_mode & 0o777 == 0o755
assert (destination / "src/image.bin").read_bytes() == b"\x00\xff\x80\r\n"
assert not (destination / "obsolete.txt").exists()
assert not (destination / ".dagger").exists()
assert git(destination, "rev-parse", "v42.0.0") == signed
assert git(destination, "status", "--porcelain") == ""
assert git(remote, "for-each-ref", "--format=%(refname)") == ("refs/heads/main" if with_history else "")
if with_history:
assert git(remote, "show-ref", "--head") == remote_refs
result = run_script(source, "push_public.py", str(destination), "v42.0.0")
assert result.returncode == 0, result.stderr
assert git(remote, "rev-parse", "main") == signed
assert git(remote, "rev-parse", "v42.0.0") == signed
assert not signer.sync_public(source, destination, "v42.0.0", dry_run=False)
assert git(destination, "rev-parse", "v42.0.0") == signed
@pytest.mark.parametrize("failure", ["tree", "signature"])
def test_signing_failure_keeps_refs(
repos: tuple[Path, Path, Path],
signer: ModuleType,
monkeypatch: pytest.MonkeyPatch,
failure: str,
) -> None:
source, destination, remote = repos
commit(destination, "public history")
git(destination, "push", "origin", "main")
original = git(destination, "show-ref", "--head")
remote_refs = git(remote, "show-ref", "--head")
create_object = signer.api
def fail(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
result = create_object(endpoint, payload)
if endpoint == "trees" and failure == "tree":
result["sha"] = "0" * 40
if endpoint == "commits":
result["verification"] = {"verified": False, "reason": "unsigned"}
return result
monkeypatch.setattr(signer, "api", fail)
with pytest.raises(ValueError, match=r"does not match|did not verify"):
signer.sync_public(source, destination, "v42.0.0", dry_run=False)
assert git(destination, "show-ref", "--head") == original
assert git(remote, "show-ref", "--head") == remote_refs
def test_dry_run_skips_github(repos: tuple[Path, Path, Path], signer: ModuleType, monkeypatch: pytest.MonkeyPatch) -> None:
source, destination, remote = repos
def fail(endpoint: str, payload: dict[str, Any]) -> dict[str, Any]:
pytest.fail("Dry runs must not call the GitHub API")
monkeypatch.setattr(signer, "api", fail)
assert signer.sync_public(source, destination, "v42.0.0", dry_run=True)
assert git(destination, "log", "-1", "--format=%s") == "Release v42.0.0"
assert git(remote, "for-each-ref", "--format=%(refname)") == ""
def test_snapshot_and_push_retries(repos: tuple[Path, Path, Path]) -> None:
source, destination, remote = repos
# Export the committed version despite local edits.
write(source, "src/untracked.py", "private draft")
write(source, "src/typesafe_sdk/__init__.py", "uncommitted edits")
set_version(source, "99.0.0")
result = sync(source, destination)
assert result.returncode == 0, result.stderr
assert git(destination, "rev-list", "--count", "HEAD") == "1"
assert git(destination, "log", "-1", "--format=%s") == "Release v42.0.0"
assert (destination / "src/typesafe_sdk/__init__.py").read_text() == "# public source\n"
assert not any(
(destination / path).exists() for path in [".dagger", "RELEASING.md", ".releaseinclude", "src/untracked.py", ".github/workflows/CI.yml"]
)
head = git(destination, "rev-parse", "HEAD")
assert git(destination, "rev-parse", "v42.0.0") == head
assert sync(source, destination).returncode == 0
dry_run = run_script(source, "push_public.py", str(destination), "v42.0.0", "--dry-run")
assert dry_run.returncode == 0, dry_run.stderr
assert git(remote, "for-each-ref", "--format=%(refname)") == ""
for _ in range(2):
pushed = run_script(source, "push_public.py", str(destination), "v42.0.0")
assert pushed.returncode == 0, pushed.stderr
assert git(remote, "rev-parse", "main") == head
assert git(remote, "rev-parse", "v42.0.0") == head
assert git(remote, "branch", "--list") == "* main"
def test_existing_history_deletions_and_immutable_tags(repos: tuple[Path, Path, Path]) -> None:
source, destination, _ = repos
write(destination, "old-private-config", "previously committed infrastructure")
commit(destination, "existing destination history")
assert sync(source, destination).returncode == 0
assert not (destination / "old-private-config").exists()
released = git(destination, "rev-parse", "HEAD")
assert git(destination, "rev-list", "--count", "HEAD") == "2"
write(source, "src/added.py", "new public code")
commit(source)
result = sync(source, destination)
assert result.returncode != 0
assert "different contents" in result.stderr
assert git(destination, "rev-parse", "v42.0.0") == released
set_version(source, "42.0.2")
commit(source)
assert sync(source, destination, "v42.0.2").returncode == 0
newest = git(destination, "rev-parse", "main")
set_version(source, "42.0.1")
commit(source)
result = sync(source, destination, "v42.0.1")
assert result.returncode != 0
assert "Refusing" in result.stderr
git(source, "checkout", "HEAD~3")
assert sync(source, destination).returncode == 0
assert git(destination, "rev-parse", "main") == newest
@pytest.mark.parametrize("include", ["../private", ".git/config", "src/*", ".releaseinclude", "/src", "missing-file"])
def test_invalid_includes(repos: tuple[Path, Path, Path], include: str) -> None:
source, destination, _ = repos
write(source, ".releaseinclude", INCLUDES + include + "\n")
commit(source)
result = sync(source, destination)
assert result.returncode != 0
assert "Invalid public include" in result.stderr or "matches no committed files" in result.stderr
assert git(destination, "tag", "--list") == ""
def test_unsafe_snapshots(repos: tuple[Path, Path, Path]) -> None:
source, destination, _ = repos
(source / "src/link").symlink_to("../RELEASING.md")
commit(source)
result = sync(source, destination)
assert result.returncode != 0
assert "Unsupported public file mode" in result.stderr
write(destination, "keep.txt", "local work")
result = sync(source, destination)
assert result.returncode != 0
assert "must be clean" in result.stderr
assert (destination / "keep.txt").read_text() == "local work"
def test_version_mismatch(repos: tuple[Path, Path, Path]) -> None:
source, destination, _ = repos
result = sync(source, destination, "v42.0.1")
assert result.returncode != 0
assert "does not match package version" in result.stderr
def test_atomic_push_rejects_concurrent_update(repos: tuple[Path, Path, Path], signer: ModuleType) -> None:
source, destination, remote = repos
assert sync(source, destination).returncode == 0
assert run_script(source, "push_public.py", str(destination), "v42.0.0").returncode == 0
set_version(source, "42.0.1")
commit(source)
assert signer.sync_public(source, destination, "v42.0.1", dry_run=False)
# Simulate a concurrent update to public main.
competitor = source.parent / "competitor"
git(source.parent, "clone", str(remote), str(competitor))
git(competitor, "config", "user.name", "Test")
git(competitor, "config", "user.email", "test@example.com")
git(competitor, "config", "commit.gpgsign", "false")
commit(competitor, "concurrent main change")
git(competitor, "push", "origin", "main")
result = run_script(source, "push_public.py", str(destination), "v42.0.1")
assert result.returncode != 0
assert git(remote, "tag", "--list") == "v42.0.0"
assert git(remote, "rev-parse", "main") == git(competitor, "rev-parse", "HEAD")
+221
View File
@@ -0,0 +1,221 @@
import copy
import inspect
from collections.abc import Mapping
from types import MappingProxyType
from typing import Any, cast
import httpx2
import msgspec
import pytest
from tests.conftest import ClientFactory
from typesafe_sdk import (
AsyncTypeSafeClient,
Choice,
ChoiceModel,
Noul,
NoulCriteria,
NoulModel,
Question,
Questions,
Score,
ScoreModel,
TypeSafeError,
)
from typesafe_sdk._core.questions import normalize_questions
from typesafe_sdk._schemas import models as wire
def test_normalization_preserves_objects_except_score_maps() -> None:
questions = {
"noul": Noul(instructions="Spam?"),
"choice": Choice(instructions="Tone?", criteria={"calm": None}),
"score": Score(instructions="Quality?", criteria={1: "good", 0: "bad"}),
"list_score": Score(criteria=["bad", "good"]),
}
result = normalize_questions(questions)
assert result["noul"] is questions["noul"]
assert result["choice"] is questions["choice"]
assert result["list_score"] is questions["list_score"]
assert result["score"] is not questions["score"]
assert isinstance(result["score"], Score)
assert questions["score"].criteria == {1: "good", 0: "bad"}
assert msgspec.json.decode(msgspec.json.encode(result)) == {
"noul": {"type": "noul", "instructions": "Spam?"},
"choice": {"type": "choice", "instructions": "Tone?", "criteria": {"calm": None}},
"score": {"type": "score", "instructions": "Quality?", "criteria": ["bad", "good"]},
"list_score": {"type": "score", "criteria": ["bad", "good"]},
}
@pytest.mark.parametrize(
"raw",
[
{"type": "noul", "instructions": "Spam?", "weight": 3, "criteria": {"future": "kept"}},
{"type": "future", "nested": {"k": None}},
{"type": "score", "criteria": {0: "good"}, "weight": 3},
{"type": "score", "criteria": ["good"], "weight": 3},
],
)
def test_normalization_preserves_raw_questions(raw: dict[str, Any]) -> None:
before = copy.deepcopy(raw)
questions = cast(Questions, {"raw": raw, "typed": Noul(instructions="Spam?")})
result = normalize_questions(questions)
expected = {**raw, "criteria": ["good"]} if raw["type"] == "score" else raw
assert isinstance(result["typed"], wire.NoulQuestion)
assert msgspec.to_builtins(result) == {"raw": expected, "typed": {"type": "noul", "instructions": "Spam?"}}
assert raw == before
if not isinstance(raw.get("criteria"), dict) or raw["type"] != "score":
assert result["raw"] is raw
@pytest.mark.parametrize(
"invalid",
[
{},
{"instructions": "Missing type"},
{"type": "choice"},
{"type": "score"},
{"type": ""},
{"type": None},
{"type": 1},
{"type": ["future"]},
"noul",
None,
],
)
def test_raw_questions_require_structural_keys(invalid: object) -> None:
with pytest.raises(TypeSafeError, match='Question "invalid"'):
normalize_questions(cast(Questions, {"invalid": invalid}))
@pytest.mark.parametrize(
"question,expected",
[
(Noul(), {"type": "noul"}),
(Choice(criteria={"a": None}), {"type": "choice", "criteria": {"a": None}}),
(Score(criteria=["good"]), {"type": "score", "criteria": ["good"]}),
(Noul(instructions="", criteria={}), {"type": "noul", "instructions": "", "criteria": {}}),
(Noul(instructions=[], criteria={"true": None}), {"type": "noul", "instructions": [], "criteria": {"true": None}}),
],
)
def test_direct_encoding_omits_only_default_fields(question: Noul | Choice | Score, expected: dict[str, Any]) -> None:
assert msgspec.json.decode(msgspec.json.encode(question)) == expected
assert msgspec.to_builtins(question) == expected
def test_score_mutations_are_normalized_when_sending() -> None:
score = Score(criteria=["initial"])
score.criteria = {1: "good", 0: "bad"}
assert msgspec.to_builtins(normalize_questions({"q": score})) == {"q": {"type": "score", "criteria": ["bad", "good"]}}
assert score.criteria == {1: "good", 0: "bad"}
score.criteria[3] = "gap"
with pytest.raises(TypeSafeError, match="no gaps"):
normalize_questions({"q": score})
def test_discriminators_are_automatic() -> None:
noul = Noul(instructions="Spam?")
choice = Choice(instructions="Tone?", criteria={"calm": None})
score = Score(instructions="Quality?", criteria=["good"])
for question, wire_type, tag in (
(noul, wire.NoulQuestion, "noul"),
(choice, wire.ChoiceQuestion, "choice"),
(score, wire.ScoreQuestion, "score"),
):
assert isinstance(question, msgspec.Struct)
assert isinstance(question, wire_type)
assert question.__struct_config__.tag == tag
assert question.__struct_config__.tag_field == "type"
assert msgspec.to_builtins(question)["type"] == tag
assert msgspec.json.decode(msgspec.json.encode(question))["type"] == tag
assert not hasattr(question, "type")
assert not hasattr(question, "__dict__")
signature = inspect.signature(type(question))
assert "type" not in signature.parameters
assert all(parameter.kind is inspect.Parameter.KEYWORD_ONLY for parameter in signature.parameters.values())
with pytest.raises(TypeError):
cast(Any, type(question))("Spam?")
with pytest.raises(AttributeError):
cast(Any, question).type = "other"
question.instructions = "Updated?"
assert msgspec.to_builtins(question)["instructions"] == "Updated?"
@pytest.mark.parametrize("raw", [False, True])
@pytest.mark.parametrize(
"criteria",
[
None,
{},
{"true": "Yes"},
{"false": "No"},
{"true": "Yes", "false": "No"},
{"true": {"summary": "Unsolicited", "examples": ["Buy now"]}},
],
)
def test_optional_noul_criteria(raw: bool, criteria: NoulCriteria | None) -> None:
expected: NoulModel = {"type": "noul", "instructions": "Spam?"}
if criteria is not None:
expected["criteria"] = criteria
question: Question
if raw:
question = expected.copy()
else:
question = Noul(instructions="Spam?", criteria=criteria)
assert msgspec.to_builtins(normalize_questions({"q": question})) == {"q": expected}
@pytest.mark.parametrize("raw", [False, True])
@pytest.mark.parametrize("criteria", [[], {}, {0: "bad", 2: "good"}, {-1: "bad"}, {False: "bad"}, {0: "bad", True: "good"}])
def test_score_validation_preserves_inputs(raw: bool, criteria: list[str] | dict[int, str]) -> None:
model = cast(ScoreModel, {"type": "score", "instructions": "Quality?", "criteria": criteria})
question = model if raw else Score(instructions=model["instructions"], criteria=model["criteria"])
before = copy.deepcopy(criteria)
with pytest.raises(TypeSafeError, match='"rating"'):
normalize_questions({"rating": question})
assert criteria == before
async def test_covariant_question_mappings(clients: ClientFactory) -> None:
nouls = {"q": Noul(instructions="Spam?")}
choices = {"q": Choice(instructions="Tone?", criteria={"calm": None})}
scores = {"q": Score(instructions="Quality?", criteria={0: "good"})}
raw_nouls: dict[str, NoulModel] = {"q": {"type": "noul", "instructions": "Spam?"}}
raw_choices: dict[str, ChoiceModel] = {"q": {"type": "choice", "instructions": "Tone?", "criteria": {"calm": None}}}
raw_scores: dict[str, ScoreModel] = {"q": {"type": "score", "instructions": "Quality?", "criteria": {0: "good"}}}
read_only: Mapping[str, Choice] = MappingProxyType(choices)
mixed: Questions = {"one": nouls["q"], "two": raw_choices["q"], "three": scores["q"]}
calls = 0
def handler(request: httpx2.Request) -> httpx2.Response:
nonlocal calls
calls += 1
assert msgspec.json.decode(request.content)["questions"]
return httpx2.Response(200, json={"model": "jev-latest", "usage": {}, "answers": {}})
client = clients(handler)
if isinstance(client, AsyncTypeSafeClient):
await client.system_one("x", nouls)
await client.system_one("x", choices)
await client.system_one("x", scores)
await client.system_one("x", raw_nouls)
await client.system_one("x", raw_choices)
await client.system_one("x", raw_scores)
await client.system_one("x", read_only)
await client.system_one("x", mixed)
await client.system_one("x", {"q": {"type": "choice", "instructions": "Tone?", "criteria": {"calm": None}}})
else:
client.system_one("x", nouls)
client.system_one("x", choices)
client.system_one("x", scores)
client.system_one("x", raw_nouls)
client.system_one("x", raw_choices)
client.system_one("x", raw_scores)
client.system_one("x", read_only)
client.system_one("x", mixed)
client.system_one("x", {"q": {"type": "choice", "instructions": "Tone?", "criteria": {"calm": None}}})
assert calls == 9
assert scores["q"].criteria == {0: "good"}
assert raw_scores["q"]["criteria"] == {0: "good"}
assert read_only["q"] is choices["q"]
+64
View File
@@ -0,0 +1,64 @@
import os
import subprocess
import sys
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parents[1] / ".github/scripts/release_notes.py"
def run_release_notes(tmp_path: Path, changelog: str, tag: str = "v1.2.3", version: str = "1.2.3") -> subprocess.CompletedProcess[str]:
(tmp_path / "docs").mkdir()
(tmp_path / "docs/changelog.md").write_text(changelog)
return subprocess.run( # noqa: S603 - Fixed interpreter and repository-owned script.
[sys.executable, str(SCRIPT), tag, version],
cwd=tmp_path,
env={**os.environ, "GITHUB_OUTPUT": str(tmp_path / "output")},
capture_output=True,
text=True,
check=False,
timeout=10,
)
@pytest.mark.parametrize("unreleased", ["", "## Unreleased\n\nFuture work.\n\n"])
def test_release_notes(tmp_path: Path, unreleased: str) -> None:
notes = "Custom **release notes**.\n\n### Features\n\n- Something new.\n"
changelog = (
"---\ntitle: Changelog\n---\n\n# Changelog\n\n"
f"{unreleased}"
f"## v1.2.3 (2026-09-09)\n\n{notes}\n"
"## v1.2.2 (2026-09-08)\n\nOlder release.\n"
)
result = run_release_notes(tmp_path, changelog)
assert result.returncode == 0, result.stderr
assert (tmp_path / "output").read_text() == "title=v1.2.3 (2026-09-09)\n"
assert (tmp_path / "release-notes.md").read_text() == notes
@pytest.mark.parametrize(
"changelog, tag, version, error",
[
("## v1.2.2 (2026-09-09)\nNotes.\n", "v1.2.3", "1.2.3", "found 0"),
("## v1.2.30 (2026-09-09)\nNotes.\n", "v1.2.3", "1.2.3", "found 0"),
(
"## v1.2.4 (2026-09-10)\nNew release.\n\n## v1.2.3 (2026-09-09)\nOlder release.\n",
"v1.2.3",
"1.2.3",
"must match the latest changelog entry",
),
("## v1.2.3 (2026-09-09)\nNotes.\n" * 2, "v1.2.3", "1.2.3", "found 2"),
("## v1.2.3 (2026-09-09)\n\n", "v1.2.3", "1.2.3", "Empty release notes"),
("## v1.2.3 (2026-02-30)\nNotes.\n", "v1.2.3", "1.2.3", "ValueError"),
("## v1.2.3 (tomorrow)\nNotes.\n", "v1.2.3", "1.2.3", "Invalid release heading"),
("", "v1.2.3", "1.2.4", "does not match package version"),
("", "v1.2", "1.2", "vX.Y.Z format"),
],
)
def test_invalid_release_notes(tmp_path: Path, changelog: str, tag: str, version: str, error: str) -> None:
result = run_release_notes(tmp_path, changelog, tag, version)
assert result.returncode != 0
assert error in result.stderr
assert not (tmp_path / "release-notes.md").exists()
assert not (tmp_path / "output").exists()
+202
View File
@@ -0,0 +1,202 @@
from typing import Any, cast
import httpx2
import msgspec
import pytest
from typing_extensions import assert_type
from tests.conftest import ClientFactory
from tests.helpers import system_one
from tests.test_clients import RESULT
from typesafe_sdk import (
Answer,
ChoiceAnswer,
NoulAnswer,
ScoreAnswer,
SystemOneResponse,
TypeSafeAPIResponseValidationError,
TypeSafeError,
Usage,
)
@pytest.mark.parametrize(
"answers,field_path",
[
({}, "model"),
({"n": {"type": "noul"}}, "answers.n.noul"),
({"c": {"type": "choice", "choice": "a", "probabilities": {}}}, "answers.c.confidence"),
({"c": {"type": "choice", "confidence": 0.5, "probabilities": {}}}, "answers.c.choice"),
({"s": {"type": "score", "score": 1.0, "confidence": 1.0, "legend": [], "probabilities": {}}}, "answers.s.legend"),
({"s": {"type": "score", "score": 1.0, "confidence": 1.0, "legend": {"x": "bad"}, "probabilities": {}}}, "answers.s.legend"),
({"c": "not-a-mapping"}, "answers.c.type"),
],
)
async def test_malformed_response_raises_validation_error(clients: ClientFactory, answers: dict[str, Any], field_path: str) -> None:
body: dict[str, Any] = {"usage": {"input_tokens": 1, "output_tokens": 1}, "answers": answers}
if field_path != "model":
body["model"] = "test"
def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, json=body, headers={"x-typesafe-request-id": "req-123"})
with pytest.raises(TypeSafeAPIResponseValidationError) as caught:
await system_one(clients(handler), state="x", questions={"q": {"type": "noul", "instructions": "?"}})
assert caught.value.field_path == field_path
assert caught.value.status == 200
assert caught.value.request_id == "req-123"
assert caught.value.body == body
async def test_response_carries_request_id(clients: ClientFactory) -> None:
result = await system_one(
clients(lambda request: httpx2.Response(200, json=RESULT, headers={"x-typesafe-request-id": "req-42"})),
state="text",
questions={"q": {"type": "noul", "instructions": "?"}},
)
assert result.request_id == "req-42"
async def test_response_carries_raw_http_response(clients: ClientFactory) -> None:
result = await system_one(
clients(lambda request: httpx2.Response(200, json=RESULT, headers={"x-typesafe-request-id": "req-42"})),
state="text",
questions={"q": {"type": "noul", "instructions": "?"}},
)
assert result.raw_http_response.status_code == 200
assert result.raw_http_response.headers["x-typesafe-request-id"] == "req-42"
assert result.raw_http_response.json() == RESULT
def test_missing_raw_raises_on_access() -> None:
result = SystemOneResponse(model="test", usage=Usage(), answers={})
with pytest.raises(TypeSafeError, match="raw HTTP response"):
_ = result.raw_http_response
async def test_missing_request_id_raises_on_access(clients: ClientFactory) -> None:
result = await system_one(
clients(lambda request: httpx2.Response(200, json=RESULT)),
state="text",
questions={"q": {"type": "noul", "instructions": "?"}},
)
with pytest.raises(TypeSafeError, match="request ID"):
_ = result.request_id
async def test_unknown_extra_fields_tolerated(clients: ClientFactory) -> None:
body = {
"model": "test",
"usage": {"input_tokens": 1, "output_tokens": 1, "reasoning_tokens": 9, "billing_units": 1},
"answers": {"spam": {"type": "noul", "noul": 0.9, "explanation": "spammy"}},
}
result = await system_one(
clients(lambda request: httpx2.Response(200, json=body)),
state="x",
questions={"q": {"type": "noul", "instructions": "?"}},
)
assert result.nouls["spam"].noul == 0.9
assert not hasattr(result.usage, "billing_units")
assert msgspec.to_builtins(result.usage) == {"input_tokens": 1, "output_tokens": 1}
async def test_unknown_answer_type_ignored(clients: ClientFactory) -> None:
body = {
"model": "test",
"usage": {"input_tokens": 1, "output_tokens": 1},
"answers": {
"spam": {"type": "noul", "noul": 0.9},
"mystery": {"type": "aurora", "value": 3},
},
}
result = await system_one(
clients(lambda request: httpx2.Response(200, json=body, headers={"x-typesafe-request-id": "req-9"})),
state="text",
questions={"q": {"type": "noul", "instructions": "?"}},
)
# A future answer type this SDK version does not model is skipped, not raised.
assert set(result.answers) == {"spam"}
assert result.nouls["spam"].noul == 0.9
# The unknown answer is still available in the raw response.
assert result.raw_http_response.json()["answers"]["mystery"]["type"] == "aurora"
def test_response_preserves_nested_json() -> None:
original = ScoreAnswer(
score=0.0,
confidence=1.0,
legend={0: {"examples": ["a", {"note": None}]}},
probabilities={0: 1.0},
)
result = SystemOneResponse(
model="test",
usage=Usage(input_tokens=1, output_tokens=1),
answers={"q": original},
)
assert not result.choices
answer = result.scores["q"]
assert answer is original
assert answer is result.answers["q"]
assert isinstance(result.usage, Usage)
assert answer.probabilities == {0: 1.0}
entry = answer.legend[0]
assert isinstance(entry, dict)
assert entry["examples"] == ["a", {"note": None}]
examples = entry["examples"]
assert isinstance(examples, list)
assert isinstance(examples[1], dict)
exported = msgspec.to_builtins(answer)
assert exported == {
"type": "score",
"score": 0.0,
"confidence": 1.0,
"legend": {0: {"examples": ["a", {"note": None}]}},
"probabilities": {0: 1.0},
}
# to_builtins returns an independent deep copy: mutating it leaves the struct untouched.
exported["legend"][0]["examples"].append("new")
exported["probabilities"][0] = 0.5
assert examples == ["a", {"note": None}]
assert answer.probabilities == {0: 1.0}
def test_answer_attributes_and_dictionary_types() -> None:
noul = NoulAnswer(noul=0.98)
choice = ChoiceAnswer(choice="billing", confidence=0.9, probabilities={"billing": 0.9, "support": 0.1})
assert_type(noul.noul, float)
assert_type(choice.choice, str)
assert_type(choice.confidence, float)
assert_type(choice.probabilities, dict[str, float])
assert msgspec.to_builtins(noul) == {"type": "noul", "noul": 0.98}
assert msgspec.to_builtins(choice) == {
"type": "choice",
"choice": "billing",
"confidence": 0.9,
"probabilities": {"billing": 0.9, "support": 0.1},
}
@pytest.mark.parametrize(
"answer",
[
NoulAnswer(noul=0.98),
ChoiceAnswer(choice="billing", confidence=1.0, probabilities={"billing": 1.0}),
ScoreAnswer(score=0.0, confidence=1.0, legend={0: "bad"}, probabilities={0: 1.0}),
],
)
def test_answer_fields_are_frozen_and_slotted(answer: Answer) -> None:
assert not hasattr(answer, "__dict__")
with pytest.raises(AttributeError):
cast(Any, answer).type = "other"
@pytest.mark.parametrize("group", ["nouls", "choices", "scores"])
def test_cached_groups_cannot_be_reassigned(group: str) -> None:
result = SystemOneResponse(model="test", usage=Usage(), answers={})
with pytest.raises(AttributeError):
setattr(result, group, {})
cached = getattr(result, group)
assert getattr(result, group) is cached
with pytest.raises(AttributeError):
setattr(result, group, {})
+524
View File
@@ -0,0 +1,524 @@
import asyncio
from datetime import datetime, timezone
from email.utils import format_datetime
from types import SimpleNamespace
import httpx2
import msgspec
import pytest
from tenacity import RetryCallState
from tests.conftest import ClientFactory
from tests.helpers import models, system_one
from tests.test_clients import RESULT
from typesafe_sdk import (
AsyncTypeSafeClient,
Noul,
Questions,
RetryPolicy,
TypeSafeAPIConnectionError,
TypeSafeAPIError,
TypeSafeAPITimeoutError,
TypeSafeError,
TypeSafeRateLimitError,
)
from typesafe_sdk._core.errors import parse_retry_after
@pytest.mark.parametrize("timeout", [0, -1, float("inf"), float("nan")])
def test_retry_policy_invalid_timeout(timeout: float) -> None:
with pytest.raises(TypeSafeError, match="timeout must be a positive, finite number of seconds"):
RetryPolicy(timeout=timeout)
@pytest.mark.parametrize("resource", ["models", "system_one"])
@pytest.mark.parametrize(
"timeout,duration,delay,attempts",
[(None, 1.0, 0.5, 3), (30.0, 10.0, 5.0, 2), (2.5, 0.75, 0.5, 2), (2.0, 1.0, 0.0, 2), (1.0, 0.0, 1.0, 1), (1.0, 0.0, 60.0, 1)],
)
async def test_retry_policy_timeout_budget(
clients: ClientFactory,
monkeypatch: pytest.MonkeyPatch,
resource: str,
timeout: float | None,
duration: float,
delay: float,
attempts: int,
) -> None:
now = 100.0
requests: list[httpx2.Request] = []
delays: list[float] = []
monkeypatch.setattr("tenacity.time", SimpleNamespace(monotonic=lambda: now))
def sleep(seconds: float) -> None:
nonlocal now
delays.append(seconds)
now += seconds
async def sleep_async(seconds: float) -> None:
sleep(seconds)
def handler(request: httpx2.Request) -> httpx2.Response:
nonlocal now
now += duration
requests.append(request)
return httpx2.Response(429, json={"message": f"attempt {len(requests)}"}, headers={"Retry-After": str(delay)})
client = clients(handler, retry=RetryPolicy(timeout=timeout))
if isinstance(client, AsyncTypeSafeClient):
if resource == "models":
client.models._retry = client.models._retry.copy(sleep=sleep_async)
else:
client._retry = client._retry.copy(sleep=sleep_async)
elif resource == "models":
client.models._retry = client.models._retry.copy(sleep=sleep)
else:
client._retry = client._retry.copy(sleep=sleep)
async def call() -> None:
if resource == "models":
await models(client)
else:
await system_one(client, state="x", questions={"q": Noul(instructions="?")})
for _ in range(2): # Each SDK call gets a fresh budget.
requests.clear()
delays.clear()
with pytest.raises(TypeSafeRateLimitError, match=f"attempt {attempts}$"):
await call()
assert len(requests) == attempts
assert delays == [delay] * (attempts - 1)
@pytest.mark.parametrize("resource", ["models", "system_one"])
async def test_retry_policy_timeout_override(clients: ClientFactory, monkeypatch: pytest.MonkeyPatch, resource: str) -> None:
now = 100.0
attempts = 0
monkeypatch.setattr("tenacity.time", SimpleNamespace(monotonic=lambda: now))
def handler(request: httpx2.Request) -> httpx2.Response:
nonlocal now, attempts
# Each attempt advances the mock clock by 20s so the default 30.0s budget binds before the
# third attempt (elapsed 40s >= 30s), rather than only max_retries capping the loop.
now += 20.0
attempts += 1
return httpx2.Response(429, headers={"retry-after-ms": "0"})
client = clients(handler, retry=RetryPolicy())
async def call(retry: RetryPolicy | None) -> None:
if resource == "models":
await models(client, retry=retry)
else:
await system_one(client, state="x", questions={"q": Noul(instructions="?")}, retry=retry)
for policy, expected in [(None, 2), (RetryPolicy(timeout=1.0), 1), (RetryPolicy(timeout=None), 3), (None, 2)]:
attempts = 0
with pytest.raises(TypeSafeRateLimitError):
await call(policy)
assert attempts == expected
@pytest.mark.parametrize(
"status,attempts", [(408, 3), (429, 3), (500, 3), (503, 3), (599, 3), (400, 1), (401, 1), (403, 1), (404, 1), (409, 1), (422, 1), (302, 1)]
)
async def test_default_retry_statuses(clients: ClientFactory, status: int, attempts: int) -> None:
requests: list[httpx2.Request] = []
def handler(request: httpx2.Request) -> httpx2.Response:
requests.append(request)
return httpx2.Response(status, json={"message": "failed"}, headers={"retry-after-ms": "0"})
with pytest.raises(TypeSafeAPIError) as caught:
await models(clients(handler, retries=True))
assert caught.value.status == status
assert len(requests) == attempts
assert [request.headers.get("x-typesafe-retry-count") for request in requests] == [None, "1", "2"][:attempts]
@pytest.mark.parametrize("kind", [httpx2.ConnectError, httpx2.ReadTimeout, httpx2.ReadError])
async def test_connection_retry_recovers(clients: ClientFactory, kind: type[httpx2.RequestError]) -> None:
attempts = 0
delays: list[float] = []
async def sleep(delay: float) -> None:
delays.append(delay)
def handler(request: httpx2.Request) -> httpx2.Response:
nonlocal attempts
attempts += 1
if attempts < 3:
raise kind("failed", request=request)
else:
return httpx2.Response(200, json={"models": []})
client = clients(handler, retries=True)
if isinstance(client, AsyncTypeSafeClient):
client.models._retry = client.models._retry.copy(sleep=sleep)
else:
client.models._retry = client.models._retry.copy(sleep=delays.append)
assert await models(client) == ()
assert attempts == 3
assert 0.375 <= delays[0] <= 0.5
assert 0.75 <= delays[1] <= 1.0
@pytest.mark.parametrize(
"headers,delay",
[
({"Retry-After": "2"}, 2.0),
({"retry-after-ms": "125"}, 0.125),
({"retry-after-ms": "0", "Retry-After": "50"}, 0.0),
({"Retry-After": "60"}, 60.0),
],
)
async def test_server_delay_through_tenacity(clients: ClientFactory, headers: dict[str, str], delay: float) -> None:
attempts = 0
delays: list[float] = []
async def sleep(seconds: float) -> None:
delays.append(seconds)
def handler(request: httpx2.Request) -> httpx2.Response:
nonlocal attempts
attempts += 1
return httpx2.Response(429, json={}, headers=headers) if attempts == 1 else httpx2.Response(200, json={"models": []})
client = clients(handler, retry=RetryPolicy(timeout=None))
if isinstance(client, AsyncTypeSafeClient):
client.models._retry = client.models._retry.copy(sleep=sleep)
else:
client.models._retry = client.models._retry.copy(sleep=delays.append)
assert await models(client) == ()
assert delays == [delay]
@pytest.mark.parametrize(
"headers,expected",
[
({}, None),
({"Retry-After": "bad"}, None),
({"Retry-After": "-1"}, None),
({"retry-after-ms": "NaN", "Retry-After": "1.5"}, 1500),
({"retry-after-ms": "-1", "Retry-After": "2"}, 2000),
({"Retry-After": ""}, 0),
({"retry-after-ms": "inf"}, None),
({"retry-after-ms": "bad", "Retry-After": "2"}, 2000),
],
)
def test_parse_retry_after(headers: dict[str, str], expected: float | None) -> None:
assert parse_retry_after(httpx2.Headers(headers)) == expected
def test_backoff_dates_cap_and_jitter(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("time.time", lambda: 1_000_000.0)
future = format_datetime(datetime.fromtimestamp(1_000_010, timezone.utc), usegmt=True)
past = format_datetime(datetime.fromtimestamp(999_990, timezone.utc), usegmt=True)
assert parse_retry_after(httpx2.Headers({"Retry-After": future})) == 10_000
assert parse_retry_after(httpx2.Headers({"Retry-After": past})) == 0
wait = RetryPolicy()._wait
state = RetryCallState(RetryPolicy()._build_tenacity(), None, (), {})
monkeypatch.setattr("random.random", lambda: 0.0)
for attempt, expected in [(1, 0.5), (2, 1.0), (3, 2.0), (4, 4.0), (5, 5.0), (20, 5.0)]:
state.attempt_number = attempt
assert wait(state) == expected
monkeypatch.setattr("random.random", lambda: 1.0)
state.attempt_number = 1
assert wait(state) == 0.375
for header, expected in [({"Retry-After": "61"}, 61), ({"retry-after-ms": "60001"}, 60.001), ({"Retry-After": future}, 10)]:
error = TypeSafeRateLimitError(429, {}, httpx2.Headers(header))
state.set_exception((type(error), error, None))
assert wait(state) == expected # Server-requested delays are always honored, however long.
error = TypeSafeRateLimitError(429, {}, httpx2.Headers({"Retry-After": "bad"})) # Unparseable headers fall back to backoff.
state.set_exception((type(error), error, None))
assert wait(state) == 0.375
@pytest.mark.parametrize("client_attempts,call_attempts", [(1, 3), (3, 1)])
async def test_system_one_retry_override(clients: ClientFactory, client_attempts: int, call_attempts: int) -> None:
requests: list[httpx2.Request] = []
client_policy = RetryPolicy(max_retries=client_attempts - 1)
call_policy = RetryPolicy(max_retries=call_attempts - 1, http_statuses={409})
def handler(request: httpx2.Request) -> httpx2.Response:
requests.append(request)
status = 409 if request.headers["x-call"] == "override" else 429
return httpx2.Response(status, json={"message": "failed"}, headers={"retry-after-ms": "0"})
client = clients(handler, retry=client_policy)
calls = [
("override", call_policy, call_attempts),
("inherited", None, client_attempts),
("override", call_policy, call_attempts),
]
for name, retry, attempts in calls:
requests.clear()
with pytest.raises(TypeSafeAPIError):
await system_one(
client,
state="hello",
questions={"q": {"type": "noul", "instructions": "?"}},
extra_headers={"x-call": name},
retry=retry,
)
assert [request.headers.get("x-typesafe-retry-count") for request in requests] == [None, *(str(index) for index in range(1, attempts))]
async def test_async_concurrent_retry_state() -> None:
attempts: dict[str, list[str | None]] = {}
async def handler(request: httpx2.Request) -> httpx2.Response:
key = request.headers["x-call"]
calls = attempts.setdefault(key, [])
calls.append(request.headers.get("x-typesafe-retry-count"))
await asyncio.sleep(0)
return httpx2.Response(429, headers={"retry-after-ms": "0"}) if len(calls) == 1 else httpx2.Response(200, json={"models": []})
async with AsyncTypeSafeClient(api_key="test", http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler))) as client:
responses = await asyncio.gather(*(client.models.list(extra_headers={"x-call": str(index)}) for index in range(4)))
assert [response.models for response in responses] == [(), (), (), ()]
assert all(value == [None, "1"] for value in attempts.values())
@pytest.mark.parametrize("raw", [False, True])
@pytest.mark.parametrize("timeout", [2.0, httpx2.Timeout(3.0, connect=1.0, read=5.0)])
async def test_system_one_retry_recovers_with_overrides(
clients: ClientFactory,
raw: bool,
timeout: float | httpx2.Timeout,
) -> None:
requests: list[httpx2.Request] = []
delays: list[float] = []
async def sleep(delay: float) -> None:
delays.append(delay)
questions: Questions = {
"q": {"type": "noul", "instructions": "?"} if raw else Noul(instructions="?"),
}
def handler(request: httpx2.Request) -> httpx2.Response:
requests.append(request)
if len(requests) == 1:
raise httpx2.ReadTimeout("timed out", request=request)
elif len(requests) == 2:
return httpx2.Response(429, json={"message": "slow down"}, headers={"retry-after-ms": "125"})
return httpx2.Response(200, json=RESULT)
headers = {"x-call": "override", "authorization": "must-not-win"}
client = clients(handler, model="client-model", timeout=7.0, headers={"x-default": "kept"}, retries=True)
if isinstance(client, AsyncTypeSafeClient):
client._retry = client._retry.copy(sleep=sleep)
else:
client._retry = client._retry.copy(sleep=delays.append)
result = await system_one(
client,
state={"document": "hello"},
questions=questions,
model="call-model",
timeout=timeout,
extra_headers=headers,
)
assert result.scores["quality"].score == 1.7
assert result.choices["tone"].confidence == 0.9
expected = msgspec.json.encode(
{
"state": {"document": "hello"},
"model": "call-model",
"questions": {"q": {"type": "noul", "instructions": "?"}},
}
)
for request in requests:
assert request.content == expected
assert request.extensions["timeout"] == httpx2.Timeout(timeout).as_dict()
assert request.headers["authorization"] == "Bearer test-key"
assert request.headers["x-default"] == "kept"
assert request.headers["x-call"] == "override"
assert [request.headers.get("x-typesafe-retry-count") for request in requests] == [None, "1", "2"]
assert 0.375 <= delays[0] <= 0.5
assert delays[1] == 0.125
assert headers == {"x-call": "override", "authorization": "must-not-win"}
await system_one(client, state="next", questions=questions)
assert msgspec.json.decode(requests[-1].content)["model"] == "client-model"
assert requests[-1].extensions["timeout"] == httpx2.Timeout(7.0).as_dict()
assert "x-call" not in requests[-1].headers
assert "x-typesafe-retry-count" not in requests[-1].headers
async def test_concurrent_system_one_overrides() -> None:
attempts: dict[str, list[httpx2.Request]] = {}
async def handler(request: httpx2.Request) -> httpx2.Response:
key = request.headers["x-call"]
calls = attempts.setdefault(key, [])
calls.append(request)
await asyncio.sleep(0)
return httpx2.Response(429, json={"message": "retry"}, headers={"retry-after-ms": "0"})
async with AsyncTypeSafeClient(
api_key="test",
http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)),
retry=RetryPolicy(max_retries=1),
timeout=9.0,
) as client:
async def call(name: str, retries: int | None, timeout: float) -> None:
with pytest.raises(TypeSafeRateLimitError):
await client.system_one(
state=name,
questions={"q": Noul(instructions="?")},
model=name,
extra_headers={"x-call": name},
timeout=timeout,
retry=None if retries is None else RetryPolicy(max_retries=retries - 1),
)
await asyncio.gather(call("one", 1, 1.0), call("three", 3, 3.0), call("default", None, 2.0))
for name, count in (("one", 1), ("three", 3), ("default", 2)):
requests = attempts[name]
assert len(requests) == count
assert [request.headers.get("x-typesafe-retry-count") for request in requests] == [
None,
*(str(index) for index in range(1, count)),
]
assert all(msgspec.json.decode(request.content)["model"] == name for request in requests)
assert all(msgspec.json.decode(request.content)["state"] == name for request in requests)
assert all(request.extensions["timeout"] == httpx2.Timeout(float(count)).as_dict() for request in requests)
@pytest.mark.parametrize("kind", [httpx2.ReadTimeout, httpx2.ConnectError])
async def test_exhausted_transport_retry(clients: ClientFactory, kind: type[httpx2.RequestError]) -> None:
attempts = 0
timeout = httpx2.Timeout(2.0, read=4.0)
def handler(request: httpx2.Request) -> httpx2.Response:
nonlocal attempts
attempts += 1
raise kind(f"attempt {attempts}", request=request)
with pytest.raises(TypeSafeAPIConnectionError) as caught:
await system_one(
clients(handler),
state="x",
questions={"q": Noul(instructions="?")},
retry=RetryPolicy(backoff_initial=0.001, backoff_max=0.001),
timeout=timeout,
)
assert attempts == 3
assert isinstance(caught.value.__cause__, kind)
assert str(caught.value.__cause__) == "attempt 3"
if kind is httpx2.ReadTimeout:
assert isinstance(caught.value, TypeSafeAPITimeoutError)
assert caught.value.timeout is timeout
else:
assert type(caught.value) is TypeSafeAPIConnectionError
async def test_exhausted_retry_preserves_final_http_error(clients: ClientFactory) -> None:
attempts = 0
def handler(request: httpx2.Request) -> httpx2.Response:
nonlocal attempts
attempts += 1
return httpx2.Response(
[429, 500, 503][attempts - 1],
json={"message": f"attempt {attempts}"},
headers={"x-typesafe-request-id": f"request-{attempts}", "retry-after-ms": "0"},
)
with pytest.raises(TypeSafeAPIError) as caught:
await system_one(clients(handler, retries=True), state="x", questions={"q": Noul(instructions="?")})
assert attempts == 3
assert caught.value.status == 503
assert caught.value.body == {"message": "attempt 3"}
assert caught.value.request_id == "request-3"
assert str(caught.value) == "503 attempt 3"
async def test_cancel_pending_retry() -> None:
sleeping = asyncio.Event()
async def sleep(delay: float) -> None:
sleeping.set()
await asyncio.Event().wait()
async with AsyncTypeSafeClient(
api_key="test",
http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(lambda request: httpx2.Response(429))),
) as client:
client.models._retry = client.models._retry.copy(sleep=sleep)
task = asyncio.create_task(client.models.list())
await asyncio.wait_for(sleeping.wait(), timeout=1)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
@pytest.mark.parametrize("max_retries,attempts", [(0, 1), (1, 2), (4, 5)])
async def test_retry_policy_max_retries(clients: ClientFactory, max_retries: int, attempts: int) -> None:
requests: list[httpx2.Request] = []
def handler(request: httpx2.Request) -> httpx2.Response:
requests.append(request)
return httpx2.Response(429, json={"message": "slow"}, headers={"retry-after-ms": "0"})
with pytest.raises(TypeSafeRateLimitError):
await models(clients(handler, retry=RetryPolicy(max_retries=max_retries)))
assert len(requests) == attempts
@pytest.mark.parametrize("status,attempts", [(409, 3), (500, 1)])
async def test_retry_policy_custom_statuses(clients: ClientFactory, status: int, attempts: int) -> None:
requests: list[httpx2.Request] = []
def handler(request: httpx2.Request) -> httpx2.Response:
requests.append(request)
return httpx2.Response(status, json={"message": "x"}, headers={"retry-after-ms": "0"})
with pytest.raises(TypeSafeAPIError):
await models(clients(handler, retry=RetryPolicy(http_statuses={409})))
assert len(requests) == attempts
async def test_retry_policy_per_call_override(clients: ClientFactory) -> None:
requests: list[httpx2.Request] = []
def handler(request: httpx2.Request) -> httpx2.Response:
requests.append(request)
return httpx2.Response(429, json={}, headers={"retry-after-ms": "0"})
client = clients(handler, retry=RetryPolicy(max_retries=2))
with pytest.raises(TypeSafeRateLimitError):
await models(client, retry=RetryPolicy(max_retries=0))
assert len(requests) == 1
@pytest.mark.parametrize(
"policy",
[
RetryPolicy(max_retries=1, predicate=lambda error: isinstance(error, TypeSafeAPIError) and error.status == 404),
RetryPolicy(max_retries=1, exceptions={TypeSafeAPIError}),
],
)
async def test_retry_policy_exceptions_and_predicate(clients: ClientFactory, policy: RetryPolicy) -> None:
requests: list[httpx2.Request] = []
def handler(request: httpx2.Request) -> httpx2.Response:
requests.append(request)
return httpx2.Response(404, json={"message": "gone"}, headers={"retry-after-ms": "0"})
with pytest.raises(TypeSafeAPIError):
await models(clients(handler, retry=policy))
assert len(requests) == 2 # A 404 is not retried by default; the predicate and extra exceptions opt in.
def test_retry_policy_wait_options(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("random.random", lambda: 0.0)
error = TypeSafeRateLimitError(429, {}, httpx2.Headers({"Retry-After": "5"}))
state = RetryCallState(RetryPolicy()._build_tenacity(), None, (), {})
state.set_exception((type(error), error, None))
state.attempt_number = 1
assert RetryPolicy()._wait(state) == 5.0
assert RetryPolicy(respect_retry_after=False)._wait(state) == 0.5
assert RetryPolicy(backoff_initial=0.2, respect_retry_after=False)._wait(state) == 0.2
+96
View File
@@ -0,0 +1,96 @@
from typing import get_args, get_origin, get_type_hints
import httpx2
import msgspec
import pytest
from tests.conftest import ClientFactory
from tests.helpers import system_one
from typesafe_sdk import AsyncTypeSafeClient, Choice, JSONValue, Noul, Questions, Score, TypeSafeClient
def test_json_value_and_state_exclude_top_level_none() -> None:
assert type(None) not in get_args(JSONValue)
for client in (AsyncTypeSafeClient, TypeSafeClient):
state_type = get_type_hints(client.system_one)["state"]
assert type(None) not in get_args(state_type)
text_type, object_type, array_type = get_args(state_type)
assert text_type is str
assert get_origin(object_type) is dict
key_type, value_type = get_args(object_type)
assert key_type is str
assert type(None) in get_args(value_type)
assert get_origin(array_type) is list
assert type(None) in get_args(get_args(array_type)[0])
@pytest.mark.parametrize("raw", [False, True])
async def test_array_inputs(clients: ClientFactory, raw: bool) -> None:
state: list[JSONValue | None] = [{"message": "Classify"}, None]
instructions: list[JSONValue | None] = ["Read the message", {"context": None}]
description: list[JSONValue | None] = ["Example", None]
questions: Questions
if raw:
questions = {
"yes": {"type": "noul", "instructions": instructions, "criteria": {"true": description, "false": None}},
"label": {"type": "choice", "instructions": instructions, "criteria": {"a": description, "b": None}},
"rating": {"type": "score", "instructions": instructions, "criteria": {0: description}},
}
else:
questions = {
"yes": Noul(instructions=instructions, criteria={"true": description, "false": None}),
"label": Choice(instructions=instructions, criteria={"a": description, "b": None}),
"rating": Score(instructions=instructions, criteria={0: description}),
}
def handler(request: httpx2.Request) -> httpx2.Response:
body = msgspec.json.decode(request.content)
assert body["state"] == state
assert body["questions"] == {
"yes": {"type": "noul", "instructions": instructions, "criteria": {"true": description, "false": None}},
"label": {"type": "choice", "instructions": instructions, "criteria": {"a": description, "b": None}},
"rating": {"type": "score", "instructions": instructions, "criteria": [description]},
}
return httpx2.Response(200, json={"model": "jev-latest", "usage": {}, "answers": {}})
await system_one(clients(handler), state=state, questions=questions)
async def test_raw_optional_fields_preserve_explicit_null(clients: ClientFactory) -> None:
questions: Questions = {
"yes": {"type": "noul", "instructions": None, "criteria": None},
"label": {"type": "choice", "instructions": None, "criteria": {"a": None}},
"rating": {"type": "score", "instructions": None, "criteria": ["good"]},
}
def handler(request: httpx2.Request) -> httpx2.Response:
assert msgspec.json.decode(request.content)["questions"] == questions
return httpx2.Response(200, json={"model": "jev-latest", "usage": {}, "answers": {}})
await system_one(clients(handler), state="x", questions=questions)
async def test_explicitly_nullable_json_values(clients: ClientFactory) -> None:
state: dict[str, JSONValue | None] = {"missing": None, "items": [None, {"nested": None}]}
instructions: dict[str, JSONValue | None] = {"text": "Classify", "extra": None}
def handler(request: httpx2.Request) -> httpx2.Response:
body = msgspec.json.decode(request.content)
assert body["state"] == state
assert body["questions"] == {
"yes": {"type": "noul", "instructions": instructions, "criteria": {"true": {"extra": None}}},
"label": {"type": "choice", "instructions": instructions, "criteria": {"a": None, "b": {"extra": None}}},
"rating": {"type": "score", "instructions": instructions, "criteria": [{"extra": None}]},
}
return httpx2.Response(200, json={"model": "jev-latest", "usage": {}, "answers": {}})
client = clients(handler)
questions = {
"yes": Noul(instructions=instructions, criteria={"true": {"extra": None}}),
"label": Choice(instructions=instructions, criteria={"a": None, "b": {"extra": None}}),
"rating": Score(instructions=instructions, criteria=[{"extra": None}]),
}
if isinstance(client, AsyncTypeSafeClient):
await client.system_one(state, questions)
else:
client.system_one(state, questions)
+20
View File
@@ -0,0 +1,20 @@
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
FIXTURES = Path(__file__).with_name("typing")
@pytest.mark.parametrize("path", sorted(FIXTURES.rglob("*.py")), ids=lambda path: str(path.relative_to(FIXTURES)))
def test_public_typing(path: Path) -> None:
result = subprocess.run( # noqa: S603 - Fixed interpreter and arguments; paths are repository-owned fixtures.
[sys.executable, "-m", "pyrefly", "check", "--config", str(ROOT / "pyrefly.toml"), "--expectations", str(path)],
capture_output=True,
text=True,
check=False,
timeout=30,
)
assert result.returncode == 0, result.stdout + result.stderr
+11
View File
@@ -0,0 +1,11 @@
import httpx2
from tenacity import Retrying
from typesafe_sdk import AsyncTypeSafeClient
async def invalid_arguments(client: AsyncTypeSafeClient) -> None:
await client.system_one(None, {}) # E: Argument `None` is not assignable to parameter `state`
AsyncTypeSafeClient(api_key="test", retry=Retrying()) # E: Argument `Retrying` is not assignable to parameter `retry`
AsyncTypeSafeClient(api_key="test", http_client=httpx2.Client()) # E: Argument `Client` is not assignable to parameter `http_client`
await client.system_one("x", {}, retry=Retrying()) # E: Argument `Retrying` is not assignable to parameter `retry`
+13
View File
@@ -0,0 +1,13 @@
from typesafe_sdk import ChoiceModel, NoulModel, Question, ScoreModel
choice: ChoiceModel = {"type": "noul", "instructions": "?", "criteria": {"a": None}} # E: is not assignable to TypedDict key `type`
noul: NoulModel = {"type": "choice", "instructions": "?"} # E: is not assignable to TypedDict key `type`
score: ScoreModel = {"type": "choice", "instructions": "?", "criteria": ["a"]} # E: is not assignable to TypedDict key `type`
missing_type: NoulModel = {"instructions": "?"} # E: Missing required key `type`
missing_choice_criteria: ChoiceModel = {"type": "choice"} # E: Missing required key `criteria`
missing_score_criteria: ScoreModel = {"type": "score"} # E: Missing required key `criteria`
bad_extra: NoulModel = {"type": "noul", "extra": object()} # E: is not assignable
bad_criteria: ChoiceModel = {"type": "choice", "criteria": ["a"]} # E: is not assignable
bad_question: Question = {"unrelated": "value"} # E: is not assignable
bad_question_type: Question = {"type": 123} # E: is not assignable
+11
View File
@@ -0,0 +1,11 @@
import httpx2
from tenacity import AsyncRetrying
from typesafe_sdk import TypeSafeClient
def invalid_arguments(client: TypeSafeClient) -> None:
client.system_one(None, {}) # E: Argument `None` is not assignable to parameter `state`
TypeSafeClient(api_key="test", retry=AsyncRetrying()) # E: Argument `AsyncRetrying` is not assignable to parameter `retry`
TypeSafeClient(api_key="test", http_client=httpx2.AsyncClient()) # E: Argument `AsyncClient` is not assignable to parameter `http_client`
client.system_one("x", {}, retry=AsyncRetrying()) # E: Argument `AsyncRetrying` is not assignable to parameter `retry`
+23
View File
@@ -0,0 +1,23 @@
import httpx2
from tenacity import AsyncRetrying, Retrying
from typesafe_sdk import AsyncTypeSafeClient, SystemOneResponse, TypeSafeClient
from typesafe_sdk._core.config import Config
from typesafe_sdk._core.endpoints import prepare_models
from typesafe_sdk._core.transport import send, send_async
def wrong_sync_response(config: Config, http_client: httpx2.Client, retry: Retrying) -> SystemOneResponse:
return send(http_client, retry, prepare_models(config, None, None)) # E: is not assignable to declared return type
async def wrong_async_response(config: Config, http_client: httpx2.AsyncClient, retry: AsyncRetrying) -> SystemOneResponse:
return await send_async(http_client, retry, prepare_models(config, None, None)) # E: is not assignable to declared return type
def wrong_sync_client_response(config: Config, client: TypeSafeClient) -> SystemOneResponse:
return client._request(prepare_models(config, None, None)) # E: is not assignable to declared return type
async def wrong_async_client_response(config: Config, client: AsyncTypeSafeClient) -> SystemOneResponse:
return await client._request(prepare_models(config, None, None)) # E: is not assignable to declared return type
+30
View File
@@ -0,0 +1,30 @@
import httpx2
from tenacity import AsyncRetrying, Retrying
from typing_extensions import assert_type
from typesafe_sdk import AsyncTypeSafeClient, ListModelsResponse, Noul, SystemOneResponse, TypeSafeClient
from typesafe_sdk._core.config import Config
from typesafe_sdk._core.endpoints import prepare_models, prepare_system_one
from typesafe_sdk._core.transport import Request, RequestState, send, send_async
def sync(config: Config, client: TypeSafeClient, http_client: httpx2.Client, retry: Retrying, response: httpx2.Response) -> None:
models = prepare_models(config, None, None)
system_one = prepare_system_one(config, "x", {"q": Noul(instructions="?")}, None, None, None, None)
assert_type(models, Request[ListModelsResponse])
assert_type(system_one, Request[SystemOneResponse])
assert_type(RequestState(models).parse(response), ListModelsResponse)
assert_type(RequestState(system_one).parse(response), SystemOneResponse)
assert_type(send(http_client, retry, models), ListModelsResponse)
assert_type(send(http_client, retry, system_one), SystemOneResponse)
assert_type(client._request(models), ListModelsResponse)
assert_type(client._request(system_one), SystemOneResponse)
async def asynchronous(config: Config, client: AsyncTypeSafeClient, http_client: httpx2.AsyncClient, retry: AsyncRetrying) -> None:
models = prepare_models(config, None, None)
system_one = prepare_system_one(config, "x", {"q": Noul(instructions="?")}, None, None, None, None)
assert_type(await send_async(http_client, retry, models), ListModelsResponse)
assert_type(await send_async(http_client, retry, system_one), SystemOneResponse)
assert_type(await client._request(models), ListModelsResponse)
assert_type(await client._request(system_one), SystemOneResponse)
+30
View File
@@ -0,0 +1,30 @@
from collections.abc import Mapping
import httpx2
from typing_extensions import assert_type
from typesafe_sdk import AsyncTypeSafeClient, Choice, ChoiceModel, ListModelsResponse, RetryPolicy, SystemOneResponse, TypeSafeClient
def sync(client: TypeSafeClient) -> None:
objects: dict[str, Choice] = {"q": Choice(instructions="?", criteria={"a": None})}
data: dict[str, ChoiceModel] = {"q": {"type": "choice", "instructions": "?", "criteria": {"a": None}}}
assert_type(client.system_one({"nullable": None}, objects, retry=RetryPolicy(), timeout=httpx2.Timeout(None)), SystemOneResponse)
assert_type(client.system_one("x", data, model="test", extra_headers={"x-call": "test"}), SystemOneResponse)
assert_type(
client.system_one("x", {"q": {"type": "noul", "instructions": "?", "weight": 2}}, retry=RetryPolicy(max_retries=1)), SystemOneResponse
)
assert_type(client.system_one("x", data, extra_body={"beam_width": 4, "nullable": None}), SystemOneResponse)
assert_type(client.models.list(retry=RetryPolicy(), timeout=2.0, extra_headers={"x-call": "test"}), ListModelsResponse)
TypeSafeClient(retry=RetryPolicy(max_retries=1))
async def asynchronous(client: AsyncTypeSafeClient) -> None:
objects: Mapping[str, Choice] = {"q": Choice(instructions="?", criteria={"a": None})}
data: dict[str, ChoiceModel] = {"q": {"type": "choice", "instructions": "?", "criteria": {"a": None}}}
assert_type(await client.system_one({"nullable": None}, objects, retry=RetryPolicy(), timeout=2.0), SystemOneResponse)
assert_type(await client.system_one("x", data, model="test", extra_headers={"x-call": "test"}), SystemOneResponse)
assert_type(await client.system_one("x", {"q": {"type": "noul", "instructions": "?", "weight": 2}}, retry=RetryPolicy()), SystemOneResponse)
assert_type(await client.system_one("x", data, extra_body={"beam_width": 4, "nullable": None}), SystemOneResponse)
assert_type(await client.models.list(retry=RetryPolicy(), timeout=2.0, extra_headers={"x-call": "test"}), ListModelsResponse)
AsyncTypeSafeClient(retry=RetryPolicy(max_retries=1))
Generated
+1391
View File
File diff suppressed because it is too large. Load diff