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/_wrap.py ADDED
@@ -0,0 +1,807 @@
1
+ """tokenmeter.wrap(client) -- OpenAI (chat/completions + responses) and Anthropic
2
+ (messages), streaming and non-streaming, sync and async.
3
+
4
+ Design contract (non-negotiable, tested):
5
+ - the SDK never throws into host code: only OUR bookkeeping is try/except'd;
6
+ the real provider call and its real exceptions pass through untouched.
7
+ - the SDK never adds latency: no network I/O on this path, ever -- capture is
8
+ post-hoc from the response object, emit() is an in-memory append.
9
+
10
+ Detection is duck-typed (no `import openai` / `import anthropic` at module load
11
+ time) so wrap() works against whichever SDK the host app already has installed,
12
+ without tokenmeter declaring a hard dependency on either.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import inspect
17
+ import logging
18
+ import time
19
+ from datetime import datetime, timezone
20
+ from typing import Any, Callable, Optional
21
+
22
+ import json as _json
23
+
24
+ from ._client import get_state
25
+ from ._preflight import active_preflight_id
26
+ from ._context import current
27
+ from ._ids import uuid7_str
28
+ from ._optimization import optimization_tags, enrich_tags
29
+ from ._normalize import (
30
+ normalize_anthropic_usage,
31
+ normalize_azure_openai_usage,
32
+ normalize_bedrock_converse_usage,
33
+ normalize_bedrock_invoke_model,
34
+ normalize_gemini_usage,
35
+ normalize_openai_usage,
36
+ )
37
+
38
+ logger = logging.getLogger("tokenmeter")
39
+
40
+ _ZERO_USAGE = {
41
+ "input_tokens": 0,
42
+ "output_tokens": 0,
43
+ "cache_read_tokens": 0,
44
+ "cache_write_tokens": 0,
45
+ "reasoning_tokens": 0,
46
+ }
47
+ _NULL_USAGE = {
48
+ "input_tokens": None,
49
+ "output_tokens": None,
50
+ "cache_read_tokens": None,
51
+ "cache_write_tokens": None,
52
+ "reasoning_tokens": None,
53
+ }
54
+
55
+
56
+ def _to_dict(obj: Any) -> Optional[dict]:
57
+ if obj is None:
58
+ return None
59
+ if isinstance(obj, dict):
60
+ return obj
61
+ if hasattr(obj, "model_dump"):
62
+ return obj.model_dump()
63
+ if hasattr(obj, "dict"):
64
+ return obj.dict()
65
+ try:
66
+ return {k: v for k, v in vars(obj).items() if not k.startswith("_")}
67
+ except TypeError:
68
+ return None
69
+
70
+
71
+ def _safe(fn: Callable, *a, **kw) -> None:
72
+ try:
73
+ fn(*a, **kw)
74
+ except Exception:
75
+ logger.debug("tokenmeter: instrumentation error (ignored)", exc_info=True)
76
+
77
+
78
+ def _emit(event: dict) -> None:
79
+ state = get_state()
80
+ if state is None:
81
+ return
82
+ state.emit(event)
83
+
84
+
85
+ def _snapshot_optimization(params: dict, ctx) -> dict:
86
+ try:
87
+ state = get_state()
88
+ if state is None:
89
+ return {}
90
+ options = {**state.optimization, **(ctx.optimization or {} if ctx else {})}
91
+ return optimization_tags(state.transport.ingest_key, params, options)
92
+ except Exception:
93
+ return {}
94
+
95
+
96
+ def _build_event(*, provider: str, model_raw: Optional[str], kwargs: dict, latency_ms: int,
97
+ usage_complete: bool, norm: dict, error_code: Optional[str] = None,
98
+ ctx: Any = "_unset", model_map: Optional[dict] = None, otel: Any = "_unset",
99
+ optimization: Optional[dict] = None) -> dict:
100
+ # ctx is captured by the CALLER at the moment the provider call was made
101
+ # (see _wrap_method) and threaded through, rather than re-read here via
102
+ # current(). Streaming responses are generators: current() read lazily
103
+ # inside a generator's `finally` block reflects whatever context is
104
+ # active at CONSUMPTION time, not at creation time (generators do not
105
+ # snapshot contextvars the way asyncio.Task does) -- a stream created
106
+ # inside action() and handed off to be iterated later (e.g. a framework's
107
+ # StreamingResponse) would otherwise silently lose all attribution. The
108
+ # non-streaming path still defaults to current() since it emits
109
+ # synchronously, in the same context the call was made in.
110
+ if ctx == "_unset":
111
+ ctx = current()
112
+ if provider == "bedrock" and kwargs.get("model"):
113
+ # Anthropic's Bedrock client: the request's `model` is the Bedrock
114
+ # modelId / inference profile that Cost Explorer bills; the response
115
+ # may carry Anthropic's own model name instead.
116
+ model_raw = kwargs["model"]
117
+ model_raw = model_raw or kwargs.get("model") or kwargs.get("modelId")
118
+ if model_map:
119
+ # Azure returns the deployment name, not the canonical model id, on
120
+ # some APIs (spike §2.2) -- resolve it here so §7.1's model_id
121
+ # aliasing/pricing catalog lookup still works. Unmapped deployments
122
+ # pass through unresolved and land in the unpriced quarantine, which
123
+ # already exists for exactly this case.
124
+ model_raw = model_map.get(model_raw, model_raw)
125
+ event = {
126
+ "event_id": uuid7_str(),
127
+ # Required by ingest's validateWireEvent (missing => quarantined as
128
+ # invalid_occurred_at). ISO 8601 UTC at capture time, same as the
129
+ # TypeScript SDK's capture.ts.
130
+ "occurred_at": datetime.now(timezone.utc).isoformat(),
131
+ "provider": provider,
132
+ "model_raw": model_raw,
133
+ "input_tokens": norm.get("input_tokens"),
134
+ "output_tokens": norm.get("output_tokens"),
135
+ "cache_read_tokens": norm.get("cache_read_tokens"),
136
+ "cache_write_tokens": norm.get("cache_write_tokens"),
137
+ "reasoning_tokens": norm.get("reasoning_tokens"),
138
+ "is_batch": False,
139
+ "usage_complete": usage_complete,
140
+ "latency_ms": latency_ms,
141
+ "error_code": error_code,
142
+ "trace_id": ctx.trace_id if ctx else None,
143
+ "parent_span_id": ctx.parent_span_id if ctx else None,
144
+ "action": ctx.action if ctx else None,
145
+ "customer_ref": ctx.customer_ref if ctx else None,
146
+ "feature": ctx.feature if ctx else None,
147
+ "team": ctx.team if ctx else None,
148
+ "environment": ctx.environment if ctx else None,
149
+ "project": ctx.project if ctx else None,
150
+ "member_ref": ctx.member_ref if ctx else None,
151
+ "provenance": "sdk",
152
+ # Streams pass the link snapshotted when the call was made (like ctx
153
+ # above); a non-streaming call builds its event in the caller's own
154
+ # context, where reading the active span now gives the same answer.
155
+ **(_otel_link_tags() if otel == "_unset" else otel),
156
+ }
157
+ # governed() scope: stamp the preflight decision so ingest's coverage
158
+ # report counts this call as preflight-protected (_preflight.py).
159
+ preflight_id = active_preflight_id()
160
+ if preflight_id:
161
+ event["tags"] = {**event.get("tags", {}), "preflight_id": preflight_id}
162
+ if usage_complete and error_code is None:
163
+ extra = optimization if optimization is not None else _snapshot_optimization({}, ctx)
164
+ if extra:
165
+ event["tags"] = enrich_tags(event.get("tags", {}), extra)
166
+ return event
167
+
168
+
169
+ def _otel_link_tags() -> dict:
170
+ """The active OpenTelemetry span as tags.otel_trace_id / otel_span_id.
171
+
172
+ When an app runs both this SDK and an OTel GenAI instrumentation, the same
173
+ model call reaches ingest twice; ingest uses this link to keep only one.
174
+ opentelemetry-api is optional -- absent, or no valid span, adds nothing.
175
+ Read when the event is built: exact for non-streaming calls; for a stream
176
+ consumed in another context it may be missing (never wrong: ingest also
177
+ requires matching output tokens before treating two events as one call).
178
+ """
179
+ try:
180
+ from opentelemetry import trace # optional dependency
181
+ sc = trace.get_current_span().get_span_context()
182
+ if not sc.is_valid:
183
+ return {}
184
+ return {"tags": {"otel_trace_id": format(sc.trace_id, "032x"), "otel_span_id": format(sc.span_id, "016x")}}
185
+ except Exception:
186
+ return {}
187
+
188
+
189
+ _NORMALIZERS = {
190
+ "openai": normalize_openai_usage,
191
+ "azure_openai": normalize_azure_openai_usage,
192
+ "anthropic": normalize_anthropic_usage,
193
+ "google": normalize_gemini_usage,
194
+ "bedrock": normalize_bedrock_converse_usage,
195
+ }
196
+
197
+
198
+ def _normalize(provider: str, usage_dict: Optional[dict]) -> dict:
199
+ if usage_dict is None:
200
+ return dict(_NULL_USAGE)
201
+ return _NORMALIZERS[provider](usage_dict)
202
+
203
+
204
+ # --------------------------------------------------------------------------
205
+ # Non-streaming capture
206
+ # --------------------------------------------------------------------------
207
+
208
+ def _emit_success(provider: str, kwargs: dict, response: Any, t0: float, model_map: Optional[dict] = None,
209
+ wire: Optional[str] = None, optimization: Optional[dict] = None, ctx: Any = "_unset") -> None:
210
+ latency_ms = int((time.monotonic() - t0) * 1000)
211
+ model_raw = getattr(response, "model", None)
212
+ usage_dict = _to_dict(getattr(response, "usage", None))
213
+ norm = _normalize(provider, usage_dict)
214
+ _emit(_build_event(provider=wire or provider, model_raw=model_raw, kwargs=kwargs, latency_ms=latency_ms,
215
+ usage_complete=usage_dict is not None, norm=norm, model_map=model_map, optimization=optimization, ctx=ctx))
216
+
217
+
218
+ def _emit_error(provider: str, kwargs: dict, t0: float, exc: BaseException, model_key: str = "model",
219
+ wire: Optional[str] = None) -> None:
220
+ latency_ms = int((time.monotonic() - t0) * 1000)
221
+ error_code = str(getattr(exc, "status_code", None) or type(exc).__name__)
222
+ _emit(_build_event(provider=wire or provider, model_raw=kwargs.get(model_key), kwargs=kwargs, latency_ms=latency_ms,
223
+ usage_complete=True, norm=dict(_ZERO_USAGE), error_code=error_code))
224
+
225
+
226
+ # --------------------------------------------------------------------------
227
+ # Streaming capture: usage lives in a terminal chunk/event, not every chunk.
228
+ # If the iterator raises or ends without ever seeing a usage frame, the event
229
+ # is recorded usage_complete=False with NULL tokens -- never guessed from
230
+ # partial deltas.
231
+ # --------------------------------------------------------------------------
232
+
233
+ _ExtractUsage = Callable[[Any], Optional[dict]]
234
+
235
+ _STREAM_EXTRACTORS: dict = {
236
+ "openai_chat": lambda chunk: _to_dict(getattr(chunk, "usage", None)),
237
+ "openai_responses": lambda ev: (
238
+ _to_dict(getattr(ev, "usage", None))
239
+ or _to_dict(getattr(getattr(ev, "response", None), "usage", None))
240
+ ),
241
+ "azure_openai_chat": lambda chunk: _to_dict(getattr(chunk, "usage", None)),
242
+ "azure_openai_responses": lambda ev: (
243
+ _to_dict(getattr(ev, "usage", None))
244
+ or _to_dict(getattr(getattr(ev, "response", None), "usage", None))
245
+ ),
246
+ "anthropic_messages": lambda ev: (
247
+ _to_dict(getattr(ev, "usage", None))
248
+ or _to_dict(getattr(getattr(ev, "message", None), "usage", None))
249
+ ),
250
+ }
251
+
252
+
253
+ def _stream_model_of(chunk: Any) -> Optional[str]:
254
+ return getattr(chunk, "model", None)
255
+
256
+
257
+ def _merge_usage(acc: Optional[dict], new: Optional[dict]) -> Optional[dict]:
258
+ """Per-field last-non-null-wins merge, not a wholesale overwrite.
259
+
260
+ Anthropic's real stream sends input/cache tokens on `message_start` and
261
+ the FINAL output_tokens on a later `message_delta` whose usage dict does
262
+ not repeat input/cache fields at all. Overwriting `acc` wholesale with
263
+ each new non-null usage dict (the earlier bug here) discards
264
+ message_start's input/cache counts the moment message_delta arrives,
265
+ which silently zero-costs every Anthropic streaming call's input tokens.
266
+ Merging field-by-field keeps every counter any frame reported.
267
+ """
268
+ if new is None:
269
+ return acc
270
+ if acc is None:
271
+ return dict(new)
272
+ merged = dict(acc)
273
+ for k, v in new.items():
274
+ if v is not None:
275
+ merged[k] = v
276
+ return merged
277
+
278
+
279
+ def _wrap_sync_stream(orig_iter, provider: str, kind: str, kwargs: dict, t0: float, ctx: Any,
280
+ model_map: Optional[dict] = None, wire: Optional[str] = None, otel: Any = "_unset", optimization: Optional[dict] = None):
281
+ # ctx is snapshotted by the caller at stream-creation time -- see the
282
+ # _build_event docstring note on why it can't be read lazily here.
283
+ extract = _STREAM_EXTRACTORS[f"{provider}_{kind}"]
284
+ final_usage: Optional[dict] = None
285
+ final_model: Optional[str] = None
286
+ errored = False
287
+ try:
288
+ for chunk in orig_iter:
289
+ m = _stream_model_of(chunk)
290
+ if m:
291
+ final_model = m
292
+ u = extract(chunk)
293
+ final_usage = _merge_usage(final_usage, u)
294
+ yield chunk
295
+ except BaseException:
296
+ errored = True
297
+ raise
298
+ finally:
299
+ usage_complete = (final_usage is not None) and not errored
300
+ norm = _normalize(provider, final_usage) if usage_complete else dict(_NULL_USAGE)
301
+ latency_ms = int((time.monotonic() - t0) * 1000)
302
+ _safe(_emit, _build_event(provider=wire or provider, model_raw=final_model, kwargs=kwargs,
303
+ latency_ms=latency_ms, usage_complete=usage_complete, norm=norm,
304
+ ctx=ctx, otel=otel, model_map=model_map, optimization=optimization))
305
+
306
+
307
+ async def _wrap_async_stream(orig_aiter, provider: str, kind: str, kwargs: dict, t0: float, ctx: Any,
308
+ model_map: Optional[dict] = None, wire: Optional[str] = None, otel: Any = "_unset", optimization: Optional[dict] = None):
309
+ # ctx is snapshotted by the caller at stream-creation time -- same reason
310
+ # as _wrap_sync_stream: an async generator does not snapshot contextvars
311
+ # at creation either, only asyncio.Task does.
312
+ extract = _STREAM_EXTRACTORS[f"{provider}_{kind}"]
313
+ final_usage: Optional[dict] = None
314
+ final_model: Optional[str] = None
315
+ errored = False
316
+ try:
317
+ async for chunk in orig_aiter:
318
+ m = _stream_model_of(chunk)
319
+ if m:
320
+ final_model = m
321
+ u = extract(chunk)
322
+ final_usage = _merge_usage(final_usage, u)
323
+ yield chunk
324
+ except BaseException:
325
+ errored = True
326
+ raise
327
+ finally:
328
+ usage_complete = (final_usage is not None) and not errored
329
+ norm = _normalize(provider, final_usage) if usage_complete else dict(_NULL_USAGE)
330
+ latency_ms = int((time.monotonic() - t0) * 1000)
331
+ _safe(_emit, _build_event(provider=wire or provider, model_raw=final_model, kwargs=kwargs,
332
+ latency_ms=latency_ms, usage_complete=usage_complete, norm=norm,
333
+ ctx=ctx, otel=otel, model_map=model_map, optimization=optimization))
334
+
335
+
336
+ # --------------------------------------------------------------------------
337
+ # Method wrapping
338
+ # --------------------------------------------------------------------------
339
+
340
+ def _wrap_method(obj: Any, attr_name: str, provider: str, kind: str, model_map: Optional[dict] = None,
341
+ wire: Optional[str] = None) -> None:
342
+ # `provider` selects the usage shape (normalizer + stream extractor);
343
+ # `wire`, when set, is the provider_kind the event is recorded under.
344
+ # They differ for Anthropic's Bedrock/Vertex clients: Anthropic-shaped
345
+ # usage, but billed -- and reconciled -- as bedrock / google.
346
+ if obj is None or not hasattr(obj, attr_name):
347
+ return
348
+ original = getattr(obj, attr_name)
349
+ if getattr(original, "_tokenmeter_wrapped", False):
350
+ return
351
+
352
+ is_async = inspect.iscoroutinefunction(original)
353
+ # Azure's Chat Completions shape is the openai-python SDK verbatim, same
354
+ # stream_options requirement/restriction as plain OpenAI (spike §2.1).
355
+ injects_stream_options = provider in ("openai", "azure_openai") and kind == "chat"
356
+
357
+ if is_async:
358
+ async def wrapper(*args, **kwargs):
359
+ ctx = current()
360
+ optimization = _snapshot_optimization(kwargs, ctx)
361
+ is_stream = bool(kwargs.get("stream"))
362
+ # Chat Completions needs stream_options to get a terminal usage
363
+ # chunk; the Responses API always sends usage on its final event
364
+ # and has no such parameter -- injecting it there is a TypeError
365
+ # (openai-python declares explicit named params, not **kwargs
366
+ # passthrough), which would turn a working streaming call into a
367
+ # hard failure the instant wrap() is applied.
368
+ if is_stream and injects_stream_options:
369
+ kwargs.setdefault("stream_options", {"include_usage": True})
370
+ t0 = time.monotonic()
371
+ try:
372
+ result = await original(*args, **kwargs)
373
+ except Exception as exc:
374
+ _safe(_emit_error, provider, kwargs, t0, exc, wire=wire)
375
+ raise
376
+ if is_stream:
377
+ # Snapshot context NOW, at call time -- the stream object
378
+ # returned below may be iterated far later/elsewhere, after
379
+ # any action() scope that was open here has already exited.
380
+ try:
381
+ return _wrap_async_stream(result, provider, kind, kwargs, t0, ctx, model_map, wire, _otel_link_tags(), optimization)
382
+ except Exception:
383
+ logger.debug("tokenmeter: failed to wrap async stream", exc_info=True)
384
+ return result
385
+ _safe(_emit_success, provider, kwargs, result, t0, model_map, wire, optimization, ctx)
386
+ return result
387
+
388
+ wrapper._tokenmeter_wrapped = True # type: ignore[attr-defined]
389
+ setattr(obj, attr_name, wrapper)
390
+ else:
391
+ def wrapper(*args, **kwargs):
392
+ ctx = current()
393
+ optimization = _snapshot_optimization(kwargs, ctx)
394
+ is_stream = bool(kwargs.get("stream"))
395
+ # See the async wrapper above: Responses API has no stream_options
396
+ # parameter and openai-python rejects unknown kwargs with a
397
+ # TypeError, so this must stay scoped to chat/completions.
398
+ if is_stream and injects_stream_options:
399
+ kwargs.setdefault("stream_options", {"include_usage": True})
400
+ t0 = time.monotonic()
401
+ try:
402
+ result = original(*args, **kwargs)
403
+ except Exception as exc:
404
+ _safe(_emit_error, provider, kwargs, t0, exc, wire=wire)
405
+ raise
406
+ if is_stream:
407
+ # Snapshot context NOW, at call time -- the stream object
408
+ # returned below may be iterated far later/elsewhere, after
409
+ # any action() scope that was open here has already exited.
410
+ try:
411
+ return _wrap_sync_stream(result, provider, kind, kwargs, t0, ctx, model_map, wire, _otel_link_tags(), optimization)
412
+ except Exception:
413
+ logger.debug("tokenmeter: failed to wrap stream", exc_info=True)
414
+ return result
415
+ _safe(_emit_success, provider, kwargs, result, t0, model_map, wire, optimization, ctx)
416
+ return result
417
+
418
+ wrapper._tokenmeter_wrapped = True # type: ignore[attr-defined]
419
+ setattr(obj, attr_name, wrapper)
420
+
421
+
422
+ def _wrap_openai_or_azure(client: Any, model_map: Optional[dict]) -> None:
423
+ # Azure's `AzureOpenAI`/`AsyncAzureOpenAI` clients ARE the openai-python
424
+ # SDK -- same `.chat.completions.create` / `.responses.create` shape, so
425
+ # detection is by client identity, not structure (spike §2.1). We check
426
+ # both the documented attribute and the class name since neither alone
427
+ # is guaranteed across openai-python versions, and this must stay a duck
428
+ # type (no `import openai` at module load).
429
+ is_azure = hasattr(client, "azure_endpoint") or type(client).__name__ in ("AzureOpenAI", "AsyncAzureOpenAI")
430
+ provider = "azure_openai" if is_azure else "openai"
431
+ azure_map = model_map if is_azure else None
432
+
433
+ chat = getattr(client, "chat", None)
434
+ completions = getattr(chat, "completions", None) if chat is not None else None
435
+ if completions is not None and hasattr(completions, "create"):
436
+ _wrap_method(completions, "create", provider, "chat", azure_map)
437
+
438
+ responses = getattr(client, "responses", None)
439
+ if responses is not None and hasattr(responses, "create"):
440
+ _wrap_method(responses, "create", provider, "responses", azure_map)
441
+
442
+
443
+ _OPENAI_COMPATIBLE_PROVIDERS = frozenset({"xai", "mistral", "fireworks", "together", "deepinfra", "openrouter", "vercel_gateway"})
444
+
445
+
446
+ def wrap_openai_compatible(client: Any, provider: str) -> Any:
447
+ """Wrap an OpenAI-compatible client under its actual billing provider.
448
+
449
+ Configure the upstream client with that provider's base URL, then call
450
+ this instead of :func:`wrap`. The OpenAI usage normalizer is deliberately
451
+ reused because these endpoints expose the Chat Completions / Responses
452
+ usage schema. Calls without provider usage remain incomplete, never
453
+ estimated from request text.
454
+ """
455
+ if provider not in _OPENAI_COMPATIBLE_PROVIDERS:
456
+ supported = ", ".join(sorted(_OPENAI_COMPATIBLE_PROVIDERS))
457
+ raise ValueError(f"provider must be one of: {supported}")
458
+ chat = getattr(client, "chat", None)
459
+ completions = getattr(chat, "completions", None) if chat is not None else None
460
+ if completions is not None and hasattr(completions, "create"):
461
+ _wrap_method(completions, "create", "openai", "chat", wire=provider)
462
+ responses = getattr(client, "responses", None)
463
+ if responses is not None and hasattr(responses, "create"):
464
+ _wrap_method(responses, "create", "openai", "responses", wire=provider)
465
+ return client
466
+
467
+
468
+ def _wrap_anthropic(client: Any) -> None:
469
+ # Anthropic-shaped: has .messages.create, no .chat (distinguishes from
470
+ # both OpenAI and Azure OpenAI, which always have .chat).
471
+ chat = getattr(client, "chat", None)
472
+ messages = getattr(client, "messages", None)
473
+ if messages is not None and hasattr(messages, "create") and chat is None:
474
+ _wrap_method(messages, "create", "anthropic", "messages", wire=_anthropic_wire_provider(client))
475
+
476
+
477
+ def _anthropic_wire_provider(client: Any) -> Optional[str]:
478
+ # anthropic-python's AnthropicBedrock / AnthropicVertex (and their Async
479
+ # variants) share the Messages API shape but are billed by AWS / Google,
480
+ # so their usage must land under bedrock / google or it can never
481
+ # reconcile against that provider's bill.
482
+ name = type(client).__name__
483
+ if "Bedrock" in name:
484
+ return "bedrock"
485
+ if "Vertex" in name:
486
+ return "google"
487
+ return None
488
+
489
+
490
+ # --------------------------------------------------------------------------
491
+ # Gemini (google-genai): client.models.generate_content / .generate_content_stream.
492
+ # Streaming is a SEPARATE method, not a `stream=True` kwarg, so it gets its
493
+ # own wrapper rather than reusing _wrap_method's is_stream branching.
494
+ # ponytail: sync client only (google-genai's async client lives at
495
+ # `client.aio.models`, same methods) -- add if a host needs it.
496
+ # --------------------------------------------------------------------------
497
+
498
+ def _emit_gemini_success(kwargs: dict, response: Any, t0: float) -> None:
499
+ latency_ms = int((time.monotonic() - t0) * 1000)
500
+ model_raw = getattr(response, "model_version", None) or kwargs.get("model")
501
+ usage_dict = _to_dict(getattr(response, "usage_metadata", None))
502
+ norm = normalize_gemini_usage(usage_dict)
503
+ _emit(_build_event(provider="google", model_raw=model_raw, kwargs=kwargs, latency_ms=latency_ms,
504
+ usage_complete=usage_dict is not None, norm=norm))
505
+
506
+
507
+ def _wrap_gemini_stream(orig_iter, kwargs: dict, t0: float, ctx: Any, otel: Any = "_unset"):
508
+ final_usage: Optional[dict] = None
509
+ final_model: Optional[str] = None
510
+ errored = False
511
+ try:
512
+ for chunk in orig_iter:
513
+ m = getattr(chunk, "model_version", None)
514
+ if m:
515
+ final_model = m
516
+ u = _to_dict(getattr(chunk, "usage_metadata", None))
517
+ # Community-observed (spike §3.2, unverified by live call): each
518
+ # chunk's usage_metadata is a complete cumulative snapshot, not a
519
+ # delta -- the LAST chunk is the correct one. Field-merge (rather
520
+ # than wholesale overwrite) is still the safe choice here since
521
+ # it degrades to "last non-null wins" for any chunk that omits a
522
+ # field, without a live probe to confirm cumulative-vs-partial.
523
+ final_usage = _merge_usage(final_usage, u)
524
+ yield chunk
525
+ except BaseException:
526
+ errored = True
527
+ raise
528
+ finally:
529
+ usage_complete = (final_usage is not None) and not errored
530
+ norm = normalize_gemini_usage(final_usage) if usage_complete else dict(_NULL_USAGE)
531
+ latency_ms = int((time.monotonic() - t0) * 1000)
532
+ _safe(_emit, _build_event(provider="google", model_raw=final_model, kwargs=kwargs,
533
+ latency_ms=latency_ms, usage_complete=usage_complete, norm=norm, ctx=ctx, otel=otel))
534
+
535
+
536
+ def _wrap_gemini_method(obj: Any, attr_name: str, streaming: bool) -> None:
537
+ if obj is None or not hasattr(obj, attr_name):
538
+ return
539
+ original = getattr(obj, attr_name)
540
+ if getattr(original, "_tokenmeter_wrapped", False):
541
+ return
542
+
543
+ def wrapper(*args, **kwargs):
544
+ t0 = time.monotonic()
545
+ try:
546
+ result = original(*args, **kwargs)
547
+ except Exception as exc:
548
+ _safe(_emit_error, "google", kwargs, t0, exc)
549
+ raise
550
+ if streaming:
551
+ ctx = current()
552
+ try:
553
+ return _wrap_gemini_stream(result, kwargs, t0, ctx, _otel_link_tags())
554
+ except Exception:
555
+ logger.debug("tokenmeter: failed to wrap gemini stream", exc_info=True)
556
+ return result
557
+ _safe(_emit_gemini_success, kwargs, result, t0)
558
+ return result
559
+
560
+ wrapper._tokenmeter_wrapped = True # type: ignore[attr-defined]
561
+ setattr(obj, attr_name, wrapper)
562
+
563
+
564
+ def _wrap_gemini(client: Any) -> None:
565
+ models = getattr(client, "models", None)
566
+ if models is None or not (hasattr(models, "generate_content") or hasattr(models, "generate_content_stream")):
567
+ return
568
+ if hasattr(models, "generate_content"):
569
+ _wrap_gemini_method(models, "generate_content", streaming=False)
570
+ if hasattr(models, "generate_content_stream"):
571
+ _wrap_gemini_method(models, "generate_content_stream", streaming=True)
572
+
573
+
574
+ # --------------------------------------------------------------------------
575
+ # AWS Bedrock (boto3 `bedrock-runtime`). Responses are plain dicts already
576
+ # (botocore, not a provider SDK object model), so no _to_dict() needed.
577
+ # Converse/ConverseStream is the primary, normalized path (spike §1.1);
578
+ # InvokeModel is per-family (Anthropic + Nova bodies, header fallback for
579
+ # the rest -- spike §1.2). boto3 methods live directly on the client, not
580
+ # nested, so these get their own thin wrapper rather than _wrap_method.
581
+ # ponytail: sync boto3 client only -- aioboto3 is a separate package with a
582
+ # different call shape; add if a host needs it.
583
+ # --------------------------------------------------------------------------
584
+
585
+ class _ReplayableBody:
586
+ """Minimal stand-in for botocore's StreamingBody so a host that calls
587
+ `response['body'].read()` after us still gets the real bytes -- reading
588
+ the body once (in-memory, no extra network hop) to extract usage would
589
+ otherwise leave the host with an already-exhausted stream.
590
+ ponytail: only `.read()`/`.close()` -- add `.iter_lines()` etc. if a host
591
+ needs them; nothing in this codebase's own tests does.
592
+ """
593
+
594
+ def __init__(self, data: bytes) -> None:
595
+ import io
596
+ self._buf = io.BytesIO(data)
597
+
598
+ def read(self, amt: Optional[int] = None) -> bytes:
599
+ return self._buf.read(amt) if amt is not None else self._buf.read()
600
+
601
+ def close(self) -> None:
602
+ pass
603
+
604
+
605
+ def _emit_bedrock_converse_success(kwargs: dict, response: Any, t0: float) -> None:
606
+ latency_ms = int((time.monotonic() - t0) * 1000)
607
+ model_raw = kwargs.get("modelId")
608
+ usage_dict = response.get("usage") if isinstance(response, dict) else None
609
+ norm = normalize_bedrock_converse_usage(usage_dict)
610
+ _emit(_build_event(provider="bedrock", model_raw=model_raw, kwargs=kwargs, latency_ms=latency_ms,
611
+ usage_complete=usage_dict is not None, norm=norm))
612
+
613
+
614
+ def _wrap_bedrock_converse_event_stream(orig_iter, kwargs: dict, t0: float, ctx: Any, model_raw: Optional[str], otel: Any = "_unset"):
615
+ final_usage: Optional[dict] = None
616
+ errored = False
617
+ try:
618
+ for event in orig_iter:
619
+ u = (event.get("metadata") or {}).get("usage") if isinstance(event, dict) else None
620
+ final_usage = _merge_usage(final_usage, u)
621
+ yield event
622
+ except BaseException:
623
+ errored = True
624
+ raise
625
+ finally:
626
+ usage_complete = (final_usage is not None) and not errored
627
+ norm = normalize_bedrock_converse_usage(final_usage) if usage_complete else dict(_NULL_USAGE)
628
+ latency_ms = int((time.monotonic() - t0) * 1000)
629
+ _safe(_emit, _build_event(provider="bedrock", model_raw=model_raw, kwargs=kwargs,
630
+ latency_ms=latency_ms, usage_complete=usage_complete, norm=norm, ctx=ctx, otel=otel))
631
+
632
+
633
+ def _emit_bedrock_invoke_model_success(kwargs: dict, response: Any, t0: float) -> None:
634
+ latency_ms = int((time.monotonic() - t0) * 1000)
635
+ model_raw = kwargs.get("modelId")
636
+ parsed: Optional[dict] = None
637
+ if isinstance(response, dict):
638
+ body_obj = response.get("body")
639
+ if body_obj is not None:
640
+ try:
641
+ raw_bytes = body_obj.read()
642
+ except Exception:
643
+ raw_bytes = b""
644
+ # Replace with a fresh readable stream over the SAME bytes so the
645
+ # host's own `response["body"].read()` still works -- reading it
646
+ # here does not add a network hop (already fully buffered locally
647
+ # by botocore for InvokeModel's single JSON body), only in-memory
648
+ # work the host would have done anyway.
649
+ response["body"] = _ReplayableBody(raw_bytes)
650
+ try:
651
+ parsed = _json.loads(raw_bytes) if raw_bytes else None
652
+ except Exception:
653
+ parsed = None
654
+ headers = ((response.get("ResponseMetadata") or {}).get("HTTPHeaders") or {})
655
+ else:
656
+ headers = {}
657
+ norm, usage_complete = normalize_bedrock_invoke_model(parsed, headers)
658
+ _emit(_build_event(provider="bedrock", model_raw=model_raw, kwargs=kwargs, latency_ms=latency_ms,
659
+ usage_complete=usage_complete, norm=norm))
660
+
661
+
662
+ def _wrap_bedrock_invoke_stream_events(orig_iter, kwargs: dict, t0: float, ctx: Any, model_raw: Optional[str], otel: Any = "_unset"):
663
+ final_metrics: Optional[dict] = None
664
+ errored = False
665
+ try:
666
+ for event in orig_iter:
667
+ try:
668
+ chunk_bytes = ((event or {}).get("chunk") or {}).get("bytes")
669
+ if chunk_bytes:
670
+ payload = _json.loads(chunk_bytes)
671
+ if isinstance(payload, dict) and "amazon-bedrock-invocationMetrics" in payload:
672
+ final_metrics = payload["amazon-bedrock-invocationMetrics"]
673
+ except Exception:
674
+ pass
675
+ yield event
676
+ except BaseException:
677
+ errored = True
678
+ raise
679
+ finally:
680
+ usage_complete = final_metrics is not None and not errored
681
+ if usage_complete:
682
+ norm = {
683
+ "input_tokens": final_metrics.get("inputTokenCount"),
684
+ "output_tokens": final_metrics.get("outputTokenCount"),
685
+ "cache_read_tokens": None,
686
+ "cache_write_tokens": None,
687
+ "reasoning_tokens": None,
688
+ }
689
+ else:
690
+ norm = dict(_NULL_USAGE)
691
+ latency_ms = int((time.monotonic() - t0) * 1000)
692
+ _safe(_emit, _build_event(provider="bedrock", model_raw=model_raw, kwargs=kwargs,
693
+ latency_ms=latency_ms, usage_complete=usage_complete, norm=norm, ctx=ctx, otel=otel))
694
+
695
+
696
+ def _wrap_bedrock_plain_method(client: Any, attr_name: str, on_success: Callable) -> None:
697
+ original = getattr(client, attr_name, None)
698
+ if original is None or getattr(original, "_tokenmeter_wrapped", False):
699
+ return
700
+
701
+ def wrapper(*args, **kwargs):
702
+ t0 = time.monotonic()
703
+ try:
704
+ result = original(*args, **kwargs)
705
+ except Exception as exc:
706
+ _safe(_emit_error, "bedrock", kwargs, t0, exc, model_key="modelId")
707
+ raise
708
+ _safe(on_success, kwargs, result, t0)
709
+ return result
710
+
711
+ wrapper._tokenmeter_wrapped = True # type: ignore[attr-defined]
712
+ setattr(client, attr_name, wrapper)
713
+
714
+
715
+ def _wrap_bedrock_converse_stream_method(client: Any) -> None:
716
+ attr_name = "converse_stream"
717
+ original = getattr(client, attr_name, None)
718
+ if original is None or getattr(original, "_tokenmeter_wrapped", False):
719
+ return
720
+
721
+ def wrapper(*args, **kwargs):
722
+ t0 = time.monotonic()
723
+ try:
724
+ result = original(*args, **kwargs)
725
+ except Exception as exc:
726
+ _safe(_emit_error, "bedrock", kwargs, t0, exc, model_key="modelId")
727
+ raise
728
+ if not isinstance(result, dict) or result.get("stream") is None:
729
+ return result
730
+ ctx = current()
731
+ try:
732
+ model_raw = kwargs.get("modelId")
733
+ result = dict(result)
734
+ result["stream"] = _wrap_bedrock_converse_event_stream(result["stream"], kwargs, t0, ctx, model_raw, _otel_link_tags())
735
+ except Exception:
736
+ logger.debug("tokenmeter: failed to wrap bedrock converse_stream", exc_info=True)
737
+ return result
738
+
739
+ wrapper._tokenmeter_wrapped = True # type: ignore[attr-defined]
740
+ setattr(client, attr_name, wrapper)
741
+
742
+
743
+ def _wrap_bedrock_invoke_stream_method(client: Any) -> None:
744
+ attr_name = "invoke_model_with_response_stream"
745
+ original = getattr(client, attr_name, None)
746
+ if original is None or getattr(original, "_tokenmeter_wrapped", False):
747
+ return
748
+
749
+ def wrapper(*args, **kwargs):
750
+ t0 = time.monotonic()
751
+ try:
752
+ result = original(*args, **kwargs)
753
+ except Exception as exc:
754
+ _safe(_emit_error, "bedrock", kwargs, t0, exc, model_key="modelId")
755
+ raise
756
+ if not isinstance(result, dict) or result.get("body") is None:
757
+ return result
758
+ ctx = current()
759
+ try:
760
+ model_raw = kwargs.get("modelId")
761
+ result = dict(result)
762
+ result["body"] = _wrap_bedrock_invoke_stream_events(result["body"], kwargs, t0, ctx, model_raw, _otel_link_tags())
763
+ except Exception:
764
+ logger.debug("tokenmeter: failed to wrap bedrock invoke_model_with_response_stream", exc_info=True)
765
+ return result
766
+
767
+ wrapper._tokenmeter_wrapped = True # type: ignore[attr-defined]
768
+ setattr(client, attr_name, wrapper)
769
+
770
+
771
+ def _wrap_bedrock(client: Any) -> None:
772
+ # boto3 bedrock-runtime client: a dynamically-generated botocore client,
773
+ # detected structurally by the methods it exposes (no `import boto3`).
774
+ if not any(
775
+ hasattr(client, m) for m in ("converse", "converse_stream", "invoke_model", "invoke_model_with_response_stream")
776
+ ):
777
+ return
778
+ _wrap_bedrock_plain_method(client, "converse", _emit_bedrock_converse_success)
779
+ _wrap_bedrock_converse_stream_method(client)
780
+ _wrap_bedrock_plain_method(client, "invoke_model", _emit_bedrock_invoke_model_success)
781
+ _wrap_bedrock_invoke_stream_method(client)
782
+
783
+
784
+ def wrap(client: Any, *, model_map: Optional[dict] = None) -> Any:
785
+ """Wraps a client instance in place and returns it. Idempotent (re-wrapping
786
+ an already-wrapped client/method is a no-op, checked via a marker attribute).
787
+
788
+ `model_map`: optional `{deployment_name: model_id}` mapping, used only for
789
+ Azure OpenAI clients (spike §2.2 -- Azure's Responses API returns the
790
+ deployment name, not the canonical model id, which breaks §7.1's pricing
791
+ aliasing unless resolved here). Ignored for every other provider.
792
+
793
+ Each provider's detection+wrapping runs in its own try/except so one
794
+ provider's failure (e.g. an unexpected client shape) never prevents the
795
+ others from being wrapped.
796
+ """
797
+ for step in (
798
+ lambda: _wrap_openai_or_azure(client, model_map),
799
+ lambda: _wrap_anthropic(client),
800
+ lambda: _wrap_gemini(client),
801
+ lambda: _wrap_bedrock(client),
802
+ ):
803
+ try:
804
+ step()
805
+ except Exception:
806
+ logger.debug("tokenmeter: wrap() step failed, continuing", exc_info=True)
807
+ return client