tokli
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
pip install tokli
openai, anthropic and google-genai are optional extras. tokli has zero runtime
dependencies and never pulls a provider SDK, or a version bound, into your app.
Quickstart
from openai import OpenAI
from tokli import wrap_openai
client = wrap_openai(OpenAI()) # ingest key from TOKLI_INGEST_KEY
client.chat.completions.create(model="gpt-5.4", messages=messages)
The returned client behaves exactly like the original: every attribute we don't instrument
passes straight through, isinstance(client, OpenAI) still holds, and
client.with_options(...) / client.copy(...) hand back clients that are still
instrumented.
Without an ingest key (neither ingest_key nor TOKLI_INGEST_KEY), the wrapper is a
silent no-op: your app keeps working exactly as before, nothing is reported.
Async
The same function. There is no separate async wrapper — wrap_openai(OpenAI()) and
wrap_openai(AsyncOpenAI()) return the same kind of object.
from openai import AsyncOpenAI
from tokli import wrap_openai
client = wrap_openai(AsyncOpenAI())
await client.chat.completions.create(model="gpt-5.4", messages=messages)
Per-feature attribution
checkout = client.with_feature("checkout") # immutable and chainable
await checkout.chat.completions.create(...) # every event tagged "checkout"
with_feature exists at runtime but type checkers cannot see it, because Python has no
intersection types and the wrappers return your client's own type so autocompletion survives.
For a type-checked equivalent, use the free function:
from tokli import with_feature
checkout = with_feature(client, "checkout")
Available wrappers
wrap_openai, wrap_anthropic, wrap_gemini, wrap_deepseek, wrap_xai, wrap_mistral,
wrap_qwen, wrap_glm, wrap_kimi, wrap_openrouter — all share the same
(client, **options) -> client signature and the .with_feature(tag) chaining above. The
seven OpenAI-compatible ones take the openai package pointed at the provider's base URL;
each wrapper's docstring carries the exact URL and the provider's caching quirks.
Config
| Argument | Env var | Default | Notes |
|---|---|---|---|
ingest_key |
TOKLI_INGEST_KEY |
— | Required. Without it, the wrapper is a no-op. |
endpoint |
TOKLI_ENDPOINT |
https://api.tokli.dev |
Ingest API base URL. |
timeout_ms |
TOKLI_TIMEOUT_MS |
2000 |
Timeout for the report request. |
flush_ms |
TOKLI_FLUSH_MS |
2000 |
How long to wait at exit for pending reports; 0 disables. |
on_error |
— | no-op | Called if reporting fails; never raises into your code. |
An argument beats the environment variable, which beats the default. An unusable value (a typo in an env var) is skipped rather than raised — a misconfiguration must not take your app down.
on_error runs on a worker thread. Keep it thread-safe and don't touch request-scoped
state from it. It receives (error, reason), where reason is "transport", "no_usage" or
"parse".
flush_ms is per-wrap but the worker pool is per-process. If two clients are wrapped with
different values, the pool waits for the longest one — a short deadline must not discard
another wrapper's pending events. So flush_ms=0 only disables the wait if it is the only
value configured in the process.
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 / .parse, responses.create / .parse / .compact |
| Anthropic | messages.create / .parse |
| Gemini | models.generate_content / .generate_content_stream, and the same two under aio.models |
Streaming and non-streaming are both covered, as is with_options() / copy(), which re-wrap
the client they return.
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 |
|---|---|
client.chats (Gemini) |
A stateful helper over generate_content. The official quickstart uses it, so it is the easiest one to trip over — call models.generate_content if you want it counted. |
chat.completions.stream(), responses.stream(), messages.stream() |
Helper wrappers with their own consumption surface. Use create(stream=True) if you want tokli to see it. |
responses.retrieve() / .cancel() / .delete() |
Idempotent or administrative — reporting retrieve would bill the same response once per poll. |
chat.completions.retrieve/list/update/delete, chat.completions.messages |
Manage stored completions; they bill no tokens. |
messages.count_tokens(), models.count_tokens(), models.compute_tokens(), responses.input_tokens |
Counting only; they bill nothing. |
messages.batches (Anthropic) |
A separate pipeline whose usage arrives out of band. |
responses.connect() |
Opens a realtime session; out of scope for v1. |
beta.* namespaces (OpenAI and Anthropic) |
Moving targets; they get instrumented once they stabilise. |
with_raw_response.*, with_streaming_response.* |
Return the raw HTTP exchange instead of a parsed body. |
Gemini models.embed_content, generate_images, generate_videos, edit_image, upscale_image, recontext_image, segment_image, list, get, update, delete |
They bill on non-token meters, or bill nothing. |
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 on every poll). 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.
Streaming
For streaming chat completions on OpenAI, DeepSeek and Qwen, pass
stream_options={"include_usage": True} so the final chunk includes token usage — without it
there is nothing for tokli to report. xAI, GLM, Kimi and OpenRouter send it either way.
Mistral takes no stream_options at all — its schema rejects unknown fields. Anthropic,
Gemini and the Responses API need no flag.
If you abandon a stream part-way (break out of the loop), nothing is reported: we cannot
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 hands those raw numbers to a small
background worker pool — it never blocks your request, your event loop, or your key. Cost is
computed server-side against a versioned price table, never by the SDK.
Reporting can never break your app: if anything in our own path raises — including your
on_error handler — you still get the provider's response untouched.
A few consequences worth knowing:
- The report POST reuses one keep-alive connection per worker thread. A connection idle for more than 5 seconds is replaced rather than reused, and a failed send is dropped, never retried: a retry could duplicate an event if the server processed it before the socket broke, and a duplicate corrupts your cost far worse than a lost event does.
http_proxy/https_proxyare not read. tokli talks to its own ingest endpoint overhttp.client, which ignores proxy environment variables — no surprise routing. Corporate proxy support is not in v1.- TLS verification is always on and cannot be disabled.
- At exit the SDK waits up to
flush_msfor pending reports, so short scripts and CLI jobs don't systematically lose their last event. - After
fork()(gunicorn/uvicorn--preload) the child drops the queue it inherited, so workers never resend the parent's backlog.
Requirements
Python 3.10+.
Links
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file tokli-0.1.0.tar.gz.
File metadata
- Download URL: tokli-0.1.0.tar.gz
- Upload date:
- Size: 55.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cbb4d2172a793b3d66d0ffd7b989095f754bc3d5206c16acb1f8e63753ea1eb0
|
|
| MD5 |
3763e39361661724cdf4485355984c84
|
|
| BLAKE2b-256 |
0b237472de2f755e396627b585f384bf9d5ef562dcf3daf7149117d352700f08
|
File details
Details for the file tokli-0.1.0-py3-none-any.whl.
File metadata
- Download URL: tokli-0.1.0-py3-none-any.whl
- Upload date:
- Size: 47.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
83bd2d48c20dfaee504357206c4a0fcf5a929888e3b374092ce140a3260a9d65
|
|
| MD5 |
ddc695cbc8f0e6ba850722113aa91054
|
|
| BLAKE2b-256 |
a266867ba819211c22b04895ee32b8ff03e142a0db3c9e4c3b6c6ab03ba6dcb1
|