Architecture
Documented for Python SDK
token-sentinel1.0.2.
Design principles
- In-process by default. runs entirely client-side. Zero network calls just to do detection. Cloud is opt-in.
- Wrap, don't replace. We wrap the official Anthropic, OpenAI, Google Gemini, and AWS Bedrock clients. Customers keep their existing SDK upgrade path.
- Be fast or be removed. ≤50ms p95 overhead per LLM call. Budget-aware rule evaluation.
- Predict, don't decide. Sentinel emits a signal. The host app decides what to do.
Data flow
┌──────────────────┐
│ on_leak handler │
│ (host app) │
└────────▲─────────┘
│
agent code │ LeakEvent
│ │
│ client.messages.create(...) │
▼ │
┌──────────────┐ ┌─────────────────┐ ┌────────────────┴─────────────┐
│ wrapped │───▶│ tracer (in-proc)│───▶│ rules engine (in-proc) │
│ Anthropic │ │ capture I/O │ │ 15 rules + thresholds │
│ client │ │ token counts │ │ emits LeakEvent │
└──────────────┘ └────────┬────────┘ └─────────────┬────────────────┘
│ │
│ optional async │ optional async
▼ ▼
┌──────────────────────────────────────────────┐
│ cloud sink (HTTP, batched, fire-and-forget) │
│ for dashboards / retention / team features │
└──────────────────────────────────────────────┘
Timing note. Wrappers invoke the real provider API first, then build a
CallRecord and run the rules engine. Detection (and mode="block" /
policy raises) therefore act after the current call has already been
billed. The savings come from halting the next call in a wasteful loop,
not from preventing the call that just completed.
Components
Wrapper layer (token_sentinel/wrappers/)
wrappers/anthropic.py— wrapsanthropic.Anthropicclient by reassigningclient.messages.createto an instrumented function that delegates to the original. Preserves type hints because we mutate the live instance, not a subclass.wrappers/openai.py— same pattern for the OpenAI v1 client.wrappers/gemini.py— same pattern forgoogle.genai.Client(covers Vertex AI viavertexai=True).wrappers/bedrock.py— same pattern for boto3bedrock-runtimeclients.- Streaming: instrumented proxy iterators / context managers that siphon chunks as they arrive. Final
CallRecordreconciled at stream close (OpenAI included; usage often requires provider-specific flags such asstream_options.include_usage).
Tracer (token_sentinel/tracer.py)
- Captures: model, prompt tokens, completion tokens, tool calls, latency, parent session, request ID.
- Stores last N events per session in a bounded ring buffer (default N=200, configurable). Memory cap.
- Thread-safe via internal
Lock. Async safe (asyncio runs in single thread per loop).
Rules engine (token_sentinel/rules/)
- Each rule is a class subclassing
Rulewithevaluate(session_buffer, project) -> Optional[LeakEvent]. - Rules run after every captured call. Total budget across all rules: 50ms.
- Rules are pure functions of the session buffer + global config. No I/O.
Event bus (Sentinel._run_handlers)
- Sync callback (registered with
@sentinel.on_leak). Multiple handlers run in registration order. - Handlers run synchronously in the wrapped call's thread after the underlying API response has been received. For async work (network calls, queue dispatch), enqueue inside the handler and let a separate worker drain the queue — see Modes — handler requirements for the safe pattern.
- Handlers wrapped in try/except — a buggy handler can never kill the agent.
BaseException(KeyboardInterrupt,SystemExit) still propagates.
Cloud sink (optional, v0.4+)
- HTTP POST batched to a configurable
cloud_endpoint(e.g.https://api.tokensentinel.dev/v1/events). - Fire-and-forget with bounded retry (3 attempts, exponential backoff). Never blocks the agent. Never raises into user code.
- Stdlib-only HTTP via
urllib.request— keeps the SDK's zero-runtime-deps core intact. - Customers can self-host the sink — the wire contract is a single
POST /v1/eventswith a JSON{"project": str, "events": [LeakEvent-shaped, ...]}body.
State
Two layers of state by default:
- Per-session ring buffer. In-memory, bounded (default 200 records per session, capped at 1000 sessions per Sentinel). Holds recent calls for rule evaluation.
- Cloud sink (opt-in). When you configure
cloud_endpoint=andapi_key=, a daemon thread batches and POSTsLeakEvents to that endpoint. Nothing else leaves the process.
Default: in-memory only. Customer opts in to cloud delivery explicitly.
Type-hint preservation gotcha
Subclassing the OpenAI v1 client breaks IDE type hints because the v1 SDK is heavily generated. Monkey-patching the live instance preserves types but requires us to mutate client.messages.create carefully. We document this clearly and prefer mutation over subclassing.
For Anthropic: same approach — mutate the live instance's messages.create. The original is captured in a closure.
Concurrency
- Single Sentinel instance per process is the recommended pattern.
- Multiple wrapped clients can share one Sentinel — they all feed the same tracer keyed by
session_id. session_iddefaults to a per-call UUID. Customer can pass a stable ID viaclient.messages.create(..., _sentinel_session_id="abc")to group calls into one session.
Failure modes
- Tracer raises an exception → caught silently. Agent continues normally. Fail-safe.
- A rule raises → caught silently; that rule's signal is dropped for the call. Other rules continue.
- Cloud sink unreachable → events queued in memory up to a cap, then oldest-dropped with a
RuntimeWarning. Never blocks the agent. - Embedding model not installed →
tool_looprule degrades gracefully to TF-IDF char-n-gram similarity (the default; lower precision but works without any model dependency).
Why not OTel-first ingestion
OTel is read-only by design. The wedge — mid-run intervention on the next
call — requires being in the data path so we can return a callback (or in
block mode, raise an exception) that the host app's flow control can act
on before another request goes out. OTel spans are emitted after the call
completes with no hook into that control flow.
We plan to emit OTel spans on the roadmap so customers can fan TokenSentinel events into their existing observability stack. We do not ingest via OTel.