mirror of
https://github.com/aphrody-labs/typesafe-sdk-python.git
synced 2026-10-13 20:10:40 +00:00
Release v0.5.7
This commit is contained in:
1 parent
4e2be3e8ed
commit
f307bce731
63 files changed
+6382
No files matched your search
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
3.14
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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
|
||||
@@ -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", "."]
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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"})
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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."""
|
||||
@@ -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()
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Installed SDK version."""
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
__version__ = version("typesafe-sdk")
|
||||
@@ -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."""
|
||||
Whitespace-only changes.
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',
|
||||
])
|
||||
# ---
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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")
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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, {})
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
Reference in new issue
Block a user