~12 µs to see
every call.
The shared foundation, kept tiny on purpose: one instrument() wrap normalizes every LLM and tool call onto an event bus — plus token counting, offline prices, and the protocols that let the tools interlock without importing each other.
installed transitively by every tool — you rarely install it directly
from cendor.core import instrument, tokens, prices client = instrument(OpenAI()) # 6 providers · sync / async / streaming n = tokens.count(messages, model="gpt-4o") # exact by default (tiktoken ships with core) cost = prices.estimate("gpt-4o", n, 200) # exact Decimal, offline tokens.method("gpt-4o") # "exact" — counts always report their method
Seven pieces. One seam.
core is the blast radius for the whole stack, so it stays deliberately small: normalize every call, count it, price it, publish it — and give the tools shared protocols to interlock through, by shape, never by import.
instrument_tool() does the same for your toolsadd_interceptor + Reroute/MISS — the single seam cassette (replay), tokenguard (block/reroute), and acttrace's guard() (block / redact-before-send via Reroute(messages=…)) share; no second patch pointmethod() reports which is active, register() plugs in a precise counterestimate(); refresh(source="litellm"|"openrouter"|"azure") pulls live rates with no auth and no new deps; a gateway-reported cost is preferred and labeled cost_reported vs cost_estimatedgen_ai.* spans, or ingest() a managed runtime's spans onto the bus — budgets and audit work even when you don't own the loopCompressor, EvictionStrategy, Sink, Subscriber, Handle — tools interlock by shape, never by importOne seam, six providers.
Six different SDKs, one wrap. instrument() detects the client by shape — pick a provider, make a call, and watch the same normalized event land on the bus for every subscriber.
▸ pick a provider · make a call · watch the bus · this is the whole integration, live
…and none of them touched your client. ~12 µs overhead: bus emit + usage extraction + Decimal pricing.
Own the loop — or don't.
When your code calls the model, instrument() sees every model and tool call directly. Wrap the client once; every call it makes lands on the bus, normalized — no per-call wiring, no callbacks to register. This is the path with full enforcement: budgets, redact-before-send, record/replay.
When a managed runtime owns the loop — Foundry Agent Service, OpenAI Assistants — feed its gen_ai.* OpenTelemetry spans to otel.ingest() and the same calls land on the same bus. tokenguard and acttrace keep working, even when you don't own the loop.
When LangChain or LangGraph drives the loop, don't wrap its inner client — register CendorCallbackHandler (the [langchain] extra). It records usage, reasoning, tool calls, and a run-correlated trace_id from the framework's own callbacks, with no client touch. Recording-only by design — for enforcement, call the provider SDK directly.
Aligned to the SDK it wraps.
Newer additions follow one rule: add no failure mode the underlying SDK lacks, and expose the SDK-aligned hook where you need one. Streaming matches the SDK's own object, durable logging stays off the call's hot path, and a contextvar correlates a whole run.
A streamed response now supports with / async with — exactly like the provider SDK's own stream object. Iterate it, or use it as a context manager (what langchain_openai does); either way the LLMCall finalizes once. Unknown attributes forward through; closing early still emits once. Replayed streams carry the same surface.
with client.chat.completions.create(..., stream=True) as s: for chunk in s: ... # iterate OR `with` — emits once
QueueSink — durable I/O, off the hot pathThe bus runs subscribers inline, so a durable sink (SQLite/OTel/file) adds its write latency to every call. Wrap it: write() enqueues and returns, a single worker drains in order. The Sink protocol gains optional flush()/close() for durability at a checkpoint or shutdown — write-only sinks stay valid.
from cendor.tokenguard.sinks import QueueSink, SQLiteSink use_sink(QueueSink(SQLiteSink("spend.db")))
trace() — ambient run correlationEvery LLMCall/ToolCall carries a trace_id (default ""). Set an ambient one to group a unit of work — a direct-SDK agent loop, a request — so its calls share an id downstream. A contextvars binding (nests, sync/async); current_trace_id() reads it. A hook, not an orchestrator.
from cendor.core import trace with trace("session-42"): client.chat.completions.create(...)
Full details in the core docs · framework integration in the Frameworks guide
Where the edges are — by design.
The bus and interceptor registry are lock-guarded module state: ideal for scripts, tests, and a worker process. Multi-process deployments externalize durable state via sinks.
The six providers' current entrypoints are covered — including OpenAI's Responses API, the google-genai SDK, and Hugging Face's InferenceClient. An unrecognized client is returned untouched, never broken. Bedrock's separate converse_stream entrypoint isn't wrapped — use converse.
Exact counting ships by default (tiktoken is a required dependency); the heuristic is only a defensive fallback if it can't be imported. method() / is_exact() always tell you which tier produced a count.
The bundled table ships dated; age_days() / is_stale() surface staleness and refresh() pulls live rates. An unpriced model yields cost=None — and tokenguard warns.
Kept tiny on purpose.
core docs → · provider integration guide → · the architecture, in depth