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/_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
|