runlatent — ingest finished LLM calls into Latent's scoring API
pip install runlatent
A Python SDK that captures every finished call your app makes through the OpenAI, Anthropic
or Google GenAI Python SDK and posts the (prompt, answer) pair to Latent's scoring API, which
is the sidecar's POST /score contract (latent_sidecar/README.md). The app's call is never
slower by a network round trip, never sees a different return value and never gets an
exception from here. Step 1 of the self-serve path: no gateway, no log tail, one line at startup.
your app ──openai / anthropic / google-genai SDK──▶ the provider
│ (after the SDK returned, fire-and-forget, a daemon thread)
└──POST <LATENT_API_URL>/score {request_id, messages, output, model, finish_reason}──▶ Latent
Install
pip install runlatent (the runlatent distribution on PyPI, built from runlatent/ in this
repository; from the repository, pip install -e .). It has no runtime dependencies: nothing
from numpy, torch or the core packages reaches your process. The provider SDKs are yours:
whichever of openai, anthropic, google-genai is importable gets patched, the others are
skipped.
One-line setup
import runlatent
runlatent.auto_instrument() # reads LATENT_API_URL and LATENT_API_KEY
Call it once, before the clients are built or after, it does not matter: the patch is on the
SDK classes (Completions.create, AsyncCompletions.create, Responses.create,
AsyncResponses.create; Messages.create / .stream and the async pair; Models.generate_content
/ .generate_content_stream and AsyncModels), so every client in the process is covered,
including ones a framework builds for you. Keyword arguments:
| argument | default | meaning |
|---|---|---|
openai, anthropic, google_genai |
True |
which SDKs to patch |
api_url |
$LATENT_API_URL |
the API base; the SDK posts to <api_url>/score |
api_key |
$LATENT_API_KEY |
sent as Authorization: Bearer <key> |
redact |
None |
callable(body) -> body or None, run on every pair before it is queued (below) |
timeout |
5.0 |
seconds per POST |
max_queue |
10000 |
pairs waiting for the poster thread; a full queue drops, counted |
Idempotent: a second call patches nothing twice, and with the same destination it keeps the
same poster and its queue; a call with a different api_url / api_key / timeout /
max_queue swaps the poster at once and drains the old one on its own thread (its final
counters stay in stats()), never on the caller and never while the app's calls wait.
runlatent.uninstrument() restores every original (classes and wrapped instances). With no URL anywhere, auto_instrument() logs one
warning, patches nothing and returns stats() with enabled: False.
Check before you deliver: runlatent.check
auto_instrument and the wrappers observe: the pair is posted after the provider returned, on a
daemon thread, and the read never blocks or fails your call. To hold, replace or re-route a
flagged answer before your user sees it, read it synchronously first. On hosted plans
POST /score answers the verdict in tens of milliseconds (the hosted service: docs/hosted-score.md),
so the whole pattern is two lines:
r = runlatent.check(prompt, answer, context=docs)
if r.flagged: answer = SAFE_FALLBACK
check is the way to hold before delivery on hosted plans; in front of your own vLLM the gateway
does it automatically from the in-band event, and auto_instrument alone never holds anything.
runlatent.check(prompt, answer, *, context=None, system=None, messages=None, model=None,
timeout=2.0, request_id=None, raise_on_error=False) -> CheckResult
await runlatent.acheck(...) # the same read, awaited (run off the event loop)
| argument | meaning |
|---|---|
prompt |
the user's question (one user turn) |
answer |
the text about to be delivered; empty -> verdict: "unscored", nothing posted |
context |
the retrieved sources; rendered before the question as Knowledge: <context>\n<prompt>, the shape the probes are calibrated on |
system |
the system prompt, a string or Anthropic's block list, posted as a leading system turn (flattened as the capture flattens it) |
messages |
the conversation as the provider saw it ([{role, content}, ...]), instead of prompt / context |
model |
the generating model's name, for the console |
timeout |
seconds for the whole exchange (default 2.0); no reply in time is a refusal, never an exception |
request_id |
the console row's id (the provider's response id when you have one; else a uuid) |
raise_on_error |
True raises runlatent.CheckError (with .result) on a refusal instead of returning it |
It uses the destination and key auto_instrument() / configure() / the environment set
(LATENT_API_URL, LATENT_API_KEY), posts the same body auto_instrument posts
(client.api: "check") through the same redact hook, and the route stores the pair in your
console like any other scored answer. With neither a URL nor a key configured it raises
runlatent.ConfigurationError at once, whatever raise_on_error says: nothing can be checked and
you need to know.
CheckResult is a small frozen dataclass:
| field | meaning |
|---|---|
risk |
the probe's probability that the answer is unreliable (None when not scored) |
verdict |
pass / escalate / ood from the reader; unscored (an empty answer, or redact returned None); unknown (the read did not happen, or came back unreadable) |
request_id |
the console row (/events/<request_id>) |
ood_distance |
the OOD guard's distance, when the reader sent one |
scored |
True exactly when the server answered 2xx with a risk and a verdict the SDK knows |
status |
the HTTP status; 0 when no reply came back (timeout, connection error, local skip) |
detail, resume |
the server's detail and how-to-resume sentence on a refusal (limit_reached, rate_limited, unauthorized: ...), or the transport error |
retry_after_s |
the server's Retry-After on a 429 / 503 |
error |
the exception class on a transport failure (TimeoutError, ConnectionRefusedError) |
.flagged |
verdict in ("escalate", "ood"): do not deliver as is |
.passed |
scored and pass |
.refused |
the read did not happen or was unreadable (verdict == "unknown") |
A refusal never raises by default. A 401 / 403 / 429 / 5xx, a timeout and a connection error
all return scored=False with the status and the server's words, so the fail-open / fail-closed
choice is yours and explicit:
r = runlatent.check(prompt, answer, context=docs)
if not r.scored: # the read did not happen: r.status, r.detail, r.resume say why
deliver(answer) # fail open: deliver unchecked (and alert on r.detail), or
# answer = SAFE_FALLBACK # fail closed: hold
elif r.flagged:
answer = SAFE_FALLBACK
if r.passed is the fail-closed test in one word (a refusal is not a pass); if not r.flagged is
the fail-open one. bool(r) is r.passed, so if runlatent.check(...) holds a refusal AND an
empty answer (unscored); the properties say which. raise_on_error=True turns a refusal into
runlatent.CheckError for code that prefers an exception. A 2xx whose verdict is not one of
pass / escalate / ood is a refusal too (scored=False, verdict unknown, error ValueError): a reply the SDK cannot read never delivers as "not flagged". The only other
exceptions are ConfigurationError (no URL or no key) and ValueError for a call that cannot
mean anything (messages= together with prompt / context; a timeout that is not positive).
One scored answer, not two. With auto_instrument on, the pair the instrumentation just
captured is scored once when the check can be paired with it. The pairing is by request_id
first (pass the provider's response id, request_id=resp.id: exact), then by the conversation
(messages= as the provider saw it, plus the answer, whitespace normalized; with Anthropic pass
system= too), never by the answer text alone: the grounding verdict depends on the prompt, so
"Paris" checked for "Capital of Germany?" must not inherit the pass of "Paris" captured for
"Capital of France?". Paired, check adopts the captured read's reply and its request_id
(stats()["check_adopted"]); a captured read still queued behind a slow one after half the
timeout is claimed and posted by check under the same id, the poster skipping its copy
(skipped_checked); one the poster is already sending, or retrying after a 429 / 5xx, is waited
for to the end of the timeout and adopted (check_waited_in_flight), never posted a second time;
a read that outlasts the timeout is a refusal (status 0, error TimeoutError, "still in
flight") and the poster's read lands in the console on its own. A check that runs before the
capture fires (Anthropic's .stream() manager: the app has the text inside the with) makes that
capture skip when it carries the same request_id (get_final_message().id) or the same
messages=. One capture pairs with one check, within 60 s, in a set bounded to 1,000 pairs.
What is not paired is scored twice, two console rows and two scored answers on the bill:
check(prompt, answer, context=docs) with no request_id next to auto_instrument (it renders
one user turn, Knowledge: <context>\n<prompt>, which equals the provider's messages only when
that is exactly what you sent; otherwise only the id pairs them: pass resp.id), and a stream
manager's check that carries neither the id nor the messages. A paired answer appears once in the
console and once on the bill.
Per-client wrappers
The same capture on one client object, nothing else in the process touched:
client = runlatent.wrap_openai(OpenAI()) # or AsyncOpenAI()
client = runlatent.wrap_anthropic(Anthropic()) # or AsyncAnthropic()
client = runlatent.wrap_genai(genai.Client(...)) # sync and .aio
Each returns the client. Wrapping twice is a no-op; a wrapped instance under a patched class captures once, not twice.
Environment
| variable | meaning |
|---|---|
LATENT_API_URL |
the API base (the sidecar's address when you run it yourself: http://sidecar:8090) |
LATENT_API_KEY |
the bearer (the sidecar's LATENT_SIDECAR_TOKEN) |
LATENT_SIDECAR_URL, LATENT_SIDECAR_TOKEN |
accepted as fallbacks when the two above are unset |
LATENT_SDK_EXIT_FLUSH_S |
seconds the atexit handler waits for queued pairs at a normal interpreter exit (default 2.0; 0 disables, see Limits) |
What is posted
One JSON body per finished call, the sidecar's /score contract plus one extra block:
{"request_id": "chatcmpl-abc123",
"messages": [{"role": "system", "content": "..."}, {"role": "user", "content": "..."}],
"output": "the full answer text",
"model": "gpt-4.1", "finish_reason": "stop",
"client": {"provider": "openai", "api": "chat.completions.create", "sdk": "openai", "sdk_version": "1.x",
"runlatent": "0.2.0", "captured_at": "2026-10-02T12:00:00.000+00:00", "stream": false,
"response_id": "chatcmpl-abc123", "finish_reason_raw": "stop",
"usage": {"input_tokens": 120, "output_tokens": 48, "total_tokens": 168},
"parts_dropped": 0, "items_dropped": 0}}
-
request_idis the provider's response id (chatcmpl-…,msg_…,resp_…, Gemini'sresponse_id) when it has one, else a uuid; the raw id is repeated inclient.response_id. -
messagesis the request as the app sent it, system prompt included, reduced to{role, content}: OpenAImessages(atoolturn is a text message; an assistant turn withtool_callscarries[tool_calls omitted]); the Responses API'sinstructionsassystemand itsinputstring or message items (non-message items, function call outputs and the like, are skipped and counted initems_dropped;previous_response_idis recorded, the earlier turns are not fetched); Anthropic'ssystemassystemandmessages(tool_resulttext kept,thinkingblocks skipped); GenAI'sconfig.system_instructionassystemandcontents(modelrole becomesassistant, loose strings and parts become one user turn). -
outputis the whole answer text: the first choice'smessage.content; the Responses output'smessageitems; Anthropic'stextblocks; the first Gemini candidate's text parts (thoughts skipped). Non-text answer parts (atool_useblock, a Geminifunction_call, an image) contribute nothing to it, so an answer with no text is not posted: countedskipped_tool_onlywhen such parts were seen,skipped_empty_outputotherwise (a blocked prompt). A Responses API response withstatus: failedor anerroris not an answer and is never posted (skipped_failed_response), in the stream (response.failed) or not. -
messagesis read BEFORE the SDK method runs, into fresh dicts and strings, sync and async alike. The pair is posted later (after the response, after a stream, after an await) but from that snapshot: an app that appends the assistant turn to its own list while the call is in flight or before the stream is exhausted does not leak the answer into the posted prompt. A one-shot iterable (a generator formessages, Responsesinput, Anthropicmessages/system, GenAIcontents) is not read ahead, which would empty it for the SDK: it reaches the SDK through a recording pass-through, so the provider receives exactly the elements the app produces (and, if the generator raises, the same exception at the same point; that request is countedskipped_unreadable_request), and the snapshot is what the SDK consumed. -
finish_reasonis the plugin's vocabulary, so the service'sfinish_to_reviewrule reads it; the provider's word is kept inclient.finish_reason_raw; a missing one stays unset so the sidecar marksfinish_unknownrather than guess. The map:posted OpenAI chat OpenAI Responses ( status[:reason])Anthropic Gemini stopstopcompletedend_turn,stop_sequenceSTOPlengthlengthincomplete:max_output_tokensmax_tokens,model_context_window_exceededMAX_TOKENStool_callstool_calls,function_calltool_useMALFORMED_FUNCTION_CALLcontent_filtercontent_filterincomplete:content_filterrefusalSAFETY,RECITATION,BLOCKLIST,PROHIBITED_CONTENT,SPII,IMAGE_SAFETY,LANGUAGEabort(stream closed early) cancelled,in_progress,queuedpassed through failedpause_turnother,unknown(OTHER,FINISH_REASON_UNSPECIFIED),blocked:<reason>for a blocked promptA word not in the table passes through lower-cased, cut to the sidecar's 32 characters.
-
A conversation longer than the sidecar's 256-message limit keeps its system turn and the newest turns; the number dropped is in
client.messages_truncated(without the cap the sidecar answers 422 and the pair is lost). -
Multimodal and tool parts in the PROMPT (images, audio, files,
inline_data, function calls) are reduced to a marker in place,[image_url omitted], so the prompt keeps its shape, and counted inclient.parts_droppedand instats()["multimodal_parts_dropped"]; in the answer they are dropped without a marker (above). The bytes never leave the process. -
usageis normalised to input / output / total tokens.
The sidecar validates the body with its own ScoreRequest; the client block is outside that
contract today and is ignored by pydantic, not refused (tests/test_sdk.py posts the body to
the real app). Token ids are not posted (the SDKs do not expose them).
What is never posted
- API keys, headers, the request's other parameters (temperature, tools, response formats,
metadata): only
modeland the message text are read. - Image, audio and file bytes or URLs (reduced to a marker, see above).
- Thinking / reasoning blocks (Anthropic
thinking, Geminithoughtparts, Responsesreasoningitems). - Anything when the call failed: the SDK's exception propagates unchanged and no pair exists.
- Anything the
redacthook returnedNonefor, or raised on (a failed masker never lets the unmasked pair through; the drop is counted).
Streaming
A stream is returned to the app through a thin proxy that yields the same items and delegates
every other attribute (.response, .close(), with …), and the pair is posted once, at
the end: when the iterator is exhausted (OpenAI chat chunks and Responses events, Anthropic
raw events, Gemini chunks are assembled into the full text; the last chunk's finish and usage
are taken), or when the proxy's own close() or context exit runs before that, in which
case the partial text is posted with finish_reason: "abort" and client.stream_complete: false. A stream the app simply stops reading (for chunk in s: break with no close() and no
with on s) is never posted: it is counted stream_unfinished when the proxy is
garbage-collected (a finalizer counts, it does not post). OpenAI's own helper managers,
client.chat.completions.stream() and client.responses.stream(), go through the patched
create and capture once when run to exhaustion; their __exit__ closes the HTTP response,
not the stream, so an early exit from one of them is the unfinished case above, not abort.
One consequence: chat.completions.stream() can raise LengthFinishReasonError from its own
parsing after the final chunk arrived; the raw stream under it was exhausted, so the pair IS
posted, as length, although the app got an exception rather than a completion (E-R149). abort is only ever
the app closing a healthy stream early: a stream that raised mid-way, or a with block an
exception left (the SDK raising on an error event, Anthropic's overloaded_error included,
or the app raising inside the block), posts nothing and is counted stream_errored. Anthropic's
messages.stream() manager hands the app the SDK's MessageStream through a thin proxy (same
events, text_stream, get_final_message() and every attribute delegated) that remembers a
failure raised while reading, so an overloaded_error the app catches inside the block still
posts nothing; a clean exit reads current_message_snapshot (no extra network read), and a
healthy stream the app stops reading early posts the partial text as abort. Only the first
choice / candidate is assembled when n > 1.
Redaction (default: none)
By default the SDK posts the text as the app sent and received it, because masking changes what Latent reads: E-R143 (2026-10-01) ran a default de-identification tool over logged prompts and answers masked separately and a third of the good answers turned into judged failures (the answer named "Felica Cannon", the masked source only "Felica"), the probe lost up to 0.07 AUROC and the flagged list overlapped the unmasked one about 50%. Identifiers-only masking with ONE dictionary derived from the source and applied to prompt and answer alike kept the judge at 0.96 and the read within 0.02. If you must mask before posting, do that, and do it in one place:
def redact(body):
table = my_entities(body["messages"]) # names from the source, one dictionary
body["messages"] = [dict(m, content=mask(m["content"], table)) for m in body["messages"]]
body["output"] = mask(body["output"], table) # the same table on the answer
return body # or None to skip this pair
runlatent.auto_instrument(redact=redact)
The hook sees the full body (the client block included) and may rewrite or drop it. Prefer
raw text under the processing terms (retention is the service's policy.retention) and mask
after the read where a report needs it.
Counters and shutdown
runlatent.stats()
# {"enabled": true, "api_url": "...", "patched": ["openai:Completions.create", ...],
# "captured": 120, "posted": 118, "failed": 0, "dropped": 2, "queued": 0,
# "skipped_empty_output": 3, "multimodal_parts_dropped": 7, "redact_skipped": 0,
# "checked": 40, "check_scored": 38, "check_adopted": 30, "check_unscored": 1, "check_refused": 1, "skipped_checked": 2,
# "errors": {"post:ConnectionRefusedError": 0}, "poster": {... the poster's own counters ...}}
runlatent.flush(timeout=5.0) # at shutdown: wait for the queue to be attempted
checked counts check() calls; check_scored those answered with a risk (check_adopted of them
read from the instrumentation's own POST rather than a second one), check_unscored the empty
answers, check_refused the refusals and transport failures (by class under errors,
check_post:TimeoutError), skipped_checked the captured pairs the poster did not post because a
check recorded them.
captured counts pairs assembled; posted / failed / dropped / queued are the poster's
(latent_vllm.events.SinkPoster through latent_sidecar.tap.FireAndForget: daemon thread,
bounded queue, three attempts per pair, no spill file: a pair that cannot be delivered is
lost and counted). dropped includes pairs captured with no destination configured.
errors is keyed by stage and exception class (observe:… a shape the adapter could not read,
redact:…, post:…, stream_feed:…); nothing in it ever reached the app. Only the class is
logged (at DEBUG), never the exception's message: a masker that raises with its input in the
message must not put answer text in your log.
Limits
- Only the methods above are patched.
chat.completions.parse()andresponses.parse()post through their own path and are not captured;beta.*, the Assistants and Realtime APIs and other providers are not in this version; wrap a gateway or uselatent_sidecar.tapfor those. Captured because they route through the patched methods:chat.completions.stream()andresponses.stream()(exhaust-to-capture, see Streaming) and Geminichats.send_message. - Arguments must be passed by keyword (the SDKs require it; a positional
messagesis not read). with_raw_response.create(...)andwith_streaming_response.create(...)return the HTTP response object, not the parsed completion; reading it would consume a streaming body, so those calls are not captured and are counted understats()["skipped_raw_response"].- At a normal interpreter exit an atexit handler waits up to
LATENT_SDK_EXIT_FLUSH_Sseconds (default 2.0) for the queued pairs to be posted, so a short script that never callsflush()still delivers; with it set to0nothing waits and pairs still queued at exit are lost without a counter (the process is gone). Akill -9or anos._exit()loses them either way: callrunlatent.flush()in your shutdown path when it matters. - The proxy around a stream is not an instance of the SDK's
Streamclass;isinstancechecks on it fail (attribute access does not). - There is no on-disk spill: a Latent outage longer than the retries loses those pairs, counted.
max_queuecounts pairs, not bytes, behind one serial poster thread (ingest tops out near 1 / POST latency pairs per second). A full queue of 10,000 pairs with 100 KB RAG prompts is about 1 GB held in the process before anything is dropped: sizemax_queueto the burst you are willing to hold in memory, and lower it on memory-tight hosts.- Multi-choice (
n > 1) and multi-candidate responses post the first only. - Gemini thinking models count their thought tokens against
max_output_tokens: a short budget yields many empty answers (not posted,skipped_empty_output) and truncated ones, posted aslengthand routed to review by the service's defaultfinish_to_reviewrule (E-R149's first Gemini run at 160 tokens produced 44 of them). Give thinking models a budget that covers the thinking, or disable it, before reading the review queue as a quality signal.
Metadata
Release files for runlatent 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| runlatent-0.2.0.tar.gz | 53.2 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| runlatent-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 100.5 kB
Release files / runlatent-0.2.0.tar.gz
| Download URL | runlatent-0.2.0.tar.gz |
|---|---|
| Size | 53.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
397c5cc10f433a1634cdf2f910e69c27aee5131215b43274d551395b11b3b3d6
|
|
BLAKE2b-256 checksum How to use checksums |
1b1fd7d250f52e4509cdacebce243cdc0e87197f0d73c0703bd3c272d0fdac09
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.8
|
Release files / runlatent-0.2.0-py3-none-any.whl
| Download URL | runlatent-0.2.0-py3-none-any.whl |
|---|---|
| Size | 47.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7d9acad056961daf3ac3bacceef9716bb2e6b77904326275d47f9f62c2444b83
|
|
BLAKE2b-256 checksum How to use checksums |
7db5aeb2863f9cf5ac240fed4ed4879ff489aa45603c53bec30e2728a606ee41
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.8
|
Security for runlatent 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.