tokenmeter-sdk 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tokenmeter_sdk-0.2.0/LICENSE +21 -0
- tokenmeter_sdk-0.2.0/MANIFEST.in +3 -0
- tokenmeter_sdk-0.2.0/PKG-INFO +221 -0
- tokenmeter_sdk-0.2.0/README.md +185 -0
- tokenmeter_sdk-0.2.0/pyproject.toml +46 -0
- tokenmeter_sdk-0.2.0/setup.cfg +4 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter/__init__.py +49 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter/_buffer.py +201 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter/_client.py +78 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter/_context.py +131 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter/_diagnose.py +57 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter/_ids.py +45 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter/_normalize.py +239 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter/_optimization.py +97 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter/_preflight.py +182 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter/_read_api.py +50 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter/_topic.py +91 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter/_transport.py +82 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter/_wrap.py +807 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter_sdk.egg-info/PKG-INFO +221 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter_sdk.egg-info/SOURCES.txt +22 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter_sdk.egg-info/dependency_links.txt +1 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter_sdk.egg-info/requires.txt +18 -0
- tokenmeter_sdk-0.2.0/src/tokenmeter_sdk.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Saqlain Rashid
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tokenmeter-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Token Meter Python SDK: wraps OpenAI/Anthropic/Gemini/Bedrock/Azure clients and meters token usage
|
|
5
|
+
Author: Saqlain Rashid
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/SaqlainRashed/token-meter
|
|
8
|
+
Project-URL: Repository, https://github.com/SaqlainRashed/token-meter
|
|
9
|
+
Project-URL: Changelog, https://github.com/SaqlainRashed/token-meter/blob/main/sdks/python/CHANGELOG.md
|
|
10
|
+
Keywords: llm,tokens,usage,metering,observability,openai,anthropic,vercel-ai-gateway
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Provides-Extra: openai
|
|
24
|
+
Requires-Dist: openai>=1.0; extra == "openai"
|
|
25
|
+
Provides-Extra: anthropic
|
|
26
|
+
Requires-Dist: anthropic>=0.30; extra == "anthropic"
|
|
27
|
+
Provides-Extra: gemini
|
|
28
|
+
Requires-Dist: google-genai>=0.1; extra == "gemini"
|
|
29
|
+
Provides-Extra: bedrock
|
|
30
|
+
Requires-Dist: boto3>=1.34; extra == "bedrock"
|
|
31
|
+
Provides-Extra: azure
|
|
32
|
+
Requires-Dist: openai>=1.0; extra == "azure"
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# token-meter (Python SDK)
|
|
38
|
+
|
|
39
|
+
## Read usage data
|
|
40
|
+
|
|
41
|
+
Create a separate read-only key under **Settings → API keys → Read API keys**. It cannot submit usage events.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import os
|
|
45
|
+
from tokenmeter import TokenMeterReadClient
|
|
46
|
+
|
|
47
|
+
read_api = TokenMeterReadClient(os.environ["TOKEN_METER_READ_KEY"], os.environ["TOKEN_METER_DASHBOARD_URL"])
|
|
48
|
+
page = read_api.list_daily_usage("2026-10-01", "2026-10-31")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
This returns daily **metered** usage/spend summaries, not provider invoice data. See [`docs/openapi.yaml`](../../docs/openapi.yaml) for pagination, fields, and limits.
|
|
52
|
+
|
|
53
|
+
In-process LLM usage capture for Python. `tokenmeter.wrap(client)` wraps a
|
|
54
|
+
provider client you already have, reads token usage out of the response
|
|
55
|
+
object it already returns (no extra network hop), normalizes it to a
|
|
56
|
+
canonical disjoint counter shape, and ships batches to the Token Meter Ingest
|
|
57
|
+
API in the background.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
pip install tokenmeter-sdk
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Note:** the PyPI distribution is named `tokenmeter-sdk` (like the npm
|
|
66
|
+
package `@tokenmeter/sdk`), but the import name is `tokenmeter`:
|
|
67
|
+
`import tokenmeter`. Distribution and import names are allowed to differ;
|
|
68
|
+
the unrelated PyPI project `tokenmeter` blocks the shorter names.
|
|
69
|
+
|
|
70
|
+
The SDK is public and installable by anyone — it does nothing without a
|
|
71
|
+
`tl_live_` ingest key; rate limits, tier caps, and soft-lock are all
|
|
72
|
+
enforced server-side at the ingest door.
|
|
73
|
+
|
|
74
|
+
## Quick start
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import tokenmeter
|
|
78
|
+
from openai import OpenAI
|
|
79
|
+
|
|
80
|
+
tokenmeter.init(ingest_key="tl_live_...")
|
|
81
|
+
client = tokenmeter.wrap(OpenAI())
|
|
82
|
+
|
|
83
|
+
with tokenmeter.action("generate-report", customer_id="acme", feature="reports"):
|
|
84
|
+
client.chat.completions.create(model="gpt-4o-mini", messages=[...])
|
|
85
|
+
# usage for this call is captured automatically, tagged with
|
|
86
|
+
# customer_ref=acme, feature=reports, and a shared trace_id.
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`wrap()` detects the client shape structurally (no hard dependency on any
|
|
90
|
+
provider's package) and is idempotent — wrapping an already-wrapped client or
|
|
91
|
+
method is a no-op.
|
|
92
|
+
|
|
93
|
+
### Optimization measurements for agents
|
|
94
|
+
|
|
95
|
+
Enable local prompt-prefix fingerprinting once with
|
|
96
|
+
`tokenmeter.init(ingest_key=key, optimization={"prompt_caching": True})`.
|
|
97
|
+
Declare delay tolerance only for suitable background tasks with
|
|
98
|
+
`tokenmeter.action("report", optimization={"max_completion_delay_ms": 86400000})`.
|
|
99
|
+
The SDK includes metadata in its normal usage events; users do not add HTTP
|
|
100
|
+
headers. Both settings are optional and default to off/absent. An inner action
|
|
101
|
+
can disable them with `"prompt_caching": False` and
|
|
102
|
+
`"max_completion_delay_ms": None`. See
|
|
103
|
+
[integration and deployment details](../../docs/SDK_OPTIMIZATION_TELEMETRY.md)
|
|
104
|
+
for supported agents, privacy behavior and detector requirements.
|
|
105
|
+
|
|
106
|
+
### Prompt topics
|
|
107
|
+
|
|
108
|
+
Label each call with what it is about, for Analytics > Use cases, without sending any prompt text:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
tokenmeter.init(ingest_key=key, optimization={"topic_classifier": tokenmeter.keyword_topic_classifier})
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The classifier runs in your process on the latest user message and returns a topic key (such as `debugging` or
|
|
115
|
+
`legal_review`); only that key is sent, as `tags["prompt_topic"]`. Anything that is not a key
|
|
116
|
+
(`^[a-z][a-z0-9_]{1,39}$`) is dropped. Supply your own synchronous function for better labels; an async one, or one
|
|
117
|
+
that raises, is ignored and the call is untouched. Turn it off for one action with
|
|
118
|
+
`optimization={"topic_classifier": None}`. Set it in `init()`: a classifier in an `action()` does not survive
|
|
119
|
+
`TraceContext.to_dict()` across a queue.
|
|
120
|
+
|
|
121
|
+
For an OpenAI-compatible client pointed at xAI, Mistral, Fireworks, Together
|
|
122
|
+
AI, DeepInfra, OpenRouter, or Vercel AI Gateway (`"vercel_gateway"`), preserve
|
|
123
|
+
the billable provider explicitly:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
client = tokenmeter.wrap_openai_compatible(
|
|
127
|
+
OpenAI(api_key="...", base_url="https://api.together.xyz/v1"), "together"
|
|
128
|
+
)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Provider support matrix
|
|
132
|
+
|
|
133
|
+
| Provider | Client | Paths wrapped | Streaming | Normalization confidence |
|
|
134
|
+
|---|---|---|---|---|
|
|
135
|
+
| OpenAI | `openai.OpenAI` / `AsyncOpenAI` | `chat.completions.create`, `responses.create` | Yes (both) | **Proven live** — `prompt_tokens` held constant at 2107 across cached/uncached calls (`docs/spikes/openai-billing.md`) |
|
|
136
|
+
| Anthropic | `anthropic.Anthropic` / `AsyncAnthropic` | `messages.create` | Yes | Doc-stated; cache reads reported separately, passthrough only |
|
|
137
|
+
| Azure OpenAI | `openai.AzureOpenAI` / `AsyncAzureOpenAI` | `chat.completions.create`, `responses.create` | Yes (both) | **Doc-inferred only, NOT empirically verified.** Same field shape as OpenAI, so the same subtraction rule is assumed to apply — but the 5-call cache probe that proved OpenAI's rule has not been replayed against a real Azure deployment. Treat as provisional until that probe runs (`docs/spikes/provider-shapes.md` §2.1, §7) |
|
|
138
|
+
| Google Gemini | `google.genai.Client` | `models.generate_content`, `models.generate_content_stream` | Yes (separate method, not a `stream=True` kwarg) | Doc-verified (`docs/spikes/provider-shapes.md` §3) — mixed rule: cached tokens ARE a subset of prompt (subtract), thinking tokens are NOT a subset of output (do not subtract). Streaming "last chunk is complete" behavior is community-observed, not doc-confirmed |
|
|
139
|
+
| AWS Bedrock — `Converse`/`ConverseStream` | `boto3` `bedrock-runtime` client | `converse`, `converse_stream` | Yes | Doc-verified disjoint (`docs/spikes/provider-shapes.md` §1.1) — the primary, normalized path across model families |
|
|
140
|
+
| AWS Bedrock — `InvokeModel` (Anthropic body) | same | `invoke_model` | No | Doc-verified — reuses native Anthropic semantics |
|
|
141
|
+
| AWS Bedrock — `InvokeModel` (Amazon Nova body) | same | `invoke_model` | No | Doc-verified shape; cache-counter key **casing** is unconfirmed (AWS's own docs disagree) — both spellings are probed defensively |
|
|
142
|
+
| AWS Bedrock — `InvokeModel` (other families / headers fallback) | same | `invoke_model` | No | Input/output only, via `x-amzn-bedrock-*-token-count` response headers; no cache/reasoning split. **Mistral bodies carry no usage at all** — this is the only source for them |
|
|
143
|
+
| AWS Bedrock — `InvokeModelWithResponseStream` | same | `invoke_model_with_response_stream` | Yes | Reads `amazon-bedrock-invocationMetrics` from the terminal chunk; input/output only, no cache split |
|
|
144
|
+
|
|
145
|
+
Not covered: async `google-genai` (`client.aio.models`), `aioboto3`, and
|
|
146
|
+
`InvokeModel` body parsers for Meta Llama beyond the documented
|
|
147
|
+
`prompt_token_count`/`generation_token_count` shape. Add as needed — the
|
|
148
|
+
header fallback already covers these families with reduced granularity.
|
|
149
|
+
|
|
150
|
+
**A provider integration is not "done" until its CI fixture exists** (PRD
|
|
151
|
+
§7.1) — see `tests/test_normalize_*.py`, one file per provider, each pinning
|
|
152
|
+
the exact per-provider arithmetic against a recorded fixture in
|
|
153
|
+
`tests/fixtures/`.
|
|
154
|
+
|
|
155
|
+
## The normalization arithmetic, per provider
|
|
156
|
+
|
|
157
|
+
Every provider is converted to the same disjoint canonical shape
|
|
158
|
+
(`input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`,
|
|
159
|
+
`reasoning_tokens`, each independently nullable) so that
|
|
160
|
+
`sum(class_count * class_price)` is correct downstream by construction. The
|
|
161
|
+
arithmetic that gets there is **not** the same across providers — two of them
|
|
162
|
+
invert the OpenAI reflex:
|
|
163
|
+
|
|
164
|
+
- **OpenAI / Azure OpenAI**: `input = prompt_tokens − cached_tokens`,
|
|
165
|
+
`output = completion_tokens − reasoning_tokens`. Both are subsets and must
|
|
166
|
+
be subtracted out.
|
|
167
|
+
- **Anthropic**: cache reads/writes are reported **separately** from
|
|
168
|
+
`input_tokens` already — passthrough, no subtraction. Subtracting would
|
|
169
|
+
double-count them as missing.
|
|
170
|
+
- **AWS Bedrock (`Converse`)**: `input = inputTokens` **as-is** — it already
|
|
171
|
+
excludes cache reads/writes (`total input = inputTokens + cacheReadInputTokens
|
|
172
|
+
+ cacheWriteInputTokens`, doc-verified). Subtracting cache reads here would
|
|
173
|
+
**double-discount**. `reasoning_tokens` is always `null` — no such field
|
|
174
|
+
exists; Claude thinking tokens are billed inside `outputTokens`.
|
|
175
|
+
- **Google Gemini**: `input = promptTokenCount − cachedContentTokenCount`
|
|
176
|
+
(cached **is** a subset, subtract — same direction as OpenAI), but
|
|
177
|
+
`output = candidatesTokenCount` **as-is** — `thoughtsTokenCount` is
|
|
178
|
+
**separate**, not a subset. Subtracting it would silently **undercount**
|
|
179
|
+
the most expensive (reasoning-heavy) traffic.
|
|
180
|
+
|
|
181
|
+
See `src/tokenmeter/_normalize.py` for the implementation and
|
|
182
|
+
`docs/spikes/provider-shapes.md` for the full citations.
|
|
183
|
+
|
|
184
|
+
## Azure: the deployment-name landmine
|
|
185
|
+
|
|
186
|
+
Azure's Responses API returns the **deployment name**, not the canonical
|
|
187
|
+
model id — a deployment named `prod-summarizer` matches nothing in the
|
|
188
|
+
pricing catalog. Pass an optional deployment → model id map to `wrap()`:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
client = tokenmeter.wrap(
|
|
192
|
+
AzureOpenAI(...),
|
|
193
|
+
model_map={"prod-summarizer": "gpt-5-2025-08-07"},
|
|
194
|
+
)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Unmapped deployments pass through unresolved and land in the unpriced
|
|
198
|
+
quarantine rather than silently mispricing. `model_map` is ignored for every
|
|
199
|
+
other provider.
|
|
200
|
+
|
|
201
|
+
## Streaming and failure contracts (all providers)
|
|
202
|
+
|
|
203
|
+
- **Never throws into host code.** Only tokenmeter's own bookkeeping is
|
|
204
|
+
try/except'd; the real provider call and its real exceptions pass through
|
|
205
|
+
untouched — proven for the transport, the buffer, and every wrapper's
|
|
206
|
+
success/error/stream paths in `tests/test_transport_never_throws.py` and
|
|
207
|
+
the per-provider `test_wrap_*.py` files.
|
|
208
|
+
- **Never adds latency.** Capture is post-hoc from the response object the
|
|
209
|
+
provider already returned — no network I/O on the metering path. The one
|
|
210
|
+
exception worth naming: Bedrock `InvokeModel`'s response body is read
|
|
211
|
+
in-memory to extract usage and then replayed via a fresh in-memory stream
|
|
212
|
+
so the host's own `response["body"].read()` still works — this adds
|
|
213
|
+
in-memory work, never a network hop.
|
|
214
|
+
- **A stream that aborts before its terminal usage frame** (or a provider
|
|
215
|
+
response with no usage at all) is recorded `usage_complete=false` with
|
|
216
|
+
every token field `null` — never estimated from partial deltas.
|
|
217
|
+
- **Context propagation** (`trace_id`/`action`/`customer_ref`/...) is
|
|
218
|
+
snapshotted at call time and threaded through every wrapper, including
|
|
219
|
+
streams handed off to be iterated later — see
|
|
220
|
+
`tests/test_context_propagation.py` and the context-propagation cases in
|
|
221
|
+
each `test_wrap_*.py`.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# token-meter (Python SDK)
|
|
2
|
+
|
|
3
|
+
## Read usage data
|
|
4
|
+
|
|
5
|
+
Create a separate read-only key under **Settings → API keys → Read API keys**. It cannot submit usage events.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
import os
|
|
9
|
+
from tokenmeter import TokenMeterReadClient
|
|
10
|
+
|
|
11
|
+
read_api = TokenMeterReadClient(os.environ["TOKEN_METER_READ_KEY"], os.environ["TOKEN_METER_DASHBOARD_URL"])
|
|
12
|
+
page = read_api.list_daily_usage("2026-10-01", "2026-10-31")
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
This returns daily **metered** usage/spend summaries, not provider invoice data. See [`docs/openapi.yaml`](../../docs/openapi.yaml) for pagination, fields, and limits.
|
|
16
|
+
|
|
17
|
+
In-process LLM usage capture for Python. `tokenmeter.wrap(client)` wraps a
|
|
18
|
+
provider client you already have, reads token usage out of the response
|
|
19
|
+
object it already returns (no extra network hop), normalizes it to a
|
|
20
|
+
canonical disjoint counter shape, and ships batches to the Token Meter Ingest
|
|
21
|
+
API in the background.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pip install tokenmeter-sdk
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**Note:** the PyPI distribution is named `tokenmeter-sdk` (like the npm
|
|
30
|
+
package `@tokenmeter/sdk`), but the import name is `tokenmeter`:
|
|
31
|
+
`import tokenmeter`. Distribution and import names are allowed to differ;
|
|
32
|
+
the unrelated PyPI project `tokenmeter` blocks the shorter names.
|
|
33
|
+
|
|
34
|
+
The SDK is public and installable by anyone — it does nothing without a
|
|
35
|
+
`tl_live_` ingest key; rate limits, tier caps, and soft-lock are all
|
|
36
|
+
enforced server-side at the ingest door.
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import tokenmeter
|
|
42
|
+
from openai import OpenAI
|
|
43
|
+
|
|
44
|
+
tokenmeter.init(ingest_key="tl_live_...")
|
|
45
|
+
client = tokenmeter.wrap(OpenAI())
|
|
46
|
+
|
|
47
|
+
with tokenmeter.action("generate-report", customer_id="acme", feature="reports"):
|
|
48
|
+
client.chat.completions.create(model="gpt-4o-mini", messages=[...])
|
|
49
|
+
# usage for this call is captured automatically, tagged with
|
|
50
|
+
# customer_ref=acme, feature=reports, and a shared trace_id.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`wrap()` detects the client shape structurally (no hard dependency on any
|
|
54
|
+
provider's package) and is idempotent — wrapping an already-wrapped client or
|
|
55
|
+
method is a no-op.
|
|
56
|
+
|
|
57
|
+
### Optimization measurements for agents
|
|
58
|
+
|
|
59
|
+
Enable local prompt-prefix fingerprinting once with
|
|
60
|
+
`tokenmeter.init(ingest_key=key, optimization={"prompt_caching": True})`.
|
|
61
|
+
Declare delay tolerance only for suitable background tasks with
|
|
62
|
+
`tokenmeter.action("report", optimization={"max_completion_delay_ms": 86400000})`.
|
|
63
|
+
The SDK includes metadata in its normal usage events; users do not add HTTP
|
|
64
|
+
headers. Both settings are optional and default to off/absent. An inner action
|
|
65
|
+
can disable them with `"prompt_caching": False` and
|
|
66
|
+
`"max_completion_delay_ms": None`. See
|
|
67
|
+
[integration and deployment details](../../docs/SDK_OPTIMIZATION_TELEMETRY.md)
|
|
68
|
+
for supported agents, privacy behavior and detector requirements.
|
|
69
|
+
|
|
70
|
+
### Prompt topics
|
|
71
|
+
|
|
72
|
+
Label each call with what it is about, for Analytics > Use cases, without sending any prompt text:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
tokenmeter.init(ingest_key=key, optimization={"topic_classifier": tokenmeter.keyword_topic_classifier})
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The classifier runs in your process on the latest user message and returns a topic key (such as `debugging` or
|
|
79
|
+
`legal_review`); only that key is sent, as `tags["prompt_topic"]`. Anything that is not a key
|
|
80
|
+
(`^[a-z][a-z0-9_]{1,39}$`) is dropped. Supply your own synchronous function for better labels; an async one, or one
|
|
81
|
+
that raises, is ignored and the call is untouched. Turn it off for one action with
|
|
82
|
+
`optimization={"topic_classifier": None}`. Set it in `init()`: a classifier in an `action()` does not survive
|
|
83
|
+
`TraceContext.to_dict()` across a queue.
|
|
84
|
+
|
|
85
|
+
For an OpenAI-compatible client pointed at xAI, Mistral, Fireworks, Together
|
|
86
|
+
AI, DeepInfra, OpenRouter, or Vercel AI Gateway (`"vercel_gateway"`), preserve
|
|
87
|
+
the billable provider explicitly:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
client = tokenmeter.wrap_openai_compatible(
|
|
91
|
+
OpenAI(api_key="...", base_url="https://api.together.xyz/v1"), "together"
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Provider support matrix
|
|
96
|
+
|
|
97
|
+
| Provider | Client | Paths wrapped | Streaming | Normalization confidence |
|
|
98
|
+
|---|---|---|---|---|
|
|
99
|
+
| OpenAI | `openai.OpenAI` / `AsyncOpenAI` | `chat.completions.create`, `responses.create` | Yes (both) | **Proven live** — `prompt_tokens` held constant at 2107 across cached/uncached calls (`docs/spikes/openai-billing.md`) |
|
|
100
|
+
| Anthropic | `anthropic.Anthropic` / `AsyncAnthropic` | `messages.create` | Yes | Doc-stated; cache reads reported separately, passthrough only |
|
|
101
|
+
| Azure OpenAI | `openai.AzureOpenAI` / `AsyncAzureOpenAI` | `chat.completions.create`, `responses.create` | Yes (both) | **Doc-inferred only, NOT empirically verified.** Same field shape as OpenAI, so the same subtraction rule is assumed to apply — but the 5-call cache probe that proved OpenAI's rule has not been replayed against a real Azure deployment. Treat as provisional until that probe runs (`docs/spikes/provider-shapes.md` §2.1, §7) |
|
|
102
|
+
| Google Gemini | `google.genai.Client` | `models.generate_content`, `models.generate_content_stream` | Yes (separate method, not a `stream=True` kwarg) | Doc-verified (`docs/spikes/provider-shapes.md` §3) — mixed rule: cached tokens ARE a subset of prompt (subtract), thinking tokens are NOT a subset of output (do not subtract). Streaming "last chunk is complete" behavior is community-observed, not doc-confirmed |
|
|
103
|
+
| AWS Bedrock — `Converse`/`ConverseStream` | `boto3` `bedrock-runtime` client | `converse`, `converse_stream` | Yes | Doc-verified disjoint (`docs/spikes/provider-shapes.md` §1.1) — the primary, normalized path across model families |
|
|
104
|
+
| AWS Bedrock — `InvokeModel` (Anthropic body) | same | `invoke_model` | No | Doc-verified — reuses native Anthropic semantics |
|
|
105
|
+
| AWS Bedrock — `InvokeModel` (Amazon Nova body) | same | `invoke_model` | No | Doc-verified shape; cache-counter key **casing** is unconfirmed (AWS's own docs disagree) — both spellings are probed defensively |
|
|
106
|
+
| AWS Bedrock — `InvokeModel` (other families / headers fallback) | same | `invoke_model` | No | Input/output only, via `x-amzn-bedrock-*-token-count` response headers; no cache/reasoning split. **Mistral bodies carry no usage at all** — this is the only source for them |
|
|
107
|
+
| AWS Bedrock — `InvokeModelWithResponseStream` | same | `invoke_model_with_response_stream` | Yes | Reads `amazon-bedrock-invocationMetrics` from the terminal chunk; input/output only, no cache split |
|
|
108
|
+
|
|
109
|
+
Not covered: async `google-genai` (`client.aio.models`), `aioboto3`, and
|
|
110
|
+
`InvokeModel` body parsers for Meta Llama beyond the documented
|
|
111
|
+
`prompt_token_count`/`generation_token_count` shape. Add as needed — the
|
|
112
|
+
header fallback already covers these families with reduced granularity.
|
|
113
|
+
|
|
114
|
+
**A provider integration is not "done" until its CI fixture exists** (PRD
|
|
115
|
+
§7.1) — see `tests/test_normalize_*.py`, one file per provider, each pinning
|
|
116
|
+
the exact per-provider arithmetic against a recorded fixture in
|
|
117
|
+
`tests/fixtures/`.
|
|
118
|
+
|
|
119
|
+
## The normalization arithmetic, per provider
|
|
120
|
+
|
|
121
|
+
Every provider is converted to the same disjoint canonical shape
|
|
122
|
+
(`input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`,
|
|
123
|
+
`reasoning_tokens`, each independently nullable) so that
|
|
124
|
+
`sum(class_count * class_price)` is correct downstream by construction. The
|
|
125
|
+
arithmetic that gets there is **not** the same across providers — two of them
|
|
126
|
+
invert the OpenAI reflex:
|
|
127
|
+
|
|
128
|
+
- **OpenAI / Azure OpenAI**: `input = prompt_tokens − cached_tokens`,
|
|
129
|
+
`output = completion_tokens − reasoning_tokens`. Both are subsets and must
|
|
130
|
+
be subtracted out.
|
|
131
|
+
- **Anthropic**: cache reads/writes are reported **separately** from
|
|
132
|
+
`input_tokens` already — passthrough, no subtraction. Subtracting would
|
|
133
|
+
double-count them as missing.
|
|
134
|
+
- **AWS Bedrock (`Converse`)**: `input = inputTokens` **as-is** — it already
|
|
135
|
+
excludes cache reads/writes (`total input = inputTokens + cacheReadInputTokens
|
|
136
|
+
+ cacheWriteInputTokens`, doc-verified). Subtracting cache reads here would
|
|
137
|
+
**double-discount**. `reasoning_tokens` is always `null` — no such field
|
|
138
|
+
exists; Claude thinking tokens are billed inside `outputTokens`.
|
|
139
|
+
- **Google Gemini**: `input = promptTokenCount − cachedContentTokenCount`
|
|
140
|
+
(cached **is** a subset, subtract — same direction as OpenAI), but
|
|
141
|
+
`output = candidatesTokenCount` **as-is** — `thoughtsTokenCount` is
|
|
142
|
+
**separate**, not a subset. Subtracting it would silently **undercount**
|
|
143
|
+
the most expensive (reasoning-heavy) traffic.
|
|
144
|
+
|
|
145
|
+
See `src/tokenmeter/_normalize.py` for the implementation and
|
|
146
|
+
`docs/spikes/provider-shapes.md` for the full citations.
|
|
147
|
+
|
|
148
|
+
## Azure: the deployment-name landmine
|
|
149
|
+
|
|
150
|
+
Azure's Responses API returns the **deployment name**, not the canonical
|
|
151
|
+
model id — a deployment named `prod-summarizer` matches nothing in the
|
|
152
|
+
pricing catalog. Pass an optional deployment → model id map to `wrap()`:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
client = tokenmeter.wrap(
|
|
156
|
+
AzureOpenAI(...),
|
|
157
|
+
model_map={"prod-summarizer": "gpt-5-2025-08-07"},
|
|
158
|
+
)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Unmapped deployments pass through unresolved and land in the unpriced
|
|
162
|
+
quarantine rather than silently mispricing. `model_map` is ignored for every
|
|
163
|
+
other provider.
|
|
164
|
+
|
|
165
|
+
## Streaming and failure contracts (all providers)
|
|
166
|
+
|
|
167
|
+
- **Never throws into host code.** Only tokenmeter's own bookkeeping is
|
|
168
|
+
try/except'd; the real provider call and its real exceptions pass through
|
|
169
|
+
untouched — proven for the transport, the buffer, and every wrapper's
|
|
170
|
+
success/error/stream paths in `tests/test_transport_never_throws.py` and
|
|
171
|
+
the per-provider `test_wrap_*.py` files.
|
|
172
|
+
- **Never adds latency.** Capture is post-hoc from the response object the
|
|
173
|
+
provider already returned — no network I/O on the metering path. The one
|
|
174
|
+
exception worth naming: Bedrock `InvokeModel`'s response body is read
|
|
175
|
+
in-memory to extract usage and then replayed via a fresh in-memory stream
|
|
176
|
+
so the host's own `response["body"].read()` still works — this adds
|
|
177
|
+
in-memory work, never a network hop.
|
|
178
|
+
- **A stream that aborts before its terminal usage frame** (or a provider
|
|
179
|
+
response with no usage at all) is recorded `usage_complete=false` with
|
|
180
|
+
every token field `null` — never estimated from partial deltas.
|
|
181
|
+
- **Context propagation** (`trace_id`/`action`/`customer_ref`/...) is
|
|
182
|
+
snapshotted at call time and threaded through every wrapper, including
|
|
183
|
+
streams handed off to be iterated later — see
|
|
184
|
+
`tests/test_context_propagation.py` and the context-propagation cases in
|
|
185
|
+
each `test_wrap_*.py`.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tokenmeter-sdk"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Token Meter Python SDK: wraps OpenAI/Anthropic/Gemini/Bedrock/Azure clients and meters token usage"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.9"
|
|
9
|
+
authors = [{ name = "Saqlain Rashid" }]
|
|
10
|
+
keywords = ["llm", "tokens", "usage", "metering", "observability", "openai", "anthropic", "vercel-ai-gateway"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.9",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/SaqlainRashed/token-meter"
|
|
26
|
+
Repository = "https://github.com/SaqlainRashed/token-meter"
|
|
27
|
+
Changelog = "https://github.com/SaqlainRashed/token-meter/blob/main/sdks/python/CHANGELOG.md"
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
openai = ["openai>=1.0"]
|
|
31
|
+
anthropic = ["anthropic>=0.30"]
|
|
32
|
+
gemini = ["google-genai>=0.1"]
|
|
33
|
+
bedrock = ["boto3>=1.34"]
|
|
34
|
+
azure = ["openai>=1.0"]
|
|
35
|
+
dev = ["pytest>=8"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
39
|
+
pythonpath = ["src"]
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["setuptools>=77"]
|
|
43
|
+
build-backend = "setuptools.build_meta"
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.packages.find]
|
|
46
|
+
where = ["src"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""TokenMeter Python SDK.
|
|
2
|
+
|
|
3
|
+
import tokenmeter
|
|
4
|
+
tokenmeter.init(ingest_key="tl_live_...")
|
|
5
|
+
client = tokenmeter.wrap(OpenAI())
|
|
6
|
+
|
|
7
|
+
with tokenmeter.action("generate-report", customer_id="acme", feature="reports"):
|
|
8
|
+
client.chat.completions.create(...)
|
|
9
|
+
"""
|
|
10
|
+
from ._client import init
|
|
11
|
+
from ._context import TraceContext, action, attach, current
|
|
12
|
+
from ._diagnose import diagnose
|
|
13
|
+
from ._preflight import GovernanceDenied, GovernanceUnavailable, PreflightDecision, governed, preflight
|
|
14
|
+
from ._wrap import wrap, wrap_openai_compatible
|
|
15
|
+
from ._read_api import ReadApiClient, ReadApiError, TokenMeterReadClient
|
|
16
|
+
from ._topic import keyword_topic_classifier, latest_user_text
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"init",
|
|
20
|
+
"wrap",
|
|
21
|
+
"wrap_openai_compatible",
|
|
22
|
+
"action",
|
|
23
|
+
"current",
|
|
24
|
+
"attach",
|
|
25
|
+
"TraceContext",
|
|
26
|
+
"diagnose",
|
|
27
|
+
"flush",
|
|
28
|
+
"preflight",
|
|
29
|
+
"governed",
|
|
30
|
+
"PreflightDecision",
|
|
31
|
+
"GovernanceDenied",
|
|
32
|
+
"GovernanceUnavailable",
|
|
33
|
+
"ReadApiClient",
|
|
34
|
+
"TokenMeterReadClient",
|
|
35
|
+
"ReadApiError",
|
|
36
|
+
"keyword_topic_classifier",
|
|
37
|
+
"latest_user_text",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
__version__ = "0.2.0"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def flush(deadline_s: float = 2.0) -> None:
|
|
44
|
+
"""Best-effort synchronous flush. Never raises."""
|
|
45
|
+
from ._client import get_state
|
|
46
|
+
|
|
47
|
+
state = get_state()
|
|
48
|
+
if state is not None:
|
|
49
|
+
state.flusher.flush_best_effort(deadline_s=deadline_s)
|