mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
Merge branch 'main' into feat/external-pbx
This commit is contained in:
commit
8d0be05d71
204 changed files with 9072 additions and 1392 deletions
|
|
@ -13,9 +13,16 @@ from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
|
|||
from api.services.auth.stack_auth import stackauth
|
||||
from api.services.configuration.registry import ServiceProviders
|
||||
from api.services.mps_billing import ensure_hosted_mps_billing_account_v2
|
||||
from api.services.posthog_client import capture_event
|
||||
from api.services.posthog_client import (
|
||||
capture_event,
|
||||
group_identify,
|
||||
set_person_properties,
|
||||
)
|
||||
from api.utils.auth import decode_jwt_token
|
||||
|
||||
POSTHOG_ORGANIZATION_GROUP_TYPE = "organization"
|
||||
POSTHOG_ORGANIZATION_USES_MPS_BILLING_V2_PROPERTY = "uses_mps_billing_v2"
|
||||
|
||||
|
||||
async def get_user(
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
|
|
@ -94,6 +101,11 @@ async def get_user(
|
|||
) = await db_client.get_or_create_organization_by_provider_id(
|
||||
org_provider_id=selected_team_id, user_id=user_model.id
|
||||
)
|
||||
if org_was_created:
|
||||
_sync_created_organization_to_posthog(
|
||||
organization=organization,
|
||||
stack_user=stack_user,
|
||||
)
|
||||
|
||||
# Check if user's selected organization differs from the current organization
|
||||
if user_model.selected_organization_id != organization.id:
|
||||
|
|
@ -107,6 +119,13 @@ async def get_user(
|
|||
# Update the user_model object to reflect the change
|
||||
user_model.selected_organization_id = organization.id
|
||||
|
||||
_associate_user_with_posthog_organization(
|
||||
user=user_model,
|
||||
organization=organization,
|
||||
stack_user=stack_user,
|
||||
org_was_created=org_was_created,
|
||||
)
|
||||
|
||||
# Only create default configuration if organization was just created
|
||||
# This prevents race conditions where multiple concurrent requests
|
||||
# might try to create configurations
|
||||
|
|
@ -156,6 +175,146 @@ async def get_user(
|
|||
return user_model
|
||||
|
||||
|
||||
def _sync_created_organization_to_posthog(
|
||||
*,
|
||||
organization,
|
||||
stack_user: dict | None = None,
|
||||
created_by_provider_id: str | None = None,
|
||||
uses_mps_billing_v2: bool | None = None,
|
||||
) -> None:
|
||||
"""Create/update the PostHog organization group for a newly-created org."""
|
||||
try:
|
||||
organization_id = int(organization.id)
|
||||
organization_provider_id = getattr(organization, "provider_id", None)
|
||||
created_by = created_by_provider_id
|
||||
if created_by is None and stack_user and stack_user.get("id"):
|
||||
created_by = str(stack_user["id"])
|
||||
properties = {
|
||||
"organization_id": organization_id,
|
||||
"organization_provider_id": organization_provider_id,
|
||||
"auth_provider": "stack",
|
||||
}
|
||||
if created_by:
|
||||
properties["created_by_provider_id"] = created_by
|
||||
if uses_mps_billing_v2 is not None:
|
||||
properties[POSTHOG_ORGANIZATION_USES_MPS_BILLING_V2_PROPERTY] = (
|
||||
uses_mps_billing_v2
|
||||
)
|
||||
|
||||
group_identify(
|
||||
POSTHOG_ORGANIZATION_GROUP_TYPE,
|
||||
str(organization_id),
|
||||
properties,
|
||||
distinct_id=created_by,
|
||||
)
|
||||
if created_by:
|
||||
capture_event(
|
||||
distinct_id=created_by,
|
||||
event=PostHogEvent.ORGANIZATION_CREATED,
|
||||
properties=properties,
|
||||
groups={POSTHOG_ORGANIZATION_GROUP_TYPE: str(organization_id)},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to sync created organization to PostHog")
|
||||
|
||||
|
||||
def _sync_posthog_organization_group_properties(
|
||||
*,
|
||||
organization,
|
||||
uses_mps_billing_v2: bool | None = None,
|
||||
) -> None:
|
||||
"""Update PostHog organization group properties without creating a person."""
|
||||
try:
|
||||
organization_id = int(organization.id)
|
||||
properties = {
|
||||
"organization_id": organization_id,
|
||||
"organization_provider_id": getattr(organization, "provider_id", None),
|
||||
"auth_provider": "stack",
|
||||
}
|
||||
if uses_mps_billing_v2 is not None:
|
||||
properties[POSTHOG_ORGANIZATION_USES_MPS_BILLING_V2_PROPERTY] = (
|
||||
uses_mps_billing_v2
|
||||
)
|
||||
|
||||
group_identify(
|
||||
POSTHOG_ORGANIZATION_GROUP_TYPE,
|
||||
str(organization_id),
|
||||
properties,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to sync organization group properties to PostHog")
|
||||
|
||||
|
||||
def _sync_posthog_organization_mps_billing_v2_status(
|
||||
organization_id: int,
|
||||
*,
|
||||
uses_mps_billing_v2: bool,
|
||||
) -> None:
|
||||
"""Update the PostHog organization group with current MPS billing status."""
|
||||
try:
|
||||
organization_id = int(organization_id)
|
||||
group_identify(
|
||||
POSTHOG_ORGANIZATION_GROUP_TYPE,
|
||||
str(organization_id),
|
||||
{POSTHOG_ORGANIZATION_USES_MPS_BILLING_V2_PROPERTY: uses_mps_billing_v2},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to sync organization billing status to PostHog")
|
||||
|
||||
|
||||
def _associate_user_with_posthog_organization(
|
||||
*,
|
||||
user: UserModel,
|
||||
organization,
|
||||
stack_user: dict | None = None,
|
||||
user_distinct_id: str | None = None,
|
||||
org_was_created: bool,
|
||||
organization_ids: list[int] | None = None,
|
||||
selected_organization_id: int | None = None,
|
||||
selected_organization_provider_id: str | None = None,
|
||||
) -> None:
|
||||
"""Attach the Stack user to the PostHog organization group."""
|
||||
try:
|
||||
organization_id = int(organization.id)
|
||||
organization_provider_id = getattr(organization, "provider_id", None)
|
||||
if user_distinct_id is None:
|
||||
if stack_user and stack_user.get("id"):
|
||||
user_distinct_id = str(stack_user["id"])
|
||||
else:
|
||||
user_distinct_id = str(user.provider_id)
|
||||
selected_org_id = selected_organization_id or organization_id
|
||||
selected_org_provider_id = (
|
||||
selected_organization_provider_id or organization_provider_id
|
||||
)
|
||||
person_properties = {
|
||||
"user_id": user.id,
|
||||
"user_provider_id": user_distinct_id,
|
||||
"selected_organization_id": selected_org_id,
|
||||
"selected_organization_provider_id": selected_org_provider_id,
|
||||
}
|
||||
if organization_ids is not None:
|
||||
person_properties["organization_ids"] = organization_ids
|
||||
if user.email:
|
||||
person_properties["email"] = user.email
|
||||
set_person_properties(user_distinct_id, person_properties)
|
||||
event_properties = {
|
||||
"user_id": user.id,
|
||||
"organization_id": organization_id,
|
||||
"organization_provider_id": organization_provider_id,
|
||||
"auth_provider": "stack",
|
||||
"organization_was_created": org_was_created,
|
||||
}
|
||||
|
||||
capture_event(
|
||||
distinct_id=user_distinct_id,
|
||||
event=PostHogEvent.ORGANIZATION_USER_ASSOCIATED,
|
||||
properties=event_properties,
|
||||
groups={POSTHOG_ORGANIZATION_GROUP_TYPE: str(organization_id)},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to associate user with PostHog organization")
|
||||
|
||||
|
||||
async def get_user_with_selected_organization(
|
||||
user: Annotated[UserModel, Depends(get_user)],
|
||||
) -> UserModel:
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ from api.enums import OrganizationConfigurationKey
|
|||
from api.schemas.ai_model_configuration import (
|
||||
DOGRAH_DEFAULT_LANGUAGE,
|
||||
DOGRAH_DEFAULT_VOICE,
|
||||
DOGRAH_SPEED_OPTIONS,
|
||||
DOGRAH_SPEED_MAX,
|
||||
DOGRAH_SPEED_MIN,
|
||||
BYOKAIModelConfiguration,
|
||||
BYOKPipelineAIModelConfiguration,
|
||||
BYOKRealtimeAIModelConfiguration,
|
||||
|
|
@ -436,7 +437,11 @@ def _convert_any_dograh_legacy_configuration(
|
|||
dograh_key: str,
|
||||
) -> OrganizationAIModelConfigurationV2:
|
||||
speed = getattr(configuration.tts, "speed", 1.0)
|
||||
if speed not in DOGRAH_SPEED_OPTIONS:
|
||||
try:
|
||||
speed = float(speed)
|
||||
except (TypeError, ValueError):
|
||||
speed = 1.0
|
||||
if not DOGRAH_SPEED_MIN <= speed <= DOGRAH_SPEED_MAX:
|
||||
speed = 1.0
|
||||
return OrganizationAIModelConfigurationV2(
|
||||
mode="dograh",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from typing import Optional, TypedDict
|
||||
|
||||
import httpx
|
||||
import openai
|
||||
from deepgram import DeepgramClient
|
||||
from groq import Groq
|
||||
|
|
@ -38,6 +39,7 @@ class UserConfigurationValidator:
|
|||
ServiceProviders.DEEPGRAM.value: self._check_deepgram_api_key,
|
||||
ServiceProviders.GROQ.value: self._check_groq_api_key,
|
||||
ServiceProviders.OPENROUTER.value: self._check_openrouter_api_key,
|
||||
ServiceProviders.INWORLD.value: self._check_inworld_api_key,
|
||||
ServiceProviders.ELEVENLABS.value: self._validate_elevenlabs_api_key,
|
||||
ServiceProviders.GOOGLE.value: self._check_google_api_key,
|
||||
ServiceProviders.AZURE.value: self._check_azure_api_key,
|
||||
|
|
@ -49,6 +51,7 @@ class UserConfigurationValidator:
|
|||
ServiceProviders.CAMB.value: self._check_camb_api_key,
|
||||
ServiceProviders.AWS_BEDROCK.value: self._check_aws_bedrock_api_key,
|
||||
ServiceProviders.SPEACHES.value: self._check_speaches_api_key,
|
||||
ServiceProviders.HUGGINGFACE.value: self._check_huggingface_api_key,
|
||||
ServiceProviders.GOOGLE_VERTEX.value: self._check_google_vertex_llm_api_key,
|
||||
ServiceProviders.OPENAI_REALTIME.value: self._check_openai_api_key,
|
||||
ServiceProviders.GROK_REALTIME.value: self._check_grok_realtime_api_key,
|
||||
|
|
@ -60,6 +63,7 @@ class UserConfigurationValidator:
|
|||
ServiceProviders.GLADIA.value: self._check_gladia_api_key,
|
||||
ServiceProviders.RIME.value: self._check_rime_api_key,
|
||||
ServiceProviders.MINIMAX.value: self._check_minimax_api_key,
|
||||
ServiceProviders.SMALLEST.value: self._check_smallest_api_key,
|
||||
}
|
||||
|
||||
async def validate(
|
||||
|
|
@ -343,6 +347,32 @@ class UserConfigurationValidator:
|
|||
def _check_openrouter_api_key(self, model: str, api_key: str) -> bool:
|
||||
return True
|
||||
|
||||
def _check_inworld_api_key(self, model: str, api_key: str) -> bool:
|
||||
try:
|
||||
response = httpx.get(
|
||||
"https://api.inworld.ai/voices/v1/voices",
|
||||
headers={"Authorization": f"Basic {api_key}"},
|
||||
params={"pageSize": 1},
|
||||
timeout=10.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except httpx.HTTPStatusError as exc:
|
||||
if exc.response.status_code in (401, 403):
|
||||
raise ValueError(
|
||||
"Invalid Inworld API key. The key was rejected by the Inworld API. "
|
||||
"Please verify that your API key is correct, active, and has voice read access."
|
||||
) from exc
|
||||
raise ValueError(
|
||||
"The Inworld API returned an error while validating the API key. "
|
||||
"Please try again later."
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise ValueError(
|
||||
"Could not connect to the Inworld API. Please check your network connection "
|
||||
"and try again."
|
||||
) from exc
|
||||
|
||||
def _check_grok_realtime_api_key(self, model: str, api_key: str) -> bool:
|
||||
return True
|
||||
|
||||
|
|
@ -360,6 +390,14 @@ class UserConfigurationValidator:
|
|||
raise ValueError("base_url is required for Speaches services")
|
||||
return True
|
||||
|
||||
def _check_huggingface_api_key(self, model: str, api_key: str) -> bool:
|
||||
if not api_key.startswith("hf_"):
|
||||
raise ValueError(
|
||||
"Invalid Hugging Face API token format. Use a token that starts with "
|
||||
"'hf_' and has Inference Providers permission."
|
||||
)
|
||||
return True
|
||||
|
||||
def _check_google_vertex_realtime_api_key(self, model: str, service_config) -> bool:
|
||||
if not getattr(service_config, "project_id", None):
|
||||
raise ValueError("project_id is required for Google Vertex Realtime")
|
||||
|
|
@ -389,6 +427,7 @@ class UserConfigurationValidator:
|
|||
return True
|
||||
|
||||
def _check_minimax_api_key(self, model: str, api_key: str) -> bool:
|
||||
# MiniMax doesn't publish a cheap key-validation endpoint; trust the key
|
||||
# at save time and surface auth errors at first call (same as Rime/Sarvam).
|
||||
return True
|
||||
|
||||
def _check_smallest_api_key(self, model: str, api_key: str) -> bool:
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@ from .azure import (
|
|||
AZURE_SPEECH_TTS_LANGUAGES,
|
||||
AZURE_SPEECH_TTS_VOICES,
|
||||
)
|
||||
from .deepgram import DEEPGRAM_LANGUAGES, DEEPGRAM_STT_MODELS
|
||||
from .deepgram import (
|
||||
DEEPGRAM_FLUX_MODELS,
|
||||
DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS,
|
||||
DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES,
|
||||
DEEPGRAM_LANGUAGES,
|
||||
DEEPGRAM_STT_MODELS,
|
||||
)
|
||||
from .gladia import GLADIA_STT_LANGUAGES, GLADIA_STT_MODELS
|
||||
from .google import (
|
||||
GOOGLE_MODELS,
|
||||
|
|
@ -35,6 +41,12 @@ from .sarvam import (
|
|||
SARVAM_V2_VOICES,
|
||||
SARVAM_V3_VOICES,
|
||||
)
|
||||
from .smallest import (
|
||||
SMALLEST_TTS_LANGUAGES,
|
||||
SMALLEST_TTS_MODELS,
|
||||
SMALLEST_TTS_PRO_VOICES,
|
||||
SMALLEST_TTS_VOICES,
|
||||
)
|
||||
from .speechmatics import SPEECHMATICS_STT_LANGUAGES
|
||||
|
||||
__all__ = [
|
||||
|
|
@ -47,6 +59,9 @@ __all__ = [
|
|||
"AZURE_SPEECH_STT_LANGUAGES",
|
||||
"AZURE_SPEECH_TTS_LANGUAGES",
|
||||
"AZURE_SPEECH_TTS_VOICES",
|
||||
"DEEPGRAM_FLUX_MODELS",
|
||||
"DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES",
|
||||
"DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS",
|
||||
"DEEPGRAM_LANGUAGES",
|
||||
"DEEPGRAM_STT_MODELS",
|
||||
"GLADIA_STT_LANGUAGES",
|
||||
|
|
@ -71,5 +86,9 @@ __all__ = [
|
|||
"SARVAM_TTS_MODELS",
|
||||
"SARVAM_V2_VOICES",
|
||||
"SARVAM_V3_VOICES",
|
||||
"SMALLEST_TTS_LANGUAGES",
|
||||
"SMALLEST_TTS_MODELS",
|
||||
"SMALLEST_TTS_PRO_VOICES",
|
||||
"SMALLEST_TTS_VOICES",
|
||||
"SPEECHMATICS_STT_LANGUAGES",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,21 @@
|
|||
DEEPGRAM_STT_MODELS = ("nova-3-general", "flux-general-en", "flux-general-multi")
|
||||
DEEPGRAM_FLUX_MODELS = ("flux-general-en", "flux-general-multi")
|
||||
DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES = (
|
||||
"de",
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"hi",
|
||||
"it",
|
||||
"ja",
|
||||
"nl",
|
||||
"pt",
|
||||
"ru",
|
||||
)
|
||||
DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS = (
|
||||
"multi",
|
||||
*DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES,
|
||||
)
|
||||
DEEPGRAM_STT_MODELS = ("nova-3-general", *DEEPGRAM_FLUX_MODELS)
|
||||
DEEPGRAM_LANGUAGES = (
|
||||
"multi",
|
||||
"ar",
|
||||
|
|
|
|||
45
api/services/configuration/options/smallest.py
Normal file
45
api/services/configuration/options/smallest.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
SMALLEST_TTS_MODELS = ("lightning_v3.1", "lightning_v3.1_pro")
|
||||
SMALLEST_TTS_VOICES = (
|
||||
"sophia",
|
||||
"avery",
|
||||
"liam",
|
||||
"lucas",
|
||||
"olivia",
|
||||
"ryan",
|
||||
"freya",
|
||||
"william",
|
||||
"devansh",
|
||||
"arjun",
|
||||
"niharika",
|
||||
"maya",
|
||||
"dhruv",
|
||||
"mia",
|
||||
"maithili",
|
||||
)
|
||||
# Premium voices for lightning_v3.1_pro (American, British, Indian accents; English + Hindi only)
|
||||
SMALLEST_TTS_PRO_VOICES = (
|
||||
"meher",
|
||||
"rhea",
|
||||
"aviraj",
|
||||
"cressida",
|
||||
"willow",
|
||||
"maverick",
|
||||
)
|
||||
SMALLEST_TTS_LANGUAGES = (
|
||||
"en",
|
||||
"hi",
|
||||
"fr",
|
||||
"de",
|
||||
"es",
|
||||
"it",
|
||||
"nl",
|
||||
"pl",
|
||||
"ru",
|
||||
"ar",
|
||||
"bn",
|
||||
"gu",
|
||||
"he",
|
||||
"kn",
|
||||
"mr",
|
||||
"ta",
|
||||
)
|
||||
|
|
@ -14,6 +14,7 @@ from api.services.configuration.options import (
|
|||
AZURE_SPEECH_STT_LANGUAGES,
|
||||
AZURE_SPEECH_TTS_LANGUAGES,
|
||||
AZURE_SPEECH_TTS_VOICES,
|
||||
DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS,
|
||||
DEEPGRAM_LANGUAGES,
|
||||
DEEPGRAM_STT_MODELS,
|
||||
GLADIA_STT_LANGUAGES,
|
||||
|
|
@ -38,6 +39,10 @@ from api.services.configuration.options import (
|
|||
SARVAM_TTS_MODELS,
|
||||
SARVAM_V2_VOICES,
|
||||
SARVAM_V3_VOICES,
|
||||
SMALLEST_TTS_LANGUAGES,
|
||||
SMALLEST_TTS_MODELS,
|
||||
SMALLEST_TTS_PRO_VOICES,
|
||||
SMALLEST_TTS_VOICES,
|
||||
SPEECHMATICS_STT_LANGUAGES,
|
||||
)
|
||||
from api.services.configuration.options.google import GOOGLE_VERTEX_MODELS
|
||||
|
|
@ -56,6 +61,7 @@ class ServiceProviders(str, Enum):
|
|||
DEEPGRAM = "deepgram"
|
||||
GROQ = "groq"
|
||||
OPENROUTER = "openrouter"
|
||||
INWORLD = "inworld"
|
||||
CARTESIA = "cartesia"
|
||||
# NEUPHONIC = "neuphonic"
|
||||
ELEVENLABS = "elevenlabs"
|
||||
|
|
@ -68,6 +74,7 @@ class ServiceProviders(str, Enum):
|
|||
CAMB = "camb"
|
||||
AWS_BEDROCK = "aws_bedrock"
|
||||
SPEACHES = "speaches"
|
||||
HUGGINGFACE = "huggingface"
|
||||
ASSEMBLYAI = "assemblyai"
|
||||
GLADIA = "gladia"
|
||||
RIME = "rime"
|
||||
|
|
@ -79,6 +86,7 @@ class ServiceProviders(str, Enum):
|
|||
GOOGLE_REALTIME = "google_realtime"
|
||||
GOOGLE_VERTEX_REALTIME = "google_vertex_realtime"
|
||||
AZURE_REALTIME = "azure_realtime"
|
||||
SMALLEST = "smallest"
|
||||
|
||||
|
||||
class BaseServiceConfiguration(BaseModel):
|
||||
|
|
@ -87,6 +95,7 @@ class BaseServiceConfiguration(BaseModel):
|
|||
ServiceProviders.DEEPGRAM,
|
||||
ServiceProviders.GROQ,
|
||||
ServiceProviders.OPENROUTER,
|
||||
ServiceProviders.INWORLD,
|
||||
ServiceProviders.ELEVENLABS,
|
||||
ServiceProviders.GOOGLE,
|
||||
ServiceProviders.AZURE,
|
||||
|
|
@ -94,6 +103,7 @@ class BaseServiceConfiguration(BaseModel):
|
|||
ServiceProviders.DOGRAH,
|
||||
ServiceProviders.AWS_BEDROCK,
|
||||
ServiceProviders.SPEACHES,
|
||||
ServiceProviders.HUGGINGFACE,
|
||||
ServiceProviders.ASSEMBLYAI,
|
||||
ServiceProviders.GLADIA,
|
||||
ServiceProviders.RIME,
|
||||
|
|
@ -106,6 +116,7 @@ class BaseServiceConfiguration(BaseModel):
|
|||
ServiceProviders.GOOGLE_VERTEX_REALTIME,
|
||||
ServiceProviders.AZURE_REALTIME,
|
||||
ServiceProviders.SARVAM,
|
||||
ServiceProviders.SMALLEST,
|
||||
]
|
||||
api_key: str | list[str]
|
||||
|
||||
|
|
@ -240,6 +251,14 @@ GOOGLE_VERTEX_REALTIME_PROVIDER_MODEL_CONFIG = provider_model_config(
|
|||
DEEPGRAM_PROVIDER_MODEL_CONFIG = provider_model_config("Deepgram")
|
||||
ELEVENLABS_PROVIDER_MODEL_CONFIG = provider_model_config("ElevenLabs")
|
||||
CARTESIA_PROVIDER_MODEL_CONFIG = provider_model_config("Cartesia")
|
||||
INWORLD_PROVIDER_MODEL_CONFIG = provider_model_config(
|
||||
"Inworld",
|
||||
description=(
|
||||
"Inworld AI streaming text-to-speech with built-in and cloned voices. "
|
||||
"Defaults to the Ashley system voice on inworld-tts-2."
|
||||
),
|
||||
provider_docs_url="https://docs.inworld.ai/tts/tts",
|
||||
)
|
||||
SARVAM_PROVIDER_MODEL_CONFIG = provider_model_config("Sarvam")
|
||||
CAMB_PROVIDER_MODEL_CONFIG = provider_model_config("Camb.ai")
|
||||
RIME_PROVIDER_MODEL_CONFIG = provider_model_config("Rime")
|
||||
|
|
@ -255,6 +274,11 @@ SPEACHES_PROVIDER_MODEL_CONFIG = provider_model_config(
|
|||
),
|
||||
provider_docs_url="https://github.com/speaches-ai/speaches",
|
||||
)
|
||||
HUGGINGFACE_PROVIDER_MODEL_CONFIG = provider_model_config(
|
||||
"Hugging Face",
|
||||
description="Hosted Hugging Face Inference Providers API for usage-based inference.",
|
||||
provider_docs_url="https://huggingface.co/docs/inference-providers/en/index",
|
||||
)
|
||||
AZURE_SPEECH_PROVIDER_MODEL_CONFIG = provider_model_config(
|
||||
"Azure Speech Services",
|
||||
description="Azure Cognitive Services Speech — TTS and STT via the Azure Speech SDK.",
|
||||
|
|
@ -471,6 +495,35 @@ class SpeachesLLMConfiguration(BaseLLMConfiguration):
|
|||
)
|
||||
|
||||
|
||||
HUGGINGFACE_LLM_MODELS = [
|
||||
"openai/gpt-oss-120b:cerebras",
|
||||
"deepseek-ai/DeepSeek-R1:fastest",
|
||||
"Qwen/Qwen3-Coder-480B-A35B-Instruct:fastest",
|
||||
]
|
||||
|
||||
|
||||
@register_llm
|
||||
class HuggingFaceLLMConfiguration(BaseLLMConfiguration):
|
||||
model_config = HUGGINGFACE_PROVIDER_MODEL_CONFIG
|
||||
provider: Literal[ServiceProviders.HUGGINGFACE] = ServiceProviders.HUGGINGFACE
|
||||
model: str = Field(
|
||||
default="openai/gpt-oss-120b:cerebras",
|
||||
description="Hugging Face chat-completion model identifier, optionally with provider suffix.",
|
||||
json_schema_extra={
|
||||
"examples": HUGGINGFACE_LLM_MODELS,
|
||||
"allow_custom_input": True,
|
||||
},
|
||||
)
|
||||
base_url: str = Field(
|
||||
default="https://router.huggingface.co/v1",
|
||||
description="Hugging Face OpenAI-compatible chat-completions router base URL.",
|
||||
)
|
||||
bill_to: str | None = Field(
|
||||
default=None,
|
||||
description="Optional Hugging Face organization or user to bill using X-HF-Bill-To.",
|
||||
)
|
||||
|
||||
|
||||
MINIMAX_MODELS = [
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
|
|
@ -741,6 +794,7 @@ LLMConfig = Annotated[
|
|||
DograhLLMService,
|
||||
AWSBedrockLLMConfiguration,
|
||||
SpeachesLLMConfiguration,
|
||||
HuggingFaceLLMConfiguration,
|
||||
MiniMaxLLMConfiguration,
|
||||
SarvamLLMConfiguration,
|
||||
],
|
||||
|
|
@ -907,11 +961,15 @@ class DograhTTSService(BaseTTSConfiguration):
|
|||
voice: str = Field(
|
||||
default="default",
|
||||
description="Voice preset.",
|
||||
json_schema_extra={"allow_custom_input": True},
|
||||
)
|
||||
speed: float = Field(default=1.0, ge=0.5, le=2.0, description="Speed of the voice.")
|
||||
|
||||
|
||||
CARTESIA_TTS_MODELS = ["sonic-3.5", "sonic-3"]
|
||||
INWORLD_TTS_MODELS = ["inworld-tts-2"]
|
||||
INWORLD_TTS_VOICES = ["Ashley"]
|
||||
INWORLD_TTS_LANGUAGES = ["en-US"]
|
||||
|
||||
|
||||
@register_tts
|
||||
|
|
@ -934,6 +992,51 @@ class CartesiaTTSConfiguration(BaseTTSConfiguration):
|
|||
le=2.0,
|
||||
description="Volume multiplier for generated speech.",
|
||||
)
|
||||
language: str = Field(
|
||||
default="en",
|
||||
description="Cartesia language code for TTS synthesis (e.g. 'en', 'tr', 'fr', 'de').",
|
||||
json_schema_extra={"allow_custom_input": True},
|
||||
)
|
||||
|
||||
|
||||
@register_tts
|
||||
class InworldTTSConfiguration(BaseTTSConfiguration):
|
||||
model_config = INWORLD_PROVIDER_MODEL_CONFIG
|
||||
provider: Literal[ServiceProviders.INWORLD] = ServiceProviders.INWORLD
|
||||
model: str = Field(
|
||||
default="inworld-tts-2",
|
||||
description="Inworld TTS model.",
|
||||
json_schema_extra={"examples": INWORLD_TTS_MODELS, "allow_custom_input": True},
|
||||
)
|
||||
voice: str = Field(
|
||||
default="Ashley",
|
||||
description=(
|
||||
"Inworld voice ID. Use Ashley for the default warm English voice, "
|
||||
"or a workspace voice ID for a cloned/custom voice."
|
||||
),
|
||||
json_schema_extra={"examples": INWORLD_TTS_VOICES, "allow_custom_input": True},
|
||||
)
|
||||
language: str = Field(
|
||||
default="en-US",
|
||||
description="BCP-47 language code for synthesis.",
|
||||
json_schema_extra={
|
||||
"examples": INWORLD_TTS_LANGUAGES,
|
||||
"allow_custom_input": True,
|
||||
},
|
||||
)
|
||||
speed: float = Field(
|
||||
default=1.0,
|
||||
ge=0.25,
|
||||
le=4.0,
|
||||
description="Speech speed multiplier.",
|
||||
)
|
||||
delivery_mode: Literal["STABLE", "BALANCED", "CREATIVE"] = Field(
|
||||
default="BALANCED",
|
||||
description=(
|
||||
"Controls stability versus expressiveness for inworld-tts-2 "
|
||||
"(STABLE, BALANCED, or CREATIVE)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@register_tts
|
||||
|
|
@ -947,9 +1050,10 @@ class SarvamTTSConfiguration(BaseTTSConfiguration):
|
|||
)
|
||||
voice: str = Field(
|
||||
default="anushka",
|
||||
description="Sarvam voice name; must match the selected model's voice list.",
|
||||
description="Sarvam voice name or custom voice ID.",
|
||||
json_schema_extra={
|
||||
"examples": SARVAM_V2_VOICES,
|
||||
"allow_custom_input": True,
|
||||
"model_options": {
|
||||
"bulbul:v2": SARVAM_V2_VOICES,
|
||||
"bulbul:v3": SARVAM_V3_VOICES,
|
||||
|
|
@ -961,6 +1065,12 @@ class SarvamTTSConfiguration(BaseTTSConfiguration):
|
|||
description="BCP-47 Indian-language code (e.g. hi-IN, en-IN).",
|
||||
json_schema_extra={"examples": SARVAM_LANGUAGES},
|
||||
)
|
||||
speed: float = Field(
|
||||
default=1.0,
|
||||
ge=0.5,
|
||||
le=2.0,
|
||||
description="Speech speed multiplier.",
|
||||
)
|
||||
|
||||
|
||||
CAMB_TTS_MODELS = ["mars-flash", "mars-pro", "mars-instruct"]
|
||||
|
|
@ -1120,6 +1230,50 @@ class AzureSpeechTTSConfiguration(BaseTTSConfiguration):
|
|||
)
|
||||
|
||||
|
||||
SMALLEST_PROVIDER_MODEL_CONFIG = provider_model_config(
|
||||
"Smallest AI",
|
||||
description="Smallest AI ultralow-latency TTS (Waves) and STT (Pulse) APIs.",
|
||||
provider_docs_url="https://smallest.ai/docs",
|
||||
)
|
||||
|
||||
|
||||
@register_tts
|
||||
class SmallestAITTSConfiguration(BaseTTSConfiguration):
|
||||
model_config = SMALLEST_PROVIDER_MODEL_CONFIG
|
||||
provider: Literal[ServiceProviders.SMALLEST] = ServiceProviders.SMALLEST
|
||||
model: str = Field(
|
||||
default="lightning_v3.1",
|
||||
description="Smallest AI TTS model. lightning_v3.1_pro is the premium pool (American, British, Indian accents); lightning_v3.1 is the standard pool with 217 voices across 12 languages.",
|
||||
json_schema_extra={"examples": SMALLEST_TTS_MODELS},
|
||||
)
|
||||
voice: str = Field(
|
||||
default="sophia",
|
||||
description="Smallest AI voice ID. Available voices differ by model: lightning_v3.1 has a broad multilingual pool; lightning_v3.1_pro has premium American, British, and Indian accent voices (English + Hindi only).",
|
||||
json_schema_extra={
|
||||
"examples": list(SMALLEST_TTS_VOICES),
|
||||
"allow_custom_input": True,
|
||||
"model_options": {
|
||||
"lightning_v3.1": list(SMALLEST_TTS_VOICES),
|
||||
"lightning_v3.1_pro": list(SMALLEST_TTS_PRO_VOICES),
|
||||
},
|
||||
},
|
||||
)
|
||||
language: str = Field(
|
||||
default="en",
|
||||
description="ISO 639-1 language code for synthesis.",
|
||||
json_schema_extra={
|
||||
"examples": SMALLEST_TTS_LANGUAGES,
|
||||
"allow_custom_input": True,
|
||||
},
|
||||
)
|
||||
speed: float = Field(
|
||||
default=1.0,
|
||||
ge=0.5,
|
||||
le=2.0,
|
||||
description="Speech speed multiplier (0.5 to 2.0).",
|
||||
)
|
||||
|
||||
|
||||
TTSConfig = Annotated[
|
||||
Union[
|
||||
DeepgramTTSConfiguration,
|
||||
|
|
@ -1127,6 +1281,7 @@ TTSConfig = Annotated[
|
|||
OpenAITTSService,
|
||||
ElevenlabsTTSConfiguration,
|
||||
CartesiaTTSConfiguration,
|
||||
InworldTTSConfiguration,
|
||||
DograhTTSService,
|
||||
SarvamTTSConfiguration,
|
||||
CambTTSConfiguration,
|
||||
|
|
@ -1134,6 +1289,7 @@ TTSConfig = Annotated[
|
|||
SpeachesTTSConfiguration,
|
||||
MiniMaxTTSConfiguration,
|
||||
AzureSpeechTTSConfiguration,
|
||||
SmallestAITTSConfiguration,
|
||||
],
|
||||
Field(discriminator="provider"),
|
||||
]
|
||||
|
|
@ -1152,12 +1308,16 @@ class DeepgramSTTConfiguration(BaseSTTConfiguration):
|
|||
)
|
||||
language: str = Field(
|
||||
default="multi",
|
||||
description="Language code; 'multi' enables auto-detect (Nova-3 only).",
|
||||
description=(
|
||||
"Language code. 'multi' enables Nova-3 auto-detect and omits "
|
||||
"language hints for Flux multilingual auto-detect."
|
||||
),
|
||||
json_schema_extra={
|
||||
"examples": DEEPGRAM_LANGUAGES,
|
||||
"model_options": {
|
||||
"nova-3-general": DEEPGRAM_LANGUAGES,
|
||||
"flux-general-en": ("en",),
|
||||
"flux-general-multi": DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
@ -1334,6 +1494,38 @@ class SpeachesSTTConfiguration(BaseSTTConfiguration):
|
|||
)
|
||||
|
||||
|
||||
HUGGINGFACE_STT_MODELS = [
|
||||
"openai/whisper-large-v3-turbo",
|
||||
"openai/whisper-large-v3",
|
||||
]
|
||||
|
||||
|
||||
@register_stt
|
||||
class HuggingFaceSTTConfiguration(BaseSTTConfiguration):
|
||||
model_config = HUGGINGFACE_PROVIDER_MODEL_CONFIG
|
||||
provider: Literal[ServiceProviders.HUGGINGFACE] = ServiceProviders.HUGGINGFACE
|
||||
model: str = Field(
|
||||
default="openai/whisper-large-v3-turbo",
|
||||
description="Hugging Face ASR model identifier served through Inference Providers.",
|
||||
json_schema_extra={
|
||||
"examples": HUGGINGFACE_STT_MODELS,
|
||||
"allow_custom_input": True,
|
||||
},
|
||||
)
|
||||
base_url: str = Field(
|
||||
default="https://router.huggingface.co/hf-inference",
|
||||
description="Hugging Face Inference Providers router base URL.",
|
||||
)
|
||||
bill_to: str | None = Field(
|
||||
default=None,
|
||||
description="Optional Hugging Face organization or user to bill using X-HF-Bill-To.",
|
||||
)
|
||||
return_timestamps: bool = Field(
|
||||
default=False,
|
||||
description="Request timestamp chunks when supported by the selected provider/model.",
|
||||
)
|
||||
|
||||
|
||||
ASSEMBLYAI_STT_MODELS = ["u3-rt-pro"]
|
||||
ASSEMBLYAI_STT_LANGUAGES = ["en", "es", "de", "fr", "pt", "it"]
|
||||
|
||||
|
|
@ -1396,6 +1588,62 @@ class AzureSpeechSTTConfiguration(BaseSTTConfiguration):
|
|||
)
|
||||
|
||||
|
||||
SMALLEST_STT_MODELS = ["pulse"]
|
||||
SMALLEST_STT_LANGUAGES = [
|
||||
"en",
|
||||
"hi",
|
||||
"fr",
|
||||
"de",
|
||||
"es",
|
||||
"it",
|
||||
"nl",
|
||||
"pl",
|
||||
"ru",
|
||||
"pt",
|
||||
"bn",
|
||||
"gu",
|
||||
"kn",
|
||||
"ml",
|
||||
"mr",
|
||||
"ta",
|
||||
"te",
|
||||
"pa",
|
||||
"or",
|
||||
"bg",
|
||||
"cs",
|
||||
"da",
|
||||
"et",
|
||||
"fi",
|
||||
"hu",
|
||||
"lt",
|
||||
"lv",
|
||||
"mt",
|
||||
"ro",
|
||||
"sk",
|
||||
"sv",
|
||||
"uk",
|
||||
]
|
||||
|
||||
|
||||
@register_stt
|
||||
class SmallestAISTTConfiguration(BaseSTTConfiguration):
|
||||
model_config = SMALLEST_PROVIDER_MODEL_CONFIG
|
||||
provider: Literal[ServiceProviders.SMALLEST] = ServiceProviders.SMALLEST
|
||||
model: str = Field(
|
||||
default="pulse",
|
||||
description="Smallest AI STT model. Supports 38 languages with real-time streaming.",
|
||||
json_schema_extra={"examples": SMALLEST_STT_MODELS},
|
||||
)
|
||||
language: str = Field(
|
||||
default="en",
|
||||
description="ISO 639-1 language code for transcription.",
|
||||
json_schema_extra={
|
||||
"examples": SMALLEST_STT_LANGUAGES,
|
||||
"allow_custom_input": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
STTConfig = Annotated[
|
||||
Union[
|
||||
DeepgramSTTConfiguration,
|
||||
|
|
@ -1406,9 +1654,11 @@ STTConfig = Annotated[
|
|||
SpeechmaticsSTTConfiguration,
|
||||
SarvamSTTConfiguration,
|
||||
SpeachesSTTConfiguration,
|
||||
HuggingFaceSTTConfiguration,
|
||||
AssemblyAISTTConfiguration,
|
||||
GladiaSTTConfiguration,
|
||||
AzureSpeechSTTConfiguration,
|
||||
SmallestAISTTConfiguration,
|
||||
],
|
||||
Field(discriminator="provider"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -40,10 +40,7 @@ from api.services.workflow.node_specs.model_spec import (
|
|||
)
|
||||
],
|
||||
graph_constraints=GraphConstraints(
|
||||
min_incoming=0,
|
||||
max_incoming=0,
|
||||
min_outgoing=0,
|
||||
max_outgoing=0,
|
||||
min_incoming=0, max_incoming=0, min_outgoing=0, max_outgoing=0, max_instances=1
|
||||
),
|
||||
property_order=(
|
||||
"name",
|
||||
|
|
|
|||
|
|
@ -512,7 +512,7 @@ class MPSServiceKeyClient:
|
|||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
logger.error(
|
||||
logger.warning(
|
||||
"Failed to authorize MPS workflow run start: "
|
||||
f"{response.status_code} - {response.text}"
|
||||
)
|
||||
|
|
@ -601,16 +601,15 @@ class MPSServiceKeyClient:
|
|||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
should_retry = (
|
||||
response.status_code == 409
|
||||
and "usage_not_ready" in response.text
|
||||
and attempt < max_attempts
|
||||
usage_not_ready = (
|
||||
response.status_code == 409 and "usage_not_ready" in response.text
|
||||
)
|
||||
if should_retry:
|
||||
if usage_not_ready and attempt < max_attempts:
|
||||
await asyncio.sleep(attempt)
|
||||
continue
|
||||
|
||||
logger.error(
|
||||
log = logger.warning if usage_not_ready else logger.error
|
||||
log(
|
||||
"Failed to report platform usage: "
|
||||
f"{response.status_code} - {response.text}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ from api.services.integrations import IntegrationRuntimeSession
|
|||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.audio_playback import play_audio_loop
|
||||
from api.services.pipecat.in_memory_buffers import (
|
||||
InMemoryAudioBuffer,
|
||||
InMemoryLogsBuffer,
|
||||
InMemoryRecordingBuffers,
|
||||
)
|
||||
from api.services.pipecat.pipeline_metrics_aggregator import PipelineMetricsAggregator
|
||||
from api.services.pipecat.tracing_config import get_trace_url
|
||||
|
|
@ -40,11 +40,11 @@ async def _capture_call_event(
|
|||
"workflow_run_id": workflow_run_id,
|
||||
"workflow_id": workflow_run.workflow_id if workflow_run else None,
|
||||
"call_type": workflow_run.mode if workflow_run else None,
|
||||
"call_direction": (workflow_run.initial_context or {}).get(
|
||||
"direction", "outbound"
|
||||
)
|
||||
if workflow_run
|
||||
else None,
|
||||
"call_direction": (
|
||||
(workflow_run.initial_context or {}).get("direction", "outbound")
|
||||
if workflow_run
|
||||
else None
|
||||
),
|
||||
}
|
||||
if extra_properties:
|
||||
properties.update(extra_properties)
|
||||
|
|
@ -73,7 +73,7 @@ def register_event_handlers(
|
|||
"""Register all event handlers for transport and task events.
|
||||
|
||||
Returns:
|
||||
in_memory_audio_buffer for use by other handlers.
|
||||
In-memory recording buffers for use by other handlers.
|
||||
"""
|
||||
# Initialize in-memory buffers with proper audio configuration
|
||||
sample_rate = audio_config.pipeline_sample_rate if audio_config else 16000
|
||||
|
|
@ -84,7 +84,7 @@ def register_event_handlers(
|
|||
f"with sample_rate={sample_rate}Hz, channels={num_channels}"
|
||||
)
|
||||
|
||||
in_memory_audio_buffer = InMemoryAudioBuffer(
|
||||
in_memory_audio_buffers = InMemoryRecordingBuffers(
|
||||
workflow_run_id=workflow_run_id,
|
||||
sample_rate=sample_rate,
|
||||
num_channels=num_channels,
|
||||
|
|
@ -363,14 +363,32 @@ def register_event_handlers(
|
|||
|
||||
# Write buffers to temp files and enqueue combined processing task
|
||||
audio_temp_path = None
|
||||
user_audio_temp_path = None
|
||||
bot_audio_temp_path = None
|
||||
transcript_temp_path = None
|
||||
|
||||
try:
|
||||
if not in_memory_audio_buffer.is_empty:
|
||||
audio_temp_path = await in_memory_audio_buffer.write_to_temp_file()
|
||||
if not in_memory_audio_buffers.mixed.is_empty:
|
||||
audio_temp_path = (
|
||||
await in_memory_audio_buffers.mixed.write_to_temp_file()
|
||||
)
|
||||
else:
|
||||
logger.debug("Audio buffer is empty, skipping upload")
|
||||
|
||||
if not in_memory_audio_buffers.user.is_empty:
|
||||
user_audio_temp_path = (
|
||||
await in_memory_audio_buffers.user.write_to_temp_file()
|
||||
)
|
||||
else:
|
||||
logger.debug("User audio buffer is empty, skipping upload")
|
||||
|
||||
if not in_memory_audio_buffers.bot.is_empty:
|
||||
bot_audio_temp_path = (
|
||||
await in_memory_audio_buffers.bot.write_to_temp_file()
|
||||
)
|
||||
else:
|
||||
logger.debug("Bot audio buffer is empty, skipping upload")
|
||||
|
||||
transcript_temp_path = in_memory_logs_buffer.write_transcript_to_temp_file()
|
||||
if not transcript_temp_path:
|
||||
logger.debug("No transcript events in logs buffer, skipping upload")
|
||||
|
|
@ -385,16 +403,18 @@ def register_event_handlers(
|
|||
workflow_run_id,
|
||||
audio_temp_path,
|
||||
transcript_temp_path,
|
||||
user_audio_temp_path,
|
||||
bot_audio_temp_path,
|
||||
)
|
||||
|
||||
# Return the buffer so it can be passed to other handlers
|
||||
return in_memory_audio_buffer
|
||||
return in_memory_audio_buffers
|
||||
|
||||
|
||||
def register_audio_data_handler(
|
||||
audio_buffer: AudioBufferProcessor,
|
||||
workflow_run_id,
|
||||
in_memory_buffer: InMemoryAudioBuffer,
|
||||
in_memory_buffers: InMemoryRecordingBuffers,
|
||||
):
|
||||
"""Register event handler for audio data"""
|
||||
logger.info(f"Registering audio data handler for workflow run {workflow_run_id}")
|
||||
|
|
@ -404,9 +424,19 @@ def register_audio_data_handler(
|
|||
if not audio:
|
||||
return
|
||||
|
||||
# Use in-memory buffer
|
||||
try:
|
||||
await in_memory_buffer.append(audio)
|
||||
await in_memory_buffers.mixed.append(audio)
|
||||
except MemoryError as e:
|
||||
logger.error(f"Memory buffer full: {e}")
|
||||
# Could implement overflow to disk here if needed
|
||||
logger.error(f"Mixed audio buffer full: {e}")
|
||||
|
||||
@audio_buffer.event_handler("on_track_audio_data")
|
||||
async def on_track_audio_data(
|
||||
buffer, user_audio, bot_audio, sample_rate, num_channels
|
||||
):
|
||||
try:
|
||||
if user_audio:
|
||||
await in_memory_buffers.user.append(user_audio)
|
||||
if bot_audio:
|
||||
await in_memory_buffers.bot.append(bot_audio)
|
||||
except MemoryError as e:
|
||||
logger.error(f"Track audio buffer full: {e}")
|
||||
|
|
|
|||
|
|
@ -75,6 +75,27 @@ class InMemoryAudioBuffer:
|
|||
return self._total_size
|
||||
|
||||
|
||||
class InMemoryRecordingBuffers:
|
||||
"""Holds the mixed recording plus aligned user and bot mono tracks."""
|
||||
|
||||
def __init__(self, workflow_run_id: int, sample_rate: int, num_channels: int = 1):
|
||||
self.mixed = InMemoryAudioBuffer(
|
||||
workflow_run_id=workflow_run_id,
|
||||
sample_rate=sample_rate,
|
||||
num_channels=num_channels,
|
||||
)
|
||||
self.user = InMemoryAudioBuffer(
|
||||
workflow_run_id=workflow_run_id,
|
||||
sample_rate=sample_rate,
|
||||
num_channels=1,
|
||||
)
|
||||
self.bot = InMemoryAudioBuffer(
|
||||
workflow_run_id=workflow_run_id,
|
||||
sample_rate=sample_rate,
|
||||
num_channels=1,
|
||||
)
|
||||
|
||||
|
||||
class InMemoryLogsBuffer:
|
||||
"""Buffer real-time feedback events in memory during a call, then save to workflow run logs."""
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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,
|
||||
|
|
@ -469,7 +470,10 @@ async def _run_pipeline(
|
|||
workflow_run_id, initial_context=merged_call_context_vars
|
||||
)
|
||||
|
||||
workflow_graph = WorkflowGraph(ReactFlowDTO.model_validate(run_workflow_json))
|
||||
workflow_graph = WorkflowGraph(
|
||||
ReactFlowDTO.model_validate(run_workflow_json),
|
||||
skip_instance_constraints_for={"trigger"},
|
||||
)
|
||||
|
||||
# Pre-call fetch: fire early so it runs concurrently with remaining setup
|
||||
pre_call_fetch_task = None
|
||||
|
|
@ -626,7 +630,7 @@ async def _run_pipeline(
|
|||
# Other models use configurable turn detection strategy
|
||||
is_deepgram_flux = (
|
||||
user_config.stt.provider == ServiceProviders.DEEPGRAM.value
|
||||
and user_config.stt.model == "flux-general-en"
|
||||
and user_config.stt.model in DEEPGRAM_FLUX_MODELS
|
||||
)
|
||||
|
||||
if is_deepgram_flux:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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.registry import ServiceProviders
|
||||
from api.services.pipecat.minimax_tts import MiniMaxOwnedSessionTTSService
|
||||
from api.utils.url_security import validate_user_configured_service_url
|
||||
|
|
@ -39,8 +40,18 @@ from pipecat.services.google.vertex.llm import (
|
|||
GoogleVertexLLMSettings,
|
||||
)
|
||||
from pipecat.services.groq.llm import GroqLLMService, GroqLLMSettings
|
||||
from pipecat.services.huggingface.llm import (
|
||||
HuggingFaceLLMService,
|
||||
HuggingFaceLLMSettings,
|
||||
)
|
||||
from pipecat.services.huggingface.stt import (
|
||||
HuggingFaceSTTService,
|
||||
HuggingFaceSTTSettings,
|
||||
)
|
||||
from pipecat.services.inworld.tts import InworldTTSService, InworldTTSSettings
|
||||
from pipecat.services.minimax.llm import MiniMaxLLMService
|
||||
from pipecat.services.minimax.tts import MiniMaxTTSSettings
|
||||
from pipecat.services.openai._constants import OPENAI_SAMPLE_RATE
|
||||
from pipecat.services.openai.base_llm import OpenAILLMSettings
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
from pipecat.services.openai.stt import (
|
||||
|
|
@ -53,6 +64,8 @@ from pipecat.services.rime.tts import RimeTTSService, RimeTTSSettings
|
|||
from pipecat.services.sarvam.llm import SarvamLLMService, SarvamLLMSettings
|
||||
from pipecat.services.sarvam.stt import SarvamSTTService, SarvamSTTSettings
|
||||
from pipecat.services.sarvam.tts import SarvamTTSService, SarvamTTSSettings
|
||||
from pipecat.services.smallest.stt import SmallestSTTService, SmallestSTTSettings
|
||||
from pipecat.services.smallest.tts import SmallestTTSService, SmallestTTSSettings
|
||||
from pipecat.services.speaches.llm import SpeachesLLMService, SpeachesLLMSettings
|
||||
from pipecat.services.speaches.stt import SpeachesSTTService, SpeachesSTTSettings
|
||||
from pipecat.services.speaches.tts import SpeachesTTSService, SpeachesTTSSettings
|
||||
|
|
@ -67,6 +80,20 @@ if TYPE_CHECKING:
|
|||
from api.services.pipecat.audio_config import AudioConfig
|
||||
|
||||
|
||||
DEEPGRAM_FLUX_LANGUAGE_HINTS = {
|
||||
"de": Language.DE,
|
||||
"en": Language.EN,
|
||||
"es": Language.ES,
|
||||
"fr": Language.FR,
|
||||
"hi": Language.HI,
|
||||
"it": Language.IT,
|
||||
"ja": Language.JA,
|
||||
"nl": Language.NL,
|
||||
"pt": Language.PT,
|
||||
"ru": Language.RU,
|
||||
}
|
||||
|
||||
|
||||
def _validate_runtime_service_url(url: str, field_name: str) -> None:
|
||||
try:
|
||||
validate_user_configured_service_url(
|
||||
|
|
@ -93,17 +120,23 @@ def create_stt_service(
|
|||
f"Creating STT service: provider={user_config.stt.provider}, model={user_config.stt.model}"
|
||||
)
|
||||
if user_config.stt.provider == ServiceProviders.DEEPGRAM.value:
|
||||
# Check if using Flux model (English-only, no language selection)
|
||||
if user_config.stt.model == "flux-general-en":
|
||||
if user_config.stt.model in DEEPGRAM_FLUX_MODELS:
|
||||
settings_kwargs = {
|
||||
"model": user_config.stt.model,
|
||||
"eot_timeout_ms": 3000,
|
||||
"eot_threshold": 0.7,
|
||||
"eager_eot_threshold": 0.5,
|
||||
"keyterm": keyterms or [],
|
||||
}
|
||||
if user_config.stt.model == "flux-general-multi":
|
||||
language = getattr(user_config.stt, "language", None)
|
||||
language_hint = DEEPGRAM_FLUX_LANGUAGE_HINTS.get(language)
|
||||
if language_hint:
|
||||
settings_kwargs["language_hints"] = [language_hint]
|
||||
|
||||
return DeepgramFluxSTTService(
|
||||
api_key=user_config.stt.api_key,
|
||||
settings=DeepgramFluxSTTSettings(
|
||||
model=user_config.stt.model,
|
||||
eot_timeout_ms=3000,
|
||||
eot_threshold=0.7,
|
||||
eager_eot_threshold=0.5,
|
||||
keyterm=keyterms or [],
|
||||
),
|
||||
settings=DeepgramFluxSTTSettings(**settings_kwargs),
|
||||
should_interrupt=False, # Let UserAggregator take care of sending InterruptionFrame
|
||||
sample_rate=audio_config.transport_in_sample_rate,
|
||||
)
|
||||
|
|
@ -218,6 +251,22 @@ def create_stt_service(
|
|||
),
|
||||
sample_rate=audio_config.transport_in_sample_rate,
|
||||
)
|
||||
elif user_config.stt.provider == ServiceProviders.HUGGINGFACE.value:
|
||||
base_url = (
|
||||
getattr(user_config.stt, "base_url", None)
|
||||
or "https://router.huggingface.co/hf-inference"
|
||||
)
|
||||
_validate_runtime_service_url(base_url, "base_url")
|
||||
return HuggingFaceSTTService(
|
||||
api_key=user_config.stt.api_key,
|
||||
base_url=base_url,
|
||||
bill_to=getattr(user_config.stt, "bill_to", None),
|
||||
settings=HuggingFaceSTTSettings(
|
||||
model=user_config.stt.model,
|
||||
return_timestamps=getattr(user_config.stt, "return_timestamps", False),
|
||||
),
|
||||
sample_rate=audio_config.transport_in_sample_rate,
|
||||
)
|
||||
elif user_config.stt.provider == ServiceProviders.ASSEMBLYAI.value:
|
||||
language = getattr(user_config.stt, "language", None)
|
||||
settings_kwargs = {"model": user_config.stt.model, "language": language}
|
||||
|
|
@ -284,6 +333,20 @@ def create_stt_service(
|
|||
settings=AzureSTTSettings(language=pipecat_language),
|
||||
sample_rate=audio_config.transport_in_sample_rate,
|
||||
)
|
||||
elif user_config.stt.provider == ServiceProviders.SMALLEST.value:
|
||||
language_code = getattr(user_config.stt, "language", None) or "en"
|
||||
try:
|
||||
pipecat_language = Language(language_code)
|
||||
except ValueError:
|
||||
pipecat_language = Language.EN
|
||||
return SmallestSTTService(
|
||||
api_key=user_config.stt.api_key,
|
||||
settings=SmallestSTTSettings(
|
||||
model=user_config.stt.model,
|
||||
language=pipecat_language,
|
||||
),
|
||||
sample_rate=audio_config.transport_in_sample_rate,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid STT provider {user_config.stt.provider}"
|
||||
|
|
@ -320,6 +383,7 @@ def create_tts_service(
|
|||
kwargs["base_url"] = base_url
|
||||
return OpenAITTSService(
|
||||
api_key=user_config.tts.api_key,
|
||||
sample_rate=OPENAI_SAMPLE_RATE,
|
||||
settings=OpenAITTSSettings(model=user_config.tts.model),
|
||||
text_filters=[xml_function_tag_filter],
|
||||
skip_aggregator_types=["recording_router", "recording"],
|
||||
|
|
@ -389,11 +453,13 @@ def create_tts_service(
|
|||
generation_config = (
|
||||
GenerationConfig(**gen_config_kwargs) if gen_config_kwargs else None
|
||||
)
|
||||
language = getattr(user_config.tts, "language", None) or "en"
|
||||
return CartesiaTTSService(
|
||||
api_key=user_config.tts.api_key,
|
||||
settings=CartesiaTTSSettings(
|
||||
voice=user_config.tts.voice,
|
||||
model=user_config.tts.model,
|
||||
language=language,
|
||||
**(
|
||||
{"generation_config": generation_config}
|
||||
if generation_config
|
||||
|
|
@ -404,6 +470,25 @@ def create_tts_service(
|
|||
skip_aggregator_types=["recording_router", "recording"],
|
||||
silence_time_s=1.0,
|
||||
)
|
||||
elif user_config.tts.provider == ServiceProviders.INWORLD.value:
|
||||
voice = getattr(user_config.tts, "voice", None) or "Ashley"
|
||||
model = getattr(user_config.tts, "model", None) or "inworld-tts-2"
|
||||
speed = getattr(user_config.tts, "speed", None)
|
||||
language = getattr(user_config.tts, "language", None) or "en-US"
|
||||
delivery_mode = getattr(user_config.tts, "delivery_mode", None) or "BALANCED"
|
||||
return InworldTTSService(
|
||||
api_key=user_config.tts.api_key,
|
||||
settings=InworldTTSSettings(
|
||||
voice=voice,
|
||||
model=model,
|
||||
language=language,
|
||||
speaking_rate=speed,
|
||||
delivery_mode=delivery_mode,
|
||||
),
|
||||
text_filters=[xml_function_tag_filter],
|
||||
skip_aggregator_types=["recording_router", "recording"],
|
||||
silence_time_s=1.0,
|
||||
)
|
||||
elif user_config.tts.provider == ServiceProviders.DOGRAH.value:
|
||||
# Convert HTTP URL to WebSocket URL for TTS
|
||||
base_url = MPS_API_URL.replace("http://", "ws://").replace("https://", "wss://")
|
||||
|
|
@ -492,14 +577,20 @@ def create_tts_service(
|
|||
language = getattr(user_config.tts, "language", None)
|
||||
pipecat_language = language_mapping.get(language, Language.HI)
|
||||
|
||||
voice = getattr(user_config.tts, "voice", None) or "anushka"
|
||||
voice = (
|
||||
getattr(user_config.tts, "voice", None) or ""
|
||||
).strip().lower() or "anushka"
|
||||
speed = getattr(user_config.tts, "speed", None)
|
||||
settings_kwargs = {
|
||||
"model": user_config.tts.model,
|
||||
"voice": voice,
|
||||
"language": pipecat_language,
|
||||
}
|
||||
if speed and speed != 1.0:
|
||||
settings_kwargs["pace"] = speed
|
||||
return SarvamTTSService(
|
||||
api_key=user_config.tts.api_key,
|
||||
settings=SarvamTTSSettings(
|
||||
model=user_config.tts.model,
|
||||
voice=voice,
|
||||
language=pipecat_language,
|
||||
),
|
||||
settings=SarvamTTSSettings(**settings_kwargs),
|
||||
text_filters=[xml_function_tag_filter],
|
||||
skip_aggregator_types=["recording_router", "recording"],
|
||||
silence_time_s=1.0,
|
||||
|
|
@ -560,6 +651,28 @@ def create_tts_service(
|
|||
skip_aggregator_types=["recording_router", "recording"],
|
||||
silence_time_s=1.0,
|
||||
)
|
||||
elif user_config.tts.provider == ServiceProviders.SMALLEST.value:
|
||||
language_code = getattr(user_config.tts, "language", None) or "en"
|
||||
try:
|
||||
pipecat_language = Language(language_code)
|
||||
except ValueError:
|
||||
pipecat_language = Language.EN
|
||||
speed = getattr(user_config.tts, "speed", None)
|
||||
model = user_config.tts.model.replace("lightning-v", "lightning_v")
|
||||
settings_kwargs = SmallestTTSSettings(
|
||||
model=model,
|
||||
voice=user_config.tts.voice,
|
||||
language=pipecat_language,
|
||||
)
|
||||
if speed and speed != 1.0:
|
||||
settings_kwargs.speed = speed
|
||||
return SmallestTTSService(
|
||||
api_key=user_config.tts.api_key,
|
||||
settings=settings_kwargs,
|
||||
text_filters=[xml_function_tag_filter],
|
||||
skip_aggregator_types=["recording_router", "recording"],
|
||||
silence_time_s=1.0,
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid TTS provider {user_config.tts.provider}"
|
||||
|
|
@ -581,6 +694,7 @@ def create_llm_service_from_provider(
|
|||
location: str | None = None,
|
||||
credentials: str | None = None,
|
||||
temperature: float | None = None,
|
||||
bill_to: str | None = None,
|
||||
):
|
||||
"""Create an LLM service from explicit provider/model/api_key.
|
||||
|
||||
|
|
@ -663,6 +777,15 @@ def create_llm_service_from_provider(
|
|||
api_key=api_key or "none",
|
||||
settings=SpeachesLLMSettings(model=model),
|
||||
)
|
||||
elif provider == ServiceProviders.HUGGINGFACE.value:
|
||||
base_url = base_url or "https://router.huggingface.co/v1"
|
||||
_validate_runtime_service_url(base_url, "base_url")
|
||||
return HuggingFaceLLMService(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
bill_to=bill_to,
|
||||
settings=HuggingFaceLLMSettings(model=model, temperature=0.1),
|
||||
)
|
||||
elif provider == ServiceProviders.MINIMAX.value:
|
||||
base_url = base_url or "https://api.minimax.io/v1"
|
||||
_validate_runtime_service_url(base_url, "base_url")
|
||||
|
|
@ -875,6 +998,9 @@ def create_llm_service(user_config, correlation_id: str | None = None):
|
|||
kwargs["endpoint"] = user_config.llm.endpoint
|
||||
elif provider == ServiceProviders.SPEACHES.value:
|
||||
kwargs["base_url"] = user_config.llm.base_url
|
||||
elif provider == ServiceProviders.HUGGINGFACE.value:
|
||||
kwargs["base_url"] = user_config.llm.base_url
|
||||
kwargs["bill_to"] = user_config.llm.bill_to
|
||||
elif provider == ServiceProviders.AWS_BEDROCK.value:
|
||||
kwargs["aws_access_key"] = user_config.llm.aws_access_key
|
||||
kwargs["aws_secret_key"] = user_config.llm.aws_secret_key
|
||||
|
|
|
|||
|
|
@ -1,31 +1,95 @@
|
|||
from typing import Any, Optional
|
||||
|
||||
from loguru import logger
|
||||
from posthog import Posthog
|
||||
|
||||
from api.constants import ENABLE_TELEMETRY, POSTHOG_API_KEY, POSTHOG_HOST
|
||||
from api.constants import POSTHOG_API_KEY, POSTHOG_HOST
|
||||
|
||||
_posthog_client: Posthog | None = None
|
||||
POSTHOG_SERVER_GROUP_IDENTIFY_DISTINCT_ID = "server-group-identify"
|
||||
|
||||
|
||||
def get_posthog() -> Posthog | None:
|
||||
"""Return the lazily-initialised PostHog client, or None if not configured."""
|
||||
global _posthog_client
|
||||
if _posthog_client is None and POSTHOG_API_KEY and ENABLE_TELEMETRY:
|
||||
if _posthog_client is None and POSTHOG_API_KEY:
|
||||
_posthog_client = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST)
|
||||
return _posthog_client
|
||||
|
||||
|
||||
def shutdown_posthog() -> None:
|
||||
"""Flush queued PostHog messages before a short-lived process exits."""
|
||||
client = get_posthog()
|
||||
if not client:
|
||||
return
|
||||
try:
|
||||
client.shutdown()
|
||||
except Exception:
|
||||
logger.exception("Failed to shut down PostHog client")
|
||||
|
||||
|
||||
def flush_posthog() -> None:
|
||||
"""Flush queued PostHog messages without shutting down the client."""
|
||||
client = get_posthog()
|
||||
if not client:
|
||||
return
|
||||
try:
|
||||
client.flush()
|
||||
except Exception:
|
||||
logger.exception("Failed to flush PostHog client")
|
||||
|
||||
|
||||
def capture_event(
|
||||
distinct_id: str,
|
||||
event: str,
|
||||
properties: dict | None = None,
|
||||
properties: dict[str, Any] | None = None,
|
||||
groups: Optional[dict[str, str]] = None,
|
||||
) -> None:
|
||||
"""Fire a PostHog event. Silently no-ops if PostHog is not configured."""
|
||||
client = get_posthog()
|
||||
if not client:
|
||||
return
|
||||
try:
|
||||
client.capture(
|
||||
distinct_id=distinct_id, event=event, properties=properties or {}
|
||||
)
|
||||
kwargs: dict[str, Any] = {
|
||||
"distinct_id": distinct_id,
|
||||
"event": event,
|
||||
"properties": properties or {},
|
||||
}
|
||||
if groups:
|
||||
kwargs["groups"] = groups
|
||||
client.capture(**kwargs)
|
||||
except Exception:
|
||||
logger.exception(f"Failed to send PostHog event '{event}'")
|
||||
|
||||
|
||||
def group_identify(
|
||||
group_type: str,
|
||||
group_key: str,
|
||||
properties: dict[str, Any],
|
||||
*,
|
||||
distinct_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Set PostHog group properties. Silently no-ops if PostHog is not configured."""
|
||||
client = get_posthog()
|
||||
if not client:
|
||||
return
|
||||
try:
|
||||
client.group_identify(
|
||||
group_type,
|
||||
group_key,
|
||||
properties,
|
||||
distinct_id=distinct_id or POSTHOG_SERVER_GROUP_IDENTIFY_DISTINCT_ID,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to identify PostHog group")
|
||||
|
||||
|
||||
def set_person_properties(distinct_id: str, properties: dict[str, Any]) -> None:
|
||||
"""Set PostHog person properties. Silently no-ops if PostHog is not configured."""
|
||||
client = get_posthog()
|
||||
if not client:
|
||||
return
|
||||
try:
|
||||
client.set(distinct_id=distinct_id, properties=properties)
|
||||
except Exception:
|
||||
logger.exception("Failed to set PostHog person properties")
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ BILLING_V2_QUOTA_EXCEEDED_MESSAGE = (
|
|||
"or change providers in Models configurations."
|
||||
)
|
||||
|
||||
SERVICE_TOKEN_ORG_MISMATCH_MESSAGE = (
|
||||
"The Dograh service token being used is created from another account. "
|
||||
"Please create a new service token from the Developers tab and use it in "
|
||||
"your model configuration."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuotaCheckResult:
|
||||
|
|
@ -98,6 +104,26 @@ def _dograh_api_keys(user_config: Any) -> set[str]:
|
|||
return api_keys
|
||||
|
||||
|
||||
def _is_service_key_org_mismatch_error(error: Exception) -> bool:
|
||||
response = getattr(error, "response", None)
|
||||
if getattr(response, "status_code", None) != 403:
|
||||
return False
|
||||
|
||||
detail: Any = None
|
||||
try:
|
||||
payload = response.json()
|
||||
if isinstance(payload, dict):
|
||||
detail = payload.get("detail")
|
||||
except Exception:
|
||||
detail = None
|
||||
|
||||
if isinstance(detail, str):
|
||||
return detail.lower() == "service key organization mismatch"
|
||||
|
||||
response_text = getattr(response, "text", "")
|
||||
return "Service key organization mismatch" in response_text
|
||||
|
||||
|
||||
async def _store_run_correlation_id(
|
||||
workflow_run_id: int | None,
|
||||
correlation_id: str | None,
|
||||
|
|
@ -173,11 +199,20 @@ async def _authorize_hosted_workflow_run_start(
|
|||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
logger.warning(
|
||||
"Failed to authorize workflow start with MPS for org {}: {}",
|
||||
organization_id,
|
||||
e,
|
||||
)
|
||||
if _is_service_key_org_mismatch_error(e):
|
||||
return (
|
||||
QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="service_key_org_mismatch",
|
||||
error_message=SERVICE_TOKEN_ORG_MISMATCH_MESSAGE,
|
||||
),
|
||||
True,
|
||||
)
|
||||
return (
|
||||
QuotaCheckResult(
|
||||
has_quota=False,
|
||||
|
|
|
|||
37
api/services/user_onboarding.py
Normal file
37
api/services/user_onboarding.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
from loguru import logger
|
||||
from pydantic import ValidationError
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import UserConfigurationKey
|
||||
from api.schemas.onboarding_state import OnboardingState, OnboardingStateUpdate
|
||||
|
||||
|
||||
async def get_onboarding_state(user_id: int) -> OnboardingState:
|
||||
value = await db_client.get_user_configuration_value(
|
||||
user_id, UserConfigurationKey.ONBOARDING.value
|
||||
)
|
||||
return _parse_state(value, user_id)
|
||||
|
||||
|
||||
async def update_onboarding_state(
|
||||
user_id: int, update: OnboardingStateUpdate
|
||||
) -> OnboardingState:
|
||||
state = update.apply_to(await get_onboarding_state(user_id))
|
||||
await db_client.upsert_user_configuration_value(
|
||||
user_id,
|
||||
UserConfigurationKey.ONBOARDING.value,
|
||||
state.model_dump(mode="json", exclude_none=True),
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
def _parse_state(value, user_id: int) -> OnboardingState:
|
||||
if not value or not isinstance(value, dict):
|
||||
return OnboardingState()
|
||||
try:
|
||||
return OnboardingState.model_validate(value)
|
||||
except ValidationError as exc:
|
||||
logger.warning(
|
||||
f"Invalid onboarding state for user {user_id}: {exc}. Returning defaults."
|
||||
)
|
||||
return OnboardingState()
|
||||
|
|
@ -7,15 +7,19 @@ script in `api/services/admin_utils/local_exec.py` is the production
|
|||
consumer.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from api.services.workflow.node_specs import all_specs
|
||||
|
||||
|
||||
def _build_type_rules() -> tuple[set[str], set[str]]:
|
||||
def _build_type_rules() -> tuple[set[str], set[str], dict[str, int], dict[str, int]]:
|
||||
"""From NodeSpec.graph_constraints, derive the set of types that are
|
||||
forbidden as edge sources (max_outgoing == 0) and as targets
|
||||
(max_incoming == 0)."""
|
||||
src_forbidden: set[str] = set()
|
||||
tgt_forbidden: set[str] = set()
|
||||
min_instances: dict[str, int] = {}
|
||||
max_instances: dict[str, int] = {}
|
||||
for spec in all_specs():
|
||||
gc = spec.graph_constraints
|
||||
if gc is None:
|
||||
|
|
@ -24,7 +28,11 @@ def _build_type_rules() -> tuple[set[str], set[str]]:
|
|||
src_forbidden.add(spec.name)
|
||||
if gc.max_incoming == 0:
|
||||
tgt_forbidden.add(spec.name)
|
||||
return src_forbidden, tgt_forbidden
|
||||
if gc.min_instances is not None:
|
||||
min_instances[spec.name] = gc.min_instances
|
||||
if gc.max_instances is not None:
|
||||
max_instances[spec.name] = gc.max_instances
|
||||
return src_forbidden, tgt_forbidden, min_instances, max_instances
|
||||
|
||||
|
||||
def _empty_violation(reason: str) -> dict:
|
||||
|
|
@ -49,7 +57,7 @@ def audit_definition(nodes, edges) -> list[dict]:
|
|||
if not isinstance(nodes, list) or not isinstance(edges, list):
|
||||
return []
|
||||
|
||||
src_forbidden, tgt_forbidden = _build_type_rules()
|
||||
src_forbidden, tgt_forbidden, min_instances, max_instances = _build_type_rules()
|
||||
nodes_by_id: dict = {}
|
||||
for n in nodes:
|
||||
if isinstance(n, dict) and "id" in n:
|
||||
|
|
@ -57,14 +65,25 @@ def audit_definition(nodes, edges) -> list[dict]:
|
|||
|
||||
violations: list[dict] = []
|
||||
|
||||
# Graph-level: WorkflowGraph._assert_start_node requires exactly one
|
||||
# startCall node. The DTO doesn't enforce this, so legacy or
|
||||
# script-edited rows can land in a state that fails at runtime.
|
||||
start_count = sum(1 for t in nodes_by_id.values() if t == "startCall")
|
||||
if start_count == 0:
|
||||
violations.append(_empty_violation("no_start_node"))
|
||||
elif start_count > 1:
|
||||
violations.append(_empty_violation(f"multiple_start_nodes:{start_count}"))
|
||||
node_counts = Counter(t for t in nodes_by_id.values() if isinstance(t, str))
|
||||
for node_type, min_count in min_instances.items():
|
||||
count = node_counts.get(node_type, 0)
|
||||
if count < min_count:
|
||||
reason = (
|
||||
"no_start_node"
|
||||
if node_type == "startCall" and min_count == 1
|
||||
else f"min_instances_{min_count}:{node_type}:{count}"
|
||||
)
|
||||
violations.append(_empty_violation(reason))
|
||||
for node_type, max_count in max_instances.items():
|
||||
count = node_counts.get(node_type, 0)
|
||||
if count > max_count:
|
||||
reason = (
|
||||
f"multiple_start_nodes:{count}"
|
||||
if node_type == "startCall" and max_count == 1
|
||||
else f"max_instances_{max_count}:{node_type}:{count}"
|
||||
)
|
||||
violations.append(_empty_violation(reason))
|
||||
for e in edges:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -196,7 +196,12 @@ class _ToolDocumentRefsMixin(BaseModel):
|
|||
},
|
||||
)
|
||||
],
|
||||
graph_constraints=GraphConstraints(min_incoming=0, max_incoming=0),
|
||||
graph_constraints=GraphConstraints(
|
||||
min_incoming=0,
|
||||
max_incoming=0,
|
||||
min_instances=1,
|
||||
max_instances=1,
|
||||
),
|
||||
property_order=(
|
||||
"name",
|
||||
"greeting_type",
|
||||
|
|
@ -539,6 +544,7 @@ class EndCallNodeData(
|
|||
max_incoming=0,
|
||||
min_outgoing=0,
|
||||
max_outgoing=0,
|
||||
max_instances=1,
|
||||
),
|
||||
property_order=("name", "prompt"),
|
||||
field_overrides={
|
||||
|
|
@ -597,7 +603,11 @@ class GlobalNodeData(BaseNodeData, _PromptedNodeDataMixin):
|
|||
examples=[
|
||||
NodeExample(name="default", data={"name": "Inbound Trigger", "enabled": True})
|
||||
],
|
||||
graph_constraints=GraphConstraints(min_incoming=0, max_incoming=0),
|
||||
graph_constraints=GraphConstraints(
|
||||
min_incoming=0,
|
||||
max_incoming=0,
|
||||
max_instances=1,
|
||||
),
|
||||
property_order=("name", "enabled", "trigger_path"),
|
||||
field_overrides={
|
||||
"name": {
|
||||
|
|
@ -718,6 +728,8 @@ class TriggerNodeData(BaseNodeData):
|
|||
"rsvp": "{{gathered_context.rsvp}}",
|
||||
"duration": "{{cost_info.call_duration_seconds}}",
|
||||
"recording_url": "{{recording_url}}",
|
||||
"user_recording_url": "{{user_recording_url}}",
|
||||
"bot_recording_url": "{{bot_recording_url}}",
|
||||
"transcript_url": "{{transcript_url}}",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -243,6 +243,8 @@ class GraphConstraints(BaseModel):
|
|||
max_incoming: Optional[int] = None
|
||||
min_outgoing: Optional[int] = None
|
||||
max_outgoing: Optional[int] = None
|
||||
min_instances: Optional[int] = None
|
||||
max_instances: Optional[int] = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from pipecat.processors.aggregators.llm_context import LLMContext
|
|||
|
||||
from api.db.models import WorkflowRunModel
|
||||
from api.services.gen_ai.json_parser import parse_llm_json
|
||||
from api.services.managed_model_services import get_mps_correlation_id
|
||||
from api.services.pipecat.service_factory import create_llm_service_from_provider
|
||||
from api.services.workflow.dto import QANodeData
|
||||
from api.services.workflow.qa.conversation import (
|
||||
|
|
@ -132,8 +133,15 @@ async def run_per_node_qa_analysis(
|
|||
# Set up Langfuse tracing
|
||||
parent_ctx = setup_langfuse_parent_context(workflow_run)
|
||||
|
||||
# Build LLM service
|
||||
llm = create_llm_service_from_provider(provider, model, api_key, **service_kwargs)
|
||||
# Build LLM service. Reuse the run's MPS correlation id (minted at run
|
||||
# start, persisted on initial_context) so managed-model-services calls carry
|
||||
# billing-v2 markers — orgs on billing v2 reject managed calls that lack them.
|
||||
mps_correlation_id = get_mps_correlation_id(
|
||||
getattr(workflow_run, "initial_context", None)
|
||||
)
|
||||
llm = create_llm_service_from_provider(
|
||||
provider, model, api_key, correlation_id=mps_correlation_id, **service_kwargs
|
||||
)
|
||||
|
||||
node_results: dict[str, Any] = {}
|
||||
prior_conversation: list[dict] = [] # Running accumulation of all prior nodes
|
||||
|
|
@ -206,6 +214,16 @@ async def run_per_node_qa_analysis(
|
|||
}
|
||||
try:
|
||||
parsed = parse_llm_json(raw_response)
|
||||
# parse_llm_json can return a list (e.g. when the model emits a
|
||||
# top-level JSON array); coerce non-dict results so the .get()
|
||||
# lookups below don't raise AttributeError.
|
||||
if not isinstance(parsed, dict):
|
||||
logger.warning(
|
||||
f"QA LLM returned non-object JSON for node '{node_name}' "
|
||||
f"on run {workflow_run_id}; got {type(parsed).__name__}, "
|
||||
"using empty QA result"
|
||||
)
|
||||
parsed = {}
|
||||
node_result["tags"] = parsed.get("tags", [])
|
||||
node_result["summary"] = parsed.get("summary", "")
|
||||
node_result["score"] = parsed.get("call_quality_score")
|
||||
|
|
@ -280,8 +298,14 @@ async def _run_whole_call_qa_analysis(
|
|||
{"role": "user", "content": f"## Transcript\n{transcript}"},
|
||||
]
|
||||
|
||||
# Call LLM
|
||||
llm = create_llm_service_from_provider(provider, model, api_key, **service_kwargs)
|
||||
# Build LLM service. Reuse the run's MPS correlation id so managed-model
|
||||
# calls carry billing-v2 markers (see run_per_node_qa_analysis).
|
||||
mps_correlation_id = get_mps_correlation_id(
|
||||
getattr(workflow_run, "initial_context", None)
|
||||
)
|
||||
llm = create_llm_service_from_provider(
|
||||
provider, model, api_key, correlation_id=mps_correlation_id, **service_kwargs
|
||||
)
|
||||
|
||||
try:
|
||||
raw_response = await _run_llm_inference(llm, messages, system_content)
|
||||
|
|
@ -296,6 +320,16 @@ async def _run_whole_call_qa_analysis(
|
|||
}
|
||||
try:
|
||||
parsed = parse_llm_json(raw_response)
|
||||
# parse_llm_json can return a list (e.g. when the model emits a
|
||||
# top-level JSON array); coerce non-dict results so the .get()
|
||||
# lookups below don't raise AttributeError.
|
||||
if not isinstance(parsed, dict):
|
||||
logger.warning(
|
||||
f"QA LLM returned non-object JSON for whole-call QA on run "
|
||||
f"{workflow_run_id}; got {type(parsed).__name__}, using empty "
|
||||
"QA result"
|
||||
)
|
||||
parsed = {}
|
||||
node_result["tags"] = parsed.get("tags", [])
|
||||
node_result["summary"] = parsed.get("summary", "")
|
||||
node_result["score"] = parsed.get("call_quality_score")
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ from pipecat.frames.frames import (
|
|||
LLMContextFrame,
|
||||
LLMFullResponseEndFrame,
|
||||
LLMFullResponseStartFrame,
|
||||
TextFrame,
|
||||
TTSSpeakFrame,
|
||||
TTSStoppedFrame,
|
||||
)
|
||||
|
|
@ -167,12 +166,17 @@ class _TaskQueueProxy:
|
|||
|
||||
|
||||
class _TextChatCaptureProcessor(FrameProcessor):
|
||||
def __init__(self, response_window: _ResponseWindowState) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
response_window: _ResponseWindowState,
|
||||
context: LLMContext,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.last_activity_at = time.monotonic()
|
||||
self.activity_count = 0
|
||||
self.events: list[dict[str, Any]] = []
|
||||
self._response_window = response_window
|
||||
self._context = context
|
||||
|
||||
def _touch(self) -> None:
|
||||
self.last_activity_at = time.monotonic()
|
||||
|
|
@ -192,12 +196,14 @@ class _TextChatCaptureProcessor(FrameProcessor):
|
|||
self._touch()
|
||||
|
||||
if isinstance(frame, TTSSpeakFrame):
|
||||
text_frame = TextFrame(frame.text)
|
||||
text_frame.append_to_context = (
|
||||
append_to_context = (
|
||||
frame.append_to_context if frame.append_to_context is not None else True
|
||||
)
|
||||
await self.push_frame(text_frame, direction)
|
||||
await self.push_frame(LLMAssistantPushAggregationFrame(), direction)
|
||||
text = frame.text.strip()
|
||||
if text:
|
||||
self._response_window.outputs.append(text)
|
||||
if append_to_context:
|
||||
self._context.add_message({"role": "assistant", "content": text})
|
||||
return
|
||||
|
||||
if isinstance(frame, LLMContextFrame) and direction == FrameDirection.UPSTREAM:
|
||||
|
|
@ -452,14 +458,15 @@ async def execute_text_chat_pending_turn(
|
|||
)
|
||||
|
||||
workflow_graph = WorkflowGraph(
|
||||
ReactFlowDTO.model_validate(run_definition.workflow_json)
|
||||
ReactFlowDTO.model_validate(run_definition.workflow_json),
|
||||
skip_instance_constraints_for={"trigger"},
|
||||
)
|
||||
base_checkpoint = _resolve_checkpoint_for_pending_turn(session_data, checkpoint)
|
||||
|
||||
response_window = _ResponseWindowState()
|
||||
capture_processor = _TextChatCaptureProcessor(response_window)
|
||||
context = LLMContext()
|
||||
context.set_messages(base_checkpoint["messages"])
|
||||
response_window = _ResponseWindowState()
|
||||
capture_processor = _TextChatCaptureProcessor(response_window, context)
|
||||
|
||||
node_transition_events = capture_processor.events
|
||||
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ async def execute_pending_text_chat_turn(
|
|||
error_message=str(e),
|
||||
)
|
||||
raise TextChatSessionExecutionError(
|
||||
"Failed to execute text chat assistant turn"
|
||||
f"Failed to execute text chat assistant turn: {e}"
|
||||
) from e
|
||||
|
||||
completed_session_data = normalize_text_chat_session_data(text_session.session_data)
|
||||
|
|
|
|||
|
|
@ -127,8 +127,7 @@ def validate_trigger_paths(
|
|||
)
|
||||
)
|
||||
|
||||
first_node_id = seen_paths.get(trigger_path)
|
||||
if first_node_id is None:
|
||||
if trigger_path not in seen_paths:
|
||||
seen_paths[trigger_path] = node_id
|
||||
else:
|
||||
issues.append(
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from typing import Dict, List, Set
|
|||
from api.services.workflow.dto import EdgeDataDTO, NodeType, ReactFlowDTO
|
||||
from api.services.workflow.errors import ItemKind, WorkflowError
|
||||
from api.services.workflow.node_data import BaseNodeData
|
||||
from api.services.workflow.node_specs import get_spec
|
||||
from api.services.workflow.node_specs import all_specs, get_spec
|
||||
|
||||
# Regex for matching {{ variable }} template placeholders.
|
||||
# Captures: group(1) = variable path, group(2) = filter name, group(3) = filter value.
|
||||
|
|
@ -68,10 +68,11 @@ class Node:
|
|||
self.out: Dict[str, "Node"] = {} # forward nodes
|
||||
self.out_edges: List[Edge] = [] # forward edges with properties
|
||||
|
||||
# name/is_start/is_end live on every per-type data class (base).
|
||||
# Start/end semantics are defined by node type. The persisted
|
||||
# data flags are legacy UI/runtime state and may be stale.
|
||||
self.name = data.name
|
||||
self.is_start = data.is_start
|
||||
self.is_end = data.is_end
|
||||
self.is_start = node_type == NodeType.startNode.value
|
||||
self.is_end = node_type == NodeType.endNode.value
|
||||
|
||||
# Type-specific fields — read with getattr so this works for every
|
||||
# node variant in the discriminated union.
|
||||
|
|
@ -98,13 +99,89 @@ class Node:
|
|||
self.data = data
|
||||
|
||||
|
||||
def _instance_constraint_message(
|
||||
label: str,
|
||||
count: int,
|
||||
*,
|
||||
min_count: int | None = None,
|
||||
max_count: int | None = None,
|
||||
) -> str:
|
||||
if max_count is not None and count > max_count:
|
||||
if max_count == 1:
|
||||
return f"Workflow can have at most one {label}"
|
||||
return f"Workflow can have at most {max_count} {label} nodes"
|
||||
if min_count is not None and count < min_count:
|
||||
if min_count == 1:
|
||||
return f"Workflow must have at least one {label}"
|
||||
return f"Workflow must have at least {min_count} {label} nodes"
|
||||
return ""
|
||||
|
||||
|
||||
def validate_node_instance_constraints(
|
||||
node_types: list[str],
|
||||
*,
|
||||
enforce_min_instances: bool = True,
|
||||
skip_types: Set[str] | None = None,
|
||||
) -> list[WorkflowError]:
|
||||
"""Validate workflow-level node type counts from NodeSpec.graph_constraints."""
|
||||
errors: list[WorkflowError] = []
|
||||
skip_types = skip_types or set()
|
||||
counts = Counter(node_types)
|
||||
|
||||
for spec in all_specs():
|
||||
if spec.name in skip_types:
|
||||
continue
|
||||
gc = spec.graph_constraints
|
||||
if gc is None:
|
||||
continue
|
||||
|
||||
count = counts.get(spec.name, 0)
|
||||
if gc.max_instances is not None and count > gc.max_instances:
|
||||
errors.append(
|
||||
WorkflowError(
|
||||
kind=ItemKind.workflow,
|
||||
id=None,
|
||||
field=None,
|
||||
message=_instance_constraint_message(
|
||||
spec.display_name,
|
||||
count,
|
||||
max_count=gc.max_instances,
|
||||
),
|
||||
)
|
||||
)
|
||||
if (
|
||||
enforce_min_instances
|
||||
and gc.min_instances is not None
|
||||
and count < gc.min_instances
|
||||
):
|
||||
errors.append(
|
||||
WorkflowError(
|
||||
kind=ItemKind.workflow,
|
||||
id=None,
|
||||
field=None,
|
||||
message=_instance_constraint_message(
|
||||
spec.display_name,
|
||||
count,
|
||||
min_count=gc.min_instances,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
class WorkflowGraph:
|
||||
"""
|
||||
*All* business invariants (acyclic, cardinality, etc.) are verified here.
|
||||
The constructor accepts a validated ReactFlowDTO.
|
||||
"""
|
||||
|
||||
def __init__(self, dto: ReactFlowDTO):
|
||||
def __init__(
|
||||
self,
|
||||
dto: ReactFlowDTO,
|
||||
*,
|
||||
skip_instance_constraints_for: Set[str] | None = None,
|
||||
):
|
||||
# Build adjacency list from validated DTO nodes. Core node comparisons
|
||||
# still use NodeType string enums; integration nodes remain plain
|
||||
# strings and resolve constraints through node specs.
|
||||
|
|
@ -131,10 +208,12 @@ class WorkflowGraph:
|
|||
# Set up the node references for backward compatibility
|
||||
source_node.out[target_node.id] = target_node
|
||||
|
||||
self._validate_graph()
|
||||
self._validate_graph(skip_instance_constraints_for or set())
|
||||
|
||||
# Get a reference to the start node
|
||||
self.start_node_id = [n.id for n in dto.nodes if n.data.is_start][0]
|
||||
self.start_node_id = [
|
||||
n.id for n in dto.nodes if n.type == NodeType.startNode.value
|
||||
][0]
|
||||
|
||||
# Get a reference to the global node
|
||||
try:
|
||||
|
|
@ -185,7 +264,7 @@ class WorkflowGraph:
|
|||
# -----------------------------------------------------------
|
||||
# validators
|
||||
# -----------------------------------------------------------
|
||||
def _validate_graph(self) -> None:
|
||||
def _validate_graph(self, skip_instance_constraints_for: Set[str]) -> None:
|
||||
errors: list[WorkflowError] = []
|
||||
|
||||
# TODO: Figure out what kind of cyclic contraints can be applied, since there can be a cycle in the graph
|
||||
|
|
@ -198,9 +277,13 @@ class WorkflowGraph:
|
|||
# )
|
||||
# )
|
||||
|
||||
errors.extend(self._assert_start_node())
|
||||
errors.extend(
|
||||
validate_node_instance_constraints(
|
||||
[n.node_type for n in self.nodes.values()],
|
||||
skip_types=skip_instance_constraints_for,
|
||||
)
|
||||
)
|
||||
errors.extend(self._assert_connection_counts())
|
||||
errors.extend(self._assert_global_node())
|
||||
errors.extend(self._assert_node_configs())
|
||||
if errors:
|
||||
raise ValueError(errors)
|
||||
|
|
@ -220,48 +303,6 @@ class WorkflowGraph:
|
|||
for n in self.nodes.values():
|
||||
dfs(n)
|
||||
|
||||
def _assert_start_node(self):
|
||||
errors: list[WorkflowError] = []
|
||||
start_nodes = [n for n in self.nodes.values() if n.data.is_start]
|
||||
if not start_nodes:
|
||||
errors.append(
|
||||
WorkflowError(
|
||||
kind=ItemKind.workflow,
|
||||
id=None,
|
||||
field=None,
|
||||
message="Workflow has no start node — exactly one is required",
|
||||
)
|
||||
)
|
||||
elif len(start_nodes) > 1:
|
||||
errors.append(
|
||||
WorkflowError(
|
||||
kind=ItemKind.workflow,
|
||||
id=None,
|
||||
field=None,
|
||||
message=(
|
||||
f"Workflow has {len(start_nodes)} start nodes — "
|
||||
f"exactly one is required"
|
||||
),
|
||||
)
|
||||
)
|
||||
return errors
|
||||
|
||||
def _assert_global_node(self):
|
||||
errors: list[WorkflowError] = []
|
||||
global_node = [
|
||||
n for n in self.nodes.values() if n.node_type == NodeType.globalNode.value
|
||||
]
|
||||
if not len(global_node) <= 1:
|
||||
errors.append(
|
||||
WorkflowError(
|
||||
kind=ItemKind.workflow,
|
||||
id=None,
|
||||
field=None,
|
||||
message="Workflow must have at most one global node",
|
||||
)
|
||||
)
|
||||
return errors
|
||||
|
||||
def _assert_connection_counts(self):
|
||||
"""Enforce per-type incoming/outgoing edge constraints.
|
||||
|
||||
|
|
|
|||
|
|
@ -39,12 +39,22 @@ async def _organization_uses_mps_billing_v2(organization_id: int) -> bool:
|
|||
return bool(account and account.get("billing_mode") == "v2")
|
||||
|
||||
|
||||
def _is_usage_not_ready_error(exc: Exception) -> bool:
|
||||
response = getattr(exc, "response", None)
|
||||
if getattr(response, "status_code", None) != 409:
|
||||
return False
|
||||
return "usage_not_ready" in (getattr(response, "text", "") or "")
|
||||
|
||||
|
||||
async def report_workflow_run_platform_usage(workflow_run) -> None:
|
||||
"""Report hosted platform usage for a completed workflow run to MPS."""
|
||||
if DEPLOYMENT_MODE == "oss":
|
||||
return
|
||||
|
||||
if not getattr(workflow_run, "is_completed", False):
|
||||
logger.warning(
|
||||
"Workflow run is not completed in report_workflow_run_platform_usage"
|
||||
)
|
||||
return
|
||||
|
||||
organization_id = _workflow_run_organization_id(workflow_run)
|
||||
|
|
@ -70,6 +80,9 @@ async def report_workflow_run_platform_usage(workflow_run) -> None:
|
|||
|
||||
try:
|
||||
if not await _organization_uses_mps_billing_v2(organization_id):
|
||||
logger.debug(
|
||||
"Not reporting platform usage since org not using mps billing v2"
|
||||
)
|
||||
return
|
||||
|
||||
result = await mps_service_key_client.report_platform_usage(
|
||||
|
|
@ -91,11 +104,21 @@ async def report_workflow_run_platform_usage(workflow_run) -> None:
|
|||
result,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to report platform usage for workflow run {}: {}",
|
||||
workflow_run.id,
|
||||
e,
|
||||
)
|
||||
if _is_usage_not_ready_error(e):
|
||||
# A run can start and receive an MPS correlation id, then fail or end
|
||||
# before billable STT usage is recorded. MPS returns usage_not_ready
|
||||
# for that no-platform-fee path, so keep it out of error alerts.
|
||||
logger.warning(
|
||||
"Failed to report platform usage for workflow run {}: {}",
|
||||
workflow_run.id,
|
||||
e,
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Failed to report platform usage for workflow run {}: {}",
|
||||
workflow_run.id,
|
||||
e,
|
||||
)
|
||||
|
||||
|
||||
async def report_completed_workflow_run_platform_usage(workflow_run_id: int) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue