class ExtractorUsage(BaseModel):
"""Raw provider usage objects grouped by extraction step."""
model_config = ConfigDict(arbitrary_types_allowed=True)
steps: Dict[ExtractionStep, List[Any]] = Field(default_factory=dict)
_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
@field_serializer("steps")
def serialize_steps(
self, steps: Dict[ExtractionStep, List[Any]]
) -> Dict[str, List[Any]]:
with self._lock:
return {step.value: list(usages) for step, usages in steps.items()}
def add_step_usage(self, step: ExtractionStep, usage: Any) -> None:
"""Associate a provider usage object with a pipeline step."""
if usage is not None:
with self._lock:
self.steps.setdefault(step, []).append(usage)
def merge(self, other: "ExtractorUsage") -> None:
"""Append another tracker's raw usages while preserving step order."""
for step in ExtractionStep:
for usage in other.get_step(step):
self.add_step_usage(step, usage)
def get_step(self, step: Union[ExtractionStep, str]) -> List[Any]:
"""Return the raw usage objects recorded for a pipeline step."""
step = ExtractionStep(step) if isinstance(step, str) else step
with self._lock:
return list(self.steps.get(step, []))
def _all_usages(self) -> Iterable[Any]:
with self._lock:
return [usage for usages in self.steps.values() for usage in usages]
@staticmethod
def _prompt_tokens(usage: Any) -> int:
return _token_count(usage, "prompt_tokens", "input_tokens")
@staticmethod
def _completion_tokens(usage: Any) -> int:
return _token_count(usage, "completion_tokens", "output_tokens")
@classmethod
def _total_tokens(cls, usage: Any) -> int:
total = _optional_token_count(usage, "total_tokens")
if total is not None:
return total
return cls._prompt_tokens(usage) + cls._completion_tokens(usage)
@staticmethod
def _reasoning_tokens(usage: Any) -> Optional[int]:
details = _read_value(
usage, "completion_tokens_details", "output_tokens_details"
)
nested = _optional_token_count(details, "reasoning_tokens", "thinking_tokens")
return (
nested
if nested is not None
else _optional_token_count(usage, "reasoning_tokens", "thinking_tokens")
)
@staticmethod
def _cached_tokens(usage: Any) -> Optional[int]:
details = _read_value(usage, "prompt_tokens_details", "input_tokens_details")
nested = _optional_token_count(
details,
"cached_tokens",
"cache_read_tokens",
"cache_read_input_tokens",
)
return (
nested
if nested is not None
else _optional_token_count(
usage,
"cached_tokens",
"cache_read_tokens",
"cache_read_input_tokens",
)
)
@computed_field
@property
def total_tokens(self) -> int:
return sum(self._total_tokens(usage) for usage in self._all_usages())
@computed_field
@property
def prompt_tokens(self) -> int:
return sum(self._prompt_tokens(usage) for usage in self._all_usages())
@computed_field
@property
def completion_tokens(self) -> int:
return sum(self._completion_tokens(usage) for usage in self._all_usages())
@computed_field
@property
def thinking_tokens(self) -> Optional[int]:
values = [
value
for usage in self._all_usages()
if (value := self._reasoning_tokens(usage)) is not None
]
return sum(values) if values else None
@computed_field
@property
def cached_tokens(self) -> Optional[int]:
values = [
value
for usage in self._all_usages()
if (value := self._cached_tokens(usage)) is not None
]
return sum(values) if values else None