From 2ce5c65f13646cab6e6f782328194c9d85f3300a Mon Sep 17 00:00:00 2001 From: "typesafe-public-bot[bot]" <321128928+typesafe-public-bot[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:11:48 +0000 Subject: [PATCH] Release v0.7.0 Co-authored-by: Allie Laabs Co-authored-by: Daniel Gafni --- CONTRIBUTING.md | 5 + docs/changelog.md | 14 ++ pyproject.toml | 9 +- src/typesafe_sdk/_core/client/aio/client.py | 52 +++- src/typesafe_sdk/_core/client/sync/client.py | 52 +++- src/typesafe_sdk/_core/endpoints.py | 9 +- src/typesafe_sdk/_core/json.py | 19 +- src/typesafe_sdk/_core/json_types.py | 19 +- src/typesafe_sdk/_core/question_types.py | 41 ++- src/typesafe_sdk/_core/response_types.py | 187 ++++++-------- src/typesafe_sdk/_core/schemas/__init__.py | 2 +- src/typesafe_sdk/_core/schemas/base.py | 100 ++++---- src/typesafe_sdk/_core/transport.py | 11 +- src/typesafe_sdk/_schemas/__init__.py | 7 +- src/typesafe_sdk/_schemas/models.py | 252 +++++++++++++++---- tests/test_clients.py | 44 ++-- tests/test_config.py | 8 +- tests/test_integration.py | 29 ++- tests/test_public_api_surface.py | 3 +- tests/test_pydantic_response_models.py | 131 ++++++++++ tests/test_questions.py | 60 +++-- tests/test_responses.py | 61 +++-- tests/test_retry.py | 10 +- tests/test_types.py | 48 ++-- tests/typing/negative/async_client.py | 2 + tests/typing/negative/questions.py | 2 +- tests/typing/negative/sync_client.py | 2 + tests/typing/pydantic_response_models.py | 51 ++++ tests/typing/transport.py | 4 +- tests/typing/valid.py | 21 +- uv.lock | 78 ++---- 31 files changed, 922 insertions(+), 411 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 tests/test_pydantic_response_models.py create mode 100644 tests/typing/pydantic_response_models.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..51c647f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,5 @@ +# Contributing + +We are not accepting pull requests at this moment, but we plan to move to a public development model in the future! + +Issues are welcome. diff --git a/docs/changelog.md b/docs/changelog.md index 35d6b27..64748da 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,20 @@ icon: lucide/history # Changelog +## v0.7.0 (2026-09-18) + +### Breaking Changes + +- ser/de library has been changed from `msgspec` to `pydantic` + +### Bug fixes + +- `str` subclasses are now correctly serialized as strings instead of lists of characters + +### Features + +- the `system_one` method now accepts a new `response_model` argument that can be set to a desired `pydantic` model for additional _type-safety_ + ## v0.6.0 (2026-09-15) ### Breaking Changes diff --git a/pyproject.toml b/pyproject.toml index a006981..fd7efd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "typesafe-sdk" -version = "0.6.0" +version = "0.7.0" description = "Python SDK for TypeSafe AI API." readme = "README.md" license = "MIT" @@ -34,7 +34,8 @@ classifiers = [ ] dependencies = [ "httpx2>=2.0.0", - "msgspec>=0.21.1", + "pydantic>=2.12.0", + "pydantic-core>=2.41.1", "tenacity>=9.0.0", "typing-extensions>=4.13.0", ] @@ -62,6 +63,7 @@ dev = [ "tach>=0.35.0", ] docs = [ + "griffe-pydantic>=1.3.1", "mkdocstrings[python]>=1.0.6", "ruff>=0.16.6", "zensical>=0.0.60", @@ -79,6 +81,9 @@ asyncio_mode = "auto" testpaths = ["tests"] markers = ["integration: live API tests requiring TYPESAFE_API_KEY"] +[tool.deptry] +extend_exclude = [".dagger"] + [[tool.uv.index]] name = "testpypi" url = "https://test.pypi.org/simple/" diff --git a/src/typesafe_sdk/_core/client/aio/client.py b/src/typesafe_sdk/_core/client/aio/client.py index 87c48f4..cb4e58c 100644 --- a/src/typesafe_sdk/_core/client/aio/client.py +++ b/src/typesafe_sdk/_core/client/aio/client.py @@ -3,6 +3,7 @@ from collections.abc import Mapping from functools import cached_property from types import TracebackType +from typing import overload import httpx2 from typing_extensions import Self @@ -103,6 +104,7 @@ class AsyncTypeSafeClient: """ return AsyncModels(self._config, self._http_client, self._retry) + @overload async def system_one( self, state: JSONContent, @@ -113,7 +115,35 @@ class AsyncTypeSafeClient: timeout: float | httpx2.Timeout | None = None, extra_headers: Mapping[str, str] | None = None, extra_body: Mapping[str, JSONValue | None] | None = None, - ) -> SystemOneResponse: + response_model: None = None, + ) -> SystemOneResponse: ... + + @overload + async def system_one( + self, + state: JSONContent, + 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, + response_model: type[ResponseT], + ) -> ResponseT: ... + + async def system_one( + self, + state: JSONContent, + 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, + response_model: type[ResponseT] | None = None, + ) -> SystemOneResponse | ResponseT: """Answer named questions about text or structured state. See [System One](https://docs.typesafe.ai/concepts/system-one) for details. @@ -130,14 +160,18 @@ class AsyncTypeSafeClient: `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. + response_model: Optional Pydantic `BaseModel` type describing the + JSON response body, including any nested answer models. Returns: - Answers keyed by question name, with model and token usage details. + An instance of `response_model`, or `SystemOneResponse` with answers keyed by question + name and model and token usage details when no custom model is supplied. Raises: TypeSafeError: Questions are empty or a score question's criteria list is empty. TypeSafeAPIError: The server returns an unsuccessful HTTP response after any retries. TypeSafeAPIConnectionError: The request cannot connect or times out after any retries. + TypeSafeAPIResponseValidationError: The response body does not match the response model. Examples: Create questions with named arguments: @@ -179,7 +213,19 @@ class AsyncTypeSafeClient: 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) + return await self._request( + prepare_system_one( + self._config, + state, + questions, + model, + extra_body, + timeout, + extra_headers, + SystemOneResponse if response_model is None else response_model, + ), + 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) diff --git a/src/typesafe_sdk/_core/client/sync/client.py b/src/typesafe_sdk/_core/client/sync/client.py index ff39367..2affade 100644 --- a/src/typesafe_sdk/_core/client/sync/client.py +++ b/src/typesafe_sdk/_core/client/sync/client.py @@ -3,6 +3,7 @@ from collections.abc import Mapping from functools import cached_property from types import TracebackType +from typing import overload import httpx2 from typing_extensions import Self @@ -95,6 +96,7 @@ class TypeSafeClient: """ return Models(self._config, self._http_client, self._retry) + @overload def system_one( self, state: JSONContent, @@ -105,7 +107,35 @@ class TypeSafeClient: timeout: float | httpx2.Timeout | None = None, extra_headers: Mapping[str, str] | None = None, extra_body: Mapping[str, JSONValue | None] | None = None, - ) -> SystemOneResponse: + response_model: None = None, + ) -> SystemOneResponse: ... + + @overload + def system_one( + self, + state: JSONContent, + 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, + response_model: type[ResponseT], + ) -> ResponseT: ... + + def system_one( + self, + state: JSONContent, + 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, + response_model: type[ResponseT] | None = None, + ) -> SystemOneResponse | ResponseT: """Answer named questions about text or structured state. See [System One](https://docs.typesafe.ai/concepts/system-one) for details. @@ -122,14 +152,18 @@ class TypeSafeClient: `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. + response_model: Optional Pydantic `BaseModel` type describing the JSON response body, + including any nested answer models. Returns: - Answers keyed by question name, with model and token usage details. + An instance of `response_model`, or `SystemOneResponse` with answers keyed by question + name and model and token usage details when no custom model is supplied. Raises: TypeSafeError: Questions are empty or a score question's criteria list is empty. TypeSafeAPIError: The server returns an unsuccessful HTTP response after any retries. TypeSafeAPIConnectionError: The request cannot connect or times out after any retries. + TypeSafeAPIResponseValidationError: The response body does not match the response model. Examples: Create questions with named arguments: @@ -169,7 +203,19 @@ class TypeSafeClient: 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) + return self._request( + prepare_system_one( + self._config, + state, + questions, + model, + extra_body, + timeout, + extra_headers, + SystemOneResponse if response_model is None else response_model, + ), + retry=retry, + ) def _request(self, request: Request[ResponseT], *, retry: RetryPolicy | None = None) -> ResponseT: return send(self._http_client, self._retry, request, retry) diff --git a/src/typesafe_sdk/_core/endpoints.py b/src/typesafe_sdk/_core/endpoints.py index 8847b09..c80e8a4 100644 --- a/src/typesafe_sdk/_core/endpoints.py +++ b/src/typesafe_sdk/_core/endpoints.py @@ -10,8 +10,8 @@ from typesafe_sdk._core.constants import MODELS_PATH, SYSTEM_ONE_PATH from typesafe_sdk._core.json_types import JSONContent, 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 +from typesafe_sdk._core.response_types import ListModelsResponse +from typesafe_sdk._core.transport import Request, ResponseT, prepare def prepare_system_one( @@ -22,7 +22,8 @@ def prepare_system_one( extra_body: Mapping[str, JSONValue | None] | None, timeout: float | httpx2.Timeout | None, headers: Mapping[str, str] | None, -) -> Request[SystemOneResponse]: + response_type: type[ResponseT], +) -> Request[ResponseT]: body: dict[str, Any] = { "state": state, "model": config.default_model if model is None else model, @@ -30,7 +31,7 @@ def prepare_system_one( } if extra_body is not None: body.update(extra_body) - return prepare(config, "POST", SYSTEM_ONE_PATH, body, timeout, headers, SystemOneResponse) + return prepare(config, "POST", SYSTEM_ONE_PATH, body, timeout, headers, response_type) def prepare_models(config: Config, timeout: float | httpx2.Timeout | None, headers: Mapping[str, str] | None) -> Request[ListModelsResponse]: diff --git a/src/typesafe_sdk/_core/json.py b/src/typesafe_sdk/_core/json.py index 96c0b8a..29337b0 100644 --- a/src/typesafe_sdk/_core/json.py +++ b/src/typesafe_sdk/_core/json.py @@ -1,32 +1,35 @@ -"""JSON encoding and lenient response decoding.""" +"""JSON encoding and lenient response decoding, backed by pydantic-core's fast codec.""" from collections.abc import Mapping, Sequence from typing import Any -import msgspec +from pydantic_core import from_json, to_json -def _enc_hook(value: object) -> object: +def _fallback(value: object) -> object: """Materialize abstract input containers (any `Mapping`/`Sequence`) into JSON-encodable types. Public input types are declared as `Mapping`/`Sequence` so callers may pass, for example, a - `MappingProxyType` or a tuple; msgspec natively encodes only concrete `dict`/`list`/`tuple`. + `MappingProxyType` or a custom mapping; pydantic-core's serializer encodes only concrete + containers natively, so anything else is coerced here before it is re-serialized. """ + if isinstance(value, str): + return str(value) if isinstance(value, Mapping): return dict(value) - if isinstance(value, Sequence): + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): return list(value) raise TypeError(f"Encoding objects of type {type(value).__name__} is unsupported") def serialize(value: object) -> bytes: - return msgspec.json.encode(value, enc_hook=_enc_hook) + return to_json(value, fallback=_fallback) def deserialize(content: bytes) -> Any: if not content: return None try: - return msgspec.json.decode(content) - except msgspec.DecodeError: + return from_json(content) + except ValueError: return content.decode("utf-8", errors="replace") diff --git a/src/typesafe_sdk/_core/json_types.py b/src/typesafe_sdk/_core/json_types.py index e107530..f05f9c3 100644 --- a/src/typesafe_sdk/_core/json_types.py +++ b/src/typesafe_sdk/_core/json_types.py @@ -1,10 +1,21 @@ -"""Shared JSON value types.""" +"""Shared JSON value types. + +Defined with `TypeAliasType` so the recursive aliases build a Pydantic core schema +without hitting the recursion limit that a plain recursive `TypeAlias` triggers. +""" from collections.abc import Mapping, Sequence -from typing import TypeAlias -JSONValue: TypeAlias = str | int | float | bool | Sequence["JSONValue | None"] | Mapping[str, "JSONValue | None"] +from typing_extensions import TypeAliasType + +JSONValue = TypeAliasType( + "JSONValue", + "str | int | float | bool | Sequence[JSONValue | None] | Mapping[str, JSONValue | None]", +) """A JSON-like value. May be nested and contain `None`.""" -JSONContent: TypeAlias = str | Mapping[str, JSONValue | None] | Sequence[JSONValue | None] +JSONContent = TypeAliasType( + "JSONContent", + "str | Mapping[str, JSONValue | None] | Sequence[JSONValue | None]", +) """Either a plain string or a mapping/sequence of [`JSONValue`][typesafe_sdk.JSONValue] entries.""" diff --git a/src/typesafe_sdk/_core/question_types.py b/src/typesafe_sdk/_core/question_types.py index e865095..7bac483 100644 --- a/src/typesafe_sdk/_core/question_types.py +++ b/src/typesafe_sdk/_core/question_types.py @@ -1,15 +1,17 @@ """Question objects and raw input models.""" from collections.abc import Mapping, Sequence -from typing import Literal, TypeAlias +from typing import Any, Literal, TypeAlias +from pydantic import BaseModel, ConfigDict, model_serializer +from pydantic.functional_serializers import SerializerFunctionWrapHandler from typing_extensions import NotRequired, TypedDict -from typesafe_sdk._core.json_types import JSONContent, JSONValue +from typesafe_sdk._core.json_types import JSONContent from typesafe_sdk._schemas import models as wire -class NoulCriteria(TypedDict, total=False, extra_items=JSONValue | None): +class NoulCriteria(TypedDict, total=False, closed=True): """Optional descriptions of the yes and no outcomes. See the [noul primitive](https://docs.typesafe.ai/primitives/noul) for details. @@ -21,8 +23,8 @@ class NoulCriteria(TypedDict, total=False, extra_items=JSONValue | 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. +class NoulModel(TypedDict, closed=True): + """A yes/no question dictionary with `type="noul"`. See the [noul primitive](https://docs.typesafe.ai/primitives/noul) for details. """ @@ -34,8 +36,8 @@ class NoulModel(TypedDict, extra_items=JSONValue | 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. +class ChoiceModel(TypedDict, closed=True): + """A choice question dictionary with `type="choice"`. See the [choice primitive](https://docs.typesafe.ai/primitives/choice) for details. """ @@ -47,8 +49,8 @@ class ChoiceModel(TypedDict, extra_items=JSONValue | 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. +class ScoreModel(TypedDict, closed=True): + """A score question dictionary with `type="score"`. See the [score primitive](https://docs.typesafe.ai/primitives/score) for details. """ @@ -60,36 +62,51 @@ class ScoreModel(TypedDict, extra_items=JSONValue | None): """A nonempty, ordered list of text, object, or array descriptions, one per score from zero.""" -class Noul(wire.NoulQuestion, kw_only=True, omit_defaults=True): +class _Question(BaseModel): + """Reject unknown fields and omit optional fields left at their default from the wire form.""" + + model_config = ConfigDict(extra="forbid") + + @model_serializer(mode="wrap") + def _omit_none(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + # An unset optional field (`None`) is left off the wire, while user-supplied `None` values + # nested inside `criteria`/`instructions` are preserved. + return {key: value for key, value in handler(self).items() if value is not None} + + +class Noul(_Question, wire.NoulQuestion): """A yes/no question with optional descriptions for either outcome. See the [noul primitive](https://docs.typesafe.ai/primitives/noul) for details. """ + type: Literal["noul"] = "noul" instructions: JSONContent | 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): +class Choice(_Question, wire.ChoiceQuestion): """A question that selects between named alternatives. See the [choice primitive](https://docs.typesafe.ai/primitives/choice) for details. """ + type: Literal["choice"] = "choice" criteria: Mapping[str, JSONContent | None] # pyrefly: ignore[bad-override-mutable-attribute] """Labels mapped to text, object, or array descriptions, or `None` for undescribed labels.""" instructions: JSONContent | 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): +class Score(_Question, wire.ScoreQuestion): """A question that assigns a score using an ordered rubric. See the [score primitive](https://docs.typesafe.ai/primitives/score) for details. """ + type: Literal["score"] = "score" criteria: Sequence[JSONContent] # pyrefly: ignore[bad-override-mutable-attribute] """A nonempty, ordered list of text, object, or array descriptions, one per score from zero.""" instructions: JSONContent | None = None # pyrefly: ignore[bad-override-mutable-attribute] diff --git a/src/typesafe_sdk/_core/response_types.py b/src/typesafe_sdk/_core/response_types.py index ab7644d..236b264 100644 --- a/src/typesafe_sdk/_core/response_types.py +++ b/src/typesafe_sdk/_core/response_types.py @@ -1,117 +1,102 @@ """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. +The wire models in `typesafe_sdk._schemas.models` mirror the OpenAPI schema. The public answer types +subclass them, adding immutability and integer-keyed score maps. Because the answer models are tagged, +the public `Answer` union validates by discriminator; per-answer dispatch is kept only to skip answer +kinds a future API adds without failing the whole response. """ from functools import cached_property -from typing import Any, TypeAlias +from typing import Annotated, Any, Literal, TypeAlias import httpx2 -import msgspec -from msgspec import field +from pydantic import ConfigDict, Field, ValidationError from typing_extensions import Self, override +from typesafe_sdk._core.json import deserialize, serialize from typesafe_sdk._core.logging import logger -from typesafe_sdk._core.schemas.base import Response, field_path, validation_error +from typesafe_sdk._core.schemas.base import Response, Schema, format_error_path, format_path, validation_error from typesafe_sdk._schemas import models as wire -from typesafe_sdk._schemas.models import ModelMetadata -# The generated wire fields are mutable; the public answer overrides intentionally make them read-only. -class NoulAnswer(wire.NoulAnswer, frozen=True, kw_only=True): +class NoulAnswer(wire.NoulAnswer): """A yes/no answer. See the [noul primitive](https://docs.typesafe.ai/primitives/noul) for details. """ - noul: float # pyrefly: ignore[bad-override] - """Probability of a yes answer, from zero to one.""" + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) + + type: Literal["noul"] = "noul" # pyrefly: ignore[bad-override] -class ChoiceAnswer(wire.ChoiceAnswer, frozen=True, kw_only=True): +class ChoiceAnswer(wire.ChoiceAnswer): """A selected label and its probabilities. See the [choice primitive](https://docs.typesafe.ai/primitives/choice) for details. """ - choice: str # pyrefly: ignore[bad-override] - """The selected label.""" - confidence: float # pyrefly: ignore[bad-override] - """Reported confidence in the selected label.""" - probabilities: dict[str, float] # pyrefly: ignore[bad-override] - """Probabilities keyed by label.""" + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) + + type: Literal["choice"] = "choice" # pyrefly: ignore[bad-override] -class ScoreAnswer(wire.ScoreAnswer, frozen=True, kw_only=True): +class ScoreAnswer(wire.ScoreAnswer): """An expected score with its rubric and probabilities. See the [score primitive](https://docs.typesafe.ai/primitives/score) for details. """ - score: float # pyrefly: ignore[bad-override] - """Expected score, which may fall between the integer rubric levels.""" - confidence: float # pyrefly: ignore[bad-override] - """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. + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) + + type: Literal["score"] = "score" # pyrefly: ignore[bad-override] + # JSON object keys are strings; `dict[int, ...]` tells Pydantic to coerce them to the integer score + # levels. `Any` (not the recursive JSON value) keeps the nested values decodable. legend: dict[int, str | dict[str, Any] | list[Any]] # pyrefly: ignore[bad-override] """Rubric descriptions keyed by integer score.""" probabilities: dict[int, float] # pyrefly: ignore[bad-override] """Probabilities keyed by integer score.""" -Answer: TypeAlias = NoulAnswer | ChoiceAnswer | ScoreAnswer +Answer: TypeAlias = Annotated[NoulAnswer | ChoiceAnswer | ScoreAnswer, Field(discriminator="type")] """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): +class Usage(wire.Usage): """Token counts for a request, when reported by the API.""" - input_tokens: int | None = None + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) + + input_tokens: int | None = None # pyrefly: ignore[bad-override] """Number of input tokens used, or `None` when the API did not report it.""" - output_tokens: int | None = None + output_tokens: int | None = None # pyrefly: ignore[bad-override] """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 +_ANSWER_TYPES = {"noul", "choice", "score"} -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] +def _prepare_system_one_response(response: httpx2.Response, answer_fields: set[str]) -> dict[str, Any]: + """Validate answers, drop future types, and lift declared answer fields into top-level keys.""" + decoded = deserialize(response.content) + if not isinstance(decoded, dict): + raise validation_error(response, "") + answers = decoded.get("answers") + if isinstance(answers, dict): + for name, raw in list(answers.items()): + if not isinstance(raw, dict) or not isinstance(raw.get("type"), str): + raise validation_error(response, f"answers.{name}.type") + if raw["type"] not in _ANSWER_TYPES: + # 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, raw["type"]) + del answers[name] + for answer_field in answer_fields & answers.keys(): + decoded[answer_field] = answers[answer_field] + return decoded -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): +class SystemOneResponse(Response): """Answers grouped by question type with model and usage metadata. See [System One](https://docs.typesafe.ai/concepts/system-one) for details. @@ -121,50 +106,9 @@ class SystemOneResponse(Response, frozen=True, kw_only=True): """The model used to answer the request.""" usage: Usage """Token usage for the request.""" - answers: dict[str, Answer] = field(default_factory=dict) + 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.""" @@ -180,8 +124,33 @@ class SystemOneResponse(Response, frozen=True, kw_only=True): """Score answers keyed by question name.""" return {name: answer for name, answer in self.answers.items() if isinstance(answer, ScoreAnswer)} + @classmethod + @override + def _decode(cls, response: httpx2.Response) -> Self: + answer_fields = set(cls.model_fields) - set(SystemOneResponse.model_fields) + decoded = _prepare_system_one_response(response, answer_fields) + try: + return cls.model_validate_json(serialize(decoded)) + except ValidationError as error: + location = list(error.errors(include_url=False)[0]["loc"]) + match location: + case ["answers", _, answer_type, *_] if answer_type in _ANSWER_TYPES: + del location[2] + raise validation_error(response, format_path(location)) from error -class ListModelsResponse(Response, frozen=True, kw_only=True): + +class ModelMetadata(Schema): + """Metadata describing a single available model.""" + + name: str + """Model name or alias accepted by a request's model field.""" + description: str + """Human-readable description of the model and its capabilities.""" + release_date: str + """Model release date, formatted as YYYY-MM-DD.""" + + +class ListModelsResponse(Response): """The models available to the account.""" models: tuple[ModelMetadata, ...] @@ -190,5 +159,7 @@ class ListModelsResponse(Response, frozen=True, kw_only=True): @classmethod @override def _decode(cls, response: httpx2.Response) -> Self: - body = msgspec.json.decode(response.content, type=wire.ModelMetadataList) - return cls(models=tuple(body.models)) + try: + return cls.model_validate_json(response.content) + except ValidationError as error: + raise validation_error(response, format_error_path((), error)) from error diff --git a/src/typesafe_sdk/_core/schemas/__init__.py b/src/typesafe_sdk/_core/schemas/__init__.py index 6f7c180..ee7735c 100644 --- a/src/typesafe_sdk/_core/schemas/__init__.py +++ b/src/typesafe_sdk/_core/schemas/__init__.py @@ -1 +1 @@ -"""msgspec structs for API responses.""" +"""Base models for API responses.""" diff --git a/src/typesafe_sdk/_core/schemas/base.py b/src/typesafe_sdk/_core/schemas/base.py index e6c8dcd..785ac1a 100644 --- a/src/typesafe_sdk/_core/schemas/base.py +++ b/src/typesafe_sdk/_core/schemas/base.py @@ -1,37 +1,47 @@ -"""Base msgspec structs shared by the response schemas.""" +"""Response schemas and decoding shared by the SDK response types and custom Pydantic models.""" -import re +from collections.abc import Sequence from functools import cached_property +from typing import Any, TypeVar, cast import httpx2 -import msgspec +from pydantic import BaseModel, ConfigDict, ValidationError 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.""" +ResponseT = TypeVar("ResponseT", bound=BaseModel) -_ERROR_AT = re.compile(r"`\$(?P[^`]*)`") -_ERROR_MISSING = re.compile(r"missing required field `(?P[^`]+)`") +class Schema(BaseModel): + """Base type for immutable response objects that tolerate unknown fields.""" + + model_config = ConfigDict(extra="ignore", frozen=True, strict=True) -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] - if (missing := _ERROR_MISSING.search(message)) is not None: - segments.append(missing.group("field")) - return ".".join((*prefix, *segments)) +def format_path(segments: Sequence[str | int]) -> str: + """Render path segments as the SDK's dotted `field_path`.""" + path = "" + for segment in segments: + if segment == "[key]": + # Pydantic marks an invalid mapping key with a synthetic `[key]` segment; it is noise here. + continue + if isinstance(segment, int): + path += f"[{segment}]" + else: + path += f".{segment}" if path else str(segment) + return path + + +def format_error_path(prefix: Sequence[str | int], error: ValidationError) -> str: + """Render the first Pydantic error location as the SDK's dotted `field_path`. + + Integer locations become bracketed indices (`models[1].name`); string locations are dotted. + """ + first = error.errors(include_url=False)[0]["loc"] + return format_path((*prefix, *first)) def _request_endpoint(response: httpx2.Response) -> str | None: @@ -50,19 +60,8 @@ def validation_error(response: httpx2.Response, path: str) -> TypeSafeAPIRespons ) -# 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``.""" - - def __copy__(self) -> Self: - """Copy response fields and runtime metadata.""" - result = msgspec.structs.replace(self) - result.__dict__.update(self.__dict__) - return result - - def __reduce__(self) -> tuple[object, ...]: - """Preserve runtime metadata during deep copies and pickling.""" - return (*super().__reduce__(), self.__dict__) +class _ResponseMixin: + """HTTP metadata and custom decoding shared by the SDK response types.""" @classmethod def from_http_response(cls, response: httpx2.Response) -> Self: @@ -71,16 +70,7 @@ class Response(Schema, frozen=True, kw_only=True, dict=True): 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, _request_endpoint(response)) - try: - result = cls._decode(response) - except msgspec.DecodeError as error: - raise validation_error(response, field_path((), error)) from error - # Transport metadata is runtime state, not part of the serializable response schema. - result.__dict__["_request_id"] = response.headers.get(REQUEST_ID_HEADER) - result.__dict__["_raw"] = response - return result + return cast(Self, parse_response(response, cast(Any, cls))) @classmethod def _decode(cls, response: httpx2.Response) -> Self: @@ -102,3 +92,27 @@ class Response(Schema, frozen=True, kw_only=True, dict=True): if response is None: raise TypeSafeError("The response was not created from a raw HTTP response.") return response + + +class Response(Schema, _ResponseMixin): + """A response object with its originating HTTP response attached.""" + + +def parse_response(response: httpx2.Response, response_type: type[ResponseT]) -> ResponseT: + """Decode an SDK response or custom Pydantic model with consistent API and validation errors.""" + if not response.is_success: + raise api_error(response.status_code, deserialize(response.content), response.headers, _request_endpoint(response)) + if issubclass(response_type, _ResponseMixin): + result = response_type._decode(response) # noqa: SLF001 - Dispatch to the SDK response decoder. + else: + try: + result = response_type.model_validate_json(response.content) + except ValidationError as error: + raise validation_error(response, format_error_path((), error)) from error + if isinstance(result, _ResponseMixin): + # Transport metadata is runtime state, not part of the serializable response schema, so it is + # kept in ``__dict__`` where ``model_dump`` and field iteration never see it. + result.__dict__["_request_id"] = response.headers.get(REQUEST_ID_HEADER) + result.__dict__["_raw"] = response + # The subclass check narrows ResponseT to Response, but decoding preserves the concrete type. + return cast(ResponseT, result) diff --git a/src/typesafe_sdk/_core/transport.py b/src/typesafe_sdk/_core/transport.py index a585447..b444b2b 100644 --- a/src/typesafe_sdk/_core/transport.py +++ b/src/typesafe_sdk/_core/transport.py @@ -7,10 +7,10 @@ import time from collections.abc import Generator, Mapping from contextlib import contextmanager from dataclasses import dataclass -from typing import Generic, TypeVar +from typing import Generic import httpx2 -import msgspec +from pydantic_core import PydanticSerializationError from tenacity import AsyncRetrying, Retrying from typesafe_sdk._core.config import Config, resolve_timeout @@ -30,11 +30,10 @@ from typesafe_sdk._core.errors import TypeSafeAPIConnectionError, TypeSafeAPITim 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._core.schemas.base import ResponseT, parse_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) @@ -94,7 +93,7 @@ class RequestState(Generic[ResponseT]): response.headers.get(REQUEST_ID_HEADER, "-"), ) self._log_wire("<-", response.headers, response.content) - return request.response_type.from_http_response(response) + return parse_response(response, request.response_type) def prepare( @@ -135,7 +134,7 @@ def prepare( def _encode_body(body: object) -> bytes: try: return serialize(body) - except (msgspec.EncodeError, TypeError) as error: + except (PydanticSerializationError, TypeError, ValueError) as error: raise TypeSafeError("The request body could not be encoded as JSON") from error diff --git a/src/typesafe_sdk/_schemas/__init__.py b/src/typesafe_sdk/_schemas/__init__.py index 72d9a90..e894957 100644 --- a/src/typesafe_sdk/_schemas/__init__.py +++ b/src/typesafe_sdk/_schemas/__init__.py @@ -1,6 +1 @@ -"""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. -""" +"""Generated Pydantic wire schemas, mirroring the API's OpenAPI spec.""" diff --git a/src/typesafe_sdk/_schemas/models.py b/src/typesafe_sdk/_schemas/models.py index 4b9ca8e..1251e93 100644 --- a/src/typesafe_sdk/_schemas/models.py +++ b/src/typesafe_sdk/_schemas/models.py @@ -2,88 +2,234 @@ # filename: https://api.typesafe.ai/openapi.json from __future__ import annotations -from typing import Any, TypeAlias -from msgspec import Struct, UNSET, UnsetType + +from typing import Any, Literal + +from pydantic import BaseModel, Field, RootModel -class ChoiceAnswer(Struct, tag_field="type", tag="choice"): - choice: str - confidence: float - probabilities: dict[str, float] +class ChoiceAnswer(BaseModel): + type: Literal["choice"] = Field(..., description="Identifies a selection from the requested choices.", examples=["choice"], title="Type") + choice: str = Field( + ..., + description="The name of the choice with the highest probability among the question's criteria.", + examples=["angry"], + title="Choice", + ) + confidence: float = Field( + ..., + description="Confidence in the selected choice, from 0 to 1. Higher values indicate greater certainty; use lower values to flag uncertain selections for review.", + examples=[0.9], + title="Confidence", + ) + probabilities: dict[str, float] = Field( + ..., + description="Probability of each choice in criteria, keyed by choice name, from 0 to 1. Shows how likely the alternatives are; values sum to approximately 1.", + examples=[{"angry": 0.8, "calm": 0.1, "excited": 0.1}], + title="Probabilities", + ) -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 ChoiceQuestion(BaseModel): + type: Literal["choice"] = Field( + ..., description="Identifies a question that selects one of the choices in criteria.", examples=["choice"], title="Type" + ) + instructions: str | dict[str, Any] | list[Any] | None = Field( + None, + description="What the model should decide when choosing an option.", + examples=["What is the tone of this message?"], + title="Instructions", + ) + criteria: dict[str, str | dict[str, Any] | list[Any] | None] = Field( + ..., + description="Choice names and descriptions of when each applies. A choice without a description is interpreted by its name alone.", + examples=[ + {"angry": "An upset or hostile message", "calm": "A neutral or polite message", "excited": "An enthusiastic or eager message"} + ], + title="Criteria", + ) -class ModelMetadata(Struct): - name: str - description: str - release_date: str +class ModelMetadata(BaseModel): + name: str = Field(..., description="Model name or alias accepted by the request's model field.", examples=["jev-latest"], title="Name") + description: str = Field( + ..., + description="Human-readable description of the model and its capabilities.", + examples=["General-purpose system one model."], + title="Description", + ) + release_date: str = Field(..., description="Model release date, formatted as YYYY-MM-DD.", examples=["2026-09-15"], title="Release Date") -class ModelMetadataList(Struct): - models: list[ModelMetadata] +class ModelMetadataList(BaseModel): + models: list[ModelMetadata] = Field( + ..., + description="Available models and aliases. Use a model's name in POST /v1/systemone requests.", + examples=[[{"description": "General-purpose system one model.", "name": "jev-latest", "release_date": "2026-09-15"}]], + title="Models", + ) -class NoulAnswer(Struct, tag_field="type", tag="noul"): - noul: float +class NoulAnswer(BaseModel): + type: Literal["noul"] = Field(..., description="Identifies a yes/no answer.", examples=["noul"], title="Type") + noul: float = Field( + ..., + description="Probability of a yes answer or a true statement, from 0 to 1. Values near 1 favor yes or true, values near 0 favor no or false, and values near 0.5 indicate uncertainty.", + examples=[0.98], + title="Noul", + ) -class NoulCriteria(Struct): - true: str | dict[str, Any] | list[Any] | None | UnsetType = UNSET - false: str | dict[str, Any] | list[Any] | None | UnsetType = UNSET +class NoulCriteria(BaseModel): + true: str | dict[str, Any] | list[Any] | None = Field( + None, description="What counts as a yes answer.", examples=["The message is unsolicited advertising."], title="True" + ) + false: str | dict[str, Any] | list[Any] | None = Field( + None, description="What counts as a no answer.", examples=["The message is a legitimate conversation."], title="False" + ) -class NoulQuestion(Struct, tag_field="type", tag="noul"): - instructions: str | dict[str, Any] | list[Any] | None | UnsetType = UNSET - criteria: NoulCriteria | None | UnsetType = UNSET +class NoulQuestion(BaseModel): + type: Literal["noul"] = Field(..., description="Identifies a yes/no question or statement.", examples=["noul"], title="Type") + instructions: str | dict[str, Any] | list[Any] | None = Field( + None, + description="The yes/no question or statement to evaluate.", + examples=["Is this message spam?", "This message contains unsolicited advertising.", {"task": "Identify unsolicited advertising."}], + title="Instructions", + ) + criteria: NoulCriteria | None = Field( + None, + description="Criteria clarifying what counts as a yes or no answer.", + examples=[{"false": "A legitimate conversation", "true": "Unsolicited advertising"}], + ) -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 ScoreAnswer(BaseModel): + type: Literal["score"] = Field(..., description="Identifies a rating against the requested score levels.", examples=["score"], title="Type") + score: float = Field( + ..., + description="Expected score: the probability-weighted average of the rubric levels. May fall between integer levels.", + examples=[1.7], + title="Score", + ) + confidence: float = Field( + ..., + description="Confidence in the score, from 0 to 1. Higher values indicate greater certainty; use lower values to flag uncertain ratings for review.", + examples=[0.9], + title="Confidence", + ) + legend: dict[str, str | dict[str, Any] | list[Any]] = Field( + ..., + description="The requested criteria mapped to their score levels, so you can interpret the score.", + examples=[{"0": "Can wait", "1": "Needs attention this week", "2": "Needs attention today"}], + title="Legend", + ) + probabilities: dict[str, float] = Field( + ..., + description="Probability of each score level, from 0 to 1, using the same keys as legend. Shows how likely the alternatives are; values sum to approximately 1.", + examples=[{"0": 0.1, "1": 0.1, "2": 0.8}], + title="Probabilities", + ) -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 ScoreQuestion(BaseModel): + type: Literal["score"] = Field( + ..., description="Identifies a question that rates the content using the levels in criteria.", examples=["score"], title="Type" + ) + instructions: str | dict[str, Any] | list[Any] | None = Field( + None, description="What the model should rate.", examples=["How urgent is this message?"], title="Instructions" + ) + criteria: list[str | dict[str, Any] | list[Any]] = Field( + ..., + description="Ordered descriptions of the score levels. Each description's position determines its score, starting at zero.", + examples=[["Can wait", "Needs attention this week", "Needs attention today"]], + min_length=1, + title="Criteria", + ) -class Usage(Struct): - billing_units: int - input_tokens: int | None | UnsetType = UNSET - output_tokens: int | None | UnsetType = UNSET +class Usage(BaseModel): + input_tokens: int = Field( + ..., description="Number of billable input tokens used to evaluate the request.", examples=[120], title="Input Tokens" + ) + output_tokens: int = Field( + ..., + description="Number of output tokens used to answer the questions. Output tokens are currently free of charge.", + examples=[12], + title="Output Tokens", + ) -class ValidationError(Struct): - loc: list[str | int] - msg: str - type: str - input: Any | UnsetType = UNSET - ctx: dict[str, Any] | UnsetType = UNSET +class ValidationError(BaseModel): + loc: list[str | int] = Field( + ..., + description="Path to the invalid value: the request location followed by field names and array indices.", + examples=[["body", "questions", "urgency", "score", "criteria"]], + title="Location", + ) + msg: str = Field(..., description="Human-readable explanation of the validation failure.", examples=["Field required"], title="Message") + type: str = Field(..., description="Machine-readable validation error code.", examples=["missing"], title="Error Type") + input: Any | None = Field(None, description="The input value that failed validation.", examples=[{"type": "score"}], title="Input") + ctx: dict[str, Any] | None = Field( + None, description="Additional context used to explain the validation failure.", examples=[{"min_length": 1}], title="Context" + ) -Answer: TypeAlias = NoulAnswer | ScoreAnswer | ChoiceAnswer +class Answer(RootModel[NoulAnswer | ScoreAnswer | ChoiceAnswer]): + root: NoulAnswer | ScoreAnswer | ChoiceAnswer = Field( + ..., description="An answer whose type matches the corresponding question.", discriminator="type" + ) -class HTTPValidationError(Struct): - detail: list[ValidationError] | UnsetType = UNSET +class HTTPValidationError(BaseModel): + detail: list[ValidationError] | None = Field( + None, + description="Validation errors describing which request values are missing or invalid.", + examples=[[{"loc": ["body", "state"], "msg": "Field required", "type": "missing"}]], + title="Detail", + ) -Question: TypeAlias = NoulQuestion | ChoiceQuestion | ScoreQuestion +class Question(RootModel[NoulQuestion | ChoiceQuestion | ScoreQuestion]): + root: NoulQuestion | ChoiceQuestion | ScoreQuestion = Field(..., description="A question about the supplied content.", discriminator="type") -class SystemOneRequest(Struct): - state: str | dict[str, Any] | list[Any] - model: str - questions: dict[str, Question] +class SystemOneRequest(BaseModel): + state: str | dict[str, Any] | list[Any] = Field( + ..., + description="The content all questions in this request refer to.", + examples=["I was charged twice. Please help.", {"message": "Please help.", "subject": "Duplicate charge"}], + title="State", + ) + model: str = Field( + ..., + description="Name or alias of the model to use. Available names are returned by GET /v1/models.", + examples=["jev-latest"], + title="Model", + ) + questions: dict[str, Question] = Field( + ..., + description="Questions to ask about the content, each with a name you choose. The response uses those names to identify the answers.", + examples=[{"billing": {"instructions": "Is this message about billing?", "type": "noul"}}], + min_length=1, + title="Questions", + ) -class SystemOneResponse(Struct): - model: str - answers: dict[str, Answer] - usage: Usage +class SystemOneResponse(BaseModel): + model: str = Field( + ..., + description="Name of the model that answered the questions. May differ from the alias supplied in the request.", + examples=["jev-latest"], + title="Model", + ) + answers: dict[str, Answer] = Field( + ..., + description="Answers keyed by the question names supplied in the request. Each answer's type matches its question's type.", + examples=[{"billing": {"noul": 0.98, "type": "noul"}}], + min_length=1, + title="Answers", + ) + usage: Usage = Field( + ..., description="Input and output token counts for this evaluation.", examples=[{"input_tokens": 120, "output_tokens": 12}] + ) diff --git a/tests/test_clients.py b/tests/test_clients.py index 9d9a504..18ece30 100644 --- a/tests/test_clients.py +++ b/tests/test_clients.py @@ -4,8 +4,9 @@ from importlib.metadata import version from typing import Any, cast import httpx2 -import msgspec import pytest +from pydantic import ValidationError +from pydantic_core import from_json, to_json from typing_extensions import assert_type from tests.conftest import ClientFactory @@ -86,9 +87,9 @@ async def test_round_trip(clients: ClientFactory, question_form: str) -> None: 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 from_json(request.content) == expected assert request.headers["content-type"] == "application/json" - return httpx2.Response(200, content=msgspec.json.encode(RESULT)) + return httpx2.Response(200, content=to_json(RESULT)) result = await system_one( clients(handler), @@ -121,7 +122,7 @@ async def test_round_trip(clients: ClientFactory, question_form: str) -> None: 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): + with pytest.raises(ValidationError): cast(Any, result).model = "other" @@ -135,8 +136,8 @@ async def test_extra_body_shallow_override(clients: ClientFactory) -> None: } def handler(request: httpx2.Request) -> httpx2.Response: - assert msgspec.json.decode(request.content) == expected - return httpx2.Response(200, content=msgspec.json.encode(RESULT)) + assert from_json(request.content) == expected + return httpx2.Response(200, content=to_json(RESULT)) await system_one( clients(handler), @@ -161,31 +162,36 @@ async def test_unserializable_request_body_raises(clients: ClientFactory) -> Non 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}, - } + # Explicit escape hatch for fields introduced by the API before this SDK models them. + questions = cast( + 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)) + assert from_json(request.content) == expected + return httpx2.Response(200, content=to_json(RESULT)) await system_one(clients(handler), state="hi", questions=questions) +# Raw dictionary questions are passed through untouched: the SDK leaves their schema validation to +# the API. (Typed `Noul`/`Choice`/`Score` objects instead validate eagerly on construction.) @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) + assert from_json(request.content)["questions"]["q"] == question return httpx2.Response(422, json={"detail": "Invalid question"}) with pytest.raises(TypeSafeUnprocessableEntityError, match="Invalid question"): @@ -205,7 +211,7 @@ async def test_rich_descriptions(clients: ClientFactory) -> None: } def handler(request: httpx2.Request) -> httpx2.Response: - assert msgspec.json.decode(request.content) == expected + assert from_json(request.content) == expected return httpx2.Response( 200, json={ @@ -242,14 +248,14 @@ async def test_models_shape(clients: ClientFactory) -> None: assert request.method == "GET" assert request.url.path == "/v1/models" assert request.content == b"" - return httpx2.Response(200, content=msgspec.json.encode({"models": [CARD]})) + return httpx2.Response(200, content=to_json({"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]}))) + client = clients(lambda request: httpx2.Response(200, content=to_json({"models": [card]}))) response = await client.models.list() if isinstance(client, AsyncTypeSafeClient) else client.models.list() [model] = response.models assert model.name == "jev-latest" @@ -327,7 +333,7 @@ async def test_error_mapping(clients: ClientFactory, status: int, error: type[Ty ], ) async def test_error_messages(clients: ClientFactory, body: object, message: str) -> None: - content = body.encode() if isinstance(body, str) else msgspec.json.encode(body) + content = body.encode() if isinstance(body, str) else to_json(body) with pytest.raises(TypeSafeAPIError, match="400") as caught: await models(clients(lambda request: httpx2.Response(400, content=content))) assert str(caught.value) == f"GET https://api.typesafe.ai/v1/models: 400 {message}" diff --git a/tests/test_config.py b/tests/test_config.py index 3b2ec32..1a97829 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,6 @@ import httpx2 -import msgspec import pytest +from pydantic_core import from_json from tests.conftest import ClientFactory from tests.helpers import models, system_one @@ -28,7 +28,7 @@ async def test_transport_and_http_client_mutually_exclusive(clients: ClientFacto @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") + assert from_json(request.content)["model"] == (model or "client-model") return httpx2.Response(200, json=RESULT) await system_one( @@ -52,7 +52,7 @@ async def test_resolution(clients: ClientFactory, monkeypatch: pytest.MonkeyPatc 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 from_json(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) @@ -80,7 +80,7 @@ async def test_empty_env_unset(clients: ClientFactory, monkeypatch: pytest.Monke 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" + assert from_json(request.content)["model"] == "jev-latest" return httpx2.Response(200, json=RESULT) await system_one(clients(handler), state="x", questions={"q": {"type": "noul", "instructions": "?"}}) diff --git a/tests/test_integration.py b/tests/test_integration.py index d8ae267..8d91d2a 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,11 +2,17 @@ import pytest from tests.conftest import Client from tests.helpers import models, system_one -from typesafe_sdk import Choice, Score +from typesafe_sdk import AsyncTypeSafeClient, Choice, ChoiceAnswer, NoulAnswer, Questions, Score, ScoreAnswer, SystemOneResponse pytestmark = pytest.mark.integration +class PydanticQuestionsResponse(SystemOneResponse): + billing: NoulAnswer + tone: ChoiceAnswer + urgency: ScoreAnswer + + async def test_live_models(live_client: Client) -> None: available = await models(live_client) assert available @@ -40,3 +46,24 @@ async def test_live_questions(live_client: Client) -> None: 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) + + +async def test_live_pydantic_response(live_client: Client) -> None: + state = {"subject": "Charged twice this month", "body": "I see two charges of $49. Please fix this ASAP."} + questions: Questions = { + "billing": {"type": "noul", "instructions": "Is this ticket about billing?"}, + "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=["can wait", "this week", "today"]), + } + if isinstance(live_client, AsyncTypeSafeClient): + result = await live_client.system_one(state, questions, response_model=PydanticQuestionsResponse) + else: + result = live_client.system_one(state, questions, response_model=PydanticQuestionsResponse) + + assert result.billing == result.nouls["billing"] + assert result.tone == result.choices["tone"] + assert result.urgency == result.scores["urgency"] + assert 0 <= result.billing.noul <= 1 + assert result.tone.choice in {"calm", "frustrated", "angry"} + assert 0 <= result.urgency.score <= 2 + assert result.request_id diff --git a/tests/test_public_api_surface.py b/tests/test_public_api_surface.py index 8797216..072e005 100644 --- a/tests/test_public_api_surface.py +++ b/tests/test_public_api_surface.py @@ -10,7 +10,8 @@ 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 + members = module.__all__ if module is typesafe_sdk else (name for name in vars(module) if not name.startswith("_")) + assert sorted(members) == snapshot def test_package_exports() -> None: diff --git a/tests/test_pydantic_response_models.py b/tests/test_pydantic_response_models.py new file mode 100644 index 0000000..ba793c4 --- /dev/null +++ b/tests/test_pydantic_response_models.py @@ -0,0 +1,131 @@ +from typing import Literal + +import httpx2 +import pytest +from pydantic import BaseModel, ConfigDict + +from tests.conftest import ClientFactory +from tests.test_clients import RESULT +from typesafe_sdk import ( + AsyncTypeSafeClient, + Noul, + NoulAnswer, + SystemOneResponse, + TypeSafeAPIResponseValidationError, + TypeSafeBadRequestError, +) + + +class KnownAnswers(BaseModel): + spam: NoulAnswer + + +class KnownResponse(BaseModel): + model: str + answers: KnownAnswers + + +class ToneProbabilities(BaseModel): + friendly: float + hostile: float + + +class Tone(BaseModel): + model_config = ConfigDict(extra="ignore", frozen=True) + + choice: Literal["friendly", "hostile"] + probabilities: ToneProbabilities + + +class TypedSystemOneResponse(SystemOneResponse): + spam: NoulAnswer + tone: Tone + missing: NoulAnswer | None = None + + +@pytest.mark.parametrize("extra_answer_fields", [{}, {"explanation": "spammy"}]) +async def test_standalone_pydantic_response_model(clients: ClientFactory, extra_answer_fields: dict[str, str]) -> None: + body = {**RESULT, "answers": {**RESULT["answers"], "spam": {**RESULT["answers"]["spam"], **extra_answer_fields}}} + client = clients(lambda request: httpx2.Response(200, json=body)) + if isinstance(client, AsyncTypeSafeClient): + result = await client.system_one("x", {"spam": Noul()}, response_model=KnownResponse) + else: + result = client.system_one("x", {"spam": Noul()}, response_model=KnownResponse) + assert type(result) is KnownResponse + assert result.model == "jev-latest" + assert result.answers.spam.noul == 0.98 + + +@pytest.mark.parametrize("response_model", [None, SystemOneResponse]) +async def test_explicit_default_response_model(clients: ClientFactory, response_model: type[SystemOneResponse] | None) -> None: + client = clients(lambda request: httpx2.Response(200, json=RESULT, headers={"x-typesafe-request-id": "req-default"})) + if isinstance(client, AsyncTypeSafeClient): + result = await client.system_one("x", {"spam": Noul()}, response_model=response_model) + else: + result = client.system_one("x", {"spam": Noul()}, response_model=response_model) + assert isinstance(result, SystemOneResponse) + assert result.nouls["spam"].noul == 0.98 + assert result.request_id == "req-default" + assert result.raw_http_response.json() == RESULT + + +async def test_pydantic_system_one_response_subclass(clients: ClientFactory) -> None: + body = {**RESULT, "answers": {**RESULT["answers"], "future": {"type": "future", "value": 1}}} + client = clients(lambda request: httpx2.Response(200, json=body, headers={"x-typesafe-request-id": "req-pydantic"})) + if isinstance(client, AsyncTypeSafeClient): + result = await client.system_one("x", {"spam": Noul()}, response_model=TypedSystemOneResponse) + else: + result = client.system_one("x", {"spam": Noul()}, response_model=TypedSystemOneResponse) + assert type(result) is TypedSystemOneResponse + assert result.spam.noul == 0.98 + assert result.tone.choice == "friendly" + assert result.tone.probabilities.friendly == 0.9 + assert result.missing is None + assert result.nouls["spam"].noul == 0.98 + assert result.choices["tone"].choice == "friendly" + assert result.scores["quality"].score == 1.7 + assert "future" not in result.answers + assert result.request_id == "req-pydantic" + assert result.raw_http_response.json() == body + assert "_raw" not in result.model_dump() + assert "_request_id" not in result.model_dump() + + +@pytest.mark.parametrize( + "response_model,body,field_path", + [ + (KnownResponse, {"model": "test", "answers": {"spam": {"type": "noul"}}}, "answers.spam.noul"), + ( + TypedSystemOneResponse, + {**RESULT, "answers": {**RESULT["answers"], "tone": {**RESULT["answers"]["tone"], "choice": "unknown"}}}, + "tone.choice", + ), + ], +) +async def test_pydantic_response_validation( + clients: ClientFactory, response_model: type[BaseModel], body: dict[str, object], field_path: str +) -> None: + client = clients(lambda request: httpx2.Response(200, json=body, headers={"x-typesafe-request-id": "req-invalid"})) + if isinstance(client, AsyncTypeSafeClient): + with pytest.raises(TypeSafeAPIResponseValidationError) as caught: + await client.system_one("x", {"spam": Noul()}, response_model=response_model) + else: + with pytest.raises(TypeSafeAPIResponseValidationError) as caught: + client.system_one("x", {"spam": Noul()}, response_model=response_model) + assert caught.value.field_path == field_path + assert caught.value.request_id == "req-invalid" + assert caught.value.body == body + + +async def test_custom_response_preserves_api_errors(clients: ClientFactory) -> None: + body = {"detail": "Invalid request"} + client = clients(lambda request: httpx2.Response(400, json=body, headers={"x-typesafe-request-id": "req-error"})) + if isinstance(client, AsyncTypeSafeClient): + with pytest.raises(TypeSafeBadRequestError) as caught: + await client.system_one("x", {"spam": Noul()}, response_model=KnownResponse) + else: + with pytest.raises(TypeSafeBadRequestError) as caught: + client.system_one("x", {"spam": Noul()}, response_model=KnownResponse) + assert caught.value.status == 400 + assert caught.value.body == body + assert caught.value.request_id == "req-error" diff --git a/tests/test_questions.py b/tests/test_questions.py index 1bee69f..78cec45 100644 --- a/tests/test_questions.py +++ b/tests/test_questions.py @@ -1,12 +1,12 @@ 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 pydantic import BaseModel, ValidationError +from pydantic_core import from_json, to_json from tests.conftest import ClientFactory from typesafe_sdk import ( @@ -36,7 +36,7 @@ def test_normalization_preserves_objects() -> None: assert result["noul"] is questions["noul"] assert result["choice"] is questions["choice"] assert result["score"] is questions["score"] - assert msgspec.json.decode(msgspec.json.encode(result)) == { + assert from_json(to_json(result)) == { "noul": {"type": "noul", "instructions": "Spam?"}, "choice": {"type": "choice", "instructions": "Tone?", "criteria": {"calm": None}}, "score": {"type": "score", "instructions": "Quality?", "criteria": ["bad", "good"]}, @@ -56,7 +56,7 @@ def test_normalization_preserves_raw_questions(raw: dict[str, Any]) -> None: questions = cast(Questions, {"raw": raw, "typed": Noul(instructions="Spam?")}) result = normalize_questions(questions) assert isinstance(result["typed"], wire.NoulQuestion) - assert msgspec.to_builtins(result) == {"raw": raw, "typed": {"type": "noul", "instructions": "Spam?"}} + assert from_json(to_json(result)) == {"raw": raw, "typed": {"type": "noul", "instructions": "Spam?"}} assert raw == before assert result["raw"] is raw @@ -92,8 +92,8 @@ def test_raw_questions_require_structural_keys(invalid: object) -> 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 + assert from_json(to_json(question)) == expected + assert question.model_dump() == expected def test_discriminators_are_automatic() -> None: @@ -105,23 +105,35 @@ def test_discriminators_are_automatic() -> None: (choice, wire.ChoiceQuestion, "choice"), (score, wire.ScoreQuestion, "score"), ): - assert isinstance(question, msgspec.Struct) + assert isinstance(question, BaseModel) 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()) + assert question.type == tag + assert question.model_dump()["type"] == tag + assert from_json(to_json(question))["type"] == tag + # Construction is keyword-only: a positional argument is rejected. 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?" + assert question.model_dump()["instructions"] == "Updated?" + + +def test_invalid_typed_question_is_rejected_on_construction() -> None: + # Unlike raw dictionaries, typed questions validate eagerly rather than deferring to the API. + with pytest.raises(ValidationError): + Choice(criteria=cast(Any, ["invalid", "shape"])) + + +@pytest.mark.parametrize( + "question_type,kwargs", + [ + (Noul, {}), + (Choice, {"criteria": {"a": None}}), + (Score, {"criteria": ["good"]}), + ], +) +def test_typed_questions_reject_unknown_fields(question_type: Any, kwargs: dict[str, Any]) -> None: + with pytest.raises(ValidationError, match="extra_forbidden"): + question_type(**kwargs, unexpected=True) @pytest.mark.parametrize("raw", [False, True]) @@ -145,7 +157,13 @@ def test_optional_noul_criteria(raw: bool, criteria: NoulCriteria | None) -> Non question = expected.copy() else: question = Noul(instructions="Spam?", criteria=criteria) - assert msgspec.to_builtins(normalize_questions({"q": question})) == {"q": expected} + assert from_json(to_json(normalize_questions({"q": question}))) == {"q": expected} + + +def test_typed_noul_criteria_reject_unknown_fields() -> None: + criteria = cast(NoulCriteria, {"true": "yes", "metadata": {"source": None}}) + with pytest.raises(ValidationError, match="extra_forbidden"): + Noul(criteria=criteria) @pytest.mark.parametrize("raw", [False, True]) @@ -170,7 +188,7 @@ async def test_covariant_question_mappings(clients: ClientFactory) -> None: def handler(request: httpx2.Request) -> httpx2.Response: nonlocal calls calls += 1 - assert msgspec.json.decode(request.content)["questions"] + assert from_json(request.content)["questions"] return httpx2.Response(200, json={"model": "jev-latest", "usage": {}, "answers": {}}) client = clients(handler) diff --git a/tests/test_responses.py b/tests/test_responses.py index c8cf4c2..624439c 100644 --- a/tests/test_responses.py +++ b/tests/test_responses.py @@ -3,8 +3,9 @@ import pickle from typing import Any, cast import httpx2 -import msgspec import pytest +from pydantic import ValidationError +from pydantic_core import from_json from typing_extensions import assert_type from tests.conftest import ClientFactory @@ -15,6 +16,7 @@ from typesafe_sdk import ( AsyncTypeSafeClient, ChoiceAnswer, ListModelsResponse, + ModelMetadata, NoulAnswer, ScoreAnswer, SystemOneResponse, @@ -29,10 +31,11 @@ from typesafe_sdk import ( [ ({}, "model"), ({"n": {"type": "noul"}}, "answers.n.noul"), + ({"n": {"type": "noul", "noul": "0.5"}}, "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"), + ({"s": {"type": "score", "score": 1.0, "confidence": 1.0, "legend": {"x": "bad"}, "probabilities": {}}}, "answers.s.legend.x"), ({"c": "not-a-mapping"}, "answers.c.type"), ], ) @@ -98,10 +101,10 @@ async def test_response_serialization_excludes_http_metadata(clients: ClientFact assert result.choices assert result.scores assert result.request_id == "req-export" - assert set(msgspec.to_builtins(result)) == set(body) - encoded = msgspec.json.encode(result) - assert msgspec.json.decode(encoded) == body - restored = msgspec.json.decode(encoded, type=type(result)) + assert set(result.model_dump()) == set(body) + encoded = result.model_dump_json() + assert from_json(encoded) == body + restored = type(result).model_validate_json(encoded) assert restored == result assert result.raw_http_response.json() == body @@ -147,7 +150,8 @@ async def test_unknown_extra_fields_tolerated(clients: ClientFactory) -> None: ) 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} + assert result.usage.model_dump() == {"input_tokens": 1, "output_tokens": 1} + assert result.raw_http_response.json() == body async def test_unknown_answer_type_ignored(clients: ClientFactory) -> None: @@ -196,7 +200,7 @@ def test_response_preserves_nested_json() -> None: assert isinstance(examples, list) assert isinstance(examples[1], dict) - exported = msgspec.to_builtins(answer) + exported = answer.model_dump() assert exported == { "type": "score", "score": 0.0, @@ -204,7 +208,7 @@ def test_response_preserves_nested_json() -> None: "legend": {0: {"examples": ["a", {"note": None}]}}, "probabilities": {0: 1.0}, } - # to_builtins returns an independent deep copy: mutating it leaves the struct untouched. + # model_dump returns an independent deep copy: mutating it leaves the model untouched. exported["legend"][0]["examples"].append("new") exported["probabilities"][0] = 0.5 assert examples == ["a", {"note": None}] @@ -218,8 +222,8 @@ def test_answer_attributes_and_dictionary_types() -> None: 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) == { + assert noul.model_dump() == {"type": "noul", "noul": 0.98} + assert choice.model_dump() == { "type": "choice", "choice": "billing", "confidence": 0.9, @@ -227,6 +231,24 @@ def test_answer_attributes_and_dictionary_types() -> None: } +@pytest.mark.parametrize( + "model_type,kwargs", + [ + (NoulAnswer, {"noul": 0.5}), + (ChoiceAnswer, {"choice": "a", "confidence": 1.0, "probabilities": {"a": 1.0}}), + (ScoreAnswer, {"score": 0.0, "confidence": 1.0, "legend": {0: "bad"}, "probabilities": {0: 1.0}}), + (Usage, {}), + (SystemOneResponse, {"model": "test", "usage": Usage()}), + (ModelMetadata, {"name": "test", "description": "Test model", "release_date": "2026-09-14"}), + (ListModelsResponse, {"models": ()}), + ], +) +def test_public_response_types_ignore_unknown_fields(model_type: Any, kwargs: dict[str, Any]) -> None: + result = model_type(**kwargs, unexpected=True) + assert not hasattr(result, "unexpected") + assert "unexpected" not in result.model_dump() + + @pytest.mark.parametrize( "answer", [ @@ -235,21 +257,18 @@ def test_answer_attributes_and_dictionary_types() -> None: 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): +def test_answer_fields_are_frozen(answer: Answer) -> None: + with pytest.raises(ValidationError): cast(Any, answer).type = "other" - for name in answer.__struct_fields__: - with pytest.raises(AttributeError): + for name in type(answer).model_fields: + with pytest.raises(ValidationError): setattr(answer, name, getattr(answer, name)) @pytest.mark.parametrize("group", ["nouls", "choices", "scores"]) -def test_cached_groups_cannot_be_reassigned(group: str) -> None: +def test_answer_groups_are_cached_and_not_serialized(group: str) -> None: result = SystemOneResponse(model="test", usage=Usage(), answers={}) - with pytest.raises(AttributeError): - setattr(result, group, {}) cached = getattr(result, group) + # The derived view is memoized (stable identity) and never leaks into the serialized payload. assert getattr(result, group) is cached - with pytest.raises(AttributeError): - setattr(result, group, {}) + assert group not in result.model_dump() diff --git a/tests/test_retry.py b/tests/test_retry.py index 5786f62..f9fa611 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -4,8 +4,8 @@ from email.utils import format_datetime from types import SimpleNamespace import httpx2 -import msgspec import pytest +from pydantic_core import from_json, to_json from tenacity import RetryCallState from tests.conftest import ClientFactory @@ -361,7 +361,7 @@ async def test_system_one_retry_recovers_with_overrides( ) assert result.scores["quality"].score == 1.7 assert result.choices["tone"].confidence == 0.9 - expected = msgspec.json.encode( + expected = to_json( { "state": {"document": "hello"}, "model": "call-model", @@ -380,7 +380,7 @@ async def test_system_one_retry_recovers_with_overrides( 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 from_json(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 @@ -422,8 +422,8 @@ async def test_concurrent_system_one_overrides() -> None: 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(from_json(request.content)["model"] == name for request in requests) + assert all(from_json(request.content)["state"] == name for request in requests) assert all(request.extensions["timeout"] == httpx2.Timeout(float(count)).as_dict() for request in requests) diff --git a/tests/test_types.py b/tests/test_types.py index 39d8b41..c0c8d5e 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,29 +1,39 @@ -from collections.abc import Mapping, Sequence from types import MappingProxyType -from typing import get_args, get_origin, get_type_hints +from typing import get_type_hints import httpx2 -import msgspec import pytest +from pydantic import TypeAdapter, ValidationError +from pydantic_core import from_json from tests.conftest import ClientFactory from tests.helpers import system_one -from typesafe_sdk import AsyncTypeSafeClient, Choice, JSONValue, Noul, Questions, Score, TypeSafeClient +from typesafe_sdk import AsyncTypeSafeClient, Choice, JSONContent, JSONValue, Noul, Questions, Score, TypeSafeClient +from typesafe_sdk._core.json import _fallback + + +def test_str_subclasses_fallback_to_strings() -> None: + class StrSubclass(str): + __slots__ = () + + value = _fallback(StrSubclass("ARPANET")) + assert value == "ARPANET" + assert type(value) is str def test_json_value_and_state_exclude_top_level_none() -> None: - assert type(None) not in get_args(JSONValue) + # `state` is annotated with the `JSONContent` alias. 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 Mapping - 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 Sequence - assert type(None) in get_args(get_args(array_type)[0]) + assert get_type_hints(client.system_one)["state"] is JSONContent + # Both JSON aliases forbid a bare top-level `None`, while accepting the text, mapping, and + # sequence forms — and `None` remains valid *nested* as a value. + for alias in (JSONContent, JSONValue): + adapter: TypeAdapter[object] = TypeAdapter(alias) + with pytest.raises(ValidationError): + adapter.validate_python(None) + assert adapter.validate_python("text") == "text" + assert adapter.validate_python({"key": None}) == {"key": None} + assert adapter.validate_python(["item", None]) == ["item", None] @pytest.mark.parametrize("raw", [False, True]) @@ -46,7 +56,7 @@ async def test_array_inputs(clients: ClientFactory, raw: bool) -> None: } def handler(request: httpx2.Request) -> httpx2.Response: - body = msgspec.json.decode(request.content) + body = from_json(request.content) assert body["state"] == state assert body["questions"] == { "yes": {"type": "noul", "instructions": instructions, "criteria": {"true": description, "false": None}}, @@ -66,7 +76,7 @@ async def test_raw_optional_fields_preserve_explicit_null(clients: ClientFactory } def handler(request: httpx2.Request) -> httpx2.Response: - assert msgspec.json.decode(request.content)["questions"] == questions + assert from_json(request.content)["questions"] == questions return httpx2.Response(200, json={"model": "jev-latest", "usage": {}, "answers": {}}) await system_one(clients(handler), state="x", questions=questions) @@ -77,7 +87,7 @@ async def test_explicitly_nullable_json_values(clients: ClientFactory) -> None: instructions: dict[str, JSONValue | None] = {"text": "Classify", "extra": None} def handler(request: httpx2.Request) -> httpx2.Response: - body = msgspec.json.decode(request.content) + body = from_json(request.content) assert body["state"] == state assert body["questions"] == { "yes": {"type": "noul", "instructions": instructions, "criteria": {"true": {"extra": None}}}, @@ -109,7 +119,7 @@ async def test_abstract_input_containers_encode(clients: ClientFactory) -> None: } def handler(request: httpx2.Request) -> httpx2.Response: - assert msgspec.json.decode(request.content) == { + assert from_json(request.content) == { "state": {"items": ["a", None]}, "model": "jev-latest", "questions": { diff --git a/tests/typing/negative/async_client.py b/tests/typing/negative/async_client.py index 1bb6b74..00920ea 100644 --- a/tests/typing/negative/async_client.py +++ b/tests/typing/negative/async_client.py @@ -9,3 +9,5 @@ async def invalid_arguments(client: AsyncTypeSafeClient) -> None: 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` + await client.system_one("x", {}, response_model=int) # E: No matching overload + await client.system_one("x", {}, response_model=object()) # E: No matching overload diff --git a/tests/typing/negative/questions.py b/tests/typing/negative/questions.py index b967521..7d76bf1 100644 --- a/tests/typing/negative/questions.py +++ b/tests/typing/negative/questions.py @@ -7,7 +7,7 @@ score: ScoreModel = {"type": "choice", "instructions": "?", "criteria": ["a"]} 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_extra: NoulModel = {"type": "noul", "extra": "future"} # E: is not defined bad_criteria: ChoiceModel = {"type": "choice", "criteria": ["a"]} # E: is not assignable bad_score_criteria: ScoreModel = {"type": "score", "criteria": {0: "a"}} # E: is not assignable bad_question: Question = {"unrelated": "value"} # E: is not assignable diff --git a/tests/typing/negative/sync_client.py b/tests/typing/negative/sync_client.py index abc697a..a3ad8d8 100644 --- a/tests/typing/negative/sync_client.py +++ b/tests/typing/negative/sync_client.py @@ -9,3 +9,5 @@ def invalid_arguments(client: TypeSafeClient) -> None: 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` + client.system_one("x", {}, response_model=int) # E: No matching overload + client.system_one("x", {}, response_model=object()) # E: No matching overload diff --git a/tests/typing/pydantic_response_models.py b/tests/typing/pydantic_response_models.py new file mode 100644 index 0000000..1c41cc0 --- /dev/null +++ b/tests/typing/pydantic_response_models.py @@ -0,0 +1,51 @@ +from typing import TypeVar + +from pydantic import BaseModel +from typing_extensions import assert_type + +from tests.test_pydantic_response_models import KnownResponse, TypedSystemOneResponse +from typesafe_sdk import AsyncTypeSafeClient, ChoiceAnswer, Noul, NoulAnswer, ScoreAnswer, SystemOneResponse, TypeSafeClient + +PydanticT = TypeVar("PydanticT", bound=BaseModel) + + +def sync(client: TypeSafeClient, response_model: type[KnownResponse] | None) -> None: + questions = {"spam": Noul()} + assert_type(client.system_one("x", questions), SystemOneResponse) + assert_type(client.system_one("x", questions, response_model=None), SystemOneResponse) + assert_type(client.system_one("x", questions, response_model=SystemOneResponse), SystemOneResponse) + assert_type(client.system_one("x", questions, response_model=response_model), KnownResponse | SystemOneResponse) + standalone = client.system_one("x", questions, response_model=KnownResponse) + assert_type(standalone, KnownResponse) + assert_type(standalone.answers.spam, NoulAnswer) + typed = client.system_one("x", {"spam": Noul()}, response_model=TypedSystemOneResponse) + assert_type(typed, TypedSystemOneResponse) + assert_type(typed.spam, NoulAnswer) + assert_type(typed.nouls, dict[str, NoulAnswer]) + assert_type(typed.choices, dict[str, ChoiceAnswer]) + assert_type(typed.scores, dict[str, ScoreAnswer]) + + +async def asynchronous(client: AsyncTypeSafeClient, response_model: type[KnownResponse] | None) -> None: + questions = {"spam": Noul()} + assert_type(await client.system_one("x", questions), SystemOneResponse) + assert_type(await client.system_one("x", questions, response_model=None), SystemOneResponse) + assert_type(await client.system_one("x", questions, response_model=SystemOneResponse), SystemOneResponse) + assert_type(await client.system_one("x", questions, response_model=response_model), KnownResponse | SystemOneResponse) + standalone = await client.system_one("x", questions, response_model=KnownResponse) + assert_type(standalone, KnownResponse) + assert_type(standalone.answers.spam, NoulAnswer) + typed = await client.system_one("x", {"spam": Noul()}, response_model=TypedSystemOneResponse) + assert_type(typed, TypedSystemOneResponse) + assert_type(typed.spam, NoulAnswer) + assert_type(typed.nouls, dict[str, NoulAnswer]) + assert_type(typed.choices, dict[str, ChoiceAnswer]) + assert_type(typed.scores, dict[str, ScoreAnswer]) + + +def generic_sync(client: TypeSafeClient, response_model: type[PydanticT]) -> PydanticT: + return client.system_one("x", {"spam": Noul()}, response_model=response_model) + + +async def generic_async(client: AsyncTypeSafeClient, response_model: type[PydanticT]) -> PydanticT: + return await client.system_one("x", {"spam": Noul()}, response_model=response_model) diff --git a/tests/typing/transport.py b/tests/typing/transport.py index 46c69b9..10eefa6 100644 --- a/tests/typing/transport.py +++ b/tests/typing/transport.py @@ -10,7 +10,7 @@ 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) + system_one = prepare_system_one(config, "x", {"q": Noul(instructions="?")}, None, None, None, None, SystemOneResponse) assert_type(models, Request[ListModelsResponse]) assert_type(system_one, Request[SystemOneResponse]) assert_type(RequestState(models).parse(response), ListModelsResponse) @@ -23,7 +23,7 @@ def sync(config: Config, client: TypeSafeClient, http_client: httpx2.Client, ret 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) + system_one = prepare_system_one(config, "x", {"q": Noul(instructions="?")}, None, None, None, None, SystemOneResponse) 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) diff --git a/tests/typing/valid.py b/tests/typing/valid.py index 8ad0f09..c3d2a17 100644 --- a/tests/typing/valid.py +++ b/tests/typing/valid.py @@ -1,10 +1,21 @@ from collections.abc import Mapping from types import MappingProxyType +from typing import cast import httpx2 from typing_extensions import assert_type -from typesafe_sdk import AsyncTypeSafeClient, Choice, ChoiceModel, ListModelsResponse, RetryPolicy, Score, SystemOneResponse, TypeSafeClient +from typesafe_sdk import ( + AsyncTypeSafeClient, + Choice, + ChoiceModel, + ListModelsResponse, + Questions, + RetryPolicy, + Score, + SystemOneResponse, + TypeSafeClient, +) def sync(client: TypeSafeClient) -> None: @@ -15,9 +26,8 @@ def sync(client: TypeSafeClient) -> None: assert_type(client.system_one(state, {"s": Score(criteria=("low", "high"))}), SystemOneResponse) 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 - ) + future_questions = cast(Questions, {"q": {"type": "noul", "instructions": "?", "weight": 2}}) + assert_type(client.system_one("x", future_questions, 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)) @@ -28,7 +38,8 @@ async def asynchronous(client: AsyncTypeSafeClient) -> 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) + future_questions = cast(Questions, {"q": {"type": "noul", "instructions": "?", "weight": 2}}) + assert_type(await client.system_one("x", future_questions, 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)) diff --git a/uv.lock b/uv.lock index dedc229..38dd696 100644 --- a/uv.lock +++ b/uv.lock @@ -198,6 +198,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d6/0b/29d7965215f8ef830a7ca1f42997fe13e5693d85e9edb18f938d063ef5f2/gitpython-3.1.62-py3-none-any.whl", hash = "sha256:7002251225e10e29d2e1f49e6532613fe5d5d9f0b6f1f02997a52b38fe56899e", size = 222753, upload-time = "2026-09-07T02:57:19.762Z" }, ] +[[package]] +name = "griffe-pydantic" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/bd/d2eaeaf3f9910c9cd72793af0de18ee3d3a3a27bb30ab01cfd7659c08dc4/griffe_pydantic-1.3.1.tar.gz", hash = "sha256:f7caedfa0effedb22893bf01cc411fd567614f7b4de7ce0c1f4293eb7acb5c44", size = 38176, upload-time = "2026-02-21T09:38:49.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/88/17d656aa97406cd509e2be0e6eddcf81fc1666104bf81042b89721d4a44c/griffe_pydantic-1.3.1-py3-none-any.whl", hash = "sha256:475103794a1d5da3933d3968e82a2bd9d9a1fa507c06d5ee1a39ee1fadcca628", size = 15189, upload-time = "2026-02-20T11:34:14.965Z" }, +] + [[package]] name = "griffelib" version = "2.3.0" @@ -529,62 +541,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, ] -[[package]] -name = "msgspec" -version = "0.21.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e3/60/f79b9b013a16fa3a58350c9295ddc6789f2e335f36ea61ed10a21b215364/msgspec-0.21.1.tar.gz", hash = "sha256:2313508e394b0d208f8f56892ca9b2799e2561329de9763b19619595a6c0f72c", size = 319193, upload-time = "2026-04-12T21:44:50.394Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/38/d591d9f66d43d897ecbd249f2833665823d19c8b043f16619bc8343e23df/msgspec-0.21.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72d9cd03241b8b2edb2e12dcc66c500fa480d8cbd71a8bac105809d468882064", size = 195172, upload-time = "2026-04-12T21:43:45.062Z" }, - { url = "https://files.pythonhosted.org/packages/69/1a/6899188b5982ec1324e0c629b7801eed2db987f6634fab58abd9fc82d317/msgspec-0.21.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed2ab278200e743a1d2610a4e0c8fc74f6cecb8548544cdec43f927bd9265238", size = 188316, upload-time = "2026-04-12T21:43:46.641Z" }, - { url = "https://files.pythonhosted.org/packages/9e/95/7e591b4fa11fdbbf9891164473c23420a8c781ef553295abe416bf335f42/msgspec-0.21.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd677e3001fdfed9186de72eab434da2976303cd5eb9550921d3d0c3e3e168ce", size = 216565, upload-time = "2026-04-12T21:43:48.081Z" }, - { url = "https://files.pythonhosted.org/packages/19/86/714feeaf3b84cf2027235681725593840153dedd2868578f9f2715e296bb/msgspec-0.21.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f667b90b37fad734a91671abd68e0d7f4d066862771b87e91c53996dcb7a9027", size = 222689, upload-time = "2026-04-12T21:43:49.385Z" }, - { url = "https://files.pythonhosted.org/packages/7d/b9/4384243e814f2579e5205e17d170b9c1a30121afd1393298d904817a7fa7/msgspec-0.21.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:49880fd20fdbcfe1b793f07dd83f12572bab679c9800352c8b2240289aa46a06", size = 222343, upload-time = "2026-04-12T21:43:50.612Z" }, - { url = "https://files.pythonhosted.org/packages/04/01/4b227d9c4057346271043632bad41979cf8c3dca372e41bb1f7d546395b2/msgspec-0.21.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ae0162e22849a5e91eaad907766525107523b0daea3df267a9fcb5ba4e0936ae", size = 225607, upload-time = "2026-04-12T21:43:52.129Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ce/27021d1c3e5da837743092a7b7a5e8818397e1f4c05ee8b068bd7d1fd78a/msgspec-0.21.1-cp310-cp310-win_amd64.whl", hash = "sha256:f041a2279f31e3a53319005e4d60ba77c085cfcbe394cdc7ce803c2d01fe9449", size = 188392, upload-time = "2026-04-12T21:43:53.384Z" }, - { url = "https://files.pythonhosted.org/packages/80/2b/daf7a8d6d7cf00e0dcd0439178b284ade701234abdcadf3385601da04fbd/msgspec-0.21.1-cp310-cp310-win_arm64.whl", hash = "sha256:1bf17cbd7b28a5dffc7e764c654eed8ccde5e0f1de7970628608304640d4ce4e", size = 174191, upload-time = "2026-04-12T21:43:54.6Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7f/bbc4e74cd33d316b75541149e4d35b163b63bce066530ae185a2ec3b5bfc/msgspec-0.21.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b504b6e7f7a22a24b27232b73034421692147865162daaec9f3bf62439007c87", size = 193131, upload-time = "2026-04-12T21:43:56.094Z" }, - { url = "https://files.pythonhosted.org/packages/c1/60/504886af1aaf854112663b842d5eea9a15d9588f9bf7d0d2df736424b84d/msgspec-0.21.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4692b7c1609155708c4418f88e92f63c13fdf08aa095c84bae82bad75b53389b", size = 186597, upload-time = "2026-04-12T21:43:57.242Z" }, - { url = "https://files.pythonhosted.org/packages/fa/54/d24ddeaa65b5278c9e67f48ce3c17a9831e8f3722f3c8322ee120aca22ef/msgspec-0.21.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3124010b3815451494c85ff345e693cb9fe5889cfcbbef39ed8622e0e72319c", size = 215158, upload-time = "2026-04-12T21:43:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/9f/75/bb79c8b89a93ae23cd33c0d802373f16feaf9633f05d8af77091350dda0a/msgspec-0.21.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6badc03b9725352219cca017bfe71c61f2fbd0fb5982b410ac17c97c213deb30", size = 219856, upload-time = "2026-04-12T21:44:00.015Z" }, - { url = "https://files.pythonhosted.org/packages/b4/9c/c5ca26b46f0ebbd3a6683695ef89396712cb9e4199fd1f0bc1dd968216b1/msgspec-0.21.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5d2d4116ebe3035a78d9ec76e99a9d64e5fa6d44fe61a9c5de7fd1acf54bcc69", size = 220314, upload-time = "2026-04-12T21:44:01.548Z" }, - { url = "https://files.pythonhosted.org/packages/c8/31/645a351c4285dce40ed6755c3dcc0aa648e26dacb20a98018fe2cce5e87b/msgspec-0.21.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0d1009f6715f5bff3b54d4ff5c7428ad96197e0534e1645b8e9b955890c84664", size = 223215, upload-time = "2026-04-12T21:44:02.884Z" }, - { url = "https://files.pythonhosted.org/packages/09/af/8bf15736a6dd3cb4f90c5467f6dc39197d2daaf10754490cdc0aa17b7312/msgspec-0.21.1-cp311-cp311-win_amd64.whl", hash = "sha256:c6faffe5bb644ec884052679af4dfd776d4b5ca90e4a7ec7e7e319e4e6b93a6e", size = 188554, upload-time = "2026-04-12T21:44:04.151Z" }, - { url = "https://files.pythonhosted.org/packages/ef/29/cc7db3a165b62d16e64a83f82eccb79655055cb5bc1f60459a6f9d7c82f2/msgspec-0.21.1-cp311-cp311-win_arm64.whl", hash = "sha256:ee9e3f11fa94603f7d673bf795cfa31b549c4a2c723bc39b45beb1e7f5a3fb99", size = 174517, upload-time = "2026-04-12T21:44:05.66Z" }, - { url = "https://files.pythonhosted.org/packages/6e/cf/317224852c00248c620a9bcf4b26e2e4ab8afd752f18d2a6ef73ebd423b6/msgspec-0.21.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4248cf0b6129b7d230eacd493c17cc2d4f3989f3bb7f633a928a85b7dcfa251", size = 196188, upload-time = "2026-04-12T21:44:07.181Z" }, - { url = "https://files.pythonhosted.org/packages/6d/81/074612945c0666078f7366f40000013de9f6ba687491d450df699bceebc9/msgspec-0.21.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5102c7e9b3acff82178449b85006d96310e690291bb1ea0142f1b24bcb8aabcb", size = 188473, upload-time = "2026-04-12T21:44:08.736Z" }, - { url = "https://files.pythonhosted.org/packages/8a/37/655101799590bcc5fddb2bd3fe0e6194e816c2d1da7c361725f5eb89a910/msgspec-0.21.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846758412e9518252b2ac9bffd6f0e54d9ff614f5f9488df7749f81ff5c80920", size = 218871, upload-time = "2026-04-12T21:44:09.917Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d1/d4cd9fe89c7d400d7a18f86ccc94daa3f0927f53558846fcb60791dce5d6/msgspec-0.21.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:21995e74b5c598c2e004110ad66ec7f1b8c20bf2bcf3b2de8fd9a3094422d3ff", size = 225025, upload-time = "2026-04-12T21:44:11.191Z" }, - { url = "https://files.pythonhosted.org/packages/24/bf/e20549e602b9edccadeeff98760345a416f9cce846a657e8b18e3396b212/msgspec-0.21.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6129f0cca52992e898fd5344187f7c8127b63d810b2fd73e36fca73b4c6475ee", size = 222672, upload-time = "2026-04-12T21:44:12.481Z" }, - { url = "https://files.pythonhosted.org/packages/b4/68/04d7a8f0f786545cf9b8c280c57aa6befb5977af6e884b8b54191cbe44b3/msgspec-0.21.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ef3ec2296248d1f8b9231acb051b6d471dfde8f21819e86c9adaaa9f42918521", size = 227303, upload-time = "2026-04-12T21:44:13.709Z" }, - { url = "https://files.pythonhosted.org/packages/cc/4d/619866af2840875be408047bf9e70ceafbae6ab50660de7134ed1b25eb86/msgspec-0.21.1-cp312-cp312-win_amd64.whl", hash = "sha256:d4ab834a054c6f0cbeef6df9e7e1b33d5f1bc7b86dea1d2fd7cad003873e783d", size = 190017, upload-time = "2026-04-12T21:44:14.977Z" }, - { url = "https://files.pythonhosted.org/packages/5e/2e/a8f9eca8fd00e097d7a9e99ba8a4685db994494448e3d4f0b7f6e9a3c0f7/msgspec-0.21.1-cp312-cp312-win_arm64.whl", hash = "sha256:628aaa35c74950a8c59da330d7e98917e1c7188f983745782027748ee4ca573e", size = 175345, upload-time = "2026-04-12T21:44:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/7e/74/f11ede02839b19ff459f88e3145df5d711626ca84da4e23520cebf819367/msgspec-0.21.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:764173717a01743f007e9f74520ed281f24672c604514f7d76c1c3a10e8edb66", size = 196176, upload-time = "2026-04-12T21:44:17.613Z" }, - { url = "https://files.pythonhosted.org/packages/bb/40/4476c1bd341418a046c4955aff632ec769315d1e3cb94e6acf86d461f9ed/msgspec-0.21.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:344c7cd0eaed1fb81d7959f99100ef71ec9b536881a376f11b9a6c4803365697", size = 188524, upload-time = "2026-04-12T21:44:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/ca/d9/9e9d7d7e5061b47540d03d640fab9b3965ba7ae49c1b2154861c8f007518/msgspec-0.21.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48943e278b3854c2f89f955ddc6f9f430d3f0784b16e47d10604ee0463cd21f5", size = 218880, upload-time = "2026-04-12T21:44:20.028Z" }, - { url = "https://files.pythonhosted.org/packages/74/66/2bb344f34abb4b57e60c7c9c761994e0417b9718ec1460bf00c296f2a7ea/msgspec-0.21.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9aa659ebb0101b1cbc31461212b87e341d961f0ab0772aaf068a99e001ec4aa", size = 225050, upload-time = "2026-04-12T21:44:21.577Z" }, - { url = "https://files.pythonhosted.org/packages/1a/84/7c1e412f76092277bf760cef12b7979d03314d259ab5b5cafde5d0c1722d/msgspec-0.21.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7b27d1a8ead2b6f5b0c4f2d07b8be1ccfcc041c8a0e704781edebe3ae13c484", size = 222713, upload-time = "2026-04-12T21:44:22.83Z" }, - { url = "https://files.pythonhosted.org/packages/4e/27/0bba04b2b4ef05f3d068429410bc71d2cea925f1596a8f41152cccd5edb8/msgspec-0.21.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38fe93e86b61328fe544cb7fd871fad5a27c8734bfda90f65e5dbe288ae50f61", size = 227259, upload-time = "2026-04-12T21:44:24.11Z" }, - { url = "https://files.pythonhosted.org/packages/b0/2d/09574b0eea02fed2c2c1383dbaae2c7f79dc16dcd6487a886000afb5d7c4/msgspec-0.21.1-cp313-cp313-win_amd64.whl", hash = "sha256:8bc666331c35fcce05a7cd2d6221adbe0f6058f8e750711413d22793c080ac6a", size = 189857, upload-time = "2026-04-12T21:44:25.359Z" }, - { url = "https://files.pythonhosted.org/packages/46/34/105b1576ad182879914f0c821f17ee1d13abb165cb060448f96fe2aff078/msgspec-0.21.1-cp313-cp313-win_arm64.whl", hash = "sha256:42bb1241e0750c1a4346f2aa84db26c5ffd99a4eb3a954927d9f149ff2f42898", size = 175403, upload-time = "2026-04-12T21:44:26.608Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ad/86954e987d1d6a5c579e2c2e7832b65e0fff194179fdac4f581536086024/msgspec-0.21.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fab48eb45fdbfbdb2c0edfec00ffc53b6b6085beefc6b50b61e01659f9f8757f", size = 196261, upload-time = "2026-04-12T21:44:27.807Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a1/c5e46c3e42b866199365e35d11dddfd1fbd8bba4fdb3c52f965b1607ce94/msgspec-0.21.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3cb779ea0c35bc807ff941d415875c1f69ca0be91a2e907ab99a171811d86a9a", size = 188729, upload-time = "2026-04-12T21:44:28.99Z" }, - { url = "https://files.pythonhosted.org/packages/85/7d/1e29a319d678d6cb962ae5bdf32a6858ebdf38f73bc654c0e9c742a0c2c8/msgspec-0.21.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68604db36b3b4dd9bf160e436e12798a4738848144cea1aca1cb984011eb160f", size = 219866, upload-time = "2026-04-12T21:44:31.104Z" }, - { url = "https://files.pythonhosted.org/packages/25/1f/cca084ca2572810fff12ea9dbdcbe39eac048f40daf4a9077b49fcbe8cee/msgspec-0.21.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d6b9dc50948eaf65df54d2fd0ff66e6d8c32f116037209ee861810eb9b676cb", size = 224993, upload-time = "2026-04-12T21:44:32.649Z" }, - { url = "https://files.pythonhosted.org/packages/71/94/d2120fc9d419a89a3a7c13e5b7078798c4b392a96a02a6e2b3ce43a8766c/msgspec-0.21.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:52c5e21930942302394429c5a582ce7e6b62c7f983b3760834c2ce107e0dd6df", size = 223535, upload-time = "2026-04-12T21:44:33.839Z" }, - { url = "https://files.pythonhosted.org/packages/75/17/42418b66a3ad972a89bab73dd78b79cc6282bb488a25e73c853cee7443b9/msgspec-0.21.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:abbb39d65681fa24ed394e01af3d59d869068324f900c61d06062b7fb9980f2f", size = 227222, upload-time = "2026-04-12T21:44:35.093Z" }, - { url = "https://files.pythonhosted.org/packages/c4/33/265c894268cca88ff67b144ca2b4c522fc8b9a6f1966a3640c70516e78e1/msgspec-0.21.1-cp314-cp314-win_amd64.whl", hash = "sha256:5666b1b560b97b6ec2eb3fca8a502298ebac56e13bbca1f88523538ce83d01ea", size = 193810, upload-time = "2026-04-12T21:44:36.612Z" }, - { url = "https://files.pythonhosted.org/packages/3b/8f/a6d35f25bf1fc63c492fdd88fdce01ba0875ead48c2b91f90f33653b4131/msgspec-0.21.1-cp314-cp314-win_arm64.whl", hash = "sha256:d8b8578e4c83b14ceea4cef0d0b747e31d9330fe4b03b2b2ad4063866a178f93", size = 179125, upload-time = "2026-04-12T21:44:38.198Z" }, - { url = "https://files.pythonhosted.org/packages/c6/39/74839641e64b99d87da55af0fc472854d42b46e2183b9e2a67fe1bb2a512/msgspec-0.21.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:15f523d51c00ebad412213bfe9f06f0a50ec2b93e0c19e824a2d267cabb48ea2", size = 200171, upload-time = "2026-04-12T21:44:39.414Z" }, - { url = "https://files.pythonhosted.org/packages/70/9b/ce0cca6d2d87fcd4b6ff97600790494e64f26a2c55d61507cd2755c16193/msgspec-0.21.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e47390360583ba3d5c6cb44cf0a9f61b0a06a899d3c2c00627cedebb2e2884b", size = 192879, upload-time = "2026-04-12T21:44:40.882Z" }, - { url = "https://files.pythonhosted.org/packages/a7/08/673a7bb05e5702dc787ddd3011195b509f9867927970da59052211929987/msgspec-0.21.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60800e6299b798142dc40b0644da77ceac5ea0568be58228417eae14135c847", size = 226281, upload-time = "2026-04-12T21:44:42.181Z" }, - { url = "https://files.pythonhosted.org/packages/7d/45/86508cf57283e9070b3c447e3ab25b792a7a0855a3ea4e0c6d111ac34c97/msgspec-0.21.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f8e9dfcd98419cf7568808470c4317a3fb30bef0e3715b568730a2b272a20d7", size = 229863, upload-time = "2026-04-12T21:44:43.442Z" }, - { url = "https://files.pythonhosted.org/packages/2c/62/e7c9367cd08d590559faacd711edbae36840342843e669440363f33c7d36/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92d89dfad13bd1ea640dc3e37e724ed380da1030b272bdf5ecafb983c3ad7c75", size = 230445, upload-time = "2026-04-12T21:44:44.806Z" }, - { url = "https://files.pythonhosted.org/packages/42/b4/c0f54632103846b658a10930025f4de41c8724b5e4805a5f3b395586cb7e/msgspec-0.21.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d03867786e5d7ba25d666df4b11320c27170f4aeafcb8e3a8b0a50a4fb742ca", size = 231822, upload-time = "2026-04-12T21:44:46.343Z" }, - { url = "https://files.pythonhosted.org/packages/ea/1d/0d85cc79d0ccf5508e9c846cc66552a6a16bf92abd1dbd8362617f7b35cd/msgspec-0.21.1-cp314-cp314t-win_amd64.whl", hash = "sha256:740fbf1c9d59992ca3537d6fbe9ebbf9eaf726a65fbf31448e0ecbc710697a63", size = 206650, upload-time = "2026-04-12T21:44:47.601Z" }, - { url = "https://files.pythonhosted.org/packages/90/91/56c5d560f20e6c20e9e4f55bd0e458f7f162aa689ee350346c04c48eac0b/msgspec-0.21.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0d2cc73df6058d811a126ac3a8ad63a4dfa210c82f9cf5a004802eaf4712de90", size = 183149, upload-time = "2026-04-12T21:44:48.833Z" }, -] - [[package]] name = "mypy-extensions" version = "1.1.0" @@ -1245,11 +1201,12 @@ wheels = [ [[package]] name = "typesafe-sdk" -version = "0.6.0" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "httpx2" }, - { name = "msgspec" }, + { name = "pydantic" }, + { name = "pydantic-core" }, { name = "tenacity" }, { name = "typing-extensions" }, ] @@ -1270,6 +1227,7 @@ dev = [ { name = "tach" }, ] docs = [ + { name = "griffe-pydantic" }, { name = "mkdocstrings", extra = ["python"] }, { name = "ruff" }, { name = "zensical" }, @@ -1278,7 +1236,8 @@ docs = [ [package.metadata] requires-dist = [ { name = "httpx2", specifier = ">=2.0.0" }, - { name = "msgspec", specifier = ">=0.21.1" }, + { name = "pydantic", specifier = ">=2.12.0" }, + { name = "pydantic-core", specifier = ">=2.41.1" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "typing-extensions", specifier = ">=4.13.0" }, ] @@ -1296,6 +1255,7 @@ dev = [ { name = "tach", specifier = ">=0.35.0" }, ] docs = [ + { name = "griffe-pydantic", specifier = ">=1.3.1" }, { name = "mkdocstrings", extras = ["python"], specifier = ">=1.0.6" }, { name = "ruff", specifier = ">=0.16.6" }, { name = "zensical", specifier = ">=0.0.60" },