llm-pipeline
Production-grade LLM call execution, defensive output parsing, and retry for Rust.
What this crate does
Handles everything that happens inside an LLM workflow node: render a prompt, call a provider, parse the response, retry if it's broken. Your orchestrator (graph runtime, agent loop, or a simple for loop) decides which node runs next — this crate makes sure each node produces usable output.
- Pluggable backends — Ollama and OpenAI-compatible APIs, plus a
MockBackendfor testing without infrastructure - Defensive output parsing — multi-strategy extraction pipeline that handles markdown fences,
<think>blocks, embedded JSON in prose, and deterministic repair of malformed output (trailing commas, single quotes, Python literals, unquoted keys, unclosed brackets) - Two-tier retry — transport-level backoff (429/5xx with jitter and
Retry-Aftersupport) and semantic retry that re-prompts the LLM with error context and temperature cool-down when parsing can't salvage the output - Sequential chaining — compose payloads into multi-step workflows with
Chain - Streaming — per-token callbacks with a buffered NDJSON decoder that handles chunk-boundary splits
- Typed extraction —
output.parse_as::<T>()at workflow edges
What this crate does NOT do
- Graph orchestration (routing, branching, parallel execution, checkpoints)
- Prompt engineering or prompt management
- Embeddings, vector stores, or RAG
Quick start — no LLM required
use ;
use Payload;
use Deserialize;
use json;
use Arc;
async
With a live Ollama instance
use ;
use Payload;
use RetryConfig;
use json;
async
Core concepts
ExecCtx — shared execution context built once and passed to every payload. Carries the HTTP client, backend, base URL, template variables, cancellation flag, and optional event handler.
LlmCall — the primary payload. Renders a prompt template, calls the backend, parses the response through the configured output strategy, and optionally retries. Builder methods configure everything: .with_model(), .with_system(), .with_streaming(true), .expecting_json(), .with_retry().
Payload — object-safe trait (Box<dyn Payload>) that takes a serde_json::Value input and returns a PayloadOutput. LlmCall and Chain both implement it, so chains can nest.
PayloadOutput — wraps the parsed Value, raw response text, optional thinking content, and ParseDiagnostics recording which strategy succeeded, retry counts, and backoff time.
Chain — sequential composition. Pipes each payload's output value as the next payload's input. Respects cancellation between steps.
How output parsing works
When an LLM responds, the text goes through a parsing pipeline before the retry system ever sees it:
- Preprocess — strip
<think>...</think>blocks, trim whitespace - Extract — try direct parse, then
```jsoncode blocks, then bracket-matching{...}or[...]from prose - Repair — if extraction found a candidate but it's malformed, apply deterministic fixes: strip comments, replace Python literals (
True/False/None), remove trailing commas, swap single quotes for double, quote bare keys, close unclosed brackets, escape raw newlines - Auto-complete — for truncated streaming output, close unclosed strings and brackets
If all of that fails and RetryConfig is set, then the semantic retry kicks in: the original prompt, the bad response, and the parse error are sent back to the LLM as a correction conversation, with temperature reduced by 0.2 per attempt.
Output strategies
Configure with builder methods on LlmCall:
| Method | Strategy | Returns |
|---|---|---|
| (default) | Lossy |
Best-effort JSON extraction, falls back to Value::String — never fails |
.expecting_json() |
Json |
Full extraction + repair pipeline, can fail → triggers retry |
.expecting_list() |
StringList |
["item1", "item2"] arrays |
.expecting_choice(vec![...]) |
Choice |
Matched option from valid set (case-insensitive, handles prose/bold/quotes) |
.expecting_number() |
Number |
Numeric extraction from "Score: 8.5", "8/10", prose |
.expecting_number_in_range(1.0, 10.0) |
NumberInRange |
Bounded numeric extraction |
.expecting_text() |
Text |
Clean prose with boilerplate stripping ("Sure!", "Here's...") |
.with_output_strategy(XmlTag("tag".into())) |
XmlTag |
Content from <tag>...</tag> |
.with_output_strategy(Custom(arc_fn)) |
Custom |
Your own fn(&str) -> Result<Value, ParseError> |
Transport retry
Configure backoff on ExecCtx for automatic retry of transient HTTP errors:
use ;
// Presets
let ctx = builder
.backoff // 3 retries, 1s initial, 2x multiplier
.build;
let ctx = builder
.backoff // 2 retries, 500ms initial, 10s max
.build;
let ctx = builder
.backoff // 5 retries, 500ms initial, 120s max
.build;
Retries 429, 500, 502, 503, 504 with full jitter by default. Respects Retry-After headers. Emits Event::TransportRetry for observability.
Streaming
use ;
use Payload;
use ;
use json;
use Arc;
async
The streaming path uses StreamingDecoder, a buffered NDJSON framer that handles the common case where a JSON line is split across TCP chunks. On stream end, it attempts auto-completion of truncated JSON.
Template variables
Prompt templates use {key} placeholders. {input} is always the payload input. Additional variables come from ExecCtx:
use ;
let ctx = builder
.var
.var
.build;
// Both {domain} and {audience} are substituted, {input} comes from invoke()
let call = new;
Use {{ and }} to include literal braces (useful for JSON schemas in prompts).
Cancellation
use ExecCtx;
use Arc;
use AtomicBool;
let cancel = new;
let ctx = builder
.cancellation
.build;
// From another task:
// cancel.store(true, std::sync::atomic::Ordering::Relaxed);
Checked before each payload invocation, between chain steps, and between retry attempts.
Backends
| Backend | Protocol | Feature |
|---|---|---|
OllamaBackend |
/api/generate, /api/chat (NDJSON streaming) |
(default) |
OpenAiBackend |
/v1/chat/completions (SSE streaming) |
openai |
MockBackend |
Canned responses, cycles when exhausted | (always available) |
Base URLs are normalized at build time — passing http://localhost:11434/api or https://api.openai.com/v1 won't double the path segments.
Feature flags
| Feature | Default | Adds |
|---|---|---|
openai |
off | OpenAiBackend, SSE decoder |
yaml |
off | YAML output parsing via serde_yaml |
[]
= { = "0.1", = ["openai"] }
Diagnostics
Every PayloadOutput includes ParseDiagnostics:
if let Some = &output.diagnostics
Examples
License
MIT