otelzero 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,517 @@
1
+ """Shared plumbing for frameworks whose *own* OpenTelemetry otelzero switches on.
2
+
3
+ Some frameworks already trace themselves in the GenAI semantic conventions:
4
+ an ``invoke_agent {name}`` span per run, with ``chat`` / ``execute_tool`` (and
5
+ framework-specific) spans nested under it. For those, wrapping a method from
6
+ the outside sees less than the framework records, so their hooks wrap nothing.
7
+ Each one configures the framework's native instrumentation and attaches one
8
+ :class:`NativeRunProcessor` to the tracer provider the spans go through.
9
+ The processor does two things:
10
+
11
+ * **Identity.** It stamps otelzero's run identity (``gen_ai.conversation.id``,
12
+ ``user.name`` and ``otelzero.session.name``) on every span the framework
13
+ starts, and on any other instrumentation's span nested inside one of them
14
+ (an HTTP client's span under a model call, say). That way a trace can be
15
+ filtered by session the same way the wrapping hooks' spans can.
16
+ * **Events.** It turns each native ``invoke_agent`` span into the
17
+ ``agent_start`` / ``agent_end`` / ``agent_error`` HTTP events the wrapping
18
+ hooks send, so the dashboard's event feed doesn't change.
19
+
20
+ Frameworks fill in a span at different points. Pydantic AI passes its
21
+ attributes when it starts a span. Strands, Agent Framework and ADK start an
22
+ empty span and set the attributes afterwards, so ``on_start`` sees only the
23
+ span's name. Because of that, a run is recognised by its ``invoke_agent``
24
+ operation *or* its ``invoke_agent {name}`` span name. ``agent_start`` carries
25
+ what is known at that point. ``agent_end`` carries what the run recorded by the
26
+ time it finished: the model, the tools offered, and token usage, taken from the
27
+ run span itself or, where the framework records them only on its model-call
28
+ spans, from those spans.
29
+
30
+ Each framework gets one processor per provider (a provider cannot drop a
31
+ processor). A reinstall retargets the processor at the new client, and an
32
+ uninstall makes it dormant. Processors are scoped by instrumentation-scope
33
+ name, so two native frameworks in one process don't report each other's runs.
34
+ """
35
+
36
+ import json
37
+ import re
38
+ import threading
39
+ from typing import Any, Dict, Iterable, Optional, Tuple
40
+
41
+ from otelzero import otel, semconv
42
+ from otelzero.telemetry import TelemetryClient
43
+
44
+ try:
45
+ from opentelemetry.sdk.trace import SpanProcessor as _SpanProcessor
46
+ except ImportError: # pragma: no cover - 'otel' extra absent; install raises
47
+ _SpanProcessor = object # type: ignore[assignment,misc]
48
+
49
+ # Model-call operations whose spans may carry the model, tools and usage when
50
+ # the agent span itself does not (ADK records them only there).
51
+ _MODEL_CALL_OPERATIONS = frozenset(
52
+ {semconv.OP_CHAT, semconv.OP_TEXT_COMPLETION, semconv.OP_GENERATE_CONTENT}
53
+ )
54
+
55
+ # Where frameworks put the model on a span: the convention's attribute, then
56
+ # Pydantic AI's own ``model_name`` (set on its agent span at start).
57
+ _MODEL_KEYS = (semconv.GEN_AI_REQUEST_MODEL, semconv.GEN_AI_RESPONSE_MODEL, "model_name")
58
+
59
+ # Token usage: Pydantic AI's per-run totals live under its own
60
+ # ``gen_ai.aggregated_usage.*`` namespace; everyone else uses ``gen_ai.usage.*``.
61
+ _USAGE_PREFIXES = ("gen_ai.aggregated_usage.", "gen_ai.usage.")
62
+ _USAGE_KEYS = {
63
+ "input_tokens": "input_tokens",
64
+ "output_tokens": "output_tokens",
65
+ "reasoning_tokens": "reasoning.output_tokens",
66
+ "cache_read_tokens": "cache_read.input_tokens",
67
+ }
68
+
69
+ # Strands lists the agent's tool *names* here (a JSON array) on every agent
70
+ # span, whereas ``gen_ai.tool.definitions`` is opt-in there.
71
+ _AGENT_TOOLS = "gen_ai.agent.tools"
72
+
73
+ _SPAN_NAME = re.compile(r"^invoke_agent(?:\s+(?P<name>.+))?$")
74
+
75
+
76
+ def marker(framework: str) -> str:
77
+ """The provider attribute holding *framework*'s processor."""
78
+ return "_otelzero_native_" + re.sub(r"\W+", "_", framework.lower()).strip("_")
79
+
80
+
81
+ def usage_from(attributes: Any) -> Optional[dict]:
82
+ """otelzero's normalized usage dict from a span's attributes, or ``None``.
83
+
84
+ Same per-field shape :mod:`otelzero.hooks._usage` produces for the
85
+ wrapping hooks.
86
+ """
87
+ for prefix in _USAGE_PREFIXES:
88
+ usage = {key: attributes.get(prefix + attr) for key, attr in _USAGE_KEYS.items()}
89
+ if usage["input_tokens"] is None and usage["output_tokens"] is None:
90
+ continue
91
+ usage["input_tokens"] = usage["input_tokens"] or 0
92
+ usage["output_tokens"] = usage["output_tokens"] or 0
93
+ usage["total_tokens"] = usage["input_tokens"] + usage["output_tokens"]
94
+ return usage
95
+ return None
96
+
97
+
98
+ def _add_usage(total: Optional[dict], more: Optional[dict]) -> Optional[dict]:
99
+ if more is None:
100
+ return total
101
+ if total is None:
102
+ return dict(more)
103
+ for key, value in more.items():
104
+ if value is not None:
105
+ total[key] = (total.get(key) or 0) + value
106
+ return total
107
+
108
+
109
+ def tool_names(attributes: Any) -> list:
110
+ """Tool names a span says the agent was offered.
111
+
112
+ Read from ``gen_ai.tool.definitions`` (a JSON array of definitions) and
113
+ from Strands' ``gen_ai.agent.tools`` (a JSON array of names).
114
+ """
115
+ names = []
116
+ for key in (semconv.GEN_AI_TOOL_DEFINITIONS, _AGENT_TOOLS):
117
+ raw = attributes.get(key)
118
+ if not raw:
119
+ continue
120
+ try:
121
+ entries = json.loads(raw) if isinstance(raw, str) else list(raw)
122
+ except (TypeError, ValueError):
123
+ continue
124
+ if not isinstance(entries, list):
125
+ continue
126
+ for entry in entries:
127
+ if isinstance(entry, str) and entry:
128
+ names.append(entry)
129
+ elif isinstance(entry, dict) and entry.get("name"):
130
+ names.append(entry["name"])
131
+ return names
132
+
133
+
134
+ def model_from(attributes: Any) -> Optional[str]:
135
+ for key in _MODEL_KEYS:
136
+ value = attributes.get(key)
137
+ if isinstance(value, str) and value:
138
+ return value
139
+ return None
140
+
141
+
142
+ def exception_of(span: Any) -> Tuple[str, Optional[str]]:
143
+ """``(message, traceback)`` from the span's last ``exception`` event.
144
+
145
+ A framework that records only the exception type (Pydantic AI or Agent
146
+ Framework without content capture) gets the type as the message.
147
+ """
148
+ for event in reversed(span.events or ()):
149
+ if event.name == "exception":
150
+ attrs = event.attributes or {}
151
+ message = attrs.get("exception.message") or attrs.get("exception.type")
152
+ return message, attrs.get("exception.stacktrace")
153
+ # Agent Framework records neither the event nor a description without
154
+ # sensitive data enabled, only the convention's ``error.type``.
155
+ description = getattr(span.status, "description", None)
156
+ error_type = (span.attributes or {}).get(semconv.ERROR_TYPE)
157
+ return description or error_type or "Agent run failed", None
158
+
159
+
160
+ def _run_agent_name(span: Any, attributes: Any) -> Optional[str]:
161
+ """The agent's name when *span* is an agent run, else ``None``.
162
+
163
+ A span counts as a run if its ``gen_ai.operation.name`` says
164
+ ``invoke_agent``, or (for frameworks that set attributes only after the
165
+ span starts) if its name is ``invoke_agent`` / ``invoke_agent {agent}``.
166
+ The name then comes from ``gen_ai.agent.name`` or, failing that, from the
167
+ span name. An empty string means it's a run but has no name.
168
+ """
169
+ operation = attributes.get(semconv.GEN_AI_OPERATION_NAME)
170
+ match = _SPAN_NAME.match(span.name or "")
171
+ if operation is not None and operation != semconv.OP_INVOKE_AGENT:
172
+ return None
173
+ if operation is None and match is None:
174
+ return None
175
+ return attributes.get(semconv.GEN_AI_AGENT_NAME) or (
176
+ match.group("name") if match and match.group("name") else ""
177
+ )
178
+
179
+
180
+ class NativeRunProcessor(_SpanProcessor):
181
+ """Run identity on native spans; lifecycle events from ``invoke_agent``.
182
+
183
+ Args:
184
+ telemetry: The client events go out on (``None`` = dormant).
185
+ framework: Label for log messages.
186
+ scopes: Instrumentation-scope name prefixes this framework's spans
187
+ use. Spans from other scopes are only touched when they are
188
+ nested inside one of this framework's spans.
189
+ default_agent_name: Name for a run whose span names no agent.
190
+ send_events: Derive ``agent_*`` events from ``invoke_agent`` spans.
191
+ Off for LangSmith, whose spans arrive in batches after the fact;
192
+ its hook sends the events from LangChain's callbacks instead.
193
+ native_conversation_id: Keep the framework's own
194
+ ``gen_ai.conversation.id`` instead of replacing it with otelzero's
195
+ session, and file the run's events under it too. Use this for a
196
+ framework whose conversation id is a real, caller-chosen session
197
+ (ADK) and which writes that id after a span starts, past the point
198
+ where the processor could replace it.
199
+ """
200
+
201
+ def __init__(
202
+ self,
203
+ telemetry: Optional[TelemetryClient],
204
+ *,
205
+ framework: str,
206
+ scopes: Iterable[str],
207
+ default_agent_name: str,
208
+ native_conversation_id: bool = False,
209
+ send_events: bool = True,
210
+ ):
211
+ self.telemetry = telemetry
212
+ self.send_events = send_events
213
+ self.framework = framework
214
+ self.scopes = tuple(scopes)
215
+ self.default_agent_name = default_agent_name
216
+ self.native_conversation_id = native_conversation_id
217
+ # span id -> id of the run span it belongs to (None: one of ours, but
218
+ # not inside a run -- an ADK workflow span, say).
219
+ self._owner: Dict[int, Optional[int]] = {}
220
+ # run span id -> what the run's spans revealed so far.
221
+ self._runs: Dict[int, dict] = {}
222
+ # trace id -> the framework's own conversation id, when adopted, and
223
+ # how many of the trace's tracked spans are still open (the entry goes
224
+ # when the last one ends, whatever span the trace's root is).
225
+ self._conversations: Dict[int, str] = {}
226
+ self._open_in_trace: Dict[int, int] = {}
227
+ self._lock = threading.Lock()
228
+
229
+ # -- helpers ------------------------------------------------------------
230
+
231
+ def _is_ours(self, span: Any) -> bool:
232
+ scope = getattr(span, "instrumentation_scope", None)
233
+ name = getattr(scope, "name", None) or ""
234
+ return any(name.startswith(prefix) for prefix in self.scopes)
235
+
236
+ def _send(self, telemetry: TelemetryClient, run: dict, **fields: Any) -> None:
237
+ if run.get("conversation_id"):
238
+ fields["session"] = run["conversation_id"]
239
+ telemetry.send_log(**fields)
240
+
241
+ # -- SpanProcessor ------------------------------------------------------
242
+
243
+ def on_start(self, span: Any, parent_context: Any = None) -> None:
244
+ telemetry = self.telemetry
245
+ if telemetry is None:
246
+ return
247
+ span_id = span.context.span_id
248
+ parent_id = span.parent.span_id if span.parent else None
249
+ with self._lock:
250
+ inherited = parent_id in self._owner
251
+ parent_run = self._owner.get(parent_id) if inherited else None
252
+ ours = self._is_ours(span)
253
+ if not (inherited or ours):
254
+ return
255
+
256
+ attributes = span.attributes or {}
257
+ trace_id = span.context.trace_id
258
+ native_conversation = None
259
+ if self.native_conversation_id:
260
+ native_conversation = attributes.get(semconv.GEN_AI_CONVERSATION_ID)
261
+ with self._lock:
262
+ if native_conversation:
263
+ self._conversations.setdefault(trace_id, native_conversation)
264
+ native_conversation = self._conversations.get(trace_id)
265
+
266
+ user, session, session_name = telemetry.run_identity()
267
+ if self.native_conversation_id:
268
+ # Spans the framework leaves without one (ADK's execute_tool) get
269
+ # the trace's id, so every span still filters by session.
270
+ if native_conversation and not attributes.get(semconv.GEN_AI_CONVERSATION_ID):
271
+ span.set_attribute(semconv.GEN_AI_CONVERSATION_ID, native_conversation)
272
+ elif session:
273
+ span.set_attribute(semconv.GEN_AI_CONVERSATION_ID, session)
274
+ if session_name:
275
+ span.set_attribute(semconv.OTELZERO_SESSION_NAME, session_name)
276
+ if user:
277
+ span.set_attribute(semconv.USER_NAME, user)
278
+
279
+ with self._lock:
280
+ self._open_in_trace[trace_id] = self._open_in_trace.get(trace_id, 0) + 1
281
+ agent_name = _run_agent_name(span, attributes) if ours and self.send_events else None
282
+ if agent_name is None:
283
+ with self._lock:
284
+ self._owner[span_id] = parent_run
285
+ return
286
+
287
+ agent_name = agent_name or self.default_agent_name
288
+ model = model_from(attributes)
289
+ run = {
290
+ "agent_name": agent_name,
291
+ "model": model,
292
+ "tools": set(tool_names(attributes)),
293
+ "child_model": None,
294
+ "child_usage": None,
295
+ "conversation_id": native_conversation,
296
+ }
297
+ with self._lock:
298
+ self._owner[span_id] = span_id
299
+ self._runs[span_id] = run
300
+ self._send(
301
+ telemetry,
302
+ run,
303
+ event_type="agent_start",
304
+ agent_name=agent_name,
305
+ agent_role=agent_name,
306
+ model=model,
307
+ details={"model": model},
308
+ )
309
+
310
+ def on_end(self, span: Any) -> None:
311
+ span_id = span.context.span_id
312
+ with self._lock:
313
+ if span_id not in self._owner:
314
+ return
315
+ owner = self._owner.pop(span_id)
316
+ run = self._runs.pop(span_id, None) if owner == span_id else None
317
+ trace_id = span.context.trace_id
318
+ remaining = self._open_in_trace.get(trace_id, 1) - 1
319
+ if remaining > 0:
320
+ self._open_in_trace[trace_id] = remaining
321
+ else:
322
+ self._open_in_trace.pop(trace_id, None)
323
+ self._conversations.pop(trace_id, None)
324
+ attributes = span.attributes or {}
325
+
326
+ if run is None:
327
+ # A span inside a run: harvest what the agent span may not carry.
328
+ if owner is None:
329
+ return
330
+ if attributes.get(semconv.GEN_AI_OPERATION_NAME) not in _MODEL_CALL_OPERATIONS:
331
+ return
332
+ with self._lock:
333
+ parent_run = self._runs.get(owner)
334
+ if parent_run is None:
335
+ return
336
+ parent_run["tools"].update(tool_names(attributes))
337
+ parent_run["child_model"] = parent_run["child_model"] or model_from(attributes)
338
+ parent_run["child_usage"] = _add_usage(
339
+ parent_run["child_usage"], usage_from(attributes)
340
+ )
341
+ return
342
+
343
+ telemetry = self.telemetry
344
+ if telemetry is None:
345
+ return
346
+ run["tools"].update(tool_names(attributes))
347
+ model = model_from(attributes) or run["child_model"] or run["model"]
348
+ usage = usage_from(attributes) or run["child_usage"]
349
+ fields = {
350
+ "agent_name": run["agent_name"],
351
+ "agent_role": run["agent_name"],
352
+ "model": model,
353
+ }
354
+ if not span.status.is_ok:
355
+ error, traceback = exception_of(span)
356
+ self._send(
357
+ telemetry,
358
+ run,
359
+ event_type="agent_error",
360
+ error=error,
361
+ traceback=traceback,
362
+ details={"usage": usage} if usage else None,
363
+ **fields,
364
+ )
365
+ return
366
+ details: dict = {"status": "success"}
367
+ if run["tools"]:
368
+ details["tools"] = sorted(run["tools"])
369
+ if usage:
370
+ details["usage"] = usage
371
+ self._send(telemetry, run, event_type="agent_end", details=details, **fields)
372
+
373
+
374
+ def attach_processor(
375
+ provider: Any,
376
+ telemetry: TelemetryClient,
377
+ *,
378
+ framework: str,
379
+ scopes: Iterable[str],
380
+ default_agent_name: str,
381
+ native_conversation_id: bool = False,
382
+ send_events: bool = True,
383
+ key: Optional[str] = None,
384
+ ) -> NativeRunProcessor:
385
+ """Attach (or retarget) *framework*'s processor on *provider*.
386
+
387
+ *key* names the processor slot when several install functions share one
388
+ processor (LangChain and LangGraph both report LangSmith's spans).
389
+ """
390
+ key = marker(key or framework)
391
+ existing = getattr(provider, key, None)
392
+ if existing is not None:
393
+ existing.telemetry = telemetry
394
+ existing.framework = framework
395
+ return existing
396
+ processor = NativeRunProcessor(
397
+ telemetry,
398
+ framework=framework,
399
+ scopes=scopes,
400
+ default_agent_name=default_agent_name,
401
+ native_conversation_id=native_conversation_id,
402
+ send_events=send_events,
403
+ )
404
+ provider.add_span_processor(processor)
405
+ setattr(provider, key, processor)
406
+ return processor
407
+
408
+
409
+ def start_tracing(framework: str, telemetry: TelemetryClient, server_url: str) -> Any:
410
+ """Force otelzero's OTLP provider on; raise if the SDK is missing.
411
+
412
+ Native instrumentation is the only channel these hooks report through, so
413
+ ``OTELZERO_OTEL=0`` cannot switch them off.
414
+ """
415
+ provider = otel.init_tracing(
416
+ server_url,
417
+ service_name=framework,
418
+ service_version=telemetry.framework_version,
419
+ api_key=telemetry.api_key,
420
+ force=True,
421
+ )
422
+ if provider is None:
423
+ raise RuntimeError(
424
+ f"{framework} monitoring reports only over OpenTelemetry; install the "
425
+ "'otel' extra: uv pip install 'otelzero[otel]'"
426
+ )
427
+ return provider
428
+
429
+
430
+ def global_provider(provider: Any, framework: str) -> Any:
431
+ """The provider a framework that traces through the *global* one will use.
432
+
433
+ Normally that's otelzero's own provider. If the application installed its
434
+ own SDK provider first, OpenTelemetry keeps that one global, the
435
+ framework's spans go there, and the processor has to be attached there to
436
+ see them. The spans then export to the application's exporters, not to the
437
+ registry. The events still reach it, which a warning points out.
438
+ """
439
+ from opentelemetry import trace
440
+
441
+ current = trace.get_tracer_provider()
442
+ if current is provider or not hasattr(current, "add_span_processor"):
443
+ return provider
444
+ print(
445
+ f"[MONITOR] {framework}: a global OpenTelemetry tracer provider was "
446
+ "already configured, so the framework's spans export through it rather "
447
+ "than to the registry; otelzero's agent events are unaffected."
448
+ )
449
+ return current
450
+
451
+
452
+ def detach(framework: str, provider: Any) -> None:
453
+ """Make *framework*'s processor on *provider* dormant (no-op if absent)."""
454
+ processor = getattr(provider, marker(framework), None) if provider else None
455
+ if processor is not None:
456
+ processor.telemetry = None
457
+
458
+
459
+ _OPT_IN_ENV = "OTEL_SEMCONV_STABILITY_OPT_IN"
460
+
461
+ # token -> frameworks currently relying on it. Tokens the user had set before
462
+ # any hook added them are never in here, so they are never removed.
463
+ _opt_in_users: Dict[str, set] = {}
464
+
465
+
466
+ def _read_opt_in() -> list:
467
+ import os
468
+
469
+ return [t.strip() for t in os.environ.get(_OPT_IN_ENV, "").split(",") if t.strip()]
470
+
471
+
472
+ def _write_opt_in(tokens: list) -> None:
473
+ import os
474
+
475
+ if tokens:
476
+ os.environ[_OPT_IN_ENV] = ",".join(tokens)
477
+ else:
478
+ os.environ.pop(_OPT_IN_ENV, None)
479
+
480
+
481
+ def opt_in_tokens(
482
+ framework: str, tokens: Iterable[str], *, skip_prefixes: Iterable[str] = ()
483
+ ) -> None:
484
+ """Add *tokens* to ``OTEL_SEMCONV_STABILITY_OPT_IN`` on *framework*'s behalf.
485
+
486
+ Tokens already present stay as they are. A token is also skipped when the
487
+ variable already holds an entry starting with one of *skip_prefixes*, so a
488
+ choice the user made explicitly is kept. Each added token is
489
+ reference-counted per framework, so :func:`release_opt_in` removes only what
490
+ no installed hook still needs, and never a token the user set.
491
+ """
492
+ current = _read_opt_in()
493
+ skip = tuple(skip_prefixes)
494
+ for token in tokens:
495
+ users = _opt_in_users.get(token)
496
+ if users is not None:
497
+ users.add(framework)
498
+ continue
499
+ if token in current:
500
+ continue # the user's own token
501
+ if any(t.startswith(p) for t in current for p in skip if token.startswith(p)):
502
+ continue
503
+ current.append(token)
504
+ _opt_in_users[token] = {framework}
505
+ _write_opt_in(current)
506
+
507
+
508
+ def release_opt_in(framework: str) -> None:
509
+ """Drop *framework*'s claim on the tokens it added; remove the orphans."""
510
+ current = _read_opt_in()
511
+ for token, users in list(_opt_in_users.items()):
512
+ users.discard(framework)
513
+ if not users:
514
+ del _opt_in_users[token]
515
+ if token in current:
516
+ current.remove(token)
517
+ _write_opt_in(current)
@@ -0,0 +1,166 @@
1
+ """Normalized LLM token-usage extraction shared by all framework hooks.
2
+
3
+ Every framework reports token usage under its own spelling (Anthropic's
4
+ ``input_tokens``, OpenAI's ``prompt_tokens``, Gemini's ``prompt_token_count``,
5
+ Bedrock's ``inputTokens``, ...). :func:`extract_usage` folds any of them into
6
+ one normalized dict that the hooks attach to their terminal telemetry events::
7
+
8
+ {
9
+ "input_tokens": int, # 0 if only output known
10
+ "output_tokens": int,
11
+ "total_tokens": int, # reported total, else input + output
12
+ "reasoning_tokens": int | None, # None when the provider doesn't report
13
+ "cache_read_tokens": int | None,
14
+ }
15
+
16
+ All reads are dict-or-attr based and coercion-tolerant, so a framework version
17
+ drift degrades to "no usage" — never a crash in the wrapper.
18
+ """
19
+
20
+ from typing import Any, Optional
21
+
22
+
23
+ def _field(obj: Any, key: str) -> Any:
24
+ """Read ``key`` off a dict or an object attribute; ``None`` when absent."""
25
+ if isinstance(obj, dict):
26
+ return obj.get(key)
27
+ return getattr(obj, key, None)
28
+
29
+
30
+ def _int(value: Any) -> Optional[int]:
31
+ """Coerce to int, tolerating floats/strings; ``None`` on bools or junk."""
32
+ if value is None or isinstance(value, bool):
33
+ return None
34
+ try:
35
+ return int(value)
36
+ except (TypeError, ValueError):
37
+ return None
38
+
39
+
40
+ def _first(obj: Any, *keys: str) -> Optional[int]:
41
+ """First key of ``keys`` that reads as an int off ``obj``."""
42
+ for key in keys:
43
+ val = _int(_field(obj, key))
44
+ if val is not None:
45
+ return val
46
+ return None
47
+
48
+
49
+ def _nested(obj: Any, outer_keys, inner_keys) -> Optional[int]:
50
+ """First ``outer.inner`` int found across the given key spellings."""
51
+ for outer in outer_keys:
52
+ detail = _field(obj, outer)
53
+ if detail is not None:
54
+ val = _first(detail, *inner_keys)
55
+ if val is not None:
56
+ return val
57
+ return None
58
+
59
+
60
+ # Alias tables covering every framework this repo instruments:
61
+ # Anthropic / Claude SDK: input_tokens / output_tokens / cache_read_input_tokens
62
+ # OpenAI chat: prompt_tokens / completion_tokens
63
+ # (+ completion_tokens_details.reasoning_tokens, prompt_tokens_details.cached_tokens)
64
+ # OpenAI Responses / MLflow ResponsesAgent: input_tokens / output_tokens
65
+ # (+ output_tokens_details.reasoning_tokens, input_tokens_details.cached_tokens)
66
+ # Google GenAI / ADK: prompt_token_count / candidates_token_count /
67
+ # thoughts_token_count / cached_content_token_count / total_token_count
68
+ # Microsoft agent_framework: input_token_count / output_token_count / total_token_count
69
+ # Bedrock / Strands: inputTokens / outputTokens / totalTokens / cacheReadInputTokens
70
+ # LangChain / LangGraph usage_metadata: input_tokens / output_tokens /
71
+ # total_tokens (+ output_token_details.reasoning, input_token_details.cache_read)
72
+ # CrewAI UsageMetrics: prompt_tokens / completion_tokens / cached_prompt_tokens
73
+ # OpenAI Agents SDK Usage: input_tokens / output_tokens / total_tokens
74
+ # Pydantic AI RunUsage: input_tokens / output_tokens / cache_read_tokens
75
+ # smolagents TokenUsage: input_tokens / output_tokens / total_tokens
76
+ _INPUT = (
77
+ "input_tokens",
78
+ "prompt_tokens",
79
+ "prompt_token_count",
80
+ "input_token_count",
81
+ "inputTokens",
82
+ )
83
+ _OUTPUT = (
84
+ "output_tokens",
85
+ "completion_tokens",
86
+ "candidates_token_count",
87
+ "output_token_count",
88
+ "outputTokens",
89
+ )
90
+ _TOTAL = ("total_tokens", "total_token_count", "totalTokens")
91
+ _REASONING_FLAT = ("thoughts_token_count", "reasoning_tokens")
92
+ _REASONING_NESTED = (
93
+ ("output_token_details", "output_tokens_details", "completion_tokens_details"),
94
+ ("reasoning", "reasoning_tokens"),
95
+ )
96
+ _CACHE_FLAT = (
97
+ "cache_read_input_tokens",
98
+ "cached_prompt_tokens",
99
+ "cached_content_token_count",
100
+ "cacheReadInputTokens",
101
+ # Pydantic AI's RunUsage spelling. It is also the key this module's own
102
+ # output uses, which makes extract_usage idempotent on a normalized dict.
103
+ "cache_read_tokens",
104
+ )
105
+ _CACHE_NESTED = (
106
+ ("input_token_details", "input_tokens_details", "prompt_tokens_details"),
107
+ ("cache_read", "cached_tokens"),
108
+ )
109
+
110
+
111
+ def extract_usage(raw: Any) -> Optional[dict]:
112
+ """Normalize a framework usage object/dict; ``None`` when unrecognizable."""
113
+ if raw is None:
114
+ return None
115
+ input_t = _first(raw, *_INPUT)
116
+ output_t = _first(raw, *_OUTPUT)
117
+ if input_t is None and output_t is None:
118
+ return None
119
+ reasoning = _first(raw, *_REASONING_FLAT)
120
+ if reasoning is None:
121
+ reasoning = _nested(raw, *_REASONING_NESTED)
122
+ cache_read = _first(raw, *_CACHE_FLAT)
123
+ if cache_read is None:
124
+ cache_read = _nested(raw, *_CACHE_NESTED)
125
+ input_t, output_t = input_t or 0, output_t or 0
126
+ total = _first(raw, *_TOTAL)
127
+ return {
128
+ "input_tokens": input_t,
129
+ "output_tokens": output_t,
130
+ "total_tokens": total if total is not None else input_t + output_t,
131
+ "reasoning_tokens": reasoning,
132
+ "cache_read_tokens": cache_read,
133
+ }
134
+
135
+
136
+ class UsageAccumulator:
137
+ """Sum normalized usage across streamed messages/events.
138
+
139
+ Each streamed event's usage is treated as per-LLM-call and summed; if a
140
+ framework ever repeats one call's usage across partial events the sum
141
+ overcounts (accepted v1 tradeoff). ``result()`` is ``None`` until at least
142
+ one recognizable usage was added, so hooks never report fabricated zeros.
143
+ """
144
+
145
+ def __init__(self):
146
+ self._seen = False
147
+ self._totals = {
148
+ "input_tokens": 0,
149
+ "output_tokens": 0,
150
+ "total_tokens": 0,
151
+ "reasoning_tokens": None,
152
+ "cache_read_tokens": None,
153
+ }
154
+
155
+ def add(self, usage: Optional[dict]) -> None:
156
+ if not usage:
157
+ return
158
+ self._seen = True
159
+ for key in ("input_tokens", "output_tokens", "total_tokens"):
160
+ self._totals[key] += usage.get(key) or 0
161
+ for key in ("reasoning_tokens", "cache_read_tokens"):
162
+ if usage.get(key) is not None:
163
+ self._totals[key] = (self._totals[key] or 0) + usage[key]
164
+
165
+ def result(self) -> Optional[dict]:
166
+ return dict(self._totals) if self._seen else None