Python SDK responses, answers, usage, models
TL;DR
system_one()returns a frozenSystemOneResponsewith.model,.usage,.answersplus three cached views — the attribute names are exactly.nouls,.choices,.scores(plural, lowercase). Readresponse.nouls[name].noul(float 0–1),response.choices[name].choice/.confidence/.probabilities,response.scores[name].score/.confidence/.legend/.probabilities..request_idand.raw_http_responseexpose the HTTP layer.client.models.list()returns aListModelsResponseofModelMetadata. Since 0.7.0 every one of these is a Pydantic model (somodel_dump()/model_dump_json()work), andsystem_one(..., response_model=...)lets you decode into your own.
SystemOneResponse
A Pydantic model since 0.7.0, subclassing the internal Response base (which supplies request_id and raw_http_response). Its config, verbatim from the docs page:
model_config = ConfigDict(
extra="ignore", frozen=True, strict=True
)
frozen=True keeps it immutable and hashable; extra="ignore" is what lets a newer server add fields without breaking an older client; strict=True turns off Pydantic's lax coercion, so a server string where an int is declared is a validation error rather than a silent cast. The same three-flag config is set on Usage, NoulAnswer, ChoiceAnswer, ScoreAnswer and ListModelsResponse.
| Member | Kind | Type | Default | Description |
|---|---|---|---|---|
model |
pydantic field | str |
— | The model used to answer the request. |
usage |
pydantic field | Usage |
— | Token usage for the request. |
answers |
pydantic field | dict[str, Answer] |
Field(default_factory=dict) |
All answer objects keyed by question name. |
nouls |
cached_property |
dict[str, NoulAnswer] |
— | Yes/no answers keyed by question name. |
choices |
cached_property |
dict[str, ChoiceAnswer] |
— | Choice answers keyed by question name. |
scores |
cached_property |
dict[str, ScoreAnswer] |
— | Score answers keyed by question name. |
request_id |
cached_property |
str |
— | The x-typesafe-request-id response header. |
raw_http_response |
property |
httpx2.Response |
— | The underlying httpx2.Response (status, headers, body). |
Attribute names verified against both raw/docs/sdk__python__api__types__responses.md and _core/response_types.py: the three views are nouls, choices, scores — plural, and they are properties on the response, not on answers.
The three views are computed by filtering answers with isinstance, so answers remains the complete map and the views are mutually exclusive subsets. They are cached: the dicts are rebuilt only once per response object.
request_id and raw_http_response are runtime metadata stored in the instance __dict__ rather than schema fields; both raise TypeSafeError if the response was not built from a real HTTP response ("The response did not include a request ID." / "The response was not created from a raw HTTP response."). The source comment spells out why that location matters under Pydantic: the metadata is "kept in __dict__ where model_dump and field iteration never see it". 0.6.0 carried it across copies and pickles with hand-written __copy__ / __reduce__; 0.7.0 deleted both when Response became a BaseModel, leaving Pydantic's own copy/pickle machinery in charge.
from typesafe_sdk import Choice, Noul, Score, TypeSafeClient
with TypeSafeClient() as client:
response = client.system_one(
state={"document": "I was charged twice. Please fix this ASAP."},
questions={
"billing": 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"],
),
},
)
print(response.model, response.request_id)
print(response.nouls["billing"].noul) # 0.0 – 1.0
print(response.choices["tone"].choice) # "calm" | "frustrated" | "angry"
print(response.choices["tone"].probabilities) # {"calm": 0.1, ...}
print(response.scores["urgency"].score) # e.g. 1.7
print(response.scores["urgency"].legend) # {0: "can wait", 1: "this week", 2: "today"}
print(response.usage.input_tokens, response.usage.output_tokens)
Serializing a response (0.7.x — the 0.6.0 msgspec.json.encode(response) no longer applies):
print(response.model_dump_json()) # JSON string
payload = response.model_dump() # plain dict
model_dump() covers the schema fields only — model, usage, answers (each answer including its type). request_id and raw_http_response live in the instance __dict__ and are deliberately excluded.
Iterating everything, type-agnostically:
from typesafe_sdk import ChoiceAnswer, NoulAnswer, ScoreAnswer
for name, answer in response.answers.items():
if isinstance(answer, NoulAnswer):
print(name, "noul", answer.noul)
elif isinstance(answer, ChoiceAnswer):
print(name, "choice", answer.choice, answer.confidence)
elif isinstance(answer, ScoreAnswer):
print(name, "score", answer.score, answer.confidence)
Answers
Answer: TypeAlias = Annotated[
NoulAnswer | ChoiceAnswer | ScoreAnswer,
Field(discriminator="type"),
]
That Annotated[..., Field(discriminator="type")] wrapper is the 0.7.0 form (0.6.0 was a bare union relying on msgspec's tagged structs). Each answer type is frozen, keyword-only, and now carries type as a real defaulted field — NoulAnswer declares type: Literal["noul"] = "noul", and likewise for the other two — so type shows up in model_dump() and in the docs' field lists.
NoulAnswer
| Attribute | Type | Description |
|---|---|---|
noul |
float |
"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." |
type |
Literal['noul'] |
Discriminator, defaulted to "noul". |
NoulAnswer has no confidence and no probabilities: the single float is the calibrated probability. See Noul (yes/no) questions and Confidence vs probability.
ChoiceAnswer
| Attribute | Type | Description |
|---|---|---|
choice |
str |
"The name of the choice with the highest probability among the question's criteria" — one of your Choice.criteria keys. |
confidence |
float |
"Confidence in the selected choice, from 0 to 1." |
probabilities |
dict[str, float] |
"Probability of each choice in criteria, keyed by choice name, from 0 to 1 … values sum to approximately 1." |
type |
Literal['choice'] |
Discriminator, defaulted to "choice". |
ScoreAnswer
| Attribute | Type | Description |
|---|---|---|
score |
float |
"Expected score: the probability-weighted average of the rubric levels. May fall between integer levels." |
confidence |
float |
"Confidence in the score, from 0 to 1." |
legend |
dict[int, str | dict[str, Any] | list[Any]] |
Rubric descriptions keyed by integer score. |
probabilities |
dict[int, float] |
Probabilities keyed by integer score. |
type |
Literal['score'] |
Discriminator, defaulted to "score". |
Key typing detail: JSON object keys are strings on the wire, and the public ScoreAnswer declares dict[int, ...] so the keys are coerced to integers at validation time — the source comment now reads "dict[int, ...] tells Pydantic to coerce them to the integer score levels" (it said msgspec in 0.6.0). So answer.probabilities[2] (int key), not answer.probabilities["2"]. This is the inverse of Score.criteria, which since 0.6.0 is an ordered sequence — see Python SDK question types (Noul, Choice, Score). Note this int coercion is a deliberate exception to the models' strict=True, achieved by declaring the key type rather than by relaxing the config.
score = response.scores["urgency"]
top_level = max(score.probabilities, key=score.probabilities.get)
print(top_level, score.legend[top_level], score.probabilities[top_level])
Confidence-gated routing, the canonical use of confidence:
tone = response.choices["tone"]
if tone.confidence >= 0.85:
auto_route(tone.choice)
else:
send_to_human(tone.probabilities)
Usage
| Attribute | Type | Default | Description |
|---|---|---|---|
input_tokens |
int | None |
None |
"Number of input tokens used, or None when the API did not report it." |
output_tokens |
int | None |
None |
"Number of output tokens used, or None when the API did not report it." |
Usage now subclasses the generated wire.Usage (in 0.6.0 it was a standalone msgspec Struct) and overrides both fields to make them optional: the wire model declares them required (input_tokens: int, "Number of billable input tokens used to evaluate the request"; output_tokens: int, "Output tokens are currently free of charge"), while the public type defaults both to None. Keep treating them as optional.
billing_units is gone. In the 0.6.0 snapshot the generated wire struct required billing_units: int while the public Usage omitted it, and _core/response_types.py carried the comment "The OpenAPI Usage schema still requires billing_units, which the API does not return." In the 0.7.1 snapshot the regenerated _schemas/models.py has no billing_units at all, the comment is gone, and raw/site/openapi.json no longer mentions it. A server that still sent it would be silently dropped by extra="ignore" — the repo's tests/test_responses.py asserts exactly that (assert not hasattr(result.usage, "billing_units") for a body containing "billing_units": 1). Do not expect response.usage.billing_units in Python. See OpenAPI component schemas and Models, aliases, pricing, rate limits, context.
Always treat both counts as optional:
usage = response.usage
if usage.input_tokens is not None:
meter(usage.input_tokens, usage.output_tokens or 0)
Raw HTTP access and forward compatibility
0.7.0 replaced the old two-pass msgspec decode (fast tagged decode, then per-answer dispatch) with a single pre-pass plus one Pydantic validation, in _core/response_types.py:
- Pre-pass —
_prepare_system_one_responsedecodes the body withpydantic_core.from_json, then walksanswers. An entry that is not an object, or whosetypeis not a string, raisesTypeSafeAPIResponseValidationErrorwithfield_path = "answers.<name>.type". An entry whosetypeis outside{"noul", "choice", "score"}is deleted and logged:logger.warning("Ignoring answer %r with unrecognized type %r", ...). If yourresponse_modeldeclares extra answer fields of its own, those answers are also copied up to top-level keys here. - Validation — the cleaned body is re-serialized and run through
model_validate_json. TheAnswerunion is a discriminated union ontype, so each answer goes straight to its class. AValidationErrorbecomesTypeSafeAPIResponseValidationErrorwith a dottedfield_pathrendered byformat_path— integer locations become bracketed indices (models[1].name), a synthetic[key]segment is dropped, and the discriminator segment Pydantic inserts for a union member (answers.tone.choice.confidence) is removed so the path staysanswers.tone.confidence.
Unknown extra fields on recognized objects are ignored (model_config = ConfigDict(extra="ignore", …)), so a newer server never breaks an older client.
To read answer kinds this SDK version does not model, go to the raw body:
raw_answers = response.raw_http_response.json()["answers"]
raw_http_response also gives you status, headers, and elapsed information:
http = response.raw_http_response
print(http.status_code, http.headers.get("x-typesafe-request-id"))
| Where | request_id behavior |
|---|---|
SystemOneResponse.request_id / ListModelsResponse.request_id |
str; raises TypeSafeError if the header was absent |
TypeSafeAPIError.request_id |
str | None; returns None if the header was absent |
Listing models
client.models.list() (sync) / await client.models.list() (async) issues GET /v1/models and returns:
ListModelsResponse
| Member | Kind | Type | Description |
|---|---|---|---|
models |
instance attribute | tuple[ModelMetadata, ...] |
The models available to the account. |
request_id |
cached_property |
str |
The x-typesafe-request-id response header. |
raw_http_response |
property |
httpx2.Response |
The underlying HTTP response. |
Note it is a tuple, not a list, and the response is frozen.
ModelMetadata
| Attribute | Type | Description |
|---|---|---|
name |
str |
Model name, e.g. an alias like jev-latest or a pinned id. |
description |
str |
Human-readable description. |
release_date |
str |
Release date as a string (no date parsing in the SDK). |
ModelMetadata is the class the wire schema calls ModelMetadata and the API groups under ModelMetadataList; there is no ModelCard symbol in the Python SDK. As of 0.7.0 the public ModelMetadata is declared directly in _core/response_types.py as a Schema subclass with its own docstrings ("Metadata describing a single available model"; name — "Model name or alias accepted by a request's model field"; release_date — "Model release date, formatted as YYYY-MM-DD"), rather than being re-exported from the generated _schemas/models.py as it was in 0.6.0. from typesafe_sdk import ModelMetadata is unaffected.
list() parameters (identical on Models and AsyncModels, all keyword-only):
| Parameter | Type | Default | Description |
|---|---|---|---|
retry |
RetryPolicy | None |
None |
Per-call retry override. |
timeout |
float | httpx2.Timeout | None |
None |
Per-operation timeout override; None inherits the client setting. |
extra_headers |
Mapping[str, str] | None |
None |
Extra headers; authentication, SDK identification, and Accept remain protected. |
Raises TypeSafeAPIError (unsuccessful HTTP after retries) and TypeSafeAPIConnectionError (cannot connect or timed out after retries).
from typesafe_sdk import TypeSafeClient
with TypeSafeClient() as client:
models = client.models.list()
for card in models.models:
print(f"{card.name}\t{card.release_date}\t{card.description}")
import asyncio
from typesafe_sdk import AsyncTypeSafeClient
async def main() -> None:
async with AsyncTypeSafeClient() as client:
models = await client.models.list()
print([card.name for card in models.models])
asyncio.run(main())
Custom response models (response_model, 0.7.0+)
system_one(..., response_model=SomeModel) returns SomeModel instead of SystemOneResponse. Two shapes, both from the upstream usage guide:
from typesafe_sdk import Noul, NoulAnswer, SystemOneResponse, TypeSafeClient
class BillingResponse(SystemOneResponse):
billing: NoulAnswer
with TypeSafeClient() as client:
result = client.system_one(
"I was charged twice.",
{"billing": Noul(instructions="Is this about billing?")},
response_model=BillingResponse,
)
assert 0 <= result.billing.noul <= 1
assert result.billing == result.nouls["billing"]
print(result.request_id)
from pydantic import BaseModel
from typesafe_sdk import Noul, NoulAnswer, TypeSafeClient
class BillingAnswers(BaseModel):
billing: NoulAnswer
class BillingResponse(BaseModel):
answers: BillingAnswers
result = TypeSafeClient().system_one(
"I was charged twice.",
{"billing": Noul(instructions="Is this about billing?")},
response_model=BillingResponse,
)
assert 0 <= result.answers.billing.noul <= 1
| Model kind | Decoded by | Answer lifting | request_id / raw_http_response |
Unknown answer kinds |
|---|---|---|---|---|
Subclass of SystemOneResponse |
the SDK path (_decode) |
yes — fields you add beyond SystemOneResponse's own are copied from answers to top level |
available | dropped + warned |
Plain pydantic.BaseModel |
model_validate_json(response.content) |
no — mirror the wire shape yourself (answers: …) |
not available | not filtered; your model must accept or ignore them |
parse_response in _core/schemas/base.py picks the branch with issubclass(response_type, _ResponseMixin). Either way a mismatch raises TypeSafeAPIResponseValidationError with the first error's dotted field_path, and a non-2xx status raises the matching TypeSafeAPIError before any validation happens. ResponseT is bound to pydantic.BaseModel, so a dataclass or TypedDict will not type-check.
Error cases
| Situation | Result |
|---|---|
| Non-2xx status | The matching TypeSafeAPIError subclass is raised by parse_response; no response object is returned. |
| 2xx with a missing/invalid required field | TypeSafeAPIResponseValidationError with .field_path (e.g. answers.tone.confidence). |
2xx with an answer that is not an object, or whose type is not a string |
TypeSafeAPIResponseValidationError with .field_path == "answers.<name>.type". |
| 2xx whose body is not a JSON object at all | TypeSafeAPIResponseValidationError with an empty .field_path. |
2xx with an unknown answer type |
That answer is dropped from .answers and logged at WARNING; the rest decode normally. |
| 2xx with unknown extra fields | Ignored (extra="ignore"). |
2xx where a declared int arrives as a string |
Validation error — the models are strict=True, except ScoreAnswer's int-keyed dicts. |
Response header x-typesafe-request-id absent |
.request_id raises TypeSafeError. |
Full exception hierarchy: Python SDK retries, exceptions, constants.
Version notes
- Described for
typesafe-sdk0.7.1 (repo captured at commit0ffd094c72ed9445223060b24ffd7a56aa781fb4, 2026-09-21). - 0.7.0 moved every response and answer type from msgspec
Structtopydantic.BaseModel. Field names, types and the wire JSON are unchanged; what changed is the serialization API (model_dump()/model_dump_json()instead ofmsgspec.json.encode), the explicittypefield on each answer, the discriminatedAnswerunion, and the single-pass decoder. See Python SDK: install, clients, system_one() "Migrating from 0.6.0". Usage.billing_unitswas dropped from the regenerated wire schema in this refresh — seeUsage.
Related
- Python SDK: install, clients, system_one() — clients and
system_one() - Python SDK question types (Noul, Choice, Score) — the questions that produce these answers
- Python SDK retries, exceptions, constants — exceptions raised instead of a response
- HTTP API: POST /v1/systemone and GET /v1/models — the JSON body these types decode
- OpenAPI component schemas — the generated wire schemas, including
billing_units - Models, aliases, pricing, rate limits, context — model names, aliases, pricing
- Confidence vs probability — what
confidencemeans versusprobabilities - Score questions · Choice questions · Noul (yes/no) questions
- Confidence-gated routing — routing on
confidence
Sources
- raw/docs/sdk__python__api__types__responses.md (https://docs.typesafe.ai/sdk/python/api/types/responses.md)
- raw/docs/sdk__python__api__clients__sync.md (https://docs.typesafe.ai/sdk/python/api/clients/sync.md)
- raw/docs/sdk__python__api__clients__async.md (https://docs.typesafe.ai/sdk/python/api/clients/async.md)
- raw/docs/sdk__python__usage.md (https://docs.typesafe.ai/sdk/python/usage.md) — the
response_modelexamples - raw/github/typesafe-sdk-python/src/typesafe_sdk/_core/response_types.py, _core/schemas/base.py, _core/client/sync/models.py, _schemas/models.py, tests/test_responses.py (https://github.com/typesafe-ai/typesafe-sdk-python @ 0ffd094c72ed9445223060b24ffd7a56aa781fb4, captured 2026-09-21)