tokenmeter-sdk 0.2.0__py3-none-any.whl
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/__init__.py +49 -0
- tokenmeter/_buffer.py +201 -0
- tokenmeter/_client.py +78 -0
- tokenmeter/_context.py +131 -0
- tokenmeter/_diagnose.py +57 -0
- tokenmeter/_ids.py +45 -0
- tokenmeter/_normalize.py +239 -0
- tokenmeter/_optimization.py +97 -0
- tokenmeter/_preflight.py +182 -0
- tokenmeter/_read_api.py +50 -0
- tokenmeter/_topic.py +91 -0
- tokenmeter/_transport.py +82 -0
- tokenmeter/_wrap.py +807 -0
- tokenmeter_sdk-0.2.0.dist-info/METADATA +221 -0
- tokenmeter_sdk-0.2.0.dist-info/RECORD +18 -0
- tokenmeter_sdk-0.2.0.dist-info/WHEEL +5 -0
- tokenmeter_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
- tokenmeter_sdk-0.2.0.dist-info/top_level.txt +1 -0
tokenmeter/_normalize.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Disjoint canonical token-counter normalization (PRD §7.1 / DECISIONS §B3).
|
|
2
|
+
|
|
3
|
+
Every provider must be converted to the SAME disjoint shape so that
|
|
4
|
+
sum(class_count * class_price) is correct by construction downstream:
|
|
5
|
+
|
|
6
|
+
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens
|
|
7
|
+
|
|
8
|
+
Each field is nullable and a null means "provider didn't report this", never a
|
|
9
|
+
guessed zero. A provider-reported explicit 0 stays 0.
|
|
10
|
+
|
|
11
|
+
OpenAI: `cached_tokens` is a SUBSET of `prompt_tokens`, and `reasoning_tokens` is a
|
|
12
|
+
SUBSET of `completion_tokens` -- both must be subtracted out.
|
|
13
|
+
|
|
14
|
+
Anthropic: cache reads/writes are reported SEPARATELY from `input_tokens` already
|
|
15
|
+
(Anthropic's own billing counts them apart) -- the normalizer here is a straight
|
|
16
|
+
field rename/passthrough, no arithmetic, which is itself the thing the fixture
|
|
17
|
+
proves (a naive implementer's instinct is to subtract, which would double-subtract
|
|
18
|
+
and undercount).
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
_NULL = {
|
|
25
|
+
"input_tokens": None,
|
|
26
|
+
"output_tokens": None,
|
|
27
|
+
"cache_read_tokens": None,
|
|
28
|
+
"cache_write_tokens": None,
|
|
29
|
+
"reasoning_tokens": None,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get(d: Optional[dict], *path):
|
|
34
|
+
cur = d
|
|
35
|
+
for key in path:
|
|
36
|
+
if cur is None:
|
|
37
|
+
return None
|
|
38
|
+
cur = cur.get(key)
|
|
39
|
+
return cur
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize_openai_usage(usage: dict) -> dict:
|
|
43
|
+
"""Accepts either the chat/completions `CompletionUsage` shape
|
|
44
|
+
(prompt_tokens/completion_tokens/{prompt,completion}_tokens_details) or the
|
|
45
|
+
Responses API shape (input_tokens/output_tokens/{input,output}_tokens_details).
|
|
46
|
+
"""
|
|
47
|
+
if usage is None:
|
|
48
|
+
return {
|
|
49
|
+
"input_tokens": None,
|
|
50
|
+
"output_tokens": None,
|
|
51
|
+
"cache_read_tokens": None,
|
|
52
|
+
"cache_write_tokens": None,
|
|
53
|
+
"reasoning_tokens": None,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
prompt_total = usage.get("prompt_tokens", usage.get("input_tokens"))
|
|
57
|
+
completion_total = usage.get("completion_tokens", usage.get("output_tokens"))
|
|
58
|
+
prompt_details = usage.get("prompt_tokens_details", usage.get("input_tokens_details"))
|
|
59
|
+
completion_details = usage.get("completion_tokens_details", usage.get("output_tokens_details"))
|
|
60
|
+
|
|
61
|
+
cached = _get(prompt_details, "cached_tokens")
|
|
62
|
+
reasoning = _get(completion_details, "reasoning_tokens")
|
|
63
|
+
|
|
64
|
+
input_tokens = None if prompt_total is None else prompt_total - (cached or 0)
|
|
65
|
+
output_tokens = None if completion_total is None else completion_total - (reasoning or 0)
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
"input_tokens": input_tokens,
|
|
69
|
+
"output_tokens": output_tokens,
|
|
70
|
+
"cache_read_tokens": cached, # subset already extracted above; report as-is
|
|
71
|
+
"cache_write_tokens": None, # OpenAI chat/responses do not report cache writes
|
|
72
|
+
"reasoning_tokens": reasoning,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def normalize_anthropic_usage(usage: dict) -> dict:
|
|
77
|
+
"""Anthropic Messages API `usage` object. Cache fields are already disjoint
|
|
78
|
+
from `input_tokens` -- passthrough only, no subtraction.
|
|
79
|
+
"""
|
|
80
|
+
if usage is None:
|
|
81
|
+
return {
|
|
82
|
+
"input_tokens": None,
|
|
83
|
+
"output_tokens": None,
|
|
84
|
+
"cache_read_tokens": None,
|
|
85
|
+
"cache_write_tokens": None,
|
|
86
|
+
"reasoning_tokens": None,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
"input_tokens": usage.get("input_tokens"),
|
|
91
|
+
"output_tokens": usage.get("output_tokens"),
|
|
92
|
+
"cache_read_tokens": usage.get("cache_read_input_tokens"),
|
|
93
|
+
"cache_write_tokens": usage.get("cache_creation_input_tokens"),
|
|
94
|
+
# ponytail: Anthropic's Messages API usage object has no distinct reasoning
|
|
95
|
+
# counter for extended-thinking today; upgrade when/if they add one.
|
|
96
|
+
"reasoning_tokens": None,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Azure OpenAI's `usage` shape is byte-identical to OpenAI's (doc-verified,
|
|
101
|
+
# docs/spikes/provider-shapes.md §2.1). Subset semantics (cached subset of
|
|
102
|
+
# prompt, reasoning subset of completion) are INFERRED from that identity,
|
|
103
|
+
# NOT empirically probed the way OpenAI's were -- the M4 cache-probe replay
|
|
104
|
+
# against a real Azure deployment is still outstanding (spike §7.1). Reusing
|
|
105
|
+
# the OpenAI normalizer verbatim is therefore a documented inference, not a
|
|
106
|
+
# second implementation to keep in sync.
|
|
107
|
+
normalize_azure_openai_usage = normalize_openai_usage
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def normalize_bedrock_converse_usage(usage: Optional[dict]) -> dict:
|
|
111
|
+
"""Bedrock `Converse`/`ConverseStream` `usage` (TokenUsage). Doc-verified
|
|
112
|
+
ALREADY DISJOINT: AWS's prompt-caching guide states verbatim that
|
|
113
|
+
`inputTokens` excludes cache reads/writes and
|
|
114
|
+
`total = inputTokens + cacheReadInputTokens + cacheWriteInputTokens`.
|
|
115
|
+
Passthrough only -- subtracting cache reads here would DOUBLE-DISCOUNT,
|
|
116
|
+
the inverse of OpenAI's trap on the same underlying models.
|
|
117
|
+
|
|
118
|
+
No reasoning field exists in TokenUsage: Claude thinking content comes
|
|
119
|
+
back as `reasoningContent` blocks but is counted INSIDE `outputTokens`
|
|
120
|
+
with no split, so `reasoning_tokens` is always null on Bedrock (never 0 --
|
|
121
|
+
0 would claim "we know there was no reasoning", which we don't).
|
|
122
|
+
"""
|
|
123
|
+
if usage is None:
|
|
124
|
+
return dict(_NULL)
|
|
125
|
+
return {
|
|
126
|
+
"input_tokens": usage.get("inputTokens"),
|
|
127
|
+
"output_tokens": usage.get("outputTokens"),
|
|
128
|
+
"cache_read_tokens": usage.get("cacheReadInputTokens"),
|
|
129
|
+
"cache_write_tokens": usage.get("cacheWriteInputTokens"),
|
|
130
|
+
"reasoning_tokens": None,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def normalize_bedrock_nova_usage(usage: Optional[dict]) -> dict:
|
|
135
|
+
"""Amazon Nova `InvokeModel` body `usage`. Already disjoint like Converse,
|
|
136
|
+
but AWS's own docs disagree on cache-counter key casing
|
|
137
|
+
(`cacheReadInputTokenCount` vs `CacheReadInputTokens`) -- spike §1.2,
|
|
138
|
+
unresolved without a live call. Probe both spellings defensively rather
|
|
139
|
+
than pin one and silently drop cache counts the day AWS's docs turn out
|
|
140
|
+
to match the other casing.
|
|
141
|
+
"""
|
|
142
|
+
if usage is None:
|
|
143
|
+
return dict(_NULL)
|
|
144
|
+
|
|
145
|
+
def first(*keys):
|
|
146
|
+
for k in keys:
|
|
147
|
+
if k in usage:
|
|
148
|
+
return usage[k]
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
"input_tokens": usage.get("inputTokens"),
|
|
153
|
+
"output_tokens": usage.get("outputTokens"),
|
|
154
|
+
"cache_read_tokens": first("cacheReadInputTokenCount", "CacheReadInputTokens", "cacheReadInputTokens"),
|
|
155
|
+
"cache_write_tokens": first("cacheWriteInputTokenCount", "CacheWriteInputTokens", "cacheWriteInputTokens"),
|
|
156
|
+
"reasoning_tokens": None,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def normalize_bedrock_invoke_model(body: Optional[dict], headers: Optional[dict]) -> tuple[dict, bool]:
|
|
161
|
+
"""`InvokeModel` usage is model-native and varies per family (spike §1.2).
|
|
162
|
+
Tries, in order: Anthropic-native body (identical shape to the Anthropic
|
|
163
|
+
Messages API -- delegates to normalize_anthropic_usage, already disjoint),
|
|
164
|
+
Amazon Nova body, Meta Llama body, then the
|
|
165
|
+
`x-amzn-bedrock-{input,output}-token-count` HTTP headers (the only source
|
|
166
|
+
for Mistral-style bodies, which return NO usage in the body at all --
|
|
167
|
+
input/output only, no cache/reasoning split). Returns
|
|
168
|
+
(norm, usage_complete); if nothing is parseable this records
|
|
169
|
+
usage_complete=False with an all-null norm rather than guessing.
|
|
170
|
+
"""
|
|
171
|
+
body = body or {}
|
|
172
|
+
headers = headers or {}
|
|
173
|
+
|
|
174
|
+
anthropic_usage = body.get("usage")
|
|
175
|
+
if isinstance(anthropic_usage, dict) and "input_tokens" in anthropic_usage:
|
|
176
|
+
return normalize_anthropic_usage(anthropic_usage), True
|
|
177
|
+
|
|
178
|
+
nova_usage = anthropic_usage if isinstance(anthropic_usage, dict) else body
|
|
179
|
+
if isinstance(nova_usage, dict) and "inputTokens" in nova_usage:
|
|
180
|
+
return normalize_bedrock_nova_usage(nova_usage), True
|
|
181
|
+
|
|
182
|
+
if "prompt_token_count" in body and "generation_token_count" in body:
|
|
183
|
+
return {
|
|
184
|
+
"input_tokens": body["prompt_token_count"],
|
|
185
|
+
"output_tokens": body["generation_token_count"],
|
|
186
|
+
"cache_read_tokens": None,
|
|
187
|
+
"cache_write_tokens": None,
|
|
188
|
+
"reasoning_tokens": None,
|
|
189
|
+
}, True
|
|
190
|
+
|
|
191
|
+
in_hdr = headers.get("x-amzn-bedrock-input-token-count")
|
|
192
|
+
out_hdr = headers.get("x-amzn-bedrock-output-token-count")
|
|
193
|
+
if in_hdr is not None or out_hdr is not None:
|
|
194
|
+
return {
|
|
195
|
+
"input_tokens": int(in_hdr) if in_hdr is not None else None,
|
|
196
|
+
"output_tokens": int(out_hdr) if out_hdr is not None else None,
|
|
197
|
+
"cache_read_tokens": None,
|
|
198
|
+
"cache_write_tokens": None,
|
|
199
|
+
"reasoning_tokens": None,
|
|
200
|
+
}, True
|
|
201
|
+
|
|
202
|
+
# Mistral (and anything else unrecognized): no usage anywhere in the
|
|
203
|
+
# response. usage_complete=False, not a guessed zero.
|
|
204
|
+
return dict(_NULL), False
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def normalize_gemini_usage(usage: Optional[dict]) -> dict:
|
|
208
|
+
"""google-genai `usage_metadata` (`UsageMetadata`). MIXED semantics --
|
|
209
|
+
the trap this function exists to prevent:
|
|
210
|
+
|
|
211
|
+
cached_content_token_count is a SUBSET of prompt_token_count (subtract,
|
|
212
|
+
OpenAI-style; doc-verified: "even when cachedContent is set, [prompt] is
|
|
213
|
+
still the TOTAL effective prompt size, INCLUDING cached content tokens").
|
|
214
|
+
|
|
215
|
+
thoughts_token_count is SEPARATE from candidates_token_count (do NOT
|
|
216
|
+
subtract, Anthropic-style; doc-verified: total = prompt + thoughts +
|
|
217
|
+
candidates). Subtracting it would silently UNDERCOUNT reasoning-heavy
|
|
218
|
+
traffic -- exactly the traffic that costs the most.
|
|
219
|
+
|
|
220
|
+
Accepts either snake_case (SDK dataclass `.model_dump()`) or camelCase
|
|
221
|
+
(raw REST JSON) keys.
|
|
222
|
+
"""
|
|
223
|
+
if usage is None:
|
|
224
|
+
return dict(_NULL)
|
|
225
|
+
|
|
226
|
+
prompt = usage.get("prompt_token_count", usage.get("promptTokenCount"))
|
|
227
|
+
candidates = usage.get("candidates_token_count", usage.get("candidatesTokenCount"))
|
|
228
|
+
cached = usage.get("cached_content_token_count", usage.get("cachedContentTokenCount"))
|
|
229
|
+
reasoning = usage.get("thoughts_token_count", usage.get("thoughtsTokenCount"))
|
|
230
|
+
|
|
231
|
+
input_tokens = None if prompt is None else prompt - (cached or 0)
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
"input_tokens": input_tokens,
|
|
235
|
+
"output_tokens": candidates, # AS-IS -- thoughts are NOT inside this
|
|
236
|
+
"cache_read_tokens": cached,
|
|
237
|
+
"cache_write_tokens": None, # explicit-cache creation bills as storage, not per-call
|
|
238
|
+
"reasoning_tokens": reasoning,
|
|
239
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Optional local measurements. No prompt text or key is emitted."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from ._topic import topic_tag
|
|
10
|
+
|
|
11
|
+
MAX_PREFIX_CHARS = 8_000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _text(value) -> Optional[str]:
|
|
15
|
+
if isinstance(value, str):
|
|
16
|
+
return value
|
|
17
|
+
if not isinstance(value, list) or len(value) > 100:
|
|
18
|
+
return None
|
|
19
|
+
parts = []
|
|
20
|
+
total = 0
|
|
21
|
+
for part in value:
|
|
22
|
+
if not isinstance(part, dict) or part.get("type") not in ("text", "input_text") or not isinstance(part.get("text"), str):
|
|
23
|
+
return None
|
|
24
|
+
total += len(part["text"]) + (1 if parts else 0)
|
|
25
|
+
if total > MAX_PREFIX_CHARS:
|
|
26
|
+
return None
|
|
27
|
+
parts.append(part["text"])
|
|
28
|
+
return "\n".join(parts)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _prefix(params: dict):
|
|
32
|
+
parts = []
|
|
33
|
+
total = 0
|
|
34
|
+
|
|
35
|
+
def add(role, value):
|
|
36
|
+
nonlocal total
|
|
37
|
+
content = _text(value)
|
|
38
|
+
if content is None or len(content) > MAX_PREFIX_CHARS:
|
|
39
|
+
return False
|
|
40
|
+
if content:
|
|
41
|
+
total += len(content) + (1 if parts else 0)
|
|
42
|
+
if total > MAX_PREFIX_CHARS:
|
|
43
|
+
return False
|
|
44
|
+
parts.append([role, content])
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
if "system" in params and not add("system", params["system"]):
|
|
48
|
+
return None
|
|
49
|
+
if "instructions" in params and not add("system", params["instructions"]):
|
|
50
|
+
return None
|
|
51
|
+
messages = params.get("messages", params.get("input", params.get("prompt")))
|
|
52
|
+
if isinstance(messages, list):
|
|
53
|
+
leading = 0
|
|
54
|
+
for message in messages:
|
|
55
|
+
if not isinstance(message, dict) or message.get("role") not in ("system", "developer"):
|
|
56
|
+
break
|
|
57
|
+
leading += 1
|
|
58
|
+
if leading > 100 or not add(message["role"], message.get("content")):
|
|
59
|
+
return None
|
|
60
|
+
content = "\n".join(part[1] for part in parts)
|
|
61
|
+
if not 128 <= len(content) <= MAX_PREFIX_CHARS:
|
|
62
|
+
return None
|
|
63
|
+
return json.dumps(parts, ensure_ascii=False, separators=(",", ":")), len(content), len(content.encode("utf-8"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def optimization_tags(ingest_key: str, params, options=None) -> dict:
|
|
67
|
+
"""Same HMAC domain and encoding as the TypeScript SDK; fail open for telemetry."""
|
|
68
|
+
try:
|
|
69
|
+
options = options if isinstance(options, dict) else {}
|
|
70
|
+
tags = {}
|
|
71
|
+
delay = options.get("max_completion_delay_ms")
|
|
72
|
+
if type(delay) is int and 1_000 <= delay <= 86_400_000:
|
|
73
|
+
tags["batch_optimization"] = {"version": 1, "source": "sdk-v1", "max_completion_delay_ms": delay}
|
|
74
|
+
if options.get("prompt_caching") is True and isinstance(ingest_key, str) and len(ingest_key) >= 16 and isinstance(params, dict):
|
|
75
|
+
value = _prefix(params)
|
|
76
|
+
if value:
|
|
77
|
+
encoded, chars, byte_count = value
|
|
78
|
+
estimate = ((byte_count + 2) // 3 + 128) // 2
|
|
79
|
+
tags["prompt_cache_telemetry"] = {
|
|
80
|
+
"version": 1, "source": "sdk-v1",
|
|
81
|
+
"prefix_hmac": hmac.new(ingest_key.encode("utf-8"), ("tokenmeter-sdk-prefix-v1\n" + encoded).encode("utf-8"), hashlib.sha256).hexdigest(),
|
|
82
|
+
"prefix_length_bucket": "128-511" if chars < 512 else "512-2047" if chars < 2_048 else "2048-7999" if chars < 8_000 else "8000",
|
|
83
|
+
"prefix_token_estimate_bucket": "128-511" if estimate < 512 else "512-2047" if estimate < 2_048 else "2048-7999" if estimate < 8_000 else "8000+",
|
|
84
|
+
}
|
|
85
|
+
# Local topic labelling (_topic.py): only a topic key leaves the process, never the text.
|
|
86
|
+
topic = topic_tag(options.get("topic_classifier"), params)
|
|
87
|
+
if topic:
|
|
88
|
+
tags["prompt_topic"] = topic
|
|
89
|
+
return tags
|
|
90
|
+
except Exception:
|
|
91
|
+
return {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def enrich_tags(tags: dict, optimization: dict) -> dict:
|
|
95
|
+
"""Leave space for JSONB overhead and preserve existing attribution tags."""
|
|
96
|
+
combined = {**tags, **optimization}
|
|
97
|
+
return combined if len(json.dumps(combined, ensure_ascii=False).encode("utf-8")) <= 1792 else tags
|
tokenmeter/_preflight.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Governance preflight for direct provider calls (TS SDK: src/preflight.ts).
|
|
2
|
+
|
|
3
|
+
tokenmeter.init(ingest_key="tl_live_...", router_url="https://router.example.com")
|
|
4
|
+
with tokenmeter.governed(provider="openai", model="gpt-4o", content=prompt, max_output_tokens=800):
|
|
5
|
+
client.chat.completions.create(model="gpt-4o", messages=..., max_tokens=800)
|
|
6
|
+
|
|
7
|
+
`governed()` asks the TokenMeter router's control plane whether the call is
|
|
8
|
+
allowed -- the same model, provider, data-classification, token and cost policy
|
|
9
|
+
a routed call gets -- before the block runs. Usage captured inside the block
|
|
10
|
+
carries the decision id (tags.preflight_id), which is what lets TokenMeter report
|
|
11
|
+
the call as preflight-protected instead of detected after the fact.
|
|
12
|
+
|
|
13
|
+
Unlike telemetry, this IS on the request path, so it is allowed to raise:
|
|
14
|
+
GovernanceDenied for a denial in "enforce" mode (default), and
|
|
15
|
+
GovernanceUnavailable when the control plane cannot be reached and
|
|
16
|
+
fail_mode is "closed" (default). stdlib only, like _transport.py.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextvars
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import List, Optional, Sequence, Union
|
|
27
|
+
|
|
28
|
+
from ._context import current
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("tokenmeter")
|
|
31
|
+
|
|
32
|
+
_preflight_id: "contextvars.ContextVar[Optional[str]]" = contextvars.ContextVar("tokenmeter_preflight_id", default=None)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class _Config:
|
|
37
|
+
ingest_key: Optional[str] = None
|
|
38
|
+
router_url: Optional[str] = None
|
|
39
|
+
mode: str = "enforce"
|
|
40
|
+
fail_mode: str = "closed"
|
|
41
|
+
timeout_s: float = 2.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_config = _Config()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def configure(ingest_key: str, router_url: Optional[str], *, mode: str = "enforce", fail_mode: str = "closed", timeout_s: float = 2.0) -> None:
|
|
48
|
+
if mode not in ("enforce", "advisory"):
|
|
49
|
+
raise ValueError("governance mode must be 'enforce' or 'advisory'")
|
|
50
|
+
if fail_mode not in ("closed", "open"):
|
|
51
|
+
raise ValueError("governance fail_mode must be 'closed' or 'open'")
|
|
52
|
+
global _config
|
|
53
|
+
_config = _Config(ingest_key=ingest_key, router_url=router_url.rstrip("/") if router_url else None,
|
|
54
|
+
mode=mode, fail_mode=fail_mode, timeout_s=timeout_s)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def active_preflight_id() -> Optional[str]:
|
|
58
|
+
return _preflight_id.get()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class PreflightDecision:
|
|
63
|
+
allowed: bool
|
|
64
|
+
preflight_id: Optional[str] = None
|
|
65
|
+
expires_at: Optional[str] = None
|
|
66
|
+
violations: List[dict] = field(default_factory=list)
|
|
67
|
+
data_classes: List[str] = field(default_factory=list)
|
|
68
|
+
estimated_total_tokens: Optional[int] = None
|
|
69
|
+
estimated_max_cost_usd: Optional[float] = None
|
|
70
|
+
failed_open: bool = False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class GovernanceDenied(Exception):
|
|
74
|
+
def __init__(self, decision: PreflightDecision) -> None:
|
|
75
|
+
reasons = "; ".join(str(v.get("reason", "")) for v in decision.violations) or "policy violation"
|
|
76
|
+
super().__init__(f"TokenMeter governance denied this call: {reasons}")
|
|
77
|
+
self.decision = decision
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class GovernanceUnavailable(Exception):
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _unavailable(detail: str) -> PreflightDecision:
|
|
85
|
+
if _config.fail_mode == "closed":
|
|
86
|
+
raise GovernanceUnavailable(f"TokenMeter governance preflight unavailable ({detail}); failing closed")
|
|
87
|
+
logger.warning("tokenmeter: governance preflight unavailable (%s); proceeding unprotected (fail_mode=open)", detail)
|
|
88
|
+
return PreflightDecision(allowed=True, failed_open=True)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def preflight(
|
|
92
|
+
provider: str,
|
|
93
|
+
model: str,
|
|
94
|
+
*,
|
|
95
|
+
team: Optional[str] = None,
|
|
96
|
+
environment: Optional[str] = None,
|
|
97
|
+
data_classification: Union[str, Sequence[str], None] = None,
|
|
98
|
+
content: Optional[str] = None,
|
|
99
|
+
estimated_input_tokens: Optional[int] = None,
|
|
100
|
+
max_output_tokens: Optional[int] = None,
|
|
101
|
+
) -> PreflightDecision:
|
|
102
|
+
"""Ask for a decision. Raises only GovernanceUnavailable (fail_mode='closed')."""
|
|
103
|
+
if not _config.router_url or not _config.ingest_key:
|
|
104
|
+
return _unavailable("router_url not configured -- pass router_url to tokenmeter.init()")
|
|
105
|
+
ctx = current()
|
|
106
|
+
body = {
|
|
107
|
+
"provider": provider,
|
|
108
|
+
"model": model,
|
|
109
|
+
"team": team if team is not None else (ctx.team if ctx else None),
|
|
110
|
+
"environment": environment if environment is not None else (ctx.environment if ctx else None),
|
|
111
|
+
"data_classification": list(data_classification) if isinstance(data_classification, (list, tuple)) else data_classification,
|
|
112
|
+
"content": content,
|
|
113
|
+
"estimated_input_tokens": estimated_input_tokens,
|
|
114
|
+
"max_output_tokens": max_output_tokens,
|
|
115
|
+
}
|
|
116
|
+
req = urllib.request.Request(
|
|
117
|
+
_config.router_url + "/v1/governance/preflight",
|
|
118
|
+
data=json.dumps({k: v for k, v in body.items() if v is not None}).encode("utf-8"),
|
|
119
|
+
method="POST",
|
|
120
|
+
headers={"Authorization": f"Bearer {_config.ingest_key}", "Content-Type": "application/json"},
|
|
121
|
+
)
|
|
122
|
+
try:
|
|
123
|
+
with urllib.request.urlopen(req, timeout=_config.timeout_s) as resp:
|
|
124
|
+
if resp.status != 200:
|
|
125
|
+
return _unavailable(f"HTTP {resp.status}")
|
|
126
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
127
|
+
except urllib.error.HTTPError as e:
|
|
128
|
+
return _unavailable(f"HTTP {e.code}")
|
|
129
|
+
except Exception as e: # network, timeout, bad JSON
|
|
130
|
+
return _unavailable(type(e).__name__)
|
|
131
|
+
if not isinstance(data, dict) or not isinstance(data.get("allowed"), bool):
|
|
132
|
+
return _unavailable("malformed response")
|
|
133
|
+
return PreflightDecision(
|
|
134
|
+
allowed=data["allowed"],
|
|
135
|
+
preflight_id=data.get("preflight_id") if isinstance(data.get("preflight_id"), str) else None,
|
|
136
|
+
expires_at=data.get("expires_at") if isinstance(data.get("expires_at"), str) else None,
|
|
137
|
+
violations=[v for v in data.get("violations") or [] if isinstance(v, dict)],
|
|
138
|
+
data_classes=[str(c) for c in data.get("data_classes") or []],
|
|
139
|
+
estimated_total_tokens=data.get("estimated_total_tokens"),
|
|
140
|
+
estimated_max_cost_usd=data.get("estimated_max_cost_usd"),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class governed:
|
|
145
|
+
"""Context manager: preflight on enter, preflight id attached to usage captured inside.
|
|
146
|
+
|
|
147
|
+
Works with `with` and `async with`. The preflight request itself is a
|
|
148
|
+
blocking HTTP call (stdlib), bounded by timeout_s.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(self, provider: str, model: str, **kwargs) -> None:
|
|
152
|
+
self.provider = provider
|
|
153
|
+
self.model = model
|
|
154
|
+
self.kwargs = kwargs
|
|
155
|
+
self.decision: Optional[PreflightDecision] = None
|
|
156
|
+
self._token: Optional[contextvars.Token] = None
|
|
157
|
+
|
|
158
|
+
def __enter__(self) -> PreflightDecision:
|
|
159
|
+
decision = preflight(self.provider, self.model, **self.kwargs)
|
|
160
|
+
self.decision = decision
|
|
161
|
+
if not decision.allowed and not decision.failed_open and _config.mode == "enforce":
|
|
162
|
+
raise GovernanceDenied(decision)
|
|
163
|
+
if decision.preflight_id:
|
|
164
|
+
self._token = _preflight_id.set(decision.preflight_id)
|
|
165
|
+
return decision
|
|
166
|
+
|
|
167
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
168
|
+
if self._token is not None:
|
|
169
|
+
_preflight_id.reset(self._token)
|
|
170
|
+
self._token = None
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
async def __aenter__(self) -> PreflightDecision:
|
|
174
|
+
return self.__enter__()
|
|
175
|
+
|
|
176
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
177
|
+
self.__exit__(exc_type, exc, tb)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _reset_for_tests() -> None:
|
|
181
|
+
global _config
|
|
182
|
+
_config = _Config()
|
tokenmeter/_read_api.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Read-only Token Meter warehouse API client (standard library only)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from urllib.error import HTTPError, URLError
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
from urllib.request import Request, urlopen
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ReadApiError(RuntimeError):
|
|
12
|
+
def __init__(self, message: str, status: int, code: Optional[str] = None) -> None:
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.status = status
|
|
15
|
+
self.code = code
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TokenMeterReadClient:
|
|
19
|
+
"""Read daily organization usage using a dedicated ``tl_admin_`` read key."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, api_key: str, base_url: str, timeout: float = 20.0) -> None:
|
|
22
|
+
if not api_key.startswith("tl_admin_"):
|
|
23
|
+
raise ValueError("ReadApiClient requires a read API key (tl_admin_…).")
|
|
24
|
+
self.api_key = api_key
|
|
25
|
+
self.base_url = base_url.rstrip("/")
|
|
26
|
+
self.timeout = timeout
|
|
27
|
+
|
|
28
|
+
def list_daily_usage(self, start: str, end: str, *, limit: int = 100, cursor: Optional[str] = None) -> dict[str, Any]:
|
|
29
|
+
query = {"start": start, "end": end, "limit": str(limit)}
|
|
30
|
+
if cursor is not None:
|
|
31
|
+
query["cursor"] = cursor
|
|
32
|
+
request = Request(
|
|
33
|
+
f"{self.base_url}/api/v1/usage/daily?{urlencode(query)}",
|
|
34
|
+
headers={"Authorization": f"Bearer {self.api_key}", "Accept": "application/json"},
|
|
35
|
+
)
|
|
36
|
+
try:
|
|
37
|
+
with urlopen(request, timeout=self.timeout) as response:
|
|
38
|
+
return json.loads(response.read().decode("utf-8"))
|
|
39
|
+
except HTTPError as exc:
|
|
40
|
+
try:
|
|
41
|
+
body = json.loads(exc.read().decode("utf-8"))
|
|
42
|
+
error = body.get("error", {})
|
|
43
|
+
except (ValueError, AttributeError):
|
|
44
|
+
error = {}
|
|
45
|
+
raise ReadApiError(error.get("message", f"Token Meter read API returned HTTP {exc.code}."), exc.code, error.get("code")) from exc
|
|
46
|
+
except URLError as exc:
|
|
47
|
+
raise ReadApiError(f"Could not reach Token Meter read API: {exc.reason}", 0) from exc
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
ReadApiClient = TokenMeterReadClient
|
tokenmeter/_topic.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""SDK-side prompt topics (docs/PROMPT_INTELLIGENCE_AND_BUSINESS_UNITS_PLAN.md A-5). Same contract as the TypeScript
|
|
2
|
+
SDK's topic.ts.
|
|
3
|
+
|
|
4
|
+
Opt-in: pass ``optimization={"topic_classifier": fn}`` to ``tokenmeter.init`` (or ``action``) and each call is labelled
|
|
5
|
+
with ``tags["prompt_topic"]``, a topic KEY your classifier returns. The prompt text never leaves your process: the
|
|
6
|
+
classifier runs locally, and only a key matching ``^[a-z][a-z0-9_]{1,39}$`` is sent (anything else is dropped here,
|
|
7
|
+
and again at ingest).
|
|
8
|
+
|
|
9
|
+
The classifier must be synchronous and fast: it runs on the call path. A classifier that returns a coroutine, raises,
|
|
10
|
+
or returns something that is not a key is ignored and the call proceeds untouched. ``keyword_topic_classifier`` is a
|
|
11
|
+
small built-in one for the standard topic list (keywords only, no model).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import inspect
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any, Callable, Optional
|
|
18
|
+
|
|
19
|
+
TOPIC_KEY_RE = re.compile(r"^[a-z][a-z0-9_]{1,39}$")
|
|
20
|
+
TOPIC_TEXT_CHARS = 2_000
|
|
21
|
+
|
|
22
|
+
TopicClassifier = Callable[[str], Optional[str]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parts_text(value: Any) -> Optional[str]:
|
|
26
|
+
if isinstance(value, str):
|
|
27
|
+
return value
|
|
28
|
+
if not isinstance(value, list):
|
|
29
|
+
return None
|
|
30
|
+
parts = [p["text"] for p in value if isinstance(p, dict) and p.get("type") in ("text", "input_text") and isinstance(p.get("text"), str)]
|
|
31
|
+
return "\n".join(parts) if parts else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def latest_user_text(params: Any) -> Optional[str]:
|
|
35
|
+
"""The last user message of a chat / messages / responses request, or a plain prompt string."""
|
|
36
|
+
if not isinstance(params, dict):
|
|
37
|
+
return None
|
|
38
|
+
messages = params.get("messages", params.get("input"))
|
|
39
|
+
if isinstance(messages, str):
|
|
40
|
+
return messages
|
|
41
|
+
if isinstance(messages, list):
|
|
42
|
+
for message in reversed(messages):
|
|
43
|
+
if isinstance(message, dict) and message.get("role") == "user":
|
|
44
|
+
text = _parts_text(message.get("content"))
|
|
45
|
+
if text and text.strip():
|
|
46
|
+
return text
|
|
47
|
+
return None
|
|
48
|
+
prompt = params.get("prompt")
|
|
49
|
+
return prompt if isinstance(prompt, str) else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def topic_tag(classifier: Any, params: Any) -> Optional[str]:
|
|
53
|
+
"""Runs the caller's classifier defensively; returns a valid key or None."""
|
|
54
|
+
if not callable(classifier):
|
|
55
|
+
return None
|
|
56
|
+
text = latest_user_text(params)
|
|
57
|
+
if not text or not text.strip():
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
key = classifier(text[:TOPIC_TEXT_CHARS])
|
|
61
|
+
except Exception:
|
|
62
|
+
return None
|
|
63
|
+
if inspect.isawaitable(key):
|
|
64
|
+
# An async classifier would add latency; it is ignored (and closed so it does not warn).
|
|
65
|
+
close = getattr(key, "close", None)
|
|
66
|
+
if callable(close):
|
|
67
|
+
close()
|
|
68
|
+
return None
|
|
69
|
+
return key if isinstance(key, str) and TOPIC_KEY_RE.match(key) else None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Order matters: the first rule that matches wins (an error report about code is debugging, not code generation).
|
|
73
|
+
_KEYWORD_RULES = [
|
|
74
|
+
("debugging", re.compile(r"\b(error|exception|stack ?trace|traceback|bug|crash(es|ed)?|failing|fails|broken|typeerror|nullpointer|segfault|doesn'?t work)\b", re.I)),
|
|
75
|
+
("translation", re.compile(r"\b(translat(e|ion|ing)|in (french|spanish|german|italian|portuguese|japanese|chinese|korean|arabic|hindi|dutch))\b", re.I)),
|
|
76
|
+
("summarisation", re.compile(r"\b(summar(y|ise|ize|ising|izing)|tl;?dr|condense|key points|recap)\b", re.I)),
|
|
77
|
+
("data_extraction", re.compile(r"\b(extract|parse|pull out)\b[\s\S]{0,80}\b(fields?|json|table|entit(y|ies)|names?|dates?|values?|data)\b", re.I)),
|
|
78
|
+
("classification", re.compile(r"\b(classif(y|ication)|categori[sz]e|sentiment|label (this|these|each)|which category)\b", re.I)),
|
|
79
|
+
("customer_support", re.compile(r"\b(customer|ticket|refund|complaint|support request|reply to (this|the) (customer|user))\b", re.I)),
|
|
80
|
+
("content_writing", re.compile(r"\b(blog|article|newsletter|tagline|headline|marketing copy|social (media )?post|write an? (email|post|story))\b", re.I)),
|
|
81
|
+
("code_generation", re.compile(r"```|\b(write|implement|create|generate|build)\b[\s\S]{0,60}\b(function|class|script|code|query|sql|regex|component|endpoint|api|test)\b", re.I)),
|
|
82
|
+
("analysis_qa", re.compile(r"\b(explain|why|how (does|do|can)|what (is|are)|compare|analy[sz]e|pros and cons)\b|\?\s*$", re.I)),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def keyword_topic_classifier(text: str) -> str:
|
|
87
|
+
"""A keyword-only classifier for the standard topics. Coarse by design; supply your own for better labels."""
|
|
88
|
+
for key, pattern in _KEYWORD_RULES:
|
|
89
|
+
if pattern.search(text):
|
|
90
|
+
return key
|
|
91
|
+
return "other"
|