A provider-agnostic state machine for handling, normalizing, and recovering structured LLM outputs.
Project description
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) |
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ratchet_sm-0.2.0.tar.gz.
File metadata
- Download URL: ratchet_sm-0.2.0.tar.gz
- Upload date:
- Size: 23.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2bd1bd71eaf4508dae5e8cd5e7dec4c8d0cb5f8b20fc719e57f95a4b5ba86878
|
|
| MD5 |
94bcf07c326cacb235401257be082256
|
|
| BLAKE2b-256 |
65db1e517cb55df0353a8cb729c8ccccd92f152989a6189cbdaf7256850f4a79
|
File details
Details for the file ratchet_sm-0.2.0-py3-none-any.whl.
File metadata
- Download URL: ratchet_sm-0.2.0-py3-none-any.whl
- Upload date:
- Size: 24.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aed3595b2dc8e1890476672bb584927ca8aca6fe4b8cdf11b0f84c5c2164ed00
|
|
| MD5 |
c5f03b792968ba688096cbe2b550f5df
|
|
| BLAKE2b-256 |
22bb78efdb7bdf8cd4038807690af9fc193a958098f035ac49a38fa49ff82a97
|