Skip to content

Commit 4d251d0

Browse files
dhruvraajeevclaude
andcommitted
feat(flows): aggregate token/cost usage in batch_run and enforce budgets
Populate the previously dead BatchResult.tokens_total and cost_estimate_usd fields and enforce the declared-but-unenforced max_total_input_tokens / max_total_cost_usd guardrails. Usage is surfaced across the run_with_observability seam via a contextvars-scoped accumulator (quantmind/flows/_usage.py): the runner records each run's SDK usage into the active scope, and batch_run opens one scope per input, so per-input usage aggregates without changing any flow's return type. Cost is caller-priced via an optional prices table so the library ships no model prices. When a running total crosses a budget, batch_run stops launching new work and marks skipped inputs with BudgetExceededError. Adds tests/flows/test_usage.py, extends tests/flows/test_batch.py, adds examples/flows/batch_usage.py, and updates the docs/README.md catalog. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 27a08e6 commit 4d251d0

8 files changed

Lines changed: 427 additions & 10 deletions

File tree

docs/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ harness.
1818
| Paper structure build | `quantmind.flows.PaperFlow` | `PaperFlow(PaperStructureCfg)`; `build()`: `PaperInput` | `PaperStructureTree` (self-contained) | [Build and retrieve](../examples/mind/paper_structure_retrieval.py) | [Structure retrieval design](../contexts/design/mind/retrieval.md) |
1919
| Reasoning-based retrieval (agentic) | `quantmind.mind.AgenticRetriever` | `AgenticRetriever(RetrievalCfg)`; `retrieve()`: one `StructureTree` + question (no library) | `list[RetrievalEvidence]` | [Build and retrieve](../examples/mind/paper_structure_retrieval.py) | [Structure retrieval design](../contexts/design/mind/retrieval.md) |
2020
| News collection | `quantmind.flows.collect_news` | `NewsWindow`, `NewsCollectionCfg` | `NewsBatch` from `quantmind.preprocess` | [Collect news](../examples/flows/collect_news.py) | [News collection design](../contexts/design/flow/news.md) |
21-
| Bounded fan-out | `quantmind.flows.batch_run` | Operation inputs and shared config | `BatchResult` | [README usage](../README.md#-usage-examples) | API docstrings |
21+
| Bounded fan-out | `quantmind.flows.batch_run` | Operation inputs, shared config, and optional `prices` table | `BatchResult` (with aggregate `tokens_total` / `cost_estimate_usd` and budget guardrails) | [Batch usage and budgets](../examples/flows/batch_usage.py) | API docstrings |
2222
| Local semantic search | `quantmind.library.LocalKnowledgeLibrary` | `BaseKnowledge` or `PaperFlowResult`, `SemanticQuery` | `list[SemanticHit]` | [Library example](../examples/library/README.md) | [Library guide](library.md) |
2323
| Page-aware document RAG | `quantmind.rag.chunk_parsed_document`, `quantmind.rag.retrieve_parsed_document` | `ParsedDocument`, splitter config, and query | `tuple[ParsedDocumentHit, ...]` | [Paper RAG](../examples/rag/paper.py) | [Document RAG design](../contexts/design/rag/document.md) |
2424

examples/flows/batch_usage.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Fan out over papers, then read aggregate token usage and priced cost.
2+
3+
``batch_run`` now reports the SDK token usage it consumed and, when given a
4+
price table, an estimated USD cost — and enforces the ``cfg`` budget
5+
guardrails, marking any input skipped after the budget trips with a
6+
``BudgetExceededError``. Requires ``OPENAI_API_KEY`` (like the other flow
7+
examples).
8+
"""
9+
10+
import asyncio
11+
12+
from quantmind.configs import PaperFlowCfg
13+
from quantmind.configs.paper import ArxivIdentifier
14+
from quantmind.flows import (
15+
BudgetExceededError,
16+
PriceRate,
17+
batch_run,
18+
paper_flow,
19+
)
20+
21+
22+
async def main() -> None:
23+
"""Build several papers under one shared budget and report spend."""
24+
cfg = PaperFlowCfg(
25+
model="gpt-4o-mini",
26+
max_total_input_tokens=200_000, # stop launching once we cross this
27+
)
28+
# Caller-supplied pricing (USD per 1M tokens); the library ships none.
29+
prices = {
30+
"gpt-4o-mini": PriceRate(input_usd_per_1m=0.15, output_usd_per_1m=0.60),
31+
}
32+
inputs = [
33+
ArxivIdentifier(id="1706.03762v7"),
34+
ArxivIdentifier(id="2404.11584"),
35+
]
36+
37+
result = await batch_run(
38+
paper_flow, inputs, cfg=cfg, concurrency=2, prices=prices
39+
)
40+
41+
print(f"success={result.success_count} failure={result.failure_count}")
42+
print(f"tokens={result.tokens_total}")
43+
print(f"cost_usd≈{result.cost_estimate_usd:.4f}")
44+
skipped = [
45+
i for i, e in result.errors if isinstance(e, BudgetExceededError)
46+
]
47+
if skipped:
48+
print(f"budget-skipped inputs: {skipped}")
49+
50+
51+
if __name__ == "__main__":
52+
asyncio.run(main())

quantmind/flows/__init__.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,19 @@
1212
- ``paper_flow`` is a thin compatibility function for the semantic
1313
chunk/summary shape (``PaperFlowResult``).
1414
- ``batch_run`` runs any flow over a list of inputs with bounded
15-
concurrency and aggregated results.
16-
- ``BatchResult`` is the shape returned by ``batch_run``.
15+
concurrency and aggregated results, reporting aggregate token usage
16+
(and optionally priced cost) and enforcing the ``cfg`` budget guardrails.
17+
- ``BatchResult`` is the shape returned by ``batch_run``; ``UsageSummary``
18+
and ``PriceRate`` describe its usage/cost fields, and
19+
``BudgetExceededError`` marks inputs skipped after a budget tripped.
1720
- ``UnsupportedContentTypeError`` is raised when a paper pipeline does not
1821
resolve a page-aware PDF.
1922
- ``PaperStructureError`` is raised when structure building exceeds its
2023
runtime boundary.
2124
"""
2225

23-
from quantmind.flows.batch import BatchResult, batch_run
26+
from quantmind.flows._usage import PriceRate, UsageSummary
27+
from quantmind.flows.batch import BatchResult, BudgetExceededError, batch_run
2428
from quantmind.flows.news import collect_news
2529
from quantmind.flows.paper import (
2630
PaperFlow,
@@ -32,10 +36,13 @@
3236

3337
__all__ = [
3438
"BatchResult",
39+
"BudgetExceededError",
3540
"PaperCitationValidationError",
3641
"PaperFlow",
3742
"PaperStructureError",
43+
"PriceRate",
3844
"UnsupportedContentTypeError",
45+
"UsageSummary",
3946
"batch_run",
4047
"collect_news",
4148
"paper_flow",

quantmind/flows/_runner.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from agents import Agent, RunConfig, RunHooks, Runner
1313

1414
from quantmind.configs import BaseFlowCfg
15+
from quantmind.flows._usage import record_usage
1516

1617

1718
async def run_with_observability(
@@ -55,6 +56,9 @@ async def run_with_observability(
5556
hooks=hooks,
5657
max_turns=cfg.max_turns,
5758
)
59+
# No-op unless a `usage_scope` is active (e.g. inside `batch_run`);
60+
# keeps every existing caller's behaviour unchanged.
61+
record_usage(result.context_wrapper.usage)
5862
_archive_run_artifacts(cfg, memory, result)
5963
return result.final_output
6064

quantmind/flows/_usage.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Per-run usage accounting layered on the flows observability seam.
2+
3+
The Agents SDK computes token usage as ``RunResult.context_wrapper.usage``,
4+
but ``run_with_observability`` returns only ``final_output`` and drops the
5+
rest. Flows return domain objects, so ``batch_run`` never sees usage.
6+
7+
This module surfaces usage across that seam without changing any flow's
8+
return type. ``run_with_observability`` calls ``record_usage`` after every
9+
run; a caller opens a ``usage_scope`` around the work it wants measured, and
10+
each run folds its usage into the active accumulator. ``asyncio`` copies the
11+
context (and with it the accumulator *reference*) into every child task, so
12+
usage from nested ``gather`` / ``wait_for`` fan-outs accumulates into the one
13+
scope the caller opened. Mutation has no ``await`` points, so no lock is
14+
needed on the single-threaded event loop.
15+
"""
16+
17+
import contextlib
18+
from collections.abc import Iterator
19+
from contextvars import ContextVar
20+
from dataclasses import dataclass
21+
22+
23+
@dataclass(frozen=True, slots=True)
24+
class PriceRate:
25+
"""Per-token USD pricing for one model (rates are per one million tokens)."""
26+
27+
input_usd_per_1m: float
28+
output_usd_per_1m: float
29+
30+
def cost(self, input_tokens: int, output_tokens: int) -> float:
31+
"""USD cost for the given input / output token counts."""
32+
return (
33+
input_tokens / 1_000_000 * self.input_usd_per_1m
34+
+ output_tokens / 1_000_000 * self.output_usd_per_1m
35+
)
36+
37+
38+
@dataclass(frozen=True, slots=True)
39+
class UsageSummary:
40+
"""Immutable token-usage snapshot returned to callers.
41+
42+
``cost_usd`` is ``None`` unless the caller supplied a price table; the
43+
library reports tokens and leaves per-model pricing to the caller.
44+
"""
45+
46+
requests: int = 0
47+
input_tokens: int = 0
48+
output_tokens: int = 0
49+
total_tokens: int = 0
50+
cost_usd: float | None = None
51+
52+
def as_tokens_dict(self) -> dict[str, int]:
53+
"""Return the token counts as a plain dict (no cost)."""
54+
return {
55+
"requests": self.requests,
56+
"input_tokens": self.input_tokens,
57+
"output_tokens": self.output_tokens,
58+
"total_tokens": self.total_tokens,
59+
}
60+
61+
62+
@dataclass(slots=True)
63+
class _Accumulator:
64+
"""Mutable running total; duck-types the SDK ``Usage`` object."""
65+
66+
requests: int = 0
67+
input_tokens: int = 0
68+
output_tokens: int = 0
69+
total_tokens: int = 0
70+
71+
def add(self, usage: object) -> None:
72+
"""Fold one SDK ``Usage`` (or ``UsageSummary``) into the total."""
73+
self.requests += getattr(usage, "requests", 0)
74+
self.input_tokens += getattr(usage, "input_tokens", 0)
75+
self.output_tokens += getattr(usage, "output_tokens", 0)
76+
self.total_tokens += getattr(usage, "total_tokens", 0)
77+
78+
def summary(self) -> UsageSummary:
79+
"""Snapshot the running total as an immutable ``UsageSummary``."""
80+
return UsageSummary(
81+
requests=self.requests,
82+
input_tokens=self.input_tokens,
83+
output_tokens=self.output_tokens,
84+
total_tokens=self.total_tokens,
85+
)
86+
87+
88+
_usage_var: ContextVar[_Accumulator | None] = ContextVar(
89+
"quantmind_usage", default=None
90+
)
91+
92+
93+
@contextlib.contextmanager
94+
def usage_scope() -> Iterator[_Accumulator]:
95+
"""Accumulate usage from every run inside this context.
96+
97+
Yields the accumulator; read ``.summary()`` after the block. Nested
98+
``usage_scope`` calls each measure only their own runs.
99+
"""
100+
accumulator = _Accumulator()
101+
token = _usage_var.set(accumulator)
102+
try:
103+
yield accumulator
104+
finally:
105+
_usage_var.reset(token)
106+
107+
108+
def record_usage(usage: object) -> None:
109+
"""Fold one run's SDK usage into the active scope (no-op if none)."""
110+
accumulator = _usage_var.get()
111+
if accumulator is not None:
112+
accumulator.add(usage)

quantmind/flows/batch.py

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,16 @@
1313
from typing import Any, Generic, Literal, TypeVar
1414

1515
from quantmind.configs import BaseFlowCfg, BaseInput
16+
from quantmind.flows._usage import PriceRate, usage_scope
1617

1718
InputT = TypeVar("InputT", bound=BaseInput)
1819
OutputT = TypeVar("OutputT")
1920

2021

22+
class BudgetExceededError(Exception):
23+
"""Raised for inputs skipped after a batch token / cost budget tripped."""
24+
25+
2126
@dataclass(slots=True)
2227
class BatchResult(Generic[OutputT]):
2328
"""Aggregate result of running a flow over many inputs.
@@ -56,6 +61,7 @@ async def batch_run(
5661
concurrency: int = 4,
5762
on_error: Literal["raise", "skip"] = "skip",
5863
on_progress: Callable[[int, int], None] | None = None,
64+
prices: dict[str, PriceRate] | None = None,
5965
**flow_kwargs: Any,
6066
) -> BatchResult[OutputT]:
6167
"""Run ``flow_fn`` over ``inputs`` with bounded concurrency.
@@ -75,19 +81,28 @@ async def batch_run(
7581
completion (success or failure). Must be cheap and
7682
non-blocking — callbacks are invoked synchronously inside
7783
the worker loop.
84+
prices: Optional ``{model: PriceRate}`` table. When it covers
85+
``cfg.model``, ``cost_estimate_usd`` is filled and the
86+
``max_total_cost_usd`` guardrail is enforced; otherwise cost
87+
stays ``0.0`` and only the token guardrail applies. Pricing is
88+
caller-supplied so the library never ships model prices.
7889
**flow_kwargs: Forwarded verbatim to ``flow_fn``. ``memory=`` is
7990
**forbidden** in MVP; passing it raises ``ValueError``.
8091
8192
Returns:
8293
``BatchResult`` with ``results`` parallel to ``inputs`` (None for
83-
failures) and ``errors`` sorted by index.
94+
failures) and ``errors`` sorted by index. ``tokens_total`` holds
95+
aggregate SDK usage (empty when nothing was recorded) and
96+
``cost_estimate_usd`` the priced total.
8497
8598
Raises:
8699
ValueError: If ``memory=`` is passed via ``flow_kwargs``, or if
87100
``concurrency < 1``.
88101
Exception: Re-raised when ``on_error="raise"`` and any input
89-
fails. The exception is the first one raised by a worker;
90-
other workers may already be cancelled when this surfaces.
102+
fails (including a ``BudgetExceededError`` for an input skipped
103+
after ``cfg.max_total_input_tokens`` / ``max_total_cost_usd``
104+
tripped). Other workers may already be cancelled when this
105+
surfaces.
91106
"""
92107
if "memory" in flow_kwargs:
93108
raise ValueError(
@@ -105,11 +120,52 @@ async def batch_run(
105120
started = time.monotonic()
106121
done_counter = 0
107122

123+
price = prices.get(cfg.model) if prices is not None and cfg else None
124+
max_tokens = cfg.max_total_input_tokens if cfg else None
125+
max_cost = cfg.max_total_cost_usd if cfg else None
126+
# asyncio is single-threaded; `.add` and these reads have no `await`
127+
# between them, so this shared running total needs no lock.
128+
running = {
129+
"requests": 0,
130+
"input_tokens": 0,
131+
"output_tokens": 0,
132+
"total_tokens": 0,
133+
}
134+
running_cost = 0.0
135+
budget_tripped = False
136+
108137
async def run_one(i: int, inp: InputT) -> None:
109-
nonlocal done_counter
138+
nonlocal done_counter, running_cost, budget_tripped
110139
async with sem:
111140
try:
112-
results[i] = await flow_fn(inp, cfg=cfg, **flow_kwargs)
141+
# ponytail: post-hoc gate — under concurrency we can't
142+
# pre-check spend before it happens; we stop *launching*
143+
# new work once the budget trips. Upgrade path: pre-flight
144+
# token estimate per input if strict caps matter.
145+
if budget_tripped:
146+
raise BudgetExceededError(
147+
f"input {i} skipped: batch budget already exceeded"
148+
)
149+
with usage_scope() as acc:
150+
results[i] = await flow_fn(inp, cfg=cfg, **flow_kwargs)
151+
summary = acc.summary()
152+
running["requests"] += summary.requests
153+
running["input_tokens"] += summary.input_tokens
154+
running["output_tokens"] += summary.output_tokens
155+
running["total_tokens"] += summary.total_tokens
156+
if price is not None:
157+
running_cost = price.cost(
158+
running["input_tokens"], running["output_tokens"]
159+
)
160+
if (
161+
max_tokens is not None
162+
and running["input_tokens"] > max_tokens
163+
) or (
164+
max_cost is not None
165+
and price is not None
166+
and running_cost > max_cost
167+
):
168+
budget_tripped = True
113169
except Exception as exc:
114170
errors.append((i, exc))
115171
if on_error == "raise":
@@ -133,4 +189,6 @@ async def run_one(i: int, inp: InputT) -> None:
133189
results=results,
134190
errors=sorted(errors, key=lambda t: t[0]),
135191
duration_seconds=time.monotonic() - started,
192+
tokens_total=dict(running) if any(running.values()) else {},
193+
cost_estimate_usd=running_cost,
136194
)

0 commit comments

Comments
 (0)