Merge branch 'main' into sslip-trusted-https

This commit is contained in:
Abhishek Kumar 2026-06-26 12:34:11 +05:30
commit 53b8ba08fd
87 changed files with 3220 additions and 1145 deletions

View file

@ -1,3 +1,3 @@
{
".": "1.37.0"
".": "1.38.0"
}

View file

@ -1,5 +1,30 @@
# Changelog
## 1.38.0 (2026-06-25)
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Features
* feat(scripts): generate REDIS_PASSWORD on setup, plumb through compose by @tecnomanu in https://github.com/dograh-hq/dograh/pull/458
* feat(storage): support custom S3 endpoint, signature version, and addressing style by @skymoore in https://github.com/dograh-hq/dograh/pull/461
* feat(twilio): add Answering Machine Detection (AMD) support via telephony config by @nuthalapativarun in https://github.com/dograh-hq/dograh/pull/443
### Bug Fixes
* fix: support Gemini JSON schema tools by @snvtac in https://github.com/dograh-hq/dograh/pull/463
### Documentation
* docs: update Tuner integration to use Dograh provider by @mohamedsalem-bot in https://github.com/dograh-hq/dograh/pull/457
### Other Changes
* style(docs): add custom green scrollbar by @Gurkirat-Singh-bit in https://github.com/dograh-hq/dograh/pull/434
* Add Hostinger (managed-Traefik) deployment files by @a6kme in https://github.com/dograh-hq/dograh/pull/459
## New Contributors
* @Gurkirat-Singh-bit made their first contribution in https://github.com/dograh-hq/dograh/pull/434
* @tecnomanu made their first contribution in https://github.com/dograh-hq/dograh/pull/458
* @skymoore made their first contribution in https://github.com/dograh-hq/dograh/pull/461
* @snvtac made their first contribution in https://github.com/dograh-hq/dograh/pull/463
**Full Changelog**: https://github.com/dograh-hq/dograh/compare/dograh-v1.37.0...dograh-v1.38.0
## 1.37.0 (2026-06-19)
<!-- Release notes generated using configuration in .github/release.yml at main -->

View file

@ -18,6 +18,13 @@ ENABLE_AWS_S3="false"
# AWS_SECRET_ACCESS_KEY=""
# S3_BUCKET=""
# S3_REGION=""
# --- S3-compatible servers (MinIO, rustfs, Ceph, ...) ---
# Use the S3 backend (ENABLE_AWS_S3=true) against a non-AWS, S3-compatible
# server by overriding the endpoint and signing. Unlike the MinIO backend, the
# S3 backend emits real presigned URLs, so the bucket can stay private.
# S3_ENDPOINT_URL="" # e.g. https://s3.example.com (blank = AWS default)
# S3_SIGNATURE_VERSION="" # blank = botocore default; set "s3v4" if the server requires SigV4
# S3_ADDRESSING_STYLE="" # blank = auto; set "path" if the server / TLS cert requires path-style
# MinIO Configuration if using containerised MinIO instead of
# AWS S3

View file

@ -53,6 +53,17 @@ MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true"
# AWS S3 Configuration
S3_BUCKET = os.environ.get("S3_BUCKET")
S3_REGION = os.environ.get("S3_REGION", "us-east-1")
# Optional overrides for S3-compatible backends (e.g. MinIO, rustfs, Ceph).
# S3_ENDPOINT_URL: full URL of a custom S3 endpoint (e.g. "https://s3.example.com").
# Leave unset to use AWS's default endpoint resolution.
# S3_SIGNATURE_VERSION: botocore signature version used to sign requests and
# presigned URLs. Defaults to None (botocore's default, currently SigV2 for
# presigned URLs). Set to "s3v4" for S3-compatible servers that require SigV4.
# S3_ADDRESSING_STYLE: "auto" (default), "path", or "virtual". Many S3-compatible
# servers and TLS setups require "path".
S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL")
S3_SIGNATURE_VERSION = os.environ.get("S3_SIGNATURE_VERSION")
S3_ADDRESSING_STYLE = os.environ.get("S3_ADDRESSING_STYLE")
# Sentry configuration
SENTRY_DSN = os.getenv("SENTRY_DSN")

View file

@ -5,7 +5,7 @@ from pathlib import Path
from typing import List, Optional
from loguru import logger
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.orm import selectinload
from api.db.base_client import BaseDBClient
@ -300,6 +300,31 @@ class KnowledgeBaseClient(BaseDBClient):
logger.info(f"Created {len(chunks)} chunks")
return chunks
async def replace_chunks_for_document(
self,
document_id: int,
organization_id: int,
chunks: List[KnowledgeBaseChunkModel],
) -> List[KnowledgeBaseChunkModel]:
"""Replace all chunks for a document with a new precomputed batch."""
async with self.async_session() as session:
await session.execute(
delete(KnowledgeBaseChunkModel).where(
KnowledgeBaseChunkModel.document_id == document_id,
KnowledgeBaseChunkModel.organization_id == organization_id,
)
)
session.add_all(chunks)
await session.commit()
for chunk in chunks:
await session.refresh(chunk)
logger.info(
f"Replaced chunks for document {document_id}: {len(chunks)} chunks"
)
return chunks
async def get_chunks_for_document(
self,
document_id: int,

View file

@ -17,6 +17,32 @@ class CallType(Enum):
OUTBOUND = "outbound"
class TelephonyCallStatus(str, Enum):
INITIATED = "initiated"
RINGING = "ringing"
IN_PROGRESS = "in-progress"
ANSWERED = "answered"
COMPLETED = "completed"
FAILED = "failed"
BUSY = "busy"
NO_ANSWER = "no-answer"
CANCELED = "canceled"
ERROR = "error"
@classmethod
def from_raw(cls, value: object) -> "TelephonyCallStatus | None":
if isinstance(value, cls):
return value
if value in (None, ""):
return None
try:
return cls(str(value).lower())
except ValueError:
return None
class WorkflowRunMode(Enum):
ARI = "ari"
PLIVO = "plivo"
@ -77,8 +103,6 @@ class WorkflowRunStatus(Enum):
class OrganizationConfigurationKey(Enum):
DISPOSITION_CODE_MAPPING = "DISPOSITION_CODE_MAPPING"
DISPOSITION_MESSAGE_TEMPLATE = "DISPOSITION_MESSAGE_TEMPLATE"
CONCURRENT_CALL_LIMIT = "CONCURRENT_CALL_LIMIT"
TELEPHONY_CONFIGURATION = (
"TELEPHONY_CONFIGURATION" # Stores all providers + active one

View file

@ -1,5 +1,5 @@
[project]
name = "dograh-api"
version = "1.37.0"
version = "1.38.0"
description = "Backend API for Dograh voice AI platform"
requires-python = ">=3.13,<3.14"

View file

@ -373,11 +373,7 @@ async def search_chunks(
apply_managed_embeddings_base_url,
get_resolved_ai_model_configuration,
)
from api.services.configuration.registry import ServiceProviders
from api.services.gen_ai import (
AzureOpenAIEmbeddingService,
OpenAIEmbeddingService,
)
from api.services.gen_ai import build_embedding_service
# Try to get user's embeddings configuration
resolved_config = await get_resolved_ai_model_configuration(
@ -405,22 +401,20 @@ async def search_chunks(
effective_config.embeddings, "api_version", None
)
# Initialize embedding service based on provider
if embeddings_provider == ServiceProviders.AZURE.value and embeddings_endpoint:
embedding_service = AzureOpenAIEmbeddingService(
db_client=db_client,
api_key=embeddings_api_key,
endpoint=embeddings_endpoint,
model_id=embeddings_model or "text-embedding-3-small",
api_version=embeddings_api_version or "2024-02-15-preview",
)
else:
embedding_service = OpenAIEmbeddingService(
db_client=db_client,
api_key=embeddings_api_key,
model_id=embeddings_model or "text-embedding-3-small",
base_url=embeddings_base_url,
)
# Manual search runs outside any workflow run, so resolve the MPS
# correlation id here (mint only for orgs already on v2; never create one).
embedding_service = await build_embedding_service(
db_client=db_client,
provider=embeddings_provider,
api_key=embeddings_api_key,
model=embeddings_model,
base_url=embeddings_base_url,
endpoint=embeddings_endpoint,
api_version=embeddings_api_version,
organization_id=user.selected_organization_id,
created_by=str(user.provider_id),
resolve_correlation=True,
)
# Perform search
results = await embedding_service.search_similar_chunks(

View file

@ -61,6 +61,7 @@ from api.services.configuration.check_validity import UserConfigurationValidator
from api.services.configuration.defaults import DEFAULT_SERVICE_PROVIDERS
from api.services.configuration.masking import is_mask_of, mask_key, mask_user_config
from api.services.configuration.registry import (
DOGRAH_MULTILINGUAL_AUTODETECT_LANGUAGES,
DOGRAH_STT_LANGUAGES,
REGISTRY,
DograhTTSService,
@ -274,6 +275,7 @@ async def get_model_configuration_v2_defaults(
"step": DOGRAH_SPEED_STEP,
},
"languages": DOGRAH_STT_LANGUAGES,
"multilingual_languages": DOGRAH_MULTILINGUAL_AUTODETECT_LANGUAGES,
"defaults": {
"voice": DOGRAH_DEFAULT_VOICE,
"speed": 1.0,

View file

@ -388,9 +388,18 @@ class VoiceInfo(BaseModel):
preview_url: Optional[str] = None
class VoiceFacets(BaseModel):
"""Distinct selector values across a provider's full voice catalog."""
genders: List[str] = []
accents: List[str] = []
languages: List[str] = []
class VoicesResponse(BaseModel):
provider: str
voices: List[VoiceInfo]
facets: Optional[VoiceFacets] = None
@router.get("/configurations/voices/{provider}")
@ -398,6 +407,9 @@ async def get_voices(
provider: TTSProvider,
model: Optional[str] = None,
language: Optional[str] = None,
q: Optional[str] = None,
gender: Optional[str] = None,
accent: Optional[str] = None,
user: UserModel = Depends(get_user),
) -> VoicesResponse:
"""Get available voices for a TTS provider."""
@ -406,12 +418,16 @@ async def get_voices(
provider=provider,
model=model,
language=language,
q=q,
gender=gender,
accent=accent,
organization_id=user.selected_organization_id,
created_by=user.provider_id,
)
return VoicesResponse(
provider=result.get("provider", provider),
voices=[VoiceInfo(**voice) for voice in result.get("voices", [])],
facets=result.get("facets"),
)
except Exception as e:
logger.error(f"Failed to fetch voices for {provider}: {e}")

View file

@ -176,7 +176,7 @@ def _compile_dograh_configuration(
embeddings=DograhEmbeddingsConfiguration(
provider=ServiceProviders.DOGRAH,
api_key=configuration.api_key,
model="default",
model="dograh_embedding_v1",
),
is_realtime=False,
managed_service_version=2,

View file

@ -457,6 +457,11 @@ async def create_user_configuration_with_mps_key(
"api_key": [service_key],
"model": "default",
},
"embeddings": {
"provider": ServiceProviders.DOGRAH.value,
"api_key": [service_key],
"model": "dograh_embedding_v1",
},
}
effective_config = EffectiveAIModelConfiguration(**configuration)
return effective_config

View file

@ -316,6 +316,7 @@ def convert_legacy_ai_model_configuration_to_v2(
def dograh_embeddings_base_url() -> str:
# AsyncOpenAI appends "/embeddings"; MPS exposes that under /api/v1/llm.
return f"{MPS_API_URL}/api/v1/llm"

View file

@ -1,6 +1,4 @@
GOOGLE_MODELS = (
"gemini-2.0-flash",
"gemini-2.0-flash-lite",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-3.5-flash",

View file

@ -15,6 +15,7 @@ from api.services.configuration.options import (
AZURE_SPEECH_TTS_LANGUAGES,
AZURE_SPEECH_TTS_VOICES,
DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS,
DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES,
DEEPGRAM_LANGUAGES,
DEEPGRAM_STT_MODELS,
GLADIA_STT_LANGUAGES,
@ -315,7 +316,6 @@ OPENROUTER_MODELS = [
"openai/gpt-4.1-mini",
"anthropic/claude-sonnet-4",
"google/gemini-2.5-flash",
"google/gemini-2.0-flash",
"meta-llama/llama-3.3-70b-instruct",
"deepseek/deepseek-chat-v3-0324",
]
@ -350,7 +350,7 @@ class GoogleLLMService(BaseLLMConfiguration):
model_config = GOOGLE_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.GOOGLE] = ServiceProviders.GOOGLE
model: str = Field(
default="gemini-2.0-flash",
default="gemini-2.5-flash",
description="Gemini model on Google AI Studio (not Vertex).",
json_schema_extra={"examples": GOOGLE_MODELS, "allow_custom_input": True},
)
@ -1397,6 +1397,10 @@ class GoogleSTTConfiguration(BaseSTTConfiguration):
# Dograh STT Service
DOGRAH_STT_MODELS = ["default"]
DOGRAH_STT_LANGUAGES = DEEPGRAM_LANGUAGES
# Languages auto-detected when the Dograh STT language is "multi". Dograh STT runs
# Deepgram Flux multilingual under the hood, which only auto-detects this subset —
# not the full DOGRAH_STT_LANGUAGES list offered for explicit single-language selection.
DOGRAH_MULTILINGUAL_AUTODETECT_LANGUAGES = DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES
@register_stt
@ -1722,7 +1726,7 @@ class AzureOpenAIEmbeddingsConfiguration(BaseEmbeddingsConfiguration):
)
DOGRAH_EMBEDDING_MODELS = ["default"]
DOGRAH_EMBEDDING_MODELS = ["dograh_embedding_v1"]
@register_embeddings
@ -1730,7 +1734,7 @@ class DograhEmbeddingsConfiguration(BaseEmbeddingsConfiguration):
model_config = DOGRAH_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.DOGRAH] = ServiceProviders.DOGRAH
model: str = Field(
default="default",
default="dograh_embedding_v1",
description="Dograh-managed embedding model.",
json_schema_extra={"examples": DOGRAH_EMBEDDING_MODELS},
)

View file

@ -1,6 +1,7 @@
from typing import Any, BinaryIO, Dict, Optional
import aioboto3
from botocore.config import Config
from botocore.exceptions import ClientError
from .base import BaseFileSystem
@ -9,22 +10,56 @@ from .base import BaseFileSystem
class S3FileSystem(BaseFileSystem):
"""S3 implementation of the filesystem interface."""
def __init__(self, bucket_name: str, region_name: str = "us-east-1"):
def __init__(
self,
bucket_name: str,
region_name: str = "us-east-1",
endpoint_url: Optional[str] = None,
signature_version: Optional[str] = None,
addressing_style: Optional[str] = None,
):
"""Initialize S3 filesystem.
Args:
bucket_name: Name of the S3 bucket
region_name: AWS region name
endpoint_url: Optional custom S3 endpoint (e.g. for MinIO/rustfs).
``None`` uses AWS's default endpoint resolution.
signature_version: Optional botocore signature version (e.g.
``"s3v4"``). ``None`` keeps botocore's default signing behavior.
addressing_style: Optional S3 addressing style (``"path"`` /
``"virtual"`` / ``"auto"``). ``None`` keeps botocore's default.
"""
self.bucket_name = bucket_name
self.region_name = region_name
self.endpoint_url = endpoint_url
self.session = aioboto3.Session()
# Build a botocore Config only when an override is requested so that the
# default behavior is byte-for-byte unchanged when no env vars are set.
config_kwargs: Dict[str, Any] = {}
if signature_version:
config_kwargs["signature_version"] = signature_version
if addressing_style:
config_kwargs["s3"] = {"addressing_style": addressing_style}
self._config = Config(**config_kwargs) if config_kwargs else None
def _client_kwargs(self) -> Dict[str, Any]:
"""Common kwargs for every ``session.client("s3", ...)`` call.
Only includes ``endpoint_url`` / ``config`` when configured, so default
deployments behave exactly as before.
"""
kwargs: Dict[str, Any] = {"region_name": self.region_name}
if self.endpoint_url:
kwargs["endpoint_url"] = self.endpoint_url
if self._config is not None:
kwargs["config"] = self._config
return kwargs
async def acreate_file(self, file_path: str, content: BinaryIO) -> bool:
try:
async with self.session.client(
"s3", region_name=self.region_name
) as s3_client:
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
await s3_client.put_object(
Bucket=self.bucket_name, Key=file_path, Body=await content.read()
)
@ -34,9 +69,7 @@ class S3FileSystem(BaseFileSystem):
async def aupload_file(self, local_path: str, destination_path: str) -> bool:
try:
async with self.session.client(
"s3", region_name=self.region_name
) as s3_client:
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
await s3_client.upload_file(
local_path, self.bucket_name, destination_path
)
@ -59,9 +92,7 @@ class S3FileSystem(BaseFileSystem):
disposition on the response.
"""
try:
async with self.session.client(
"s3", region_name=self.region_name
) as s3_client:
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
params = {"Bucket": self.bucket_name, "Key": file_path}
# Make artifacts viewable inline in the browser when requested
@ -100,9 +131,7 @@ class S3FileSystem(BaseFileSystem):
async def aget_file_metadata(self, file_path: str) -> Optional[Dict[str, Any]]:
"""Get S3 object metadata."""
try:
async with self.session.client(
"s3", region_name=self.region_name
) as s3_client:
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
response = await s3_client.head_object(
Bucket=self.bucket_name, Key=file_path
)
@ -126,9 +155,7 @@ class S3FileSystem(BaseFileSystem):
) -> Optional[str]:
"""Generate a presigned PUT URL for direct file upload."""
try:
async with self.session.client(
"s3", region_name=self.region_name
) as s3_client:
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
url = await s3_client.generate_presigned_url(
"put_object",
Params={
@ -145,9 +172,7 @@ class S3FileSystem(BaseFileSystem):
async def adownload_file(self, source_path: str, local_path: str) -> bool:
"""Download a file from S3 to local path."""
try:
async with self.session.client(
"s3", region_name=self.region_name
) as s3_client:
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
await s3_client.download_file(self.bucket_name, source_path, local_path)
return True
except ClientError:
@ -156,9 +181,7 @@ class S3FileSystem(BaseFileSystem):
async def acopy_file(self, source_path: str, destination_path: str) -> bool:
"""Copy a file within S3 (server-side copy)."""
try:
async with self.session.client(
"s3", region_name=self.region_name
) as s3_client:
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
await s3_client.copy_object(
Bucket=self.bucket_name,
Key=destination_path,

View file

@ -4,8 +4,11 @@ from .embedding import (
AzureEmbeddingAPIKeyNotConfiguredError,
AzureOpenAIEmbeddingService,
BaseEmbeddingService,
DograhEmbeddingService,
EmbeddingAPIKeyNotConfiguredError,
OpenAIEmbeddingService,
build_embedding_service,
resolve_embedding_correlation_id,
)
from .json_parser import parse_llm_json
@ -13,7 +16,10 @@ __all__ = [
"AzureEmbeddingAPIKeyNotConfiguredError",
"AzureOpenAIEmbeddingService",
"BaseEmbeddingService",
"DograhEmbeddingService",
"EmbeddingAPIKeyNotConfiguredError",
"OpenAIEmbeddingService",
"build_embedding_service",
"resolve_embedding_correlation_id",
"parse_llm_json",
]

View file

@ -5,12 +5,17 @@ from .azure_openai_service import (
AzureOpenAIEmbeddingService,
)
from .base import BaseEmbeddingService
from .dograh_service import DograhEmbeddingService
from .factory import build_embedding_service, resolve_embedding_correlation_id
from .openai_service import EmbeddingAPIKeyNotConfiguredError, OpenAIEmbeddingService
__all__ = [
"AzureEmbeddingAPIKeyNotConfiguredError",
"AzureOpenAIEmbeddingService",
"BaseEmbeddingService",
"DograhEmbeddingService",
"EmbeddingAPIKeyNotConfiguredError",
"OpenAIEmbeddingService",
"build_embedding_service",
"resolve_embedding_correlation_id",
]

View file

@ -0,0 +1,69 @@
"""Dograh-managed embedding service.
Routes embeddings through Dograh's managed proxy (MPS). This mirrors the managed
voice services (``DograhLLMService`` / ``DograhTTSService``): when a server-minted
MPS correlation id is present, it forwards the MPS billing v2 protocol
(``correlation_id`` + ``mps_billing_version``) in the request body so MPS can
authorize and attribute the call. With no correlation id (e.g. a v1 org) it
behaves like a plain OpenAI-compatible call, which MPS accepts.
Keeping this in a subclass keeps ``OpenAIEmbeddingService`` a generic
OpenAI-compatible client; only the managed path carries MPS-specific metadata,
so BYOK OpenAI/Azure requests never ship MPS fields to the real provider.
"""
from typing import Any, Dict, Optional
from api.db.db_client import DBClient
from .openai_service import DEFAULT_MODEL_ID, OpenAIEmbeddingService
# Protocol contract with MPS (see model_services
# api/services/model_service_correlations.py). Kept local to avoid coupling the
# app layer to the pipecat package, which defines its own copy for voice.
MPS_BILLING_VERSION_KEY = "mps_billing_version"
MPS_BILLING_VERSION_V2 = "2"
class DograhEmbeddingService(OpenAIEmbeddingService):
"""OpenAI-compatible embedding client pointed at Dograh's managed proxy."""
def __init__(
self,
db_client: DBClient,
api_key: Optional[str] = None,
model_id: str = DEFAULT_MODEL_ID,
base_url: Optional[str] = None,
correlation_id: Optional[str] = None,
):
"""Initialize the managed embedding service.
Args:
db_client: Database client for vector similarity search.
api_key: Dograh-managed MPS service key.
model_id: Embedding model/tier id (default: text-embedding-3-small).
base_url: MPS embeddings base URL.
correlation_id: Server-minted MPS correlation id. When set, the MPS
billing v2 protocol is forwarded with each request. When None,
requests are sent without the protocol (valid for v1 orgs).
"""
super().__init__(
db_client=db_client,
api_key=api_key,
model_id=model_id,
base_url=base_url,
)
self._correlation_id = correlation_id
def _request_kwargs(self) -> Dict[str, Any]:
"""Forward the MPS billing v2 protocol when a correlation id is present."""
if not self._correlation_id:
return {}
return {
"extra_body": {
"metadata": {
"correlation_id": self._correlation_id,
MPS_BILLING_VERSION_KEY: MPS_BILLING_VERSION_V2,
}
}
}

View file

@ -0,0 +1,137 @@
"""Factory for embedding services, including the Dograh-managed (MPS) path.
Centralizes the provider branching (Azure BYOK / Dograh-managed / OpenAI-compatible
BYOK) that was previously duplicated across document ingestion, the search route,
and the RAG tool, and resolves the MPS billing v2 protocol the same way the voice
path does: attach it only for orgs already on v2, and never create a billing
account to do so.
"""
from typing import Optional
from loguru import logger
from api.db.db_client import DBClient
from .azure_openai_service import AzureOpenAIEmbeddingService
from .base import BaseEmbeddingService
from .dograh_service import DograhEmbeddingService
from .openai_service import OpenAIEmbeddingService
DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"
DEFAULT_AZURE_API_VERSION = "2024-02-15-preview"
async def resolve_embedding_correlation_id(
*,
organization_id: Optional[int],
service_key: Optional[str],
created_by: Optional[str] = None,
) -> Optional[str]:
"""Resolve an MPS correlation id for a managed embedding call made outside a run.
Mirrors the voice path's gating:
- OSS deployments use a pasted hosted v2 key (v2 by definition), so mint
directly via the bearer endpoint matching ``_authorize_oss_managed_v2_correlation``.
- Hosted/SaaS: read the org's billing mode (no side effects) and mint only when
it is already v2. Minting for an already-v2 org is a no-op on the account.
Returns ``None`` when the call should be sent without the protocol; MPS accepts
un-gated embedding calls from v1 orgs. Never creates a v2 billing account.
"""
if not service_key:
return None
# Imported lazily to avoid import-time cycles between the gen_ai and service
# layers (matches the inline-import convention used elsewhere in the app).
from api.constants import DEPLOYMENT_MODE
from api.services.mps_service_key_client import mps_service_key_client
try:
if DEPLOYMENT_MODE == "oss":
minted = await mps_service_key_client.create_correlation_id(
service_key=service_key
)
return minted.get("correlation_id")
if organization_id is None:
return None
status = await mps_service_key_client.get_billing_account_status(
organization_id, created_by=created_by
)
if not status or status.get("billing_mode") != "v2":
return None
minted = await mps_service_key_client.create_correlation_id(
service_key=service_key
)
return minted.get("correlation_id")
except Exception as e:
logger.warning(
"Could not resolve MPS correlation id for managed embeddings; "
"sending without v2 protocol: {}",
e,
)
return None
async def build_embedding_service(
*,
db_client: DBClient,
provider: Optional[str],
api_key: Optional[str],
model: Optional[str],
base_url: Optional[str] = None,
endpoint: Optional[str] = None,
api_version: Optional[str] = None,
correlation_id: Optional[str] = None,
organization_id: Optional[int] = None,
created_by: Optional[str] = None,
resolve_correlation: bool = False,
) -> BaseEmbeddingService:
"""Construct the right embedding service for a provider/config.
Args:
correlation_id: A correlation id already available in context (e.g. the
running workflow's MPS correlation id). Used for the Dograh provider.
resolve_correlation: When True and no ``correlation_id`` is supplied, resolve
one for the Dograh provider via ``resolve_embedding_correlation_id``
(for calls made outside a workflow run: ingestion, manual search).
"""
from api.services.configuration.registry import ServiceProviders
model_id = model or DEFAULT_EMBEDDING_MODEL
if provider == ServiceProviders.AZURE.value and endpoint:
return AzureOpenAIEmbeddingService(
db_client=db_client,
api_key=api_key,
endpoint=endpoint,
model_id=model_id,
api_version=api_version or DEFAULT_AZURE_API_VERSION,
)
if provider == ServiceProviders.DOGRAH.value:
cid = correlation_id
if cid is None and resolve_correlation:
cid = await resolve_embedding_correlation_id(
organization_id=organization_id,
service_key=api_key,
created_by=created_by,
)
return DograhEmbeddingService(
db_client=db_client,
api_key=api_key,
model_id=model_id,
base_url=base_url,
correlation_id=cid,
)
return OpenAIEmbeddingService(
db_client=db_client,
api_key=api_key,
model_id=model_id,
base_url=base_url,
)

View file

@ -85,6 +85,14 @@ class OpenAIEmbeddingService(BaseEmbeddingService):
if not self._api_key_configured or self.client is None:
raise EmbeddingAPIKeyNotConfiguredError()
def _request_kwargs(self) -> Dict[str, Any]:
"""Extra kwargs merged into every embeddings.create() call.
Override hook for subclasses (e.g. DograhEmbeddingService injects the MPS
billing protocol here). The base service adds nothing.
"""
return {}
async def embed_texts(self, texts: List[str]) -> List[List[float]]:
"""Embed a batch of texts using OpenAI API.
@ -97,6 +105,7 @@ class OpenAIEmbeddingService(BaseEmbeddingService):
response = await self.client.embeddings.create(
input=texts,
model=self.model_id,
**self._request_kwargs(),
)
return [item.embedding for item in response.data]
except Exception as e:

View file

@ -720,6 +720,9 @@ class MPSServiceKeyClient:
provider: str,
model: Optional[str] = None,
language: Optional[str] = None,
q: Optional[str] = None,
gender: Optional[str] = None,
accent: Optional[str] = None,
organization_id: Optional[int] = None,
created_by: Optional[str] = None,
) -> dict:
@ -745,6 +748,12 @@ class MPSServiceKeyClient:
params["model"] = model
if language:
params["language"] = language
if q:
params["q"] = q
if gender:
params["gender"] = gender
if accent:
params["accent"] = accent
response = await client.get(
f"{self.base_url}/api/v1/voice-proxy/{provider}/voices",
headers=self._get_headers(organization_id, created_by),

View file

@ -0,0 +1,39 @@
"""Dograh-specific Gemini adapter customizations."""
from typing import Any
from pipecat.adapters.schemas.tools_schema import AdapterType, ToolsSchema
from pipecat.adapters.services.gemini_adapter import GeminiLLMAdapter
class DograhGeminiJSONSchemaAdapter(GeminiLLMAdapter):
"""Use Gemini's full JSON Schema tool parameter field.
Pipecat's default Gemini adapter maps ``FunctionSchema.parameters`` into
``FunctionDeclaration.parameters``, which is backed by Google GenAI's
stricter OpenAPI-style ``Schema`` model. MCP and imported tools may contain
valid JSON Schema keywords such as ``const`` and ``not`` that are rejected
by that model. ``parameters_json_schema`` is the Google GenAI field intended
for full JSON Schema payloads.
"""
def to_provider_tools_format(
self, tools_schema: ToolsSchema
) -> list[dict[str, Any]]:
functions_schema = tools_schema.standard_tools
if functions_schema:
formatted_functions = []
for func in functions_schema:
func_dict = func.to_default_dict()
parameters = func_dict.pop("parameters")
func_dict["parameters_json_schema"] = parameters
formatted_functions.append(func_dict)
formatted_standard_tools = [{"function_declarations": formatted_functions}]
else:
formatted_standard_tools = []
custom_gemini_tools = []
if tools_schema.custom_tools:
custom_gemini_tools = tools_schema.custom_tools.get(AdapterType.GEMINI, [])
return formatted_standard_tools + custom_gemini_tools

View file

@ -22,6 +22,9 @@ from typing import Any
from loguru import logger
from api.services.pipecat.gemini_json_schema_adapter import (
DograhGeminiJSONSchemaAdapter,
)
from pipecat.frames.frames import (
BotStoppedSpeakingFrame,
Frame,
@ -39,6 +42,13 @@ from pipecat.utils.tracing.service_decorators import traced_gemini_live
class DograhGeminiLiveLLMService(GeminiLiveLLMService):
"""Gemini Live with Dograh engine integration quirks. See module docstring."""
# Route tool schemas through Gemini's ``parameters_json_schema`` field so
# MCP/imported tools that use JSON Schema keywords (``const``, ``not``,
# nested ``anyOf``) rejected by the strict ``Schema`` model are accepted.
# Mirrors the non-realtime ``DograhGoogleLLMService`` fix;
# ``DograhGeminiLiveVertexLLMService`` inherits this via MRO.
adapter_class = DograhGeminiJSONSchemaAdapter
def __init__(self, **kwargs):
super().__init__(**kwargs)
# User-mute state, driven by broadcast UserMute{Started,Stopped}Frames.

View file

@ -6,7 +6,6 @@ from loguru import logger
from api.db import db_client
from api.enums import WorkflowRunMode
from api.services.configuration.options import DEEPGRAM_FLUX_MODELS
from api.services.configuration.registry import ServiceProviders
from api.services.integrations import (
IntegrationRuntimeContext,
@ -47,6 +46,7 @@ from api.services.pipecat.service_factory import (
create_realtime_llm_service,
create_stt_service,
create_tts_service,
stt_uses_flux_turns,
)
from api.services.pipecat.tracing_config import (
ensure_tracing,
@ -626,14 +626,10 @@ async def _run_pipeline(
user_config.realtime.provider
)
else:
# Deepgram Flux uses external turn detection (VAD + External start/stop)
# Other models use configurable turn detection strategy
is_deepgram_flux = (
user_config.stt.provider == ServiceProviders.DEEPGRAM.value
and user_config.stt.model in DEEPGRAM_FLUX_MODELS
)
if is_deepgram_flux:
# Deepgram Flux and supported Dograh managed Flux languages emit their
# own turn boundaries, so the aggregator follows those external signals.
# Other models use configurable turn detection.
if stt_uses_flux_turns(user_config):
user_turn_strategies = UserTurnStrategies(
start=[
VADUserTurnStartStrategy(),

View file

@ -6,8 +6,14 @@ from fastapi import HTTPException
from loguru import logger
from api.constants import MPS_API_URL
from api.services.configuration.options import DEEPGRAM_FLUX_MODELS
from api.services.configuration.options import (
DEEPGRAM_FLUX_MODELS,
DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS,
)
from api.services.configuration.registry import ServiceProviders
from api.services.pipecat.gemini_json_schema_adapter import (
DograhGeminiJSONSchemaAdapter,
)
from api.services.pipecat.minimax_tts import MiniMaxOwnedSessionTTSService
from api.utils.url_security import validate_user_configured_service_url
from pipecat.services.assemblyai.stt import AssemblyAISTTService, AssemblyAISTTSettings
@ -27,6 +33,7 @@ from pipecat.services.deepgram.flux.stt import (
)
from pipecat.services.deepgram.stt import DeepgramSTTService, DeepgramSTTSettings
from pipecat.services.deepgram.tts import DeepgramTTSService, DeepgramTTSSettings
from pipecat.services.dograh.flux.stt import DograhFluxSTTService
from pipecat.services.dograh.llm import DograhLLMService
from pipecat.services.dograh.stt import DograhSTTService, DograhSTTSettings
from pipecat.services.dograh.tts import DograhTTSService, DograhTTSSettings
@ -94,6 +101,27 @@ DEEPGRAM_FLUX_LANGUAGE_HINTS = {
}
def dograh_stt_uses_flux_language(language: str | None) -> bool:
language = language or "multi"
return language in DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS
def stt_uses_flux_turns(user_config) -> bool:
if user_config.stt.provider == ServiceProviders.DEEPGRAM.value:
return user_config.stt.model in DEEPGRAM_FLUX_MODELS
if user_config.stt.provider == ServiceProviders.DOGRAH.value:
return dograh_stt_uses_flux_language(getattr(user_config.stt, "language", None))
return False
class DograhGoogleLLMService(GoogleLLMService):
adapter_class = DograhGeminiJSONSchemaAdapter
class DograhGoogleVertexLLMService(GoogleVertexLLMService):
adapter_class = DograhGeminiJSONSchemaAdapter
def _validate_runtime_service_url(url: str, field_name: str) -> None:
try:
validate_user_configured_service_url(
@ -193,6 +221,29 @@ def create_stt_service(
elif user_config.stt.provider == ServiceProviders.DOGRAH.value:
base_url = MPS_API_URL.replace("http://", "ws://").replace("https://", "wss://")
language = getattr(user_config.stt, "language", None) or "multi"
if dograh_stt_uses_flux_language(language):
# Dograh's Flux proxy only supports multilingual auto-detect and the
# same language hint subset as Deepgram Flux multilingual.
settings_kwargs = {
"model": "flux-general-multi",
"eot_timeout_ms": 3000,
"eot_threshold": 0.7,
"eager_eot_threshold": 0.5,
"keyterm": keyterms or [],
}
language_hint = DEEPGRAM_FLUX_LANGUAGE_HINTS.get(language)
if language_hint:
settings_kwargs["language_hints"] = [language_hint]
return DograhFluxSTTService(
base_url=base_url,
api_key=user_config.stt.api_key,
correlation_id=correlation_id,
settings=DeepgramFluxSTTSettings(**settings_kwargs),
should_interrupt=False, # external turn strategies own interruption
sample_rate=audio_config.transport_in_sample_rate,
)
return DograhSTTService(
base_url=base_url,
api_key=user_config.stt.api_key,
@ -679,6 +730,19 @@ def create_tts_service(
)
def _migrate_deprecated_google_model(model: str) -> str:
"""Google removed the ``gemini-2.0-flash*`` models. Transparently upgrade
any stored config that still references them to the 2.5 equivalent so old
user configurations keep working instead of failing at runtime."""
if model and model.startswith("gemini-2.0-flash"):
migrated = model.replace("gemini-2.0-", "gemini-2.5-", 1)
logger.warning(
f"Google model '{model}' is no longer supported; using '{migrated}' instead"
)
return migrated
return model
def create_llm_service_from_provider(
provider: str,
model: str,
@ -736,12 +800,13 @@ def create_llm_service_from_provider(
**kwargs,
)
elif provider == ServiceProviders.GOOGLE.value:
return GoogleLLMService(
model = _migrate_deprecated_google_model(model)
return DograhGoogleLLMService(
api_key=api_key,
settings=GoogleLLMSettings(model=model, temperature=0.1),
)
elif provider == ServiceProviders.GOOGLE_VERTEX.value:
return GoogleVertexLLMService(
return DograhGoogleVertexLLMService(
credentials=credentials,
project_id=project_id,
location=location or "us-east4",

View file

@ -9,8 +9,11 @@ from api.constants import (
MINIO_PUBLIC_ENDPOINT,
MINIO_SECRET_KEY,
MINIO_SECURE,
S3_ADDRESSING_STYLE,
S3_BUCKET,
S3_ENDPOINT_URL,
S3_REGION,
S3_SIGNATURE_VERSION,
)
from api.enums import Environment, StorageBackend
@ -57,7 +60,13 @@ def get_storage_for_backend(backend: str) -> BaseFileSystem:
logger.info(
f"Initializing {backend} storage with bucket '{bucket}' in region '{region}'"
)
return S3FileSystem(bucket, region)
return S3FileSystem(
bucket_name=bucket,
region_name=region,
endpoint_url=S3_ENDPOINT_URL,
signature_version=S3_SIGNATURE_VERSION,
addressing_style=S3_ADDRESSING_STYLE,
)
# Future backend implementations can be added here:
# elif backend == StorageBackend.GCS: # Code 3

View file

@ -56,6 +56,15 @@ class NormalizedInboundData:
raw_data: Dict[str, Any] = field(default_factory=dict) # Original webhook data
@dataclass
class AnsweringMachineDetectionResult:
"""Standardized answering-machine detection result across providers."""
call_id: str
answered_by: str
raw_data: Dict[str, Any] = field(default_factory=dict)
class TelephonyProvider(ABC):
"""
Abstract base class for telephony providers.
@ -192,6 +201,23 @@ class TelephonyProvider(ABC):
"""
pass
def supports_answering_machine_detection(self) -> bool:
"""Return whether this provider can request answering-machine detection."""
return False
def apply_answering_machine_detection_call_params(
self,
data: Dict[str, Any],
) -> Dict[str, Any]:
"""Add provider-specific AMD parameters to an outbound call request."""
return data
def parse_answering_machine_detection_result(
self, data: Dict[str, Any]
) -> Optional[AnsweringMachineDetectionResult]:
"""Parse provider-specific callback data into a normalized AMD result."""
return None
@abstractmethod
async def handle_websocket(
self,

View file

@ -14,7 +14,7 @@ from fastapi import HTTPException
from loguru import logger
from api.db import db_client
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -205,12 +205,12 @@ class ARIProvider(TelephonyProvider):
"""
# Map ARI channel states to common status format
state_map = {
"Up": "answered",
"Down": "completed",
"Ringing": "ringing",
"Ring": "ringing",
"Busy": "busy",
"Unavailable": "failed",
"Up": TelephonyCallStatus.ANSWERED,
"Down": TelephonyCallStatus.COMPLETED,
"Ringing": TelephonyCallStatus.RINGING,
"Ring": TelephonyCallStatus.RINGING,
"Busy": TelephonyCallStatus.BUSY,
"Unavailable": TelephonyCallStatus.FAILED,
}
channel_state = data.get("channel", {}).get("state", "")
@ -218,11 +218,11 @@ class ARIProvider(TelephonyProvider):
# Determine status from event type
if event_type == "StasisStart":
status = "answered"
status = TelephonyCallStatus.ANSWERED
elif event_type == "StasisEnd":
status = "completed"
status = TelephonyCallStatus.COMPLETED
elif event_type == "ChannelDestroyed":
status = "completed"
status = TelephonyCallStatus.COMPLETED
else:
status = state_map.get(channel_state, channel_state.lower())

View file

@ -11,7 +11,7 @@ from fastapi import HTTPException
from loguru import logger
from api.db import db_client
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -348,15 +348,15 @@ class CloudonixProvider(TelephonyProvider):
# Map Cloudonix status values to common format
# These mappings may need adjustment based on actual Cloudonix callback format
status_map = {
"initiated": "initiated",
"ringing": "ringing",
"answered": "answered",
"completed": "completed",
"failed": "failed",
"busy": "busy",
"no-answer": "no-answer",
"canceled": "canceled",
"error": "error",
"initiated": TelephonyCallStatus.INITIATED,
"ringing": TelephonyCallStatus.RINGING,
"answered": TelephonyCallStatus.ANSWERED,
"completed": TelephonyCallStatus.COMPLETED,
"failed": TelephonyCallStatus.FAILED,
"busy": TelephonyCallStatus.BUSY,
"no-answer": TelephonyCallStatus.NO_ANSWER,
"canceled": TelephonyCallStatus.CANCELED,
"error": TelephonyCallStatus.ERROR,
}
call_status = data.get("status", "")
@ -374,6 +374,33 @@ class CloudonixProvider(TelephonyProvider):
"extra": data, # Include all original data
}
@staticmethod
def parse_cdr_status_callback(data: Dict[str, Any]) -> Dict[str, Any]:
"""Parse Cloudonix CDR data into generic status callback format."""
disposition_map = {
"ANSWER": TelephonyCallStatus.COMPLETED,
"BUSY": TelephonyCallStatus.BUSY,
"CANCEL": TelephonyCallStatus.CANCELED,
"FAILED": TelephonyCallStatus.FAILED,
"CONGESTION": TelephonyCallStatus.FAILED,
"NOANSWER": TelephonyCallStatus.NO_ANSWER,
}
disposition = data.get("disposition") or ""
session = data.get("session")
billsec = data.get("billsec")
return {
"call_id": session.get("token") if isinstance(session, dict) else "",
"status": disposition_map.get(disposition.upper(), disposition.lower()),
"from_number": data.get("from"),
"to_number": data.get("to"),
"duration": str(
billsec if billsec is not None else (data.get("duration") or 0)
),
"extra": data,
}
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
) -> str:

View file

@ -12,6 +12,7 @@ from pipecat.utils.run_context import set_current_run_id
from api.db import db_client
from api.services.telephony.factory import get_telephony_provider_for_run
from api.services.telephony.providers.cloudonix.provider import CloudonixProvider
from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
@ -120,8 +121,15 @@ async def handle_cloudonix_cdr(request: Request):
set_current_run_id(workflow_run_id)
logger.info(f"[run {workflow_run_id}] Processing Cloudonix CDR for call {call_id}")
# Convert CDR to status update using StatusCallbackRequest
status_update = StatusCallbackRequest.from_cloudonix_cdr(cdr_data)
parsed_data = CloudonixProvider.parse_cdr_status_callback(cdr_data)
status_update = StatusCallbackRequest(
call_id=parsed_data["call_id"],
status=parsed_data["status"],
from_number=parsed_data.get("from_number"),
to_number=parsed_data.get("to_number"),
duration=parsed_data.get("duration"),
extra=parsed_data.get("extra", {}),
)
# Process the status update
await _process_status_update(workflow_run_id, status_update)

View file

@ -15,7 +15,7 @@ from fastapi import HTTPException
from loguru import logger
from api.db import db_client
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -281,17 +281,17 @@ class PlivoProvider(TelephonyProvider):
def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]:
status_map = {
"in-progress": "answered",
"ringing": "ringing",
"ring": "ringing",
"completed": "completed",
"hangup": "completed",
"stopstream": "completed",
"busy": "busy",
"no-answer": "no-answer",
"cancel": "canceled",
"cancelled": "canceled",
"timeout": "no-answer",
"in-progress": TelephonyCallStatus.ANSWERED,
"ringing": TelephonyCallStatus.RINGING,
"ring": TelephonyCallStatus.RINGING,
"completed": TelephonyCallStatus.COMPLETED,
"hangup": TelephonyCallStatus.COMPLETED,
"stopstream": TelephonyCallStatus.COMPLETED,
"busy": TelephonyCallStatus.BUSY,
"no-answer": TelephonyCallStatus.NO_ANSWER,
"cancel": TelephonyCallStatus.CANCELED,
"cancelled": TelephonyCallStatus.CANCELED,
"timeout": TelephonyCallStatus.NO_ANSWER,
}
call_status = (data.get("CallStatus") or data.get("Event") or "").lower()

View file

@ -25,7 +25,7 @@ TELNYX_TIMESTAMP_TOLERANCE_SECONDS = 300
TELNYX_PUBLIC_KEY_BYTES = 32
TELNYX_SIGNATURE_BYTES = 64
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -305,23 +305,25 @@ class TelnyxProvider(TelephonyProvider):
}
@staticmethod
def _resolve_status(event_type: str, payload: Dict[str, Any]) -> str:
def _resolve_status(
event_type: str, payload: Dict[str, Any]
) -> TelephonyCallStatus | str:
"""Map a Telnyx event type (and hangup cause) to a normalized status."""
EVENT_STATUS = {
"call.initiated": "initiated",
"call.answered": "in-progress",
"call.hangup": "completed",
"call.initiated": TelephonyCallStatus.INITIATED,
"call.answered": TelephonyCallStatus.IN_PROGRESS,
"call.hangup": TelephonyCallStatus.COMPLETED,
"call.machine.detection.ended": "machine-detected",
"streaming.started": "streaming-started",
"streaming.stopped": "streaming-stopped",
}
HANGUP_STATUS = {
"busy": "busy",
"no_answer": "no-answer",
"timeout": "no-answer",
"call_rejected": "failed",
"unallocated_number": "failed",
"busy": TelephonyCallStatus.BUSY,
"no_answer": TelephonyCallStatus.NO_ANSWER,
"timeout": TelephonyCallStatus.NO_ANSWER,
"call_rejected": TelephonyCallStatus.FAILED,
"unallocated_number": TelephonyCallStatus.FAILED,
}
status = EVENT_STATUS.get(event_type, event_type)

View file

@ -20,6 +20,7 @@ def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
"account_sid": value.get("account_sid"),
"auth_token": value.get("auth_token"),
"from_numbers": value.get("from_numbers", []),
"amd_enabled": value.get("amd_enabled", False),
}
@ -47,6 +48,15 @@ _UI_METADATA = ProviderUIMetadata(
type="string-array",
description="E.164-formatted Twilio phone numbers used for outbound calls",
),
ProviderUIField(
name="amd_enabled",
label="Answering Machine Detection",
type="boolean",
description=(
"Detect whether outbound calls are answered by a person or "
"machine. Twilio may bill AMD as an additional per-call feature."
),
),
],
)

View file

@ -16,6 +16,13 @@ class TwilioConfigurationRequest(BaseModel):
from_numbers: List[str] = Field(
default_factory=list, description="List of Twilio phone numbers"
)
amd_enabled: bool = Field(
default=False,
description=(
"Detect whether outbound calls are answered by a person or machine. "
"Twilio may bill AMD as an additional per-call feature."
),
)
class TwilioConfigurationResponse(BaseModel):
@ -25,3 +32,4 @@ class TwilioConfigurationResponse(BaseModel):
account_sid: str # Masked (e.g., "****************def0")
auth_token: str # Masked (e.g., "****************abc1")
from_numbers: List[str]
amd_enabled: bool = False

View file

@ -11,8 +11,9 @@ from fastapi import HTTPException
from loguru import logger
from twilio.request_validator import RequestValidator
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
AnsweringMachineDetectionResult,
CallInitiationResult,
NormalizedInboundData,
ProviderSyncResult,
@ -47,6 +48,7 @@ class TwilioProvider(TelephonyProvider):
self.account_sid = config.get("account_sid")
self.auth_token = config.get("auth_token")
self.from_numbers = config.get("from_numbers", [])
self.amd_enabled: bool = bool(config.get("amd_enabled", False))
# Handle both single number (string) and multiple numbers (list)
if isinstance(self.from_numbers, str):
@ -96,6 +98,8 @@ class TwilioProvider(TelephonyProvider):
}
)
data = self.apply_answering_machine_detection_call_params(data)
data.update(kwargs)
# Make the API request
@ -230,9 +234,10 @@ class TwilioProvider(TelephonyProvider):
"""
Parse Twilio status callback data into generic format.
"""
call_status = data.get("CallStatus", "")
return {
"call_id": data.get("CallSid", ""),
"status": data.get("CallStatus", ""),
"status": TelephonyCallStatus.from_raw(call_status) or call_status,
"from_number": data.get("From"),
"to_number": data.get("To"),
"direction": data.get("Direction"),
@ -240,6 +245,31 @@ class TwilioProvider(TelephonyProvider):
"extra": data, # Include all original data
}
def supports_answering_machine_detection(self) -> bool:
"""Twilio supports AMD through the Voice Calls API."""
return True
def apply_answering_machine_detection_call_params(
self,
data: Dict[str, Any],
) -> Dict[str, Any]:
if self.amd_enabled:
data["MachineDetection"] = "Enable"
return data
def parse_answering_machine_detection_result(
self, data: Dict[str, Any]
) -> Optional[AnsweringMachineDetectionResult]:
answered_by = data.get("AnsweredBy")
if not answered_by:
return None
return AnsweringMachineDetectionResult(
call_id=data.get("CallSid", ""),
answered_by=answered_by,
raw_data=data,
)
async def handle_websocket(
self,
websocket: "WebSocket",

View file

@ -12,6 +12,7 @@ from pipecat.utils.run_context import set_current_run_id
from starlette.responses import HTMLResponse
from api.db import db_client
from api.services.telephony.base import TelephonyProvider
from api.services.telephony.factory import get_telephony_provider_for_run
from api.services.telephony.status_processor import (
StatusCallbackRequest,
@ -21,6 +22,28 @@ from api.services.telephony.status_processor import (
router = APIRouter()
async def _persist_amd_result_if_present(
*,
provider: TelephonyProvider,
workflow_run_id: int,
callback_data: dict,
) -> None:
amd_result = provider.parse_answering_machine_detection_result(callback_data)
if not amd_result:
return
try:
logger.info(
f"[run {workflow_run_id}] AMD result: AnsweredBy={amd_result.answered_by}"
)
await db_client.update_workflow_run(
run_id=workflow_run_id,
gathered_context={"answered_by": amd_result.answered_by},
)
except Exception as exc:
logger.warning(f"[run {workflow_run_id}] Failed to persist AMD result: {exc}")
@router.post("/twiml", include_in_schema=False)
async def handle_twiml_webhook(
workflow_id: int,
@ -49,6 +72,12 @@ async def handle_twiml_webhook(
)
raise HTTPException(status_code=401, detail="Invalid webhook signature")
await _persist_amd_result_if_present(
provider=provider,
workflow_run_id=workflow_run_id,
callback_data=callback_data,
)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
)
@ -111,6 +140,12 @@ async def handle_twilio_status_callback(
extra=parsed_data.get("extra", {}),
)
await _persist_amd_result_if_present(
provider=provider,
workflow_run_id=workflow_run_id,
callback_data=callback_data,
)
# Process the status update
await _process_status_update(workflow_run_id, status_update)

View file

@ -14,7 +14,7 @@ import aiohttp
from fastapi import HTTPException
from loguru import logger
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -335,9 +335,10 @@ class VobizProvider(TelephonyProvider):
- call_uuid (instead of CallSid)
- status, from, to, duration, etc.
"""
call_status = data.get("CallStatus", "")
return {
"call_id": data.get("CallUUID", ""),
"status": data.get("CallStatus", ""),
"status": TelephonyCallStatus.from_raw(call_status) or call_status,
"from_number": data.get("From"),
"to_number": data.get("To"),
"direction": data.get("Direction"),

View file

@ -12,7 +12,7 @@ import jwt
from fastapi import HTTPException, Response
from loguru import logger
from api.enums import WorkflowRunMode
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
@ -291,14 +291,14 @@ class VonageProvider(TelephonyProvider):
"""
# Map Vonage status to common format
status_map = {
"started": "initiated",
"ringing": "ringing",
"answered": "answered",
"complete": "completed",
"failed": "failed",
"busy": "busy",
"timeout": "no-answer",
"rejected": "busy",
"started": TelephonyCallStatus.INITIATED,
"ringing": TelephonyCallStatus.RINGING,
"answered": TelephonyCallStatus.ANSWERED,
"complete": TelephonyCallStatus.COMPLETED,
"failed": TelephonyCallStatus.FAILED,
"busy": TelephonyCallStatus.BUSY,
"timeout": TelephonyCallStatus.NO_ANSWER,
"rejected": TelephonyCallStatus.BUSY,
}
return {

View file

@ -40,7 +40,8 @@ class ProviderUIField:
name: str # Must match the Pydantic field name on config_request_cls
label: str
type: str # "text" | "password" | "textarea" | "string-array" | "number"
# "text" | "password" | "textarea" | "string-array" | "number" | "boolean"
type: str
required: bool = True
sensitive: bool = False # If true, mask when displaying stored value
description: Optional[str] = None

View file

@ -12,25 +12,96 @@ from loguru import logger
from pydantic import BaseModel
from api.db import db_client
from api.enums import WorkflowRunState
from api.enums import TelephonyCallStatus, WorkflowRunState
from api.services.campaign.campaign_call_dispatcher import campaign_call_dispatcher
from api.services.campaign.campaign_event_publisher import (
get_campaign_event_publisher,
)
from api.services.campaign.circuit_breaker import circuit_breaker
from api.tasks.arq import enqueue_job
from api.tasks.function_names import FunctionNames
TERMINAL_NOT_CONNECTED_STATUSES = frozenset(
{
TelephonyCallStatus.FAILED,
TelephonyCallStatus.BUSY,
TelephonyCallStatus.NO_ANSWER,
TelephonyCallStatus.CANCELED,
TelephonyCallStatus.ERROR,
}
)
IN_FLIGHT_STATUSES = frozenset(
{
TelephonyCallStatus.INITIATED,
TelephonyCallStatus.RINGING,
TelephonyCallStatus.IN_PROGRESS,
TelephonyCallStatus.ANSWERED,
}
)
RETRYABLE_NOT_CONNECTED_STATUSES = frozenset(
{TelephonyCallStatus.BUSY, TelephonyCallStatus.NO_ANSWER}
)
FAILURE_NOT_CONNECTED_STATUSES = frozenset(
{TelephonyCallStatus.ERROR, TelephonyCallStatus.FAILED}
)
def _status_value(value: object) -> str:
status = TelephonyCallStatus.from_raw(value)
if status is not None:
return status.value
return str(value or "").lower()
def _duration_seconds(duration: str | None) -> int | float:
if duration in (None, ""):
return 0
try:
parsed = float(duration)
except (TypeError, ValueError):
return 0
return int(parsed) if parsed.is_integer() else parsed
def _append_unique_tags(existing_tags: object, new_tags: list[str]) -> list[str]:
tags = existing_tags if isinstance(existing_tags, list) else []
merged = list(tags)
for tag in new_tags:
if tag not in merged:
merged.append(tag)
return merged
async def _enqueue_integrations_for_unconnected_run(
workflow_run_id: int,
status: str,
) -> None:
"""Fire post-call integrations (e.g. webhooks) when a call ends before the
Pipecat pipeline ever starts.
Enqueues integrations only -- deliberately *not*
``PROCESS_WORKFLOW_COMPLETION`` -- so an unconnected call still triggers the
configured webhooks without incurring platform-usage billing.
"""
await enqueue_job(FunctionNames.RUN_INTEGRATIONS_POST_WORKFLOW_RUN, workflow_run_id)
logger.info(
f"[run {workflow_run_id}] Enqueued post-call integrations after terminal "
f"telephony status: {status}"
)
class StatusCallbackRequest(BaseModel):
"""Normalized status callback shape used across all telephony providers.
Per-provider converters live as classmethods (``from_twilio``, ``from_plivo``,
``from_vonage``, ``from_cloudonix_cdr``) so the route handler for each
provider can map raw webhook payloads into this shape and hand off to
:func:`_process_status_update`.
Provider-specific route handlers map raw webhook payloads into this shape,
then hand it off to :func:`_process_status_update`.
"""
call_id: str
status: str
status: TelephonyCallStatus | str
from_number: Optional[str] = None
to_number: Optional[str] = None
direction: Optional[str] = None
@ -38,102 +109,14 @@ class StatusCallbackRequest(BaseModel):
extra: dict = {}
@classmethod
def from_twilio(cls, data: dict):
"""Convert Twilio callback to generic format."""
return cls(
call_id=data.get("CallSid", ""),
status=data.get("CallStatus", ""),
from_number=data.get("From"),
to_number=data.get("To"),
direction=data.get("Direction"),
duration=data.get("CallDuration") or data.get("Duration"),
extra=data,
)
@classmethod
def from_plivo(cls, data: dict):
"""Convert Plivo callback to generic format."""
status_map = {
"in-progress": "answered",
"ringing": "ringing",
"ring": "ringing",
"completed": "completed",
"hangup": "completed",
"stopstream": "completed",
"busy": "busy",
"no-answer": "no-answer",
"cancel": "canceled",
"cancelled": "canceled",
"timeout": "no-answer",
}
call_status = (data.get("CallStatus") or data.get("Event") or "").lower()
return cls(
call_id=data.get("CallUUID", "") or data.get("RequestUUID", ""),
status=status_map.get(call_status, call_status),
from_number=data.get("From"),
to_number=data.get("To"),
direction=data.get("Direction"),
duration=data.get("Duration"),
extra=data,
)
@classmethod
def from_vonage(cls, data: dict):
"""Convert Vonage event to generic format."""
status_map = {
"started": "initiated",
"ringing": "ringing",
"answered": "answered",
"complete": "completed",
"failed": "failed",
"busy": "busy",
"timeout": "no-answer",
"rejected": "busy",
}
return cls(
call_id=data.get("uuid", ""),
status=status_map.get(data.get("status", ""), data.get("status", "")),
from_number=data.get("from"),
to_number=data.get("to"),
direction=data.get("direction"),
duration=data.get("duration"),
extra=data,
)
@classmethod
def from_cloudonix_cdr(cls, data: dict):
"""Convert Cloudonix CDR to generic format."""
disposition_map = {
"ANSWER": "completed",
"BUSY": "busy",
"CANCEL": "canceled",
"FAILED": "failed",
"CONGESTION": "failed",
"NOANSWER": "no-answer",
}
disposition = data.get("disposition") or ""
status = disposition_map.get(disposition.upper(), disposition.lower())
session = data.get("session")
call_id = session.get("token") if isinstance(session, dict) else ""
return cls(
call_id=call_id or "",
status=status,
from_number=data.get("from"),
to_number=data.get("to"),
duration=str(data.get("billsec") or data.get("duration") or 0),
extra=data,
)
async def _process_status_update(workflow_run_id: int, status: StatusCallbackRequest):
"""Process status updates from telephony providers.
Idempotent: handles repeated callbacks (e.g. from both webhook and CDR).
"""
normalized_status = TelephonyCallStatus.from_raw(status.status)
status_value = _status_value(status.status)
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.warning(
@ -143,7 +126,7 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", [])
telephony_callback_log = {
"status": status.status,
"status": status_value,
"timestamp": datetime.now(UTC).isoformat(),
"call_id": status.call_id,
"duration": status.duration,
@ -156,7 +139,7 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
logs={"telephony_status_callbacks": telephony_callback_logs},
)
if status.status == "completed":
if normalized_status == TelephonyCallStatus.COMPLETED:
logger.info(
f"[run {workflow_run_id}] Call completed with duration: {status.duration}s"
)
@ -174,26 +157,29 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
state=WorkflowRunState.COMPLETED.value,
)
elif status.status in ["failed", "busy", "no-answer", "canceled", "error"]:
elif normalized_status in TERMINAL_NOT_CONNECTED_STATUSES:
logger.warning(
f"[run {workflow_run_id}] Call failed with status: {status.status}"
f"[run {workflow_run_id}] Call failed with status: {normalized_status.value}"
)
if workflow_run.campaign_id:
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
is_failure = status.status in ("error", "failed")
is_failure = normalized_status in FAILURE_NOT_CONNECTED_STATUSES
await circuit_breaker.record_and_evaluate(
workflow_run.campaign_id,
is_failure=is_failure,
workflow_run_id=workflow_run_id if is_failure else None,
reason=status.status if is_failure else None,
reason=normalized_status.value if is_failure else None,
)
if status.status in ["busy", "no-answer"] and workflow_run.campaign_id:
if (
normalized_status in RETRYABLE_NOT_CONNECTED_STATUSES
and workflow_run.campaign_id
):
publisher = await get_campaign_event_publisher()
await publisher.publish_retry_needed(
workflow_run_id=workflow_run_id,
reason=status.status.replace("-", "_"),
reason=normalized_status.value.replace("-", "_"),
campaign_id=workflow_run.campaign_id,
queued_run_id=workflow_run.queued_run_id,
)
@ -203,15 +189,42 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
if workflow_run.gathered_context
else []
)
call_tags.extend(["not_connected", f"telephony_{status.status.lower()}"])
await db_client.update_workflow_run(
run_id=workflow_run_id,
is_completed=True,
state=WorkflowRunState.COMPLETED.value,
gathered_context={"call_tags": call_tags},
call_tags = _append_unique_tags(
call_tags,
["not_connected", f"telephony_{normalized_status.value}"],
)
elif status.status in ["in-progress", "initiated", "ringing"]:
gathered_context = {
"call_tags": call_tags,
"call_disposition": normalized_status.value,
"mapped_call_disposition": normalized_status.value,
}
if status.call_id:
gathered_context["call_id"] = status.call_id
should_run_post_call_integrations = (
workflow_run.state == WorkflowRunState.INITIALIZED.value
and not workflow_run.is_completed
)
update_kwargs = {
"run_id": workflow_run_id,
"is_completed": True,
"state": WorkflowRunState.COMPLETED.value,
"gathered_context": gathered_context,
}
if should_run_post_call_integrations:
update_kwargs["usage_info"] = {
"call_duration_seconds": _duration_seconds(status.duration)
}
await db_client.update_workflow_run(**update_kwargs)
if should_run_post_call_integrations:
await _enqueue_integrations_for_unconnected_run(
workflow_run_id, normalized_status.value
)
elif normalized_status in IN_FLIGHT_STATUSES:
# No-op while the call is in flight.
pass
else:

View file

@ -1,46 +0,0 @@
"""Utility module for applying disposition code mapping."""
from loguru import logger
from api.db import db_client
from api.enums import OrganizationConfigurationKey
async def apply_disposition_mapping(value: str, organization_id: int | None) -> str:
"""Apply disposition code mapping if configured.
Args:
value: The original disposition value to map
organization_id: The organization ID
Returns:
The mapped value if found in configuration, otherwise the original value
"""
if not organization_id or not value:
return value
try:
disposition_mapping = await db_client.get_configuration_value(
organization_id,
OrganizationConfigurationKey.DISPOSITION_CODE_MAPPING.value,
default={},
)
if not disposition_mapping:
return value
# Return mapped value if exists, otherwise original
# DISPOSITION_CODE_MAPPING looks like {"user_idle_max_duration_exceeded": "DAIR"} etc.
mapped_value = disposition_mapping.get(value, value)
if mapped_value != value:
logger.debug(
f"Mapped disposition code from '{value}' to '{mapped_value}' "
f"for organization {organization_id}"
)
return mapped_value
except Exception as e:
logger.error(f"Error applying disposition mapping: {e}")
return value

View file

@ -19,7 +19,6 @@ from pipecat.utils.enums import EndTaskReason
from api.db import db_client
from api.enums import ToolCategory
from api.services.pipecat.audio_playback import play_audio
from api.services.workflow.disposition_mapper import apply_disposition_mapping
from api.services.workflow.workflow_graph import Node, WorkflowGraph
if TYPE_CHECKING:
@ -751,38 +750,21 @@ class PipecatEngine:
CancelFrame(reason=reason) if abort_immediately else EndFrame(reason=reason)
)
# Apply disposition mapping - first try call_disposition if it is,
# extracted from the call conversation then fall back to reason
call_disposition = self._gathered_context.get("call_disposition", "")
organization_id = await self._get_organization_id()
# Record the call disposition: prefer one extracted from the conversation,
# otherwise fall back to the disconnect reason.
call_disposition = self._gathered_context.get("call_disposition", "") or reason
self._gathered_context["call_disposition"] = call_disposition
self._gathered_context["mapped_call_disposition"] = call_disposition
if call_disposition:
# If call_disposition exists, map it
mapped_disposition = await apply_disposition_mapping(
call_disposition, organization_id
)
# Store the original and mapped values
self._gathered_context["extracted_call_disposition"] = call_disposition
self._gathered_context["call_disposition"] = call_disposition
self._gathered_context["mapped_call_disposition"] = mapped_disposition
else:
# Otherwise, map the disconnect reason
mapped_disposition = await apply_disposition_mapping(
reason, organization_id
)
# Store the mapped disconnect reason
self._gathered_context["call_disposition"] = reason
self._gathered_context["mapped_call_disposition"] = mapped_disposition
effective_disposition = self._gathered_context.get("call_disposition", "")
if effective_disposition:
call_tags = self._gathered_context.get("call_tags", [])
if effective_disposition not in call_tags:
call_tags.append(effective_disposition)
if call_disposition not in call_tags:
call_tags.append(call_disposition)
self._gathered_context["call_tags"] = call_tags
logger.debug(
f"Finishing run with reason: {reason}, disposition: {mapped_disposition} queueing frame {frame_to_push}"
f"Finishing run with reason: {reason}, disposition: {call_disposition} "
f"queueing frame {frame_to_push}"
)
await self.task.queue_frame(frame_to_push)

View file

@ -6,7 +6,7 @@ from datetime import UTC, datetime
from typing import Any
from fastapi.encoders import jsonable_encoder
from loguru import logger
from pipecat.bus.serializers.json import JSONMessageSerializer
from pipecat.frames.frames import (
BotStoppedSpeakingFrame,
CancelFrame,
@ -56,6 +56,46 @@ TEXT_CHAT_IDLE_SETTLE_SECONDS = 0.2
TEXT_CHAT_INTERNAL_CANCEL_REASON = "text_chat_turn_complete"
def _pipecat_type_tag(type_: type) -> str:
return f"{type_.__module__}.{type_.__name__}"
def _pipecat_json_serializer() -> JSONMessageSerializer:
return JSONMessageSerializer()
def _serialize_text_chat_checkpoint_messages(messages: list[Any]) -> list[Any]:
"""Serialize Pipecat context messages for JSONB checkpoint storage."""
# Pipecat's bus JSON serializer already knows how to preserve LLMContext,
# LLMSpecificMessage, and binary provider fields such as Gemini signatures.
# Keep the serializer shape dependency contained to these checkpoint helpers.
encoded_context = _pipecat_json_serializer()._serialize_value(
LLMContext(messages=list(messages))
)
encoded_data = (
encoded_context.get("__data__") if isinstance(encoded_context, dict) else None
)
encoded_messages = (
encoded_data.get("messages") if isinstance(encoded_data, dict) else None
)
if not isinstance(encoded_messages, list):
raise TypeError("Pipecat LLMContext serializer returned an unexpected shape")
return encoded_messages
def _deserialize_text_chat_checkpoint_messages(messages: list[Any]) -> list[Any]:
"""Restore JSONB checkpoint messages to Pipecat context message objects."""
restored_context = _pipecat_json_serializer()._deserialize_value(
{
"__type__": _pipecat_type_tag(LLMContext),
"__data__": {"messages": list(messages)},
}
)
if not isinstance(restored_context, LLMContext):
raise TypeError("Pipecat LLMContext deserializer returned an unexpected type")
return restored_context.get_messages()
def text_chat_trace_id(workflow_run_id: int) -> str:
"""Deterministic Langfuse trace id for a text-chat session.
@ -391,7 +431,6 @@ async def execute_text_chat_pending_turn(
if pending_turn.get("user_message") is not None
else None
)
workflow_run, _ = await db_client.get_workflow_run_with_context(workflow_run_id)
if not workflow_run or workflow_run.workflow_id != workflow_id:
raise ValueError("Workflow run not found for text chat execution")
@ -405,7 +444,6 @@ async def execute_text_chat_pending_turn(
)
if workflow is None:
raise ValueError("Workflow not found for text chat execution")
# Stamp the async context so OTEL spans are tagged with this org and routed
# to its Langfuse project (the voice paths do this in run_pipeline /
# webrtc_signaling; the text path previously skipped it, so its spans never
@ -426,7 +464,6 @@ async def execute_text_chat_pending_turn(
)
if user_config.llm is None:
raise ValueError("Text chat requires an LLM configuration")
from api.services.managed_model_services import (
MPS_CORRELATION_ID_CONTEXT_KEY,
ensure_mps_correlation_id,
@ -462,9 +499,10 @@ async def execute_text_chat_pending_turn(
skip_instance_constraints_for={"trigger"},
)
base_checkpoint = _resolve_checkpoint_for_pending_turn(session_data, checkpoint)
context = LLMContext()
context.set_messages(base_checkpoint["messages"])
context.set_messages(
_deserialize_text_chat_checkpoint_messages(base_checkpoint["messages"])
)
response_window = _ResponseWindowState()
capture_processor = _TextChatCaptureProcessor(response_window, context)
@ -511,7 +549,6 @@ async def execute_text_chat_pending_turn(
context_compaction_enabled = (workflow.workflow_configurations or {}).get(
"context_compaction_enabled", False
)
engine = PipecatEngine(
llm=llm,
inference_llm=inference_llm,
@ -557,7 +594,6 @@ async def execute_text_chat_pending_turn(
trace_span_attributes = {
"langfuse.trace.name": workflow_run.name or f"text-chat-{workflow_run_id}"
}
pipeline = Pipeline(
[
llm,
@ -634,20 +670,13 @@ async def execute_text_chat_pending_turn(
activity_marker=generation_marker,
)
finally:
if not task.has_finished():
await task.cancel(reason=TEXT_CHAT_INTERNAL_CANCEL_REASON)
try:
if not task.has_finished():
await task.cancel(reason=TEXT_CHAT_INTERNAL_CANCEL_REASON)
await runner_task
except Exception:
logger.exception(
"Transportless text chat pipeline failed while closing run {}",
workflow_run_id,
)
finally:
await engine.close_mcp_sessions()
await engine.cleanup()
raise
await engine.close_mcp_sessions()
await engine.cleanup()
gathered_context = await engine.get_gathered_context()
assistant_text = (
@ -658,29 +687,36 @@ async def execute_text_chat_pending_turn(
assistant_created_at = datetime.now(UTC).isoformat()
usage = pipeline_metrics_aggregator.get_all_usage_metrics_serialized()
current_node = getattr(engine, "_current_node", None)
context_messages = context.get_messages()
encoded_messages = _serialize_text_chat_checkpoint_messages(context_messages)
encoded_gathered_context = jsonable_encoder(gathered_context)
encoded_tool_state = jsonable_encoder(base_checkpoint.get("tool_state") or {})
updated_checkpoint = {
"version": TEXT_CHAT_CHECKPOINT_VERSION,
"anchor_turn_id": pending_turn.get("id"),
"current_node_id": current_node.id if current_node else None,
"messages": jsonable_encoder(context.get_messages()),
"gathered_context": jsonable_encoder(gathered_context),
"tool_state": jsonable_encoder(base_checkpoint.get("tool_state") or {}),
"messages": encoded_messages,
"gathered_context": encoded_gathered_context,
"tool_state": encoded_tool_state,
}
encoded_gathered_context = jsonable_encoder(gathered_context)
trace_url = get_trace_url(trace_id, org_id=workflow.organization_id)
if trace_url:
encoded_gathered_context = {**encoded_gathered_context, "trace_url": trace_url}
encoded_events = jsonable_encoder(capture_processor.events)
encoded_usage = jsonable_encoder(usage)
encoded_initial_context = jsonable_encoder(initial_context)
return TextChatTurnExecutionResult(
assistant_text=assistant_text,
assistant_created_at=assistant_created_at,
events=jsonable_encoder(capture_processor.events),
usage=jsonable_encoder(usage),
events=encoded_events,
usage=encoded_usage,
checkpoint=updated_checkpoint,
gathered_context=encoded_gathered_context,
initial_context=jsonable_encoder(initial_context),
initial_context=encoded_initial_context,
state=(
WorkflowRunState.COMPLETED.value
if engine.is_call_disposed()

View file

@ -13,8 +13,7 @@ from loguru import logger
from opentelemetry import trace
from api.db import db_client
from api.services.configuration.registry import ServiceProviders
from api.services.gen_ai import AzureOpenAIEmbeddingService, OpenAIEmbeddingService
from api.services.gen_ai import build_embedding_service
from api.services.pipecat.tracing_config import ensure_tracing
@ -266,33 +265,19 @@ async def _perform_retrieval(
"Model Configurations > Embedding."
)
if (
embeddings_provider == ServiceProviders.AZURE.value
and embeddings_endpoint
):
embedding_service = AzureOpenAIEmbeddingService(
db_client=db_client,
api_key=embeddings_api_key,
endpoint=embeddings_endpoint,
model_id=embeddings_model or "text-embedding-3-small",
api_version=embeddings_api_version or "2024-02-15-preview",
)
else:
default_headers = None
if (
embeddings_provider == ServiceProviders.DOGRAH.value
and correlation_id
):
default_headers = {
"X-Dograh-Correlation-Id": correlation_id,
}
embedding_service = OpenAIEmbeddingService(
db_client=db_client,
api_key=embeddings_api_key,
model_id=embeddings_model or "text-embedding-3-small",
base_url=embeddings_base_url,
default_headers=default_headers,
)
# Search runs inside a workflow run: reuse the run's MPS correlation
# id (present only for v2 orgs; None otherwise → sent without the
# protocol). The Dograh-managed path forwards it via request metadata.
embedding_service = await build_embedding_service(
db_client=db_client,
provider=embeddings_provider,
api_key=embeddings_api_key,
model=embeddings_model,
base_url=embeddings_base_url,
endpoint=embeddings_endpoint,
api_version=embeddings_api_version,
correlation_id=correlation_id,
)
results = await embedding_service.search_similar_chunks(
query=query,

View file

@ -12,12 +12,28 @@ from loguru import logger
from api.db import db_client
from api.db.models import KnowledgeBaseChunkModel
from api.services.configuration.registry import ServiceProviders
from api.services.gen_ai import AzureOpenAIEmbeddingService, OpenAIEmbeddingService
from api.services.gen_ai import build_embedding_service
from api.services.mps_service_key_client import mps_service_key_client
from api.services.storage import storage_fs
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
EMBEDDING_BATCH_SIZE = 64
async def _embed_texts_in_batches(
embedding_service,
texts: list[str],
batch_size: int = EMBEDDING_BATCH_SIZE,
) -> list[list[float]]:
"""Generate embeddings in bounded batches for provider/MPS stability."""
embeddings: list[list[float]] = []
for start in range(0, len(texts), batch_size):
batch = texts[start : start + batch_size]
logger.info(
f"Generating embedding batch {start // batch_size + 1} ({len(batch)} texts)"
)
embeddings.extend(await embedding_service.embed_texts(batch))
return embeddings
async def process_knowledge_base_document(
@ -121,42 +137,13 @@ async def process_knowledge_base_document(
mime_type=mime_type,
)
logger.info(f"Delegating document processing to MPS (mode={retrieval_mode})")
mps_response = await mps_service_key_client.process_document(
file_path=temp_file_path,
filename=filename,
content_type=mime_type or "application/octet-stream",
retrieval_mode=retrieval_mode,
max_tokens=max_tokens,
organization_id=organization_id,
created_by=created_by_provider_id,
)
docling_metadata = mps_response.get("docling_metadata", {})
if retrieval_mode == "full_document":
full_text = mps_response.get("full_text") or ""
await db_client.update_document_full_text(document_id, full_text)
await db_client.update_document_status(
document_id,
"completed",
total_chunks=0,
docling_metadata=docling_metadata,
)
logger.info(
f"Successfully processed full_document {document_id}. "
f"Text length: {len(full_text)} chars"
)
return
# Chunked mode: fetch user embedding config, embed, and persist chunks.
embeddings_provider = None
embeddings_api_key = None
embeddings_model = None
embeddings_base_url = None
embeddings_endpoint = None
embeddings_api_version = None
if document.created_by:
if retrieval_mode == "chunked" and document.created_by:
from api.services.configuration.ai_model_configuration import (
apply_managed_embeddings_base_url,
get_resolved_ai_model_configuration,
@ -188,6 +175,34 @@ async def process_knowledge_base_document(
f"model={embeddings_model}"
)
logger.info(f"Delegating document processing to MPS (mode={retrieval_mode})")
mps_response = await mps_service_key_client.process_document(
file_path=temp_file_path,
filename=filename,
content_type=mime_type or "application/octet-stream",
retrieval_mode=retrieval_mode,
max_tokens=max_tokens,
organization_id=organization_id,
created_by=created_by_provider_id,
)
docling_metadata = mps_response.get("docling_metadata", {})
if retrieval_mode == "full_document":
full_text = mps_response.get("full_text") or ""
await db_client.update_document_full_text(document_id, full_text)
await db_client.update_document_status(
document_id,
"completed",
total_chunks=0,
docling_metadata=docling_metadata,
)
logger.info(
f"Successfully processed full_document {document_id}. "
f"Text length: {len(full_text)} chars"
)
return
if not embeddings_api_key:
error_message = (
"API key not configured. Please set your API key in "
@ -199,21 +214,20 @@ async def process_knowledge_base_document(
)
return
if embeddings_provider == ServiceProviders.AZURE.value and embeddings_endpoint:
embedding_service = AzureOpenAIEmbeddingService(
db_client=db_client,
api_key=embeddings_api_key,
endpoint=embeddings_endpoint,
model_id=embeddings_model or "text-embedding-3-small",
api_version=embeddings_api_version or "2024-02-15-preview",
)
else:
embedding_service = OpenAIEmbeddingService(
db_client=db_client,
api_key=embeddings_api_key,
model_id=embeddings_model or "text-embedding-3-small",
base_url=embeddings_base_url,
)
# Ingestion runs outside any workflow run, so resolve the MPS correlation
# id here (mint only for orgs already on v2; never create an account).
embedding_service = await build_embedding_service(
db_client=db_client,
provider=embeddings_provider,
api_key=embeddings_api_key,
model=embeddings_model,
base_url=embeddings_base_url,
endpoint=embeddings_endpoint,
api_version=embeddings_api_version,
organization_id=organization_id,
created_by=created_by_provider_id,
resolve_correlation=True,
)
mps_chunks = mps_response.get("chunks", [])
if not mps_chunks:
@ -242,12 +256,21 @@ async def process_knowledge_base_document(
f"Generating embeddings for {len(chunk_texts)} chunks "
f"using {embedding_service.get_model_id()}"
)
embeddings = await embedding_service.embed_texts(chunk_texts)
embeddings = await _embed_texts_in_batches(embedding_service, chunk_texts)
if len(embeddings) != len(chunk_records):
raise ValueError(
"Embedding count mismatch: "
f"expected {len(chunk_records)}, got {len(embeddings)}"
)
for chunk_record, embedding in zip(chunk_records, embeddings):
chunk_record.embedding = embedding
logger.info("Storing chunks in database")
await db_client.create_chunks_batch(chunk_records)
await db_client.replace_chunks_for_document(
document_id=document_id,
organization_id=organization_id,
chunks=chunk_records,
)
await db_client.update_document_status(
document_id,
@ -262,9 +285,8 @@ async def process_knowledge_base_document(
)
except Exception as e:
logger.error(
f"Error processing knowledge base document {document_id}: {e}",
exc_info=True,
logger.exception(
"Error processing knowledge base document {}: {}", document_id, e
)
await db_client.update_document_status(
document_id, "failed", error_message=str(e)

View file

@ -436,6 +436,15 @@ async def _execute_webhook_node(
payload = render_template(webhook_data.payload_template or {}, render_context)
# Always surface the call disposition on the outgoing payload, even when the
# template author didn't reference it. Fill only if absent so a template that
# sets it explicitly keeps its own value.
if isinstance(payload, dict):
gathered_context = render_context.get("gathered_context") or {}
payload.setdefault(
"call_disposition", gathered_context.get("call_disposition", "")
)
method = (webhook_data.http_method or "POST").upper()
logger.info(f"Executing webhook '{webhook_name}': {method}")

View file

@ -160,14 +160,6 @@ def patch_run_pipeline_externals(
NoopFeedbackObserver,
)
)
# Disposition mapper would otherwise call out to the LLM.
stack.enter_context(
patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
new_callable=AsyncMock,
return_value="completed",
)
)
# Capture the PipelineWorker so the test can drive it from outside.
stack.enter_context(
patch(

View file

@ -11,8 +11,9 @@ from unittest.mock import AsyncMock, patch
import pytest
from starlette.requests import Request
from api.enums import TelephonyCallStatus
from api.services.telephony.providers.cloudonix.provider import CloudonixProvider
from api.services.telephony.providers.cloudonix.routes import handle_cloudonix_cdr
from api.services.telephony.status_processor import StatusCallbackRequest
def _json_request(body: bytes) -> Request:
@ -79,33 +80,33 @@ async def test_cdr_route_handles_string_session():
assert result == {"status": "error", "message": "Missing call_id field"}
def test_from_cloudonix_cdr_tolerates_missing_session_and_disposition():
"""``from_cloudonix_cdr`` must not crash on a partial CDR payload."""
def test_parse_cloudonix_cdr_tolerates_missing_session_and_disposition():
"""Cloudonix CDR parsing must not crash on a partial payload."""
# Missing both session and disposition.
req = StatusCallbackRequest.from_cloudonix_cdr({"domain": "acme.cloudonix.io"})
assert req.call_id == ""
assert req.status == ""
req = CloudonixProvider.parse_cdr_status_callback({"domain": "acme.cloudonix.io"})
assert req["call_id"] == ""
assert req["status"] == ""
# Explicit null values.
req = StatusCallbackRequest.from_cloudonix_cdr(
req = CloudonixProvider.parse_cdr_status_callback(
{"session": None, "disposition": None}
)
assert req.call_id == ""
assert req.status == ""
assert req["call_id"] == ""
assert req["status"] == ""
def test_from_cloudonix_cdr_tolerates_string_session():
"""``from_cloudonix_cdr`` treats a non-object session as missing call_id."""
req = StatusCallbackRequest.from_cloudonix_cdr(
def test_parse_cloudonix_cdr_tolerates_string_session():
"""Cloudonix CDR parsing treats a non-object session as missing call_id."""
req = CloudonixProvider.parse_cdr_status_callback(
{"session": "abc", "disposition": "ANSWER"}
)
assert req.call_id == ""
assert req.status == "completed"
assert req["call_id"] == ""
assert req["status"] == TelephonyCallStatus.COMPLETED
def test_from_cloudonix_cdr_maps_disposition_and_session_token():
def test_parse_cloudonix_cdr_maps_disposition_and_session_token():
"""Normal, well-formed CDR payloads still map correctly."""
req = StatusCallbackRequest.from_cloudonix_cdr(
req = CloudonixProvider.parse_cdr_status_callback(
{
"session": {"token": "abc123"},
"disposition": "BUSY",
@ -114,6 +115,20 @@ def test_from_cloudonix_cdr_maps_disposition_and_session_token():
"billsec": 12,
}
)
assert req.call_id == "abc123"
assert req.status == "busy"
assert req.duration == "12"
assert req["call_id"] == "abc123"
assert req["status"] == TelephonyCallStatus.BUSY
assert req["duration"] == "12"
def test_parse_cloudonix_cdr_preserves_zero_billsec():
"""A zero billed duration must not fall back to total call duration."""
req = CloudonixProvider.parse_cdr_status_callback(
{
"session": {"token": "abc123"},
"disposition": "ANSWER",
"billsec": 0,
"duration": 42,
}
)
assert req["duration"] == "0"

View file

@ -0,0 +1,98 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from api.enums import TelephonyCallStatus, WorkflowRunState
from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
)
from api.tasks.function_names import FunctionNames
@pytest.mark.asyncio
async def test_initialized_no_answer_enqueues_workflow_completion():
workflow_run = SimpleNamespace(
id=123,
campaign_id=None,
queued_run_id=None,
state=WorkflowRunState.INITIALIZED.value,
is_completed=False,
logs={"telephony_status_callbacks": []},
gathered_context={"call_tags": ["existing"]},
)
status = StatusCallbackRequest(
call_id="call-123",
status="No-Answer",
)
with (
patch("api.services.telephony.status_processor.db_client") as mock_db,
patch(
"api.services.telephony.status_processor.enqueue_job",
new_callable=AsyncMock,
) as mock_enqueue,
):
mock_db.get_workflow_run_by_id = AsyncMock(return_value=workflow_run)
mock_db.update_workflow_run = AsyncMock()
await _process_status_update(123, status)
log_update = mock_db.update_workflow_run.await_args_list[0].kwargs
callback_log = log_update["logs"]["telephony_status_callbacks"][0]
assert callback_log["status"] == "no-answer"
assert callback_log["call_id"] == "call-123"
completion_update = mock_db.update_workflow_run.await_args_list[1].kwargs
assert completion_update["run_id"] == 123
assert completion_update["is_completed"] is True
assert completion_update["state"] == WorkflowRunState.COMPLETED.value
assert completion_update["usage_info"] == {"call_duration_seconds": 0}
assert completion_update["gathered_context"] == {
"call_tags": ["existing", "not_connected", "telephony_no-answer"],
"call_disposition": "no-answer",
"mapped_call_disposition": "no-answer",
"call_id": "call-123",
}
mock_enqueue.assert_awaited_once_with(
FunctionNames.RUN_INTEGRATIONS_POST_WORKFLOW_RUN, 123
)
@pytest.mark.asyncio
async def test_running_terminal_status_does_not_enqueue_workflow_completion():
workflow_run = SimpleNamespace(
id=456,
campaign_id=None,
queued_run_id=None,
state=WorkflowRunState.RUNNING.value,
is_completed=False,
logs={"telephony_status_callbacks": []},
gathered_context={"call_tags": ["not_connected"]},
)
status = StatusCallbackRequest(
call_id="call-456",
status=TelephonyCallStatus.FAILED,
duration="7",
)
with (
patch("api.services.telephony.status_processor.db_client") as mock_db,
patch(
"api.services.telephony.status_processor.enqueue_job",
new_callable=AsyncMock,
) as mock_enqueue,
):
mock_db.get_workflow_run_by_id = AsyncMock(return_value=workflow_run)
mock_db.update_workflow_run = AsyncMock()
await _process_status_update(456, status)
completion_update = mock_db.update_workflow_run.await_args_list[1].kwargs
assert "usage_info" not in completion_update
assert completion_update["gathered_context"]["call_tags"] == [
"not_connected",
"telephony_failed",
]
mock_enqueue.assert_not_awaited()

View file

@ -76,6 +76,34 @@ def _signature(
return validator.compute_signature(url, form_data)
def test_twilio_provider_applies_answering_machine_detection_params():
provider = TwilioProvider(
{
"account_sid": "AC123",
"auth_token": "twilio-auth-token",
"from_numbers": ["+15551230002"],
"amd_enabled": True,
}
)
data = provider.apply_answering_machine_detection_call_params({"To": "+1555"})
assert provider.supports_answering_machine_detection() is True
assert data["MachineDetection"] == "Enable"
def test_twilio_provider_parses_answering_machine_detection_result():
provider = _provider()
result = provider.parse_answering_machine_detection_result(
{"CallSid": "CA123", "AnsweredBy": "machine_start"}
)
assert result is not None
assert result.call_id == "CA123"
assert result.answered_by == "machine_start"
@pytest.mark.asyncio
async def test_twiml_route_accepts_valid_signature_with_extra_query_param():
provider = _provider()
@ -251,3 +279,106 @@ async def test_twilio_status_callback_accepts_valid_signature():
assert result == {"status": "success"}
process_status.assert_awaited_once()
@pytest.mark.asyncio
async def test_twilio_status_callback_persists_answering_machine_detection_result():
provider = _provider()
form_data = {
"CallSid": "CA123",
"CallStatus": "completed",
"AnsweredBy": "machine_start",
}
request = _request(
path="/api/v1/telephony/twilio/status-callback/123",
query={},
form_data=form_data,
headers={
"x-twilio-signature": _signature(
provider,
path="/api/v1/telephony/twilio/status-callback/123",
query={},
form_data=form_data,
)
},
)
with (
patch("api.services.telephony.providers.twilio.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch(
"api.services.telephony.providers.twilio.routes._process_status_update",
new_callable=AsyncMock,
),
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(workflow_id=7)
)
db_client.get_workflow_by_id = AsyncMock(
return_value=SimpleNamespace(organization_id=11)
)
db_client.update_workflow_run = AsyncMock()
result = await handle_twilio_status_callback(
workflow_run_id=123, request=request
)
assert result == {"status": "success"}
db_client.update_workflow_run.assert_awaited_once_with(
run_id=123,
gathered_context={"answered_by": "machine_start"},
)
@pytest.mark.asyncio
async def test_twilio_status_callback_continues_when_amd_persistence_fails():
provider = _provider()
form_data = {
"CallSid": "CA123",
"CallStatus": "completed",
"AnsweredBy": "machine_start",
}
request = _request(
path="/api/v1/telephony/twilio/status-callback/123",
query={},
form_data=form_data,
headers={
"x-twilio-signature": _signature(
provider,
path="/api/v1/telephony/twilio/status-callback/123",
query={},
form_data=form_data,
)
},
)
with (
patch("api.services.telephony.providers.twilio.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch(
"api.services.telephony.providers.twilio.routes._process_status_update",
new_callable=AsyncMock,
) as process_status,
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(workflow_id=7)
)
db_client.get_workflow_by_id = AsyncMock(
return_value=SimpleNamespace(organization_id=11)
)
db_client.update_workflow_run = AsyncMock(side_effect=RuntimeError("db down"))
result = await handle_twilio_status_callback(
workflow_run_id=123, request=request
)
assert result == {"status": "success"}
process_status.assert_awaited_once()

View file

@ -55,7 +55,7 @@ def test_dograh_v2_compiles_to_effective_managed_pipeline_with_embeddings():
assert effective.stt.provider == "dograh"
assert effective.stt.language == "multi"
assert effective.embeddings.provider == "dograh"
assert effective.embeddings.model == "default"
assert effective.embeddings.model == "dograh_embedding_v1"
assert effective.managed_service_version == 2
@ -132,7 +132,7 @@ async def test_byok_realtime_validator_does_not_require_stt_or_tts():
"llm": {
"provider": "google",
"api_key": "google-llm-key",
"model": "gemini-2.0-flash",
"model": "gemini-2.5-flash",
},
},
},
@ -154,7 +154,7 @@ async def test_pipeline_validator_requires_stt_and_tts_when_not_realtime():
llm=GoogleLLMService(
provider="google",
api_key="google-llm-key",
model="gemini-2.0-flash",
model="gemini-2.5-flash",
),
realtime=GoogleRealtimeLLMConfiguration(
provider="google_realtime",

View file

@ -0,0 +1,162 @@
"""Tests for the Dograh-managed embedding service and its correlation resolver."""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from api.services.gen_ai.embedding.dograh_service import DograhEmbeddingService
from api.services.gen_ai.embedding.factory import resolve_embedding_correlation_id
def _service_with_fake_client(correlation_id):
service = DograhEmbeddingService(
db_client=None,
api_key="sk-test",
model_id="text-embedding-3-small",
base_url=None,
correlation_id=correlation_id,
)
create = AsyncMock(
return_value=SimpleNamespace(data=[SimpleNamespace(embedding=[0.1, 0.2])])
)
service.client = SimpleNamespace(embeddings=SimpleNamespace(create=create))
return service, create
@pytest.mark.asyncio
async def test_dograh_embedding_forwards_v2_protocol_when_correlation_present():
service, create = _service_with_fake_client("corr-123")
await service.embed_texts(["hello"])
create.assert_awaited_once()
kwargs = create.await_args.kwargs
assert kwargs["input"] == ["hello"]
assert kwargs["model"] == "text-embedding-3-small"
assert kwargs["extra_body"] == {
"metadata": {
"correlation_id": "corr-123",
"mps_billing_version": "2",
}
}
@pytest.mark.asyncio
async def test_dograh_embedding_sends_plain_without_correlation():
service, create = _service_with_fake_client(None)
await service.embed_texts(["hello"])
create.assert_awaited_once()
# No correlation id (e.g. a v1 org) → no MPS metadata; MPS accepts plain calls.
assert "extra_body" not in create.await_args.kwargs
def _fake_mps_client(*, status_return=None, minted="minted"):
return SimpleNamespace(
get_billing_account_status=AsyncMock(return_value=status_return),
create_correlation_id=AsyncMock(return_value={"correlation_id": minted}),
)
@pytest.mark.asyncio
async def test_resolve_correlation_oss_mints_directly(monkeypatch):
fake = _fake_mps_client()
monkeypatch.setattr(
"api.services.mps_service_key_client.mps_service_key_client", fake
)
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "oss")
result = await resolve_embedding_correlation_id(
organization_id=None, service_key="sk-mps"
)
assert result == "minted"
fake.create_correlation_id.assert_awaited_once_with(service_key="sk-mps")
fake.get_billing_account_status.assert_not_awaited()
@pytest.mark.asyncio
async def test_resolve_correlation_hosted_v2_mints(monkeypatch):
fake = _fake_mps_client(status_return={"billing_mode": "v2"})
monkeypatch.setattr(
"api.services.mps_service_key_client.mps_service_key_client", fake
)
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
result = await resolve_embedding_correlation_id(
organization_id=42, service_key="sk-mps", created_by="user-1"
)
assert result == "minted"
fake.get_billing_account_status.assert_awaited_once_with(42, created_by="user-1")
fake.create_correlation_id.assert_awaited_once_with(service_key="sk-mps")
@pytest.mark.asyncio
async def test_resolve_correlation_hosted_v1_returns_none_without_minting(monkeypatch):
fake = _fake_mps_client(status_return={"billing_mode": "v1"})
monkeypatch.setattr(
"api.services.mps_service_key_client.mps_service_key_client", fake
)
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
result = await resolve_embedding_correlation_id(
organization_id=42, service_key="sk-mps"
)
assert result is None
fake.create_correlation_id.assert_not_awaited()
@pytest.mark.asyncio
async def test_resolve_correlation_hosted_no_account_returns_none(monkeypatch):
fake = _fake_mps_client(status_return=None)
monkeypatch.setattr(
"api.services.mps_service_key_client.mps_service_key_client", fake
)
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
result = await resolve_embedding_correlation_id(
organization_id=42, service_key="sk-mps"
)
assert result is None
fake.create_correlation_id.assert_not_awaited()
@pytest.mark.asyncio
async def test_resolve_correlation_no_service_key_returns_none(monkeypatch):
fake = _fake_mps_client(status_return={"billing_mode": "v2"})
monkeypatch.setattr(
"api.services.mps_service_key_client.mps_service_key_client", fake
)
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
result = await resolve_embedding_correlation_id(
organization_id=42, service_key=None
)
assert result is None
fake.get_billing_account_status.assert_not_awaited()
fake.create_correlation_id.assert_not_awaited()
@pytest.mark.asyncio
async def test_resolve_correlation_swallows_errors(monkeypatch):
fake = SimpleNamespace(
get_billing_account_status=AsyncMock(side_effect=RuntimeError("mps down")),
create_correlation_id=AsyncMock(),
)
monkeypatch.setattr(
"api.services.mps_service_key_client.mps_service_key_client", fake
)
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
# A transient MPS failure must not break embeddings — fall back to no protocol.
result = await resolve_embedding_correlation_id(
organization_id=42, service_key="sk-mps"
)
assert result is None

View file

@ -0,0 +1,107 @@
from types import SimpleNamespace
from unittest.mock import patch
from pipecat.services.settings import NOT_GIVEN
from pipecat.transcriptions.language import Language
from api.services.configuration.registry import ServiceProviders
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.service_factory import (
create_stt_service,
dograh_stt_uses_flux_language,
stt_uses_flux_turns,
)
def _audio_config() -> AudioConfig:
return AudioConfig(
transport_in_sample_rate=16000,
transport_out_sample_rate=16000,
)
def _dograh_config(language: str | None) -> SimpleNamespace:
return SimpleNamespace(
stt=SimpleNamespace(
provider=ServiceProviders.DOGRAH.value,
api_key="mps-key",
model="default",
language=language,
)
)
def test_dograh_flux_language_predicate_matches_multilingual_support():
assert dograh_stt_uses_flux_language(None)
assert dograh_stt_uses_flux_language("multi")
assert dograh_stt_uses_flux_language("es")
assert not dograh_stt_uses_flux_language("ar")
def test_stt_uses_flux_turns_only_for_dograh_flux_supported_languages():
assert stt_uses_flux_turns(_dograh_config("multi"))
assert stt_uses_flux_turns(_dograh_config("es"))
assert not stt_uses_flux_turns(_dograh_config("ar"))
def test_create_dograh_multi_uses_flux_service_without_language_hint():
user_config = _dograh_config("multi")
with (
patch(
"api.services.pipecat.service_factory.DograhFluxSTTService"
) as flux_service,
patch("api.services.pipecat.service_factory.DograhSTTService") as stt_service,
):
create_stt_service(user_config, _audio_config(), correlation_id="corr-123")
flux_service.assert_called_once()
stt_service.assert_not_called()
kwargs = flux_service.call_args.kwargs
assert kwargs["correlation_id"] == "corr-123"
assert kwargs["settings"].model == "flux-general-multi"
assert kwargs["settings"].language_hints is NOT_GIVEN
def test_create_dograh_supported_language_uses_flux_service_with_hint():
user_config = _dograh_config("es")
with (
patch(
"api.services.pipecat.service_factory.DograhFluxSTTService"
) as flux_service,
patch("api.services.pipecat.service_factory.DograhSTTService") as stt_service,
):
create_stt_service(user_config, _audio_config(), keyterms=["Dograh"])
flux_service.assert_called_once()
stt_service.assert_not_called()
kwargs = flux_service.call_args.kwargs
assert kwargs["settings"].model == "flux-general-multi"
assert kwargs["settings"].language_hints == [Language.ES]
assert kwargs["settings"].keyterm == ["Dograh"]
def test_create_dograh_unsupported_language_falls_back_to_standard_stt_service():
user_config = _dograh_config("ar")
with (
patch(
"api.services.pipecat.service_factory.DograhFluxSTTService"
) as flux_service,
patch("api.services.pipecat.service_factory.DograhSTTService") as stt_service,
):
create_stt_service(
user_config,
_audio_config(),
keyterms=["Dograh"],
correlation_id="corr-123",
)
flux_service.assert_not_called()
stt_service.assert_called_once()
kwargs = stt_service.call_args.kwargs
assert kwargs["correlation_id"] == "corr-123"
assert kwargs["settings"].model == "default"
assert kwargs["settings"].language == "ar"
assert kwargs["keyterms"] == ["Dograh"]

View file

@ -0,0 +1,160 @@
from unittest.mock import patch
from google.genai.types import GenerateContentConfig, LiveConnectConfig
from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from api.services.configuration.registry import ServiceProviders
from api.services.pipecat.gemini_json_schema_adapter import (
DograhGeminiJSONSchemaAdapter,
)
from api.services.pipecat.realtime.gemini_live import DograhGeminiLiveLLMService
from api.services.pipecat.realtime.gemini_live_vertex import (
DograhGeminiLiveVertexLLMService,
)
from api.services.pipecat.service_factory import (
DograhGoogleLLMService,
DograhGoogleVertexLLMService,
create_llm_service_from_provider,
)
def test_gemini_tools_use_json_schema_parameters_for_external_schemas():
function_schema = FunctionSchema(
name="customer_lookup",
description="Look up a customer by email.",
properties={
"customerEmail": {
"description": "Customer email address",
"anyOf": [
{"anyOf": [{"not": {}}]},
{"const": ""},
],
},
"metadata": {
"type": "object",
"additionalProperties": {"type": "string"},
},
},
required=["customerEmail"],
)
tools = DograhGeminiJSONSchemaAdapter().to_provider_tools_format(
ToolsSchema(standard_tools=[function_schema])
)
declaration = tools[0]["function_declarations"][0]
assert "parameters" not in declaration
assert (
declaration["parameters_json_schema"]["properties"]["customerEmail"]["anyOf"][
0
]["anyOf"][0]["not"]
== {}
)
assert (
declaration["parameters_json_schema"]["properties"]["customerEmail"]["anyOf"][
1
]["const"]
== ""
)
assert declaration["parameters_json_schema"]["properties"]["metadata"][
"additionalProperties"
] == {"type": "string"}
GenerateContentConfig(tools=tools)
def test_gemini_tools_use_json_schema_parameters_for_no_argument_tools():
function_schema = FunctionSchema(
name="refresh_context",
description="Refresh the current context.",
properties={},
required=[],
)
tools = DograhGeminiJSONSchemaAdapter().to_provider_tools_format(
ToolsSchema(standard_tools=[function_schema])
)
declaration = tools[0]["function_declarations"][0]
assert "parameters" not in declaration
assert declaration["parameters_json_schema"] == {
"type": "object",
"properties": {},
"required": [],
}
GenerateContentConfig(tools=tools)
def test_google_service_classes_use_dograh_gemini_adapter_class():
assert DograhGoogleLLMService.adapter_class is DograhGeminiJSONSchemaAdapter
assert DograhGoogleVertexLLMService.adapter_class is DograhGeminiJSONSchemaAdapter
def test_google_llm_service_factory_uses_dograh_service_class():
with patch(
"api.services.pipecat.service_factory.DograhGoogleLLMService",
) as mock_service:
result = create_llm_service_from_provider(
provider=ServiceProviders.GOOGLE.value,
model="gemini-2.5-flash",
api_key="test-api-key",
)
assert result is mock_service.return_value
assert mock_service.call_args.kwargs["api_key"] == "test-api-key"
assert mock_service.call_args.kwargs["settings"].model == "gemini-2.5-flash"
def test_google_vertex_llm_service_factory_uses_dograh_service_class():
with patch(
"api.services.pipecat.service_factory.DograhGoogleVertexLLMService",
) as mock_service:
result = create_llm_service_from_provider(
provider=ServiceProviders.GOOGLE_VERTEX.value,
model="gemini-2.5-pro",
api_key=None,
project_id="demo-project",
location="us-central1",
credentials='{"type":"service_account"}',
)
assert result is mock_service.return_value
assert mock_service.call_args.kwargs["project_id"] == "demo-project"
assert mock_service.call_args.kwargs["location"] == "us-central1"
assert mock_service.call_args.kwargs["settings"].model == "gemini-2.5-pro"
def test_gemini_live_service_classes_use_dograh_gemini_adapter_class():
assert DograhGeminiLiveLLMService.adapter_class is DograhGeminiJSONSchemaAdapter
# Vertex Live inherits adapter_class from DograhGeminiLiveLLMService via MRO.
assert (
DograhGeminiLiveVertexLLMService.adapter_class is DograhGeminiJSONSchemaAdapter
)
def test_gemini_live_config_accepts_json_schema_tools():
function_schema = FunctionSchema(
name="customer_lookup",
description="Look up a customer by email.",
properties={
"customerEmail": {
"description": "Customer email address",
"anyOf": [{"not": {}}, {"const": ""}],
},
},
required=["customerEmail"],
)
tools = DograhGeminiJSONSchemaAdapter().to_provider_tools_format(
ToolsSchema(standard_tools=[function_schema])
)
declaration = tools[0]["function_declarations"][0]
assert "parameters" not in declaration
assert "parameters_json_schema" in declaration
# Gemini Live validates tools through LiveConnectConfig rather than
# GenerateContentConfig; it must also accept the raw JSON Schema payload.
LiveConnectConfig(tools=tools)

View file

@ -34,7 +34,7 @@ class TestGoogleVertexLLMConfiguration:
class TestGoogleVertexLLMServiceFactory:
def test_create_llm_service_from_provider_uses_vertex_service(self):
with patch(
"api.services.pipecat.service_factory.GoogleVertexLLMService"
"api.services.pipecat.service_factory.DograhGoogleVertexLLMService"
) as mock_service:
create_llm_service_from_provider(
provider=ServiceProviders.GOOGLE_VERTEX.value,
@ -65,7 +65,7 @@ class TestGoogleVertexLLMServiceFactory:
)
with patch(
"api.services.pipecat.service_factory.GoogleVertexLLMService"
"api.services.pipecat.service_factory.DograhGoogleVertexLLMService"
) as mock_service:
create_llm_service(user_config)

View file

@ -0,0 +1,26 @@
import pytest
from api.tasks.knowledge_base_processing import _embed_texts_in_batches
class FakeEmbeddingService:
def __init__(self):
self.calls = []
async def embed_texts(self, texts):
self.calls.append(list(texts))
return [[float(len(text))] for text in texts]
@pytest.mark.asyncio
async def test_embed_texts_in_batches_preserves_order():
service = FakeEmbeddingService()
embeddings = await _embed_texts_in_batches(
service,
["a", "bb", "ccc", "dddd", "eeeee"],
batch_size=2,
)
assert service.calls == [["a", "bb"], ["ccc", "dddd"], ["eeeee"]]
assert embeddings == [[1.0], [2.0], [3.0], [4.0], [5.0]]

View file

@ -66,7 +66,7 @@ class TestMaskedKeyRejection:
"llm": {
"provider": "google",
"api_key": MASKED_KEY,
"model": "gemini-2.0-flash",
"model": "gemini-2.5-flash",
}
},
)
@ -97,7 +97,7 @@ class TestMaskedKeyRejection:
"llm": {
"provider": "google",
"api_key": ["AIzaSyRealKey123456", MASKED_KEY],
"model": "gemini-2.0-flash",
"model": "gemini-2.5-flash",
}
},
)
@ -115,7 +115,7 @@ class TestMaskedKeyRejection:
llm=GoogleLLMService(
provider="google",
api_key=new_key,
model="gemini-2.0-flash",
model="gemini-2.5-flash",
)
)
@ -135,7 +135,7 @@ class TestMaskedKeyRejection:
"llm": {
"provider": "google",
"api_key": new_key,
"model": "gemini-2.0-flash",
"model": "gemini-2.5-flash",
}
},
)

View file

@ -126,22 +126,17 @@ async def run_pipeline_and_capture_context(
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
new_callable=AsyncMock,
return_value="completed",
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.gather(run_pipeline(), initialize_engine())
await asyncio.gather(run_pipeline(), initialize_engine())
return llm, context

View file

@ -268,28 +268,23 @@ class TestEndCallViaNodeTransition:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={"user_intent": "end call"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_intent": "end call"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.gather(run_pipeline(), initialize_engine())
await asyncio.gather(run_pipeline(), initialize_engine())
# Verify end_call_with_reason was called
assert len(test_helper.end_call_reasons) >= 1, (
@ -371,28 +366,23 @@ class TestEndCallViaNodeTransition:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={"greeting_type": "formal", "user_name": "John"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"greeting_type": "formal", "user_name": "John"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.gather(run_pipeline(), initialize_engine())
await asyncio.gather(run_pipeline(), initialize_engine())
# Should have 3 LLM generations
assert llm.get_current_step() == 3
@ -469,28 +459,23 @@ class TestEndCallViaCustomTool:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="end_call_tool",
return_value={"user_intent": "end"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_intent": "end"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.gather(run_pipeline(), initialize_engine())
await asyncio.gather(run_pipeline(), initialize_engine())
# Verify end_call_with_reason was called with END_CALL_TOOL_REASON
assert len(test_helper.end_call_reasons) >= 1, (
@ -560,28 +545,23 @@ class TestEndCallViaCustomTool:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="end_call_tool",
return_value={"user_intent": "end"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_intent": "end"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.gather(run_pipeline(), initialize_engine())
await asyncio.gather(run_pipeline(), initialize_engine())
# Verify end_call_with_reason was called
assert len(test_helper.end_call_reasons) >= 1, (
@ -637,37 +617,32 @@ class TestEndCallViaClientDisconnect:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="user_hangup",
return_value={"user_intent": "disconnected"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_intent": "disconnected"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_and_disconnect():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_and_disconnect():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Wait for initial generation to complete
await asyncio.sleep(0.1)
# Wait for initial generation to complete
await asyncio.sleep(0.1)
# Simulate client disconnect by calling end_call_with_reason directly
# This is what on_client_disconnected does
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
)
# Simulate client disconnect by calling end_call_with_reason directly
# This is what on_client_disconnected does
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
)
await asyncio.gather(run_pipeline(), initialize_and_disconnect())
await asyncio.gather(run_pipeline(), initialize_and_disconnect())
# Verify end_call_with_reason was called with USER_HANGUP
assert EndTaskReason.USER_HANGUP.value in test_helper.end_call_reasons, (
@ -727,46 +702,41 @@ class TestEndCallRaceConditions:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="first_reason",
return_value={"user_intent": "end"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_intent": "end"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_and_race():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_and_race():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Wait for initial generation
await asyncio.sleep(0.1)
# Wait for initial generation
await asyncio.sleep(0.1)
# Try to end call multiple times concurrently
await asyncio.gather(
engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
),
engine.end_call_with_reason(
EndTaskReason.END_CALL_TOOL_REASON.value,
abort_immediately=True,
),
engine.end_call_with_reason(
EndTaskReason.USER_QUALIFIED.value,
abort_immediately=False,
),
)
# Try to end call multiple times concurrently
await asyncio.gather(
engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
),
engine.end_call_with_reason(
EndTaskReason.END_CALL_TOOL_REASON.value,
abort_immediately=True,
),
engine.end_call_with_reason(
EndTaskReason.USER_QUALIFIED.value,
abort_immediately=False,
),
)
await asyncio.gather(run_pipeline(), initialize_and_race())
await asyncio.gather(run_pipeline(), initialize_and_race())
# Due to the _call_disposed guard, only one end_call should fully execute
# The tracked end_call_reasons will show all attempted calls
@ -838,41 +808,34 @@ class TestEndCallRaceConditions:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="end_reason",
return_value={"user_intent": "end"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_intent": "end"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_and_race_disconnect():
nonlocal disconnect_called
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_and_race_disconnect():
nonlocal disconnect_called
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Wait for the end_call tool to be called
await asyncio.sleep(0.15)
# Wait for the end_call tool to be called
await asyncio.sleep(0.15)
# Simulate client disconnect racing with end_call tool
disconnect_called = True
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
)
await asyncio.gather(
run_pipeline(), initialize_and_race_disconnect()
# Simulate client disconnect racing with end_call tool
disconnect_called = True
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
)
await asyncio.gather(run_pipeline(), initialize_and_race_disconnect())
# Verify disconnect was attempted
assert disconnect_called, "Disconnect should have been called"
@ -933,40 +896,35 @@ class TestEndCallExtractionBehavior:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
new_callable=AsyncMock,
return_value="completed",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
side_effect=mock_extraction,
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
side_effect=mock_extraction,
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_and_end():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_and_end():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Wait for initial generation
await asyncio.sleep(0.1)
# Wait for initial generation
await asyncio.sleep(0.1)
# End the call
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
)
# End the call
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
)
# Verify extraction was awaited (synchronous)
assert extraction_completed.is_set(), (
"Extraction should have completed before end_call returned"
)
# Verify extraction was awaited (synchronous)
assert extraction_completed.is_set(), (
"Extraction should have completed before end_call returned"
)
await asyncio.gather(run_pipeline(), initialize_and_end())
await asyncio.gather(run_pipeline(), initialize_and_end())
# Verify synchronous extraction was used
sync_extractions = [
@ -1058,35 +1016,30 @@ class TestEndCallExtractionBehavior:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
new_callable=AsyncMock,
return_value="completed",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
extraction_mock,
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
extraction_mock,
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_and_end():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_and_end():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Wait for initial generation
await asyncio.sleep(0.1)
# Wait for initial generation
await asyncio.sleep(0.1)
# End the call
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
)
# End the call
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value, abort_immediately=True
)
await asyncio.gather(run_pipeline(), initialize_and_end())
await asyncio.gather(run_pipeline(), initialize_and_end())
# Extraction should have been called but the inner _perform_extraction
# should not have been called because extraction_enabled=False

View file

@ -281,24 +281,19 @@ class TestNodeSwitchWithUserSpeech:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
new_callable=AsyncMock,
return_value="completed",
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
# Start the LLM generation - user speech will be injected
# automatically when FunctionCallResultFrame #1 is seen
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
# Start the LLM generation - user speech will be injected
# automatically when FunctionCallResultFrame #1 is seen
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.gather(run_pipeline(), initialize_engine())
await asyncio.gather(run_pipeline(), initialize_engine())
# Total 4 generations out of which 1 was cancelled due to interruption
assert llm.get_current_step() == 4

View file

@ -117,24 +117,19 @@ async def run_pipeline_with_tool_calls(
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
new_callable=AsyncMock,
return_value="completed",
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
# Small delay to let runner start
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
# Small delay to let runner start
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Run both concurrently
await asyncio.gather(run_pipeline(), initialize_engine())
# Run both concurrently
await asyncio.gather(run_pipeline(), initialize_engine())
return llm, context

View file

@ -171,31 +171,26 @@ class TestTransitionFunctionMutesUser:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={"user_intent": "end call"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_intent": "end call"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.wait_for(
asyncio.gather(run_pipeline(), initialize_engine()),
timeout=10.0,
)
await asyncio.wait_for(
asyncio.gather(run_pipeline(), initialize_engine()),
timeout=10.0,
)
assert len(captured_states) == 1, (
f"Expected the transition function to be invoked exactly once, "
@ -245,31 +240,26 @@ class TestTransitionFunctionMutesUser:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={"user_intent": "end call"},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_intent": "end call"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.wait_for(
asyncio.gather(run_pipeline(), initialize_engine()),
timeout=10.0,
)
await asyncio.wait_for(
asyncio.gather(run_pipeline(), initialize_engine()),
timeout=10.0,
)
assert function_call_mute_strategy._function_call_in_progress == set(), (
"FunctionCallUserMuteStrategy should have cleared its in-progress "

View file

@ -156,29 +156,24 @@ class TestVariableExtractionDuringTransitions:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
# Mock the actual extraction to avoid needing a real LLM
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={"user_name": "John Doe"},
):
# Mock the actual extraction to avoid needing a real LLM
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={"user_name": "John Doe"},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.gather(run_pipeline(), initialize_engine())
await asyncio.gather(run_pipeline(), initialize_engine())
# Should have 3 LLM generations
assert llm.get_current_step() == 3

View file

@ -0,0 +1,88 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from api.services.workflow.dto import WebhookNodeData
from api.tasks.run_integrations import _execute_webhook_node
def _mock_httpx_client(captured: dict):
"""Build a patch target for httpx.AsyncClient that records the request kwargs."""
response = MagicMock()
response.status_code = 200
response.raise_for_status = MagicMock()
async def _request(**kwargs):
captured.update(kwargs)
return response
client = MagicMock()
client.request = AsyncMock(side_effect=_request)
ctx = MagicMock()
ctx.__aenter__ = AsyncMock(return_value=client)
ctx.__aexit__ = AsyncMock(return_value=False)
return MagicMock(return_value=ctx)
@pytest.mark.asyncio
async def test_webhook_injects_disposition_when_absent():
"""call_disposition is added to the payload when the template omits it."""
webhook = WebhookNodeData(
name="Test Webhook",
enabled=True,
endpoint_url="https://example.com/hook",
payload_template={"event": "call_done"},
)
render_context = {"gathered_context": {"call_disposition": "no-answer"}}
captured: dict = {}
with patch(
"api.tasks.run_integrations.httpx.AsyncClient", _mock_httpx_client(captured)
):
ok = await _execute_webhook_node(webhook, render_context, organization_id=1)
assert ok is True
assert captured["json"] == {
"event": "call_done",
"call_disposition": "no-answer",
}
@pytest.mark.asyncio
async def test_webhook_preserves_template_disposition():
"""A disposition key set explicitly in the template is not overwritten."""
webhook = WebhookNodeData(
name="Test Webhook",
enabled=True,
endpoint_url="https://example.com/hook",
payload_template={"call_disposition": "custom-from-template"},
)
render_context = {"gathered_context": {"call_disposition": "no-answer"}}
captured: dict = {}
with patch(
"api.tasks.run_integrations.httpx.AsyncClient", _mock_httpx_client(captured)
):
await _execute_webhook_node(webhook, render_context, organization_id=1)
assert captured["json"]["call_disposition"] == "custom-from-template"
@pytest.mark.asyncio
async def test_webhook_injects_empty_disposition_when_context_missing():
"""Missing gathered_context values fall back to an empty string, not omission."""
webhook = WebhookNodeData(
name="Test Webhook",
enabled=True,
endpoint_url="https://example.com/hook",
payload_template={},
)
captured: dict = {}
with patch(
"api.tasks.run_integrations.httpx.AsyncClient", _mock_httpx_client(captured)
):
await _execute_webhook_node(webhook, {}, organization_id=1)
assert captured["json"] == {"call_disposition": ""}

View file

@ -241,11 +241,6 @@ async def run_pipeline_and_capture_frames(
new_callable=AsyncMock,
return_value=1,
),
patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
new_callable=AsyncMock,
return_value="completed",
),
):
async def run():

View file

@ -208,63 +208,58 @@ class TestTTSPauseWithAudioWriteFailure:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_and_end_call():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
async def initialize_and_end_call():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
# Start LLM generation - this will trigger TTS
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Start LLM generation - this will trigger TTS
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Sleep so that processing is paused in TTS Service
await asyncio.sleep(0.1)
# Sleep so that processing is paused in TTS Service
await asyncio.sleep(0.1)
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value,
abort_immediately=False,
)
# Create tasks explicitly for better control
pipeline_task = asyncio.create_task(run_pipeline())
end_call_task = asyncio.create_task(initialize_and_end_call())
# Wait with timeout
done, pending = await asyncio.wait(
[pipeline_task, end_call_task],
timeout=3.0,
return_when=asyncio.ALL_COMPLETED,
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value,
abort_immediately=False,
)
# If there are pending tasks, we timed out
if pending:
test_timed_out = True
# Cancel all pending tasks
for t in pending:
t.cancel()
# Create tasks explicitly for better control
pipeline_task = asyncio.create_task(run_pipeline())
end_call_task = asyncio.create_task(initialize_and_end_call())
# Give limited time for cleanup
try:
await asyncio.wait_for(
asyncio.gather(*pending, return_exceptions=True),
timeout=1.0,
)
except asyncio.TimeoutError:
pass # Cleanup took too long, continue anyway
# Wait with timeout
done, pending = await asyncio.wait(
[pipeline_task, end_call_task],
timeout=3.0,
return_when=asyncio.ALL_COMPLETED,
)
# If there are pending tasks, we timed out
if pending:
test_timed_out = True
# Cancel all pending tasks
for t in pending:
t.cancel()
# Give limited time for cleanup
try:
await asyncio.wait_for(
asyncio.gather(*pending, return_exceptions=True),
timeout=1.0,
)
except asyncio.TimeoutError:
pass # Cleanup took too long, continue anyway
# Verify audio write was attempted but failed
output_transport = transport._output
@ -327,62 +322,57 @@ class TestTTSPauseWithAudioWriteFailure:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_and_observe():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
async def initialize_and_observe():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Sleep so that processing is paused in TTS Service
await asyncio.sleep(0.1)
# Sleep so that processing is paused in TTS Service
await asyncio.sleep(0.1)
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value,
abort_immediately=False,
)
# Create tasks explicitly for better control
pipeline_task = asyncio.create_task(run_pipeline())
end_call_task = asyncio.create_task(initialize_and_observe())
# Wait with timeout
done, pending = await asyncio.wait(
[pipeline_task, end_call_task],
timeout=3.0,
return_when=asyncio.ALL_COMPLETED,
await engine.end_call_with_reason(
EndTaskReason.USER_HANGUP.value,
abort_immediately=False,
)
# If there are pending tasks, we timed out
if pending:
test_timed_out = True
# Cancel all pending tasks
for t in pending:
t.cancel()
# Create tasks explicitly for better control
pipeline_task = asyncio.create_task(run_pipeline())
end_call_task = asyncio.create_task(initialize_and_observe())
# Give limited time for cleanup
try:
await asyncio.wait_for(
asyncio.gather(*pending, return_exceptions=True),
timeout=1.0,
)
except asyncio.TimeoutError:
pass # Cleanup took too long, continue anyway
# Wait with timeout
done, pending = await asyncio.wait(
[pipeline_task, end_call_task],
timeout=3.0,
return_when=asyncio.ALL_COMPLETED,
)
# If there are pending tasks, we timed out
if pending:
test_timed_out = True
# Cancel all pending tasks
for t in pending:
t.cancel()
# Give limited time for cleanup
try:
await asyncio.wait_for(
asyncio.gather(*pending, return_exceptions=True),
timeout=1.0,
)
except asyncio.TimeoutError:
pass # Cleanup took too long, continue anyway
# Verify some frames were written successfully before failure
output_transport = transport._output

View file

@ -261,22 +261,17 @@ class TestUserIdleHandler:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
new_callable=AsyncMock,
return_value="completed",
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
async def initialize_engine():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
await engine.llm.queue_frame(LLMContextFrame(engine.context))
await asyncio.gather(run_pipeline(), initialize_engine())
await asyncio.gather(run_pipeline(), initialize_engine())
# All 5 LLM steps should have been consumed
assert llm.get_current_step() == 5

View file

@ -247,50 +247,45 @@ class TestUserMutingDuringBotSpeech:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def run_test():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
async def run_test():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
# Trigger first LLM completion
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Trigger first LLM completion
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Wait for first bot started
await asyncio.wait_for(
observer.first_bot_started.wait(), timeout=5.0
)
# Queue user speaking frames so that second generation starts
await queue_user_speaking_and_transcript_frames(task)
# Wait for first bot stopped
await asyncio.wait_for(
observer.first_bot_stopped.wait(), timeout=5.0
)
await task.cancel()
await asyncio.gather(
run_pipeline(),
run_test(),
return_exceptions=True,
# Wait for first bot started
await asyncio.wait_for(
observer.first_bot_started.wait(), timeout=5.0
)
# Queue user speaking frames so that second generation starts
await queue_user_speaking_and_transcript_frames(task)
# Wait for first bot stopped
await asyncio.wait_for(
observer.first_bot_stopped.wait(), timeout=5.0
)
await task.cancel()
await asyncio.gather(
run_pipeline(),
run_test(),
return_exceptions=True,
)
# VERIFY: Muted at first BotStartedSpeaking
assert len(observer.mute_status_on_bot_started) >= 1
assert observer.mute_status_on_bot_started[0] is True, (
@ -337,55 +332,50 @@ class TestUserMutingDuringBotSpeech:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def run_test():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
async def run_test():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
# Trigger first LLM completion
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Trigger first LLM completion
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Wait for first bot stopped (first response complete)
await asyncio.wait_for(
observer.first_bot_stopped.wait(), timeout=5.0
)
# Queue user speaking frames for second generation
await queue_user_speaking_and_transcript_frames(task)
# Wait for second bot started
await asyncio.wait_for(
observer.second_bot_started.wait(), timeout=5.0
)
# Wait for second bot stopped
await asyncio.wait_for(
observer.second_bot_stopped.wait(), timeout=5.0
)
await task.cancel()
await asyncio.gather(
run_pipeline(),
run_test(),
return_exceptions=True,
# Wait for first bot stopped (first response complete)
await asyncio.wait_for(
observer.first_bot_stopped.wait(), timeout=5.0
)
# Queue user speaking frames for second generation
await queue_user_speaking_and_transcript_frames(task)
# Wait for second bot started
await asyncio.wait_for(
observer.second_bot_started.wait(), timeout=5.0
)
# Wait for second bot stopped
await asyncio.wait_for(
observer.second_bot_stopped.wait(), timeout=5.0
)
await task.cancel()
await asyncio.gather(
run_pipeline(),
run_test(),
return_exceptions=True,
)
# VERIFY: First bot started - should be muted (MuteUntilFirstBotComplete)
assert len(observer.mute_status_on_bot_started) >= 2
assert observer.mute_status_on_bot_started[0] is True, (
@ -432,55 +422,50 @@ class TestUserMutingDuringBotSpeech:
new_callable=AsyncMock,
return_value=1,
):
with patch(
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value="completed",
return_value={},
):
with patch.object(
VariableExtractionManager,
"_perform_extraction",
new_callable=AsyncMock,
return_value={},
):
async def run_pipeline():
await run_pipeline_worker(task)
async def run_pipeline():
await run_pipeline_worker(task)
async def run_test():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
async def run_test():
await asyncio.sleep(0.01)
await engine.initialize()
await engine.set_node(engine.workflow.start_node_id)
# Trigger first LLM completion
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Trigger first LLM completion
await engine.llm.queue_frame(LLMContextFrame(engine.context))
# Wait for first bot stopped (first response complete)
await asyncio.wait_for(
observer.first_bot_stopped.wait(), timeout=5.0
)
# Queue user speaking frames for second llm generation
await queue_user_speaking_and_transcript_frames(task)
# Wait for second bot started
await asyncio.wait_for(
observer.second_bot_started.wait(), timeout=5.0
)
# Wait for second bot stopped
await asyncio.wait_for(
observer.second_bot_stopped.wait(), timeout=5.0
)
await task.cancel()
await asyncio.gather(
run_pipeline(),
run_test(),
return_exceptions=True,
# Wait for first bot stopped (first response complete)
await asyncio.wait_for(
observer.first_bot_stopped.wait(), timeout=5.0
)
# Queue user speaking frames for second llm generation
await queue_user_speaking_and_transcript_frames(task)
# Wait for second bot started
await asyncio.wait_for(
observer.second_bot_started.wait(), timeout=5.0
)
# Wait for second bot stopped
await asyncio.wait_for(
observer.second_bot_stopped.wait(), timeout=5.0
)
await task.cancel()
await asyncio.gather(
run_pipeline(),
run_test(),
return_exceptions=True,
)
# VERIFY: First bot started - should be muted (MuteUntilFirstBotComplete)
assert len(observer.mute_status_on_bot_started) >= 2
assert observer.mute_status_on_bot_started[0] is True, (

View file

@ -1,10 +1,16 @@
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from pipecat.processors.aggregators.llm_context import LLMSpecificMessage
from api.db.models import OrganizationModel, UserModel
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
from api.services.workflow.text_chat_runner import (
_deserialize_text_chat_checkpoint_messages,
_serialize_text_chat_checkpoint_messages,
)
from api.tests.integrations._run_pipeline_helpers import USER_CONFIGURATION
from pipecat.tests import MockLLMService
@ -18,6 +24,49 @@ def _log_texts(logs: dict | None, event_type: str) -> list[str]:
]
def test_text_chat_checkpoint_messages_round_trip_google_thought_signature():
signature = bytes.fromhex("12340a32010c39d6c7f38fd8b8eb6ab0")
messages = [
{"role": "assistant", "content": "Hello."},
{
"role": "user",
"content": "Hi",
},
LLMSpecificMessage(
llm="google",
message={
"type": "thought_signature",
"signature": signature,
"bookmark": {"text": "Hello."},
},
),
]
encoded = _serialize_text_chat_checkpoint_messages(messages)
json.dumps(encoded)
assert encoded[-1] == {
"__specific__": True,
"llm": "google",
"message": {
"type": "thought_signature",
"signature": {
"__type__": "bytes",
"__data__": "EjQKMgEMOdbH84/YuOtqsA==",
},
"bookmark": {"text": "Hello."},
},
}
restored = _deserialize_text_chat_checkpoint_messages(encoded)
assert restored[:2] == messages[:2]
assert isinstance(restored[-1], LLMSpecificMessage)
assert restored[-1].llm == "google"
assert restored[-1].message["signature"] == signature
assert restored[-1].message["bookmark"] == {"text": "Hello."}
async def _create_user_and_workflow(
db_session,
async_session,

View file

@ -145,8 +145,27 @@ services:
# Redis configuration (using containerized redis)
REDIS_URL: "redis://:${REDIS_PASSWORD:-redissecret}@redis:6379"
# Storage configuration - using local MinIO
ENABLE_AWS_S3: "false"
# Storage configuration - bundled MinIO by default. Set ENABLE_AWS_S3=true
# in .env to make the API use AWS S3 or another S3-compatible server.
ENABLE_AWS_S3: "${ENABLE_AWS_S3:-false}"
# S3 backend configuration. Compose's .env file is used for interpolation,
# but those values are not automatically injected into containers, so pass
# the S3 settings through explicitly.
AWS_ACCESS_KEY_ID: "${AWS_ACCESS_KEY_ID:-}"
AWS_SECRET_ACCESS_KEY: "${AWS_SECRET_ACCESS_KEY:-}"
AWS_SESSION_TOKEN: "${AWS_SESSION_TOKEN:-}"
S3_BUCKET: "${S3_BUCKET:-}"
S3_REGION: "${S3_REGION:-us-east-1}"
# For a non-AWS S3-compatible server, also set these in Compose's .env
# S3_ENDPOINT_URL e.g. https://s3.example.com
# S3_SIGNATURE_VERSION set "s3v4" if the server requires SigV4 (e.g. rustfs)
# S3_ADDRESSING_STYLE set "path" if the server / TLS cert requires path-style
# The S3 backend issues real presigned URLs, so the bucket can stay private.
S3_ENDPOINT_URL: "${S3_ENDPOINT_URL:-}"
S3_SIGNATURE_VERSION: "${S3_SIGNATURE_VERSION:-}"
S3_ADDRESSING_STYLE: "${S3_ADDRESSING_STYLE:-}"
# MinIO
MINIO_ENDPOINT: "minio:9000"

File diff suppressed because one or more lines are too long

View file

@ -3,10 +3,12 @@ title: "LLM"
description: "Voice Agents use LLM (Large Language Models), which are trained to understand the conversational context, and respond to users."
---
You can currently use OpenAI, Google, Groq, Azure and Dograh LLMs in LLM configuration. There are some models provided by default for you to choose from the drop down.
Dograh platform supports OpenAI, Google AI Studio, Google Vertex AI, Azure OpenAI, AWS Bedrock, Groq, OpenRouter, Hugging Face, MiniMax, Sarvam, and Dograh-managed LLMs. There are some models provided by default for you to choose from the drop down.
For locally deployed or self-hosted LLMs, Dograh also supports OpenAI-compatible endpoints such as Ollama and vLLM.
![Select Models from DropDown](../images/models_dropdown.png)
If you don't find a model in the drop down, you can always add a model manually.
![Select Models from DropDown](../images/add_model_manually.png)
![Select Models from DropDown](../images/add_model_manually.png)

View file

@ -3,6 +3,8 @@ title: "Transcriber"
description: "Voice Agents use STT (Speech to Text), to transcribe what the user speaks. This transcribed speech as text goes into an LLM to generate the response that gets played out to the user."
---
Dograh platform ships with Deepgram, Cartesia, OpenAI and Dograh transcribers by default. You can take a look at the providers documentation of which language to select for your language requirements.
Dograh platform supports Deepgram, OpenAI, Google, Azure Speech, AssemblyAI, Speechmatics, Cartesia, Gladia, Sarvam, Smallest AI, Hugging Face, and Dograh transcribers. You can take a look at the providers documentation of which language to select for your language requirements.
Example: Deepgram has their language support documentation at https://developers.deepgram.com/docs/models-languages-overview#nova-3
For locally deployed or self-hosted STT models, Dograh also supports Speaches, an OpenAI API-compatible server for streaming transcription.
Example: Deepgram has their language support documentation at https://developers.deepgram.com/docs/models-languages-overview#nova-3

View file

@ -3,8 +3,10 @@ title: "Voice"
description: "Voice Agents use TTS (Text to Speech), which generates audio that LLMs generate during the course of a conversation. This is the audio that the end user having the conversation listens to."
---
Dograh platform ships with Elevenlabs, Deepgram, OpenAI and Dograh TTS engines by default. There are some voices from the providers that we ship by default. You can refer to the providers API documentation to select a voice ID thats most relevant for your language requirement.
Dograh platform supports ElevenLabs, OpenAI, Google, Azure Speech, Deepgram, Cartesia, Smallest AI, MiniMax, Sarvam, Rime, Inworld, Camb.ai, and Dograh TTS engines. There are some voices from the providers that we ship by default. You can refer to the providers API documentation to select a voice ID that's most relevant for your language requirement.
If you dont find your favourite voice, you can always add the voice ID manually.
For locally deployed or self-hosted TTS models, Dograh also supports Speaches, an OpenAI API-compatible server for speech generation.
![Add Voice Manually](../images/add_tts_manually.png)
If you don't find your favourite voice, you can always add the voice ID manually.
![Add Voice Manually](../images/add_tts_manually.png)

View file

@ -95,6 +95,32 @@ Dograh uses **MinIO by default**, which is bundled with the self-hosted deployme
| `ENABLE_AWS_S3` | `false` | Set to `true` to use AWS S3 instead of MinIO |
| `S3_BUCKET` | `null` | S3 bucket name |
| `S3_REGION` | `us-east-1` | AWS region |
| `S3_ENDPOINT_URL` | `null` | Custom S3 endpoint for S3-compatible servers (e.g. `https://s3.example.com`). Leave unset for AWS. |
| `S3_SIGNATURE_VERSION` | `null` | Signing version. Unset uses botocore's default; set `s3v4` for servers that require SigV4. |
| `S3_ADDRESSING_STYLE` | `null` | `auto` (default), `path`, or `virtual`. Many S3-compatible servers and TLS setups require `path`. |
Credentials come from the standard `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` environment variables.
#### S3-compatible servers (MinIO, rustfs, Ceph, ...)
The S3 backend can target any S3-compatible server, not just AWS. Prefer it over the MinIO backend when you need **presigned URLs against a private bucket**: the MinIO backend returns plain unsigned object URLs and relies on the bucket being anonymously public-readable, whereas the S3 backend issues real presigned URLs so the bucket can stay private.
To use it, set `ENABLE_AWS_S3=true` and point it at your server with the `S3_*` overrides above. For example, against [rustfs](https://github.com/rustfs/rustfs):
```bash
ENABLE_AWS_S3=true
S3_BUCKET=voice-audio
S3_REGION=us-east-1
S3_ENDPOINT_URL=https://s3.example.com
S3_SIGNATURE_VERSION=s3v4 # rustfs rejects SigV2 with SignatureDoesNotMatch
S3_ADDRESSING_STYLE=path # rustfs and most non-AWS TLS certs require path-style
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
```
<Note>
Presigned URLs point at `S3_ENDPOINT_URL`, so that host must be reachable from the browser. Because browsers fetch transcripts cross-origin, the bucket also needs a CORS rule allowing your app's origin for `GET`/`HEAD` — configure this on the storage server (e.g. via `PutBucketCors`), not in Dograh.
</Note>
---

@ -1 +1 @@
Subproject commit 85a48a37bf51e5b8854a85a2ff6319c67937b6fa
Subproject commit afe5b6b6c90ad04557ad93b69f7f6ba662c2072e

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.6F33jkClt9
# timestamp: 2026-06-19T12:41:10+00:00
# filename: dograh-openapi-XXXXXX.json.FJTeRc3I3o
# timestamp: 2026-06-25T16:50:38+00:00
from __future__ import annotations

172
ui/package-lock.json generated
View file

@ -716,7 +716,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -918,6 +917,7 @@
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
"license": "MIT",
"peer": true,
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
@ -1032,6 +1032,7 @@
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
"integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-module-imports": "^7.16.7",
"@babel/runtime": "^7.18.3",
@ -1050,13 +1051,15 @@
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@emotion/babel-plugin/node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -1066,6 +1069,7 @@
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
"integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@emotion/memoize": "^0.9.0",
"@emotion/sheet": "^1.4.0",
@ -1078,19 +1082,22 @@
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@emotion/memoize": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@emotion/react": {
"version": "11.14.0",
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -1115,6 +1122,7 @@
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
"integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@emotion/hash": "^0.9.2",
"@emotion/memoize": "^0.9.0",
@ -1127,19 +1135,22 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
"integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@emotion/unitless": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
"integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@emotion/use-insertion-effect-with-fallbacks": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
"integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": ">=16.8.0"
}
@ -1148,13 +1159,15 @@
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@emotion/weak-memoize": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
@ -2442,6 +2455,7 @@
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25"
@ -2680,7 +2694,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@ -2731,7 +2744,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz",
"integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/api-logs": "0.57.2",
"@types/shimmer": "^1.2.0",
@ -3403,7 +3415,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz",
"integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=14"
}
@ -8126,7 +8137,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -10010,7 +10020,6 @@
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
@ -10422,6 +10431,7 @@
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "*",
"@types/json-schema": "*"
@ -10432,6 +10442,7 @@
"resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz",
"integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint": "*",
"@types/estree": "*"
@ -10484,7 +10495,8 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/pg": {
"version": "8.6.1",
@ -10511,7 +10523,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz",
"integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -10522,7 +10533,6 @@
"integrity": "sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.0.0"
}
@ -10532,6 +10542,7 @@
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "*"
}
@ -11061,6 +11072,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
"integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/helper-numbers": "1.13.2",
"@webassemblyjs/helper-wasm-bytecode": "1.13.2"
@ -11070,25 +11082,29 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
"integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@webassemblyjs/helper-api-error": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
"integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@webassemblyjs/helper-buffer": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
"integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@webassemblyjs/helper-numbers": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz",
"integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/floating-point-hex-parser": "1.13.2",
"@webassemblyjs/helper-api-error": "1.13.2",
@ -11099,13 +11115,15 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
"integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@webassemblyjs/helper-wasm-section": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz",
"integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
@ -11118,6 +11136,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz",
"integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@xtuc/ieee754": "^1.2.0"
}
@ -11127,6 +11146,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz",
"integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@xtuc/long": "4.2.2"
}
@ -11135,13 +11155,15 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
"integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@webassemblyjs/wasm-edit": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz",
"integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
@ -11158,6 +11180,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz",
"integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
@ -11171,6 +11194,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz",
"integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-buffer": "1.14.1",
@ -11183,6 +11207,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz",
"integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@webassemblyjs/helper-api-error": "1.13.2",
@ -11197,6 +11222,7 @@
"resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz",
"integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@webassemblyjs/ast": "1.14.1",
"@xtuc/long": "4.2.2"
@ -11212,13 +11238,15 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
"integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/@xtuc/long": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
"license": "Apache-2.0"
"license": "Apache-2.0",
"peer": true
},
"node_modules/@xyflow/react": {
"version": "12.10.2",
@ -11285,7 +11313,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -11307,6 +11334,7 @@
"resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz",
"integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.13.0"
},
@ -11358,6 +11386,7 @@
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ajv": "^8.0.0"
},
@ -11375,6 +11404,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -11390,7 +11420,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/ansi-colors": {
"version": "4.1.3",
@ -11702,6 +11733,7 @@
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
"integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"cosmiconfig": "^7.0.0",
@ -11829,7 +11861,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@ -12034,6 +12065,7 @@
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
"integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.0"
}
@ -12292,6 +12324,7 @@
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
"integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/parse-json": "^4.0.0",
"import-fresh": "^3.2.1",
@ -12463,7 +12496,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -12806,6 +12838,7 @@
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
@ -12902,6 +12935,7 @@
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
"license": "MIT",
"peer": true,
"dependencies": {
"is-arrayish": "^0.2.1"
}
@ -12910,7 +12944,8 @@
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/es-abstract": {
"version": "1.23.9",
@ -13030,7 +13065,8 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
@ -13182,7 +13218,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -13356,7 +13391,6 @@
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.8",
@ -13644,6 +13678,7 @@
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.8.x"
}
@ -13749,7 +13784,8 @@
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/fast-xml-builder": {
"version": "1.1.4",
@ -13831,7 +13867,8 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/find-up": {
"version": "5.0.0",
@ -14083,7 +14120,8 @@
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
"license": "BSD-2-Clause"
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/globals": {
"version": "14.0.0",
@ -14297,7 +14335,6 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@ -14907,6 +14944,7 @@
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
"integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*",
"merge-stream": "^2.0.0",
@ -14921,6 +14959,7 @@
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"has-flag": "^4.0.0"
},
@ -15010,7 +15049,8 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
@ -15342,13 +15382,15 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/loader-runner": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.11.5"
},
@ -15429,13 +15471,15 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/merge2": {
"version": "1.4.1",
@ -15466,6 +15510,7 @@
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
@ -15475,6 +15520,7 @@
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "1.52.0"
},
@ -15572,14 +15618,14 @@
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/next": {
"version": "15.5.14",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz",
"integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@next/env": "15.5.14",
"@swc/helpers": "0.5.15",
@ -16007,6 +16053,7 @@
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
@ -16081,6 +16128,7 @@
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@ -16523,7 +16571,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -16554,7 +16601,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -16581,7 +16627,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz",
"integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -16606,15 +16651,13 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@ -16754,6 +16797,7 @@
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
@ -16819,8 +16863,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@ -16858,7 +16901,8 @@
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
@ -16895,6 +16939,7 @@
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -16979,7 +17024,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@ -17151,6 +17195,7 @@
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/json-schema": "^7.0.9",
"ajv": "^8.9.0",
@ -17187,6 +17232,7 @@
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3"
},
@ -17198,7 +17244,8 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/secure-json-parse": {
"version": "4.0.0",
@ -17754,7 +17801,8 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/supports-color": {
"version": "7.2.0",
@ -17794,8 +17842,7 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.1.tgz",
"integrity": "sha512-QNbdmeS979Efzim2g/bEvfuh+fTcIdp1y7gA+sb6OYSW74rt7Cr7M78AKdf6HqWT3d5AiTb7SwTT3sLQxr4/qw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
@ -17824,6 +17871,7 @@
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz",
"integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
@ -17842,6 +17890,7 @@
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz",
"integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"jest-worker": "^27.4.5",
@ -17874,7 +17923,8 @@
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/thread-stream": {
"version": "3.1.0",
@ -17951,7 +18001,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -18140,7 +18189,6 @@
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -18327,6 +18375,7 @@
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
@ -18413,6 +18462,7 @@
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
"integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==",
"license": "MIT",
"peer": true,
"dependencies": {
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.1.2"
@ -18438,6 +18488,7 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz",
"integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@ -18501,6 +18552,7 @@
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"esrecurse": "^4.3.0",
"estraverse": "^4.1.1"
@ -18514,6 +18566,7 @@
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=4.0"
}
@ -18691,6 +18744,7 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">= 6"
}
@ -18799,7 +18853,6 @@
"resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz",
"integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"property-expr": "^2.0.5",
"tiny-case": "^1.0.3",
@ -18842,7 +18895,6 @@
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.20.0"
},

View file

@ -1,6 +1,6 @@
{
"name": "ui",
"version": "1.37.0",
"version": "1.38.0",
"private": true,
"scripts": {
"dev": "cross-env NODE_OPTIONS=--enable-source-maps next dev --turbopack",

File diff suppressed because one or more lines are too long

View file

@ -5892,6 +5892,12 @@ export type TwilioConfigurationRequest = {
* List of Twilio phone numbers
*/
from_numbers?: Array<string>;
/**
* Amd Enabled
*
* Detect whether outbound calls are answered by a person or machine. Twilio may bill AMD as an additional per-call feature.
*/
amd_enabled?: boolean;
};
/**
@ -5916,6 +5922,10 @@ export type TwilioConfigurationResponse = {
* From Numbers
*/
from_numbers: Array<string>;
/**
* Amd Enabled
*/
amd_enabled?: boolean;
};
/**
@ -6294,6 +6304,26 @@ export type VobizConfigurationResponse = {
from_numbers: Array<string>;
};
/**
* VoiceFacets
*
* Distinct selector values across a provider's full voice catalog.
*/
export type VoiceFacets = {
/**
* Genders
*/
genders?: Array<string>;
/**
* Accents
*/
accents?: Array<string>;
/**
* Languages
*/
languages?: Array<string>;
};
/**
* VoiceInfo
*/
@ -6340,6 +6370,7 @@ export type VoicesResponse = {
* Voices
*/
voices: Array<VoiceInfo>;
facets?: VoiceFacets | null;
};
/**
@ -9208,6 +9239,18 @@ export type GetVoicesApiV1UserConfigurationsVoicesProviderGetData = {
* Language
*/
language?: string | null;
/**
* Q
*/
q?: string | null;
/**
* Gender
*/
gender?: string | null;
/**
* Accent
*/
accent?: string | null;
};
url: '/api/v1/user/configurations/voices/{provider}';
};

View file

@ -12,15 +12,18 @@ import {
} from "@/components/ServiceConfigurationForm";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { VoiceSelectorModal } from "@/components/VoiceSelectorModal";
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
type ModelMode = "realtime" | "dograh" | "byok";
// Sentinel language value for "Multilingual (Auto-detect)".
const MULTILINGUAL_LANGUAGE_CODE = "multi";
interface DograhDefaults {
voices: string[];
allow_custom_input?: boolean;
@ -31,6 +34,8 @@ interface DograhDefaults {
step?: number;
};
languages: string[];
// Languages covered by the "multi" (Multilingual / Auto-detect) option.
multilingual_languages?: string[];
defaults: {
voice: string;
speed: number;
@ -278,11 +283,15 @@ export function AIModelConfigurationV2Editor({
const [realtimeInitialConfig, setRealtimeInitialConfig] = useState<Record<string, unknown> | null>(null);
const [pipelineInitialConfig, setPipelineInitialConfig] = useState<Record<string, unknown> | null>(null);
const [isSavingDograh, setIsSavingDograh] = useState(false);
const [isCustomVoice, setIsCustomVoice] = useState(false);
const [error, setError] = useState<string | null>(null);
const allowCustomVoice = defaults.dograh.allow_custom_input ?? false;
const dograhSpeedRange = defaults.dograh.speed_range ?? { min: 0.5, max: 2.0, step: 0.1 };
const multilingualLanguageNames = useMemo(() => {
const codes = defaults.dograh.multilingual_languages ?? [];
if (codes.length === 0) return null;
return codes.map((code) => LANGUAGE_DISPLAY_NAMES[code] || code).join(", ");
}, [defaults.dograh.multilingual_languages]);
useEffect(() => {
const rawConfiguration = asRecord(configuration);
@ -290,7 +299,6 @@ export function AIModelConfigurationV2Editor({
setMode(preferredMode(rawConfiguration, rawEffectiveConfiguration));
const nextDograh = buildDograhState(defaults, rawConfiguration, rawEffectiveConfiguration);
setDograh(nextDograh);
setIsCustomVoice(allowCustomVoice && !defaults.dograh.voices.includes(nextDograh.voice));
setRealtimeInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, true));
setPipelineInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, false));
}, [configuration, defaults, effectiveConfiguration, allowCustomVoice]);
@ -390,45 +398,34 @@ export function AIModelConfigurationV2Editor({
<Card>
<CardContent className="pt-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="space-y-2 sm:col-span-2">
<Label>Voice</Label>
{isCustomVoice ? (
<Input
placeholder="Enter voice"
value={dograh.voice}
onChange={(event) => setDograh({ ...dograh, voice: event.target.value })}
/>
) : (
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select voice" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.voices.map((voice) => (
<SelectItem key={voice} value={voice}>
{voice}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{allowCustomVoice && (
<div className="flex items-center space-x-2">
<Checkbox
id="dograh-custom-voice"
checked={isCustomVoice}
onCheckedChange={(checked) => {
const custom = checked as boolean;
setIsCustomVoice(custom);
if (!custom) {
setDograh({ ...dograh, voice: defaults.dograh.defaults.voice });
}
}}
/>
<Label htmlFor="dograh-custom-voice" className="text-sm font-normal cursor-pointer">
Enter Custom Value
</Label>
</div>
<VoiceSelectorModal
provider="dograh"
value={dograh.voice}
onChange={(voice) => setDograh({ ...dograh, voice })}
allowManualInput={allowCustomVoice}
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Language</Label>
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.languages.map((language) => (
<SelectItem key={language} value={language}>
{LANGUAGE_DISPLAY_NAMES[language] || language}
</SelectItem>
))}
</SelectContent>
</Select>
{dograh.language === MULTILINGUAL_LANGUAGE_CODE && multilingualLanguageNames && (
<p className="text-xs text-muted-foreground">
Auto-detects {multilingualLanguageNames}.
</p>
)}
</div>
@ -451,23 +448,7 @@ export function AIModelConfigurationV2Editor({
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Language</Label>
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.languages.map((language) => (
<SelectItem key={language} value={language}>
{LANGUAGE_DISPLAY_NAMES[language] || language}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<div className="space-y-2">
<Label htmlFor="dograh-api-key">API Key</Label>
<div className="relative">
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />

View file

@ -10,11 +10,13 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
// Providers that have MPS voice endpoints
type TTSProviderWithVoices = "elevenlabs" | "deepgram" | "sarvam" | "cartesia" | "dograh" | "rime";
const MPS_VOICE_PROVIDERS: TTSProviderWithVoices[] = ["elevenlabs", "deepgram", "sarvam", "cartesia", "dograh", "rime"];
const ALL_FILTER_VALUE = "__all__";
interface VoiceSelectorProps {
provider: string;
@ -22,6 +24,8 @@ interface VoiceSelectorProps {
onChange: (voiceId: string) => void;
model?: string;
language?: string;
showFilters?: boolean;
allowManualInput?: boolean;
className?: string;
}
@ -31,10 +35,15 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
onChange,
model,
language,
showFilters = false,
allowManualInput = true,
className,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [genderFilter, setGenderFilter] = useState(ALL_FILTER_VALUE);
const [languageFilter, setLanguageFilter] = useState(ALL_FILTER_VALUE);
const [accentFilter, setAccentFilter] = useState(ALL_FILTER_VALUE);
const [isManualInput, setIsManualInput] = useState(false);
const [manualVoiceId, setManualVoiceId] = useState(value || "");
const [voices, setVoices] = useState<VoiceInfo[]>([]);
@ -102,13 +111,15 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
useEffect(() => {
if (value && voices.length > 0) {
const voiceExists = voices.some((v) => v.voice_id === value);
if (!voiceExists) {
if (!voiceExists && allowManualInput) {
// If the value doesn't exist in the list, switch to manual input mode
setIsManualInput(true);
setManualVoiceId(value);
} else if (voiceExists) {
setIsManualInput(false);
}
}
}, [value, voices]);
}, [value, voices, allowManualInput]);
// Cleanup audio on unmount or when popover closes
useEffect(() => {
@ -131,7 +142,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
const filteredVoices = voices.filter((voice) => {
const searchLower = searchTerm.toLowerCase();
return (
const matchesSearch = (
voice.name.toLowerCase().includes(searchLower) ||
voice.voice_id.toLowerCase().includes(searchLower) ||
(voice.description?.toLowerCase() || "").includes(searchLower) ||
@ -139,8 +150,23 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
(voice.gender?.toLowerCase() || "").includes(searchLower) ||
(voice.language?.toLowerCase() || "").includes(searchLower)
);
if (!matchesSearch) return false;
if (genderFilter !== ALL_FILTER_VALUE && (voice.gender || "").toLowerCase() !== genderFilter) return false;
if (languageFilter !== ALL_FILTER_VALUE && (voice.language || "").toLowerCase() !== languageFilter) return false;
if (accentFilter !== ALL_FILTER_VALUE && (voice.accent || "").toLowerCase() !== accentFilter) return false;
return true;
});
const genderOptions = Array.from(
new Set(voices.map((voice) => voice.gender?.toLowerCase()).filter(Boolean) as string[]),
).sort();
const languageOptions = Array.from(
new Set(voices.map((voice) => voice.language?.toLowerCase()).filter(Boolean) as string[]),
).sort();
const accentOptions = Array.from(
new Set(voices.map((voice) => voice.accent?.toLowerCase()).filter(Boolean) as string[]),
).sort();
const handleSelectVoice = (voiceId: string) => {
onChange(voiceId);
setIsOpen(false);
@ -148,6 +174,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
};
const handleManualInputToggle = (checked: boolean) => {
if (!allowManualInput) return;
setIsManualInput(checked);
if (checked) {
setManualVoiceId(value || "");
@ -219,7 +246,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
);
}
if (isManualInput) {
if (isManualInput && allowManualInput) {
return (
<div className={cn("space-y-2", className)}>
<Input
@ -281,6 +308,52 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
/>
</div>
{showFilters && (
<div className="grid gap-2 sm:grid-cols-3">
<Select value={genderFilter} onValueChange={setGenderFilter}>
<SelectTrigger className="h-8">
<SelectValue placeholder="Gender" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_FILTER_VALUE}>All genders</SelectItem>
{genderOptions.map((gender) => (
<SelectItem key={gender} value={gender} className="capitalize">
{gender}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={languageFilter} onValueChange={setLanguageFilter}>
<SelectTrigger className="h-8">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_FILTER_VALUE}>All languages</SelectItem>
{languageOptions.map((voiceLanguage) => (
<SelectItem key={voiceLanguage} value={voiceLanguage} className="uppercase">
{voiceLanguage}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={accentFilter} onValueChange={setAccentFilter}>
<SelectTrigger className="h-8">
<SelectValue placeholder="Accent" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_FILTER_VALUE}>All accents</SelectItem>
{accentOptions.map((accent) => (
<SelectItem key={accent} value={accent} className="uppercase">
{accent}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="max-h-[300px] overflow-auto space-y-1">
{error ? (
<p className="text-sm text-red-500 text-center py-4">
@ -358,26 +431,30 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
</div>
<div className="pt-2 border-t flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox
id="manual-voice-input-popup"
checked={isManualInput}
onCheckedChange={(checked) => {
handleManualInputToggle(checked as boolean);
if (checked) {
setIsOpen(false);
}
}}
/>
<Label
htmlFor="manual-voice-input-popup"
className="text-sm font-normal cursor-pointer"
>
Add Voice ID Manually
</Label>
</div>
{allowManualInput ? (
<div className="flex items-center space-x-2">
<Checkbox
id="manual-voice-input-popup"
checked={isManualInput}
onCheckedChange={(checked) => {
handleManualInputToggle(checked as boolean);
if (checked) {
setIsOpen(false);
}
}}
/>
<Label
htmlFor="manual-voice-input-popup"
className="text-sm font-normal cursor-pointer"
>
Add Voice ID Manually
</Label>
</div>
) : (
<span />
)}
<p className="text-xs text-muted-foreground">
{voices.length} voices available
{filteredVoices.length} of {voices.length} voices
</p>
</div>
</div>

View file

@ -0,0 +1,451 @@
"use client";
import { Check, ChevronDown, Loader2, Pencil, Play, Square } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getVoicesApiV1UserConfigurationsVoicesProviderGet } from "@/client/sdk.gen";
import { VoiceInfo } from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ACCENT_DISPLAY_NAMES } from "@/constants/accents";
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
import { cn } from "@/lib/utils";
const ALL_FILTER_VALUE = "__all__";
// Defaults so the modal opens on a focused set instead of the full catalog.
const DEFAULT_GENDER = "female";
const DEFAULT_ACCENT = "us"; // American
const DEFAULT_LANGUAGE = "en";
const SEARCH_DEBOUNCE_MS = 300;
interface Facets {
genders: string[];
accents: string[];
languages: string[];
}
const EMPTY_FACETS: Facets = { genders: [], accents: [], languages: [] };
interface VoiceSelectorModalProps {
provider: string;
value: string;
onChange: (voiceId: string) => void;
/** Optional model passed through to the voice catalog query. */
model?: string;
/** Allow typing a raw voice ID for voices outside the catalog. */
allowManualInput?: boolean;
className?: string;
}
const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
const accentLabel = (code?: string | null) =>
code ? ACCENT_DISPLAY_NAMES[code.toLowerCase()] || capitalize(code) : "";
const languageLabel = (code?: string | null) =>
code ? LANGUAGE_DISPLAY_NAMES[code] || code.toUpperCase() : "";
const genderLabel = (gender?: string | null) => (gender ? capitalize(gender) : "");
/** Build the "Accent · Gender · Language" trait line shown under a voice name. */
function voiceTraits(voice: VoiceInfo): string {
return [accentLabel(voice.accent), genderLabel(voice.gender), languageLabel(voice.language)]
.filter(Boolean)
.join(" · ");
}
/** Ensure the active filter value is always an option so the Select can render it. */
function withSelected(options: string[], selected: string): string[] {
if (selected === ALL_FILTER_VALUE || options.includes(selected)) return options;
return [selected, ...options];
}
export const VoiceSelectorModal: React.FC<VoiceSelectorModalProps> = ({
provider,
value,
onChange,
model,
allowManualInput = false,
className,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [voices, setVoices] = useState<VoiceInfo[]>([]);
const [facets, setFacets] = useState<Facets>(EMPTY_FACETS);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Filters drive a server-side query (we never fetch the whole catalog).
const [gender, setGender] = useState(DEFAULT_GENDER);
const [accent, setAccent] = useState(DEFAULT_ACCENT);
const [language, setLanguage] = useState(DEFAULT_LANGUAGE);
const [searchInput, setSearchInput] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
// Pending (in-modal) selection; only committed via "Use this voice".
const [pendingVoiceId, setPendingVoiceId] = useState(value);
const [selectedVoiceInfo, setSelectedVoiceInfo] = useState<VoiceInfo | null>(null);
const [manualMode, setManualMode] = useState(false);
const [manualVoiceId, setManualVoiceId] = useState("");
// Preview playback.
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const requestId = useRef(0);
const stopPreview = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setPlayingVoiceId(null);
}, []);
// Debounce the search box so typing doesn't fire a request per keystroke.
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(searchInput), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [searchInput]);
// Resolve the currently-selected voice (for the trigger label) without
// pulling the catalog: a targeted lookup by voice ID.
useEffect(() => {
if (!value) {
setSelectedVoiceInfo(null);
return;
}
let active = true;
(async () => {
const response = await getVoicesApiV1UserConfigurationsVoicesProviderGet({
path: { provider: provider as never },
query: { q: value },
});
if (!active) return;
const found = response.data?.voices?.find((voice) => voice.voice_id === value) ?? null;
setSelectedVoiceInfo(found);
})();
return () => {
active = false;
};
}, [value, provider]);
// Fetch the filtered voice list (server-side) whenever the modal is open
// and a filter changes. A request counter discards out-of-order responses.
useEffect(() => {
if (!isOpen || manualMode) return;
const id = ++requestId.current;
setIsLoading(true);
setError(null);
(async () => {
const query: Record<string, string> = {};
if (model) query.model = model;
if (gender !== ALL_FILTER_VALUE) query.gender = gender;
if (accent !== ALL_FILTER_VALUE) query.accent = accent;
if (language !== ALL_FILTER_VALUE) query.language = language;
const search = debouncedSearch.trim();
if (search) query.q = search;
const response = await getVoicesApiV1UserConfigurationsVoicesProviderGet({
path: { provider: provider as never },
query,
});
if (id !== requestId.current) return; // a newer request superseded this one
if (response.error) {
setError("Failed to load voices");
setVoices([]);
} else {
setVoices(response.data?.voices ?? []);
if (response.data?.facets) {
setFacets({
genders: response.data.facets.genders ?? [],
accents: response.data.facets.accents ?? [],
languages: response.data.facets.languages ?? [],
});
}
}
setIsLoading(false);
})();
}, [isOpen, manualMode, provider, model, gender, accent, language, debouncedSearch]);
// Stop any preview when the modal closes / unmounts.
useEffect(() => {
if (!isOpen) stopPreview();
return () => stopPreview();
}, [isOpen, stopPreview]);
// Facets arrive sorted by raw code; present them sorted by display label so
// the dropdowns read alphabetically (e.g. "American" near the top, not "us").
const toSortedOptions = (codes: string[], selected: string, label: (code: string) => string) =>
withSelected(codes, selected)
.map((code) => ({ value: code, label: label(code) }))
.sort((a, b) => a.label.localeCompare(b.label));
const genderOptions = useMemo(
() => toSortedOptions(facets.genders, gender, genderLabel),
[facets.genders, gender],
);
const accentOptions = useMemo(
() => toSortedOptions(facets.accents, accent, accentLabel),
[facets.accents, accent],
);
const languageOptions = useMemo(
() => toSortedOptions(facets.languages, language, languageLabel),
[facets.languages, language],
);
const openModal = () => {
setGender(DEFAULT_GENDER);
setAccent(DEFAULT_ACCENT);
setLanguage(DEFAULT_LANGUAGE);
setSearchInput("");
setDebouncedSearch("");
setManualMode(false);
setManualVoiceId(value);
setPendingVoiceId(value);
setIsOpen(true);
};
const playPreview = (voice: VoiceInfo) => {
if (playingVoiceId === voice.voice_id) {
stopPreview();
return;
}
stopPreview();
if (!voice.preview_url) return;
const audio = new Audio(voice.preview_url);
audioRef.current = audio;
setPlayingVoiceId(voice.voice_id);
const clear = () => {
if (audioRef.current === audio) audioRef.current = null;
setPlayingVoiceId((current) => (current === voice.voice_id ? null : current));
};
audio.onended = clear;
audio.onerror = clear;
audio.play().catch(clear);
};
const commitSelection = () => {
if (manualMode) {
const next = manualVoiceId.trim();
if (next) onChange(next);
} else if (pendingVoiceId) {
onChange(pendingVoiceId);
const chosen = voices.find((voice) => voice.voice_id === pendingVoiceId);
if (chosen) setSelectedVoiceInfo(chosen);
}
setIsOpen(false);
};
const triggerLabel = selectedVoiceInfo?.name || value || "Select a voice";
const triggerTraits = selectedVoiceInfo ? voiceTraits(selectedVoiceInfo) : "";
return (
<div className={cn("space-y-2", className)}>
<Button
type="button"
variant="outline"
className={cn("w-full justify-between", !value && "text-muted-foreground")}
onClick={openModal}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{triggerLabel}</span>
{triggerTraits && (
<span className="truncate text-xs text-muted-foreground">{triggerTraits}</span>
)}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>Select Voice</DialogTitle>
</DialogHeader>
{/* Filter row: Gender · Accent · Language · Search */}
<div className="flex flex-wrap items-center gap-2 border-b px-6 py-3">
<Select value={gender} onValueChange={setGender} disabled={manualMode}>
<SelectTrigger className="h-9 w-[130px]">
<SelectValue placeholder="Gender" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_FILTER_VALUE}>All genders</SelectItem>
{genderOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={accent} onValueChange={setAccent} disabled={manualMode}>
<SelectTrigger className="h-9 w-[140px]">
<SelectValue placeholder="Accent" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_FILTER_VALUE}>All accents</SelectItem>
{accentOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={language} onValueChange={setLanguage} disabled={manualMode}>
<SelectTrigger className="h-9 w-[150px]">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_FILTER_VALUE}>All languages</SelectItem>
{languageOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="Search voices..."
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
className="h-9 min-w-[160px] flex-1"
disabled={manualMode}
/>
</div>
{/* Body */}
<div className="min-h-[260px] flex-1 overflow-auto px-6 py-4">
{manualMode ? (
<div className="space-y-2">
<Label htmlFor="manual-voice-id">Custom voice ID</Label>
<Input
id="manual-voice-id"
placeholder="Enter voice ID"
value={manualVoiceId}
onChange={(event) => setManualVoiceId(event.target.value)}
autoFocus
/>
<p className="text-xs text-muted-foreground">
Use a voice ID that isn&apos;t in the catalog above.
</p>
</div>
) : error ? (
<p className="py-10 text-center text-sm text-destructive">{error}</p>
) : isLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : voices.length === 0 ? (
<p className="py-10 text-center text-sm text-muted-foreground">
No voices match these filters
</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{voices.map((voice) => {
const isSelected = pendingVoiceId === voice.voice_id;
const isPlaying = playingVoiceId === voice.voice_id;
return (
<button
type="button"
key={voice.voice_id}
onClick={() => setPendingVoiceId(voice.voice_id)}
className={cn(
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-accent",
isSelected ? "border-primary ring-1 ring-primary" : "border-border",
)}
>
<span
role="button"
tabIndex={voice.preview_url ? 0 : -1}
aria-label={isPlaying ? "Stop preview" : "Play preview"}
onClick={(event) => {
event.stopPropagation();
playPreview(voice);
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
playPreview(voice);
}
}}
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
voice.preview_url
? "bg-primary/10 text-primary hover:bg-primary/20"
: "bg-muted text-muted-foreground",
)}
>
{isPlaying ? (
<Square className="h-4 w-4 fill-current" />
) : (
<Play className="h-4 w-4 fill-current" />
)}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{voice.name}</span>
{isSelected && <Check className="h-4 w-4 shrink-0 text-primary" />}
</span>
{voiceTraits(voice) && (
<span className="truncate text-xs text-muted-foreground">
{voiceTraits(voice)}
</span>
)}
<span className="truncate text-[11px] text-muted-foreground/70">
ID: {voice.voice_id}
</span>
</span>
</button>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-3 border-t px-6 py-3">
{allowManualInput ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => setManualMode((prev) => !prev)}
>
<Pencil className="mr-2 h-4 w-4" />
{manualMode ? "Browse catalog" : "Custom voice ID"}
</Button>
) : (
<span className="text-xs text-muted-foreground">
{!manualMode && !isLoading && !error ? `${voices.length} voices` : ""}
</span>
)}
<div className="flex items-center gap-2">
<Button type="button" variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button
type="button"
onClick={commitSelection}
disabled={manualMode ? !manualVoiceId.trim() : !pendingVoiceId}
>
Use this voice
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
};

View file

@ -47,7 +47,8 @@ interface ConfigFormDialogProps {
onSaved: () => void;
}
type FieldValues = Record<string, string | number | undefined>;
type FieldValue = string | number | boolean | undefined;
type FieldValues = Record<string, FieldValue>;
export function ConfigFormDialog({
open,
@ -104,7 +105,7 @@ export function ConfigFormDialog({
if (!isEdit) setValues({});
}, [providerName, isEdit]);
const updateField = (fieldName: string, value: string | number) => {
const updateField = (fieldName: string, value: FieldValue) => {
setValues((prev) => ({ ...prev, [fieldName]: value }));
};
@ -292,8 +293,8 @@ export function ConfigFormDialog({
interface FieldInputProps {
field: TelephonyProviderMetadata["fields"][number];
value: string | number | undefined;
onChange: (v: string | number) => void;
value: FieldValue;
onChange: (v: FieldValue) => void;
isEdit: boolean;
}
@ -335,6 +336,15 @@ function FieldInput({ field, value, onChange, isEdit }: FieldInputProps) {
/>
);
}
if (field.type === "boolean") {
return (
<Switch
id={`cfg-field-${field.name}`}
checked={Boolean(value)}
onCheckedChange={onChange}
/>
);
}
return (
<Input
id={`cfg-field-${field.name}`}

View file

@ -0,0 +1,56 @@
// Display names for accent codes returned by the voice catalog.
//
// The catalog derives accent from a voice's locale country (e.g. "en-US" -> "us"),
// so the stored/filter value is an ISO 3166-1 alpha-2 country code. These are the
// human-readable accent labels shown in the UI; the underlying code stays the
// filter value. Unknown codes fall back to a capitalized form at the call site.
export const ACCENT_DISPLAY_NAMES: Record<string, string> = {
us: "American",
gb: "British",
au: "Australian",
ca: "Canadian",
ie: "Irish",
nz: "New Zealand",
za: "South African",
in: "Indian",
bd: "Bangladeshi",
sg: "Singaporean",
my: "Malaysian",
ph: "Filipino",
id: "Indonesian",
vn: "Vietnamese",
th: "Thai",
cn: "Chinese",
jp: "Japanese",
kr: "Korean",
fr: "French",
de: "German",
ch: "Swiss",
nl: "Dutch",
it: "Italian",
es: "Spanish",
mx: "Mexican",
co: "Colombian",
bo: "Bolivian",
br: "Brazilian",
pt: "Portuguese",
ru: "Russian",
ua: "Ukrainian",
pl: "Polish",
cz: "Czech",
sk: "Slovak",
hu: "Hungarian",
ro: "Romanian",
bg: "Bulgarian",
hr: "Croatian",
gr: "Greek",
ge: "Georgian",
md: "Moldovan",
se: "Swedish",
no: "Norwegian",
dk: "Danish",
fi: "Finnish",
tr: "Turkish",
il: "Israeli",
sa: "Saudi",
};