Wassitai
Ask an LLM a question, get back a validated Python object — not a string you have to parse.
Wassitai sends your prompt with a schema, picks the best structured-output method the provider
supports, then extracts, repairs, validates and coerces the reply through a deterministic pipeline.
If the result still doesn't fit the schema, it re-asks the model. Ships as the importable package
wassitai, with a REST API and a browser playground on top.
pip install wassitai # library
pip install "wassitai[api]" # + REST API and playground
Requirements: Python 3.9–3.13. Four runtime dependencies — pydantic>=2.11, httpx>=0.24,
json_repair>=0.25, xmltodict>=0.13. No vendor SDK is bundled: every provider goes over raw HTTP.
Contents
Features · Quick start · XML answers · Capture · REST API · How it works · Providers · Design choices · Guides · Development · Legacy API
Features
- Six provider presets — Anthropic, Groq, OpenAI, DeepSeek, OpenRouter, Kimi. Any other
OpenAI-compatible endpoint works with
base_url=andmodel=. - Six ways to write a schema — Pydantic model; a
{"field": "type"}or{"model": ..., "attributes": {...}}dict; JSON Schema; a JSON or an XML schema string. - JSON or XML, in and out, with the format detected from the answer's own shape.
- Lists — ask for
list[Invoice], get a list back. - Repairs before it fails, lifting JSON out of fences and prose and fixing malformed syntax, so the model is re-asked only when repair isn't enough.
- Strict or flexible validation — exact types for production, coercion while prototyping.
- Diagnostics you can act on — a per-stage report, an intervention score (carried by the
confidencefield; it counts repair work, not correctness), and one typed error tree. - A playground, not just a library, plus capture for improving the pipeline from evidence.
Quick start
from wassitai import Client
from pydantic import BaseModel
class Invoice(BaseModel):
number: str
total: float
client = Client.from_provider("groq", api_key="…", model="llama-3.3-70b-versatile")
invoice = client.generate("Extract the invoice from: …", schema=Invoice) # -> Invoice
# A list, by asking for a list schema:
invoices = client.generate("Extract all invoices: …", schema=list[Invoice])
# Never raises — for batch pipelines:
res = client.try_generate("…", schema=Invoice) # -> Result[Invoice]
if res.ok:
print(res.value, res.confidence)
# Text you already have, no provider call:
from wassitai import parse, render
invoice = parse('{"number": "A1", "total": 9.5}', Invoice)
text = render(invoice, "json")
strict=True enforces exact types; the default coerces close-enough values ("3" → 3).
max_retries caps how often the model is re-asked (default 1). The {"field": "type"} and
{"model": ..., "attributes": {...}} dict forms, and a JSON/XML schema string, are all
type-checked like a Pydantic model.
Nested schemas are compiled to full depth. A nested dict means an object; a single-element list literal means a list of that shape:
{"organization": {"name": "str", "departments": [{"name": "str"}]}}
Two limits remain, both deliberate:
- A dict written as a real JSON Schema (
{"type": "object", "properties": {...}}) stays presence-only — declared types andstrictare not enforced for it. This is true at any depth, not just the top level. Presence-only means presence is checked: the value must be an object and itsrequiredkeys must be there, at depth exactly as at the top level. Only the declared types inside it are left alone. - Because the two are told apart by the presence of a
"properties"or"type"key, a nested object with a field literally namedtype—{"event": {"type": "str", "payload": "str"}}— reads as a JSON Schema fragment, while{"manager": {"name": "str"}}alongside it is fully type-checked. Such a fragment declares no recognisable object ("str"is not a JSON Schema type), so it is left unconstrained rather than presence-checked: the engine will not guess whether you meant a string field or an object with a field namedtype. Give the field another name, or declare the whole schema as a real JSON Schema, if you need it enforced.
A dict (or the same dict as a JSON string) carrying a model key but no attributes —
{"model": "M"} — is read as a bare field map with one field named model, not as
the {model, attributes} envelope with a missing attributes. Its declared type
("M") is not a recognised type name, so that field is left unconstrained, same as any
other unrecognised type string — there is no "you forgot attributes" error for this
one shape. Give the envelope its attributes key if that's what you meant.
A list specification must hold exactly one element: it describes the shape of every
item. Two elements raise SchemaError rather than the engine guessing which one you meant.
A schema may carry one array wrapper ({"type": "array", "items": …}), not two: many
says the answer is a list of objects and has no second level to record, so a list of lists
raises rather than being silently flattened.
Extraction for a many schema recognises only {...} JSON objects as list items, so an
item in the answer that is not an object — a number, string, boolean, null, or nested
array, anywhere in the list — cannot reach the list. Since 0.5.0 it fails the run
rather than disappearing from it: wassitai.try_parse('[{"n": "A"}, 42, {"n": "B"}]', schema) returns ok=False with a validate-stage error reading Expected an object.
The engine will not return the object subset as though it were the whole answer — before
0.5.0 it did, at confidence 1.0 with no diagnostic (issue
#41).
Two ceilings bound schema compilation, which is work a caller can ask for before any
provider is reached. A schema may compile at most 5,000 nodes — one per field, one per
nested object, counted across the whole schema — and may nest at most 48 levels, short of
the declared 100 because Pydantic's own model_json_schema() exhausts the call stack first.
Measured: 4,999 flat fields compile and 5,000 do not; 48 levels compile and 49 do not. Past
either, SchemaError names the limit rather than the schema quietly losing its shape. Both
are far above anything written by hand, and a part of a schema too large to compile can still
be passed as a real JSON Schema, at the cost of presence-only checking.
A field name Pydantic cannot make into a field raises SchemaError naming it, rather
than being renamed to something that works. Two families: any name beginning with _
(Pydantic never builds a field for one, so the field would be declared and not enforced —
and __base__, __config__, __doc__, __module__, __validators__ and __cls_kwargs__
are create_model's own parameters), and any model_-prefixed name that shadows a
BaseModel member. A name that merely shadows some other BaseModel attribute — json,
dict, copy, schema — is fine and is enforced normally.
An XML schema string may not declare entities: <!ENTITY …> in the DOCTYPE subset
raises SchemaError. The frozen parser expands them, so a schema carrying one cannot be
read for its declared types without expanding it too.
An XML schema string cannot express nesting at all: its <attribute name= type=>
pairs are flat by construction — there is no syntax for one attribute to contain another.
A nested attempt is not merely ignored, it is actively rejected: both the frozen
core/model_parser (root.findall(".//attribute")) and this adapter's own declared-type
reader search all descendant <attribute> elements, not only a <model>'s direct
children, so a nested attribute's child elements are hoisted into the root model as
additional required top-level fields. A correctly-nested answer then fails validation
with "Field required" for every hoisted child. Pass a dict schema or a JSON schema
string instead if you need nesting enforced. This is a limit of the schema input
format only — an XML response is validated against a nested schema exactly as a
JSON response is (see "When the model answers in XML" below).
Generation parameters live on Config and can be overridden per call:
client.generate("…", schema=Invoice, max_tokens=512, top_p=0.9, stop=["END"])
max_tokens, top_p and stop are unset by default and then left off the request entirely, so the
provider's own default applies. temperature is the exception: it defaults to 0.0 and is sent on
every request. A provider that declares it does not accept one of these has it dropped instead, and
the run names it in result.diagnostics.
Config(max_tokens_ceiling=…) is how far a re-ask may raise max_tokens once the provider has
reported that its cap — not the model — ended the answer. It defaults to None, meaning never
escalate. A ceiling works with max_tokens unset (the re-ask jumps straight to it); when both are
set the ceiling must exceed the cap, or Config raises ConfigurationError.
XML answers
Nothing asks for XML — structured-output APIs speak JSON — but models ignore instructions, and
stored answers get replayed through parse(). An XML body reads into the same validated object a
JSON one produces:
parse("<person><name>Ada</name><age>36</age></person>", Person) # Person(name='Ada', age=36)
parse('<person name="Ada" age="36"/>', Person) # the same
parse("<ns:person><ns:name>Ada</ns:name></ns:person>", Person) # and the same
Five shapes XML cannot express follow documented rules, each recorded as a diagnostic:
| in the answer | what happens |
|---|---|
<r id="7"> — an attribute |
reaches the id field, unless a sibling <id> is also present: then neither is renamed, both survive, and the collision is a warning |
<ns:name> — a namespace prefix |
same rule, same reason: stripped when the local name is free, kept when it would collide |
<tag>x</tag> once, for a list[str] field |
wrapped into ["x"] — the format renders a list of one exactly like a scalar |
<name/> — an empty element |
stays None, never "" — a value the model never sent |
<r>text<child/>more</r> — mixed content |
a RepairError; any rule invented for it would produce plausible, silently wrong data |
strict=True cannot enforce scalar types on an XML body, because the format does not carry
them: every leaf is a string, so <age>36</age> and <age>"36"</age> are the same document.
Declared int/float/bool fields are therefore converted before validation in both modes, and
each conversion is recorded. A value that is genuinely not a number is left alone and still fails.
Capture
Write failing and repaired runs to disk, so the pipeline can be improved from evidence rather than memory.
from wassitai import Capture, Client, JsonlSink
with Capture(sink=JsonlSink("./captures"), experiment="tool-calling-vs-prompt-repair") as capture:
client = Client.from_provider("groq", api_key="…", capture=capture)
client.try_generate("List the invoice lines", schema=Invoice)
print(capture.stats())
Two files appear per process: a .jsonl of records and a .manifest.json of counters. By default
runs that fail and runs that needed repair are captured; pass when="failures" or when="all"
to change that.
A record holds the prompt, the schema, every attempt's raw answer, the parsed result, the error
detail, provider and model names, strategy, config, token usage and timestamps. Treat records as
sensitive. Your configured API key is removed from every record automatically; everything else is
stored as sent unless you supply a redact callable.
Capture(sink=JsonlSink("./captures"), experiment="e",
redact=lambda text: EMAIL_RE.sub("[email]", text),
max_field_bytes=16_384)
A sink failure — full disk, read-only volume — warns once, disables capture, and lets your run
continue. → Python guide for what is exempt from redaction, how
truncation is reported, and strict_sink.
REST API
uvicorn wassitai.api.app:app --reload
Playground at http://localhost:8000/, beginner's guide at /static/guide.html, OpenAPI reference
at /docs.
| Method | Path | Does |
|---|---|---|
POST |
/v1/generate |
prompt + schema → validated object |
POST |
/v1/parse |
text + schema → validated object (no provider call) |
GET |
/v1/providers |
providers and their capabilities |
GET |
/health |
liveness, and whether capture would really store a run |
curl -X POST http://localhost:8000/v1/generate \
-H "X-Provider-Key: $GROQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{"provider":"groq","prompt":"Extract the invoice: …",
"schema":{"number":"str","total":"float"}}'
Provider keys travel per-request in the X-Provider-Key header (or come from <PROVIDER>_API_KEY
on the server) and are never stored, logged, or pre-filled into the browser. Errors share one shape
and map to status codes: 400 unknown provider · 401 auth · 413 body too large · 422 the
request or the answer did not validate, the schema could not be read, the run could not be set up,
or the answer could not be rendered · 429 rate limit · 500 server-side · 502 provider ·
504 timeout.
Both endpoints accept include_record, capture_consent and experiment. A run is stored only
when capture_consent is true and WASSITAI_CAPTURE_DIR is set on the server — either alone
stores nothing, and GET /health reports storage_active for the real answer. Every attempted run
comes back with a run_id, the handle for asking that a stored run be deleted.
Request size is bounded by WASSITAI_MAX_BODY_BYTES (1 MiB) and, on /v1/parse, by
WASSITAI_MAX_TEXT_CHARS (262144 characters) — backstops, not a substitute for a limit at your own
proxy. → REST guide
How it works
generate() → schema adapter → strategy → provider → pipeline → validated object.
- Strategy — resolved per provider in order
native(JSON-schema mode) →tool_calling→prompt_repair, which is the universal floor and needs nothing from the provider. - Pipeline — six ordered stages:
normalize → extract → syntax_repair → deserialize → validate → coerce. Each records a diagnostic, and the intervention score falls by how much work a stage had to do — 0.05 to lift the payload out of surrounding prose, 0.20 to repair syntax, 0.25 when a record had to be discarded (a second object, an entry cut off mid-stream, or an enclosing container that never closed), 0.30 to re-ask. - Extending —
Provider,Strategy,SchemaAdapterandStageare the four interfaces; most providers need only a preset inwassitai/providers/__init__.py.
Errors form one tree under LLMParserError: ConfigurationError (your bug), ProviderError (the
network's or the provider's), and ParseError (the model's).
Providers
Presets speaking the OpenAI wire format: groq, openai, deepseek, openrouter,
kimi. anthropic is the exception — it speaks /v1/messages through its own Provider class
behind the same interface, and defaults to claude-opus-5.
openrouter and kimi carry no default model, so pass model=. Each preset declares what its
provider actually accepts, so a sampling parameter the vendor rejects is left off the wire and named
in diagnostics rather than sent and refused.
Any other OpenAI-compatible endpoint — Together, xAI, Ollama, vLLM, a local server — works by
passing base_url= and model=. Native adapters for Gemini and Bedrock are planned behind the same
Provider interface.
Design choices
Each is a trade-off rather than a free feature, so the cost is stated alongside.
| Choice | What it buys | What it costs |
|---|---|---|
| Repair, then re-ask | A malformed reply is fixed locally before the model is asked again, so most bad answers never cost a second call | A repaired answer is not a clean one; read confidence and diagnostics rather than treating success as uniform |
| Two validation modes | Coerce while prototyping, enforce exact types in production, without editing the schema | strict means different things per format — XML cannot carry types, so scalars are converted in both modes |
| JSON and XML in | A stored or off-spec answer parses to the same object; format is detected from the answer's own shape | XML has no type information, and five of its shapes need documented rules |
| Capability-driven strategies | The provider decides the method — native, tool calling, or the universal prompt floor | A provider that declares its capabilities dishonestly degrades silently, so presets are tuned per provider |
| Raw HTTP, no vendor SDKs | Four runtime dependencies; a new OpenAI-compatible provider is usually one preset line | Non-OpenAI wire formats need a Provider class, as Anthropic has |
| Two doors | generate/parse raise; try_generate/try_parse return a Result |
Two call styles to learn — pick the one that fits the caller |
Guides
docs/guides/python.md— every exported object:Configfield by field,Result, the full 22-type error tree, capture in depth, and which strategy gets chosen whendocs/guides/rest.md— the HTTP surface: keys, the shared error shape, every status code, the two size limits, and capture over the wiredocs/guides/versioning.md— which names carry a promise, what 1.0 means, and how long a deprecation lasts
With the API running, /docs carries the generated field-by-field reference and
/static/guide.html is the beginner's walkthrough.
Development
pip install -e ".[api,dev]" # editable install with the API and test deps
pytest # 2,792 tests, no network calls unless you opt in
pytest -m unit # or: integration, e2e, provider
pytest -n auto # parallel
pytest --cov=wassitai --cov-report=html
A live provider run needs both WASSITAI_LIVE=1 and GROQ_API_KEY; see tests/bench/. CI runs the
same suite on Python 3.9–3.13, plus ruff, a blocking mypy leg, a Docker build-and-smoke-test,
and a floor leg that installs every declared minimum version to keep the dependency floor honest.
Release notes for every version are in CHANGELOG.md.
Legacy API (removed in 2.0)
LLMParser.ask still works and still supports Gemini and HuggingFace, but is deprecated.
| Old | New |
|---|---|
LLMParser("groq", api_key=k).ask(q, schema=S, fmt="json") |
Client.from_provider("groq", api_key=k).generate(q, schema=S) |
...ask(q, schema=S, many=True) |
...generate(q, schema=list[S]) |
strict_validation=True |
generate(..., strict=True) |
| need the JSON string back | render(obj, "json") |
The current engine ships no preset for Gemini or HuggingFace, so on the new path they go through
Client.from_provider(name, api_key=…, base_url=…, model=…) like any other OpenAI-compatible
endpoint — or open an issue if a preset would be more useful.
License
MIT.
Contact
Issues and discussions on GitHub · kaa.kella.abdelaziz@gmail.com
Metadata
Release files for wassitai 0.5.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| wassitai-0.5.0.tar.gz | 939.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| wassitai-0.5.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.4 MB
Release files / wassitai-0.5.0.tar.gz
| Download URL | wassitai-0.5.0.tar.gz |
|---|---|
| Size | 939.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
7b9356bb2f01c44ee3116ead588e9847a1cfeafaaea4c26137fb5e6803412c2b
|
|
BLAKE2b-256 checksum How to use checksums |
b9f04b6c722db91f2eb87d79b17bff29ff1902adc6e5e55438d14613006fcef9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency logRelease files / wassitai-0.5.0-py3-none-any.whl
| Download URL | wassitai-0.5.0-py3-none-any.whl |
|---|---|
| Size | 461.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3f57c9e22a0986905c8e2469d35201ed29bdb616767e311f031d36628a7c5bfc
|
|
BLAKE2b-256 checksum How to use checksums |
940566503052e0cad29dba6ce5d818200ad3a4907986e05099cde4eaf46d7b75
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 14, 2026.
Transparency log