mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-16 11:31:04 +02:00
* fix: fix transition logic for realtime providers * chore: run formatter * chore: generate SDK and fix other realtime providers * fix: fix ultravox node transitions
193 lines
8.4 KiB
Python
193 lines
8.4 KiB
Python
"""Paygent post-call completion handler.
|
|
|
|
Reads the ``paygent_snapshot`` that the runtime session stored in
|
|
``workflow_run.logs``, reconstructs the full ``PaygentCallSnapshot``, and
|
|
drives the ordered REST delivery sequence via ``client.deliver()``.
|
|
|
|
Mirrors ``tuner/completion.py`` exactly:
|
|
- validate each node with Pydantic
|
|
- skip disabled nodes
|
|
- read runtime snapshot from ``context.workflow_run.logs``
|
|
- build a ``PaygentDeliveryConfig`` per node
|
|
- call ``deliver(config, snapshot)``
|
|
- collect results keyed by ``paygent_{node_id}``
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from loguru import logger
|
|
|
|
from api.services.integrations.base import IntegrationCompletionContext
|
|
|
|
from .client import PaygentCallSnapshot, PaygentDeliveryConfig, deliver
|
|
from .node import PaygentNodeData
|
|
|
|
_DEFAULT_BASE_URL = "https://cp-api.withpaygent.com"
|
|
|
|
|
|
def _build_snapshot(
|
|
raw: dict[str, Any],
|
|
*,
|
|
workflow_run_id: int,
|
|
) -> PaygentCallSnapshot:
|
|
"""Reconstruct a ``PaygentCallSnapshot`` from the persisted log dict."""
|
|
return PaygentCallSnapshot(
|
|
# session_id is always the authoritative workflow_run_id; the persisted
|
|
# snapshot value is never used to override it, preventing billing drift
|
|
# if the log is stale or corrupted.
|
|
session_id=str(workflow_run_id),
|
|
agent_id=raw.get("agent_id", ""), # filled from node config below
|
|
customer_id=raw.get("customer_id", ""), # filled from node config below
|
|
is_realtime=raw.get("is_realtime", False),
|
|
stt_provider=raw.get("stt_provider", ""),
|
|
stt_model=raw.get("stt_model", ""),
|
|
stt_audio_seconds=float(raw.get("stt_audio_seconds", 0.0)),
|
|
llm_provider=raw.get("llm_provider", ""),
|
|
llm_model=raw.get("llm_model", ""),
|
|
llm_prompt_tokens=int(raw.get("llm_prompt_tokens", 0)),
|
|
llm_completion_tokens=int(raw.get("llm_completion_tokens", 0)),
|
|
llm_cached_tokens=int(raw.get("llm_cached_tokens", 0)),
|
|
tts_provider=raw.get("tts_provider", ""),
|
|
tts_model=raw.get("tts_model", ""),
|
|
tts_characters=int(raw.get("tts_characters", 0)),
|
|
sts_provider=raw.get("sts_provider", ""),
|
|
sts_model=raw.get("sts_model", ""),
|
|
sts_usage_metadata=raw.get("sts_usage_metadata"),
|
|
call_disposition=raw.get("call_disposition", "completed"),
|
|
total_duration_seconds=int(raw.get("total_duration_seconds", 0)),
|
|
)
|
|
|
|
|
|
async def run_completion(
|
|
nodes: list[dict[str, Any]],
|
|
context: IntegrationCompletionContext,
|
|
) -> dict[str, Any]:
|
|
"""Post-call completion handler: deliver usage data to Paygent REST API."""
|
|
results: dict[str, Any] = {}
|
|
|
|
raw_snapshot: dict[str, Any] | None = (context.workflow_run.logs or {}).get(
|
|
"paygent_snapshot"
|
|
)
|
|
|
|
for node in nodes:
|
|
node_id = node.get("id", "unknown")
|
|
|
|
# ---- Validate the node config via Pydantic -------------------------
|
|
try:
|
|
node_data = PaygentNodeData.model_validate(node.get("data", {}))
|
|
except Exception:
|
|
results[f"paygent_{node_id}"] = {"error": "validation_failed"}
|
|
continue
|
|
|
|
if not node_data.paygent_enabled:
|
|
continue
|
|
|
|
# ---- Guard: runtime snapshot must exist ----------------------------
|
|
if not raw_snapshot:
|
|
results[f"paygent_{node_id}"] = {"error": "missing_runtime_snapshot"}
|
|
continue
|
|
|
|
# ---- Build typed objects -------------------------------------------
|
|
snapshot = _build_snapshot(
|
|
raw_snapshot, workflow_run_id=context.workflow_run_id
|
|
)
|
|
# Inject node-level credentials into the snapshot
|
|
snapshot.agent_id = (node_data.paygent_agent_id or "").strip()
|
|
snapshot.customer_id = (node_data.paygent_customer_id or "").strip()
|
|
snapshot.indicator = (node_data.paygent_indicator or "per-minute-call").strip()
|
|
|
|
# Fallback to usage_info if snapshot has 0s (Pipecat metrics might be missing)
|
|
usage_info = context.workflow_run.usage_info or {}
|
|
try:
|
|
# Only fallback to pipeline-level llm usage if this is NOT a realtime pipeline.
|
|
# In realtime pipelines, the collector properly segregates STS and LLM tokens;
|
|
# falling back here would duplicate the STS tokens into the LLM bucket.
|
|
if (
|
|
snapshot.llm_prompt_tokens == 0
|
|
and snapshot.llm_completion_tokens == 0
|
|
and not snapshot.is_realtime
|
|
):
|
|
llm_providers: list[str] = []
|
|
llm_models: list[str] = []
|
|
for key, val in usage_info.get("llm", {}).items():
|
|
# Skip post-call QA analysis entries — they must not be billed
|
|
# as in-conversation LLM usage.
|
|
if key.startswith("QAAnalysis|||"):
|
|
continue
|
|
snapshot.llm_prompt_tokens += val.get("prompt_tokens", 0)
|
|
snapshot.llm_completion_tokens += val.get("completion_tokens", 0)
|
|
snapshot.llm_cached_tokens += val.get(
|
|
"cache_read_input_tokens", 0
|
|
) + val.get("cache_creation_input_tokens", 0)
|
|
parts = key.split("|||")
|
|
if len(parts) == 2:
|
|
llm_providers.append(parts[0])
|
|
llm_models.append(parts[1])
|
|
if not snapshot.llm_provider and llm_providers:
|
|
snapshot.llm_provider = ",".join(dict.fromkeys(llm_providers))
|
|
if not snapshot.llm_model and llm_models:
|
|
snapshot.llm_model = ",".join(dict.fromkeys(llm_models))
|
|
|
|
if snapshot.tts_characters == 0:
|
|
tts_providers: list[str] = []
|
|
tts_models: list[str] = []
|
|
for key, val in usage_info.get("tts", {}).items():
|
|
snapshot.tts_characters += val
|
|
parts = key.split("|||")
|
|
if len(parts) == 2:
|
|
tts_providers.append(parts[0])
|
|
tts_models.append(parts[1])
|
|
if not snapshot.tts_provider and tts_providers:
|
|
snapshot.tts_provider = ",".join(dict.fromkeys(tts_providers))
|
|
if not snapshot.tts_model and tts_models:
|
|
snapshot.tts_model = ",".join(dict.fromkeys(tts_models))
|
|
|
|
if snapshot.stt_audio_seconds == 0:
|
|
stt_providers: list[str] = []
|
|
stt_models: list[str] = []
|
|
for key, val in usage_info.get("stt", {}).items():
|
|
snapshot.stt_audio_seconds += val
|
|
parts = key.split("|||")
|
|
if len(parts) == 2:
|
|
stt_providers.append(parts[0])
|
|
stt_models.append(parts[1])
|
|
if not snapshot.stt_provider and stt_providers:
|
|
snapshot.stt_provider = ",".join(dict.fromkeys(stt_providers))
|
|
if not snapshot.stt_model and stt_models:
|
|
snapshot.stt_model = ",".join(dict.fromkeys(stt_models))
|
|
# Note: if STT audio seconds remain 0 after all fallbacks, we do NOT
|
|
# substitute total_duration_seconds — that would overbill wall-clock time
|
|
# (silence, hold, agent speech) as STT input.
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"[paygent] Failed to apply usage_info fallback for run {}: {}",
|
|
context.workflow_run_id,
|
|
exc,
|
|
)
|
|
|
|
try:
|
|
config = PaygentDeliveryConfig(
|
|
api_key=(node_data.paygent_api_key or "").strip(),
|
|
agent_id=snapshot.agent_id,
|
|
customer_id=snapshot.customer_id,
|
|
)
|
|
except Exception as exc:
|
|
results[f"paygent_{node_id}"] = {"error": f"invalid_config: {exc}"}
|
|
continue
|
|
|
|
# ---- REST delivery -------------------------------------------------
|
|
try:
|
|
delivery_result = await deliver(config, snapshot)
|
|
results[f"paygent_{node_id}"] = {
|
|
**delivery_result,
|
|
"agent_id": snapshot.agent_id,
|
|
"customer_id": snapshot.customer_id,
|
|
"exported_at": datetime.now(UTC).isoformat(),
|
|
}
|
|
except Exception as exc:
|
|
results[f"paygent_{node_id}"] = {"error": str(exc)}
|
|
|
|
return results
|