recovea
v0.4.2
Published
The Baseline package (the passive tap): installed beside the OpenAI or Anthropic client you already use, it reports each call's envelope to your Recovea Baseline: never a prompt, never a completion, never a provider key.
Maintainers
Readme
recovea: the Baseline package (the passive tap)
Your calls reach your provider exactly as before. After each call returns, the package you installed beside your OpenAI or Anthropic client reports its envelope to your Recovea Baseline: never a prompt, never a completion, never a provider key.
AI spend control: spend caps, budgets, cost tracking, receipts. Recovea meters, caps and receipts a company's AI spend from beside their app; the number is one they can re-derive. This package is the Baseline, where it starts.
Install
npm i recoveaNode 18 or later, no runtime dependencies. Versions before 0.2 are the command line only and do not export tap.
The install
OpenAI:
import OpenAI from "openai";
import { tap } from "recovea";
const openai = tap(new OpenAI(), { tag: "matter-4821" });
// use openai exactly as beforeAnthropic:
import Anthropic from "@anthropic-ai/sdk";
import { tap } from "recovea";
const anthropic = tap(new Anthropic(), { tag: "matter-4821" });
// use anthropic exactly as beforetap() returns the same client you passed in, wrapped, with the same behaviour. The request goes to your provider on your own path, and the wrapper does not change the request, the response, or the stream you see. Nothing routes through Recovea: if our endpoint is down, your call does not know.
Supported call sites: OpenAI chat.completions.create and responses.create, Anthropic messages.create, streaming and non-streaming.
This entry is ESM. require("recovea") is not supported; a CommonJS caller uses const { tap } = await import("recovea").
Where the key goes
The tap key is shown once, when the Baseline is created. Put it in the environment:
RECOVEA_TAP_KEY=rcv_tap_...or pass it as key in the options. The key starts rcv_tap_. It is sent as Authorization: Bearer <key> and is never a field in an event. Once you have seen it, Recovea keeps no clear copy of it, so if you lose it, rotate it on the Baseline's page: the new key is shown once and the old key stops working at that moment.
With no key configured, the wrapped client works normally and the tap reports nothing, with one line to stderr when you construct it.
The tag is the unit
The tag is your own unit id: a matter, a claim, a client, an agent, a workload. It is required, 1 to 64 characters from [A-Za-z0-9._:-], and it is never normalised: nothing lowercases, trims or truncates it, here or on our side. An invalid tag throws at tap() time, before a single request has run, so you find out at start-up and not in production.
One wrapped client carries one tag. For several agents or several clients, wrap several clients.
Use the real id the first time. It is the same string a per-client or per-agent cap is written against later on the paid plan, so a Baseline tagged with your real ids has done that work already, and a Baseline tagged test has not.
Look first, send nothing
$ RECOVEA_TAP_INSPECT=1 node ./your-script.js
[recovea] tap inspect mode is on: each batch is printed below as the exact JSON body the endpoint would receive, and nothing is sent.
{"events":[{"schema":"recovea-tap-v1","provider":"openai","model":"gpt-5",…}]}With RECOVEA_TAP_INSPECT set to 1, true, yes or on (case and surrounding spaces ignored), every batch prints to stderr as the exact JSON body the endpoint would receive, and nothing is transmitted. Any other value, and unset, is off. It needs no key, so you can read the fifteen fields on your own machine, at your own traffic, before any of them leave it. The inspect option (inspect: true or inspect: false) wins over the variable in both directions.
With a tap key configured as well, inspect mode still wins and nothing is sent. No event reaches your Baseline until inspect mode is turned off, and the stderr line says so when both are set.
What is reported
One event per call, schema recovea-tap-v1. If a field is not in this table, it is not in the object. Copied from the published table at recovea.ai/docs/passive-tap:
| Field | Type | Rule |
|---|---|---|
| schema | string | exactly recovea-tap-v1 |
| provider | string enum | openai · anthropic · google · xai · mistral · other |
| model | string | 1 to 128 characters, [A-Za-z0-9._:/-]; the model the provider says it served, not the one you asked for |
| request_id | string | 1 to 128 characters, [A-Za-z0-9._:-]; the provider's response id or request-id header, and a UUID the SDK generates when the provider gives neither |
| input_tokens | integer | 0 to 1,000,000,000 |
| output_tokens | integer | 0 to 1,000,000,000 |
| cached_tokens | integer | 0 to 1,000,000,000; default 0 |
| stop_reason | string enum | stop · length · tool_calls · content_filter · error · other |
| tool_calls | integer | 0 to 10,000. The count, never the arguments |
| status | integer | 100 to 599, the provider's HTTP status. 599 is the sentinel for a call that failed with no HTTP response at all, and 200 is assumed where the provider's SDK hands back a successful response and no status |
| retries | integer | 0 to 100, the provider call's own retry count. Always 0 in SDK v1: no provider SDK exposes one, and the field is on the wire so a later version can fill it without a schema change |
| latency_ms | integer | 0 to 3,600,000 |
| rate_limit | object or null | exactly requests_limit, requests_remaining, tokens_limit, tokens_remaining; each an integer 0 to 1,000,000,000 or null. No other key. Null when the SDK does not expose the headers |
| timestamp | string | RFC 3339, with a timezone. Refused more than 5 minutes in the future or more than 30 days in the past |
| tag | string | 1 to 64 characters, [A-Za-z0-9._:-]. Your own unit id: a matter, a claim, a client, an agent, a workload |
In this package rate_limit is null unless you pass rateLimitHeaders: true, which reads the provider's rate-limit headers through the SDK's .withResponse(). retries is always 0 today: read it as not reported, never as no retry happened.
tag is the only field a human types. Everything else is filled in by the tap from the call itself, typed and bounded.
What is never reported
Prompts. Messages. System prompts. Completions. Text, image, audio or file content of any kind. Tool definitions. Tool arguments. Tool results. Embeddings. Your provider key: the tap never asks for one. Your Recovea key is a header, never a field.
The field list is enforced twice. The tap builds each event field by field and validates it before it leaves your process, and the server checks it again on arrival and refuses the whole batch if it carries anything else, without logging it. The tarball ships src/ beside dist/ so you can check the first half yourself: the readers in src/tap/providers.ts name every response key they touch.
Delivery
Events are queued in memory and posted on the side in batches of up to 100, every 2,000 ms and whenever 100 are waiting. Delivery never delays your call, never fails it, and never throws into your code.
A 429 or a 503 means not now, and posting the same batch again is safe after either. The tap keeps the batch and the queue behind it, waits the answer's Retry-After (whole seconds or an HTTP date, never more than 60 seconds a wait), and posts the same batch again. After a 503 each wait is at least double the one before, so a Retry-After of 5 gives waits of 5, 10, 20, 40 and then 60 seconds. With no readable Retry-After the waits are 200 ms, 800 ms, and then double. A batch gets at most eight attempts. Any other failed post is retried twice, after 200 ms and 800 ms.
A batch that is still not accepted is dropped, and the tap writes one line to stderr naming the last answer, the attempts and the events lost:
recovea tap: a batch was not accepted after 8 attempts (last answer 503); 100 events were not reported.A 401 or a 410 writes its own line instead, below. The queue holds 1,000 events and drops the oldest first, counted through onDrop; the ones it drops while a batch waits out a 429 or a 503 are counted in a line of the same kind. On the way out of the process there is one more flush, a single attempt for each batch, a waiting one included, with a 1.5 second deadline.
Options
const openai = tap(new OpenAI(), {
tag: "matter-4821", // required, 1 to 64 characters of [A-Za-z0-9._:-]
key: "rcv_tap_...", // default: process.env.RECOVEA_TAP_KEY
endpoint: "...", // default: process.env.RECOVEA_TAP_ENDPOINT, then production
inspect: true, // default: process.env.RECOVEA_TAP_INSPECT; a boolean wins both ways
flushIntervalMs: 2000,
maxQueue: 1000, // oldest dropped first
onDrop: (count) => {}, // a count only, never the event
provider: "openai", // only when detection cannot see which provider is billed
rateLimitHeaders: false, // true fills rate_limit through .withResponse()
});An invalid tag or provider throws at tap() time, never at request time. transport also exists; it is the testing hook the suite uses in place of the HTTP post.
The endpoint, off production
Leave the endpoint alone in production: the default is https://platform-api.recovea.ai/tap/v1/events. On a non-production deployment the install line the Baseline page prints carries the endpoint; pass it as the endpoint option or set RECOVEA_TAP_ENDPOINT. The option wins over the variable.
What a 410 means
The Baseline behind the key has closed and is not accepting events. The answer's code is baseline_closed, and its message is the server's own sentence:
| code | message |
|---|---|
| baseline_closed | This Baseline has closed. Start a plan to keep reporting. |
The tap writes that sentence to stderr once, exactly as the server sent it, after recovea tap: , and your client goes on working exactly as it did:
recovea tap: This Baseline has closed. Start a plan to keep reporting.If the answer carries no sentence that fits on one short line (a string of at most 200 characters with no control, format or line-break character), the tap writes its own instead: recovea tap: this Baseline has closed. See its page in Recovea.
A closed Baseline pauses reporting; it does not end it. Leave the line where it is and start a plan. The tap drops what was queued, stops posting, and re-checks about once a minute. Once a plan is active, reporting starts again on its own, with no restart and nothing to re-integrate, within about a minute of the next call your application makes. An application that is idle then resumes at its next call. What resumes is new calls only; events dropped while it was closed are not replayed. A plan that ends returns the same closed answer, and starting one again resumes reporting the same way.
On a 410 with any other code, or one whose code it cannot read, the tap stops for the life of the process and writes one neutral line that points at the page in Recovea and tells nobody to remove anything.
What a 401 means
The key was not accepted. A 401 carries no detail: an unknown, revoked or mistyped key, and the key of an organization whose assessment data was erased, all answer the same. After three 401s in a row with no success between them, the tap writes one line to stderr and stops for the life of the process:
recovea tap: the tap key was not accepted three times; reporting stoppedYour client goes on working exactly as it did.
Docs
The whole wire, field by field, with every rule above: recovea.ai/docs/passive-tap.
The recovea command: a usage-export reader and the gateway commands
The package also installs a recovea command. It is separate from the tap: nothing above uses it, and it never runs inside your application.
recovea scan <file> reads an OpenAI, Anthropic or OpenRouter usage export already on your machine (CSV or JSON; - reads stdin, --sample uses built-in data) and prints spend by model, priced against the reference price list embedded in the tool (rpl-2026-08-05). --explain prints the arithmetic behind every figure and --json prints the result as JSON. No key, no account, nothing uploaded.
recovea verify <export.json>, new in 0.3.0, re-derives the hash chain in a Baseline export you downloaded from the Baseline or Assessment page. Each exported row carries its recovea-tap-chain-v1 link beside the fifteen fields (received_at, chain_seq, prev_hash, chain_hash), and the command hashes every row on your own machine and prints the head it reaches, which is the head the page shows, or the first row that breaks the chain. No key, no account, no network call. A server-side chain proves the events were not altered after receipt, not that they were true.
The other commands set up the Recovea gateway, and they are about routing, which is true of the gateway and never of the tap. recovea login signs in through the browser and stores an rcv_ gateway key (alias signup). recovea install sets OPENAI_BASE_URL in your bash and zsh config so that OpenAI-compatible tools such as Cursor and Codex route through the gateway, and when a key is stored it sets OPENAI_API_KEY to that rcv_ key from a file only you can read; --dry-run prints the exact block and writes nothing. It is OpenAI-only: Claude Code talks to Anthropic, and install does not route it. recovea uninstall removes exactly those lines and that file. recovea status [--follow] prints the gateway's spend this period and your cap headroom. recovea upgrade prints the dashboard's checkout link and the pricing page. recovea --help lists every flag.
Licence
MIT. See LICENSE.
