// batch LLM work on DataFrames
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.
New in this release
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.
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.
When to reach for which
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
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.
What's under the hood
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.
Usage
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
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.
Get started
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.