---
title: "Python SDK responses, answers, usage, models"
type: reference
tags: [python, sdk, responses, answers, usage, models]
created: 2026-09-17
updated: 2026-09-21
confidence: high
sources:
  - raw/docs/sdk__python__api__types__responses.md
  - raw/docs/sdk__python__api__clients__sync.md
  - raw/docs/sdk__python__api__clients__async.md
  - raw/docs/sdk__python__usage.md
  - raw/github/typesafe-sdk-python/src/typesafe_sdk/_core/response_types.py
  - raw/github/typesafe-sdk-python/src/typesafe_sdk/_core/schemas/base.py
  - raw/github/typesafe-sdk-python/src/typesafe_sdk/_schemas/models.py
jev_version: "jev-1.13.0"
sdk_python: "0.7.1"
summary: "SystemOneResponse fields and the .nouls/.choices/.scores views, every answer attribute, Usage, request_id and raw_http_response, ListModelsResponse and ModelMetadata, plus response_model in 0.7.x."
---

# Python SDK responses, answers, usage, models

> **TL;DR** `system_one()` returns a frozen `SystemOneResponse` with `.model`, `.usage`, `.answers` plus three cached views — the attribute names are exactly **`.nouls`**, **`.choices`**, **`.scores`** (plural, lowercase). Read `response.nouls[name].noul` (float 0–1), `response.choices[name].choice` / `.confidence` / `.probabilities`, `response.scores[name].score` / `.confidence` / `.legend` / `.probabilities`. `.request_id` and `.raw_http_response` expose the HTTP layer. `client.models.list()` returns a `ListModelsResponse` of `ModelMetadata`. Since 0.7.0 every one of these is a **Pydantic model** (so `model_dump()` / `model_dump_json()` work), and `system_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:

```python
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.

```python
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):

```python
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:

```python
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

```python
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 [[concepts/noul]] and [[concepts/confidence]].

### `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 [[reference/python-sdk-questions]]. 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.

```python
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`:

```python
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 [[reference/openapi-schemas]] and [[reference/models-and-pricing]].

Always treat both counts as optional:

```python
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`:

1. **Pre-pass** — `_prepare_system_one_response` decodes the body with `pydantic_core.from_json`, then walks `answers`. An entry that is not an object, or whose `type` is not a string, raises `TypeSafeAPIResponseValidationError` with `field_path = "answers.<name>.type"`. An entry whose `type` is outside `{"noul", "choice", "score"}` is **deleted** and logged: `logger.warning("Ignoring answer %r with unrecognized type %r", ...)`. If your `response_model` declares extra answer fields of its own, those answers are also copied up to top-level keys here.
2. **Validation** — the cleaned body is re-serialized and run through `model_validate_json`. The `Answer` union is a discriminated union on `type`, so each answer goes straight to its class. A `ValidationError` becomes `TypeSafeAPIResponseValidationError` with a dotted `field_path` rendered by `format_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 stays `answers.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:

```python
raw_answers = response.raw_http_response.json()["answers"]
```

`raw_http_response` also gives you status, headers, and elapsed information:

```python
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).

```python
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}")
```

```python
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:

```python
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)
```

```python
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: [[reference/python-sdk-retries-errors]].

## Version notes

- Described for `typesafe-sdk` **0.7.1** (repo captured at commit `0ffd094c72ed9445223060b24ffd7a56aa781fb4`, 2026-09-21).
- 0.7.0 moved every response and answer type from msgspec `Struct` to `pydantic.BaseModel`. Field names, types and the wire JSON are unchanged; what changed is the serialization API (`model_dump()` / `model_dump_json()` instead of `msgspec.json.encode`), the explicit `type` field on each answer, the discriminated `Answer` union, and the single-pass decoder. See [[reference/python-sdk]] "Migrating from 0.6.0".
- `Usage.billing_units` was dropped from the regenerated wire schema in this refresh — see [`Usage`](#usage).

## Related

- [[reference/python-sdk]] — clients and `system_one()`
- [[reference/python-sdk-questions]] — the questions that produce these answers
- [[reference/python-sdk-retries-errors]] — exceptions raised instead of a response
- [[reference/http-api]] — the JSON body these types decode
- [[reference/openapi-schemas]] — the generated wire schemas, including `billing_units`
- [[reference/models-and-pricing]] — model names, aliases, pricing
- [[concepts/confidence]] — what `confidence` means versus `probabilities`
- [[concepts/score]] · [[concepts/choice]] · [[concepts/noul]]
- [[patterns/confidence-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_model` examples
- 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)
