mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-16 11:31:04 +02:00
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:
parent
348cd8427b
commit
01acf6ac30
34 changed files with 1282 additions and 617 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 []
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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})"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 == []
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 == []
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
2
pipecat
2
pipecat
|
|
@ -1 +1 @@
|
||||||
Subproject commit f4feab0c59129702b3a6e6f818f62229cc663f1a
|
Subproject commit aadd1d5dd606d2871b082e6f2ca1ad1eee53785b
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
56
sdk/python/src/dograh_sdk/typed/paygent.py
Normal file
56
sdk/python/src/dograh_sdk/typed/paygent.py
Normal 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).
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
44
sdk/typescript/src/typed/paygent.ts
Normal file
44
sdk/typescript/src/typed/paygent.ts
Normal 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 };
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue