Metadata-Version: 2.4
Name: recovea
Version: 0.1.2
Summary: Recovea passive tap: one line wraps your OpenAI or Anthropic client and reports the envelope of each call (tokens, model, timing, status). Never prompts, never completions.
Project-URL: Homepage, https://recovea.ai
Project-URL: Source, https://github.com/recovea
Project-URL: Schema, https://recovea.ai/docs/passive-tap
Author-email: "Recovea, Inc." <hello@recovea.ai>
License: MIT
License-File: LICENSE
Keywords: ai,anthropic,cost,finops,llm,observability,openai,telemetry,tokens
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# recovea

**One line wraps the model client you already use, and reports the envelope of each call. Never a prompt, never a completion.**

```bash
pip install recovea
```

```python
from openai import OpenAI
from recovea import tap

client = tap(OpenAI(), tag="matter-4821")
# use client exactly as before
```

```python
from anthropic import Anthropic
from recovea import tap

client = tap(Anthropic(), tag="matter-4821")
```

Your calls keep going straight to your provider. Nothing routes through Recovea, and no provider key is ever asked for. The wrapper reads the response your provider already returned, builds one small object from it, and posts that object on the side, after your call has returned. It never delays your call, never changes what your call returns, and never raises into your code.

Supported call sites: OpenAI `chat.completions.create` and `responses.create`, Anthropic `messages.create`, sync and async clients, streaming and non streaming.

## What the tap sends

One event per call, `recovea-tap-v1`. This is the whole object. The published schema is at https://recovea.ai/docs/passive-tap

| field | type | where it comes from |
|---|---|---|
| `schema` | string | always `recovea-tap-v1` |
| `provider` | `openai` `anthropic` `google` `xai` `mistral` `other` | your client's base URL, then its class name |
| `model` | string, 1 to 128 chars | the model your provider says it served |
| `request_id` | string, 1 to 128 chars | the provider's response id or request id header, otherwise a generated UUID |
| `input_tokens` | integer | the provider's usage field |
| `output_tokens` | integer | the provider's usage field |
| `cached_tokens` | integer | the provider's usage field (cache reads) |
| `stop_reason` | `stop` `length` `tool_calls` `content_filter` `error` `other` | the provider's stop or finish reason, mapped to this list |
| `tool_calls` | integer | how many tool calls the response contained. The count only |
| `status` | integer 100 to 599 | the HTTP status of the call. 599 when the call failed with no HTTP response, such as a transport failure or a timeout; 200 is assumed for a successful response the SDK returns without a status |
| `retries` | integer | always 0 in v1 (no SDK exposes a count) |
| `latency_ms` | integer | measured by the wrapper, start of call to end of response |
| `rate_limit` | object or null | the provider's rate limit response headers. Null unless you pass `rate_limit_headers=True` |
| `timestamp` | RFC 3339 with timezone | when the event was built |
| `tag` | string, 1 to 64 chars of `[A-Za-z0-9._:-]` | the `tag` you passed to `tap()`. The only free text field |

## What the tap never sends

Prompts. Messages. System prompts. Completions. Text, image, audio or file content of any kind. Tool definitions. Tool arguments. Tool results. Embeddings. Your provider key. Your Recovea key is a header, never a field.

The allowlist above is enforced twice: the SDK builds the object field by field and validates it before it leaves your process, and the server re-checks every field on arrival and rejects a batch that carries anything else, without logging it. A field that is not on the published page is not in the object.

## See exactly what is sent

`RECOVEA_TAP_INSPECT=1` (or `inspect=True`) prints every batch to stderr as the exact JSON body the endpoint would receive, and transmits nothing.

It needs no tap key, so you can read the fifteen fields on your own machine before any of them leave it.

One line on stderr says the mode is on; unset the variable and delivery works exactly as it did before.

If a tap key is configured as well, inspect mode still wins: nothing is transmitted, so a Baseline you are watching stays on "waiting for your first event" until you turn inspect mode off. That stderr line says so in as many words when both are set.

## Options

```python
client = tap(
    OpenAI(),
    tag="matter-4821",  # required, 1 to 64 chars of [A-Za-z0-9._:-]
    key=None,  # default: RECOVEA_TAP_KEY
    endpoint=None,  # default: RECOVEA_TAP_ENDPOINT
    flush_interval_ms=2000,
    max_queue=1000,  # oldest dropped first
    on_drop=None,  # called with a count only, never the event
    rate_limit_headers=False,  # see below
    inspect=None,  # default: RECOVEA_TAP_INSPECT. A bool overrides it both ways
)
```

One tag per wrapped client. Wrap a second client if you need a second tag. An invalid `tag` or `provider` raises at `tap()` time, never at request time.

Delivery is in memory and best effort: events are queued, posted in batches of up to 100 by a background thread, flushed every 2,000 ms and whenever 100 are waiting, retried twice on failure and then dropped. On the way out of the process there is one more flush, with a single attempt and a 1.5 second deadline, so the tap is never the reason a process is slow to exit. Nothing about delivery can reach your call path.

**How fast it catches up.** One wrapped client posts at most four batches a second on a clean path: the drain leaves at least 250 ms between the starts of two posts, so a burst is delivered steadily rather than as fast as the network allows. A single batch waits for nothing unless it follows another within that 250 ms. Two things are outside that number and are meant to be. A post that is **retried** makes its extra attempts inside the same gap, so an endpoint answering 429 can see up to three requests where the pacing counts one — that is how `Retry-After` is honoured, and slowing it down would make the tap worse at obeying it. And the pacing is **per wrapped client**, so if you wrap a second client for a second tag, the two pace themselves independently. The ceiling that follows is about four hundred events a second per wrapped client, at the 100-event batch. Above that the queue fills to `max_queue` (1,000) and the oldest events are dropped and counted through `on_drop` — the tap sheds load rather than growing without limit, and it never slows your own calls down.

If the endpoint answers 410, or answers 401 three times in a row without a success in between, the tap stops sending — the queue is dropped, nothing more is posted, and one line to stderr says why — one line per reason, for the whole process, however many clients you have wrapped, so two clients that stop for the same reason say it once and a later, different ending still gets said — while your client goes on working exactly as it did. A 410 has three causes and the line names the one that happened.

**A Baseline that has closed is the one that comes back.** Leave the tap line exactly where it is and buy the Assessment: reporting starts again by itself within about a minute, with no restart, no second line, and nothing to re-integrate. Only events from then on are sent — whatever was queued when the Baseline closed was dropped and counted through `on_drop`, and it is not replayed. While it is waiting, the tap tries the endpoint again at most once a minute, and only when your own traffic gives it something to send.

**An Assessment that was refunded, and an organization whose assessment data was erased, do not come back.** Nothing will be accepted again, so reporting stops for the rest of the process and those lines say the tap line can come out. A 410 whose reason cannot be read is treated the same way: one neutral line that tells nobody to remove anything, and a pointer to the page in Recovea.

If no key is configured the client still works and the tap reports nothing, with one line to stderr when you construct it — unless inspect mode is on, which needs no key and prints every batch instead of reporting nothing (see "See exactly what is sent" above).

`rate_limit_headers=True` reads the provider's rate limit headers through the SDK's `.with_raw_response` path, which means the wrapper calls that method and hands you back its `.parse()` result. That is the same object the plain call returns, but it is a different code path inside the provider SDK, so it is off by default and `rate_limit` is null until you turn it on. The JavaScript tap defaults it off too, so the same call produces the same event shape in both SDKs.

## Environment

| variable | meaning |
|---|---|
| `RECOVEA_TAP_KEY` | your tap key, shown once in the product. Sent as `Authorization: Bearer <key>` |
| `RECOVEA_TAP_ENDPOINT` | where events are posted. Default `https://platform-api.recovea.ai/tap/v1/events` |
| `RECOVEA_TAP_INSPECT` | set to `1`, `true`, `yes` or `on` (case and surrounding spaces ignored) to print every batch to stderr and send nothing. Any other value, and unset, leaves it off. No key needed |

## Requirements

Python 3.9 or newer. No dependencies.

Learn more: https://recovea.ai

---

(c) 2026 Recovea, Inc. MIT License.
