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.
@@ -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
@@ -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()
@@ -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"