fix: fix speech to speech model transitions (#545)

* fix: fix transition logic for realtime providers

* chore: run formatter

* chore: generate SDK and fix other realtime providers

* fix: fix ultravox node transitions
This commit is contained in:
Abhishek 2026-07-15 18:36:36 +05:30 committed by GitHub
parent 348cd8427b
commit 01acf6ac30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1282 additions and 617 deletions

View file

@ -1,6 +1,10 @@
AZURE_MODELS = ["gpt-4.1-mini"] AZURE_MODELS = ["gpt-4.1-mini"]
AZURE_REALTIME_MODELS = ["gpt-4o-realtime-preview"] AZURE_REALTIME_MODELS = [
"gpt-realtime",
"gpt-realtime-1.5",
"gpt-realtime-mini",
]
AZURE_REALTIME_VOICES = [ AZURE_REALTIME_VOICES = [
"alloy", "alloy",
"ash", "ash",
@ -12,6 +16,7 @@ AZURE_REALTIME_VOICES = [
"verse", "verse",
] ]
AZURE_REALTIME_API_VERSIONS = [ AZURE_REALTIME_API_VERSIONS = [
"v1",
"2025-04-01-preview", "2025-04-01-preview",
"2024-10-01-preview", "2024-10-01-preview",
"2024-12-17", "2024-12-17",

View file

@ -621,7 +621,7 @@ class OpenAIRealtimeLLMConfiguration(BaseLLMConfiguration):
GROK_REALTIME_MODELS = ["grok-voice-think-fast-1.0"] GROK_REALTIME_MODELS = ["grok-voice-think-fast-1.0"]
GROK_REALTIME_VOICES = ["Ara", "Rex", "Sal", "Eve", "Leo"] GROK_REALTIME_VOICES = ["ara", "rex", "sal", "eve", "leo"]
ULTRAVOX_REALTIME_MODELS = ["ultravox-v0.7", "fixie-ai/ultravox"] ULTRAVOX_REALTIME_MODELS = ["ultravox-v0.7", "fixie-ai/ultravox"]
@ -638,7 +638,7 @@ class GrokRealtimeLLMConfiguration(BaseLLMConfiguration):
}, },
) )
voice: str = Field( voice: str = Field(
default="Ara", default="ara",
description="Voice the model speaks in.", description="Voice the model speaks in.",
json_schema_extra={ json_schema_extra={
"examples": GROK_REALTIME_VOICES, "examples": GROK_REALTIME_VOICES,
@ -756,7 +756,7 @@ class AzureRealtimeLLMConfiguration(BaseLLMConfiguration):
model_config = AZURE_REALTIME_PROVIDER_MODEL_CONFIG model_config = AZURE_REALTIME_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.AZURE_REALTIME] = ServiceProviders.AZURE_REALTIME provider: Literal[ServiceProviders.AZURE_REALTIME] = ServiceProviders.AZURE_REALTIME
model: str = Field( model: str = Field(
default="gpt-4o-realtime-preview", default="gpt-realtime",
description="Azure OpenAI realtime deployment name.", description="Azure OpenAI realtime deployment name.",
json_schema_extra={ json_schema_extra={
"examples": AZURE_REALTIME_MODELS, "examples": AZURE_REALTIME_MODELS,
@ -775,8 +775,11 @@ class AzureRealtimeLLMConfiguration(BaseLLMConfiguration):
}, },
) )
api_version: str = Field( api_version: str = Field(
default="2025-04-01-preview", default="v1",
description="Azure OpenAI API version.", description=(
"Azure OpenAI Realtime protocol version. Use 'v1' for the GA API; "
"date-based versions select the deprecated preview endpoint."
),
json_schema_extra={ json_schema_extra={
"examples": AZURE_REALTIME_API_VERSIONS, "examples": AZURE_REALTIME_API_VERSIONS,
}, },

View file

@ -10,6 +10,7 @@ Provides:
- ``create_runtime_sessions`` live-call observer that accumulates usage data - ``create_runtime_sessions`` live-call observer that accumulates usage data
- ``run_completion`` post-call REST delivery to the Paygent API - ``run_completion`` post-call REST delivery to the Paygent API
""" """
from __future__ import annotations from __future__ import annotations
from api.services.integrations.base import IntegrationPackageSpec from api.services.integrations.base import IntegrationPackageSpec

View file

@ -5,13 +5,13 @@ coroutine used by the completion handler. The individual tracker functions
(session, STT, TTS, LLM, STS, indicator) mirror the exact shape of the (session, STT, TTS, LLM, STS, indicator) mirror the exact shape of the
Paygent REST API documented in ``paygent_sdk/voice_client.py``. Paygent REST API documented in ``paygent_sdk/voice_client.py``.
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import Any from typing import Any
import httpx import httpx
from loguru import logger
from pydantic import BaseModel, field_validator from pydantic import BaseModel, field_validator
_DEFAULT_BASE_URL = "https://cp-api.withpaygent.com" _DEFAULT_BASE_URL = "https://cp-api.withpaygent.com"
@ -169,7 +169,6 @@ async def deliver(
errors: list[str] = [] errors: list[str] = []
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client: async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client:
# 1. Initialize voice session ---------------------------------------- # 1. Initialize voice session ----------------------------------------
try: try:
await _post( await _post(
@ -256,8 +255,14 @@ async def deliver(
metadata = snapshot.sts_usage_metadata or {} metadata = snapshot.sts_usage_metadata or {}
# Only append connection minutes if we don't already have a rich token payload # Only append connection minutes if we don't already have a rich token payload
# (e.g. from OpenAI Realtime or Gemini Live) # (e.g. from OpenAI Realtime or Gemini Live)
if "connection" not in metadata and "prompt_tokens" not in metadata and "input" not in metadata: if (
metadata["connection"] = {"minutes": snapshot.total_duration_seconds / 60.0} "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: try:
await _post( await _post(

View file

@ -10,6 +10,7 @@ Design mirrors ``api/services/integrations/tuner/collector.py`` exactly:
- Build a serialisable snapshot in ``build_snapshot`` - Build a serialisable snapshot in ``build_snapshot``
- Return it from ``on_call_finished`` so it lands in ``workflow_run.logs`` - Return it from ``on_call_finished`` so it lands in ``workflow_run.logs``
""" """
from __future__ import annotations from __future__ import annotations
import time import time
@ -43,15 +44,22 @@ def _detect_provider(name: str, fallback: str = "unknown") -> str:
if "gemini" in clean_name: if "gemini" in clean_name:
return "google" return "google"
suffixes = [ suffixes = [
"service", "multimodallive", "realtime", "service",
"vertex", "llm", "tts", "stt", "helper", "transport" "multimodallive",
"realtime",
"vertex",
"llm",
"tts",
"stt",
"helper",
"transport",
] ]
changed = True changed = True
while changed: while changed:
changed = False changed = False
for suffix in suffixes: for suffix in suffixes:
if clean_name.endswith(suffix): if clean_name.endswith(suffix):
clean_name = clean_name[:-len(suffix)].rstrip("_").rstrip("-") clean_name = clean_name[: -len(suffix)].rstrip("_").rstrip("-")
changed = True changed = True
break break
return clean_name or fallback return clean_name or fallback
@ -124,7 +132,9 @@ class _UsageAccumulator:
try: try:
self.tts_characters += int(val or 0) self.tts_characters += int(val or 0)
except Exception as exc: except Exception as exc:
logger.warning("[paygent] Failed to accumulate TTS characters (val={!r}): {}", val, exc) logger.warning(
"[paygent] Failed to accumulate TTS characters (val={!r}): {}", val, exc
)
def add_tts_manual(self, text: str) -> None: def add_tts_manual(self, text: str) -> None:
if not self._has_tts_metrics: if not self._has_tts_metrics:
@ -138,7 +148,9 @@ class _UsageAccumulator:
def on_user_stopped_speaking(self) -> None: def on_user_stopped_speaking(self) -> None:
"""Accumulate the completed utterance duration into stt_audio_seconds.""" """Accumulate the completed utterance duration into stt_audio_seconds."""
if self._user_started_speaking_ns is not None: if self._user_started_speaking_ns is not None:
elapsed_s = (time.time_ns() - self._user_started_speaking_ns) / 1_000_000_000 elapsed_s = (
time.time_ns() - self._user_started_speaking_ns
) / 1_000_000_000
self.stt_audio_seconds += elapsed_s self.stt_audio_seconds += elapsed_s
self._user_started_speaking_ns = None self._user_started_speaking_ns = None
@ -162,9 +174,11 @@ def _google_live_usage_to_sts_metadata(usage: Dict[str, Any]) -> Dict[str, Any]:
return None return None
for k in keys: for k in keys:
if isinstance(obj, dict): if isinstance(obj, dict):
if k in obj: return obj[k] if k in obj:
return obj[k]
else: else:
if hasattr(obj, k): return getattr(obj, k) if hasattr(obj, k):
return getattr(obj, k)
return None return None
def _get_list(obj, *keys): def _get_list(obj, *keys):
@ -202,20 +216,36 @@ def _google_live_usage_to_sts_metadata(usage: Dict[str, Any]) -> Dict[str, Any]:
return total return total
prompt_details = _get_list(usage, "prompt_tokens_details", "promptTokensDetails") prompt_details = _get_list(usage, "prompt_tokens_details", "promptTokensDetails")
response_details = _get_list(usage, "response_tokens_details", "responseTokensDetails") response_details = _get_list(
tool_details = _get_list(usage, "tool_use_prompt_tokens_details", "toolUsePromptTokensDetails") 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") cache_details = _get_list(usage, "cache_tokens_details", "cacheTokensDetails")
# input side: TEXT + DOCUMENT + AUDIO + IMAGE + VIDEO # input side: TEXT + DOCUMENT + AUDIO + IMAGE + VIDEO
text_in = _modality_token_count(prompt_details, "TEXT") + _modality_token_count(tool_details, "TEXT") text_in = _modality_token_count(prompt_details, "TEXT") + _modality_token_count(
audio_in = _modality_token_count(prompt_details, "AUDIO") + _modality_token_count(tool_details, "AUDIO") tool_details, "TEXT"
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") audio_in = _modality_token_count(prompt_details, "AUDIO") + _modality_token_count(
doc_as_text = _modality_token_count(prompt_details, "DOCUMENT") + _modality_token_count(tool_details, "DOCUMENT") 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 text_in += doc_as_text
# fallback aggregate mapping # fallback aggregate mapping
tutc = _optional_int(usage, "tool_use_prompt_token_count", "toolUsePromptTokenCount") tutc = _optional_int(
usage, "tool_use_prompt_token_count", "toolUsePromptTokenCount"
)
if tutc is not None and not tool_details: if tutc is not None and not tool_details:
text_in += int(tutc) text_in += int(tutc)
@ -224,8 +254,12 @@ def _google_live_usage_to_sts_metadata(usage: Dict[str, Any]) -> Dict[str, Any]:
text_in += int(ptc) text_in += int(ptc)
# output side: TEXT + DOCUMENT + AUDIO + VIDEO + THINKING # output side: TEXT + DOCUMENT + AUDIO + VIDEO + THINKING
text_out = _modality_token_count(response_details, "TEXT") + _modality_token_count(response_details, "DOCUMENT") text_out = _modality_token_count(response_details, "TEXT") + _modality_token_count(
audio_out = _modality_token_count(response_details, "AUDIO") + _modality_token_count(response_details, "VIDEO") 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") rtc = _optional_int(usage, "response_token_count", "responseTokenCount")
if text_out == 0 and audio_out == 0 and rtc is not None: if text_out == 0 and audio_out == 0 and rtc is not None:
@ -234,35 +268,55 @@ def _google_live_usage_to_sts_metadata(usage: Dict[str, Any]) -> Dict[str, Any]:
# Thinking / reasoning tokens (Gemini 2.5+ thinking models). # Thinking / reasoning tokens (Gemini 2.5+ thinking models).
# Emitted as a separate output modality so Paygent has full billing visibility. # Emitted as a separate output modality so Paygent has full billing visibility.
thinking_tokens = _optional_int( thinking_tokens = (
_optional_int(
usage, usage,
"thoughts_token_count", "thoughtsTokenCount", "thoughts_token_count",
"thinking_token_count", "thinkingTokenCount", "thoughtsTokenCount",
) or 0 "thinking_token_count",
"thinkingTokenCount",
)
or 0
)
# Cache breakdowns # Cache breakdowns
cached_text = _modality_token_count(cache_details, "TEXT") + _modality_token_count(cache_details, "DOCUMENT") cached_text = _modality_token_count(cache_details, "TEXT") + _modality_token_count(
cached_audio = _modality_token_count(cache_details, "AUDIO") + _modality_token_count(cache_details, "VIDEO") 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_image = _modality_token_count(cache_details, "IMAGE")
cached_legacy = _optional_int(usage, "cached_content_token_count", "cachedContentTokenCount") cached_legacy = _optional_int(
usage, "cached_content_token_count", "cachedContentTokenCount"
)
# Build response payload # Build response payload
out = {"schemaVersion": 1} out = {"schemaVersion": 1}
# Input Side # Input Side
inp = {} inp = {}
if text_in > 0: inp["text"] = {"tokens": text_in} if text_in > 0:
if audio_in > 0: inp["audio"] = {"tokens": audio_in} inp["text"] = {"tokens": text_in}
if image_in > 0: inp["image"] = {"tokens": image_in} if audio_in > 0:
if video_in > 0: inp["video"] = {"tokens": video_in} inp["audio"] = {"tokens": audio_in}
if inp: out["input"] = inp 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 # Output Side
o = {} o = {}
if text_out > 0: o["text"] = {"tokens": text_out} if text_out > 0:
if audio_out > 0: o["audio"] = {"tokens": audio_out} o["text"] = {"tokens": text_out}
if thinking_tokens > 0: o["thinking"] = {"tokens": thinking_tokens} if audio_out > 0:
if o: out["output"] = o o["audio"] = {"tokens": audio_out}
if thinking_tokens > 0:
o["thinking"] = {"tokens": thinking_tokens}
if o:
out["output"] = o
# Cached breakdown # Cached breakdown
has_split = bool(cached_text or cached_audio or cached_image) has_split = bool(cached_text or cached_audio or cached_image)
@ -270,15 +324,18 @@ def _google_live_usage_to_sts_metadata(usage: Dict[str, Any]) -> Dict[str, Any]:
out["cached"] = {"tokens": int(cached_legacy)} out["cached"] = {"tokens": int(cached_legacy)}
elif has_split: elif has_split:
cd = {} cd = {}
if cached_text > 0: cd["text"] = {"tokens": cached_text} if cached_text > 0:
if cached_audio > 0: cd["audio"] = {"tokens": cached_audio} cd["text"] = {"tokens": cached_text}
if cached_image > 0: cd["image"] = {"tokens": cached_image} if cached_audio > 0:
if cd: out["cached"] = cd cd["audio"] = {"tokens": cached_audio}
if cached_image > 0:
cd["image"] = {"tokens": cached_image}
if cd:
out["cached"] = cd
return out return out
def _openai_realtime_usage_to_sts_metadata(usage: Dict[str, Any]) -> Dict[str, Any]: def _openai_realtime_usage_to_sts_metadata(usage: Dict[str, Any]) -> Dict[str, Any]:
""" """
Pure Python translation of OpenAI Realtime usage_metadata to Pure Python translation of OpenAI Realtime usage_metadata to
@ -292,9 +349,11 @@ def _openai_realtime_usage_to_sts_metadata(usage: Dict[str, Any]) -> Dict[str, A
return None return None
for k in keys: for k in keys:
if isinstance(obj, dict): if isinstance(obj, dict):
if k in obj: return obj[k] if k in obj:
return obj[k]
else: else:
if hasattr(obj, k): return getattr(obj, k) if hasattr(obj, k):
return getattr(obj, k)
return None return None
total_in = int(_get_val(usage, "input_tokens", "inputTokens") or 0) total_in = int(_get_val(usage, "input_tokens", "inputTokens") or 0)
@ -307,17 +366,29 @@ def _openai_realtime_usage_to_sts_metadata(usage: Dict[str, Any]) -> Dict[str, A
text_in = int(_get_val(in_details, "text_tokens", "textTokens") 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) 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_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_details = (
_get_val(in_details, "cached_tokens_details", "cachedTokensDetails") or {}
)
cached_audio = int(_get_val(cached_details, "audio_tokens", "audioTokens") or 0) 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_text = int(_get_val(cached_details, "text_tokens", "textTokens") or 0)
cached_image = int(_get_val(cached_details, "image_tokens", "imageTokens") or 0) cached_image = int(_get_val(cached_details, "image_tokens", "imageTokens") or 0)
if not (cached_audio or cached_text or cached_image): if not (cached_audio or cached_text or cached_image):
cached_audio = int(_get_val(in_details, "cached_audio_tokens", "cachedAudioTokens") or 0) cached_audio = int(
cached_text = int(_get_val(in_details, "cached_text_tokens", "cachedTextTokens") or 0) _get_val(in_details, "cached_audio_tokens", "cachedAudioTokens") or 0
cached_image = int(_get_val(in_details, "cached_image_tokens", "cachedImageTokens") 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) audio_out = int(_get_val(out_details, "audio_tokens", "audioTokens") or 0)
text_out = int(_get_val(out_details, "text_tokens", "textTokens") or 0) text_out = int(_get_val(out_details, "text_tokens", "textTokens") or 0)
@ -327,25 +398,36 @@ def _openai_realtime_usage_to_sts_metadata(usage: Dict[str, Any]) -> Dict[str, A
out = {"schemaVersion": 1} out = {"schemaVersion": 1}
inp = {} inp = {}
if text_in > 0: inp["text"] = {"tokens": text_in} if text_in > 0:
if audio_in > 0: inp["audio"] = {"tokens": audio_in} inp["text"] = {"tokens": text_in}
if image_in > 0: inp["image"] = {"tokens": image_in} if audio_in > 0:
if inp: out["input"] = inp inp["audio"] = {"tokens": audio_in}
if image_in > 0:
inp["image"] = {"tokens": image_in}
if inp:
out["input"] = inp
o = {} o = {}
if text_out > 0: o["text"] = {"tokens": text_out} if text_out > 0:
if audio_out > 0: o["audio"] = {"tokens": audio_out} o["text"] = {"tokens": text_out}
if o: out["output"] = o 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) has_split = bool(cached_text or cached_audio or cached_image)
if cached_total > 0 and not has_split: if cached_total > 0 and not has_split:
out["cached"] = {"tokens": int(cached_total)} out["cached"] = {"tokens": int(cached_total)}
elif has_split: elif has_split:
cd = {} cd = {}
if cached_text > 0: cd["text"] = {"tokens": cached_text} if cached_text > 0:
if cached_audio > 0: cd["audio"] = {"tokens": cached_audio} cd["text"] = {"tokens": cached_text}
if cached_image > 0: cd["image"] = {"tokens": cached_image} if cached_audio > 0:
if cd: out["cached"] = cd cd["audio"] = {"tokens": cached_audio}
if cached_image > 0:
cd["image"] = {"tokens": cached_image}
if cd:
out["cached"] = cd
return out return out
@ -365,8 +447,12 @@ def _merge_sts_metadata(existing: dict, new: dict) -> dict:
# Prefer per-modality merge when either side has per-modality detail. # Prefer per-modality merge when either side has per-modality detail.
# Only use the flat aggregate{"tokens": N} form when neither side has # Only use the flat aggregate{"tokens": N} form when neither side has
# any per-modality breakdown at all (e.g. legacy schema). # 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")) e_has_modalities = any(
n_has_modalities = any(m in n_val for m in ("text", "audio", "image", "video", "thinking")) 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: if e_has_modalities or n_has_modalities:
for modality in ("text", "audio", "image", "video", "thinking"): for modality in ("text", "audio", "image", "video", "thinking"):
@ -395,13 +481,18 @@ def _merge_sts_metadata(existing: dict, new: dict) -> dict:
out[k] = v out[k] = v
for k, v in new.items(): for k, v in new.items():
if k not in ("schemaVersion", "input", "output", "cached"): if k not in ("schemaVersion", "input", "output", "cached"):
if k in out and isinstance(out[k], (int, float)) and isinstance(v, (int, float)): if (
k in out
and isinstance(out[k], (int, float))
and isinstance(v, (int, float))
):
out[k] = out[k] + v out[k] = out[k] + v
else: else:
out[k] = v out[k] = v
return out return out
class PaygentCollector(BaseObserver): class PaygentCollector(BaseObserver):
"""Pipecat observer that accumulates usage data for a single call. """Pipecat observer that accumulates usage data for a single call.
@ -514,35 +605,63 @@ class PaygentCollector(BaseObserver):
if is_sts_frame: if is_sts_frame:
# Normalise the raw provider slug so that variants like # Normalise the raw provider slug so that variants like
# "openai_realtime", "azure_realtime", etc. route correctly. # "openai_realtime", "azure_realtime", etc. route correctly.
raw_provider = ( raw_provider = getattr(
getattr(self, "_sts_provider", "") or getattr(self, "_llm_provider", "") self, "_sts_provider", ""
) or getattr(self, "_llm_provider", "")
provider = (
_detect_provider(raw_provider)
if raw_provider
else "unknown"
) )
provider = _detect_provider(raw_provider) if raw_provider else "unknown"
if provider not in ("grok", "ultravox"): if provider not in ("grok", "ultravox"):
usage = item.value usage = item.value
raw_metadata = getattr(usage, "raw_usage_metadata", None) raw_metadata = getattr(
usage, "raw_usage_metadata", None
)
if raw_metadata: if raw_metadata:
# OpenAI Realtime and Azure Realtime (azure→openai via _detect_provider) # OpenAI Realtime and Azure Realtime (azure→openai via _detect_provider)
# share the same wire format. # share the same wire format.
if provider in ("openai", "azure"): if provider in ("openai", "azure"):
new_meta = _openai_realtime_usage_to_sts_metadata(raw_metadata) new_meta = (
_openai_realtime_usage_to_sts_metadata(
raw_metadata
)
)
else: else:
new_meta = _google_live_usage_to_sts_metadata(raw_metadata) new_meta = _google_live_usage_to_sts_metadata(
raw_metadata
)
else: else:
prompt_tokens = getattr(usage, "prompt_tokens", 0) or 0 prompt_tokens = (
completion_tokens = getattr(usage, "completion_tokens", 0) or 0 getattr(usage, "prompt_tokens", 0) or 0
cached_tokens = (getattr(usage, "cache_read_input_tokens", 0) or getattr(usage, "cached_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} new_meta = {"schemaVersion": 1}
if prompt_tokens > 0: if prompt_tokens > 0:
new_meta.setdefault("input", {})["text"] = {"tokens": prompt_tokens} new_meta.setdefault("input", {})["text"] = {
"tokens": prompt_tokens
}
if completion_tokens > 0: if completion_tokens > 0:
new_meta.setdefault("output", {})["text"] = {"tokens": completion_tokens} new_meta.setdefault("output", {})["text"] = {
"tokens": completion_tokens
}
if cached_tokens > 0: if cached_tokens > 0:
new_meta["cached"] = {"tokens": cached_tokens} new_meta["cached"] = {"tokens": cached_tokens}
if hasattr(usage, "__dict__"): if hasattr(usage, "__dict__"):
for k, v in vars(usage).items(): for k, v in vars(usage).items():
if not k.startswith("_") and v is not None and k not in new_meta: if (
not k.startswith("_")
and v is not None
and k not in new_meta
):
new_meta[k] = v new_meta[k] = v
self._acc.sts_usage_metadata = _merge_sts_metadata( self._acc.sts_usage_metadata = _merge_sts_metadata(
@ -579,5 +698,7 @@ class PaygentCollector(BaseObserver):
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
"[paygent] Unexpected error processing frame {!r} in collector: {}", "[paygent] Unexpected error processing frame {!r} in collector: {}",
type(data.frame).__name__, exc, exc_info=True, type(data.frame).__name__,
exc,
exc_info=True,
) )

View file

@ -12,6 +12,7 @@ Mirrors ``tuner/completion.py`` exactly:
- call ``deliver(config, snapshot)`` - call ``deliver(config, snapshot)``
- collect results keyed by ``paygent_{node_id}`` - collect results keyed by ``paygent_{node_id}``
""" """
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime from datetime import UTC, datetime
@ -90,7 +91,9 @@ async def run_completion(
continue continue
# ---- Build typed objects ------------------------------------------- # ---- Build typed objects -------------------------------------------
snapshot = _build_snapshot(raw_snapshot, workflow_run_id=context.workflow_run_id) snapshot = _build_snapshot(
raw_snapshot, workflow_run_id=context.workflow_run_id
)
# Inject node-level credentials into the snapshot # Inject node-level credentials into the snapshot
snapshot.agent_id = (node_data.paygent_agent_id or "").strip() snapshot.agent_id = (node_data.paygent_agent_id or "").strip()
snapshot.customer_id = (node_data.paygent_customer_id or "").strip() snapshot.customer_id = (node_data.paygent_customer_id or "").strip()
@ -102,7 +105,11 @@ async def run_completion(
# Only fallback to pipeline-level llm usage if this is NOT a realtime pipeline. # 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; # In realtime pipelines, the collector properly segregates STS and LLM tokens;
# falling back here would duplicate the STS tokens into the LLM bucket. # 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: if (
snapshot.llm_prompt_tokens == 0
and snapshot.llm_completion_tokens == 0
and not snapshot.is_realtime
):
llm_providers: list[str] = [] llm_providers: list[str] = []
llm_models: list[str] = [] llm_models: list[str] = []
for key, val in usage_info.get("llm", {}).items(): for key, val in usage_info.get("llm", {}).items():
@ -112,7 +119,9 @@ async def run_completion(
continue continue
snapshot.llm_prompt_tokens += val.get("prompt_tokens", 0) snapshot.llm_prompt_tokens += val.get("prompt_tokens", 0)
snapshot.llm_completion_tokens += val.get("completion_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) snapshot.llm_cached_tokens += val.get(
"cache_read_input_tokens", 0
) + val.get("cache_creation_input_tokens", 0)
parts = key.split("|||") parts = key.split("|||")
if len(parts) == 2: if len(parts) == 2:
llm_providers.append(parts[0]) llm_providers.append(parts[0])
@ -153,7 +162,11 @@ async def run_completion(
# substitute total_duration_seconds — that would overbill wall-clock time # substitute total_duration_seconds — that would overbill wall-clock time
# (silence, hold, agent speech) as STT input. # (silence, hold, agent speech) as STT input.
except Exception as exc: except Exception as exc:
logger.warning("[paygent] Failed to apply usage_info fallback for run {}: {}", context.workflow_run_id, exc) logger.warning(
"[paygent] Failed to apply usage_info fallback for run {}: {}",
context.workflow_run_id,
exc,
)
try: try:
config = PaygentDeliveryConfig( config = PaygentDeliveryConfig(

View file

@ -12,11 +12,11 @@ Lifecycle:
integration framework, which persists it in ``workflow_run.logs`` under integration framework, which persists it in ``workflow_run.logs`` under
the key ``"paygent_snapshot"``. the key ``"paygent_snapshot"``.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from api.services.configuration.registry import ServiceProviders
from api.services.integrations.base import ( from api.services.integrations.base import (
IntegrationRuntimeContext, IntegrationRuntimeContext,
IntegrationRuntimeSession, IntegrationRuntimeSession,
@ -90,9 +90,7 @@ class PaygentRuntimeSession(IntegrationRuntimeSession):
gathered_context: dict[str, Any], gathered_context: dict[str, Any],
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""Seal the snapshot and hand it to the framework for persistence.""" """Seal the snapshot and hand it to the framework for persistence."""
self._collector.set_call_disposition( self._collector.set_call_disposition(gathered_context.get("call_disposition"))
gathered_context.get("call_disposition")
)
snapshot = self._collector.build_snapshot() snapshot = self._collector.build_snapshot()
return {"paygent_snapshot": snapshot} return {"paygent_snapshot": snapshot}
@ -109,8 +107,7 @@ def create_runtime_sessions(
paygent_nodes = [ paygent_nodes = [
node node
for node in context.workflow_graph.nodes.values() for node in context.workflow_graph.nodes.values()
if node.node_type == "paygent" if node.node_type == "paygent" and getattr(node.data, "paygent_enabled", True)
and getattr(node.data, "paygent_enabled", True)
] ]
if not paygent_nodes: if not paygent_nodes:
return [] return []

View file

@ -1,9 +1,9 @@
"""Dograh subclass of pipecat's Azure OpenAI Realtime LLM service. """Dograh subclass of pipecat's Azure OpenAI Realtime LLM service.
Layers Dograh engine integration quirks (mute gating, TTSSpeakFrame greeting Layers Dograh engine integration quirks (mute gating, TTSSpeakFrame greeting
trigger, LLMMessagesAppendFrame handling, deferred tool calls) onto pipecat's trigger, LLMMessagesAppendFrame handling, workflow-control deferral) onto
AzureRealtimeLLMService, mirroring what DograhOpenAIRealtimeLLMService does pipecat's AzureRealtimeLLMService, mirroring what
for the standard OpenAI Realtime endpoint. DograhOpenAIRealtimeLLMService does for the standard OpenAI Realtime endpoint.
""" """
import json import json
@ -40,7 +40,7 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
- User-mute audio gating - User-mute audio gating
- TTSSpeakFrame as initial-response trigger - TTSSpeakFrame as initial-response trigger
- One-off LLMMessagesAppendFrame handling - One-off LLMMessagesAppendFrame handling
- Deferred tool calls until bot finishes speaking - Workflow-control calls deferred until bot finishes speaking
- finalized=True on TranscriptionFrame for consistency - finalized=True on TranscriptionFrame for consistency
""" """
@ -49,7 +49,7 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
self._user_is_muted: bool = False self._user_is_muted: bool = False
self._handled_initial_context: bool = False self._handled_initial_context: bool = False
self._bot_is_speaking: bool = False self._bot_is_speaking: bool = False
self._deferred_function_calls: list[FunctionCallFromLLM] = [] self._deferred_node_transition_function_calls: list[FunctionCallFromLLM] = []
self._pending_initial_greeting_text: str | None = None self._pending_initial_greeting_text: str | None = None
async def process_frame(self, frame: Frame, direction: FrameDirection): async def process_frame(self, frame: Frame, direction: FrameDirection):
@ -81,7 +81,7 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
self._bot_is_speaking = True self._bot_is_speaking = True
elif isinstance(frame, BotStoppedSpeakingFrame): elif isinstance(frame, BotStoppedSpeakingFrame):
self._bot_is_speaking = False self._bot_is_speaking = False
await self._run_pending_function_calls() await self._run_pending_node_transition_function_calls()
await super().process_frame(frame, direction) await super().process_frame(frame, direction)
async def _handle_messages_append(self, frame: LLMMessagesAppendFrame): async def _handle_messages_append(self, frame: LLMMessagesAppendFrame):
@ -247,18 +247,19 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
) )
) )
async def _run_pending_function_calls(self): async def _run_pending_node_transition_function_calls(self):
if not self._deferred_function_calls: if not self._deferred_node_transition_function_calls:
return return
function_calls = self._deferred_function_calls function_calls = self._deferred_node_transition_function_calls
self._deferred_function_calls = [] self._deferred_node_transition_function_calls = []
logger.debug( logger.debug(
f"{self}: executing {len(function_calls)} deferred function call(s) " f"{self}: executing {len(function_calls)} deferred workflow-control "
"after bot turn ended" "call(s) after bot turn ended"
) )
await self.run_function_calls(function_calls) await self.run_function_calls(function_calls)
async def _handle_evt_function_call_arguments_done(self, evt): async def _handle_evt_function_call_arguments_done(self, evt):
"""Run ordinary tools immediately and defer workflow-control calls."""
try: try:
args = json.loads(evt.arguments) args = json.loads(evt.arguments)
@ -275,10 +276,14 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
) )
] ]
if self._bot_is_speaking: is_node_transition = self._function_is_node_transition(
self._deferred_function_calls.extend(function_calls) function_call_item.name
)
if self._bot_is_speaking and is_node_transition:
self._deferred_node_transition_function_calls.extend(function_calls)
logger.debug( logger.debug(
f"{self}: deferring function call {function_call_item.name} " f"{self}: deferring workflow-control call "
f"{function_call_item.name} "
"until bot stops speaking" "until bot stops speaking"
) )
else: else:

View file

@ -9,8 +9,8 @@ Layers Dograh engine integration quirks onto upstream-pristine
- **Reconnect on node transitions.** Gemini Live cannot update - **Reconnect on node transitions.** Gemini Live cannot update
``system_instruction`` mid-session, so a setting change triggers a ``system_instruction`` mid-session, so a setting change triggers a
reconnect (deferred until the bot turn ends if currently responding). reconnect (deferred until the bot turn ends if currently responding).
- **Function-call deferral.** Tool calls emitted mid-turn are queued and run - **Node-transition deferral.** Node-transition calls emitted mid-turn are
when the bot stops speaking, to avoid racing the turn's audio. queued and run when the bot stops speaking, to avoid cutting off its audio.
- **User-mute audio gating.** ``UserMuteStarted/StoppedFrame`` from the - **User-mute audio gating.** ``UserMuteStarted/StoppedFrame`` from the
user aggregator gates whether incoming audio is forwarded to Gemini. user aggregator gates whether incoming audio is forwarded to Gemini.
- **TTSSpeakFrame as greeting trigger.** The engine queues a TTSSpeakFrame - **TTSSpeakFrame as greeting trigger.** The engine queues a TTSSpeakFrame
@ -18,6 +18,7 @@ Layers Dograh engine integration quirks onto upstream-pristine
it and runs the initial-context path. it and runs the initial-context path.
""" """
import asyncio
from typing import Any from typing import Any
from google.genai.types import Content, Part from google.genai.types import Content, Part
@ -44,6 +45,11 @@ from pipecat.utils.tracing.service_decorators import traced_gemini_live
class DograhGeminiLiveLLMService(GeminiLiveLLMService): class DograhGeminiLiveLLMService(GeminiLiveLLMService):
"""Gemini Live with Dograh engine integration quirks. See module docstring.""" """Gemini Live with Dograh engine integration quirks. See module docstring."""
# Gemini input transcription is delivered independently from tool calls.
# Give late transcription messages a small window to arrive before running
# a node-transition function and tearing down the current Live connection.
_NODE_TRANSITION_TRANSCRIPTION_GRACE_SECONDS = 0.5
# Route tool schemas through Gemini's ``parameters_json_schema`` field so # Route tool schemas through Gemini's ``parameters_json_schema`` field so
# MCP/imported tools that use JSON Schema keywords (``const``, ``not``, # MCP/imported tools that use JSON Schema keywords (``const``, ``not``,
# nested ``anyOf``) rejected by the strict ``Schema`` model are accepted. # nested ``anyOf``) rejected by the strict ``Schema`` model are accepted.
@ -59,15 +65,19 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
# Guards initial-response triggering against double-firing across the # Guards initial-response triggering against double-firing across the
# initial TTSSpeakFrame and any LLMContextFrame that may arrive. # initial TTSSpeakFrame and any LLMContextFrame that may arrive.
self._handled_initial_context: bool = False self._handled_initial_context: bool = False
# When a system_instruction change arrives mid-bot-turn, the reconnect # Node-transition calls emitted mid-bot-turn are deferred here so the
# is queued and drained when the turn ends. # transition does not tear down Gemini while it is still producing audio.
self._reconnect_pending: bool = False self._pending_node_transition_function_calls: list[FunctionCallFromLLM] = []
# Function calls emitted by Gemini mid-bot-turn are deferred here and
# invoked when the turn ends, so they don't race the turn's audio.
self._pending_function_calls: list[FunctionCallFromLLM] = []
# Text greeting captured from the first TTSSpeakFrame while the Gemini # Text greeting captured from the first TTSSpeakFrame while the Gemini
# session is still connecting. # session is still connecting.
self._pending_initial_greeting_text: str | None = None self._pending_initial_greeting_text: str | None = None
self._transition_function_call_task: asyncio.Task | None = None
# Intentional node changes use a fresh, context-seeded connection rather
# than a potentially stale session-resumption handle. The new connection
# remains gated until the function-call result has landed in LLMContext.
self._awaiting_node_transition_context: bool = False
self._node_transition_context_received: bool = False
self._node_transition_context_seed_started: bool = False
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Hooks from upstream GeminiLiveLLMService # Hooks from upstream GeminiLiveLLMService
@ -78,33 +88,109 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
# lets pre-call fetch populate template variables first. # lets pre-call fetch populate template variables first.
return bool(self._settings.system_instruction) return bool(self._settings.system_instruction)
def _requires_node_transition_context_aggregation(self) -> bool:
# A node transition replaces the current Gemini Live connection and
# seeds the new one from our local LLMContext. Wait for the upstream
# user aggregator to commit any final TranscriptionFrame before
# set_node() changes the prompt and starts that reconnect.
return True
async def cleanup(self) -> None:
"""Cancel a delayed transition before tearing down the Live session."""
if self._transition_function_call_task:
await self.cancel_task(self._transition_function_call_task)
self._transition_function_call_task = None
await super().cleanup()
async def _handle_changed_settings(self, changed: dict[str, Any]) -> set[str]: async def _handle_changed_settings(self, changed: dict[str, Any]) -> set[str]:
if "system_instruction" not in changed: if "system_instruction" not in changed:
return set() return set()
# PipecatEngine updates system_instruction only from set_node(). The
# first set_node happens before a Live session exists; every later one
# is a node transition whose tool call has already been deferred until
# the current bot turn finishes.
if not self._session: if not self._session:
# First-time setting after deferred-connect. # First-time setting after deferred-connect.
await self._connect() await self._connect()
elif self._bot_is_responding:
# Bot is mid-turn — drain the reconnect when it ends so we don't
# cut the bot off mid-utterance.
self._reconnect_pending = True
else: else:
await self._reconnect() await self._reconnect_for_node_transition()
return {"system_instruction"} return {"system_instruction"}
async def _run_or_defer_function_calls( async def _run_or_defer_function_calls(
self, function_calls_llm: list[FunctionCallFromLLM] self, function_calls_llm: list[FunctionCallFromLLM]
): ):
if not self._contains_node_transition(function_calls_llm):
await super()._run_or_defer_function_calls(function_calls_llm)
return
# Keep a provider tool-call batch together. Splitting a mixed batch here
# would discard Pipecat's shared function-call group and could trigger an
# LLM run before every result from the original batch has arrived.
if self._bot_is_responding: if self._bot_is_responding:
# Latest batch wins; Gemini emits tool calls as one batch per # Latest batch wins; Gemini emits tool calls as one batch per
# tool_call message, so this overwrite is intentional. # tool_call message, so this overwrite is intentional.
self._pending_function_calls = function_calls_llm self._pending_node_transition_function_calls = function_calls_llm
logger.debug( logger.debug(
f"{self}: deferring {len(function_calls_llm)} function call(s) " f"{self}: deferring {len(function_calls_llm)} node-transition "
"function call(s) "
"until bot turn ends" "until bot turn ends"
) )
return return
await super()._run_or_defer_function_calls(function_calls_llm)
self._schedule_node_transition_function_calls(function_calls_llm)
def _contains_node_transition(
self, function_calls_llm: list[FunctionCallFromLLM]
) -> bool:
return any(self._is_node_transition(fc) for fc in function_calls_llm)
def _is_node_transition(self, function_call: FunctionCallFromLLM) -> bool:
return self._function_is_node_transition(function_call.function_name)
def _schedule_node_transition_function_calls(
self, function_calls_llm: list[FunctionCallFromLLM]
) -> None:
"""Run transition calls after late input transcription has settled."""
if (
self._transition_function_call_task
and not self._transition_function_call_task.done()
):
logger.warning(
f"{self}: node-transition function call already pending; "
"ignoring duplicate batch"
)
return
async def _run_after_transcription_grace() -> None:
try:
await asyncio.sleep(self._NODE_TRANSITION_TRANSCRIPTION_GRACE_SECONDS)
await self._flush_pending_user_transcription()
await self.run_function_calls(function_calls_llm)
finally:
self._transition_function_call_task = None
self._transition_function_call_task = self.create_task(
_run_after_transcription_grace(),
name=f"{self}::node-transition-function-calls",
)
async def _flush_pending_user_transcription(self) -> None:
"""Publish any punctuationless user transcript before a node handoff."""
if self._transcription_timeout_task:
if not self._transcription_timeout_task.done():
await self.cancel_task(self._transcription_timeout_task)
self._transcription_timeout_task = None
if not self._user_transcription_buffer:
return
text = self._user_transcription_buffer
self._user_transcription_buffer = ""
logger.debug(
f"{self}: flushing pending user transcription before node transition"
)
await self._push_user_transcription(text, result=None)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# State-transition side effects # State-transition side effects
@ -114,22 +200,35 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
was_responding = self._bot_is_responding was_responding = self._bot_is_responding
await super()._set_bot_is_responding(responding) await super()._set_bot_is_responding(responding)
if was_responding and not responding: if was_responding and not responding:
await self._run_pending_function_calls() await self._run_pending_node_transition_function_calls()
if self._reconnect_pending:
self._reconnect_pending = False
await self._reconnect()
async def _run_pending_function_calls(self): async def _run_pending_node_transition_function_calls(self):
"""Run any function calls deferred during the bot's last turn.""" """Run any node-transition calls deferred during the bot's last turn."""
if not self._pending_function_calls: if not self._pending_node_transition_function_calls:
return return
fcs = self._pending_function_calls fcs = self._pending_node_transition_function_calls
self._pending_function_calls = [] self._pending_node_transition_function_calls = []
logger.debug( logger.debug(
f"{self}: executing {len(fcs)} deferred function call(s) " f"{self}: executing {len(fcs)} deferred node-transition call(s) "
"after bot turn ended" "after bot turn ended"
) )
await self.run_function_calls(fcs) self._schedule_node_transition_function_calls(fcs)
async def _reconnect_for_node_transition(self) -> None:
"""Start a fresh connection and wait to seed the completed context.
Gemini can report ``resumable=False`` while generating or executing a
function call. A workflow transition happens at exactly that boundary,
so using the last (older) resumption handle can omit the triggering user
turn. Use the local LLMContext as the source of truth for this intentional
handoff instead.
"""
self._awaiting_node_transition_context = True
self._node_transition_context_received = False
self._node_transition_context_seed_started = False
self._session_resumption_handle = None
await self._disconnect()
await self._connect(session_resumption_handle=None)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Frame handling: mute, TTSSpeakFrame, BotStoppedSpeakingFrame flush # Frame handling: mute, TTSSpeakFrame, BotStoppedSpeakingFrame flush
@ -165,9 +264,9 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
if isinstance(frame, BotStoppedSpeakingFrame): if isinstance(frame, BotStoppedSpeakingFrame):
# Belt-and-suspenders: the main drain happens in # Belt-and-suspenders: the main drain happens in
# _set_bot_is_responding(False), but if Gemini delays turn_complete # _set_bot_is_responding(False), but if Gemini delays turn_complete
# past the audible end of the turn, flushing here ensures pending # past the audible end of the turn, flushing here ensures a pending
# function calls fire promptly. # node transition fires promptly.
await self._run_pending_function_calls() await self._run_pending_node_transition_function_calls()
# Fall through to super for the actual push. # Fall through to super for the actual push.
await super().process_frame(frame, direction) await super().process_frame(frame, direction)
@ -185,6 +284,11 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
async def _handle_context(self, context: LLMContext): async def _handle_context(self, context: LLMContext):
if self._awaiting_node_transition_context:
self._context = context
self._node_transition_context_received = True
await self._maybe_seed_node_transition_context()
return
if not self._handled_initial_context: if not self._handled_initial_context:
self._handled_initial_context = True self._handled_initial_context = True
self._context = context self._context = context
@ -249,6 +353,12 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
f"In _handle_session_ready self._run_llm_when_session_ready: {self._run_llm_when_session_ready}" f"In _handle_session_ready self._run_llm_when_session_ready: {self._run_llm_when_session_ready}"
) )
self._session = session self._session = session
if self._awaiting_node_transition_context:
# Do not accept realtime input until the function-call result frame
# has updated the shared context and that complete history is seeded.
self._ready_for_realtime_input = False
await self._maybe_seed_node_transition_context()
return
self._ready_for_realtime_input = True self._ready_for_realtime_input = True
if self._run_llm_when_session_ready: if self._run_llm_when_session_ready:
# Context arrived before session was ready — fulfil the queued # Context arrived before session was ready — fulfil the queued
@ -266,3 +376,25 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
# a handle (e.g. node transitions before any handle was issued) are # a handle (e.g. node transitions before any handle was issued) are
# followed by a function-call-result LLMContextFrame which feeds the # followed by a function-call-result LLMContextFrame which feeds the
# updated-context branch in _handle_context. # updated-context branch in _handle_context.
async def _maybe_seed_node_transition_context(self) -> None:
if (
not self._awaiting_node_transition_context
or not self._node_transition_context_received
or not self._session
or self._node_transition_context_seed_started
):
return
self._node_transition_context_seed_started = True
try:
# The complete tool result is already present in the history being
# seeded, so mark it delivered locally instead of sending a provider
# tool response for a call that the fresh session never issued.
await self._process_completed_function_calls(send_new_results=False)
await self._create_initial_response()
self._awaiting_node_transition_context = False
self._node_transition_context_received = False
await self._drain_pending_tool_results()
finally:
self._node_transition_context_seed_started = False

View file

@ -11,8 +11,9 @@ Adds:
flow kicks off the bot's first response. flow kicks off the bot's first response.
- **One-off LLMMessagesAppendFrame handling** for ephemeral realtime prompts - **One-off LLMMessagesAppendFrame handling** for ephemeral realtime prompts
like user-idle checks, without mutating Dograh's local ``LLMContext``. like user-idle checks, without mutating Dograh's local ``LLMContext``.
- **Function-call deferral** until the bot finishes speaking, to avoid racing - **Workflow-control deferral** so node transitions, call termination, and
tool execution with the active audio turn. transfers wait for any current bot audio to finish while ordinary tools run
immediately.
- **finalized=True on TranscriptionFrame** for parity with Dograh's other - **finalized=True on TranscriptionFrame** for parity with Dograh's other
realtime providers. realtime providers.
""" """
@ -50,7 +51,7 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
self._user_is_muted: bool = False self._user_is_muted: bool = False
self._handled_initial_context: bool = False self._handled_initial_context: bool = False
self._bot_is_speaking: bool = False self._bot_is_speaking: bool = False
self._deferred_function_calls: list[FunctionCallFromLLM] = [] self._deferred_node_transition_function_calls: list[FunctionCallFromLLM] = []
self._pending_initial_greeting_text: str | None = None self._pending_initial_greeting_text: str | None = None
async def process_frame(self, frame: Frame, direction: FrameDirection): async def process_frame(self, frame: Frame, direction: FrameDirection):
@ -82,7 +83,7 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
self._bot_is_speaking = True self._bot_is_speaking = True
elif isinstance(frame, BotStoppedSpeakingFrame): elif isinstance(frame, BotStoppedSpeakingFrame):
self._bot_is_speaking = False self._bot_is_speaking = False
await self._run_pending_function_calls() await self._run_pending_node_transition_function_calls()
await super().process_frame(frame, direction) await super().process_frame(frame, direction)
async def _handle_messages_append(self, frame: LLMMessagesAppendFrame): async def _handle_messages_append(self, frame: LLMMessagesAppendFrame):
@ -251,19 +252,19 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
) )
) )
async def _run_pending_function_calls(self): async def _run_pending_node_transition_function_calls(self):
if not self._deferred_function_calls: if not self._deferred_node_transition_function_calls:
return return
function_calls = self._deferred_function_calls function_calls = self._deferred_node_transition_function_calls
self._deferred_function_calls = [] self._deferred_node_transition_function_calls = []
logger.debug( logger.debug(
f"{self}: executing {len(function_calls)} deferred function call(s) " f"{self}: executing {len(function_calls)} deferred workflow-control "
"after bot turn ended" "call(s) after bot turn ended"
) )
await self.run_function_calls(function_calls) await self.run_function_calls(function_calls)
async def _handle_evt_function_call_arguments_done(self, evt): async def _handle_evt_function_call_arguments_done(self, evt):
"""Process or defer tool calls until the bot finishes speaking.""" """Run ordinary tools immediately and defer workflow-control calls."""
try: try:
args = json.loads(evt.arguments) args = json.loads(evt.arguments)
@ -281,10 +282,11 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
) )
] ]
if self._bot_is_speaking: is_node_transition = self._function_is_node_transition(function_name)
self._deferred_function_calls.extend(function_calls) if self._bot_is_speaking and is_node_transition:
self._deferred_node_transition_function_calls.extend(function_calls)
logger.debug( logger.debug(
f"{self}: deferring function call {function_name} " f"{self}: deferring workflow-control call {function_name} "
"until bot stops speaking" "until bot stops speaking"
) )
else: else:

View file

@ -3,8 +3,8 @@
Layers Dograh engine integration quirks onto upstream-pristine Layers Dograh engine integration quirks onto upstream-pristine
:class:`OpenAIRealtimeLLMService`. Substantially smaller than the Gemini :class:`OpenAIRealtimeLLMService`. Substantially smaller than the Gemini
subclass because OpenAI Realtime supports runtime ``session.update`` for subclass because OpenAI Realtime supports runtime ``session.update`` for
both ``system_instruction`` and tools no reconnect/defer-tool-call both ``system_instruction`` and tools, so node changes do not require a
machinery needed. reconnect.
Adds: Adds:
@ -13,6 +13,9 @@ Adds:
flow kicks off the bot's first response. flow kicks off the bot's first response.
- **One-off LLMMessagesAppendFrame handling** for ephemeral realtime prompts - **One-off LLMMessagesAppendFrame handling** for ephemeral realtime prompts
like user-idle checks, without mutating Dograh's local ``LLMContext``. like user-idle checks, without mutating Dograh's local ``LLMContext``.
- **Workflow-control deferral** so node transitions, call termination, and
transfers wait for any current bot audio to finish while ordinary tools run
immediately.
- **finalized=True on TranscriptionFrame** because every OpenAI - **finalized=True on TranscriptionFrame** because every OpenAI
transcription via the ``completed`` event is final by construction. transcription via the ``completed`` event is final by construction.
""" """
@ -53,10 +56,10 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
# LLMContextFrame arrives, so upstream's "first arrival means # LLMContextFrame arrives, so upstream's "first arrival means
# self._context is None" check no longer works. # self._context is None" check no longer works.
self._handled_initial_context: bool = False self._handled_initial_context: bool = False
# Track bot speech locally so tool calls can be deferred until the bot # Track bot speech locally so workflow-control calls can wait until the
# has finished speaking, matching Dograh's Gemini Live behavior. # bot has finished speaking without delaying ordinary tools.
self._bot_is_speaking: bool = False self._bot_is_speaking: bool = False
self._deferred_function_calls: list[FunctionCallFromLLM] = [] self._deferred_node_transition_function_calls: list[FunctionCallFromLLM] = []
self._pending_initial_greeting_text: str | None = None self._pending_initial_greeting_text: str | None = None
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -100,7 +103,7 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
self._bot_is_speaking = True self._bot_is_speaking = True
elif isinstance(frame, BotStoppedSpeakingFrame): elif isinstance(frame, BotStoppedSpeakingFrame):
self._bot_is_speaking = False self._bot_is_speaking = False
await self._run_pending_function_calls() await self._run_pending_node_transition_function_calls()
await super().process_frame(frame, direction) await super().process_frame(frame, direction)
async def _handle_messages_append(self, frame: LLMMessagesAppendFrame): async def _handle_messages_append(self, frame: LLMMessagesAppendFrame):
@ -268,19 +271,19 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
) )
) )
async def _run_pending_function_calls(self): async def _run_pending_node_transition_function_calls(self):
if not self._deferred_function_calls: if not self._deferred_node_transition_function_calls:
return return
function_calls = self._deferred_function_calls function_calls = self._deferred_node_transition_function_calls
self._deferred_function_calls = [] self._deferred_node_transition_function_calls = []
logger.debug( logger.debug(
f"{self}: executing {len(function_calls)} deferred function call(s) " f"{self}: executing {len(function_calls)} deferred workflow-control "
"after bot turn ended" "call(s) after bot turn ended"
) )
await self.run_function_calls(function_calls) await self.run_function_calls(function_calls)
async def _handle_evt_function_call_arguments_done(self, evt): async def _handle_evt_function_call_arguments_done(self, evt):
"""Process or defer tool calls until the bot finishes speaking.""" """Run ordinary tools immediately and defer workflow-control calls."""
try: try:
args = json.loads(evt.arguments) args = json.loads(evt.arguments)
@ -297,10 +300,14 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
) )
] ]
if self._bot_is_speaking: is_node_transition = self._function_is_node_transition(
self._deferred_function_calls.extend(function_calls) function_call_item.name
)
if self._bot_is_speaking and is_node_transition:
self._deferred_node_transition_function_calls.extend(function_calls)
logger.debug( logger.debug(
f"{self}: deferring function call {function_call_item.name} " f"{self}: deferring workflow-control call "
f"{function_call_item.name} "
"until bot stops speaking" "until bot stops speaking"
) )
else: else:

View file

@ -1,19 +1,17 @@
"""Dograh subclass of pipecat's Ultravox realtime LLM service. """Dograh subclass of pipecat's Ultravox realtime LLM service.
Ultravox is audio-native and realtime, but prompt and tool configuration is Ultravox is audio-native and realtime. Its native call stages allow a client
bound to call creation. Dograh therefore cannot lean on in-session updates or tool result to atomically change the system prompt and tools while preserving
Gemini-style session resumption handles. This wrapper adapts Ultravox to the the call's server-side conversation history. This wrapper adapts that model to
Dograh engine contract by: the Dograh engine contract by:
- deferring the first call creation until the engine queues the initial node - deferring the first call creation until the engine queues the initial node
opening via ``TTSSpeakFrame`` or ``LLMContextFrame`` opening via ``TTSSpeakFrame`` or ``LLMContextFrame``
- marking the call for recreation when ``system_instruction`` changes across - returning node-transition tool results with ``responseType="new-stage"`` so
node transitions, then rebuilding it on the follow-up ``LLMContextFrame`` the existing call keeps its complete audio-native history
so the transition tool result is present in ``initialMessages`` - updating the next stage's system prompt and selected tools without a
- reconstructing Ultravox ``initialMessages`` from Dograh context when the disconnect/reconnect cycle
call must be recreated after a node transition - deferring workflow-control tools until any active Ultravox response ends
- appending a transient resumptive user nudge to recreated ``initialMessages``
after tool-result transitions, without mutating Dograh's stored context
- handling Dograh-only frames such as user mute and idle append prompts - handling Dograh-only frames such as user mute and idle append prompts
- tagging user transcripts with ``finalized=True`` for downstream parity - tagging user transcripts with ``finalized=True`` for downstream parity
""" """
@ -34,12 +32,7 @@ from pipecat.frames.frames import (
UserMuteStartedFrame, UserMuteStartedFrame,
UserMuteStoppedFrame, UserMuteStoppedFrame,
) )
from pipecat.processors.aggregators import async_tool_messages from pipecat.processors.aggregators.llm_context import LLMContext, is_given
from pipecat.processors.aggregators.llm_context import (
LLMContext,
LLMSpecificMessage,
is_given,
)
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.llm_service import LLMService from pipecat.services.llm_service import LLMService
from pipecat.services.settings import _NotGiven, assert_given from pipecat.services.settings import _NotGiven, assert_given
@ -58,10 +51,6 @@ class DograhUltravoxOneShotInputParams(OneShotInputParams):
_ULTRAVOX_MAX_TOOL_TIMEOUT_SECS = 40.0 _ULTRAVOX_MAX_TOOL_TIMEOUT_SECS = 40.0
_RESUMPTION_USER_MESSAGE = (
"IMPORTANT: We are resuming an existing conversation. You are given previous turns ONLY for your reference. "
"Do not use that to frame your response. Follow your ORIGINAL INSTRUCTIONS ONLY."
)
class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService): class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
@ -72,12 +61,19 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
self._context: LLMContext | None = None self._context: LLMContext | None = None
self._selected_tools = None self._selected_tools = None
self._user_is_muted: bool = False self._user_is_muted: bool = False
self._call_system_instruction: str | None = None
self._reconnect_required: bool = False
self._call_started: bool = False self._call_started: bool = False
self._has_connected_once: bool = False self._stage_update_required: bool = False
self._pending_reconnect_system_instruction: str | None = None # Ultravox applies a stage update on the matching client tool result,
self._pending_initial_messages: list[dict[str, Any]] | None = None # so retain the provider invocation ID until that result reaches us via
# the context aggregator. Unlike Gemini, this ID is part of the wire
# protocol needed to update the existing call without reconnecting.
self._pending_node_transition_tool_call_ids: set[str] = set()
# A stage result can replace the active prompt and tools immediately.
# Hold transition invocations separately so ordinary tools can still
# run during speech while workflow control waits for response end.
self._deferred_node_transition_tool_invocations: list[
tuple[str, str, dict[str, Any]]
] = []
self._pending_user_text_messages: list[str] = [] self._pending_user_text_messages: list[str] = []
async def start(self, frame): async def start(self, frame):
@ -96,9 +92,7 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
if isinstance(frame, TTSSpeakFrame): if isinstance(frame, TTSSpeakFrame):
if not self._socket: if not self._socket:
await self._connect_call( await self._connect_call(
system_instruction=self._current_system_instruction(),
greeting_text=frame.text, greeting_text=frame.text,
initial_messages=None,
agent_speaks_first=True, agent_speaks_first=True,
) )
else: else:
@ -116,18 +110,15 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
changed = await super(UltravoxRealtimeLLMService, self)._update_settings(delta) changed = await super(UltravoxRealtimeLLMService, self)._update_settings(delta)
if "output_medium" in changed: if "output_medium" in changed:
await self._update_output_medium(assert_given(self._settings.output_medium)) await self._update_output_medium(assert_given(self._settings.output_medium))
if "system_instruction" in changed and self._has_connected_once: if "system_instruction" in changed and self._socket:
# Mirror Gemini's "settings change means reconnect" intent, but # The updated instruction is included in the native new-stage
# defer the actual new-call creation until the subsequent # response when the transition tool result reaches _handle_context.
# LLMContextFrame arrives with the transition tool result. Ultravox self._stage_update_required = True
# cannot accept that historical tool result over a formal
# post-connect tool-response channel the way Gemini can.
self._reconnect_required = True
handled = {"output_medium", "system_instruction"} handled = {"output_medium", "system_instruction"}
self._warn_unhandled_updated_settings(changed.keys() - handled) self._warn_unhandled_updated_settings(changed.keys() - handled)
return changed return changed
async def _disconnect(self, preserve_completed_tool_calls: bool = True): async def _disconnect(self):
self._disconnecting = True self._disconnecting = True
await self.stop_all_metrics() await self.stop_all_metrics()
if self._socket: if self._socket:
@ -136,10 +127,11 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
if self._receive_task: if self._receive_task:
await self.cancel_task(self._receive_task, timeout=1.0) await self.cancel_task(self._receive_task, timeout=1.0)
self._receive_task = None self._receive_task = None
if not preserve_completed_tool_calls:
self._completed_tool_calls = set() self._completed_tool_calls = set()
self._call_started = False self._call_started = False
self._started_placeholder_sent = set() self._started_placeholder_sent = set()
self._pending_node_transition_tool_call_ids = set()
self._deferred_node_transition_tool_invocations = []
self._disconnecting = False self._disconnecting = False
async def _send_user_audio(self, frame): async def _send_user_audio(self, frame):
@ -149,39 +141,20 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
async def _handle_context(self, context: LLMContext): async def _handle_context(self, context: LLMContext):
self._context = context self._context = context
system_instruction = self._current_system_instruction()
if self._socket and not self._reconnect_required: if not self._socket:
await self._connect_call(
greeting_text=None,
agent_speaks_first=True,
)
return
current_tools = self._current_tools_schema(context)
if self._pending_node_transition_tool_call_ids and self._tools_changed(
current_tools
):
self._stage_update_required = True
await super()._handle_context(context) await super()._handle_context(context)
return
initial_messages, history_tool_call_ids = self._build_initial_messages(context)
if history_tool_call_ids:
self._completed_tool_calls.update(history_tool_call_ids)
if self._bot_responding:
self._pending_reconnect_system_instruction = system_instruction
self._pending_initial_messages = initial_messages
return
await self._reconnect_with_context(
system_instruction=system_instruction,
initial_messages=initial_messages,
)
async def _handle_response_end(self):
await super()._handle_response_end()
if self._pending_reconnect_system_instruction is None:
return
system_instruction = self._pending_reconnect_system_instruction
initial_messages = self._pending_initial_messages
self._pending_reconnect_system_instruction = None
self._pending_initial_messages = None
await self._reconnect_with_context(
system_instruction=system_instruction,
initial_messages=initial_messages,
)
async def _handle_messages_append(self, frame: LLMMessagesAppendFrame): async def _handle_messages_append(self, frame: LLMMessagesAppendFrame):
texts = [ texts = [
@ -199,9 +172,7 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
if not self._socket: if not self._socket:
self._pending_user_text_messages.extend(texts) self._pending_user_text_messages.extend(texts)
await self._connect_call( await self._connect_call(
system_instruction=self._current_system_instruction(),
greeting_text=None, greeting_text=None,
initial_messages=None,
agent_speaks_first=False, agent_speaks_first=False,
) )
return return
@ -229,17 +200,95 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
finalized=True, finalized=True,
) )
def _requires_node_transition_context_aggregation(self) -> bool:
"""Commit any received final user transcript before changing stages.
Ultravox preserves its own audio-native history across a stage change,
but Dograh's local context still needs the final transcript before the
transition handler updates the workflow node.
"""
return True
async def _handle_tool_invocation(
self, tool_name: str, invocation_id: str, parameters: dict[str, Any]
):
if self._function_is_node_transition(tool_name):
self._pending_node_transition_tool_call_ids.add(invocation_id)
if self._bot_responding:
self._deferred_node_transition_tool_invocations.append(
(tool_name, invocation_id, parameters)
)
logger.debug(
f"{self}: deferring workflow-control call {tool_name} "
"until bot turn ends"
)
return
await super()._handle_tool_invocation(tool_name, invocation_id, parameters)
async def _handle_response_end(self):
"""Close the current response before applying queued workflow control."""
await super()._handle_response_end()
await self._run_deferred_node_transition_tool_invocations()
async def _run_deferred_node_transition_tool_invocations(self):
if not self._deferred_node_transition_tool_invocations:
return
invocations = self._deferred_node_transition_tool_invocations
self._deferred_node_transition_tool_invocations = []
logger.debug(
f"{self}: executing {len(invocations)} deferred workflow-control "
"call(s) after bot turn ended"
)
for tool_name, invocation_id, parameters in invocations:
await super()._handle_tool_invocation(
tool_name, invocation_id, parameters
)
async def _send_tool_result(self, tool_call_id: str, result: str):
is_node_transition = tool_call_id in self._pending_node_transition_tool_call_ids
try:
if is_node_transition and self._stage_update_required:
await self._send_node_transition_stage_result(tool_call_id, result)
else:
await super()._send_tool_result(tool_call_id, result)
finally:
if is_node_transition:
self._pending_node_transition_tool_call_ids.discard(tool_call_id)
async def _send_node_transition_stage_result(self, tool_call_id: str, result: str):
"""Apply node settings using Ultravox's native call-stage protocol."""
next_tools = self._current_tools_schema(self._context)
stage = {
"systemPrompt": self._current_system_instruction(),
"selectedTools": self._selected_tools_payload(next_tools),
# Keep the workflow handler's result as the tool-result message in
# the inherited conversation history for the next generation.
"toolResultText": result,
}
logger.debug(
f"{self}: updating Ultravox call stage for tool_call_id={tool_call_id} "
f"with {len(stage['selectedTools'])} selected tool(s)"
)
await self._send(
{
"type": "client_tool_result",
"invocationId": tool_call_id,
"result": json.dumps(stage, ensure_ascii=True, default=str),
"responseType": "new-stage",
}
)
self._selected_tools = next_tools
self._stage_update_required = False
async def _connect_call( async def _connect_call(
self, self,
*, *,
system_instruction: str | None,
greeting_text: str | None, greeting_text: str | None,
initial_messages: list[dict[str, Any]] | None,
agent_speaks_first: bool, agent_speaks_first: bool,
): ):
params = self._build_one_shot_params( params = self._build_one_shot_params(
greeting_text=greeting_text, greeting_text=greeting_text,
initial_messages=initial_messages,
agent_speaks_first=agent_speaks_first, agent_speaks_first=agent_speaks_first,
) )
self._params = params self._params = params
@ -265,9 +314,7 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
logger.info(f"Joining Ultravox Realtime call via URL: {join_url}") logger.info(f"Joining Ultravox Realtime call via URL: {join_url}")
self._socket = await websocket_client.connect(join_url) self._socket = await websocket_client.connect(join_url)
self._receive_task = self.create_task(self._receive_messages()) self._receive_task = self.create_task(self._receive_messages())
self._call_system_instruction = system_instruction
self._call_started = False self._call_started = False
self._has_connected_once = True
except Exception as e: except Exception as e:
logger.error( logger.error(
f"{self}: Ultravox call creation/join failed " f"{self}: Ultravox call creation/join failed "
@ -365,40 +412,17 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
for pending_text in pending_texts: for pending_text in pending_texts:
await self._send_user_text(pending_text) await self._send_user_text(pending_text)
async def _reconnect_with_context(
self,
*,
system_instruction: str | None,
initial_messages: list[dict[str, Any]] | None,
):
call_initial_messages = self._initial_messages_for_call(initial_messages)
logger.debug(
f"{self}: reconnecting Ultravox call with initialMessages="
f"{json.dumps(call_initial_messages, ensure_ascii=True, default=str)}"
)
if self._socket:
await self._disconnect(preserve_completed_tool_calls=True)
await self._connect_call(
system_instruction=system_instruction,
greeting_text=None,
initial_messages=initial_messages,
agent_speaks_first=self._should_agent_speak_first(initial_messages),
)
self._reconnect_required = False
def _build_one_shot_params( def _build_one_shot_params(
self, self,
*, *,
greeting_text: str | None, greeting_text: str | None,
initial_messages: list[dict[str, Any]] | None,
agent_speaks_first: bool, agent_speaks_first: bool,
) -> DograhUltravoxOneShotInputParams: ) -> DograhUltravoxOneShotInputParams:
current_params = self._params current_params = self._params
extra = { extra = {
key: value key: value
for key, value in current_params.extra.items() for key, value in current_params.extra.items()
if key not in {"firstSpeakerSettings", "initialMessages"} if key != "firstSpeakerSettings"
} }
if greeting_text is not None: if greeting_text is not None:
@ -407,10 +431,6 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
extra["firstSpeakerSettings"] = {"agent": {}} extra["firstSpeakerSettings"] = {"agent": {}}
else: else:
extra["firstSpeakerSettings"] = {"user": {}} extra["firstSpeakerSettings"] = {"user": {}}
call_initial_messages = self._initial_messages_for_call(initial_messages)
if call_initial_messages:
extra["initialMessages"] = call_initial_messages
output_medium = self._settings.output_medium output_medium = self._settings.output_medium
if isinstance(output_medium, _NotGiven): if isinstance(output_medium, _NotGiven):
output_medium = current_params.output_medium output_medium = current_params.output_medium
@ -432,6 +452,14 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
return None return None
return context.tools return context.tools
def _selected_tools_payload(self, tools: Any) -> list[dict[str, Any]]:
return self._to_selected_tools(tools) if tools else []
def _tools_changed(self, tools: Any) -> bool:
return self._selected_tools_payload(tools) != self._selected_tools_payload(
self._selected_tools
)
def _to_selected_tools(self, tool: Any) -> list[dict[str, Any]]: def _to_selected_tools(self, tool: Any) -> list[dict[str, Any]]:
selected_tools = super()._to_selected_tools(tool) selected_tools = super()._to_selected_tools(tool)
for selected_tool in selected_tools: for selected_tool in selected_tools:
@ -462,156 +490,6 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
timeout_secs = min(float(item.timeout_secs), _ULTRAVOX_MAX_TOOL_TIMEOUT_SECS) timeout_secs = min(float(item.timeout_secs), _ULTRAVOX_MAX_TOOL_TIMEOUT_SECS)
return f"{timeout_secs:g}s" return f"{timeout_secs:g}s"
def _initial_messages_for_call(
self, initial_messages: list[dict[str, Any]] | None
) -> list[dict[str, Any]] | None:
if not initial_messages:
return None
if not self._should_add_resumption_user_message(initial_messages):
return initial_messages
return [
*initial_messages,
{
"role": "MESSAGE_ROLE_USER",
"text": _RESUMPTION_USER_MESSAGE,
},
]
def _build_initial_messages(
self, context: LLMContext
) -> tuple[list[dict[str, Any]] | None, set[str]]:
initial_messages: list[dict[str, Any]] = []
tool_call_id_to_name: dict[str, str] = {}
completed_tool_call_ids: set[str] = set()
for message in context.get_messages():
if isinstance(message, LLMSpecificMessage):
continue
async_payload = async_tool_messages.parse_message(message)
if async_payload is not None:
if async_payload.kind == "intermediate":
logger.error(
f"{self}: Ultravox does not support streamed async tool results; "
f"dropping intermediate result from initialMessages for "
f"tool_call_id={async_payload.tool_call_id}."
)
continue
if async_payload.kind == "final":
initial_message = self._build_ultravox_message(
role="MESSAGE_ROLE_TOOL_RESULT",
text=async_payload.result or "",
invocation_id=async_payload.tool_call_id,
tool_name=tool_call_id_to_name.get(async_payload.tool_call_id),
)
if initial_message is not None:
initial_messages.append(initial_message)
completed_tool_call_ids.add(async_payload.tool_call_id)
continue
role = message.get("role")
if role == "user":
initial_message = self._build_ultravox_message(
role="MESSAGE_ROLE_USER",
text=self._extract_text_content(message.get("content")),
)
if initial_message is not None:
initial_messages.append(initial_message)
elif role == "assistant":
text = self._extract_text_content(message.get("content"))
initial_message = self._build_ultravox_message(
role="MESSAGE_ROLE_AGENT",
text=text,
)
if initial_message is not None:
initial_messages.append(initial_message)
tool_calls = message.get("tool_calls")
if isinstance(tool_calls, list):
for tool_call in tool_calls:
if not isinstance(tool_call, dict):
continue
tool_id = tool_call.get("id")
function = tool_call.get("function")
tool_name = (
function.get("name") if isinstance(function, dict) else None
)
if isinstance(tool_id, str) and isinstance(tool_name, str):
tool_call_id_to_name[tool_id] = tool_name
initial_message = self._build_ultravox_message(
role="MESSAGE_ROLE_TOOL_CALL",
text="",
invocation_id=tool_id,
tool_name=tool_name,
)
if initial_message is not None:
initial_messages.append(initial_message)
elif (
role == "tool"
and message.get("content") != "IN_PROGRESS"
and message.get("content") != "CANCELLED"
):
tool_call_id = message.get("tool_call_id")
initial_message = self._build_ultravox_message(
role="MESSAGE_ROLE_TOOL_RESULT",
text=self._stringify_tool_result(message.get("content")),
invocation_id=tool_call_id
if isinstance(tool_call_id, str)
else None,
tool_name=(
tool_call_id_to_name.get(tool_call_id)
if isinstance(tool_call_id, str)
else None
),
)
if initial_message is not None:
initial_messages.append(initial_message)
if isinstance(tool_call_id, str):
completed_tool_call_ids.add(tool_call_id)
return (initial_messages or None), completed_tool_call_ids
@staticmethod
def _build_ultravox_message(
*,
role: str,
text: str | None,
invocation_id: str | None = None,
tool_name: str | None = None,
) -> dict[str, Any] | None:
if text is None:
return None
message: dict[str, Any] = {
"role": role,
"text": text,
}
if invocation_id is not None:
message["invocationId"] = invocation_id
if tool_name is not None:
message["toolName"] = tool_name
return message
@staticmethod
def _should_agent_speak_first(
initial_messages: list[dict[str, Any]] | None,
) -> bool:
if not initial_messages:
return True
return initial_messages[-1].get("role") in {
"MESSAGE_ROLE_USER",
"MESSAGE_ROLE_TOOL_RESULT",
}
@staticmethod
def _should_add_resumption_user_message(
initial_messages: list[dict[str, Any]] | None,
) -> bool:
if not initial_messages:
return False
return initial_messages[-1].get("role") == "MESSAGE_ROLE_TOOL_RESULT"
@staticmethod @staticmethod
def _is_benign_websocket_close(exc: ConnectionClosed) -> bool: def _is_benign_websocket_close(exc: ConnectionClosed) -> bool:
return any( return any(
@ -636,18 +514,3 @@ class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService):
parts.append(text) parts.append(text)
return "\n".join(parts) if parts else None return "\n".join(parts) if parts else None
return None return None
@staticmethod
def _stringify_tool_result(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for part in content:
if isinstance(part, dict):
text = part.get("text")
if isinstance(text, str):
parts.append(text)
if parts:
return "".join(parts)
return json.dumps(content, ensure_ascii=True, default=str)

View file

@ -1029,14 +1029,28 @@ def create_realtime_llm_service(user_config, audio_config: "AudioConfig"):
from api.services.pipecat.realtime.grok_realtime import ( from api.services.pipecat.realtime.grok_realtime import (
DograhGrokRealtimeLLMService, DograhGrokRealtimeLLMService,
) )
from pipecat.services.xai.realtime.events import SessionProperties from pipecat.services.xai.realtime.events import (
AudioConfiguration,
AudioInput,
InputAudioTranscription,
SessionProperties,
)
grok_voice = voice or "ara"
if grok_voice.lower() in {"ara", "rex", "sal", "eve", "leo"}:
grok_voice = grok_voice.lower()
return DograhGrokRealtimeLLMService( return DograhGrokRealtimeLLMService(
api_key=api_key, api_key=api_key,
settings=DograhGrokRealtimeLLMService.Settings( settings=DograhGrokRealtimeLLMService.Settings(
model=model, model=model,
session_properties=SessionProperties( session_properties=SessionProperties(
voice=voice or "Ara", voice=grok_voice,
audio=AudioConfiguration(
input=AudioInput(
transcription=InputAudioTranscription(),
),
),
), ),
), ),
) )
@ -1115,19 +1129,25 @@ def create_realtime_llm_service(user_config, audio_config: "AudioConfig"):
detail="Azure Realtime requires an endpoint.", detail="Azure Realtime requires an endpoint.",
) )
_validate_runtime_service_url(endpoint, "endpoint") _validate_runtime_service_url(endpoint, "endpoint")
api_version = ( api_version = getattr(realtime_config, "api_version", None) or "v1"
getattr(realtime_config, "api_version", None) or "2025-04-01-preview"
)
# Construct the Azure Realtime WebSocket URL
# https://<resource>.openai.azure.com/openai/realtime?api-version=<ver>&deployment=<model>
parsed_endpoint = urlparse(endpoint) parsed_endpoint = urlparse(endpoint)
if api_version == "v1":
# Azure's GA Realtime API uses the deployment name as `model` and
# deliberately has no date-based api-version query parameter.
path = "/openai/v1/realtime"
query = urlencode({"model": model})
else:
# Preserve explicitly configured preview deployments while users
# migrate. Microsoft deprecated this protocol on April 30, 2026.
path = "/openai/realtime"
query = urlencode({"api-version": api_version, "deployment": model})
wss_url = urlunparse( wss_url = urlunparse(
( (
"wss", "wss",
parsed_endpoint.netloc, parsed_endpoint.netloc,
"/openai/realtime", path,
"", "",
urlencode({"api-version": api_version, "deployment": model}), query,
"", "",
) )
) )

View file

@ -1096,9 +1096,7 @@ class CloudonixProvider(TelephonyProvider):
from_number = random.choice(self.from_numbers) from_number = random.choice(self.from_numbers)
backend_endpoint, _ = await get_backend_endpoints() backend_endpoint, _ = await get_backend_endpoints()
callback_url = ( callback_url = f"{backend_endpoint}/api/v1/telephony/cloudonix/transfer-result/{transfer_id}"
f"{backend_endpoint}/api/v1/telephony/cloudonix/transfer-result/{transfer_id}"
)
endpoint = f"{self.base_url}/calls/{self.domain_id}/application" endpoint = f"{self.base_url}/calls/{self.domain_id}/application"
data: Dict[str, Any] = { data: Dict[str, Any] = {
@ -1111,9 +1109,7 @@ class CloudonixProvider(TelephonyProvider):
data.update(kwargs) data.update(kwargs)
headers = self._get_auth_headers() headers = self._get_auth_headers()
masked_destination = ( masked_destination = f"***{destination[-4:]}" if len(destination) > 4 else "***"
f"***{destination[-4:]}" if len(destination) > 4 else "***"
)
logger.info( logger.info(
f"[Cloudonix Transfer] Dialing {masked_destination} into conference " f"[Cloudonix Transfer] Dialing {masked_destination} into conference "
f"{conference_name} (transfer_id={transfer_id})" f"{conference_name} (transfer_id={transfer_id})"

View file

@ -79,7 +79,7 @@ async def handle_cloudonix_transfer_result(transfer_id: str, request: Request):
original_call_sid = transfer_context.original_call_sid original_call_sid = transfer_context.original_call_sid
conference_name = transfer_context.conference_name conference_name = transfer_context.conference_name
if (conferenceStatus == "participant-join"): if conferenceStatus == "participant-join":
event = TransferEvent( event = TransferEvent(
type=TransferEventType.DESTINATION_ANSWERED, type=TransferEventType.DESTINATION_ANSWERED,
transfer_id=transfer_id, transfer_id=transfer_id,

View file

@ -120,7 +120,9 @@ class CloudonixConferenceStrategy(TransferStrategy):
manager = await get_call_transfer_manager() manager = await get_call_transfer_manager()
await manager.remove_transfer_context(transfer_id) await manager.remove_transfer_context(transfer_id)
except Exception as e: except Exception as e:
logger.error(f"[Cloudonix Transfer] Error cleaning up transfer context: {e}") logger.error(
f"[Cloudonix Transfer] Error cleaning up transfer context: {e}"
)
class CloudonixHangupStrategy(HangupStrategy): class CloudonixHangupStrategy(HangupStrategy):

View file

@ -344,7 +344,11 @@ class PipecatEngine:
) )
# Register function with LLM # Register function with LLM
self.llm.register_function(name, transition_func) self.llm.register_function(
name,
transition_func,
is_node_transition=True,
)
async def _register_knowledge_base_function( async def _register_knowledge_base_function(
self, document_uuids: list[str] self, document_uuids: list[str]

View file

@ -288,10 +288,19 @@ class CustomToolManager:
# Create and register the handler # Create and register the handler
handler, timeout_secs = self._create_handler(tool, function_name) handler, timeout_secs = self._create_handler(tool, function_name)
# End-call and transfer-call tools are workflow-control
# boundaries even though they do not necessarily select another
# graph node. Give them the same ordering guarantees as an
# explicit node-transition function.
is_node_transition = tool.category in {
ToolCategory.END_CALL.value,
ToolCategory.TRANSFER_CALL.value,
}
self._engine.llm.register_function( self._engine.llm.register_function(
function_name, function_name,
handler, handler,
timeout_secs=timeout_secs, timeout_secs=timeout_secs,
is_node_transition=is_node_transition,
) )
logger.debug( logger.debug(

View file

@ -86,3 +86,45 @@ async def test_tts_greeting_waits_for_session_updated_before_sending_prompt():
assert service._pending_initial_greeting_text is None assert service._pending_initial_greeting_text is None
assert service._llm_needs_conversation_setup is False assert service._llm_needs_conversation_setup is False
service._create_response.assert_not_awaited() service._create_response.assert_not_awaited()
@pytest.mark.asyncio
async def test_non_transition_function_call_runs_while_bot_is_speaking():
service = _make_service()
service._context = LLMContext()
service.run_function_calls = AsyncMock()
service._bot_is_speaking = True
service._pending_function_calls["call-1"] = SimpleNamespace(name="lookup_order")
await service._handle_evt_function_call_arguments_done(
SimpleNamespace(call_id="call-1", arguments='{"order_id":"123"}')
)
service.run_function_calls.assert_awaited_once()
assert service._deferred_node_transition_function_calls == []
@pytest.mark.asyncio
async def test_node_transition_function_call_waits_until_bot_stops_speaking():
service = _make_service()
service._context = LLMContext()
service.run_function_calls = AsyncMock()
service._bot_is_speaking = True
service.register_function(
"customer_support",
AsyncMock(),
is_node_transition=True,
)
service._pending_function_calls["call-1"] = SimpleNamespace(name="customer_support")
await service._handle_evt_function_call_arguments_done(
SimpleNamespace(call_id="call-1", arguments='{"department":"sales"}')
)
service.run_function_calls.assert_not_awaited()
assert len(service._deferred_node_transition_function_calls) == 1
await service._run_pending_node_transition_function_calls()
service.run_function_calls.assert_awaited_once()
assert service._deferred_node_transition_function_calls == []

View file

@ -170,6 +170,46 @@ def test_create_azure_realtime_blocks_private_endpoint_in_saas(monkeypatch):
assert "public IP" in exc_info.value.detail assert "public IP" in exc_info.value.detail
def test_create_azure_realtime_uses_ga_websocket_url_by_default(monkeypatch):
monkeypatch.setattr("api.utils.url_security.DEPLOYMENT_MODE", "oss")
user_config = SimpleNamespace(
realtime=SimpleNamespace(
provider=ServiceProviders.AZURE_REALTIME.value,
api_key="test-key",
endpoint="https://example.openai.azure.com",
model="my-realtime-deployment",
voice="alloy",
)
)
service = create_realtime_llm_service(user_config, _audio_config())
assert service.base_url == (
"wss://example.openai.azure.com/openai/v1/realtime?model=my-realtime-deployment"
)
def test_create_azure_realtime_preserves_explicit_preview_websocket_url(monkeypatch):
monkeypatch.setattr("api.utils.url_security.DEPLOYMENT_MODE", "oss")
user_config = SimpleNamespace(
realtime=SimpleNamespace(
provider=ServiceProviders.AZURE_REALTIME.value,
api_key="test-key",
endpoint="https://example.openai.azure.com",
api_version="2025-04-01-preview",
model="my-preview-deployment",
voice="alloy",
)
)
service = create_realtime_llm_service(user_config, _audio_config())
assert service.base_url == (
"wss://example.openai.azure.com/openai/realtime?"
"api-version=2025-04-01-preview&deployment=my-preview-deployment"
)
def test_azure_embedding_service_rejects_wrong_dimension(): def test_azure_embedding_service_rejects_wrong_dimension():
service = AzureOpenAIEmbeddingService( service = AzureOpenAIEmbeddingService(
db_client=SimpleNamespace(), db_client=SimpleNamespace(),

View file

@ -1415,6 +1415,7 @@ class TestCustomToolManagerUnit:
# Verify handler was registered # Verify handler was registered
assert "api_call" in registered_handlers assert "api_call" in registered_handlers
assert registered_kwargs["api_call"]["timeout_secs"] == pytest.approx(5) assert registered_kwargs["api_call"]["timeout_secs"] == pytest.approx(5)
assert registered_kwargs["api_call"]["is_node_transition"] is False
# Now test that the handler works # Now test that the handler works
handler = registered_handlers["api_call"] handler = registered_handlers["api_call"]
@ -1445,6 +1446,41 @@ class TestCustomToolManagerUnit:
# Verify result was returned # Verify result was returned
assert result_received["status"] == "success" assert result_received["status"] == "success"
@pytest.mark.asyncio
@pytest.mark.parametrize("category", ["end_call", "transfer_call"])
async def test_register_handlers_marks_call_control_tools_as_node_transitions(
self, category
):
from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager
mock_engine = Mock()
mock_engine._get_organization_id = AsyncMock(return_value=1)
mock_engine.llm.register_function = Mock()
manager = CustomToolManager(mock_engine)
tool = MockToolModel(
tool_uuid=f"{category}-uuid",
name=category.replace("_", " "),
description=f"Perform {category}",
category=category,
definition={
"schema_version": 1,
"type": category,
"config": {},
},
)
with patch(
"api.services.workflow.pipecat_engine_custom_tools.db_client.get_tools_by_uuids",
new=AsyncMock(return_value=[tool]),
):
await manager.register_handlers([tool.tool_uuid])
mock_engine.llm.register_function.assert_called_once()
assert (
mock_engine.llm.register_function.call_args.kwargs["is_node_transition"]
is True
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transfer_call_renders_destination_from_initial_context(self): async def test_transfer_call_renders_destination_from_initial_context(self):
"""Transfer call tools resolve destination templates before provider calls.""" """Transfer call tools resolve destination templates before provider calls."""

View file

@ -134,6 +134,21 @@ def test_gemini_live_service_classes_use_dograh_gemini_adapter_class():
) )
def test_vertex_live_inherits_dograh_node_transition_lifecycle():
assert (
DograhGeminiLiveVertexLLMService._requires_node_transition_context_aggregation
is DograhGeminiLiveLLMService._requires_node_transition_context_aggregation
)
assert (
DograhGeminiLiveVertexLLMService._run_or_defer_function_calls
is DograhGeminiLiveLLMService._run_or_defer_function_calls
)
assert (
DograhGeminiLiveVertexLLMService._reconnect_for_node_transition
is DograhGeminiLiveLLMService._reconnect_for_node_transition
)
def test_gemini_live_config_accepts_json_schema_tools(): def test_gemini_live_config_accepts_json_schema_tools():
function_schema = FunctionSchema( function_schema = FunctionSchema(
name="customer_lookup", name="customer_lookup",

View file

@ -1,11 +1,20 @@
import asyncio
import json import json
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import pytest import pytest
from pipecat.frames.frames import TranscriptionFrame, TTSSpeakFrame from pipecat.frames.frames import (
NodeTransitionStartedFrame,
TranscriptionFrame,
TTSSpeakFrame,
)
from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
)
from pipecat.processors.frame_processor import FrameDirection from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.llm_service import FunctionCallFromLLM
from api.services.pipecat.realtime.gemini_live import DograhGeminiLiveLLMService from api.services.pipecat.realtime.gemini_live import DograhGeminiLiveLLMService
@ -163,3 +172,194 @@ async def test_tts_greeting_waits_for_gemini_session_before_sending_prompt():
assert prompt.endswith('"Hello from Dograh."') assert prompt.endswith('"Hello from Dograh."')
assert service._run_llm_when_session_ready is False assert service._run_llm_when_session_ready is False
assert service._pending_initial_greeting_text is None assert service._pending_initial_greeting_text is None
@pytest.mark.asyncio
async def test_transition_call_flushes_pending_transcription_before_execution():
service = _make_service()
service._NODE_TRANSITION_TRANSCRIPTION_GRACE_SECONDS = 0
service.register_function(
"transition_to_next_node",
AsyncMock(),
is_node_transition=True,
)
service._user_transcription_buffer = "My last answer"
events = []
async def _push_transcription(text, result=None):
events.append(("transcription", text))
async def _run_function_calls(function_calls):
events.append(("function", function_calls[0].function_name))
service._push_user_transcription = AsyncMock(side_effect=_push_transcription)
service.run_function_calls = AsyncMock(side_effect=_run_function_calls)
service.create_task = lambda coro, name=None: asyncio.create_task(coro, name=name)
function_call = FunctionCallFromLLM(
context=LLMContext(),
tool_call_id="call-transition",
function_name="transition_to_next_node",
arguments={},
)
await service._run_or_defer_function_calls([function_call])
transition_task = service._transition_function_call_task
assert transition_task is not None
await transition_task
assert events == [
("transcription", "My last answer"),
("function", "transition_to_next_node"),
]
assert service._user_transcription_buffer == ""
@pytest.mark.asyncio
async def test_non_transition_call_is_not_deferred_while_bot_is_responding():
service = _make_service()
service._bot_is_responding = True
service.run_function_calls = AsyncMock()
function_call = FunctionCallFromLLM(
context=LLMContext(),
tool_call_id="call-ordinary",
function_name="look_up_account",
arguments={},
)
await service._run_or_defer_function_calls([function_call])
service.run_function_calls.assert_awaited_once_with([function_call])
assert service._pending_node_transition_function_calls == []
assert service._transition_function_call_task is None
@pytest.mark.asyncio
async def test_node_transition_call_is_deferred_while_bot_is_responding():
service = _make_service()
service._bot_is_responding = True
service.register_function(
"transition_to_next_node",
AsyncMock(),
is_node_transition=True,
)
service.run_function_calls = AsyncMock()
function_call = FunctionCallFromLLM(
context=LLMContext(),
tool_call_id="call-transition",
function_name="transition_to_next_node",
arguments={},
)
await service._run_or_defer_function_calls([function_call])
service.run_function_calls.assert_not_awaited()
assert service._pending_node_transition_function_calls == [function_call]
assert service._transition_function_call_task is None
@pytest.mark.asyncio
async def test_initial_system_instruction_update_is_not_a_node_handoff():
service = _make_service()
service._connect = AsyncMock()
service._disconnect = AsyncMock()
handled = await service._handle_changed_settings(
{"system_instruction": "old prompt"}
)
assert handled == {"system_instruction"}
service._connect.assert_awaited_once_with()
service._disconnect.assert_not_awaited()
assert service._awaiting_node_transition_context is False
@pytest.mark.asyncio
async def test_node_transition_uses_fresh_connection_instead_of_stale_handle():
service = _make_service()
service._session = _FakeSession()
service._session_resumption_handle = "stale-handle"
service._disconnect = AsyncMock()
service._connect = AsyncMock()
handled = await service._handle_changed_settings(
{"system_instruction": "old prompt"}
)
assert handled == {"system_instruction"}
assert service._session_resumption_handle is None
assert service._awaiting_node_transition_context is True
service._disconnect.assert_awaited_once()
service._connect.assert_awaited_once_with(session_resumption_handle=None)
@pytest.mark.asyncio
async def test_fresh_transition_session_waits_for_updated_context_before_ready():
service = _make_service()
service._handled_initial_context = True
service._awaiting_node_transition_context = True
service._node_transition_context_received = False
service._process_completed_function_calls = AsyncMock()
service._drain_pending_tool_results = AsyncMock()
async def _seed_context():
service._ready_for_realtime_input = True
service._create_initial_response = AsyncMock(side_effect=_seed_context)
session = _FakeSession()
await service._handle_session_ready(session)
assert service._ready_for_realtime_input is False
service._create_initial_response.assert_not_awaited()
context = _make_tool_result_context("call-transition")
await service._handle_context(context)
service._process_completed_function_calls.assert_awaited_once_with(
send_new_results=False
)
service._create_initial_response.assert_awaited_once()
service._drain_pending_tool_results.assert_awaited_once()
assert service._ready_for_realtime_input is True
assert service._awaiting_node_transition_context is False
@pytest.mark.asyncio
async def test_node_transition_frame_commits_user_transcript_to_context():
context = LLMContext()
context_aggregator = LLMContextAggregatorPair(context, realtime_service_mode=True)
user_aggregator = context_aggregator.user()
user_aggregator.push_context_frame = AsyncMock()
await user_aggregator._handle_transcription(
TranscriptionFrame(
text="The answer that selected this node",
user_id="",
timestamp="now",
)
)
context_aggregation_event = asyncio.Event()
await user_aggregator._handle_node_transition_started(
NodeTransitionStartedFrame(
function_calls=[
FunctionCallFromLLM(
context=context,
tool_call_id="call-transition",
function_name="transition_to_next_node",
arguments={},
)
],
context_aggregation_event=context_aggregation_event,
)
)
assert context.messages[-1] == {
"role": "user",
"content": "The answer that selected this node",
}
user_aggregator.push_context_frame.assert_awaited_once()
assert context_aggregation_event.is_set()

View file

@ -130,7 +130,7 @@ async def test_messages_append_frame_sends_conversation_item():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_function_call_is_deferred_until_bot_stops_speaking(): async def test_non_transition_function_call_runs_while_bot_is_speaking():
service = _make_service() service = _make_service()
service._context = LLMContext() service._context = LLMContext()
service.run_function_calls = AsyncMock() service.run_function_calls = AsyncMock()
@ -145,13 +145,38 @@ async def test_function_call_is_deferred_until_bot_stops_speaking():
) )
) )
service.run_function_calls.assert_not_awaited() service.run_function_calls.assert_awaited_once()
assert len(service._deferred_function_calls) == 1 assert service._deferred_node_transition_function_calls == []
await service._run_pending_function_calls()
@pytest.mark.asyncio
async def test_node_transition_function_call_waits_until_bot_stops_speaking():
service = _make_service()
service._context = LLMContext()
service.run_function_calls = AsyncMock()
service._bot_is_speaking = True
service.register_function(
"customer_support",
AsyncMock(),
is_node_transition=True,
)
service._pending_function_calls["call-1"] = SimpleNamespace(name="customer_support")
await service._handle_evt_function_call_arguments_done(
SimpleNamespace(
call_id="call-1",
name="customer_support",
arguments='{"department":"sales"}',
)
)
service.run_function_calls.assert_not_awaited()
assert len(service._deferred_node_transition_function_calls) == 1
await service._run_pending_node_transition_function_calls()
service.run_function_calls.assert_awaited_once() service.run_function_calls.assert_awaited_once()
assert service._deferred_function_calls == [] assert service._deferred_node_transition_function_calls == []
@pytest.mark.asyncio @pytest.mark.asyncio
@ -190,3 +215,21 @@ def test_factory_creates_dograh_grok_realtime_service():
) )
assert isinstance(service, DograhGrokRealtimeLLMService) assert isinstance(service, DograhGrokRealtimeLLMService)
assert service._settings.session_properties.voice == "sal"
assert service._settings.session_properties.audio.input.transcription.model == (
"grok-transcribe"
)
def test_grok_audio_config_preserves_transcription_when_filling_sample_rates():
service = _make_service()
service._settings.session_properties.audio = events.AudioConfiguration(
input=events.AudioInput(transcription=events.InputAudioTranscription())
)
service._ensure_audio_config(input_sample_rate=16000, output_sample_rate=24000)
audio = service._settings.session_properties.audio
assert audio.input.format.rate == 16000
assert audio.input.transcription.model == "grok-transcribe"
assert audio.output.format.rate == 24000

View file

@ -127,11 +127,11 @@ async def test_function_call_executes_immediately_when_bot_is_not_speaking():
) )
service.run_function_calls.assert_awaited_once() service.run_function_calls.assert_awaited_once()
assert service._deferred_function_calls == [] assert service._deferred_node_transition_function_calls == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_function_call_is_deferred_until_bot_stops_speaking(): async def test_non_transition_function_call_runs_while_bot_is_speaking():
service = _make_service() service = _make_service()
service._context = LLMContext() service._context = LLMContext()
service.run_function_calls = AsyncMock() service.run_function_calls = AsyncMock()
@ -142,10 +142,31 @@ async def test_function_call_is_deferred_until_bot_stops_speaking():
SimpleNamespace(call_id="call-1", arguments='{"department":"sales"}') SimpleNamespace(call_id="call-1", arguments='{"department":"sales"}')
) )
service.run_function_calls.assert_not_awaited() service.run_function_calls.assert_awaited_once()
assert len(service._deferred_function_calls) == 1 assert service._deferred_node_transition_function_calls == []
await service._run_pending_function_calls()
@pytest.mark.asyncio
async def test_node_transition_function_call_is_deferred_until_bot_stops_speaking():
service = _make_service()
service._context = LLMContext()
service.run_function_calls = AsyncMock()
service._bot_is_speaking = True
service.register_function(
"customer_support",
AsyncMock(),
is_node_transition=True,
)
service._pending_function_calls["call-1"] = SimpleNamespace(name="customer_support")
await service._handle_evt_function_call_arguments_done(
SimpleNamespace(call_id="call-1", arguments='{"department":"sales"}')
)
service.run_function_calls.assert_not_awaited()
assert len(service._deferred_node_transition_function_calls) == 1
await service._run_pending_node_transition_function_calls()
service.run_function_calls.assert_awaited_once() service.run_function_calls.assert_awaited_once()
assert service._deferred_function_calls == [] assert service._deferred_node_transition_function_calls == []

View file

@ -182,6 +182,7 @@ class TestPipecatEngineToolCalls:
# Assert that the context was updated with END_CALL_SYSTEM_PROMPT # Assert that the context was updated with END_CALL_SYSTEM_PROMPT
assert llm._settings.system_instruction == END_CALL_SYSTEM_PROMPT assert llm._settings.system_instruction == END_CALL_SYSTEM_PROMPT
assert llm._functions["end_call"].is_node_transition is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_parallel_builtin_and_transition_calls_through_engine_1( async def test_parallel_builtin_and_transition_calls_through_engine_1(

View file

@ -1,3 +1,4 @@
import json
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, call from unittest.mock import AsyncMock, call
@ -13,7 +14,6 @@ from websockets.frames import Close
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
from api.services.configuration.registry import UltravoxRealtimeLLMConfiguration from api.services.configuration.registry import UltravoxRealtimeLLMConfiguration
from api.services.pipecat.realtime.ultravox_realtime import ( from api.services.pipecat.realtime.ultravox_realtime import (
_RESUMPTION_USER_MESSAGE,
DograhUltravoxOneShotInputParams, DograhUltravoxOneShotInputParams,
DograhUltravoxRealtimeLLMService, DograhUltravoxRealtimeLLMService,
) )
@ -100,50 +100,35 @@ async def test_initial_context_connects_without_replay():
await service._handle_context(context) await service._handle_context(context)
service._connect_call.assert_awaited_once() service._connect_call.assert_awaited_once()
assert service._connect_call.await_args.kwargs["initial_messages"] is None assert service._connect_call.await_args.kwargs["greeting_text"] is None
assert service._connect_call.await_args.kwargs["agent_speaks_first"] is True assert service._connect_call.await_args.kwargs["agent_speaks_first"] is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_system_instruction_update_marks_reconnect_required(): async def test_system_instruction_update_marks_stage_update_required():
service = _make_service() service = _make_service()
service._has_connected_once = True service._socket = object()
changed = await service._update_settings( changed = await service._update_settings(
DograhUltravoxRealtimeLLMService.Settings(system_instruction="new instruction") DograhUltravoxRealtimeLLMService.Settings(system_instruction="new instruction")
) )
assert "system_instruction" in changed assert "system_instruction" in changed
assert service._reconnect_required is True assert service._stage_update_required is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_system_instruction_change_reconnects_with_full_initial_messages(): async def test_node_transition_updates_native_stage_without_reconnecting():
service = _make_service() service = _make_service()
service._socket = object() service._socket = object()
service._has_connected_once = True service._send = AsyncMock()
service._call_system_instruction = "old instruction" service._connect_call = AsyncMock()
service._reconnect_required = True service._pending_node_transition_tool_call_ids.add("call-transition")
service._stage_update_required = True
service._settings.system_instruction = "new instruction" service._settings.system_instruction = "new instruction"
service._reconnect_with_context = AsyncMock()
context = LLMContext( context = LLMContext(
messages=[ messages=[
{"role": "user", "content": "I want to hear the pricing."},
{
"role": "assistant",
"content": "Let me check that for you.",
"tool_calls": [
{
"id": "call-transition",
"type": "function",
"function": {
"name": "transition_to_next_node",
"arguments": '{"reason":"pricing requested"}',
},
}
],
},
{ {
"role": "tool", "role": "tool",
"tool_call_id": "call-transition", "tool_call_id": "call-transition",
@ -155,43 +140,28 @@ async def test_system_instruction_change_reconnects_with_full_initial_messages()
await service._handle_context(context) await service._handle_context(context)
service._reconnect_with_context.assert_awaited_once() service._connect_call.assert_not_awaited()
initial_messages = service._reconnect_with_context.await_args.kwargs[ service._send.assert_awaited_once()
"initial_messages" message = service._send.await_args.args[0]
] assert message["type"] == "client_tool_result"
assert initial_messages == [ assert message["invocationId"] == "call-transition"
{ assert message["responseType"] == "new-stage"
"role": "MESSAGE_ROLE_USER", stage = json.loads(message["result"])
"text": "I want to hear the pricing.", assert stage["systemPrompt"] == "new instruction"
}, assert stage["toolResultText"] == '{"status":"done"}'
{ assert stage["selectedTools"][0]["temporaryTool"]["modelToolName"] == (
"role": "MESSAGE_ROLE_AGENT", "transition_to_next_node"
"text": "Let me check that for you.", )
},
{
"role": "MESSAGE_ROLE_TOOL_CALL",
"text": "",
"invocationId": "call-transition",
"toolName": "transition_to_next_node",
},
{
"role": "MESSAGE_ROLE_TOOL_RESULT",
"text": '{"status":"done"}',
"invocationId": "call-transition",
"toolName": "transition_to_next_node",
},
]
assert "call-transition" in service._completed_tool_calls assert "call-transition" in service._completed_tool_calls
assert service._pending_node_transition_tool_call_ids == set()
assert service._stage_update_required is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tool_context_update_does_not_reconnect_when_system_instruction_is_unchanged(): async def test_ordinary_tool_result_uses_standard_tool_response():
service = _make_service() service = _make_service()
service._socket = object() service._socket = object()
service._call_system_instruction = "same instruction" service._send = AsyncMock()
service._settings.system_instruction = "same instruction"
service._reconnect_with_context = AsyncMock()
service._send_tool_result = AsyncMock()
context = LLMContext( context = LLMContext(
messages=[ messages=[
@ -206,13 +176,89 @@ async def test_tool_context_update_does_not_reconnect_when_system_instruction_is
await service._handle_context(context) await service._handle_context(context)
service._reconnect_with_context.assert_not_awaited() service._send.assert_awaited_once_with(
service._send_tool_result.assert_awaited_once_with( {
"call-transition", "type": "client_tool_result",
'{"status":"done"}', "invocationId": "call-transition",
"result": '{"status":"done"}',
}
) )
@pytest.mark.asyncio
async def test_only_registered_node_transition_invocations_are_tracked():
service = _make_service()
service.run_function_calls = AsyncMock()
service.register_function(
"transition_to_next_node",
AsyncMock(),
is_node_transition=True,
)
await service._handle_tool_invocation(
"transition_to_next_node", "call-transition", {"reason": "pricing"}
)
await service._handle_tool_invocation("lookup_price", "call-lookup", {})
assert service._pending_node_transition_tool_call_ids == {"call-transition"}
assert service.run_function_calls.await_count == 2
@pytest.mark.asyncio
async def test_node_transition_invocation_waits_for_response_end():
service = _make_service()
service.run_function_calls = AsyncMock()
service.stop_processing_metrics = AsyncMock()
service.push_frame = AsyncMock()
service.register_function(
"transition_to_next_node",
AsyncMock(),
is_node_transition=True,
)
service._bot_responding = "voice"
await service._handle_tool_invocation(
"transition_to_next_node", "call-transition", {"reason": "pricing"}
)
service.run_function_calls.assert_not_awaited()
assert service._deferred_node_transition_tool_invocations == [
(
"transition_to_next_node",
"call-transition",
{"reason": "pricing"},
)
]
await service._handle_response_end()
service.run_function_calls.assert_awaited_once()
function_call = service.run_function_calls.await_args.args[0][0]
assert function_call.function_name == "transition_to_next_node"
assert function_call.tool_call_id == "call-transition"
assert function_call.arguments == {"reason": "pricing"}
assert service._deferred_node_transition_tool_invocations == []
assert service._bot_responding is None
@pytest.mark.asyncio
async def test_ordinary_tool_invocation_runs_while_response_is_active():
service = _make_service()
service.run_function_calls = AsyncMock()
service._bot_responding = "voice"
await service._handle_tool_invocation("lookup_price", "call-lookup", {})
service.run_function_calls.assert_awaited_once()
assert service._deferred_node_transition_tool_invocations == []
def test_ultravox_requires_transition_context_aggregation():
service = _make_service()
assert service._requires_node_transition_context_aggregation() is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_messages_append_frame_sends_user_text(): async def test_messages_append_frame_sends_user_text():
service = _make_service() service = _make_service()
@ -287,7 +333,6 @@ def test_build_one_shot_params_uses_explicit_greeting_text():
params = service._build_one_shot_params( params = service._build_one_shot_params(
greeting_text="Welcome to Dograh", greeting_text="Welcome to Dograh",
initial_messages=None,
agent_speaks_first=True, agent_speaks_first=True,
) )
@ -296,85 +341,18 @@ def test_build_one_shot_params_uses_explicit_greeting_text():
} }
def test_build_one_shot_params_includes_initial_messages(): def test_build_one_shot_params_uses_current_system_instruction():
service = _make_service() service = _make_service()
service._settings.system_instruction = "Base instruction" service._settings.system_instruction = "Base instruction"
params = service._build_one_shot_params( params = service._build_one_shot_params(
greeting_text=None, greeting_text=None,
initial_messages=[
{"role": "MESSAGE_ROLE_USER", "text": "User asked a question."},
{"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'},
],
agent_speaks_first=True, agent_speaks_first=True,
) )
assert params.extra["initialMessages"] == [
{"role": "MESSAGE_ROLE_USER", "text": "User asked a question."},
{"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'},
{"role": "MESSAGE_ROLE_USER", "text": _RESUMPTION_USER_MESSAGE},
]
assert params.system_prompt == "Base instruction" assert params.system_prompt == "Base instruction"
def test_build_one_shot_params_without_tool_result_does_not_add_resumption_user_message():
service = _make_service()
service._settings.system_instruction = "Base instruction"
params = service._build_one_shot_params(
greeting_text=None,
initial_messages=[
{"role": "MESSAGE_ROLE_USER", "text": "User asked a question."},
{"role": "MESSAGE_ROLE_AGENT", "text": "Assistant replied."},
],
agent_speaks_first=False,
)
assert params.system_prompt == "Base instruction"
def test_should_agent_speak_first_when_history_ends_with_tool_result():
service = _make_service()
assert (
service._should_agent_speak_first(
[
{"role": "MESSAGE_ROLE_USER", "text": "Hello"},
{"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'},
]
)
is True
)
def test_should_not_force_agent_speaks_first_when_history_ends_with_agent():
service = _make_service()
assert (
service._should_agent_speak_first(
[{"role": "MESSAGE_ROLE_AGENT", "text": "How else can I help?"}]
)
is False
)
def test_should_add_resumption_user_message_only_when_history_ends_with_tool_result():
service = _make_service()
assert (
service._should_add_resumption_user_message(
[{"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'}]
)
is True
)
assert (
service._should_add_resumption_user_message(
[{"role": "MESSAGE_ROLE_AGENT", "text": "Assistant replied."}]
)
is False
)
def test_to_selected_tools_includes_registered_timeout(): def test_to_selected_tools_includes_registered_timeout():
service = _make_service() service = _make_service()
service.register_function( service.register_function(

File diff suppressed because one or more lines are too long

@ -1 +1 @@
Subproject commit f4feab0c59129702b3a6e6f818f62229cc663f1a Subproject commit aadd1d5dd606d2871b082e6f2ca1ad1eee53785b

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen: # generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.73JcjTo19T # filename: dograh-openapi-XXXXXX.json.uTKtHKJw6v
# timestamp: 2026-07-11T10:21:09+00:00 # timestamp: 2026-07-15T11:25:06+00:00
from __future__ import annotations from __future__ import annotations

View file

@ -7,6 +7,7 @@ Re-exports every typed node class so users can write
from dograh_sdk.typed.agent_node import AgentNode from dograh_sdk.typed.agent_node import AgentNode
from dograh_sdk.typed.end_call import EndCall from dograh_sdk.typed.end_call import EndCall
from dograh_sdk.typed.global_node import GlobalNode from dograh_sdk.typed.global_node import GlobalNode
from dograh_sdk.typed.paygent import Paygent
from dograh_sdk.typed.qa import Qa from dograh_sdk.typed.qa import Qa
from dograh_sdk.typed.start_call import StartCall from dograh_sdk.typed.start_call import StartCall
from dograh_sdk.typed.trigger import Trigger from dograh_sdk.typed.trigger import Trigger
@ -18,6 +19,7 @@ __all__ = [
"AgentNode", "AgentNode",
"EndCall", "EndCall",
"GlobalNode", "GlobalNode",
"Paygent",
"Qa", "Qa",
"StartCall", "StartCall",
"Trigger", "Trigger",

View file

@ -0,0 +1,56 @@
"""GENERATED — do not edit by hand.
Regenerate with `python -m dograh_sdk.codegen` against the target
Dograh backend. Source of truth: the backend's model-backed node-spec
catalog served from `/api/v1/node-types`.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, ClassVar, Literal, Optional
from dograh_sdk.typed._base import TypedNode
@dataclass(kw_only=True)
class Paygent(TypedNode):
"""
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.
"""
type: ClassVar[str] = 'paygent'
paygent_api_key: str
"""
API key used to authenticate requests to the Paygent REST API.
"""
paygent_agent_id: str
"""
The agent identifier registered in your Paygent account.
"""
paygent_customer_id: str
"""
Your Paygent customer / organisation ID.
"""
name: str = 'Paygent'
"""
Short identifier for this Paygent configuration.
"""
paygent_enabled: bool = True
"""
When false, Dograh skips all Paygent tracking for this call.
"""
paygent_indicator: str = 'per-minute-call'
"""
The indicator event name sent at the end of the call (e.g. per-minute-
call).
"""

View file

@ -6,6 +6,7 @@
export { type AgentNode, agentNode } from "./agent-node.js"; export { type AgentNode, agentNode } from "./agent-node.js";
export { type EndCall, endCall } from "./end-call.js"; export { type EndCall, endCall } from "./end-call.js";
export { type GlobalNode, globalNode } from "./global-node.js"; export { type GlobalNode, globalNode } from "./global-node.js";
export { type Paygent, paygent } from "./paygent.js";
export { type Qa, qa } from "./qa.js"; export { type Qa, qa } from "./qa.js";
export { type StartCall, startCall } from "./start-call.js"; export { type StartCall, startCall } from "./start-call.js";
export { type Trigger, trigger } from "./trigger.js"; export { type Trigger, trigger } from "./trigger.js";
@ -16,6 +17,7 @@ import type {
AgentNode, AgentNode,
EndCall, EndCall,
GlobalNode, GlobalNode,
Paygent,
Qa, Qa,
StartCall, StartCall,
Trigger, Trigger,
@ -24,4 +26,4 @@ import type {
} from "./index.js"; } from "./index.js";
/** Discriminated union of every generated typed node. */ /** Discriminated union of every generated typed node. */
export type TypedNode = AgentNode | EndCall | GlobalNode | Qa | StartCall | Trigger | Tuner | Webhook; export type TypedNode = AgentNode | EndCall | GlobalNode | Paygent | Qa | StartCall | Trigger | Tuner | Webhook;

View file

@ -0,0 +1,44 @@
// GENERATED — do not edit by hand.
//
// Regenerate with `npm run codegen` against the target Dograh backend.
// Source of truth: the backend's model-backed node-spec catalog served
// from `/api/v1/node-types`.
/**
* 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.
*/
export interface Paygent {
type: "paygent";
/**
* Short identifier for this Paygent configuration.
*/
name?: string;
/**
* When false, Dograh skips all Paygent tracking for this call.
*/
paygent_enabled?: boolean;
/**
* API key used to authenticate requests to the Paygent REST API.
*/
paygent_api_key: string;
/**
* The agent identifier registered in your Paygent account.
*/
paygent_agent_id: string;
/**
* Your Paygent customer / organisation ID.
*/
paygent_customer_id: string;
/**
* The indicator event name sent at the end of the call (e.g. per-minute-call).
*/
paygent_indicator?: string;
}
/** Factory — sets `type` for you so you don't repeat the discriminator. */
export function paygent(input: Omit<Paygent, "type">): Paygent {
return { type: "paygent", ...input };
}