Skip to main content

ratchet-sm

ratchet-sm logo

CI PyPI

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, Python dataclass, or Pydantic BaseModel
  • Retry strategiesValidationFeedback, SchemaInjection, or Fixer
  • 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 into receive() 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

  1. Keep State.schema as your source of truth (dataclass/Pydantic).
  2. Derive JSON schema for provider calls from state.schema.
  3. Apply provider profile adjustments (for example, OpenAI stricter, Gemini looser).
  4. 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 / .input attributes
  • 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)

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)

Source distribution for ratchet-sm 0.2.0
File Size Uploaded
ratchet_sm-0.2.0.tar.gz 23.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for ratchet-sm 0.2.0
File Interpreter ABI Platform
ratchet_sm-0.2.0-py3-none-any.whl Python 3 none any Details

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

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.0

2 release files

0.0.1

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