@tokli/node
v0.2.0
Published
Report-only usage tracking for OpenAI, Anthropic, Gemini, DeepSeek, xAI, Mistral, Qwen, GLM, Kimi and OpenRouter — wrap your client, we read the tokens.
Maintainers
Readme
@tokli/node
Report-only usage tracking for tokli — a cost & usage dashboard for AI APIs. Wrap your existing provider client, keep calling it exactly as before, and tokli reports the token usage in the background. We never see your provider API key and we never sit in your request's critical path.
Supports OpenAI, Anthropic, Gemini, DeepSeek, xAI, Mistral, Qwen, GLM, Kimi and OpenRouter.
Install
pnpm add @tokli/nodeopenai, @anthropic-ai/sdk and @google/genai are optional peer dependencies — install
whichever ones you actually use. Supported majors: openai v4–v7, @anthropic-ai/sdk 0.27+,
@google/genai v1–v2.
Quickstart
import OpenAI from "openai";
import { wrapOpenAI } from "@tokli/node";
const client = wrapOpenAI(new OpenAI(), {
ingestKey: process.env.TOKLI_INGEST_KEY, // or set the env var directly
}).withFeature("checkout");
await client.chat.completions.create({ model: "gpt-5.4", messages });The returned client behaves exactly like the original: every property we don't instrument
passes straight through, withResponse() / asResponse() keep working, and
client.withOptions({ ... }) hands back a client that is still instrumented.
Without an ingest key (neither config.ingestKey nor TOKLI_INGEST_KEY), the wrapper is a
silent no-op: your app keeps working exactly as before, nothing is reported.
Per-feature attribution
const checkout = client.withFeature("checkout"); // immutable and chainable
await checkout.chat.completions.create({ model: "gpt-5.4", messages });withFeature returns a new wrapper over the same client — it never mutates the one you
passed in, and tags don't nest: calling it twice replaces the tag rather than combining them.
Available wrappers
wrapOpenAI, wrapAnthropic, wrapGemini, wrapDeepSeek, wrapXai, wrapMistral,
wrapQwen, wrapGlm, wrapKimi, wrapOpenRouter — all share the same
(client, config) => Wrapped<T> signature and .withFeature(tag) chaining shown above. The
seven OpenAI-compatible ones take the openai package pointed at the provider's base URL;
each wrapper's JSDoc carries the exact URL and that provider's caching quirks.
What gets reported
We instrument the calls that bill tokens, and only those:
| Client | Reported |
| --- | --- |
| OpenAI & compatible (DeepSeek, xAI, Mistral, Qwen, GLM, Kimi, OpenRouter) | chat.completions.create, chat.completions.parse, responses.create, responses.parse, responses.compact, completions.create (legacy Completions API) |
| Anthropic | messages.create, messages.parse |
| Gemini | models.generateContent, models.generateContentStream |
Streaming and non-streaming are both covered, as is withOptions(), which re-wraps the client
it returns.
What is not reported
These pass through untouched. If you use one, its spend will not appear in tokli — this list is the whole of it. For the namespaces we do instrument, a test enumerates every public method and fails the moment a provider SDK grows one nobody has classified.
| Not instrumented | Why |
| --- | --- |
| chat.completions.stream(), responses.stream(), messages.stream() | Helper wrappers with their own consumption surface (.finalMessage(), .text_stream, …). Use create({ stream: true }) if you want tokli to see it. |
| chat.completions.runTools() | Runs a multi-call tool loop of its own. |
| responses.retrieve(), .cancel(), .delete() | Idempotent or administrative — reporting retrieve would bill the same response once per call. |
| chat.completions.retrieve/list/update/delete, chat.completions.messages | Manage stored completions; they bill no tokens. |
| messages.countTokens(), responses.inputTokens | Counting only; they bill nothing. |
| responses.inputItems | Lists what was sent; bills nothing. |
| messages.batches (Anthropic) | A separate pipeline whose usage arrives out of band. |
| beta.* namespaces (OpenAI and Anthropic) | Moving targets; they get instrumented once they stabilise. |
| withRawResponse.* | Returns the raw HTTP response instead of a parsed body. |
| client.chats (Gemini) | A stateful helper over generateContent. The official quickstart uses it, so it is the easiest one to trip over — call models.generateContent if you want it counted. |
| Gemini models.list, embedContent, generateImages, generateVideos, editImage, upscaleImage | list bills nothing; the rest bill on non-token meters. |
Background responses. responses.create({ background: true }) returns immediately with no
usage, so there is nothing to report at that point — the real numbers arrive later through
responses.retrieve(), which we deliberately leave uninstrumented (it is idempotent, and
reporting it would count the same response every time you polled). A background call that also
streams is reported: its terminal event carries the usage. If you rely on non-streaming
background calls, report those yourself.
Where our numbers can differ from your invoice
tokli prices your usage from a versioned table, and a few provider pricing modes are not visible
in the usage object at all. These are the cases where the dashboard will read low:
| Case | Effect |
| --- | --- |
| Long-context premiums — OpenAI charges 2x input / 1.5x output on requests over 272K input tokens; xAI and Qwen have their own context tiers | Under-counted: we price at the base tier |
| Anthropic 1-hour cache writes — billed at 2x input where 5-minute ones are 1.25x | Under-counted: the event carries a single cache-creation bucket |
| Qwen explicit caching — hits bill at 10% where implicit ones bill at 20% | The response does not distinguish them, so explicit hits are priced as implicit |
| Anthropic fast mode — same model ID, premium price | Not captured: speed is a request parameter, not usage |
| Data residency (+10% on both providers) | Not captured: a request parameter, not usage |
| Server-side tools — web search, code execution | Never counted: they bill per call or per hour, not in tokens |
| Batch APIs (50% off on both providers) | Batch traffic does not go through these wrappers at all |
Everything else — the per-token prices, the cache read/write multipliers and the per-provider cache arithmetic — is modelled. If a model is missing from the table the event is still stored, flagged as unpriced rather than silently dropped.
Config
| Option | Env var | Default | Notes |
| --- | --- | --- | --- |
| ingestKey | TOKLI_INGEST_KEY | — | Required. Without it, the wrapper is a no-op. |
| endpoint | TOKLI_ENDPOINT | https://api.tokli.dev | Ingest API base URL. |
| timeoutMs | TOKLI_TIMEOUT_MS | 2000 | Timeout for the fire-and-forget report request. |
| onError | — | no-op | Called if reporting fails; never throws into your code. |
An option beats the environment variable, which beats the default. An unusable value (a typo
in an env var) is skipped rather than thrown — a misconfiguration must not take your app
down. onError receives
(error, reason), where reason is "transport", "no_usage" or "parse"; if your handler
itself throws, the error is swallowed rather than surfacing anywhere in your app.
Streaming
For streaming chat completions on OpenAI, DeepSeek and Qwen, pass
stream_options: { include_usage: true } so the final SSE chunk includes token usage — without
it there's nothing for tokli to report. xAI, GLM, Kimi and OpenRouter send it either way.
Mistral takes no stream_options at all — its request schema declares
additionalProperties: false and rejects the call with a 422. Anthropic, Gemini and the
Responses API need no flag.
If you abandon a stream part-way (break out of the for await), nothing is reported: we
can't know what you were billed, and we would rather report nothing than guess.
How it works
Your code keeps calling the provider with your own key. After the response comes back, the SDK
reads the usage object already present in it and sends those raw numbers to tokli,
fire-and-forget — it never blocks your request and never touches your key. Cost is computed
server-side against a versioned price table, not by the SDK.
Reporting can never break your app: if anything in our own path throws — including your
onError handler — you still get the provider's response untouched.
Links
- tokli.dev
- Support: [email protected]
