Merge branch 'main' into feat/external-pbx

This commit is contained in:
Abhishek Kumar 2026-06-20 14:17:18 +05:30
commit 8d0be05d71
204 changed files with 9072 additions and 1392 deletions

View file

@ -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:

View file

@ -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",

View file

@ -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

View file

@ -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",
]

View file

@ -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",

View 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",
)

View file

@ -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"),
]

View file

@ -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",

View file

@ -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}"
)

View file

@ -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}")

View file

@ -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."""

View file

@ -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:

View file

@ -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

View file

@ -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")

View file

@ -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,

View 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()

View file

@ -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

View file

@ -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}}",
},
},

View file

@ -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")

View file

@ -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")

View file

@ -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

View file

@ -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)

View file

@ -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(

View file

@ -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.

View file

@ -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: