ratchet-sm
A pure, provider-agnostic state machine for normalizing and recovering structured LLM outputs.
ratchet-sm gives you a reliable way to extract structured data from LLM responses — handling retries, validation feedback, multi-step flows, and fixer prompts — without being tied to any specific model provider or framework.
Table of Contents
Getting Started
Features
- Provider-agnostic — works with OpenAI, Anthropic, or any LLM
- Pure state machine — no I/O, no LLM calls; you own the call loop
- Normalizer pipeline — strips fences, parses JSON/YAML/frontmatter, repairs malformed JSON automatically
- Schema support — plain
dict, Pythondataclass, or PydanticBaseModel - Retry strategies —
ValidationFeedback,SchemaInjection, orFixer - Multi-state flows — linear or branching transitions between extraction steps
- Tool-call mode — extracts pseudo tool calls from XML tags, labelled fences, and bracket tags; returns targeted feedback when the model misses the call
- Native tool calls — pass
tool_calls=from provider responses directly intoreceive()for uniform validation and state advancement - Passthrough states — free-form chat states that forward raw text straight through without parsing
- Observable — every action is a plain dataclass you can inspect or log
Installation
# minimal (JSON parsing only)
pip install ratchet-sm
# with Pydantic support
pip install "ratchet-sm[pydantic]"
# with YAML support (ParseYAML normalizer, SchemaInjection format="yaml")
pip install "ratchet-sm[yaml]"
# with frontmatter support (ParseFrontmatter normalizer)
pip install "ratchet-sm[frontmatter]"
# everything
pip install "ratchet-sm[all]"
Quickstart
import openai
from pydantic import BaseModel
from ratchet_sm import FailAction, RetryAction, State, StateMachine, ValidAction
from ratchet_sm.normalizers import ParseJSON, StripFences
class Person(BaseModel):
name: str
age: int
client = openai.OpenAI()
machine = StateMachine(
states={
"extract": State(name="extract", schema=Person, normalizers=[StripFences(), ParseJSON()])
},
transitions={},
initial="extract",
)
messages = [{"role": "user", "content": 'Extract as JSON: Alice is 30 years old.'}]
while not machine.done:
response = client.chat.completions.create(model="gpt-4o-mini", messages=messages)
raw = response.choices[0].message.content
action = machine.receive(raw)
if isinstance(action, ValidAction):
print(action.parsed) # Person(name='Alice', age=30)
elif isinstance(action, RetryAction):
messages.append({"role": "assistant", "content": raw})
messages.append({"role": "user", "content": action.prompt_patch})
elif isinstance(action, FailAction):
raise RuntimeError(f"Failed after {action.attempts} attempts: {action.reason}")
Reference
Schemas
dict (no schema)
State(name="extract") # returns the parsed dict as-is
Python dataclass
import dataclasses
@dataclasses.dataclass
class Person:
name: str
age: int
State(name="extract", schema=Person)
# ValidAction.parsed is a Person instance
Pydantic BaseModel
from pydantic import BaseModel
class Person(BaseModel):
name: str
age: int
State(name="extract", schema=Person)
# ValidAction.parsed is a validated Person instance
Normalizers
The normalizer pipeline converts a raw LLM string into a dict. Steps are tried in order; the first success wins.
| Normalizer | What it does |
|---|---|
StripFences |
Strips ```json ... ``` markdown code fences (preprocessor) |
ParseJSON |
Parses JSON, handles BOM and whitespace |
ParseYAML |
Parses YAML dicts (yaml.safe_load) |
ParseFrontmatter |
Parses --- frontmatter blocks |
RepairJSON |
Repairs malformed JSON (missing brackets, trailing commas, unquoted keys, mixed text + JSON) |
Default pipeline: [StripFences(), ParseJSON(), ParseYAML(), ParseFrontmatter()]
Recommended configurations
| Goal | Pipeline / constant |
|---|---|
| JSON responses (or any format, default) | [StripFences(), ParseJSON(), ParseYAML(), ParseFrontmatter()] — omit normalizers= |
| YAML-only responses | [StripFences(), ParseYAML()] |
| Frontmatter responses (with YAML fallback) | [StripFences(), ParseFrontmatter(), ParseYAML()] |
| Malformed / healing (OpenRouter, weak LLMs) | HEALING_PIPELINE — see below |
For the frontmatter+YAML fallback: some models respond with a plain YAML code block (no --- delimiters), so ParseYAML() after ParseFrontmatter() catches that gracefully.
HEALING_PIPELINE
Use HEALING_PIPELINE when the model may emit malformed JSON — common with weaker models or OpenRouter's response healing scenarios. It runs StripFences and ParseJSON first (fast path), then falls back to RepairJSON which handles:
- Missing closing brackets/braces —
{"name": "Alice", "age": 30→{"name": "Alice", "age": 30} - Trailing commas —
{"name": "David",}→{"name": "David"} - Unquoted keys —
{name: "Eve", age: 40}→{"name": "Eve", "age": 40} - Mixed text + JSON —
Here's the data: {"name": "Bob"}→{"name": "Bob"}
from ratchet_sm.normalizers import HEALING_PIPELINE
State(name="extract", schema=Person, normalizers=HEALING_PIPELINE)
You can override it per state:
from ratchet_sm.normalizers import ParseJSON, StripFences
State(name="extract", normalizers=[StripFences(), ParseJSON()])
Strategies
Strategies decide what to do when parsing or validation fails. They produce a prompt_patch string to append to your next LLM call.
ValidationFeedback (default)
Returns a message listing the errors and the schema:
from ratchet_sm.strategies import ValidationFeedback
State(name="extract", strategy=ValidationFeedback())
SchemaInjection
Returns the schema serialized in the requested format — useful when you want to remind the model of the exact shape:
from ratchet_sm.strategies import SchemaInjection
State(name="extract", schema=Person, strategy=SchemaInjection(format="yaml"))
Supported formats: "json_schema" (default), "yaml", "simple".
Fixer
Instead of a retry hint, emits a FixerAction with a full self-contained prompt you can send to a separate LLM call (or a different, more capable model):
from ratchet_sm.strategies import Fixer
State(name="extract", strategy=Fixer())
elif isinstance(action, FixerAction):
# Send action.fixer_prompt to a capable model, then feed the response back
fixer_response = call_llm(action.fixer_prompt)
action = machine.receive(fixer_response)
Multi-state flows
Linear
machine = StateMachine(
states={
"classify": State(name="classify"),
"extract": State(name="extract", schema=Person),
},
transitions={"classify": "extract"},
initial="classify",
)
Branching (callable transition)
machine = StateMachine(
states={
"classify": State(name="classify"),
"person": State(name="person", schema=Person),
"company": State(name="company", schema=Company),
},
transitions={
"classify": lambda parsed: "person" if parsed["type"] == "person" else "company",
},
initial="classify",
)
The loop
# Per-state prompts — each state needs its own instruction
prompts = {
"classify": "Is the text about a person or a company? Respond with JSON: {\"type\": \"person\"} or {\"type\": \"company\"}.",
"person": "Extract the person's name, age, occupation, and location as JSON.",
"company": "Extract the company's name, founding year, industry, and headquarters as JSON.",
}
messages = [{"role": "user", "content": prompts["classify"]}]
while not machine.done:
raw = call_llm(messages)
action = machine.receive(raw)
if isinstance(action, ValidAction):
if not machine.done:
# Machine transitioned; start a fresh conversation for the next state
next_state = machine.current_state.name
messages = [{"role": "user", "content": prompts[next_state]}]
elif isinstance(action, RetryAction):
messages.append({"role": "assistant", "content": raw})
messages.append({"role": "user", "content": action.prompt_patch})
elif isinstance(action, FailAction):
raise RuntimeError(f"Failed in '{action.state_name}': {action.reason}")
Key rule: when ValidAction arrives and not machine.done, the machine has already
moved to the next state — machine.current_state.name gives the new state name.
Reset messages with a prompt appropriate for that state before the loop continues.
Actions reference
Every machine.receive(raw) call returns one of:
| Action | Meaning |
|---|---|
ValidAction |
Parsing and validation succeeded. .parsed holds the result. .format_detected is e.g. "json", "native_tool_call", "passthrough". |
RetryAction |
Failed; .prompt_patch is the hint to add to the next prompt. .reason is "parse_error" or "validation_error". |
FixerAction |
Failed with Fixer strategy; .fixer_prompt is a ready-to-send repair prompt. |
ToolCallMissingAction |
No tool call found in the response (requires_tool_call=True). .reason is "no_tool_call" or "pseudo_tool_call_in_text". .prompt_patch contains recovery instructions. |
FailAction |
Exceeded max_attempts; .history is the full action trail. |
All actions expose .attempts, .state_name, and .raw.
Advanced
Custom normalizer
from ratchet_sm.normalizers.base import Normalizer
class ParseTOML(Normalizer):
name = "toml"
def normalize(self, raw: str) -> dict | None:
import tomllib
try:
return tomllib.loads(raw)
except Exception:
return None
State(name="extract", normalizers=[ParseTOML()])
Custom strategy
from ratchet_sm.strategies.base import Strategy, FailureContext
class SlackAlert(Strategy):
def on_failure(self, context: FailureContext) -> str | None:
post_to_slack(f"Attempt {context.attempts} failed: {context.errors}")
return f"Please fix the errors: {context.errors}"
State(name="extract", strategy=SlackAlert())
Provider Native JSON Schema
If your provider supports native JSON schema enforcement (OpenAI, OpenRouter, Gemini),
you can send a schema in the API request and still keep ratchet-sm as the canonical
validator/state machine.
Recommended pattern
- Keep
State.schemaas your source of truth (dataclass/Pydantic). - Derive JSON schema for provider calls from
state.schema. - Apply provider profile adjustments (for example, OpenAI stricter, Gemini looser).
- Always pass the response back into
machine.receive(raw)for uniform validation, retry actions, and transitions.
Schema derivation uses Pydantic v2 TypeAdapter(...).json_schema() under the hood,
so both BaseModel and plain Python dataclass schemas are supported through
the same adapter path.
ratchet-sm includes helper utilities for this:
from ratchet_sm import (
apply_provider_schema_profile,
derive_provider_state_json_schema,
derive_state_json_schema,
)
Optional per-state API overrides can be maintained outside the machine
(dict[state_name, json_schema]) when a specific provider/model needs a tailored payload.
If you need OpenAI/OpenRouter-style strict normalization where every property is forced
into required, opt in explicitly:
profiled = apply_provider_schema_profile(
"openai",
schema,
enforce_all_properties_required=True,
)
See examples/structured_native_schema_hybrid.py.
Tool-call mode
When a state has requires_tool_call=True, ratchet routes the text response through the TOOL_CALL_PIPELINE, which recognizes three pseudo tool-call patterns emitted by models that do not support native function calling:
| Pattern | Example |
|---|---|
| XML tag | <tool_call>{"name": "search", "input": {"q": "hi"}}</tool_call> |
| Labelled fence | ```tool_call\n{"name": "search"}\n``` |
| Bracket tag | [TOOL_CALL]{"name": "search"}[/TOOL_CALL] |
Plain JSON with no tag is also accepted as a fallback.
from ratchet_sm import State, StateMachine, ToolCallMissingAction, ValidAction
machine = StateMachine(
states={"call": State(name="call", requires_tool_call=True)},
transitions={},
initial="call",
)
while not machine.done:
raw = call_llm(messages)
action = machine.receive(raw)
if isinstance(action, ValidAction):
tool_call = action.parsed # dict with at least "name"
elif isinstance(action, ToolCallMissingAction):
# action.reason: "no_tool_call" or "pseudo_tool_call_in_text"
messages.append({"role": "assistant", "content": raw})
messages.append({"role": "user", "content": action.prompt_patch})
You can customize feedback prompts via RequireToolCallFeedback:
from ratchet_sm.strategies import RequireToolCallFeedback
State(
name="call",
requires_tool_call=True,
strategy=RequireToolCallFeedback(
no_call_template="You must respond with a tool call.",
pseudo_call_template="Your tool call tag had invalid JSON. Please fix it.",
),
)
Native tool calls
When the provider returns response.tool_calls natively (OpenAI, Anthropic, etc.), pass them directly into receive():
response = client.chat.completions.create(
model="gpt-4o",
tools=[...],
messages=messages,
)
action = machine.receive(
raw=response.choices[0].message.content or "",
tool_calls=response.choices[0].message.tool_calls,
)
ratchet normalizes the tool call via a duck-typed extractor that handles:
- Dicts with
"name"/"input"keys (Anthropic-style) - Objects with
.name/.inputattributes - OpenAI-style
function.arguments— both JSON strings and dicts
The extracted dict is validated against state.schema exactly like any other response. On success, ValidAction.format_detected == "native_tool_call". An empty tool_calls=[] returns ToolCallMissingAction. Passing tool_calls=None falls through to the text pipeline (backward compatible).
When requires_tool_call=False (the default), the tool_calls parameter is silently ignored and the existing text pipeline runs on raw.
Passthrough state
A state with passthrough=True skips all parsing and returns the raw text directly as a ValidAction. This is useful for free-form chat steps inside multi-step flows.
machine = StateMachine(
states={
"chat": State(name="chat", passthrough=True),
"extract": State(name="extract", schema=Person),
},
transitions={"chat": "extract"},
initial="chat",
)
action = machine.receive("Sure, tell me more about Alice.")
# ValidAction(parsed="Sure, tell me more about Alice.", format_detected="passthrough")
schema is ignored when passthrough=True. The state still respects max_attempts — a FailAction is returned if the guard fires before the passthrough branch.
reset()
Resets the machine to its initial state, clearing all counters and history:
machine.reset()
Examples
| Example | Demonstrates |
|---|---|
examples/yaml_dict.py |
YAML normalizer, plain dict output, parallel models via OpenRouter |
examples/frontmatter_dataclass.py |
Frontmatter normalizer, dataclass schema, SchemaInjection strategy |
examples/openrouter_all_models.py |
Pydantic schema, default JSON pipeline, parallel multi-model comparison |
examples/multi_state_gemma.py |
Multi-state branching (classify → extract), Pydantic, Gemma 3 via OpenRouter |
examples/structured_native_schema_hybrid.py |
Provider-native JSON schema + ratchet-sm canonical validation, hybrid per-state retry policy |
examples/tool_call_loop.py |
Tool-call loop with pseudo-call extraction, RequireToolCallFeedback, OpenRouter + DeepSeek v3.2-exp |
All examples require the llm-async package (pip install llm-async) and
an OPENROUTER_API_KEY environment variable.
Why not just use instructor retries?
| instructor | ratchet-sm | |
|---|---|---|
| Provider coupling | OpenAI, Anthropic, Google… | Any LLM (you own loop) |
| Schema required | Yes (Pydantic) | No (dict, dataclass, Pydantic) |
| Stateful multi-step | No | Yes |
| Branching flows | No | Yes |
| Observable actions | No | Yes |
| Custom repair models | No | Yes (Fixer) |
Metadata
Release files for ratchet-sm 0.2.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 | Provenance | Actions |
|---|---|---|---|---|
| ratchet_sm-0.2.0.tar.gz | 23.5 kB | Without provenance | Details for ratchet_sm-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| ratchet_sm-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for ratchet_sm-0.2.0-py3-none-any.whl |
Total release size: 47.9 kB
Release files / ratchet_sm-0.2.0.tar.gz
| Download URL | ratchet_sm-0.2.0.tar.gz |
|---|---|
| Size | 23.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
2bd1bd71eaf4508dae5e8cd5e7dec4c8d0cb5f8b20fc719e57f95a4b5ba86878
|
|
BLAKE2b-256 checksum How to use checksums |
65db1e517cb55df0353a8cb729c8ccccd92f152989a6189cbdaf7256850f4a79
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.3
|
Release files / ratchet_sm-0.2.0-py3-none-any.whl
| Download URL | ratchet_sm-0.2.0-py3-none-any.whl |
|---|---|
| Size | 24.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
aed3595b2dc8e1890476672bb584927ca8aca6fe4b8cdf11b0f84c5c2164ed00
|
|
BLAKE2b-256 checksum How to use checksums |
22bb78efdb7bdf8cd4038807690af9fc193a958098f035ac49a38fa49ff82a97
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.12.3
|
Security for ratchet-sm 0.2.0
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.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.