// batch LLM work on DataFrames

Batch-process your DataFrames
with LLMs, without the boilerplate.

Agents reason row-by-row. Ondine computes columns. One call batches N rows, so a 100K-row job makes 2,000 API calls instead of 100,000 — and survives a crash mid-run without losing a single completed row.

0x
fewer API calls
vs naive row-by-row loop
$0.48
to process 100K rows
benchmark, real API
0
rows lost on crash
at 60% complete
0+
LLM providers
via LiteLLM

New in this release

Four new ways to run Ondine.

The enrich() one-liner stays the fastest path. Behind it, the architecture gained four new surfaces — each solving a real production problem without touching the common path.

The one-liner

Import, call, get columns. That is the entire surface for the common case. The DataFrame you pass in comes back with new columns added — batched, checkpointed, budget-capped automatically.

import ondine df = ondine.enrich(df, "Classify: {review}", ["sentiment"])
MCP Server
Agents call Ondine through MCP. Four tools — estimate, run, status, collect — expose batch jobs to any MCP-compatible agent.
estimate · run · status · collect
Provider Batch API
50% cost savings via OpenAI and Anthropic native Batch APIs. batch=50 + concurrency=30 finishes 100K rows in 13 minutes.
batch=50 · concurrency=30 · 13m
ondine.plan()
Describe your goal in English; Ondine builds the pipeline. Plans compose into the same builder, then execute.
plan(data, goal).build().execute()
RunRegistry
Persistent job index. Start a run, poll its progress, resume by run_id — across sessions and processes.
registry.start(run_id) · poll() · resume()

When to reach for which

Ondine vs an agent loop.

Agents plan, call tools, and reason step-by-step. That is the right tool when the work is open-ended. When the work is "compute this column for all N rows," the agentic pattern is the wrong tool — slower, costlier, and less accurate on the benchmark below.

Ondine Agent-per-row
Best for Batch column-computation over N known rows Open-ended tasks where the next step is unknown
API calls (100K rows) 2,000 300,000
Cost (100K rows) $0.48 $2.46
Crash at 60% 0 rows lost, resumes from checkpoint 60,000 rows of API spend lost
Accuracy (sentiment) 100% 93.3% — three calls added noise, not signal
Budget cap hard USD halt, enforced per batch no enforced limit

Benchmark: 100K-row Amazon reviews, DeepSeek, batch size 15. Full methodology in benchmarks/RESULTS.md.

Cost at scale

100,000 rows. Three approaches.

Same dataset, same model, same task. Measured on a 30-row real-API sample, then extrapolated linearly to 100K. Ondine's real wall-time at scale is likely lower (concurrency), so this projection is conservative.

Ondine (batched) $0.48 13m 19s wall · 2,000 calls
batch size 15
Naive loop $0.74 21.0h wall · 100,000 calls
one row per call
Agent-per-row $2.46 3.0 days · 300,000 calls
plan → classify → reflect

What's under the hood

Production plumbing, not a demo.

The one-liner is real: define a prompt, get columns. The reliability you need to run it on 100K rows in production is what Ondine adds on top. Eight pieces, each solving a failure mode that df.apply() does not.

01
Multi-row batching N rows per API call. 50x fewer calls than row-by-row.
02
Checkpointing Per-batch writes to Parquet. Crash at 60% loses zero rows; resume finishes the job.
03
Budget caps Hard USD halt enforced per batch. No runaway spend.
04
Structured output Pydantic schemas, JSON auto-retry. Typed columns, not string parsing.
05
Response cache SQLite-backed. Identical prompts cost $0 on re-run.
06
Observability Cost tracking, traces, OpenTelemetry, Langfuse, Prometheus. On by default.
07
100+ providers Any LLM through LiteLLM. Router with latency failover. Local models too.
08
RAG & grounding Knowledge base retrieval and evidence verification when you need them. Off the common path.

Usage

From CSV to answers.

The shortest path: ondine.enrich() — one function, one prompt, one DataFrame in, columns out. Need more control? The builder API exposes every knob. The agent-eval tab shows the pattern Ondine was built for.

from ondine import enrich

# One call. DataFrame in, columns out.
result = enrich(
    data="reviews.csv",                              # CSV, Parquet, or DataFrame
    prompt="Analyze this review: {review}",         # {placeholder} = column
    output_columns=["sentiment", "score", "key_topic"],
    model="gpt-4o-mini",
    budget=5.00,                                     # hard USD cap
)
# result is your enriched DataFrame — input cols + output cols
# checkpointing on by default; crash at 80K? re-run resumes at 80K.
from ondine import enrich
from pydantic import BaseModel

# Agent evaluation: score N agent traces against a rubric, in bulk.
class TraceEval(BaseModel):
    coherence: int        # 1–5
    tool_use: int          # 1–5
    hallucinated: bool
    critique: str

evals = enrich(
    data="agent_traces.csv",                       # 10K traces, one row each
    prompt="Score this trace against the rubric: {trace}",
    output_columns=["coherence", "tool_use", "hallucinated", "critique"],
    schema=TraceEval,                               # native structured output
    model="gpt-4o-mini",
    budget=10.00,
)
# 10K traces scored in 667 API calls (batch size 15), not 10,000.
from ondine import PipelineBuilder
from pydantic import BaseModel

class ReviewAnalysis(BaseModel):
    sentiment: str
    score: int
    topic: str

# Full pipeline. Composable stages, total control.
pipeline = (
    PipelineBuilder.create()
    .from_csv("reviews.csv",
              input_columns=["review"],
              output_columns=["sentiment", "score", "topic"])
    .with_prompt("Analyze the review: {review}")
    .with_llm(provider="openai", model="gpt-4o-mini")
    .with_structured_output(ReviewAnalysis)
    .with_batch_size(50)              # 200 calls, not 10,000
    .with_max_budget(25.00)            # hard halt at $25
    .with_checkpoint_interval(100)    # checkpoint every 100 rows
    .with_disk_cache(".cache")          # identical prompts = $0 second call
    .with_router(strategy="latency")   # fastest provider wins
    .build()
)

result = pipeline.execute()
import ondine

# Describe the goal in English. Ondine builds the pipeline.
result = (
    ondine.plan(
        data="reviews.csv",
        goal="Classify sentiment and extract key topics for each review",
    )
    .build()                # returns a PipelineBuilder — inspect or tweak
    .execute()              # runs it: batched, checkpointed, budget-capped
)

# plan() infers columns, schema, and prompt from the goal string.
# Override any knob on the returned builder before .execute().
from ondine import PipelineBuilder
from ondine.knowledge import KnowledgeStore

# Index your docs once. Hybrid BM25 + dense search, optional reranker.
kb = KnowledgeStore("knowledge.db")
kb.ingest("docs/")   # PDFs, Markdown, HTML, text — OCR included

# Retrieval stage runs before the LLM, injects {_kb_context}.
pipeline = (
    PipelineBuilder.create()
    .from_csv("questions.csv",
              input_columns=["question"],
              output_columns=["answer"])
    .with_knowledge_base(kb,
                         top_k=5,
                         rerank=True,              # cross-encoder reranker
                         query_transform="hyde")   # or "multi-query", "step-back"
    .with_prompt("Context:\n{_kb_context}\n\nQ: {question}\nA:")
    .with_llm(model="gpt-4o-mini")
    .build()
)

result = pipeline.execute()

What it computes

One primitive. Any transform.

The use case lives in the prompt. Ondine does not care what you are computing. The same enrich() call covers all of this, one syntax.

Classification
textlabel : str
"Classify {review} into one of {labels}"
Extraction
documentname, date, amount
"Extract name, date, amount from: {document}"
Scoring
itemscore : int
"Score {item} against {criteria} 1–10"
Comparison
a, bmatch + reason
"Is {a} equivalent to {b}? yes/no + why"
Translation
text, langtranslated
"Translate {text} to {tgt_lang}"
Summarization
documentbullets : list
"Summarize {document} in 3 bullets"
Enrichment
row + docsricher fields
"Given {_kb_context}, enrich: {row}"
Validation
recordpass/fail + why
"Does {record} meet {policy}?"

Get started

One command.
Your DataFrame, enriched.

Pandas and Polars. Any LLM through LiteLLM. Batch N rows per call, checkpoint every batch, cap your budget. The reliability you need to run 100K rows in production.

$ pip install ondine copied!