Skip to main content

A provider-agnostic state machine for handling, normalizing, and recovering structured LLM outputs.

Project description

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 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

Default pipeline: [StripFences(), ParseJSON(), ParseYAML(), ParseFrontmatter()]

Recommended configurations

Goal Pipeline
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()]

For the frontmatter+YAML fallback: some models respond with a plain YAML code block (no --- delimiters), so ParseYAML() after ParseFrontmatter() catches that gracefully.

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)

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ratchet_sm-0.1.0.tar.gz (22.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ratchet_sm-0.1.0-py3-none-any.whl (23.5 kB view details)

Uploaded Python 3

File details

Details for the file ratchet_sm-0.1.0.tar.gz.

File metadata

  • Download URL: ratchet_sm-0.1.0.tar.gz
  • Upload date:
  • Size: 22.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.3

File hashes

Hashes for ratchet_sm-0.1.0.tar.gz
Algorithm Hash digest
SHA256 29f994654d1cacb4204849115c2a6d98aa17e953fb52cffd1773c1e1b82409c7
MD5 71b73961ad32633aa745163c01e113de
BLAKE2b-256 01d04ffa719a5de995bfd4d63a79777eee31c24e78d556cfb7512ea1a4c3d54a

See more details on using hashes here.

File details

Details for the file ratchet_sm-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: ratchet_sm-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 23.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.3

File hashes

Hashes for ratchet_sm-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ae1c76980f8f0289732174388bce6c9ee834e9fc35192b7a83f381793eba1743
MD5 136336ba15a3d7f51352b931af131ceb
BLAKE2b-256 3f335e39766db7a692d9ff4bd7f302e89c66d5dfbbef5e263a76ded3312b7902

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page