2026-07-15 13:09:05 +05:30
|
|
|
|
"""Paygent REST API client (pure httpx, no SDK).
|
|
|
|
|
|
|
|
|
|
|
|
All network I/O goes through ``post_paygent`` which is the single delivery
|
|
|
|
|
|
coroutine used by the completion handler. The individual tracker functions
|
|
|
|
|
|
(session, STT, TTS, LLM, STS, indicator) mirror the exact shape of the
|
|
|
|
|
|
Paygent REST API documented in ``paygent_sdk/voice_client.py``.
|
|
|
|
|
|
"""
|
2026-07-15 18:36:36 +05:30
|
|
|
|
|
2026-07-15 13:09:05 +05:30
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-07-15 18:36:36 +05:30
|
|
|
|
from dataclasses import dataclass
|
2026-07-15 13:09:05 +05:30
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
from pydantic import BaseModel, field_validator
|
|
|
|
|
|
|
|
|
|
|
|
_DEFAULT_BASE_URL = "https://cp-api.withpaygent.com"
|
|
|
|
|
|
_REQUEST_TIMEOUT = 15 # seconds – generous for post-call delivery
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Config model
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PaygentDeliveryConfig(BaseModel):
|
|
|
|
|
|
"""Validated delivery configuration, filled from the node data."""
|
|
|
|
|
|
|
|
|
|
|
|
base_url: str = _DEFAULT_BASE_URL
|
|
|
|
|
|
api_key: str
|
|
|
|
|
|
agent_id: str
|
|
|
|
|
|
customer_id: str
|
|
|
|
|
|
|
|
|
|
|
|
@field_validator("api_key", "agent_id", "customer_id")
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def _must_not_be_empty(cls, value: str) -> str:
|
|
|
|
|
|
if not value or not value.strip():
|
|
|
|
|
|
raise ValueError("must not be empty")
|
|
|
|
|
|
return value.strip()
|
|
|
|
|
|
|
|
|
|
|
|
@field_validator("base_url")
|
|
|
|
|
|
@classmethod
|
|
|
|
|
|
def _normalise_base_url(cls, value: str) -> str:
|
|
|
|
|
|
return (value or _DEFAULT_BASE_URL).rstrip("/")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Live-call snapshot (collected during the call, delivered after)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class PaygentCallSnapshot:
|
|
|
|
|
|
"""Immutable snapshot produced at call-finish; passed to ``deliver``."""
|
|
|
|
|
|
|
|
|
|
|
|
session_id: str
|
|
|
|
|
|
agent_id: str
|
|
|
|
|
|
customer_id: str
|
|
|
|
|
|
is_realtime: bool
|
|
|
|
|
|
|
|
|
|
|
|
# Usage buckets filled from PipelineMetricsAggregator + user_config
|
|
|
|
|
|
stt_provider: str = ""
|
|
|
|
|
|
stt_model: str = ""
|
|
|
|
|
|
stt_audio_seconds: float = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
llm_provider: str = ""
|
|
|
|
|
|
llm_model: str = ""
|
|
|
|
|
|
llm_prompt_tokens: int = 0
|
|
|
|
|
|
llm_completion_tokens: int = 0
|
|
|
|
|
|
llm_cached_tokens: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
tts_provider: str = ""
|
|
|
|
|
|
tts_model: str = ""
|
|
|
|
|
|
tts_characters: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
sts_provider: str = ""
|
|
|
|
|
|
sts_model: str = ""
|
|
|
|
|
|
sts_usage_metadata: dict[str, Any] | None = None
|
|
|
|
|
|
|
|
|
|
|
|
# Final call status / total duration seconds
|
|
|
|
|
|
call_disposition: str = "completed"
|
|
|
|
|
|
total_duration_seconds: int = 0
|
|
|
|
|
|
indicator: str = "per-minute-call"
|
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"session_id": self.session_id,
|
|
|
|
|
|
"agent_id": self.agent_id,
|
|
|
|
|
|
"customer_id": self.customer_id,
|
|
|
|
|
|
"is_realtime": self.is_realtime,
|
|
|
|
|
|
"stt": {
|
|
|
|
|
|
"provider": self.stt_provider,
|
|
|
|
|
|
"model": self.stt_model,
|
|
|
|
|
|
"audio_seconds": self.stt_audio_seconds,
|
|
|
|
|
|
},
|
|
|
|
|
|
"llm": {
|
|
|
|
|
|
"provider": self.llm_provider,
|
|
|
|
|
|
"model": self.llm_model,
|
|
|
|
|
|
"prompt_tokens": self.llm_prompt_tokens,
|
|
|
|
|
|
"completion_tokens": self.llm_completion_tokens,
|
|
|
|
|
|
"cached_tokens": self.llm_cached_tokens,
|
|
|
|
|
|
},
|
|
|
|
|
|
"tts": {
|
|
|
|
|
|
"provider": self.tts_provider,
|
|
|
|
|
|
"model": self.tts_model,
|
|
|
|
|
|
"characters": self.tts_characters,
|
|
|
|
|
|
},
|
|
|
|
|
|
"sts": {
|
|
|
|
|
|
"provider": self.sts_provider,
|
|
|
|
|
|
"model": self.sts_model,
|
|
|
|
|
|
"usage_metadata": self.sts_usage_metadata,
|
|
|
|
|
|
},
|
|
|
|
|
|
"call_disposition": self.call_disposition,
|
|
|
|
|
|
"total_duration_seconds": self.total_duration_seconds,
|
|
|
|
|
|
"indicator": self.indicator,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# REST delivery helpers
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _headers(api_key: str) -> dict[str, str]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
|
"paygent-api-key": api_key,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _post(
|
|
|
|
|
|
client: httpx.AsyncClient,
|
|
|
|
|
|
url: str,
|
|
|
|
|
|
api_key: str,
|
|
|
|
|
|
payload: dict[str, Any],
|
|
|
|
|
|
*,
|
|
|
|
|
|
label: str,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""POST ``payload`` to ``url``; raises on 4xx/5xx or network failure.
|
|
|
|
|
|
|
|
|
|
|
|
Intentionally non-swallowing: callers in ``deliver()`` each wrap this in
|
|
|
|
|
|
their own try/except to build the ``errors`` list and the ``status`` field.
|
|
|
|
|
|
"""
|
|
|
|
|
|
resp = await client.post(url, json=payload, headers=_headers(api_key))
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def deliver(
|
|
|
|
|
|
config: PaygentDeliveryConfig,
|
|
|
|
|
|
snapshot: PaygentCallSnapshot,
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Execute the full Paygent REST call sequence for one completed call:
|
|
|
|
|
|
|
|
|
|
|
|
1. initialize_voice_session
|
|
|
|
|
|
2. track_stt (if STT is used, i.e. not realtime-only)
|
|
|
|
|
|
3. track_llm
|
|
|
|
|
|
4. track_tts (if TTS is used, i.e. not realtime-only)
|
|
|
|
|
|
5. track_sts (if realtime / STS model used)
|
|
|
|
|
|
6. set_indicator (always; marks end of session)
|
|
|
|
|
|
|
|
|
|
|
|
Returns a result dict merged into ``workflow_run.annotations``.
|
|
|
|
|
|
"""
|
|
|
|
|
|
base = config.base_url
|
|
|
|
|
|
api_key = config.api_key
|
|
|
|
|
|
session_id = snapshot.session_id
|
|
|
|
|
|
|
|
|
|
|
|
delivered_steps: list[str] = []
|
|
|
|
|
|
errors: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client:
|
|
|
|
|
|
# 1. Initialize voice session ----------------------------------------
|
|
|
|
|
|
try:
|
|
|
|
|
|
await _post(
|
|
|
|
|
|
client,
|
|
|
|
|
|
f"{base}/api/v1/voice/session",
|
|
|
|
|
|
api_key,
|
|
|
|
|
|
{
|
|
|
|
|
|
"sessionId": session_id,
|
|
|
|
|
|
"agentId": snapshot.agent_id,
|
|
|
|
|
|
"customerId": snapshot.customer_id,
|
|
|
|
|
|
},
|
|
|
|
|
|
label="initialize_voice_session",
|
|
|
|
|
|
)
|
|
|
|
|
|
delivered_steps.append("session_init")
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
errors.append(f"session_init: {exc}")
|
|
|
|
|
|
|
|
|
|
|
|
# 2. Track STT (only for non-realtime pipelines) ---------------------
|
|
|
|
|
|
if not snapshot.is_realtime and snapshot.stt_audio_seconds > 0:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await _post(
|
|
|
|
|
|
client,
|
|
|
|
|
|
f"{base}/api/v1/voice/stt",
|
|
|
|
|
|
api_key,
|
|
|
|
|
|
{
|
|
|
|
|
|
"sessionId": session_id,
|
|
|
|
|
|
"audioMinutes": snapshot.stt_audio_seconds / 60.0,
|
|
|
|
|
|
"provider": snapshot.stt_provider,
|
|
|
|
|
|
"model": snapshot.stt_model,
|
|
|
|
|
|
"plan": "",
|
|
|
|
|
|
},
|
|
|
|
|
|
label="track_stt",
|
|
|
|
|
|
)
|
|
|
|
|
|
delivered_steps.append("track_stt")
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
errors.append(f"track_stt: {exc}")
|
|
|
|
|
|
|
|
|
|
|
|
# 3. Track LLM -------------------------------------------------------
|
|
|
|
|
|
if snapshot.llm_prompt_tokens > 0 or snapshot.llm_completion_tokens > 0:
|
|
|
|
|
|
llm_payload: dict[str, Any] = {
|
|
|
|
|
|
"sessionId": session_id,
|
|
|
|
|
|
"provider": snapshot.llm_provider,
|
|
|
|
|
|
"model": snapshot.llm_model,
|
|
|
|
|
|
"plan": "",
|
|
|
|
|
|
"promptTokens": snapshot.llm_prompt_tokens,
|
|
|
|
|
|
"completionTokens": snapshot.llm_completion_tokens,
|
|
|
|
|
|
}
|
|
|
|
|
|
if snapshot.llm_cached_tokens > 0:
|
|
|
|
|
|
llm_payload["cachedTokens"] = snapshot.llm_cached_tokens
|
|
|
|
|
|
try:
|
|
|
|
|
|
await _post(
|
|
|
|
|
|
client,
|
|
|
|
|
|
f"{base}/api/v1/voice/llm",
|
|
|
|
|
|
api_key,
|
|
|
|
|
|
llm_payload,
|
|
|
|
|
|
label="track_llm",
|
|
|
|
|
|
)
|
|
|
|
|
|
delivered_steps.append("track_llm")
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
errors.append(f"track_llm: {exc}")
|
|
|
|
|
|
|
|
|
|
|
|
# 4. Track TTS (only for non-realtime pipelines) ---------------------
|
|
|
|
|
|
if not snapshot.is_realtime and snapshot.tts_characters > 0:
|
|
|
|
|
|
try:
|
|
|
|
|
|
await _post(
|
|
|
|
|
|
client,
|
|
|
|
|
|
f"{base}/api/v1/voice/tts",
|
|
|
|
|
|
api_key,
|
|
|
|
|
|
{
|
|
|
|
|
|
"sessionId": session_id,
|
|
|
|
|
|
"provider": snapshot.tts_provider,
|
|
|
|
|
|
"model": snapshot.tts_model,
|
|
|
|
|
|
"plan": "",
|
|
|
|
|
|
"characters": snapshot.tts_characters,
|
|
|
|
|
|
},
|
|
|
|
|
|
label="track_tts",
|
|
|
|
|
|
)
|
|
|
|
|
|
delivered_steps.append("track_tts")
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
errors.append(f"track_tts: {exc}")
|
|
|
|
|
|
|
|
|
|
|
|
# 5. Track STS (Speech-to-Speech) for Realtime Models ----------------
|
|
|
|
|
|
if snapshot.is_realtime:
|
|
|
|
|
|
metadata = snapshot.sts_usage_metadata or {}
|
|
|
|
|
|
# Only append connection minutes if we don't already have a rich token payload
|
|
|
|
|
|
# (e.g. from OpenAI Realtime or Gemini Live)
|
2026-07-15 18:36:36 +05:30
|
|
|
|
if (
|
|
|
|
|
|
"connection" not in metadata
|
|
|
|
|
|
and "prompt_tokens" not in metadata
|
|
|
|
|
|
and "input" not in metadata
|
|
|
|
|
|
):
|
|
|
|
|
|
metadata["connection"] = {
|
|
|
|
|
|
"minutes": snapshot.total_duration_seconds / 60.0
|
|
|
|
|
|
}
|
2026-07-15 13:09:05 +05:30
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
await _post(
|
|
|
|
|
|
client,
|
|
|
|
|
|
f"{base}/api/v1/voice/speech-to-speech",
|
|
|
|
|
|
api_key,
|
|
|
|
|
|
{
|
|
|
|
|
|
"sessionId": session_id,
|
|
|
|
|
|
"provider": snapshot.sts_provider,
|
|
|
|
|
|
"model": snapshot.sts_model,
|
|
|
|
|
|
"plan": "",
|
|
|
|
|
|
"usageMetadata": metadata,
|
|
|
|
|
|
},
|
|
|
|
|
|
label="track_sts",
|
|
|
|
|
|
)
|
|
|
|
|
|
delivered_steps.append("track_sts")
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
errors.append(f"track_sts: {exc}")
|
|
|
|
|
|
|
|
|
|
|
|
# 6. Set indicator (end-of-session marker) ---------------------------
|
|
|
|
|
|
try:
|
|
|
|
|
|
await _post(
|
|
|
|
|
|
client,
|
|
|
|
|
|
f"{base}/api/v1/voice/indicator",
|
|
|
|
|
|
api_key,
|
|
|
|
|
|
{
|
|
|
|
|
|
"sessionId": session_id,
|
|
|
|
|
|
"indicator": snapshot.indicator,
|
|
|
|
|
|
"totalDuration": snapshot.total_duration_seconds / 60.0,
|
|
|
|
|
|
},
|
|
|
|
|
|
label="set_indicator",
|
|
|
|
|
|
)
|
|
|
|
|
|
delivered_steps.append("set_indicator")
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
errors.append(f"set_indicator: {exc}")
|
|
|
|
|
|
|
|
|
|
|
|
return _result(session_id, delivered_steps, errors)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _result(
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
delivered_steps: list[str],
|
|
|
|
|
|
errors: list[str],
|
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"session_id": session_id,
|
|
|
|
|
|
"delivered_steps": delivered_steps,
|
|
|
|
|
|
"errors": errors,
|
|
|
|
|
|
"status": "ok" if not errors else ("partial" if delivered_steps else "failed"),
|
|
|
|
|
|
}
|