From 3739ebaf21e3c808c6a132c97085d6fac617d521 Mon Sep 17 00:00:00 2001 From: Nihalkumar Dwivedi <77735813+nihal0514@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:09:05 +0530 Subject: [PATCH] Paygent integration new with revert pipecat/realtime changes (#539) * added paygent integration * fix(paygent): resolve PR code review issues for cost tracking and billing * docs(integrations): add Paygent integration guide * docs(paygent): align step 1 with 3-step agent creation UI screenshots * removed pipecat/realtime changes and review comments solved --- api/services/integrations/paygent/__init__.py | 31 + api/services/integrations/paygent/client.py | 310 ++++++++++ .../integrations/paygent/collector.py | 583 ++++++++++++++++++ .../integrations/paygent/completion.py | 180 ++++++ api/services/integrations/paygent/node.py | 149 +++++ api/services/integrations/paygent/runtime.py | 142 +++++ docs/docs.json | 3 +- docs/images/paygent-create-agent-image-1.webp | Bin 0 -> 30442 bytes docs/images/paygent-create-agent-image-2.webp | Bin 0 -> 10670 bytes docs/images/paygent-create-agent-image-3.webp | Bin 0 -> 22304 bytes docs/integrations/paygent.mdx | 106 ++++ 11 files changed, 1503 insertions(+), 1 deletion(-) create mode 100644 api/services/integrations/paygent/__init__.py create mode 100644 api/services/integrations/paygent/client.py create mode 100644 api/services/integrations/paygent/collector.py create mode 100644 api/services/integrations/paygent/completion.py create mode 100644 api/services/integrations/paygent/node.py create mode 100644 api/services/integrations/paygent/runtime.py create mode 100644 docs/images/paygent-create-agent-image-1.webp create mode 100644 docs/images/paygent-create-agent-image-2.webp create mode 100644 docs/images/paygent-create-agent-image-3.webp create mode 100644 docs/integrations/paygent.mdx diff --git a/api/services/integrations/paygent/__init__.py b/api/services/integrations/paygent/__init__.py new file mode 100644 index 00000000..79394ca1 --- /dev/null +++ b/api/services/integrations/paygent/__init__.py @@ -0,0 +1,31 @@ +"""Paygent integration package. + +Self-registers on import via ``register_package``. Auto-discovered by +``api/services/integrations/loader.py`` (scans all submodules of +``api.services.integrations`` except ``base``, ``loader``, and ``registry``). + +Provides: +- ``PaygentNodeData`` – Pydantic config node shown in the Dograh UI under + INTEGRATIONS → "Paygent" +- ``create_runtime_sessions`` – live-call observer that accumulates usage data +- ``run_completion`` – post-call REST delivery to the Paygent API +""" +from __future__ import annotations + +from api.services.integrations.base import IntegrationPackageSpec +from api.services.integrations.registry import register_package + +from .completion import run_completion +from .node import NODE +from .runtime import create_runtime_sessions + +PACKAGE = register_package( + IntegrationPackageSpec( + name="paygent", + nodes=(NODE,), + create_runtime_sessions=create_runtime_sessions, + run_completion=run_completion, + ) +) + +__all__ = ["PACKAGE"] diff --git a/api/services/integrations/paygent/client.py b/api/services/integrations/paygent/client.py new file mode 100644 index 00000000..e39e3263 --- /dev/null +++ b/api/services/integrations/paygent/client.py @@ -0,0 +1,310 @@ +"""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``. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import httpx +from loguru import logger +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) + 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} + + 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"), + } diff --git a/api/services/integrations/paygent/collector.py b/api/services/integrations/paygent/collector.py new file mode 100644 index 00000000..bf8473fc --- /dev/null +++ b/api/services/integrations/paygent/collector.py @@ -0,0 +1,583 @@ +"""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`` +""" +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 = [ + "service", "multimodallive", "realtime", + "vertex", "llm", "tts", "stt", "helper", "transport" + ] + changed = True + while changed: + changed = False + for suffix in suffixes: + if clean_name.endswith(suffix): + clean_name = clean_name[:-len(suffix)].rstrip("_").rstrip("-") + 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) + + @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) + + 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 + + # 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 + + try: + self.tts_characters += int(val or 0) + except Exception as exc: + logger.warning("[paygent] Failed to accumulate TTS characters (val={!r}): {}", val, exc) + + 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: + elapsed_s = (time.time_ns() - self._user_started_speaking_ns) / 1_000_000_000 + 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): + if k in obj: return obj[k] + else: + if hasattr(obj, k): return getattr(obj, k) + 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") + response_details = _get_list(usage, "response_tokens_details", "responseTokensDetails") + tool_details = _get_list(usage, "tool_use_prompt_tokens_details", "toolUsePromptTokensDetails") + cache_details = _get_list(usage, "cache_tokens_details", "cacheTokensDetails") + + # input side: TEXT + DOCUMENT + AUDIO + IMAGE + VIDEO + 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") + text_in += doc_as_text + + # fallback aggregate mapping + tutc = _optional_int(usage, "tool_use_prompt_token_count", "toolUsePromptTokenCount") + 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 + 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") + + 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. + thinking_tokens = _optional_int( + usage, + "thoughts_token_count", "thoughtsTokenCount", + "thinking_token_count", "thinkingTokenCount", + ) or 0 + + # Cache breakdowns + 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") + cached_image = _modality_token_count(cache_details, "IMAGE") + cached_legacy = _optional_int(usage, "cached_content_token_count", "cachedContentTokenCount") + + # Build response payload + out = {"schemaVersion": 1} + + # Input Side + inp = {} + 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 + + # Output Side + o = {} + 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 + + # 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 = {} + 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 + + 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): + if k in obj: return obj[k] + else: + if hasattr(obj, k): return getattr(obj, k) + 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) + + cached_total = int(_get_val(usage, "cached_tokens", "cachedTokens") or _get_val(in_details, "cached_tokens", "cachedTokens") or 0) + + cached_details = _get_val(in_details, "cached_tokens_details", "cachedTokensDetails") or {} + 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): + 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) + + 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 = {} + 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 + + o = {} + if text_out > 0: o["text"] = {"tokens": text_out} + if audio_out > 0: o["audio"] = {"tokens": audio_out} + if o: out["output"] = o + + 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 = {} + 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 + + 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 + + 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). + 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")) + + 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 + + # 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"): + if k in out and isinstance(out[k], (int, float)) and isinstance(v, (int, float)): + out[k] = out[k] + v + else: + out[k] = v + + return out + +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. + raw_provider = ( + getattr(self, "_sts_provider", "") or getattr(self, "_llm_provider", "") + ) + provider = _detect_provider(raw_provider) if raw_provider else "unknown" + if provider not in ("grok", "ultravox"): + usage = item.value + raw_metadata = getattr(usage, "raw_usage_metadata", None) + if raw_metadata: + # OpenAI Realtime and Azure Realtime (azure→openai via _detect_provider) + # share the same wire format. + if provider in ("openai", "azure"): + new_meta = _openai_realtime_usage_to_sts_metadata(raw_metadata) + else: + new_meta = _google_live_usage_to_sts_metadata(raw_metadata) + else: + 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) + new_meta = {"schemaVersion": 1} + if prompt_tokens > 0: + new_meta.setdefault("input", {})["text"] = {"tokens": prompt_tokens} + if completion_tokens > 0: + new_meta.setdefault("output", {})["text"] = {"tokens": completion_tokens} + if cached_tokens > 0: + new_meta["cached"] = {"tokens": cached_tokens} + + if hasattr(usage, "__dict__"): + for k, v in vars(usage).items(): + if not k.startswith("_") and v is not None and k not in new_meta: + new_meta[k] = v + + 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: {}", + type(data.frame).__name__, exc, exc_info=True, + ) diff --git a/api/services/integrations/paygent/completion.py b/api/services/integrations/paygent/completion.py new file mode 100644 index 00000000..4893f663 --- /dev/null +++ b/api/services/integrations/paygent/completion.py @@ -0,0 +1,180 @@ +"""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 diff --git a/api/services/integrations/paygent/node.py b/api/services/integrations/paygent/node.py new file mode 100644 index 00000000..8ecd2c8d --- /dev/null +++ b/api/services/integrations/paygent/node.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from pydantic import model_validator + +from api.services.integrations.base import IntegrationNodeRegistration +from api.services.workflow.node_data import BaseNodeData +from api.services.workflow.node_specs._base import ( + GraphConstraints, + NodeCategory, + NodeExample, + PropertyType, +) +from api.services.workflow.node_specs.model_spec import ( + build_spec, + node_spec, + spec_field, +) + + +@node_spec( + name="paygent", + display_name="Paygent", + description="Cost Tracking and Billing", + llm_hint=( + "Paygent is a post-call usage-tracking and billing integration. " + "It does not participate in the conversation graph and should not be connected to other nodes." + ), + category=NodeCategory.integration, + icon="CreditCard", + examples=[ + NodeExample( + name="paygent_tracking", + data={ + "name": "Paygent Tracking", + "paygent_enabled": True, + "paygent_api_key": "pg_live_xxxxxxxxxxxxxxxx", + "paygent_agent_id": "my-voice-agent-prod", + "paygent_customer_id": "org-123", + "paygent_indicator": "per-minute-call", + }, + ) + ], + graph_constraints=GraphConstraints( + min_incoming=0, max_incoming=0, min_outgoing=0, max_outgoing=0, max_instances=1 + ), + property_order=( + "name", + "paygent_enabled", + "paygent_api_key", + "paygent_agent_id", + "paygent_customer_id", + "paygent_indicator", + ), + field_overrides={ + "name": { + "spec_default": "Paygent", + "description": "Short identifier for this Paygent configuration.", + }, + "paygent_enabled": { + "display_name": "Enabled", + "description": "When false, Dograh skips all Paygent tracking for this call.", + }, + "paygent_api_key": { + "display_name": "Paygent API Key", + "description": "API key used to authenticate requests to the Paygent REST API.", + "required": True, + }, + "paygent_agent_id": { + "display_name": "Agent ID", + "description": "The agent identifier registered in your Paygent account.", + "required": True, + }, + "paygent_customer_id": { + "display_name": "Customer ID", + "description": "Your Paygent customer / organisation ID.", + "required": True, + }, + "paygent_indicator": { + "display_name": "Indicator", + "description": "The indicator event name sent at the end of the call (e.g. per-minute-call).", + "required": True, + "spec_default": "per-minute-call", + }, + }, +) +class PaygentNodeData(BaseNodeData): + paygent_enabled: bool = spec_field( + default=True, + ui_type=PropertyType.boolean, + display_name="Enabled", + description="When false, Dograh skips all Paygent tracking for this call.", + ) + paygent_api_key: str | None = spec_field( + default=None, + ui_type=PropertyType.string, + display_name="Paygent API Key", + description="API key used to authenticate requests to the Paygent REST API.", + ) + paygent_agent_id: str | None = spec_field( + default=None, + ui_type=PropertyType.string, + display_name="Agent ID", + description="The agent identifier registered in your Paygent account.", + ) + paygent_customer_id: str | None = spec_field( + default=None, + ui_type=PropertyType.string, + display_name="Customer ID", + description="Your Paygent customer / organisation ID.", + ) + paygent_indicator: str = spec_field( + default="per-minute-call", + ui_type=PropertyType.string, + display_name="Indicator", + description="The indicator event name sent at the end of the call (e.g. per-minute-call).", + ) + + @model_validator(mode="after") + def _validate_enabled_config(self) -> "PaygentNodeData": + if not self.paygent_enabled: + return self + + missing: list[str] = [] + if not self.paygent_api_key or not self.paygent_api_key.strip(): + missing.append("paygent_api_key") + if not self.paygent_agent_id or not self.paygent_agent_id.strip(): + missing.append("paygent_agent_id") + if not self.paygent_customer_id or not self.paygent_customer_id.strip(): + missing.append("paygent_customer_id") + if not self.paygent_indicator or not self.paygent_indicator.strip(): + missing.append("paygent_indicator") + + if missing: + fields = ", ".join(missing) + raise ValueError( + f"Paygent node is enabled but missing required fields: {fields}" + ) + + return self + + +SPEC = build_spec(PaygentNodeData) + +NODE = IntegrationNodeRegistration( + type_name="paygent", + data_model=PaygentNodeData, + node_spec=SPEC, + sensitive_fields=("paygent_api_key",), +) diff --git a/api/services/integrations/paygent/runtime.py b/api/services/integrations/paygent/runtime.py new file mode 100644 index 00000000..14f0a2f5 --- /dev/null +++ b/api/services/integrations/paygent/runtime.py @@ -0,0 +1,142 @@ +"""Paygent runtime session. + +Wires the ``PaygentCollector`` into the live pipecat pipeline exactly the way +``TunerRuntimeSession`` wires ``TunerCollector``. + +Lifecycle: + 1. ``create_runtime_sessions`` scans the workflow graph for an enabled + ``paygent`` node and, if found, builds a collector from context metadata. + 2. ``attach`` hooks the collector into the task as a pipeline observer so it + receives all ``MetricsFrame`` events during the call. + 3. ``on_call_finished`` seals the snapshot and returns it to the generic + integration framework, which persists it in ``workflow_run.logs`` under + the key ``"paygent_snapshot"``. +""" +from __future__ import annotations + +from typing import Any + +from api.services.configuration.registry import ServiceProviders +from api.services.integrations.base import ( + IntegrationRuntimeContext, + IntegrationRuntimeSession, +) + +from .collector import PaygentCollector + + +def _label(provider: str | None, model: str | None) -> str: + """Compose a human-readable ``provider/model`` label.""" + if provider and model: + return f"{provider}/{model}" + return model or provider or "" + + +def _resolve_model_labels( + context: IntegrationRuntimeContext, +) -> tuple[str, str, str, str, str, str, str, str]: + """Return (stt_provider, stt_model, llm_provider, llm_model, + tts_provider, tts_model, sts_provider, sts_model). + + Mirrors the logic in ``tuner/runtime.py:_resolve_model_labels``. + """ + user_config = context.user_config + + if context.is_realtime and user_config.realtime: + realtime_provider = getattr(user_config.realtime, "provider", "") or "" + realtime_model = getattr(user_config.realtime, "model", "") or "" + llm_provider = getattr(user_config.llm, "provider", "") or "" + llm_model = getattr(user_config.llm, "model", "") or "" + return ( + "", # stt_provider (no separate STT in realtime) + "", # stt_model + llm_provider, + llm_model, + "", # tts_provider (no separate TTS in realtime) + "", # tts_model + realtime_provider, + realtime_model, + ) + + return ( + getattr(user_config.stt, "provider", "") or "", + getattr(user_config.stt, "model", "") or "", + getattr(user_config.llm, "provider", "") or "", + getattr(user_config.llm, "model", "") or "", + getattr(user_config.tts, "provider", "") or "", + getattr(user_config.tts, "model", "") or "", + "", # sts_provider + "", # sts_model + ) + + +class PaygentRuntimeSession(IntegrationRuntimeSession): + """Thin wrapper that connects the collector to the pipeline task.""" + + name = "paygent" + + def __init__(self, collector: PaygentCollector) -> None: + self._collector = collector + + # --- IntegrationRuntimeSession protocol -------------------------------- + + def attach(self, task: Any) -> None: + """Register the collector as a pipeline observer.""" + task.add_observer(self._collector) + + async def on_call_finished( + self, + *, + gathered_context: dict[str, Any], + ) -> dict[str, Any] | None: + """Seal the snapshot and hand it to the framework for persistence.""" + self._collector.set_call_disposition( + gathered_context.get("call_disposition") + ) + snapshot = self._collector.build_snapshot() + return {"paygent_snapshot": snapshot} + + +# --------------------------------------------------------------------------- +# Runtime session factory (called by the generic integration framework) +# --------------------------------------------------------------------------- + + +def create_runtime_sessions( + context: IntegrationRuntimeContext, +) -> list[IntegrationRuntimeSession]: + """Return a ``PaygentRuntimeSession`` if a live, enabled paygent node exists.""" + paygent_nodes = [ + node + for node in context.workflow_graph.nodes.values() + if node.node_type == "paygent" + and getattr(node.data, "paygent_enabled", True) + ] + if not paygent_nodes: + return [] + + ( + stt_provider, + stt_model, + llm_provider, + llm_model, + tts_provider, + tts_model, + sts_provider, + sts_model, + ) = _resolve_model_labels(context) + + collector = PaygentCollector( + workflow_run_id=context.workflow_run_id, + is_realtime=context.is_realtime, + stt_provider=stt_provider, + stt_model=stt_model, + llm_provider=llm_provider, + llm_model=llm_model, + tts_provider=tts_provider, + tts_model=tts_model, + sts_provider=sts_provider, + sts_model=sts_model, + ) + + return [PaygentRuntimeSession(collector)] diff --git a/docs/docs.json b/docs/docs.json index 4dd3a2e6..61cac8da 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -117,7 +117,8 @@ "tag": "NEW", "pages": [ "integrations/mcp", - "integrations/tuner" + "integrations/tuner", + "integrations/paygent" ] } ] diff --git a/docs/images/paygent-create-agent-image-1.webp b/docs/images/paygent-create-agent-image-1.webp new file mode 100644 index 0000000000000000000000000000000000000000..15a944acef093bdcf7eb762dc16418af58250527 GIT binary patch literal 30442 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 SGb)&9IUE`O--9NH1N7-2n literal 0 HcmV?d00001 diff --git a/docs/images/paygent-create-agent-image-3.webp b/docs/images/paygent-create-agent-image-3.webp new file mode 100644 index 0000000000000000000000000000000000000000..b51d3356918ff3b23f0a500d3ce26ff6ae99e507 GIT binary patch literal 22304 zcmb@uWmH_v5;nSF26uONcX!v|?(S|uf&?dl5S$>v-Q6v?1Pc<}ogfh)=$+)1^PY3Q z^WFR7&RRYF?Aq1URn^ti)oa!o4Fy?Qd|d$0m624_QRCM`1OR{;(gwi*2m~ldNvUn3 zK^_6LO>;LlCunp4aCY(X(2$cP*V8v3hdTzK05E_G-~x>1mY!}BYHG^Axc};Z;eUOu z79iY^&nK2&vi?Q?|Aa8CtUWCO07?zQZDHkU=?uY*008c^bo1~609ZE&o$9%l+b^69 z!Pp)U1tGZf7q|G#O9D+rion5UUI0S;}Av};d(SFJ2xA=z~+kaqlbBn)anw#7HjsLI(G7>Vew7sXB zleypThyOpnoSnQO{`&o)gM1^~dB|%(dTfZ_b{$=neqm$?zHzZoQH5Yk0DuPDL#+D~ zvx!T1ndYV9eb`6o}WhpWb4^DfwUNJ;!jKQ#A%pg-n)^mf$#MTf`-VcJTjn7iCF*=tRQJ0dmR-BMulJ%Pe;vP`apC58Ts2u{i6To@$lC8WeYWg?qTgB zCkeq2yFjTn9DEuaf) z7l}Xp14^~_)cd8|FCQ*gSxd`6FvNB+ytRw=FMA<$Fut3YlYoOCpgm0OYN5rA$o!{9LyEfAQ++#xE4?bqyTAv9PoxT z3&0g{0PH7ETTUTQf4q?e%mEL;7O)0b{-pdhLihI@7s$6J;17HOTp&E2e~**={ni@r zfzTiRgZhhR|2ORqc`Sgz)l>Z#-{nw1-Xn&1|m4TIpm4}suWdIX`MZrQ~WyrTU zq;Z1z!2*BT@h^UueV7@TZJ2qOO_(K9`%sVn@Y^$B6JpUHezk`9{2%@S(?VqOfF;2q z5a|%fU|cXEzyjum%p(C7gUrkWp-TN};V-ZJwMzfA>2C{wf347;dulVp}uV6o7zyCvum$k1K zB*pqxdMed@h@7~1^|fk zJUu;i{EMd80|0e=kd)B&FB&~R0AL^iz(|{=w};Ok^1#137)a(ogX}&6fE1tt=ppuS z0=$3_AP&d?ihwGh1?U4Nka)F&#JLCX9C!h|1R{agz*`^%$N+MH0-zMA0_uSlpcCi= zhJbNk8kh%GAkni2oB$WV6>tXy1qBC%0)+)d07V8x1H}Zz3B?a33MB)j45b034`l{r z3*`dk4fO&l4C*yhB2)%c9#k1r9aI}sAJiz+4Ac_T7Ss{cSEyTP5HvD0HZ%z|Ei@Z6 zKePn260{byF|;kTJG4J^IP@Fnbm)BOO6X?jKIn1idFV~(6X+ix0E7g>1yO)lK>Q#n zkSfRkWCQX51%Y0HQbGBkYEV05oj-v#L8qXfU|28~m>kRkiC}rKHrNvE4h{mxf-}IS z;AZdu_!D>wd;xxhL4hHLVS*8aQGn5dv4wdK6A6N_W1|l7z z2%;9E1L8}>bi``JLBv(WZ%9Z;lt=PYrTAxP;+wMZjKTSz~VF_9UOrI3w~y^-G_ zmmv2dFCl+JK|!HK5l1mV@j`inQijrxvW{|tiiOIGs(@;R8jPBW+Kf7jdWMF8MuR4S zW`gFAmWtMZHidSIj(|>!E`@H69)zBW-iE%2{sRLGg9Aeq!xjvjDRna|a8AMTI4WWr-DrRfsi+wTBIhO^2<3?STCny9#>}`vM0OhZ{!+#}_9P zrweBj7lccTtAOi-8;@I$yMTL(M}jATXN?zwSB*D|ca2YkFOF}G{|dhbe;)sqfSf>v zz>y$P>M(<9aPd~>1%fQdz$dJu2%?QoN!)V8t$vDLX&BV)O z&y>wH!wkbL!0f`D&%DTj%p$?!!&1Sr#frg-ezzn5&iRlADR!n)^NXXC5q`XFO3nA9x;k`FK5e zt9g(3sQJwJviKJHvH8{bWBEq}U<4!uf(1GSehTsmdI{DGo(r)EISQ2v9SYM4TMHKo z?}$)}n2Y3zY>JYLnu+F$Zi-QenTzF%ZHrThTZtEoA4o7rI7n1VoJq1vdPp`&UP}o| z1xocwgQVr8UrA5OV9IFAWXP<`Qp(!OR>)q+@yG?p^~%G@E6FFwFDj5ISSpk$TqyD> z1}Y9HAuDMrWhiYcGb(#1w?Bh^ruZ!B*{TYSii=8%Dxj*Mnxwj>MyKYk)}aoouBM); zzOTWd@j_!*6HC)nvt08^OHwOdYek!0+gtmC4w{aUPMOZNuC#8V?xr4_UZCE%KB2z7 zeyahTfsR43!4E?j!xY0kBOaqjqXlC+V_)M@6G9UwlP*&GZ-^nvvRY0b5-+v z^B)!p7FiY-mQt2!mM2!?R>@XJ)*{x4)(18sHiTkkV1dLo8CLZd&@`EC)4NVxz6(jUkqOt-w8iPzZky*e_8*c0C0e1z=szUFJ8Xb z3X}-U3xW!=2>K9A6&xPC7a|u@`V#S_0lBL1SXV#4Cr z#SbMeC7Y!hr2}PrWo6~WpktW?c4ca`eCJCuYYbpePD7>X>epncBp??Vz_5SWTbOcaI|fVf2?Jk zcf5ImXQJsN&&Q@o-pS@EzNyw}f$8=c;hFAPvDv;)(w~Or6z0a~Rpw_Fv==@v8ZB;q zw)%XytnH1Z3^m#j1Tv&M7r^N9<+i@i&a%crlg-w3{y zeCPiD;fLCf^(*JAyX#js1UIEW`F{@I>fRpQJ->&$Pk&%|Xn$0CTzztWdU7-OF#lZ( zfFR${HZ}n8wFCfA^Z@{C8d5(P{&7G2%>nhtofd+j{%pU<|AGIwL;iXJsUd)BNKLPP z3aM@u0id=J0B9ld(To5<4Fv$S1OR9*uD{#wA&@)w?|bL32Uy6xBG=-|wrToPN3hiy-(A$z(^AfPcj}|qxAQGAHnF}tvnTt_%wn+}e}emh zFHw6vZ9&Jukw1s;a&F0BgLt1LuC>2n{0#m!^_6U0jQT+`IO)FeB5e6-<#F~7`u6BM z*-g%R&vfwV6V?&KL-5aYjVHw?tS86&oL;zZIbSxuJy<{4KA8@MeI@(W6%w5Hl=zT! z?eIAMq!Q@0jJ2uycGI!UBM7DZRgPCa+JJ_B>Qs~ZnHL-hn^=z zSwm;#fe0yEOXq{Uh3x3$X+NJjf5eEBic5pyp%YRRX_#%9&+InlB(nMkC;83aQbtD$!0M|L6k0aOYwzX&uJPT*JlN+wGBb~d4NyE;L zsOf%BPDpU8EQJ$PE#A{p)Cj`KkR($Dy?FE!kM|+-lr;FZvC9L+WgR+@ML-|+M~%ms zNB)_OqmIth7K(!Z;yK0T3K7!FvraTec>XALlY$^saeOPEZ4Ey*zXjxVQArlT(>pSh zA8uLql9ciCHbY6gRd}Z+)uXDcg{RY@9)s!TF9yNAaSkiccbYaYHbA`aI&mL)*2kJY zBo1cZx-OtB+Q2um%I?I6kdsKA9-C?H9f}R!KV+66Dk%MYnp$dTdc)Vb+M4*yFrzQU z9m~c@XI>NRGfj`u2miX1a9TG7*Qw90pIahD9$8xtK^Xk?v7^8IoyO)6UjqYQ>! zR-Fd-ptl>MYJ1+6!~zRn*APWbrgOxELuqq<%234)z2(#9{bwi?&!p~`;B@>N{g>i@ zi~AR$OnI*z^uVggUR@GrX=|H<8^A-(FIi}NyXq>8Wv&HC1Xnsbp0}CMaH6nlil!V& zUYb20q3HN-gKlcD^Wfxn7Qx;^m{K8Ad@Px$@fiH=%B7HjhV9@<%;rzR&F8ysT^t`j z9(+=a|4xMbI(9n1L{Gd`mn;`yJPp2$-W!&t4=;R4Aw=j<%8U?cN)fxZK~3|Wg}T|u z|Jp*|0n)Er40h!57Pk~pS6*>D>t2CG|En3RkZP~=T-7-(^(hezcOKsI{a;(wC*Lb3 zQLXKkyuU5@FO#mE=B48Oavw~hp9D_Fiaz5QC>z$xBCD{ zS?soWY-5yTJA+1ihHcv*nO0;A9gDGwoB`ZeIRbt+F*EjFayyhwCbw%kuWTH}*lFf` z7;8xLNB$#ASWe!_Pxq1B%Uvg>1h$M3&|!DYV)hh09b?Otd*t_O;9fffVj>3Ar7o2uWQ;>iYaKxt& z$$OVS6zB_wCh7IL|EnBIEe{*vL(=T8gc!_Y*b-vL%8=&_Q*u@FSHS;X#*AW^qAr*3 zU4QHdK857e%>T~XB363!Q?q$7waTCWj7=7@xvpaIV>W?BceMoL-LnEC{-3*6#UxhR zy_q?a$;8nx4Nrj{`1Oh&uN5 z#2=BltXr@rZrj%HU1}~{_t)Wzz9lUzVMDJimU~2D^J3W)muXx_F&uQ^`Cj|HuZ;$2 zBVLJX_m}f5guSLV7|B&lOX07wS^Td6pc2N*N+A4`FycsP$F2~1P1iwThcqs$HR&%E@sO}Wui2`PV-A z@1o;7T&rxT-N*kHd3D?jgE${z0#=c|8#=c#nNt3A7$PAR@3hhGuM8sJ!TnhVY z>5ir_E|jX&*DT~k)hV4-R*IWMmr2FDK9eZ>xOdt+b08U~hs0UgK8!hGc5zXiE3A4I z#a8n58tsYA8mY#iiSo;1sQl)jQF|)0=wniRax$g>ge5u}BXn2Y@_o0smb#~xl#(!< ziJu&9m;Vt#e?`*2_b%O!6$-C?WD~Vu3qV9=@Y4;vNMV2(GdeWv&M$wFL8-}+BNzR& zRpQt0rty`pCP&q&r2Q4;kZQ6juMTBab5uyx#|GVa+f(l`oGK&QSv@wx_TG9C-?6sy z4OZiUlQULsV{<)IqF#mH`mS6>YIDsd&ceDxAv1JkDY+I3w%V>-<4?i4a&+Pb9di^{ zRWTwX5O$Dc$gzdmErO3Jv4LFZgX6P!DR>;zK)(v}!L7lfW!g?yN?6|Hx#3K1ir+O> zCE;{>ag0L8vmYX7buQr>;il#=J^_-2uhURu!EMsBQU*~~fl(T#%!Sg21aHb=$UKGS{|R+DtV@G7y!6S2D~$daCNT47zl-)Z3kQWj$C!Y`J|8UrMQ4btL@?&G z<0iqH>OaBGpy7kZRYGo5h7k4EMGoXIX@D)O*f`DeUTM2^kLT+y8Oc*fBEIVdhxaQ z1-!iu<7GGh%xC|Hn@!Oo7v>04375nxf}a0t`Sy2t_tW9ep#Hx|9s_{Kr;2ytA;9FB zQ$EZKzoBbY6ib#@6o(^KVLK^Cv)t?WQ_$>WSRo*01f;9S;2s-ygry%BdlghZCWPVe z#O_w_u$9Z%!~qz?59TsU$`$F{@5&1DrQdJe8U?O?Eq&@8pvJU%WQ?u}Jrqnw;S^h7 z|CwBOt+zNZbuDr!+eeW}!fj#~Ncx6vpk`_~d)1T-ODB4mu!to9ciPg%_flyVe1g&z zu&XWV;zw5Tu52u1lDJlSyzc$@=$-`PF8bi3wjS-h*(G#?dYtdY3ytp~cO1L6R`j+G zXiG9~@46=GmsW@xi}NpqWi^8c|!9yLH_0x89DKAe6)&44;fCx@q2Kz3z*AX%2$Vg+Nn25H)|-c!HEmJ!n2q7yIbK zmS~jRUt)Xvu5O5pCSoYWyGScyHA^QS)#r@4AN@;C&n+z?HKWzMp!9mC(19pdeP+(p zv+U>g;4=3js&r1|(=GD;lFJC!j_;N!B2dzyZ(O|L-hUn$^iLhA&BIU61Dyxx~sC9mtIQC zi^zSvEY4}nxSByDvz;O26cr|WM7BsP)tPt*B`#Nx5uK%6TgW{EoP;8fHF?n76V3VT zcCM!RF^{KMB?#oK3cj(F(#F?vo)L+_e}q4G6?f!uN3-`mTsLF={Cr@cHN;>76jOBl zbD_6N5Fzh-2RUB5mCPg8=A5Bnx$Wr_Y_`X<0kx+Z$85He&a=r#0VNnDhQ|FRHs_}I z2HU~oSl4Bxl1;R9{NpWGZ|d9h&NK{|pAD^fR$-549$im>B}XyOCrmQOsxZvXy7+jg z{IlE!2y>0cFv{j=)=xWm*|mxnXezBs(lx1GxTl(cZpHgWso*OsxYa`HkZWJb6N z*`D`!$`x{q60u=DgLY3nomQg$MAU^XFZd1C<=f||QDi;tN%=(_6p72<%Nq9#rE%AX zJvq?;G|4s&hYBBx}QkI*i+mzWK zu2WzIvPro~I2EuBkWnb2#zryPQoY^Ri36UrOI4?HYmP_c=8~;5*;aY0{O(~{VmClf zE?6kVG=4ioseI>5G=ozQi`k@TOnO)mC+`C+$)X4Zps5uROmy0Xg?Jex>YLNr!M?-Z zMIL&L+~5=DRb(KJzWOA6qWQ`@V9PUoc?oJMA4?+almUN=)MqxyqDk$zxQ3T0QtE2; zw&e3OD5pgI5^`fhMkc|8ZQpuS4bOcug6mP@+Ju##$3aAB7JCb9e9{M$u1Db3fh-@S z@*4CJobQ9M{*~m>Us5pgfDx^;^*;TZy&XRm{3hrRKE^lLw;$+sKoRh#^(*}!I1)P9 zp);FtdgAau8|of!sb^u}5nRG@`iEL8d$&1M_GL?P+limY>36>T8kes*uuj2EVYA!e zPJEo*W;9K_H{kuD$hD8!-MhNC1sYeKXoJW6+c)oUc%qZf9|vz8bUln~sC{gf4>5Ov zFS8!@*pJd&c;Y+B{FsqrybS}+n^Mn@MBhrMCC^*cw-JhKe|t7|q+$pO!V zxEW+mYE%v=9~-~oY$(){uI=lniYkA>S_{H9z3hl^e|jZ4;0ObJ8YUSq)mk#THbv+V zy*EN&>WL+q!D1Lzz4cdIf{uUp^cHg_5o>CGZAn0oyjAs3aZjw{ z0fpsMiKOfJrMao3ZERFYcX`B7vv5$E(;c5+2Z`VtGu>#vo`(q1^2D&fbaHBEk9HC| zl_y0fBzggX)~bMKmx1EY17w$&&IvsJn`XAq%!utM1HJL`NBlA2KLrG`pt` z9I`df55p-{cI5+{hm=In=&x>`Mk0A|B4cuz7_;*>Ha&Vzn+&a~5}#ypGlC6@>uOuS zW@^lg`Q=2n8Gu<1^?JFGd0XS-uW53T*e+q^F6W-PZ3^8*_lUuqXYu5=@Sa6wwkocZ^|F+e|T;E5gr%ib~!|ZP{13XT)1>TqJkxEnw9&5Xb3Tn)a$} z8>U(`^#Jq0mL4ZZTBS*v3+;qzS1YKZlDUIik}vMk${~=pc4&7xHr9R7JL$tsAQJW? zJ#e%~x5(~EvvxQ5DU2g;nPHVIB~)RQ@%p?4^TGEspp%QCDGjj~Dex!LXSd+1!JFrU?Hny|I8?9+?(x0BbFNd3n*GKm*MK$Bg-0L?_ z-)S?qZ~Bd=Nz7MM#A%oa-fyE;kEq)YZZURhS;DpH^FkB#=Y$etx2TIeJhMP?iE;7z z1RR0xs4Y`CjyYe(vq>|xFQ@HLifQT8k|QoU7zL=rAl8Bb;u|}6alq18jTBlPL6c2#Y}~~?1HmMY0-I{$E#vKdx@+&Lcv;q{1rQvzVID2%FeOsK)WHzJjm&qyyNY(Pfrk~B zN_XKacJU&VFGjiay^AfLl(0QHXmHVi#?&+q5Iu243bX^?0Kw>YMA9II4(CPUEC*!c#xC3z{Jf zJ|{{C6ST?JuzXtyVkOGXKM$gzMCfiJj=V{SI2Dr{rv#50hw6|TH;w?^|k8= zdUuc1b}rPYF0*(ARq4*|mlZms2x7ci3(X|5UP)w99R++vdohS+2D4SM>gV6zU>0PN@7!=apUW6NI#Xz=|fL`L&Z(3Q2CwB-4fVSD5 z7*a(P*xK$;hv(RaL=$}vQ-C@OwO0Cb-;*vrzNTW;X}`^~usr1N#NF^{nSx*F``!kS zD?C46{t<{0b@tBv`D5tuN^AaGU&FW0%V&kq3|>#(Ai^PZXavzl31z9X85G65Qfg%W zgwTl`0c=IkqLp5evvU$G4hy<#(TpJ;M=ILI=vXA`wv-+ifZzmU{eK8oiD(D%*~7CX z)g~pByA-H~{Q6M~^21xn?P%=|&O@Ch!piD>EXaXIBt+RRRVAWsWxp%YV|J!B4aSTv z0I|x4Z}8&u!ZZ`=>E%F;0X#<(+ygfuP&Uwtw2+I1iBW{LnKNYoWo2MT>1w42g$CD$ z{I2G3AzRxsxZh>skWhUml0Ua)mzeyKG#*8!2YbG1vJ)Y0XUJC(@;f9jsh+G=`&QM- zzInaF0+ug)Z8G-?9ewbE6^s6$&=eWE`lY%?QpTHBY`K8CFBfw`v03nhgd83fCMF&2 zqhY$@@gx^7K6xvzC&>@Q>#C(P3qeQ93*E%6z!!XRn)bFk;?<#@C5l2T>K9!`GrDV0 zyP|T~c=H9S&T3xf>%Jad=8)tI(es@op_P~T8Q$Dj{A#!M#Uzvy%x73N#E@0=?YhaGk;v8Mpkpk>K5sg%mRZLg3!qSbh+ICw;!@ z-eHP6L^Ue9^@FPpj+^;lt&~-&V<%Fwmlor5blPREv8!Z zqG9Y~$)>D;db#7E$2SH6LD`<}zOTOYyF z@uJ-9D1D>R6dXx$>}ND zbO+O=$5mG+K<%FQdw-!W_bxq2%zirJWKclvTLNe6w@=b-DJ>oWiiZAIf;gKpSE(`Y z$kFdFgE`;g=vv0d;w|N?JAra zD^3!~uo;NbOAGU=_A#Wq2b<5hWtA&Rr1{FU+Xt6Zdb8QpwM2Va1{Q8F-A+rTF?gp> zbBM6;ojDuT4p#2I2*-9PDAqOcDhyPWV8j(>VGt0&vKiaA2kVj>*1ctAGumWb@imY+2zHidHsQiHkp3!=Z7!0xtV;Yq3@CJ zM*Vj+Mh}!9qWHO4dpPw!)F-~5+R+;q;FdJ{mfL<#LxnSLrg{)%&Hu^CEM(EfkV>2K zc4tllsky=*b9keLu*@%_B-5ou#*kp~QT6%;VL+hfd!j1c0T* zKF-q)*Ba{uXpRd_-lsL5XoZNwacslu48vbdWY{Z4H{*?7w7+?UXj>jT zC|eAsFW^r>lYIca3K4`hgnx%2WoNbgcKr+Y2ze%2vb{=fZ|$;82C?$8k_o3K=P3#9 zJ(vr4HPYI67>yKHpU2{~v&bwF#>+3)k~AvD{;>haCCQ2Y6jiVu<}wYLe{!L*_Z*Ma zRf;z^l5v9_~d*{jC>F^0ugGEfgmDN>r^bTw}ul;;K%(dc1Q;xghoC z!Jr$;Tqk-|RW;L6fjIxc4lN^lL#x2fhX|hfc$8B3jo@h5v>Kzh5A6#=&1sz#Q|5CN z`6MsiPZ^&=kBUh!P>b!FD(MP+8X2<^Vyc2-Zn$Ilgo$_mrt;})k?@F=(~*B>$fAak zLh=)S@08e^w+uL|$Hswmp&#c+{))Nz?J` zgZAf0oqLs*QaXPH1gwV6><`+*6t=hQUhe|nrJpbws*u(_8gRL<1IA9Jb* z>bn&|UO^)D-@5J%o6+hwlf%@Hn*7SLKyIxrF?O+ziSG$@va0S=H=@-dQ-qCFUI`P# z*AU2j7phM>Jx6q|A053HOX-~#-6Mzl}N3; zj1ZMG3R{P?>#*8KQ8FD-Z$)t_3K8FrjDhf=Lu$i{Ct#yAATx*4E1rIG3SY+@bYU#c z&6@rEHLv9=&Rh2Wd;m$dSL0`=Z#yRCR9FPuZpo<)tMS?H$~eNk-9*o(YAVXtk?jV< zi?=?#9*#?Ty>1uN4SMX<5}U621j~<1H@L{5cM66lT1vJ=i`iY1R*R|@T1~MNUgy1> zr)5&KT>cr9lk^H@v?I_Ok_nJ1Wqx)2> z!a9+*@y~V)yX3GUk=#lZJx*=*{hV(deL|$nsKMcSi-AGOD8xvjCVGUC@+oswXy#`J zY*gmE&qj$px^TFyYq*ygh(4&jA6JPbK%??`??9&o}cM<1<4A! zZA)1rf}7WcP7A`>5l6uROg=hDob+Iomsv#6LOZ*MraP9B`BJDIdVVlgIQ~HjU07Wp zvbbf~u1mX^HKoIeK&**RmDc>jrPR(VZP%@Dp^Z2DNCK;JS(*}jaWXjXk)M07h=Ef+(U@JH)cc*%8e+ zxq7FNh^hCP*-fieUKVu|oL$Nfxt=2Z`ZH118j@q!E(PkJ7EIU#4ET)7o@E|BX?#?1 z?wO^NZ1nhM{RUG8AC^K|oM>}BoJ`YI}zi_}BZ&u)YIrJ|P(Ah!}Uq^RIr2M|O3wOcRmHR3UkHB~GBdWV*l$ePc(h z_4Ewt;GoxTzStRBqvyH4CdoEZFHq*@8&KS6`SC4f>ZG(eMv)cnv8+t7w$lAJjTa^o zn>2~s1X53Ec2cb(>q*7crn<4b>pIJ58h%7^My^ei2t)R?aOW4ER$0$( z}k(Cn3GIv;*};Ue`?fIMkr2o(K;Z{N%d}6=~KMYuVTTix(J!kt?^xB!m)#* zNbgps%3VsEq_)0JvJrKNCM0QHT&m09RpwjS}Nj}?N{+t9|z^tcl5O#xEWz0X+sMJuSH8`l4}L4ov=<7}mGHu|jQHOl&hZf(t!-g8~P9=vzEf;C?`8u*n; zFT8jP0~Umk)-GvLcwohn1>;l_HK(AoEshL4QqzRUgkAlo8dAFn;H&d;B@=iv7qYiw zY3uo#d$_&!MkXv7l~5Y;5~M2ptq#qs=gk9tdS5j3=O16@f@iRlEJUIc zOwo;g*lPx%9gsdh9S(*{`gZVj<-xRH>@RjZJoJ|@4Ben@P z%Qo|4HBQp0r(T!op3C5bj&BGGTbk21IFkCBBX5?mR}=n|HW{LZ+67G6N3*gt^QV2G znks_=Oq=o{mIpRPBcsllC!PxMoNviM>Y5R`1s2Fj$Z6VRGk1TbBs4i##C{@7AKa8m0|uj zsp7r_dPrDFp7x6ETL^1Z^GTDZXnj|d=9iRsaIh#XDPm!M8>yNwcYegqdw7|M?ou*H zPK7)jGC9(paukhWTxavp0e-D7{Y{b;K6k%xzrUu|30FxP zzR-4lNgb1@K!KUe7GjPfm(CSx8WSC`2U3q)HA$B2tf=@8be(y@fd|hzXe@ zO7O)LEpYDRA9KomX!e<8Ih-6%S}f#oG6`Fe4o`L^lsD-(nh!jJFBr9Zq*V{em7hx%5-LHl%5yLTQOnyN;g8sZKiceU_2!o zG=3cw%Jtt#nVC!wEJcb#vu8M2{=OP>nhGPKGY?XNa#%1r7D_O3m-CgKUA_xv|DI7Z z*-5@^s-BS1YD612f$=ia%x9bF^MR7#Sbpz~9G_HK;h|IR;=*eliyOYB(?TPaV%id6 zVd^6I7|vDYhVg3Lhy9I6N2bix^Z2Ww^tsuwmrCkcjBq>(n1RXjwcB_4cn%2u@}XSgUfPjCzgKC?(wWLz0RUVev(?P`^{pw2dm;gbt?ePtJUC)aT7oWndce zjpL7v@zH6_1=t!f$np}8nzQ3>SGv&Sp#_B_NYw4vF($D>23q|$t6z@;wC>ObHfIV) zV$kT$VWzM{eqBa&4*ZIa3DM%Fe(L3z;(8&$u1a*mgkXW;UWW8h!x~mzG#46#&P*mV zV0v}SQf)$c63P?_u2IADx_KwTMsjo0w=zjlAHY*Ynk^PLL=` zEMg#45^19nf766t?cn0|LSwRNc18H>lsck+e6in)FDa9c{ci*4@mG8!1Gr`A({}Do z#xry=E1PQW$9PX0ZHXE?at3sET26`OKCqYgJccL5eaAoMM?XzTy`H|05U`q@X{S<$ z3et60?84b@LAu%Qw6Q^4Ab*bU?DN(`rkZ^Usdj~Aqv0r$$+FM?Zf3#SF58-Cg0_(Oi{$?KqgbTl)>y)aQBSx=Pk zFXmsJ8ZziMNv%Gg%E-Jn%Bfs7;A7Lq67n-6F7~H9JA6(yY&64mFJHnNxZF^HXh4kQYfq<3|UkoJLZoSX@U3u@#+w$Ut*`F(BMzU*&y?U1#UY&#VKJF#;yqn{a z)bo->1@K4r?djR-z)zmIsmAUpGa;*G^NTZ;2)QM%Z?}^vmq{Nvx({YA$0R&B^ltZ) zw~Cj7_bZ?f*$9rD$M3Qg`@mSnmrdsBjeQ5NL^V8xfUYnWx?w{3`sck&hf{l5Y+^;B z{jabVgZC`wgT(LdK9??kGepJSLc~5Bvwe+u#CKLW61cxN#*l7h9=tYS!|E?UkBeBL z=YEA>>ft~MRz9hn-@c)qLOzg?R22$12ffB@NYuey5_52-EW3e%@v^1F(PT2j|Af;2 zs++)NPk|$9`-OTmOowy(gZScFiM?dF0ps9unTT0s1rZ!5j@rN`MDqnxYoPq7qbhTn zHuMzhJNP{8Frw1Ji6n+1MWR>63Jdr0Yn&2pDVLEIVXO0>I+~t++0i^1N~+>r)A9eT z53AD=Wp(;Y-Mc~EF&Nu)14a zOnx5@RsuGah0$uW=VMrxgQC9+$tB*RuPS z5oT8lb>7+NZsQ17ER4)hTj0YtBgEwIC5_lm#bKOi&(l6tltTpuB0kCJsn3dIZj>*V zOxDA;oBqJ%q`!K$u1JcqVF6bLsPZ$gw%gX<&1*kriJXzFtFL^Oh>JN9B3gvZfvYqm z2%36nV$2X)M>W$sx{B?k#}GK8shr?Yjm=r+MHI3LYPf&?!QWd{vi+D{<%>UT$J=P9 z#)o6{z^hdmQT(RQmklNGT4t7wA@5t=#}1)0)U10n)+c(-5C#>=Ii<<9<;wN*Sp7~p zb(Mb37k#aD@vTK)zXDxxsSQlMBz$ba6qi~T>c?Aw9!m%rG%7NWMAzu*oO^Q^cqNBA zxheW)n@4AfY65RZ<}pQSPI|u7FWj7Q$e!n9T^6?k;$%Hl&6T*`%NE_sQBz`fwodEotGy0# zd(uMz-7oHz9Xv;q9gGO73#pLMgY^BBfz)ks7lRoTXZIV$rbzax;hI3$y0t({z#FFJN-YRW7k)|b*A?{RaT{baR~3mC;+MnS@E-vjT9 z9!hMjXr6@2hBm>r-`b2fjpFOH7@CoKi+La8qQ1TFqN2G{K+O8F0 z^%}g(>*%qU5yAyDV#5H4ycZ=kFgENDBmG|?g>*WpSM_^ z`vMnsRYkL;czT%42&%A+E8Z@@{o^|wUgnT5q-BB`QzIvMBzjlKWek=I!IRd>QMxLm zAv=2RPkh5ntF|U}Ijftc%=wr8x8l+0c)K)|lcsZBp_ql4i6rZ!sHGn}=BQJ)a8ivr zKMh@bY8|OMvrB?^iOUxf>^l4E{Blr7Ek0b@O~HyFpp5?9EfAKS{N62W5N=7eQFBt% z;L0`LUKE?ZeNHA%ikH?67t;RD;2GQFOl)$D9>!WY3g*%h$^0~0okI-IIh}iKO-UO+ zJh?%(kU{TN(IUP-E&HWc$Oadf;KFDBc>S9hoAM7{$~JV-=&gy)W@99ZP*UwnP@NWp!X%Hpo(W9bVv7i08`)FCGsQ3Rfcz zrJsiF+^;Uox05e@fKp5GPt?;nd5(d^Lv5#67vgw(4maUR&6#%J{hCbFlitFr+p#Oj zrtVdHOA;@|Bebq?@Uf$zTObr~WVlVxbMD(WE{@jN{`9%7s&jUkso30=bZ2FqHhOd! z0=aa|p%Q`o2w-wm8X`#w8gnB-uJL;$@f!KU3CoBjvn$P^AaYtA9rsgZkOz; z)ms*rdWB1M>UVO!pO{>$m>Bh`YutYbU-c6zg>m2-@onG^_e)|mS2vu36~hQfy9MyK ze>e&iGUv|dpZX%E^ar`s4QV#*vJh5_8*-RGJ6N*iC)`q=fc%e#3!Y*U+M6%vpLX1S z>PgEf9f6}+Fm3Y7j2&~gvI*5Zu=iNqlr~IzF!*YdQD(&-(zS5-q#Cj{3j6J*P+3zs zRdEe4}( zC?HnAP_(;c-S65R{A7k)g(eB2D(ujRiK&uy!%awrExjft33sd@w>zFD>l^n zal^mddWOXFgjx1Y&dg=FbS=>LDkkHwRX^Yg)dYT(Hye2+<3oL`1gJU~27X>BBA@1K zf7v6_f+keQ*+I>5_qo0|eJ0iX+O&ADP)BidQw0{5LXJcA3&;006K4}vr^i8oFa=p( z$MOQgOdU#%%U_Nq)GuSoRLg>KBiM%fO;8zpU&39;SSWSIcpY7c>%Gb4Pr!h|#QNA< z@6pBTqbXPj~KCHu%8ors&zXR5<(NcUvbV z?I_2`->lrWlM@jy*V{`d`ks<_~~J)bLixJiybbpQ@=O^{Ay@jwzrSu7NH znvm1l{WZ2Qd0&!XbzDubYX*_oas&r12eAZJjrd%a(P8!7dhA(iZY_!Kw1ooo9W0Q( zrxnp7U~Kud@|o?v0$Ga<-#Gzkd^cZtRJ>R%j?@;_1v3=v*n&o0u?;+n{Z_U3JEA9r zBJaVQkH)sjJEq-y-$q8=PWYsfIeuDdQgP~WS~xvEP1xkf?l;=(#k~Rsz6+wF2t;m@ zN^Tz(yeU@e3^4NUD)Di3GjI~bbv=&k?|lin{{&!A?Ed~g0jau)b}Fwm&Z@!8(Jrl( z0$vsU(XX-D)nZQE;%eF$V3k)ec=E2#n<8T}1=FN$II30e4bhp!_@b%@r&*5-1+w1I z=})Try>J3=Z$EWceL^!u5;AiC%^ zq6?xE(Mcpa5iQEQh%OOq2)>oP?fbqtXU^QY_x|pk=gvPf&zbYvN~MJQ!Dnn%sXVMKbY>I*D3!kmWA%M@f|lXWmLbY$H5d|5j~UVc4nI;-pKkU{ z*f}^tmv5fm`B`|q?B$+n;dcs|k-J^1SLvN+^9R{YS9^gW054h-fgs6oisXlReM!9< zi3>ac8ji_ehVPaXH}8zTNPE?p7U&7!N8-moQg^fzYt_0}L-V(t7a0u1*`B!m)qO|$k*d`(kiKTp9sy^iJW ze%xE$s3{w|Qpu={L;3sEhs^PDbmz@haIiscm_grN1uYG9tjNQ4x%z?@@!ICZaJiqP#EibAWyxNg z&X)DNLUBvv=^@v!1xo-6qg<&~Zfx~)L)mmP>Z0_{%U=?IHe#OW0<|r@jr z$WB!AnyDe(#%6jh^U9w7ww+=eoiS=ozQ3(pXv#vB?&%4GUfstVJ1>=d@KYHNX@S*E96i}`I%=U`aw{ezt%1i zGHSk}77uB}#}si8eqPhAHK}URhsJ&V%(rjB8^FNYEV3!_;3Rek4CNB38dqVXV3PHji`D!r6ixdO9sq-gNm}6z>1zH znRU>ZcLQB*6K#7~qD~~gmC!ibb8|3tD1iK`EU&jS5u$EIzehJi%D*Y=TJhECol&#W zSw$n}VvuUa%rn9j&Y(EIyLXJ<*UjumSjPKK!%eJiGW6^b)v)`_P7*4EW`Ev|E)G6BvujGhoC(bleZ`&;-9iB? zNaaUplj*S!ANJC3q@;f9ltai9_BVs8P5D-}gBEka3(iVCgCgHQTA!kK6f zL3=$7xlxu@pEoNQYF{MS@GA3QM17oj&6oLBI*Y-`bjVu^<t6isM=G=pbGA^B`GRB zcP9nDT?}YuYs-+SUX7IvkWlH%Z;+Et$@u53r3M-xoVDsJNR)%MF? z*Tj9xp~vqw3vUZIcUKXuh+drrue9XLu@;Z4z(A75w_g|3IvibHld@YE7*}3Ovs?oE zr{7XDr9CFzONndEjV>HM!_bapyxptXWExR?m>)zh4LojucthZWS)w9&{(-%3R^Z~idQ&Y5-IS_?D>nZi%{BwXGTxYBV+Cl^_tw9D}`+t zg~7zEjc!xAkgMan*r5ld>M(3X!9r!%n_v|-pn$TcD}^_z0agnRi{Bj!G7lK`7IPzs z9*Ux;issg!-BkRYLntRvVQ&qsQ=r$lJ2j+r81cMD-eNqc{N>~xbIqo)Jkrq)Z4D<@tTQ28kjf;vqdTr-p!i6|e3BO9 z{2{d+tTe;E$UEdQCD3C%!u3-C#0Vsrp00?A=^-tmP2Z#pJeiWPOCW9Ctojrz)jW+a z?oHO%#}yh?QZ8=!@!gv-{apFec|=v2%0e+#D==aYkL)A!qr8PI7wDq8HHg3Ev?+4g ze6mgU!guh+?A$=GD5d}Oc^MgL?bHiDvvSRA(WmZoMKm4sfjX0!2E+1fl zj43Fik$JJDT|@ft6heR6NSozI7wWy%0Zo}$(yf+m^xn%9JRR>pbgJxz zVS3R!R|(v*V;dfHjT=bc`2uZ~8?$~|NO=-J2C4cSMoHy82r&7-st$Geh5r zzC+tua(-N_z6%*rtowQ*#trg$8k)FKa6%q;x1=PL0?Ahoag=;~Dpo^b%)VDzkyLV!kIUAN0KO#=f{u-md) zl|4-^lDRc)H+hE*&`M>NdgHPQ4K?@u%pGcmBaV8S+UY#g-LAOk&=r1;8Wrmc|FTf) zwZ44R7Ac^rdW+)`y&2Q2UY4N$!r%jF_YXXS)UHOZvi;c?IxAWpG=syuE5rx}^92|& z{Gj!iOHqm+d~mX7kWaWkofk05)^^Xnc?zR$(IEbU^k`$!d~MpB-cJ zWLh=Q1nlPfp!3k;iRDP>GUk?b$8&Bpu_C}t+-B7Y+Baf{>1|C*qG)ChU@*&#Qsp&w z5Qr5V2w`V)xeZaCiixo?YKvl&+O06_NrdA1 zz^whf(ffpQYJAcaybnysd%v=^J-3OWvXXo|xMJTo%4e*)bg&mT=e50N*?3IVot{J* z!;J(qSlmNWen*n83(|8R=N--RU+;Pg1I++Agp)n{O%?;NU*raGk11d@1bAUE$|iNi zMbjG_8=*=XQh9+hjDqY)(?&9zC!xG0oP0}=61*{&kp(gs`3S$l>1wI~AA=`>jXu+b zpX$)^7qL$}9sv35(pT_K#%KD1jiCBkn20A;#cD9#0J_;E@4cr#UgIDAyY;G2-f~*k zsauM;ll-Q32GvlwvHEUYKm65g$W(yf%>d#K<&mfC-#2SE*yXsRZ z0I$YuL?q4zx%@%k?D+#_iOeh!=g5Kw+AoN3C)TM)=W+{m=-;PVwY!??(Ku087jP3d zkOBztSaAGpzgbx1@mT+s)$#a$F{9uCaBl}95jf`3-#(XIkC!b0*Dsk{evbr6j{zjO z*%e&(&&U7Eo3KBh;4hir*VRtRP(|~S?$XfS8xD6zIeYlr)z#=WgZvdo z7000JjqvnAc(`+3#!oA1p);NL;jA9 zgUViNrS%d1a91-`gd461xE%5l5+bs{{QuALPsV>C@BV{?iirFd^k1g`g&L#a-YOn$ qxST%n|7^gYdH-$v6DSM0{Pcg7;&+{YX>o;?Czpl%y=n5~fd2rAdXo(R literal 0 HcmV?d00001 diff --git a/docs/integrations/paygent.mdx b/docs/integrations/paygent.mdx new file mode 100644 index 00000000..401af5cd --- /dev/null +++ b/docs/integrations/paygent.mdx @@ -0,0 +1,106 @@ +--- +title: "Paygent Integration" +description: "Connect Dograh to Paygent — real-time cost tracking, multimodal usage monitoring, and billing for voice agents" +--- + + + +## Overview + +**Paygent** is a specialized, usage-based billing platform designed exclusively for AI voice agents. + +If you are building voice agents for clients or offering them as a SaaS product, calculating margins across different AI providers (LLMs, TTS, STT, and Speech-to-Speech models) can be incredibly complex. Paygent solves this by serving as your centralized billing engine. + +### How you charge your customers + +Instead of building custom tracking infrastructure, you simply connect your Dograh workflow to Paygent. As your agents handle calls, Dograh passively calculates the exact multimodal token usage and audio duration. + +This data is securely exported to Paygent after every call, where your custom pricing margins (rate cards) are applied. This seamless flow allows you to automatically invoice your end-users for the exact infrastructure they consume — turning your AI agents into a scalable, profitable business with zero engineering overhead. + +## Prerequisites + +- A [Paygent account](https://withpaygent.com) +- A Dograh voice agent workflow + +## Setup + +### 1. Create an agent and configure pricing in Paygent + +Before connecting Dograh, you must register your agent in Paygent and define how you want to charge your customers. + +Log in to [Paygent](https://withpaygent.com) and click **Create Agent**. You will be prompted to define your agent's core details, including the **Agent Name** and **Agent ID**: + +Creating a new agent in Paygent - Agent Name and ID + +Next, set the **Indicator Name**. This is the billing event identifier you will pass from Dograh (e.g. `per-minute-call`) to tell Paygent which rate to apply for this agent's calls: + +Setting your indicator name in Paygent + +Finally, you will configure your **Pricing Strategy**. Paygent allows you to set custom markup rates (rate cards) for every modality. You can define exact margins for Speech-to-Text (STT) seconds, LLM tokens, Text-to-Speech (TTS) characters, and total call minutes: + +Configuring custom pricing margins and rate cards in Paygent + +Once your pricing is configured, confirm your setup. You will use the **Agent ID** and **Indicator** from this process to connect your Dograh workflow. + +### 2. Gather your Paygent credentials + +You'll need three core values from your Paygent dashboard to link your Dograh agent: + +| Credential | Where to find it / Description | +|---|---| +| **Paygent API Key** | Your workspace API key used to authenticate requests (`pg_live_...`) | +| **Agent ID** | The unique identifier configured for your agent in Step 1 | +| **Customer ID** | Your Paygent organisation or customer ID | + +### 3. Add the Paygent node to your workflow + +In your Dograh workflow editor, click **Add node** and scroll to the **Integrations** section. Select **Paygent**. The node will appear on your canvas with a **Not configured** badge. + +### 4. Configure the node + +Click on the Paygent node and fill in the following fields: + +- **Paygent API Key** — Your `pg_live_...` secret key +- **Agent ID** — The unique agent identifier from Paygent +- **Customer ID** — Your Paygent organisation ID +- **Indicator** — The billing event name (defaults to `per-minute-call`) +- **Enabled** — Toggle on to activate the export + +Click **Save**, then **Publish** your workflow. + +### 5. Verify the connection + +Make a test call through your agent. Once the call completes, check your Paygent dashboard. The billing event and detailed multimodal usage breakdown should appear under your configured Agent ID within a few moments, with your configured pricing margins automatically applied. + +## Disabling the integration + +To temporarily stop exporting usage data to Paygent, open the Paygent node configuration and toggle **Enabled** off. Your credentials are preserved — toggle it back on anytime to resume tracking. + +## Troubleshooting + +| Issue | Solution | +|---|---| +| Usage data not appearing in Paygent | Verify all credentials are correct with no extra whitespace | +| Node shows "Not configured" | Open the node and fill in API Key, Agent ID, Customer ID, and Indicator | +| Workflow not sending data | Make sure the workflow is published, not just saved as a draft | +| Missing realtime STS tokens | For OpenAI Realtime or Google Live models, verify the pipeline is running in realtime mode (`is_realtime=True`) | + +## Learn more + +- [Paygent](https://withpaygent.com) — Usage-based billing platform for AI agents