Metadata-Version: 2.4
Name: cerberus-keys
Version: 0.1.0
Summary: Client library for Cerberus — per-key API abuse detection
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: build>=1.0; extra == "dev"
Requires-Dist: twine>=5.0; extra == "dev"

# 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:

```python
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

```bash
pip install cerberus-keys
```

Zero runtime dependencies, standard library only.

```python
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:

```python
# 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:

```yaml
litellm_settings:
  callbacks: cerberus_callback.cerberus
```

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

```yaml
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:

```python
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.
