Skip to main content

Wassitai

PyPI Python CI License: MIT

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= and model=.
  • 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 confidence field; 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 and strict are 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 its required keys 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 named type — {"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 named type. 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, SchemaAdapter and Stage are the four interfaces; most providers need only a preset in wassitai/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: Config field by field, Result, the full 22-type error tree, capture in depth, and which strategy gets chosen when
  • docs/guides/rest.md — the HTTP surface: keys, the shared error shape, every status code, the two size limits, and capture over the wire
  • docs/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)

Source distribution for wassitai 0.5.0
File Size Uploaded
wassitai-0.5.0.tar.gz 939.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for wassitai 0.5.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

0.5.0 This release

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page