Python SDK internals: headers, key validation, gateways and base URLs, logging, forward compatibility, dependencies, migration notes
TL;DR The detail behind the builder contract on Python SDK: install, clients, system_one(): full constructor signatures and config resolution, the 0.7.1 API-key validation, the headers and wire body the SDK sends, the
modelsresource,TYPESAFE_*variables, pointingbase_urlat OpenRouter or Vercel AI Gateway, logging and redaction, forward-compatibility escape hatches, runtime dependencies, the export list,response_modelmechanics, and 0.6.0 → 0.7.x migration notes. Documentstypesafe-sdk0.7.1. If you just need to make a call, read Python SDK: install, clients, system_one() instead.
Requirements
| Item | Value | Source |
|---|---|---|
requires-python |
>=3.10 |
pyproject.toml |
| Declared Python classifiers | 3.10, 3.11, 3.12, 3.13, 3.14 | pyproject.toml |
| License | MIT (LICENSE shipped) |
pyproject.toml |
| Typing | Typing :: Typed, ships py.typed |
pyproject.toml, src/typesafe_sdk/py.typed |
| Build backend | uv_build>=0.12.5,<0.13 |
pyproject.toml |
| Author / maintainer | TypeSafe AI <support@typesafe.ai> / Daniel Gafni <daniel@typesafe.ai> |
pyproject.toml |
Runtime dependencies ([project].dependencies):
| Dependency | Constraint | Used for |
|---|---|---|
httpx2 |
>=2.0.0 |
HTTP transport, Timeout, Headers, Response |
pydantic |
>=2.12.0 |
question/response models (BaseModel, ConfigDict, ValidationError) |
pydantic-core |
>=2.41.1 |
JSON codec (to_json / from_json) in _core/json.py |
tenacity |
>=9.0.0 |
retry loop (Retrying / AsyncRetrying) |
typing-extensions |
>=4.13.0 |
Self, override, NotRequired, TypedDict, TypeAliasType |
msgspec>=0.21.1 was a runtime dependency up to 0.6.0 and is gone as of 0.7.0; the SDK no longer imports msgspec anywhere.
Project URLs: Homepage https://typesafe.ai, Documentation https://docs.typesafe.ai/sdk/python/, Changelog https://docs.typesafe.ai/sdk/python/changelog/, Repository https://github.com/typesafe-ai/typesafe-sdk-python, Issues .../issues.
Public exports
typesafe_sdk.__all__ (38 names, verbatim from src/typesafe_sdk/__init__.py):
| Group | Names |
|---|---|
| Clients | TypeSafeClient, AsyncTypeSafeClient, Models, AsyncModels |
| Questions | Noul, Choice, Score, NoulCriteria, NoulModel, ChoiceModel, ScoreModel, QuestionModel, Question, Questions |
| Responses | SystemOneResponse, Answer, NoulAnswer, ChoiceAnswer, ScoreAnswer, Usage, ListModelsResponse, ModelMetadata |
| JSON types | JSONContent, JSONValue |
| Retry | RetryPolicy |
| Errors | TypeSafeError, TypeSafeAPIError, TypeSafeAPIConnectionError, TypeSafeAPITimeoutError, TypeSafeAPIResponseValidationError, TypeSafeAuthenticationError, TypeSafeBadRequestError, TypeSafeInternalServerError, TypeSafeNotFoundError, TypeSafePermissionDeniedError, TypeSafeRateLimitError, TypeSafeUnprocessableEntityError |
| Submodule | constants |
__version__ is also importable (from typesafe_sdk import __version__) although it is not listed in __all__; it is resolved at import time with importlib.metadata.version("typesafe-sdk").
Everything else lives under typesafe_sdk._core / typesafe_sdk._schemas and is private. __init__.py ends with del _core, so typesafe_sdk._core is not bound as an attribute of the package after import even though the submodule itself is importable.
Clients in full
| Sync | Async | |
|---|---|---|
| Class | TypeSafeClient |
AsyncTypeSafeClient |
| Call | client.system_one(...) |
await client.system_one(...) |
| Models resource | client.models → Models |
client.models → AsyncModels |
| List models | client.models.list() |
await client.models.list() |
| Close | close() |
await aclose() |
| Context manager | with ... as client |
async with ... as client |
transport type |
httpx2.BaseTransport |
httpx2.AsyncBaseTransport |
http_client type |
httpx2.Client |
httpx2.AsyncClient |
Constructor signatures
Both constructors are keyword-only (def __init__(self, *, ...)); there are no positional parameters.
TypeSafeClient(
*,
api_key: str | None = None,
model: str | None = None,
retry: RetryPolicy | None = None,
timeout: float | httpx2.Timeout | None = None,
headers: Mapping[str, str] | None = None,
transport: httpx2.BaseTransport | None = None,
http_client: httpx2.Client | None = None,
base_url: str | None = None,
)
AsyncTypeSafeClient(
*,
api_key: str | None = None,
model: str | None = None,
retry: RetryPolicy | None = None,
timeout: float | httpx2.Timeout | None = None,
headers: Mapping[str, str] | None = None,
transport: httpx2.AsyncBaseTransport | None = None,
http_client: httpx2.AsyncClient | None = None,
base_url: str | None = None,
)
api_key, model, retry, timeout and base_url are tabled on Python SDK: install, clients, system_one(). The remaining three:
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
headers |
Mapping[str, str] | None |
no | None |
Additional default request headers. |
transport |
httpx2.BaseTransport / httpx2.AsyncBaseTransport | None |
no | None |
Custom transport, closed when the SDK client closes. Mutually exclusive with http_client. |
http_client |
httpx2.Client / httpx2.AsyncClient | None |
no | None |
Bring your own HTTP client. Closed when the SDK client closes. Mutually exclusive with transport. |
Raises:
| Exception | When |
|---|---|
TypeSafeError |
No API key resolved, the API key is invalid, or timeout is not a positive finite number / httpx2.Timeout. |
ValueError |
Both transport and http_client supplied ("transport and http_client are mutually exclusive."). |
Resolution rules (from _core/config.py):
- Explicit arguments win over environment variables.
- Empty or whitespace-only environment values are ignored and fall back to the default.
base_urlisrstrip("/")-ed.timeoutis validated byresolve_timeout: a non-httpx2.Timeoutvalue must be finite and> 0. Withtimeout=Noneand anhttp_clientsupplied, the client's ownhttp_client.timeoutis used; otherwise10.0(constants.DEFAULT_TIMEOUT).- The resolved
api_keyis stored on a dataclass field withrepr=False, as are the default headers, so it does not leak throughrepr().
Attributes and lifecycle
| Member | Kind | Type | Notes |
|---|---|---|---|
models |
cached_property |
Models / AsyncModels |
Built once per client instance. |
system_one(...) |
method / async method |
→ SystemOneResponse | ResponseT |
ResponseT only when response_model is passed. |
close() / aclose() |
method / async method |
None |
Closes the underlying HTTP client, including one you supplied via http_client. |
__enter__ / __exit__ |
context manager | — | Sync client only. |
__aenter__ / __aexit__ |
async context manager | — | Async client only. |
Context-manager usage is the documented default in every upstream example. Because close()/aclose() also close a user-supplied http_client, do not share one httpx2.Client across several TypeSafeClient instances whose lifetimes differ.
API key validation (0.7.1)
resolve_and_validate_api_key in _core/config.py runs during Config.create, i.e. inside the constructor:
| Rule | Behavior |
|---|---|
| Whitespace | Stripped from both ends, "including newlines from key files". |
| Empty after stripping | TypeSafeError("No API key was provided. Pass api_key or set the TYPESAFE_API_KEY environment variable.") |
| Non-ASCII, non-printable, or containing a space | TypeSafeError("API key must contain only printable ASCII characters without whitespace.") |
Explicitly empty api_key="" |
Does not fall back to the environment (the docs state: "An explicitly empty key does not fall back to the environment."). |
The usage guide states the consequence plainly: "Invalid API keys raise TypeSafeError during client creation, before any request or retry."
Also new in 0.7.1: credentials are stripped from exception text. _core/logging.py:redact_exception copies a transport exception's message, chain, and notes with every secret-header value (and the credential part of Authorization / Proxy-Authorization) replaced by ***, in raw, repr-escaped, byte-repr, and JSON-escaped forms, and detaches the unredacted original from __context__ before re-raising as TypeSafeAPIConnectionError / TypeSafeAPITimeoutError. Step-by-step table in Python SDK retries, exceptions, constants.
system_one() internals
The signature and parameter table are on Python SDK: install, clients, system_one(). state is JSONContent (str | Mapping[str, JSONValue | None] | Sequence[JSONValue | None]). Returns, verbatim: "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." It is published as two @overloads so the return type is exact:
| Overload | response_model |
Returns |
|---|---|---|
| Overload 1 | None = None |
SystemOneResponse |
| Overload 2 | type[ResponseT] (required, no default) |
ResponseT |
ResponseT is a TypeVar bound to pydantic.BaseModel (_core/schemas/base.py); it is not exported from typesafe_sdk. The async version has the same signature and overloads and is async def.
Raises:
| Exception | When |
|---|---|
TypeSafeError |
questions is empty; a Score question's criteria is empty; a dict question lacks a nonempty string "type"; a "choice"/"score" dict question has no "criteria" key; or the body cannot be JSON-encoded. |
TypeSafeAPIError (and subclasses) |
The server returned an unsuccessful HTTP status after any retries. |
TypeSafeAPIConnectionError / TypeSafeAPITimeoutError |
The request could not connect, or timed out, after any retries. |
TypeSafeAPIResponseValidationError |
"The response body does not match the response model." |
The docs' "Raises" block for
system_onelists only the empty-questions and empty-score-criteria cases plus the three HTTP/validation errors;_core/questions.pyadditionally raisesTypeSafeErrorfor a malformed question dictionary and for achoice/scoredict with no"criteria"key, and_core/transport.pyraises it when the body cannot be encoded as JSON (now catchingPydanticSerializationError,TypeError,ValueError).
Typed responses with response_model
Upstream shows two shapes (raw/docs/sdk__python__usage.md). Subclass SystemOneResponse to lift named answers onto attributes while keeping .nouls / .choices / .scores, .request_id and .raw_http_response:
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)
Or define a model from scratch: "It is also possible to define a completely new response model without inheriting from SystemOneResponse":
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
Mechanism (_core/schemas/base.py:parse_response, _core/response_types.py): a response_model that inherits the SDK's response base goes through the SDK decoder — unknown answer kinds are dropped, and any field you declared beyond SystemOneResponse's own is lifted out of answers into a top-level key before validation. A plain BaseModel is validated directly with model_validate_json(response.content), gets no request_id / raw_http_response, and raises TypeSafeAPIResponseValidationError (with a dotted field_path) when the body does not match. More in Python SDK responses, answers, usage, models.
Wire request built
_core/endpoints.py:prepare_system_one sends POST {base_url}/v1/systemone with the body:
{"state": ..., "model": "...", "questions": {...}}
model is always present (client default when the per-call override is None), then extra_body is applied with body.update(extra_body). Since 0.7.0 prepare_system_one also takes the response_type to decode into (SystemOneResponse when response_model is None). See HTTP API: POST /v1/systemone and GET /v1/models for the wire contract.
Headers the SDK sets
Set on every request from _core/transport.py:prepare (user headers/extra_headers are merged first, then these overwrite them — so authentication, Accept, and SDK identification cannot be overridden):
| Header | Value |
|---|---|
Authorization |
Bearer {api_key} |
Accept |
application/json |
User-Agent |
typesafe-sdk/{__version__} |
X-TypeSafe-SDK |
typesafe-sdk/{__version__} |
X-TypeSafe-Runtime |
python/{platform.python_version()} ({sys.platform}; {platform.machine()}) |
Content-Type |
application/json (only when a body is sent) |
X-TypeSafe-Retry-Count |
attempt number, added only on retries; any caller-supplied value is dropped first |
The response header x-typesafe-request-id is surfaced as response.request_id and error.request_id.
Environment variables
| Variable | Configures | Default | Constant |
|---|---|---|---|
TYPESAFE_API_KEY |
API key (required) | — | constants.API_KEY_ENV |
TYPESAFE_BASE_URL |
API root URL | https://api.typesafe.ai |
constants.BASE_URL_ENV / DEFAULT_BASE_URL |
TYPESAFE_DEFAULT_MODEL |
Default model | jev-latest |
constants.DEFAULT_MODEL_ENV / DEFAULT_MODEL |
TYPESAFE_LOG_LEVEL |
typesafe_sdk logger level, applied once at import |
unset | constants.LOG_LEVEL_ENV |
See TYPESAFE_* environment variables across SDKs and Python SDK retries, exceptions, constants for the constants module in full.
models resource
client.models is a cached property returning Models (sync) or AsyncModels (async). It has one method:
list(
*,
retry: RetryPolicy | None = None,
timeout: float | httpx2.Timeout | None = None,
extra_headers: Mapping[str, str] | None = None,
) -> ListModelsResponse
| 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. |
Issues GET {base_url}/v1/models. Returns ListModelsResponse whose .models is a tuple[ModelMetadata, ...] of name, description, release_date — see Python SDK responses, answers, usage, models and Models, aliases, pricing, rate limits, context.
from typesafe_sdk import TypeSafeClient
with TypeSafeClient() as client:
for card in client.models.list().models:
print(card.name, card.release_date, card.description)
Select a model when constructing a client:
client = TypeSafeClient(model="jev")
Note: "jev" is the upstream usage-guide sample verbatim (raw/docs/sdk__python__usage.md), but it is not among the names listed on Models, aliases, pricing, rate limits, context (jev-latest, jev-preview, jev-1.13.0). Prefer model="jev-latest" or an explicit versioned id, and confirm with client.models.list().
Pointing the client at another base URL (AI gateways)
"In order to use the SDK with a different API url, set base_url on the client or the TYPESAFE_BASE_URL environment variable." 0.7.1 added two worked gateway examples to the usage guide; both are reproduced verbatim. Upstream marks each block skip: next (they are not executed in the docs test suite).
OpenRouter — "Use an OpenRouter API key and an OpenRouter model ID":
import os
from typesafe_sdk import Noul, TypeSafeClient
with TypeSafeClient(
api_key=os.environ["OPENROUTER_API_KEY"],
base_url="https://openrouter.ai/api",
model="~typesafe/jev-latest",
) as client:
result = client.system_one(
"I was charged twice.",
{"billing": Noul(instructions="Is this about billing?")},
)
print(result.nouls["billing"].noul)
Vercel AI Gateway — "Vercel's TypeSafe-compatible API can be used with the SDK":
import os
from typesafe_sdk import Noul, TypeSafeClient
with TypeSafeClient(
api_key=os.environ["AI_GATEWAY_API_KEY"],
base_url="https://ai-gateway.vercel.sh/typesafe",
model="typesafe-ai/jev",
) as client:
result = client.system_one(
"I was charged twice.",
{"billing": Noul(instructions="Is this about billing?")},
)
print(result.nouls["billing"].noul)
Upstream's one caveat: "This requires the alternative API to follow the TypeSafe OpenAPI spec." Note the gateway key still passes the 0.7.1 API-key validation (printable ASCII, no whitespace), and that neither gateway's pricing or rate limits are covered by Models, aliases, pricing, rate limits, context.
Migrating from 0.6.0
Breaking change in 0.7.0: msgspec → pydantic, system_one(..., response_model=) added, str subclasses serialized correctly; Score.criteria stays an ordered sequence. The code-level before/after table is in Python SDK changelog (v0.7.0, "What the breaking change means in code"); pin typesafe-sdk>=0.7.1,<0.8.
Logging
The SDK logs to the typesafe_sdk logger (logging.getLogger("typesafe_sdk")) and attaches a NullHandler plus a SensitiveHeadersFilter. It never configures handlers for you.
import logging
logging.getLogger("typesafe_sdk").setLevel(logging.DEBUG)
Or set TYPESAFE_LOG_LEVEL before importing the SDK; it is applied once at import.
| Level string | Effect |
|---|---|
debug |
logging.DEBUG — also logs request and response headers and bodies |
info |
logging.INFO — one summary line per request (METHOD url <- status in Nms (request <id>)) plus a line per retry |
warn |
logging.WARNING (accepted by the source; not listed in the docs page) |
warning |
logging.WARNING |
error |
logging.ERROR |
off |
logging.CRITICAL + 1 |
Redaction: header names in {authorization, proxy-authorization, x-api-key, api-key, cookie, set-cookie}, plus any header name containing token or secret (case-insensitive), are replaced with ***. Request and response bodies are not redacted — debug will print your state and the model's answers.
The SDK also logs Ignoring answer %r with unrecognized type %r at WARNING when the API returns an answer kind this version does not model.
Forward compatibility
| Need | Mechanism |
|---|---|
| Send a request field newer than the SDK | extra_body={"beam_width": 4} |
| Send a question field newer than the SDK | Pass the question as a plain dict: {"type": "noul", "instructions": "...", "weight": 2} |
| Read an answer kind newer than the SDK | The SDK logs a warning, skips it, and you read result.raw_http_response.json()["answers"] |
| Unknown extra fields on known responses | Silently ignored (model_config = ConfigDict(extra="ignore", ...)) |
One 0.7.0 wrinkle on the second row: the question TypedDicts are now declared closed=True (they were extra_items=JSONValue | None in 0.6.0), so a dict question carrying an unmodelled key no longer type-checks. It is still sent, and the docs keep the escape hatch with the note: "Unknown fields are a forward-compatibility escape hatch. Ignore their type-checking errors and prefer upgrading the SDK instead." See Python SDK question types (Noul, Choice, Score).
from typesafe_sdk import Noul, TypeSafeClient
with TypeSafeClient() as client:
client.system_one(
"I was charged twice.",
{"billing": Noul(instructions="About billing?")},
extra_body={"beam_width": 4},
)
More complete examples
The sync quickstart, with rate-limit and API-error handling added, is the example on Python SDK: install, clients, system_one().
Async, verbatim from the upstream usage guide:
import asyncio
from typesafe_sdk import AsyncTypeSafeClient, Choice, Noul, Score
async def main() -> None:
async with AsyncTypeSafeClient() as client:
result = await client.system_one(
"I was charged twice. Please help ASAP.",
{
"billing": Noul(instructions="Is this about billing?"),
"tone": Choice(
instructions="What is the tone?",
criteria={"calm": None, "angry": None},
),
"urgency": Score(
instructions="How urgent is this?",
criteria=["low", "medium", "high"],
),
},
)
print(
result.nouls["billing"].noul,
result.choices["tone"].choice,
result.scores["urgency"].score,
)
asyncio.run(main())
Per-call overrides plus error handling. The client-level model="jev" is the usage-guide value discussed under models resource (not a listed model id); here the per-call model="jev-latest" overrides it:
from typesafe_sdk import Noul, RetryPolicy, TypeSafeAPIError, TypeSafeClient
with TypeSafeClient(model="jev") as client:
try:
result = client.system_one(
"I was charged twice.",
{"billing": Noul(instructions="Is this about billing?")},
model="jev-latest",
retry=RetryPolicy(max_retries=3, backoff_max=0.2, timeout=1.0),
timeout=5.0,
extra_headers={"X-Request-Source": "support-bot"},
)
except TypeSafeAPIError as error:
print(error.status, error.request_id)
else:
print(result.model, result.usage.input_tokens, result.request_id)
Version notes
- Version documented here:
typesafe-sdk0.7.1 (repo captured at commit0ffd094c72ed9445223060b24ffd7a56aa781fb4, 2026-09-21;pyproject.tomldeclaresversion = "0.7.1"). See Python SDK changelog. - 0.7.0 replaced
msgspecwithpydanticand addedresponse_model— see Migrating from 0.6.0. - 0.6.0 changed
Score.criteriafrom an int-keyed dict to an ordered sequence, and that is still the current form — see Python SDK question types (Noul, Choice, Score). Usage.billing_unitsis gone from the regenerated wire schema; the publicUsagemakes both token counts optional. Details in Python SDK responses, answers, usage, models.- The
sync/client,sync/models,async/clientandasync/modelsdoc pages were merged upstream into one page per client (/sdk/python/api/clients/syncand/sdk/python/api/clients/async); the old URLs redirect. - 2026-09-22: this page was split out of Python SDK: install, clients, system_one(), which now carries only the builder contract.
Related
- Python SDK: install, clients, system_one() — the builder contract: install, clients,
system_one(), answers, errors - Python SDK question types (Noul, Choice, Score) —
Noul,Choice,Scoreand their dict forms - Python SDK responses, answers, usage, models —
SystemOneResponse, answers, usage, models - Python SDK retries, exceptions, constants —
RetryPolicy, exceptions, constants, credential redaction - Python SDK changelog — release history and the 0.7.0 migration table
- TYPESAFE_* environment variables across SDKs —
TYPESAFE_*across SDKs - HTTP API: POST /v1/systemone and GET /v1/models — the wire contract the SDK speaks
- Models, aliases, pricing, rate limits, context — model ids to pass as
model
Sources
- raw/docs/sdk.md (https://docs.typesafe.ai/sdk.md)
- raw/docs/sdk__python.md (https://docs.typesafe.ai/sdk/python.md)
- raw/docs/sdk__python__usage.md (https://docs.typesafe.ai/sdk/python/usage.md)
- raw/docs/sdk__python__api.md (https://docs.typesafe.ai/sdk/python/api.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__api__constants.md (https://docs.typesafe.ai/sdk/python/api/constants.md)
- raw/docs/sdk__python__changelog.md (https://docs.typesafe.ai/sdk/python/changelog.md)
- raw/github/typesafe-sdk-python/README.md, pyproject.toml, src/typesafe_sdk/init.py, src/typesafe_sdk/_core/{config,transport,endpoints,json,logging,constants}.py, src/typesafe_sdk/_core/schemas/base.py (https://github.com/typesafe-ai/typesafe-sdk-python @ 0ffd094c72ed9445223060b24ffd7a56aa781fb4, captured 2026-09-21)