Skip to main content

Cerberus

Watches API-key usage metadata and tells you when one of your customers' keys starts behaving unlike its own history — a shared, stolen, or scraped credential — before the bill arrives.

Not bot detection. Specifically: this paying customer's key is doing something it has never done, and it's costing you money.


What Cerberus receives

Twelve fields. Nothing else. The ingest endpoint rejects any unknown field by name, so this list is enforced in code rather than promised in prose.

Field Meaning
ts ISO 8601 UTC timestamp
key_fp Fingerprint of your API key ID — never the key
endpoint Route template (see below)
tokens_in / tokens_out Token counts
latency_ms Request latency
status HTTP status code
ip_fp Fingerprint of the client IP — never the address
ip_net_fp / ip_block_fp Fingerprints of the surrounding network
ip_family v4 or v6
cost Optional, in USD

No content, ever. No prompts, no responses — not hashed, not truncated, not optional.

Thirty-day rolling retention.

Why we cannot see your keys or your users' IP addresses

Fingerprints are HMAC-SHA256 under a secret salt you generate and we never receive, truncated to 128 bits.

This matters because a plain hash is not anonymisation: the IPv4 space is 2^32, so a GPU builds the whole rainbow table in minutes. With a salt we never hold, we cannot reverse a fingerprint even in principle — and because the salt is per-tenant, the same IP hitting two of our customers produces two unrelated fingerprints. We could not build a cross-customer profile if we wanted to.

Salt handling

  • Store it wherever your other secrets live, not in a config file.
  • Use the same salt on every instance, or one key looks like several.
  • Do not rotate it. Every historical fingerprint becomes uncorrelatable and all baselines reset.
  • If you lose it, baselines reset. Nothing else breaks.

Looking up a fingerprint at 2am

An alert names a key by fingerprint, because that is all we have. You can always map it back — you hold both the salt and your key list:

from cerberus_keys import fingerprint
{fingerprint(k, salt): k for k in my_api_keys}

We cannot do this lookup. That is the point. Build the index once during onboarding and keep it.

Installing

pip install cerberus-keys

Zero runtime dependencies, standard library only.

from cerberus_keys import Cerberus

client = Cerberus(ingest_token, salt, endpoint_url="https://.../v1/events")
client.record(api_key=key, ip=client_ip, endpoint="/v1/chat",
          tokens_in=n_in, tokens_out=n_out, latency_ms=ms, status=200)

record() never blocks, never raises, and never adds latency to your request path. It enqueues and returns; a full buffer drops rather than applying backpressure; every exception is swallowed at the boundary. A monitoring library that can stall the thing it monitors is not worth running. Dropped events are visible on client.dropped and are never an error.

You will know within the hour that it works

Once your first events reach us, Cerberus posts a one-time confirmation to your Slack webhook. It arrives on the next hourly pass — so within the hour, not instantly — and it tells you:

  • how many events we received, and how many distinct API keys they came from
  • how many distinct client IPs we can see
  • when detection starts, since we need about a week of history per key first

If it does not arrive, something is wrong, and that is the point. Cerberus can fail in a way that looks exactly like working: this SDK drops failed sends silently, on purpose, so it can never add latency or raise into your request path. A Cerberus that is receiving nothing looks identical to a Cerberus that is watching quietly. The confirmation exists so that silence means something specific instead of nothing.

If an hour passes and no message arrives, check in this order:

  1. Is a Slack webhook configured on your account? No webhook, no message — and no alerts either.
  2. Is the ingest token right, and not revoked? A bad token gets a 401, which the SDK drops.
  3. Is endpoint_url correct? Anything non-2xx is dropped the same way.
  4. Is your process alive long enough to flush? The client batches. A script that exits immediately should call .close().

Cerberus(...).dropped counts events the client discarded, and is the fastest local check that something is being sent at all.

endpoint must be a route template

Send /v1/orgs/{org_id}/chat, not /v1/orgs/acme-corp/chat. Live paths routinely carry identifiers, and this field would carry them to us.

The ingest endpoint rejects paths containing @, UUIDs, or long digit and hex runs — but that is defence in depth, not a guarantee. /v1/orgs/acme-corp/chat defeats every one of those checks. The template requirement is the mechanism; we cannot detect every violation of it.

If you run LiteLLM

This is the cheapest integration on offer: LiteLLM's callback payload already carries eight of the twelve fields, so there is no instrumentation to write. It is two config blocks, and both are required.

First, a file the proxy can import. Put it next to your config:

# cerberus_callback.py
import os
from cerberus_keys.litellm import CerberusLogger

cerberus = CerberusLogger(
    ingest_token=os.environ["CERBERUS_INGEST_TOKEN"],
    salt=bytes.fromhex(os.environ["CERBERUS_SALT"]),
    endpoint_url="https://cerberushq.dev/v1/events",
)

Block one registers it:

litellm_settings:
  callbacks: cerberus_callback.cerberus

Block two is the one that matters, and it is in a different section:

general_settings:
  use_x_forwarded_for: true   # REQUIRED

Without it, LiteLLM reports request.client.host — which behind any load balancer, ingress, or CDN is the load balancer's address on every request. Distinct-IP counts collapse to one permanently and fan-out detection silently cannot fire, while looking exactly like a clean bill of health. It is the only way to install Cerberus, have it appear to work, and get nothing from it.

Cerberus checks for this shape and tells you if it sees it. Don't rely on that; set the flag.

Two things that are different on LiteLLM

Resolving a fingerprint takes the hash, not the key. LiteLLM never hands a callback your raw key — it passes user_api_key_hash, which is sha256(key).hexdigest(). So the fingerprint in an alert is HMAC(salt, sha256hex(key)). Calling resolve() with raw keys matches nothing, silently, and reads exactly like "that key isn't ours" at the worst possible moment. Map them first:

from cerberus_keys import resolve
from cerberus_keys.litellm import key_fingerprint_input

resolve(fp, [key_fingerprint_input(k) for k in your_keys], salt)

Streaming latency is time-to-first-token. LiteLLM's response_time is completion_start_time - start_time when stream=True, not the full duration. Latency is a digest signal rather than a fan-out condition, so this does not affect what pages you — but a streaming-heavy proxy will show lower latencies than its users experience, and that is a property of the source, not of Cerberus.

The proxy path only. requester_ip_address is populated in LiteLLM's proxy-side request handling, so the LiteLLM SDK used as a library does not carry it — take the normal Cerberus(...).record(...) path there.

What it detects

One real-time signal: key fan-out. Not "many IPs" — many IPs each doing very little, which is the shape of a shared credential and what separates it from your infrastructure scaling up. Scaling three containers to forty raises volume in proportion; a leaked key inverts that. Five conditions must all hold, sustained across consecutive hours, before anything pages you.

Everything else — cost and volume anomalies — goes in a daily digest. A customer who just launched and 10x'd their usage looks identical to abuse, and that customer is the best thing that happened to you this quarter.

Known limits, stated plainly

  • Keys used from fewer than 3 distinct IPs are not protected at all. A key that lives on one or two servers never builds a baseline the rule can compare against, so a leak of that key -- however dispersed, however sustained -- does not fire. This is the single biggest gap, it covers the most common key shape, and GET /v1/status names the affected keys as below_detection_floor rather than pretending they are still warming up. The floor is a calibration threshold; it will move only on replay evidence, not by guess.
  • Keys already spread across many IPs are under-protected. The rule is relative to each key's own history, so a key that normally lives on 200 IPs needs a far larger jump to trip. Conservative by design; you should know it.
  • A leak that ramps slowly enough is never caught. The baseline tracks it upward and nothing fires. Detecting that needs a long-horizon reference we don't yet have.
  • Concentrated datacenter scrapers don't page. A scraper rotating inside one provider's range looks concentrated, and the rule requires dispersion. Deliberate: a missed scraper is cheaper than a false page.
  • A serverless migration can page you. If you move a key from a few servers to many small workers (a serverless or autoscaling rollout) at roughly constant traffic, and those workers are spread across many network blocks — which cloud egress usually is — that has the same shape as a leaked key being used from many places, and the rule can fire. It is the one false alarm the design cannot rule out from the data it holds: it cannot tell your cloud's address ranges apart from a stranger's. If it happens, acknowledge the alert (it offers a "this key is legitimately distributed" link) and it stops. Tell us if you have a rollout planned and we can quiet the key ahead of time.

Release files for cerberus-keys 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for cerberus-keys 0.1.0
File Size Uploaded
cerberus_keys-0.1.0.tar.gz 34.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for cerberus-keys 0.1.0
File Interpreter ABI Platform
cerberus_keys-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 56.0 kB

Release files / cerberus_keys-0.1.0.tar.gz

Download URL cerberus_keys-0.1.0.tar.gz
Size 34.9 kB
Tags Source
SHA-256 checksum
How to use checksums
69562def7c49f4a06ccf4dff275be6c01b0b267048260c55b0a9a058c9a28e2d
BLAKE2b-256 checksum
How to use checksums
eae01f2d700050b6a04c9e0f565aa02d4bfc95d90728e56875c48e95106ee185
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / cerberus_keys-0.1.0-py3-none-any.whl

Download URL cerberus_keys-0.1.0-py3-none-any.whl
Size 21.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d8df47028753779d0c9fd0d0404cc35ace569580a786e56054f5bb05b1bb8fd9
BLAKE2b-256 checksum
How to use checksums
fa74cf52b64650c17349f67f38a9ee28702d42a0f73c954e309825444fdf5175
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release history Release notifications | RSS feed

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page