2026-07-15 13:09:05 +05:30
|
|
|
|
"""Paygent live-call collector.
|
|
|
|
|
|
|
|
|
|
|
|
Attaches to the pipecat pipeline as a ``BaseObserver`` to accumulate per-call
|
|
|
|
|
|
usage metrics (STT audio seconds, LLM tokens, TTS characters, STS metadata)
|
|
|
|
|
|
in memory during the call. No network I/O happens here; all delivery is
|
|
|
|
|
|
deferred to the post-call completion handler.
|
|
|
|
|
|
|
|
|
|
|
|
Design mirrors ``api/services/integrations/tuner/collector.py`` exactly:
|
|
|
|
|
|
- Attach to the task in ``PaygentRuntimeSession.attach``
|
|
|
|
|
|
- Build a serialisable snapshot in ``build_snapshot``
|
|
|
|
|
|
- Return it from ``on_call_finished`` so it lands in ``workflow_run.logs``
|
|
|
|
|
|
"""
|
2026-07-15 18:36:36 +05:30
|
|
|
|
|
2026-07-15 13:09:05 +05:30
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import time
|
|
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
|
|
from typing import Any, Dict
|
|
|
|
|
|
|
|
|
|
|
|
from loguru import logger
|
|
|
|
|
|
from pipecat.frames.frames import (
|
|
|
|
|
|
CancelFrame,
|
|
|
|
|
|
EndFrame,
|
|
|
|
|
|
MetricsFrame,
|
|
|
|
|
|
StartFrame,
|
|
|
|
|
|
TTSTextFrame,
|
|
|
|
|
|
UserStartedSpeakingFrame,
|
|
|
|
|
|
UserStoppedSpeakingFrame,
|
|
|
|
|
|
)
|
|
|
|
|
|
from pipecat.metrics.metrics import (
|
|
|
|
|
|
LLMTokenUsage,
|
|
|
|
|
|
LLMUsageMetricsData,
|
|
|
|
|
|
TTSUsageMetricsData,
|
|
|
|
|
|
)
|
|
|
|
|
|
from pipecat.observers.base_observer import BaseObserver, FramePushed
|
|
|
|
|
|
from pipecat.processors.frame_processor import FrameDirection
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _detect_provider(name: str, fallback: str = "unknown") -> str:
|
|
|
|
|
|
"""Map a processor/model name to a canonical Paygent provider slug dynamically."""
|
|
|
|
|
|
if not name:
|
|
|
|
|
|
return fallback
|
|
|
|
|
|
clean_name = name.lower().strip()
|
|
|
|
|
|
if "gemini" in clean_name:
|
|
|
|
|
|
return "google"
|
|
|
|
|
|
suffixes = [
|
2026-07-15 18:36:36 +05:30
|
|
|
|
"service",
|
|
|
|
|
|
"multimodallive",
|
|
|
|
|
|
"realtime",
|
|
|
|
|
|
"vertex",
|
|
|
|
|
|
"llm",
|
|
|
|
|
|
"tts",
|
|
|
|
|
|
"stt",
|
|
|
|
|
|
"helper",
|
|
|
|
|
|
"transport",
|
2026-07-15 13:09:05 +05:30
|
|
|
|
]
|
|
|
|
|
|
changed = True
|
|
|
|
|
|
while changed:
|
|
|
|
|
|
changed = False
|
|
|
|
|
|
for suffix in suffixes:
|
|
|
|
|
|
if clean_name.endswith(suffix):
|
2026-07-15 18:36:36 +05:30
|
|
|
|
clean_name = clean_name[: -len(suffix)].rstrip("_").rstrip("-")
|
2026-07-15 13:09:05 +05:30
|
|
|
|
changed = True
|
|
|
|
|
|
break
|
|
|
|
|
|
return clean_name or fallback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class _UsageAccumulator:
|
|
|
|
|
|
"""In-memory accumulator for per-call usage data."""
|
|
|
|
|
|
|
|
|
|
|
|
# STT
|
|
|
|
|
|
stt_audio_seconds: float = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
# LLM (aggregated across all turns)
|
|
|
|
|
|
llm_prompt_tokens: int = 0
|
|
|
|
|
|
llm_completion_tokens: int = 0
|
|
|
|
|
|
llm_cached_tokens: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
# TTS
|
|
|
|
|
|
tts_characters: int = 0
|
|
|
|
|
|
_has_tts_metrics: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
# STS / realtime (last seen usage_metadata dict; callers merge these)
|
|
|
|
|
|
sts_usage_metadata: dict[str, Any] | None = None
|
|
|
|
|
|
|
|
|
|
|
|
# Call timing
|
|
|
|
|
|
call_start_abs_ns: int = field(default_factory=time.time_ns)
|
|
|
|
|
|
call_end_abs_ns: int | None = None
|
|
|
|
|
|
# STT: timestamp of when user started speaking; None when not speaking
|
|
|
|
|
|
_user_started_speaking_ns: int | None = field(default=None, repr=False)
|
2026-07-15 18:36:36 +05:30
|
|
|
|
|
2026-07-15 13:09:05 +05:30
|
|
|
|
@property
|
|
|
|
|
|
def total_duration_seconds(self) -> int:
|
|
|
|
|
|
if self.call_end_abs_ns is None:
|
|
|
|
|
|
return int((time.time_ns() - self.call_start_abs_ns) / 1_000_000_000)
|
|
|
|
|
|
return int((self.call_end_abs_ns - self.call_start_abs_ns) / 1_000_000_000)
|
2026-07-15 18:36:36 +05:30
|
|
|
|
|
2026-07-15 13:09:05 +05:30
|
|
|
|
def get_stt_audio_seconds(self) -> float:
|
|
|
|
|
|
"""Return measured STT audio seconds accumulated from the pipeline.
|
|
|
|
|
|
|
|
|
|
|
|
NOTE: This is the real measured STT audio duration collected from the
|
|
|
|
|
|
pipeline's STT metrics frames, NOT the total call wall-clock duration.
|
|
|
|
|
|
The call wall-clock duration is available separately via
|
|
|
|
|
|
``total_duration_seconds``.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return self.stt_audio_seconds
|
|
|
|
|
|
|
|
|
|
|
|
def add_llm(self, usage: LLMTokenUsage) -> None:
|
|
|
|
|
|
self.llm_prompt_tokens += usage.prompt_tokens or 0
|
|
|
|
|
|
self.llm_completion_tokens += usage.completion_tokens or 0
|
|
|
|
|
|
self.llm_cached_tokens += (usage.cache_read_input_tokens or 0) + (
|
|
|
|
|
|
usage.cache_creation_input_tokens or 0
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def add_tts_metrics(self, data: Any) -> None:
|
|
|
|
|
|
if not self._has_tts_metrics:
|
|
|
|
|
|
self._has_tts_metrics = True
|
|
|
|
|
|
self.tts_characters = 0 # Ignore manual count if metrics emit natively
|
2026-07-15 18:36:36 +05:30
|
|
|
|
|
2026-07-15 13:09:05 +05:30
|
|
|
|
# Extremely robust extraction
|
|
|
|
|
|
val = 0
|
|
|
|
|
|
if isinstance(data, (int, float)):
|
|
|
|
|
|
val = data
|
|
|
|
|
|
elif hasattr(data, "value"):
|
|
|
|
|
|
val = getattr(data, "value", 0) or 0
|
|
|
|
|
|
elif hasattr(data, "characters"):
|
|
|
|
|
|
val = getattr(data, "characters", 0) or 0
|
|
|
|
|
|
elif isinstance(data, dict):
|
|
|
|
|
|
val = data.get("value") or data.get("characters") or 0
|
2026-07-15 18:36:36 +05:30
|
|
|
|
|
2026-07-15 13:09:05 +05:30
|
|
|
|
try:
|
|
|
|
|
|
self.tts_characters += int(val or 0)
|
|
|
|
|
|
except Exception as exc:
|
2026-07-15 18:36:36 +05:30
|
|
|
|
logger.warning(
|
|
|
|
|
|
"[paygent] Failed to accumulate TTS characters (val={!r}): {}", val, exc
|
|
|
|
|
|
)
|
2026-07-15 13:09:05 +05:30
|
|
|
|
|
|
|
|
|
|
def add_tts_manual(self, text: str) -> None:
|
|
|
|
|
|
if not self._has_tts_metrics:
|
|
|
|
|
|
self.tts_characters += len(text)
|
|
|
|
|
|
|
|
|
|
|
|
def on_user_started_speaking(self) -> None:
|
|
|
|
|
|
"""Mark the start of a user utterance for STT audio metering."""
|
|
|
|
|
|
if self._user_started_speaking_ns is None:
|
|
|
|
|
|
self._user_started_speaking_ns = time.time_ns()
|
|
|
|
|
|
|
|
|
|
|
|
def on_user_stopped_speaking(self) -> None:
|
|
|
|
|
|
"""Accumulate the completed utterance duration into stt_audio_seconds."""
|
|
|
|
|
|
if self._user_started_speaking_ns is not None:
|
2026-07-15 18:36:36 +05:30
|
|
|
|
elapsed_s = (
|
|
|
|
|
|
time.time_ns() - self._user_started_speaking_ns
|
|
|
|
|
|
) / 1_000_000_000
|
2026-07-15 13:09:05 +05:30
|
|
|
|
self.stt_audio_seconds += elapsed_s
|
|
|
|
|
|
self._user_started_speaking_ns = None
|
|
|
|
|
|
|
|
|
|
|
|
def finalize(self) -> None:
|
|
|
|
|
|
if self.call_end_abs_ns is None:
|
|
|
|
|
|
self.call_end_abs_ns = time.time_ns()
|
|
|
|
|
|
# If user was mid-utterance when the call ended, close the interval.
|
|
|
|
|
|
self.on_user_stopped_speaking()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _google_live_usage_to_sts_metadata(usage: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Pure Python translation of Google GenAI Live usage_metadata to
|
|
|
|
|
|
Paygent's canonical speech-to-speech /api/v1/voice/speech-to-speech API schema.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not usage:
|
|
|
|
|
|
return {"schemaVersion": 1}
|
|
|
|
|
|
|
|
|
|
|
|
def _get_val(obj, *keys):
|
|
|
|
|
|
if not obj:
|
|
|
|
|
|
return None
|
|
|
|
|
|
for k in keys:
|
|
|
|
|
|
if isinstance(obj, dict):
|
2026-07-15 18:36:36 +05:30
|
|
|
|
if k in obj:
|
|
|
|
|
|
return obj[k]
|
2026-07-15 13:09:05 +05:30
|
|
|
|
else:
|
2026-07-15 18:36:36 +05:30
|
|
|
|
if hasattr(obj, k):
|
|
|
|
|
|
return getattr(obj, k)
|
2026-07-15 13:09:05 +05:30
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _get_list(obj, *keys):
|
|
|
|
|
|
val = _get_val(obj, *keys)
|
|
|
|
|
|
if val is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return list(val) if not isinstance(val, list) else val
|
|
|
|
|
|
|
|
|
|
|
|
def _optional_int(obj, *keys):
|
|
|
|
|
|
val = _get_val(obj, *keys)
|
|
|
|
|
|
if val is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return int(val)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return None
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _modality_token_count(details, modality_name):
|
|
|
|
|
|
if not details:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
want = modality_name.upper()
|
|
|
|
|
|
total = 0
|
|
|
|
|
|
for d in details:
|
|
|
|
|
|
try:
|
|
|
|
|
|
mod = _get_val(d, "modality")
|
|
|
|
|
|
if mod is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
label = _get_val(mod, "name") or _get_val(mod, "value") or mod
|
|
|
|
|
|
if str(label).upper() != want:
|
|
|
|
|
|
continue
|
|
|
|
|
|
tc = _get_val(d, "token_count", "tokenCount")
|
|
|
|
|
|
total += int(tc or 0)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
return total
|
|
|
|
|
|
|
|
|
|
|
|
prompt_details = _get_list(usage, "prompt_tokens_details", "promptTokensDetails")
|
2026-07-15 18:36:36 +05:30
|
|
|
|
response_details = _get_list(
|
|
|
|
|
|
usage, "response_tokens_details", "responseTokensDetails"
|
|
|
|
|
|
)
|
|
|
|
|
|
tool_details = _get_list(
|
|
|
|
|
|
usage, "tool_use_prompt_tokens_details", "toolUsePromptTokensDetails"
|
|
|
|
|
|
)
|
2026-07-15 13:09:05 +05:30
|
|
|
|
cache_details = _get_list(usage, "cache_tokens_details", "cacheTokensDetails")
|
|
|
|
|
|
|
|
|
|
|
|
# input side: TEXT + DOCUMENT + AUDIO + IMAGE + VIDEO
|
2026-07-15 18:36:36 +05:30
|
|
|
|
text_in = _modality_token_count(prompt_details, "TEXT") + _modality_token_count(
|
|
|
|
|
|
tool_details, "TEXT"
|
|
|
|
|
|
)
|
|
|
|
|
|
audio_in = _modality_token_count(prompt_details, "AUDIO") + _modality_token_count(
|
|
|
|
|
|
tool_details, "AUDIO"
|
|
|
|
|
|
)
|
|
|
|
|
|
image_in = _modality_token_count(prompt_details, "IMAGE") + _modality_token_count(
|
|
|
|
|
|
tool_details, "IMAGE"
|
|
|
|
|
|
)
|
|
|
|
|
|
video_in = _modality_token_count(prompt_details, "VIDEO") + _modality_token_count(
|
|
|
|
|
|
tool_details, "VIDEO"
|
|
|
|
|
|
)
|
|
|
|
|
|
doc_as_text = _modality_token_count(
|
|
|
|
|
|
prompt_details, "DOCUMENT"
|
|
|
|
|
|
) + _modality_token_count(tool_details, "DOCUMENT")
|
2026-07-15 13:09:05 +05:30
|
|
|
|
text_in += doc_as_text
|
|
|
|
|
|
|
|
|
|
|
|
# fallback aggregate mapping
|
2026-07-15 18:36:36 +05:30
|
|
|
|
tutc = _optional_int(
|
|
|
|
|
|
usage, "tool_use_prompt_token_count", "toolUsePromptTokenCount"
|
|
|
|
|
|
)
|
2026-07-15 13:09:05 +05:30
|
|
|
|
if tutc is not None and not tool_details:
|
|
|
|
|
|
text_in += int(tutc)
|
|
|
|
|
|
|
|
|
|
|
|
ptc = _optional_int(usage, "prompt_token_count", "promptTokenCount")
|
|
|
|
|
|
if ptc is not None and not prompt_details and not tool_details:
|
|
|
|
|
|
text_in += int(ptc)
|
|
|
|
|
|
|
|
|
|
|
|
# output side: TEXT + DOCUMENT + AUDIO + VIDEO + THINKING
|
2026-07-15 18:36:36 +05:30
|
|
|
|
text_out = _modality_token_count(response_details, "TEXT") + _modality_token_count(
|
|
|
|
|
|
response_details, "DOCUMENT"
|
|
|
|
|
|
)
|
|
|
|
|
|
audio_out = _modality_token_count(
|
|
|
|
|
|
response_details, "AUDIO"
|
|
|
|
|
|
) + _modality_token_count(response_details, "VIDEO")
|
2026-07-15 13:09:05 +05:30
|
|
|
|
|
|
|
|
|
|
rtc = _optional_int(usage, "response_token_count", "responseTokenCount")
|
|
|
|
|
|
if text_out == 0 and audio_out == 0 and rtc is not None:
|
|
|
|
|
|
# Default fallback to audio output for STS audio connection
|
|
|
|
|
|
audio_out = int(rtc)
|
|
|
|
|
|
|
|
|
|
|
|
# Thinking / reasoning tokens (Gemini 2.5+ thinking models).
|
|
|
|
|
|
# Emitted as a separate output modality so Paygent has full billing visibility.
|
2026-07-15 18:36:36 +05:30
|
|
|
|
thinking_tokens = (
|
|
|
|
|
|
_optional_int(
|
|
|
|
|
|
usage,
|
|
|
|
|
|
"thoughts_token_count",
|
|
|
|
|
|
"thoughtsTokenCount",
|
|
|
|
|
|
"thinking_token_count",
|
|
|
|
|
|
"thinkingTokenCount",
|
|
|
|
|
|
)
|
|
|
|
|
|
or 0
|
|
|
|
|
|
)
|
2026-07-15 13:09:05 +05:30
|
|
|
|
|
|
|
|
|
|
# Cache breakdowns
|
2026-07-15 18:36:36 +05:30
|
|
|
|
cached_text = _modality_token_count(cache_details, "TEXT") + _modality_token_count(
|
|
|
|
|
|
cache_details, "DOCUMENT"
|
|
|
|
|
|
)
|
|
|
|
|
|
cached_audio = _modality_token_count(
|
|
|
|
|
|
cache_details, "AUDIO"
|
|
|
|
|
|
) + _modality_token_count(cache_details, "VIDEO")
|
2026-07-15 13:09:05 +05:30
|
|
|
|
cached_image = _modality_token_count(cache_details, "IMAGE")
|
2026-07-15 18:36:36 +05:30
|
|
|
|
cached_legacy = _optional_int(
|
|
|
|
|
|
usage, "cached_content_token_count", "cachedContentTokenCount"
|
|
|
|
|
|
)
|
2026-07-15 13:09:05 +05:30
|
|
|
|
|
|
|
|
|
|
# Build response payload
|
|
|
|
|
|
out = {"schemaVersion": 1}
|
|
|
|
|
|
|
|
|
|
|
|
# Input Side
|
|
|
|
|
|
inp = {}
|
2026-07-15 18:36:36 +05:30
|
|
|
|
if text_in > 0:
|
|
|
|
|
|
inp["text"] = {"tokens": text_in}
|
|
|
|
|
|
if audio_in > 0:
|
|
|
|
|
|
inp["audio"] = {"tokens": audio_in}
|
|
|
|
|
|
if image_in > 0:
|
|
|
|
|
|
inp["image"] = {"tokens": image_in}
|
|
|
|
|
|
if video_in > 0:
|
|
|
|
|
|
inp["video"] = {"tokens": video_in}
|
|
|
|
|
|
if inp:
|
|
|
|
|
|
out["input"] = inp
|
2026-07-15 13:09:05 +05:30
|
|
|
|
|
|
|
|
|
|
# Output Side
|
|
|
|
|
|
o = {}
|
2026-07-15 18:36:36 +05:30
|
|
|
|
if text_out > 0:
|
|
|
|
|
|
o["text"] = {"tokens": text_out}
|
|
|
|
|
|
if audio_out > 0:
|
|
|
|
|
|
o["audio"] = {"tokens": audio_out}
|
|
|
|
|
|
if thinking_tokens > 0:
|
|
|
|
|
|
o["thinking"] = {"tokens": thinking_tokens}
|
|
|
|
|
|
if o:
|
|
|
|
|
|
out["output"] = o
|
2026-07-15 13:09:05 +05:30
|
|
|
|
|
|
|
|
|
|
# Cached breakdown
|
|
|
|
|
|
has_split = bool(cached_text or cached_audio or cached_image)
|
|
|
|
|
|
if cached_legacy is not None and cached_legacy > 0 and not has_split:
|
|
|
|
|
|
out["cached"] = {"tokens": int(cached_legacy)}
|
|
|
|
|
|
elif has_split:
|
|
|
|
|
|
cd = {}
|
2026-07-15 18:36:36 +05:30
|
|
|
|
if cached_text > 0:
|
|
|
|
|
|
cd["text"] = {"tokens": cached_text}
|
|
|
|
|
|
if cached_audio > 0:
|
|
|
|
|
|
cd["audio"] = {"tokens": cached_audio}
|
|
|
|
|
|
if cached_image > 0:
|
|
|
|
|
|
cd["image"] = {"tokens": cached_image}
|
|
|
|
|
|
if cd:
|
|
|
|
|
|
out["cached"] = cd
|
2026-07-15 13:09:05 +05:30
|
|
|
|
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _openai_realtime_usage_to_sts_metadata(usage: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Pure Python translation of OpenAI Realtime usage_metadata to
|
|
|
|
|
|
Paygent's canonical speech-to-speech /api/v1/voice/speech-to-speech API schema.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not usage:
|
|
|
|
|
|
return {"schemaVersion": 1}
|
|
|
|
|
|
|
|
|
|
|
|
def _get_val(obj, *keys):
|
|
|
|
|
|
if not obj:
|
|
|
|
|
|
return None
|
|
|
|
|
|
for k in keys:
|
|
|
|
|
|
if isinstance(obj, dict):
|
2026-07-15 18:36:36 +05:30
|
|
|
|
if k in obj:
|
|
|
|
|
|
return obj[k]
|
2026-07-15 13:09:05 +05:30
|
|
|
|
else:
|
2026-07-15 18:36:36 +05:30
|
|
|
|
if hasattr(obj, k):
|
|
|
|
|
|
return getattr(obj, k)
|
2026-07-15 13:09:05 +05:30
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
total_in = int(_get_val(usage, "input_tokens", "inputTokens") or 0)
|
|
|
|
|
|
total_out = int(_get_val(usage, "output_tokens", "outputTokens") or 0)
|
|
|
|
|
|
|
|
|
|
|
|
in_details = _get_val(usage, "input_token_details", "inputTokenDetails") or {}
|
|
|
|
|
|
out_details = _get_val(usage, "output_token_details", "outputTokenDetails") or {}
|
|
|
|
|
|
|
|
|
|
|
|
audio_in = int(_get_val(in_details, "audio_tokens", "audioTokens") or 0)
|
|
|
|
|
|
text_in = int(_get_val(in_details, "text_tokens", "textTokens") or 0)
|
|
|
|
|
|
image_in = int(_get_val(in_details, "image_tokens", "imageTokens") or 0)
|
|
|
|
|
|
|
2026-07-15 18:36:36 +05:30
|
|
|
|
cached_total = int(
|
|
|
|
|
|
_get_val(usage, "cached_tokens", "cachedTokens")
|
|
|
|
|
|
or _get_val(in_details, "cached_tokens", "cachedTokens")
|
|
|
|
|
|
or 0
|
|
|
|
|
|
)
|
2026-07-15 13:09:05 +05:30
|
|
|
|
|
2026-07-15 18:36:36 +05:30
|
|
|
|
cached_details = (
|
|
|
|
|
|
_get_val(in_details, "cached_tokens_details", "cachedTokensDetails") or {}
|
|
|
|
|
|
)
|
2026-07-15 13:09:05 +05:30
|
|
|
|
cached_audio = int(_get_val(cached_details, "audio_tokens", "audioTokens") or 0)
|
|
|
|
|
|
cached_text = int(_get_val(cached_details, "text_tokens", "textTokens") or 0)
|
|
|
|
|
|
cached_image = int(_get_val(cached_details, "image_tokens", "imageTokens") or 0)
|
|
|
|
|
|
|
|
|
|
|
|
if not (cached_audio or cached_text or cached_image):
|
2026-07-15 18:36:36 +05:30
|
|
|
|
cached_audio = int(
|
|
|
|
|
|
_get_val(in_details, "cached_audio_tokens", "cachedAudioTokens") or 0
|
|
|
|
|
|
)
|
|
|
|
|
|
cached_text = int(
|
|
|
|
|
|
_get_val(in_details, "cached_text_tokens", "cachedTextTokens") or 0
|
|
|
|
|
|
)
|
|
|
|
|
|
cached_image = int(
|
|
|
|
|
|
_get_val(in_details, "cached_image_tokens", "cachedImageTokens") or 0
|
|
|
|
|
|
)
|
2026-07-15 13:09:05 +05:30
|
|
|
|
|
|
|
|
|
|
audio_out = int(_get_val(out_details, "audio_tokens", "audioTokens") or 0)
|
|
|
|
|
|
text_out = int(_get_val(out_details, "text_tokens", "textTokens") or 0)
|
|
|
|
|
|
|
|
|
|
|
|
if not (text_in or audio_in or image_in) and total_in > 0:
|
|
|
|
|
|
text_in = total_in - cached_total
|
|
|
|
|
|
|
|
|
|
|
|
out = {"schemaVersion": 1}
|
|
|
|
|
|
inp = {}
|
2026-07-15 18:36:36 +05:30
|
|
|
|
if text_in > 0:
|
|
|
|
|
|
inp["text"] = {"tokens": text_in}
|
|
|
|
|
|
if audio_in > 0:
|
|
|
|
|
|
inp["audio"] = {"tokens": audio_in}
|
|
|
|
|
|
if image_in > 0:
|
|
|
|
|
|
inp["image"] = {"tokens": image_in}
|
|
|
|
|
|
if inp:
|
|
|
|
|
|
out["input"] = inp
|
2026-07-15 13:09:05 +05:30
|
|
|
|
|
|
|
|
|
|
o = {}
|
2026-07-15 18:36:36 +05:30
|
|
|
|
if text_out > 0:
|
|
|
|
|
|
o["text"] = {"tokens": text_out}
|
|
|
|
|
|
if audio_out > 0:
|
|
|
|
|
|
o["audio"] = {"tokens": audio_out}
|
|
|
|
|
|
if o:
|
|
|
|
|
|
out["output"] = o
|
2026-07-15 13:09:05 +05:30
|
|
|
|
|
|
|
|
|
|
has_split = bool(cached_text or cached_audio or cached_image)
|
|
|
|
|
|
if cached_total > 0 and not has_split:
|
|
|
|
|
|
out["cached"] = {"tokens": int(cached_total)}
|
|
|
|
|
|
elif has_split:
|
|
|
|
|
|
cd = {}
|
2026-07-15 18:36:36 +05:30
|
|
|
|
if cached_text > 0:
|
|
|
|
|
|
cd["text"] = {"tokens": cached_text}
|
|
|
|
|
|
if cached_audio > 0:
|
|
|
|
|
|
cd["audio"] = {"tokens": cached_audio}
|
|
|
|
|
|
if cached_image > 0:
|
|
|
|
|
|
cd["image"] = {"tokens": cached_image}
|
|
|
|
|
|
if cd:
|
|
|
|
|
|
out["cached"] = cd
|
2026-07-15 13:09:05 +05:30
|
|
|
|
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _merge_sts_metadata(existing: dict, new: dict) -> dict:
|
|
|
|
|
|
if not existing:
|
|
|
|
|
|
return new
|
|
|
|
|
|
out = {"schemaVersion": 1}
|
|
|
|
|
|
for key in ("input", "output", "cached"):
|
|
|
|
|
|
e_val = existing.get(key, {})
|
|
|
|
|
|
n_val = new.get(key, {})
|
|
|
|
|
|
if not e_val and not n_val:
|
|
|
|
|
|
continue
|
2026-07-15 18:36:36 +05:30
|
|
|
|
|
2026-07-15 13:09:05 +05:30
|
|
|
|
merged_cat: dict = {}
|
|
|
|
|
|
|
|
|
|
|
|
# Prefer per-modality merge when either side has per-modality detail.
|
|
|
|
|
|
# Only use the flat aggregate{"tokens": N} form when neither side has
|
|
|
|
|
|
# any per-modality breakdown at all (e.g. legacy schema).
|
2026-07-15 18:36:36 +05:30
|
|
|
|
e_has_modalities = any(
|
|
|
|
|
|
m in e_val for m in ("text", "audio", "image", "video", "thinking")
|
|
|
|
|
|
)
|
|
|
|
|
|
n_has_modalities = any(
|
|
|
|
|
|
m in n_val for m in ("text", "audio", "image", "video", "thinking")
|
|
|
|
|
|
)
|
2026-07-15 13:09:05 +05:30
|
|
|
|
|
|
|
|
|
|
if e_has_modalities or n_has_modalities:
|
|
|
|
|
|
for modality in ("text", "audio", "image", "video", "thinking"):
|
|
|
|
|
|
e_mod = e_val.get(modality, {}).get("tokens", 0)
|
|
|
|
|
|
n_mod = n_val.get(modality, {}).get("tokens", 0)
|
|
|
|
|
|
total = e_mod + n_mod
|
|
|
|
|
|
if total > 0:
|
|
|
|
|
|
merged_cat[modality] = {"tokens": total}
|
|
|
|
|
|
# Also sum any lingering aggregate total so no tokens are lost
|
|
|
|
|
|
e_agg = e_val.get("tokens", 0) if not e_has_modalities else 0
|
|
|
|
|
|
n_agg = n_val.get("tokens", 0) if not n_has_modalities else 0
|
|
|
|
|
|
if e_agg or n_agg:
|
|
|
|
|
|
# Incorporate the unbroken-down side into the "text" bucket as
|
|
|
|
|
|
# a best-effort attribution rather than silently dropping it.
|
|
|
|
|
|
existing_text = merged_cat.get("text", {}).get("tokens", 0)
|
|
|
|
|
|
merged_cat["text"] = {"tokens": existing_text + e_agg + n_agg}
|
|
|
|
|
|
elif "tokens" in e_val or "tokens" in n_val:
|
|
|
|
|
|
merged_cat["tokens"] = e_val.get("tokens", 0) + n_val.get("tokens", 0)
|
|
|
|
|
|
|
|
|
|
|
|
if merged_cat:
|
|
|
|
|
|
out[key] = merged_cat
|
2026-07-15 18:36:36 +05:30
|
|
|
|
|
2026-07-15 13:09:05 +05:30
|
|
|
|
# retain any other keys, summing up numeric ones to keep metadata consistent
|
|
|
|
|
|
for k, v in existing.items():
|
|
|
|
|
|
if k not in ("schemaVersion", "input", "output", "cached"):
|
|
|
|
|
|
out[k] = v
|
|
|
|
|
|
for k, v in new.items():
|
|
|
|
|
|
if k not in ("schemaVersion", "input", "output", "cached"):
|
2026-07-15 18:36:36 +05:30
|
|
|
|
if (
|
|
|
|
|
|
k in out
|
|
|
|
|
|
and isinstance(out[k], (int, float))
|
|
|
|
|
|
and isinstance(v, (int, float))
|
|
|
|
|
|
):
|
2026-07-15 13:09:05 +05:30
|
|
|
|
out[k] = out[k] + v
|
|
|
|
|
|
else:
|
|
|
|
|
|
out[k] = v
|
2026-07-15 18:36:36 +05:30
|
|
|
|
|
2026-07-15 13:09:05 +05:30
|
|
|
|
return out
|
|
|
|
|
|
|
2026-07-15 18:36:36 +05:30
|
|
|
|
|
2026-07-15 13:09:05 +05:30
|
|
|
|
class PaygentCollector(BaseObserver):
|
|
|
|
|
|
"""Pipecat observer that accumulates usage data for a single call.
|
|
|
|
|
|
|
|
|
|
|
|
Accumulates:
|
|
|
|
|
|
- LLM token usage from ``MetricsFrame / LLMUsageMetricsData``
|
|
|
|
|
|
- TTS character usage from ``MetricsFrame / TTSUsageMetricsData``
|
|
|
|
|
|
- STT audio seconds from ``MetricsFrame`` (when exposed by the pipeline)
|
|
|
|
|
|
- Call start / end timestamps for ``total_duration_seconds``
|
|
|
|
|
|
|
|
|
|
|
|
Does **not** do any network I/O.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
workflow_run_id: int,
|
|
|
|
|
|
is_realtime: bool,
|
|
|
|
|
|
stt_provider: str = "",
|
|
|
|
|
|
stt_model: str = "",
|
|
|
|
|
|
llm_provider: str = "",
|
|
|
|
|
|
llm_model: str = "",
|
|
|
|
|
|
tts_provider: str = "",
|
|
|
|
|
|
tts_model: str = "",
|
|
|
|
|
|
sts_provider: str = "",
|
|
|
|
|
|
sts_model: str = "",
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
super().__init__()
|
|
|
|
|
|
self._workflow_run_id = workflow_run_id
|
|
|
|
|
|
self._is_realtime = is_realtime
|
|
|
|
|
|
self._stt_provider = stt_provider
|
|
|
|
|
|
self._stt_model = stt_model
|
|
|
|
|
|
self._llm_provider = llm_provider
|
|
|
|
|
|
self._llm_model = llm_model
|
|
|
|
|
|
self._tts_provider = tts_provider
|
|
|
|
|
|
self._tts_model = tts_model
|
|
|
|
|
|
self._sts_provider = sts_provider
|
|
|
|
|
|
self._sts_model = sts_model
|
|
|
|
|
|
self._acc = _UsageAccumulator()
|
|
|
|
|
|
self._call_disposition: str = "completed"
|
|
|
|
|
|
# Dedup guard: pipecat can re-deliver frames. This collector is created
|
|
|
|
|
|
# fresh per call (see create_runtime_sessions) so the set size is bounded
|
|
|
|
|
|
# by call duration. We intentionally do NOT trim the set: trimming would
|
|
|
|
|
|
# evict old IDs and allow re-delivered frames to be processed a second time.
|
|
|
|
|
|
self._seen_frame_ids: set[int] = set()
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Public hooks
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def set_call_disposition(self, disposition: str | None) -> None:
|
|
|
|
|
|
if disposition:
|
|
|
|
|
|
self._call_disposition = disposition
|
|
|
|
|
|
|
|
|
|
|
|
def build_snapshot(self) -> dict[str, Any]:
|
|
|
|
|
|
"""Return a JSON-serialisable dict stored in ``workflow_run.logs``."""
|
|
|
|
|
|
self._acc.finalize()
|
|
|
|
|
|
stt_audio_sec = self._acc.get_stt_audio_seconds()
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"workflow_run_id": self._workflow_run_id,
|
|
|
|
|
|
"is_realtime": self._is_realtime,
|
|
|
|
|
|
"stt_provider": self._stt_provider,
|
|
|
|
|
|
"stt_model": self._stt_model,
|
|
|
|
|
|
"stt_audio_seconds": stt_audio_sec,
|
|
|
|
|
|
"llm_provider": self._llm_provider,
|
|
|
|
|
|
"llm_model": self._llm_model,
|
|
|
|
|
|
"llm_prompt_tokens": self._acc.llm_prompt_tokens,
|
|
|
|
|
|
"llm_completion_tokens": self._acc.llm_completion_tokens,
|
|
|
|
|
|
"llm_cached_tokens": self._acc.llm_cached_tokens,
|
|
|
|
|
|
"tts_provider": self._tts_provider,
|
|
|
|
|
|
"tts_model": self._tts_model,
|
|
|
|
|
|
"tts_characters": self._acc.tts_characters,
|
|
|
|
|
|
"sts_provider": self._sts_provider,
|
|
|
|
|
|
"sts_model": self._sts_model,
|
|
|
|
|
|
"sts_usage_metadata": self._acc.sts_usage_metadata,
|
|
|
|
|
|
"call_disposition": self._call_disposition,
|
|
|
|
|
|
"total_duration_seconds": self._acc.total_duration_seconds,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# BaseObserver implementation
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async def on_push_frame(self, data: FramePushed) -> None: # type: ignore[override]
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Only process downstream frames; ignore upstream (mic → STT direction)
|
|
|
|
|
|
if data.direction != FrameDirection.DOWNSTREAM:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
frame = data.frame
|
|
|
|
|
|
|
|
|
|
|
|
# Dedup: per-call set; grows with the call but is GC’d when the
|
|
|
|
|
|
# call ends. Never trim — trimming would reopen a re-delivery window.
|
|
|
|
|
|
if frame.id in self._seen_frame_ids:
|
|
|
|
|
|
return
|
|
|
|
|
|
self._seen_frame_ids.add(frame.id)
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(frame, StartFrame):
|
|
|
|
|
|
self._acc.call_start_abs_ns = time.time_ns()
|
|
|
|
|
|
|
|
|
|
|
|
elif isinstance(frame, MetricsFrame):
|
|
|
|
|
|
for item in frame.data:
|
|
|
|
|
|
if isinstance(item, LLMUsageMetricsData):
|
|
|
|
|
|
is_sts_frame = False
|
|
|
|
|
|
proc_lower = (item.processor or "").lower()
|
|
|
|
|
|
if getattr(self, "_is_realtime", False):
|
|
|
|
|
|
if "realtime" in proc_lower or "live" in proc_lower:
|
|
|
|
|
|
is_sts_frame = True
|
|
|
|
|
|
|
|
|
|
|
|
if is_sts_frame:
|
|
|
|
|
|
# Normalise the raw provider slug so that variants like
|
|
|
|
|
|
# "openai_realtime", "azure_realtime", etc. route correctly.
|
2026-07-15 18:36:36 +05:30
|
|
|
|
raw_provider = getattr(
|
|
|
|
|
|
self, "_sts_provider", ""
|
|
|
|
|
|
) or getattr(self, "_llm_provider", "")
|
|
|
|
|
|
provider = (
|
|
|
|
|
|
_detect_provider(raw_provider)
|
|
|
|
|
|
if raw_provider
|
|
|
|
|
|
else "unknown"
|
2026-07-15 13:09:05 +05:30
|
|
|
|
)
|
|
|
|
|
|
if provider not in ("grok", "ultravox"):
|
|
|
|
|
|
usage = item.value
|
2026-07-15 18:36:36 +05:30
|
|
|
|
raw_metadata = getattr(
|
|
|
|
|
|
usage, "raw_usage_metadata", None
|
|
|
|
|
|
)
|
2026-07-15 13:09:05 +05:30
|
|
|
|
if raw_metadata:
|
|
|
|
|
|
# OpenAI Realtime and Azure Realtime (azure→openai via _detect_provider)
|
|
|
|
|
|
# share the same wire format.
|
|
|
|
|
|
if provider in ("openai", "azure"):
|
2026-07-15 18:36:36 +05:30
|
|
|
|
new_meta = (
|
|
|
|
|
|
_openai_realtime_usage_to_sts_metadata(
|
|
|
|
|
|
raw_metadata
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-07-15 13:09:05 +05:30
|
|
|
|
else:
|
2026-07-15 18:36:36 +05:30
|
|
|
|
new_meta = _google_live_usage_to_sts_metadata(
|
|
|
|
|
|
raw_metadata
|
|
|
|
|
|
)
|
2026-07-15 13:09:05 +05:30
|
|
|
|
else:
|
2026-07-15 18:36:36 +05:30
|
|
|
|
prompt_tokens = (
|
|
|
|
|
|
getattr(usage, "prompt_tokens", 0) or 0
|
|
|
|
|
|
)
|
|
|
|
|
|
completion_tokens = (
|
|
|
|
|
|
getattr(usage, "completion_tokens", 0) or 0
|
|
|
|
|
|
)
|
|
|
|
|
|
cached_tokens = (
|
|
|
|
|
|
getattr(usage, "cache_read_input_tokens", 0)
|
|
|
|
|
|
or getattr(usage, "cached_tokens", 0)
|
|
|
|
|
|
or 0
|
|
|
|
|
|
)
|
2026-07-15 13:09:05 +05:30
|
|
|
|
new_meta = {"schemaVersion": 1}
|
|
|
|
|
|
if prompt_tokens > 0:
|
2026-07-15 18:36:36 +05:30
|
|
|
|
new_meta.setdefault("input", {})["text"] = {
|
|
|
|
|
|
"tokens": prompt_tokens
|
|
|
|
|
|
}
|
2026-07-15 13:09:05 +05:30
|
|
|
|
if completion_tokens > 0:
|
2026-07-15 18:36:36 +05:30
|
|
|
|
new_meta.setdefault("output", {})["text"] = {
|
|
|
|
|
|
"tokens": completion_tokens
|
|
|
|
|
|
}
|
2026-07-15 13:09:05 +05:30
|
|
|
|
if cached_tokens > 0:
|
|
|
|
|
|
new_meta["cached"] = {"tokens": cached_tokens}
|
2026-07-15 18:36:36 +05:30
|
|
|
|
|
2026-07-15 13:09:05 +05:30
|
|
|
|
if hasattr(usage, "__dict__"):
|
|
|
|
|
|
for k, v in vars(usage).items():
|
2026-07-15 18:36:36 +05:30
|
|
|
|
if (
|
|
|
|
|
|
not k.startswith("_")
|
|
|
|
|
|
and v is not None
|
|
|
|
|
|
and k not in new_meta
|
|
|
|
|
|
):
|
2026-07-15 13:09:05 +05:30
|
|
|
|
new_meta[k] = v
|
2026-07-15 18:36:36 +05:30
|
|
|
|
|
2026-07-15 13:09:05 +05:30
|
|
|
|
self._acc.sts_usage_metadata = _merge_sts_metadata(
|
|
|
|
|
|
self._acc.sts_usage_metadata or {}, new_meta
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self._acc.add_llm(item.value)
|
|
|
|
|
|
elif isinstance(item, TTSUsageMetricsData):
|
|
|
|
|
|
chars_val = getattr(item, "value", 0) or 0
|
|
|
|
|
|
self._acc.add_tts_metrics(chars_val)
|
|
|
|
|
|
# STT usage is exposed as a float in TTSUsageMetricsData-like
|
|
|
|
|
|
# structure by some providers; we also pull from the aggregator
|
|
|
|
|
|
# snapshot at call-finish (see runtime.py) for robustness.
|
|
|
|
|
|
|
|
|
|
|
|
elif isinstance(frame, TTSTextFrame):
|
|
|
|
|
|
# Fallback character counting for providers that don't emit native TTS metrics.
|
|
|
|
|
|
# TTSTextFrame carries only the text actually sent to the TTS engine;
|
|
|
|
|
|
# using base TextFrame would incorrectly include user transcriptions.
|
|
|
|
|
|
self._acc.add_tts_manual(frame.text)
|
|
|
|
|
|
|
|
|
|
|
|
elif isinstance(frame, UserStartedSpeakingFrame):
|
|
|
|
|
|
# Measure real STT audio seconds from VAD events rather than
|
|
|
|
|
|
# relying on wall-clock time. Skipped for realtime pipelines
|
|
|
|
|
|
# which have no separate STT stage.
|
|
|
|
|
|
if not self._is_realtime:
|
|
|
|
|
|
self._acc.on_user_started_speaking()
|
|
|
|
|
|
|
|
|
|
|
|
elif isinstance(frame, UserStoppedSpeakingFrame):
|
|
|
|
|
|
if not self._is_realtime:
|
|
|
|
|
|
self._acc.on_user_stopped_speaking()
|
|
|
|
|
|
|
|
|
|
|
|
elif isinstance(frame, (EndFrame, CancelFrame)):
|
|
|
|
|
|
self._acc.finalize()
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"[paygent] Unexpected error processing frame {!r} in collector: {}",
|
2026-07-15 18:36:36 +05:30
|
|
|
|
type(data.frame).__name__,
|
|
|
|
|
|
exc,
|
|
|
|
|
|
exc_info=True,
|
2026-07-15 13:09:05 +05:30
|
|
|
|
)
|