mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
Merge remote-tracking branch 'origin/main' into feat/provisional-vad
# Conflicts: # api/routes/main.py # api/tests/test_active_calls.py # pipecat # scripts/rolling_update.sh
This commit is contained in:
commit
cc658131aa
28 changed files with 1101 additions and 31 deletions
|
|
@ -12,6 +12,11 @@ UI_APP_URL="http://localhost:3000"
|
|||
DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/postgres"
|
||||
REDIS_URL="redis://:redissecret@localhost:6379"
|
||||
|
||||
# Internal devops secret for deployment scripts and lifecycle hooks.
|
||||
# scripts/rolling_update.sh sends this to protected operational endpoints via
|
||||
# X-Dograh-Devops-Secret. Use a unique random value in production.
|
||||
DOGRAH_DEVOPS_SECRET="change-me-dograh-devops-secret"
|
||||
|
||||
# AWS S3 Configuration
|
||||
ENABLE_AWS_S3="false"
|
||||
# AWS_ACCESS_KEY_ID=""
|
||||
|
|
|
|||
|
|
@ -14,4 +14,6 @@ UI_APP_URL=http://localhost:3000
|
|||
DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/test_db"
|
||||
REDIS_URL="redis://:redissecret@localhost:6379/0"
|
||||
|
||||
DOGRAH_DEVOPS_SECRET="test-dograh-devops-secret"
|
||||
|
||||
MINIO_PUBLIC_ENDPOINT=http://localhost:9000
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ STACK_AUTH_PROJECT_ID = os.getenv("STACK_AUTH_PROJECT_ID")
|
|||
STACK_PUBLISHABLE_CLIENT_KEY = os.getenv("STACK_PUBLISHABLE_CLIENT_KEY")
|
||||
DOGRAH_MPS_SECRET_KEY = os.getenv("DOGRAH_MPS_SECRET_KEY", None)
|
||||
MPS_API_URL = os.getenv("MPS_API_URL", "https://services.dograh.com")
|
||||
DOGRAH_DEVOPS_SECRET = os.getenv("DOGRAH_DEVOPS_SECRET") or None
|
||||
|
||||
# Storage Configuration
|
||||
ENABLE_AWS_S3 = os.getenv("ENABLE_AWS_S3", "false").lower() == "true"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
from fastapi import APIRouter
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
|
@ -131,8 +134,35 @@ class ActiveCallsResponse(BaseModel):
|
|||
active_calls: int
|
||||
|
||||
|
||||
DOGRAH_DEVOPS_SECRET_HEADER = "X-Dograh-Devops-Secret"
|
||||
|
||||
|
||||
def _verify_devops_secret(
|
||||
configured_secret: str | None,
|
||||
provided_secret: str | None,
|
||||
) -> None:
|
||||
if not configured_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Devops secret is not configured",
|
||||
)
|
||||
if not provided_secret or not secrets.compare_digest(
|
||||
provided_secret,
|
||||
configured_secret,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Forbidden",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health/active-calls", response_model=ActiveCallsResponse)
|
||||
async def active_calls() -> ActiveCallsResponse:
|
||||
async def active_calls(
|
||||
x_dograh_devops_secret: Annotated[
|
||||
str | None,
|
||||
Header(alias=DOGRAH_DEVOPS_SECRET_HEADER),
|
||||
] = None,
|
||||
) -> ActiveCallsResponse:
|
||||
"""In-flight call count for THIS worker — the drain signal for deploys.
|
||||
|
||||
A deploy orchestrator polls this per worker and waits for zero before
|
||||
|
|
@ -141,6 +171,8 @@ async def active_calls() -> ActiveCallsResponse:
|
|||
count is per-process: one uvicorn per VM port (scripts/rolling_update.sh)
|
||||
or per Kubernetes pod (preStop hook). See api/services/pipecat/active_calls.py.
|
||||
"""
|
||||
from api.constants import DOGRAH_DEVOPS_SECRET
|
||||
from api.services.pipecat.active_calls import active_call_count
|
||||
|
||||
_verify_devops_secret(DOGRAH_DEVOPS_SECRET, x_dograh_devops_secret)
|
||||
return ActiveCallsResponse(active_calls=active_call_count())
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ from .azure import (
|
|||
AZURE_SPEECH_TTS_LANGUAGES,
|
||||
AZURE_SPEECH_TTS_VOICES,
|
||||
)
|
||||
from .cartesia import (
|
||||
CARTESIA_INK_2_STT_LANGUAGES,
|
||||
CARTESIA_INK_WHISPER_STT_LANGUAGES,
|
||||
CARTESIA_STT_LANGUAGES,
|
||||
CARTESIA_STT_MODELS,
|
||||
)
|
||||
from .deepgram import (
|
||||
DEEPGRAM_FLUX_MODELS,
|
||||
DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS,
|
||||
|
|
@ -59,6 +65,10 @@ __all__ = [
|
|||
"AZURE_SPEECH_STT_LANGUAGES",
|
||||
"AZURE_SPEECH_TTS_LANGUAGES",
|
||||
"AZURE_SPEECH_TTS_VOICES",
|
||||
"CARTESIA_INK_2_STT_LANGUAGES",
|
||||
"CARTESIA_INK_WHISPER_STT_LANGUAGES",
|
||||
"CARTESIA_STT_LANGUAGES",
|
||||
"CARTESIA_STT_MODELS",
|
||||
"DEEPGRAM_FLUX_MODELS",
|
||||
"DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES",
|
||||
"DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS",
|
||||
|
|
|
|||
105
api/services/configuration/options/cartesia.py
Normal file
105
api/services/configuration/options/cartesia.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
CARTESIA_STT_MODELS = ["ink-2", "ink-whisper"]
|
||||
CARTESIA_INK_2_STT_LANGUAGES = ("en",)
|
||||
CARTESIA_INK_WHISPER_STT_LANGUAGES = (
|
||||
"en",
|
||||
"zh",
|
||||
"de",
|
||||
"es",
|
||||
"ru",
|
||||
"ko",
|
||||
"fr",
|
||||
"ja",
|
||||
"pt",
|
||||
"tr",
|
||||
"pl",
|
||||
"ca",
|
||||
"nl",
|
||||
"ar",
|
||||
"sv",
|
||||
"it",
|
||||
"id",
|
||||
"hi",
|
||||
"fi",
|
||||
"vi",
|
||||
"he",
|
||||
"uk",
|
||||
"el",
|
||||
"ms",
|
||||
"cs",
|
||||
"ro",
|
||||
"da",
|
||||
"hu",
|
||||
"ta",
|
||||
"no",
|
||||
"th",
|
||||
"ur",
|
||||
"hr",
|
||||
"bg",
|
||||
"lt",
|
||||
"la",
|
||||
"mi",
|
||||
"ml",
|
||||
"cy",
|
||||
"sk",
|
||||
"te",
|
||||
"fa",
|
||||
"lv",
|
||||
"bn",
|
||||
"sr",
|
||||
"az",
|
||||
"sl",
|
||||
"kn",
|
||||
"et",
|
||||
"mk",
|
||||
"br",
|
||||
"eu",
|
||||
"is",
|
||||
"hy",
|
||||
"ne",
|
||||
"mn",
|
||||
"bs",
|
||||
"kk",
|
||||
"sq",
|
||||
"sw",
|
||||
"gl",
|
||||
"mr",
|
||||
"pa",
|
||||
"si",
|
||||
"km",
|
||||
"sn",
|
||||
"yo",
|
||||
"so",
|
||||
"af",
|
||||
"oc",
|
||||
"ka",
|
||||
"be",
|
||||
"tg",
|
||||
"sd",
|
||||
"gu",
|
||||
"am",
|
||||
"yi",
|
||||
"lo",
|
||||
"uz",
|
||||
"fo",
|
||||
"ht",
|
||||
"ps",
|
||||
"tk",
|
||||
"nn",
|
||||
"mt",
|
||||
"sa",
|
||||
"lb",
|
||||
"my",
|
||||
"bo",
|
||||
"tl",
|
||||
"mg",
|
||||
"as",
|
||||
"tt",
|
||||
"haw",
|
||||
"ln",
|
||||
"ha",
|
||||
"ba",
|
||||
"jw",
|
||||
"su",
|
||||
"yue",
|
||||
)
|
||||
CARTESIA_STT_LANGUAGES = CARTESIA_INK_WHISPER_STT_LANGUAGES
|
||||
|
|
@ -14,6 +14,10 @@ from api.services.configuration.options import (
|
|||
AZURE_SPEECH_STT_LANGUAGES,
|
||||
AZURE_SPEECH_TTS_LANGUAGES,
|
||||
AZURE_SPEECH_TTS_VOICES,
|
||||
CARTESIA_INK_2_STT_LANGUAGES,
|
||||
CARTESIA_INK_WHISPER_STT_LANGUAGES,
|
||||
CARTESIA_STT_LANGUAGES,
|
||||
CARTESIA_STT_MODELS,
|
||||
DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS,
|
||||
DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES,
|
||||
DEEPGRAM_LANGUAGES,
|
||||
|
|
@ -1323,9 +1327,6 @@ class DeepgramSTTConfiguration(BaseSTTConfiguration):
|
|||
)
|
||||
|
||||
|
||||
CARTESIA_STT_MODELS = ["ink-whisper"]
|
||||
|
||||
|
||||
@register_stt
|
||||
class CartesiaSTTConfiguration(BaseSTTConfiguration):
|
||||
model_config = CARTESIA_PROVIDER_MODEL_CONFIG
|
||||
|
|
@ -1335,6 +1336,17 @@ class CartesiaSTTConfiguration(BaseSTTConfiguration):
|
|||
description="Cartesia STT model.",
|
||||
json_schema_extra={"examples": CARTESIA_STT_MODELS},
|
||||
)
|
||||
language: str = Field(
|
||||
default="en",
|
||||
description="ISO 639-1 language code. ink-2 currently supports English only.",
|
||||
json_schema_extra={
|
||||
"examples": CARTESIA_STT_LANGUAGES,
|
||||
"model_options": {
|
||||
"ink-2": CARTESIA_INK_2_STT_LANGUAGES,
|
||||
"ink-whisper": CARTESIA_INK_WHISPER_STT_LANGUAGES,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
OPENAI_STT_MODELS = ["gpt-4o-transcribe"]
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ from api.services.pipecat.service_factory import (
|
|||
create_realtime_llm_service,
|
||||
create_stt_service,
|
||||
create_tts_service,
|
||||
stt_uses_flux_turns,
|
||||
stt_uses_external_turns,
|
||||
)
|
||||
from api.services.pipecat.tracing_config import (
|
||||
ensure_tracing,
|
||||
|
|
@ -97,6 +97,19 @@ from pipecat.utils.run_context import set_current_org_id, set_current_run_id
|
|||
# Setup tracing if enabled
|
||||
ensure_tracing()
|
||||
|
||||
DEFAULT_USER_TURN_STOP_TIMEOUT = 5.0
|
||||
EXTERNAL_TURN_USER_STOP_TIMEOUT = 30.0
|
||||
|
||||
|
||||
def _resolve_user_turn_stop_timeout(
|
||||
run_configs: dict, *, uses_external_turns: bool
|
||||
) -> float:
|
||||
if "user_turn_stop_timeout" in run_configs:
|
||||
return float(run_configs["user_turn_stop_timeout"])
|
||||
if uses_external_turns:
|
||||
return EXTERNAL_TURN_USER_STOP_TIMEOUT
|
||||
return DEFAULT_USER_TURN_STOP_TIMEOUT
|
||||
|
||||
|
||||
def _create_realtime_user_turn_config(provider: str):
|
||||
"""Return user turn strategies and optional local VAD for realtime providers."""
|
||||
|
|
@ -154,6 +167,34 @@ async def run_pipeline_telephony(
|
|||
user_id: int,
|
||||
call_id: str,
|
||||
transport_kwargs: dict,
|
||||
) -> None:
|
||||
"""Run a pipeline for any telephony provider."""
|
||||
# Register before any async setup so deploy drains see calls that are still
|
||||
# resolving DB/config/transport state.
|
||||
register_active_call(workflow_run_id)
|
||||
try:
|
||||
await _run_pipeline_telephony_impl(
|
||||
websocket,
|
||||
provider_name=provider_name,
|
||||
workflow_id=workflow_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
user_id=user_id,
|
||||
call_id=call_id,
|
||||
transport_kwargs=transport_kwargs,
|
||||
)
|
||||
finally:
|
||||
unregister_active_call(workflow_run_id)
|
||||
|
||||
|
||||
async def _run_pipeline_telephony_impl(
|
||||
websocket,
|
||||
*,
|
||||
provider_name: str,
|
||||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user_id: int,
|
||||
call_id: str,
|
||||
transport_kwargs: dict,
|
||||
) -> None:
|
||||
"""Run a pipeline for any telephony provider.
|
||||
|
||||
|
|
@ -227,7 +268,7 @@ async def run_pipeline_telephony(
|
|||
)
|
||||
|
||||
try:
|
||||
await _run_pipeline(
|
||||
await _run_pipeline_impl(
|
||||
transport,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
|
|
@ -251,6 +292,31 @@ async def run_pipeline_smallwebrtc(
|
|||
user_id: int,
|
||||
call_context_vars: dict = {},
|
||||
user_provider_id: str | None = None,
|
||||
) -> None:
|
||||
"""Run pipeline for WebRTC connections."""
|
||||
# Register before any async setup so deploy drains see calls that are still
|
||||
# resolving DB/config/transport state.
|
||||
register_active_call(workflow_run_id)
|
||||
try:
|
||||
await _run_pipeline_smallwebrtc_impl(
|
||||
webrtc_connection,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
user_id,
|
||||
call_context_vars=call_context_vars,
|
||||
user_provider_id=user_provider_id,
|
||||
)
|
||||
finally:
|
||||
unregister_active_call(workflow_run_id)
|
||||
|
||||
|
||||
async def _run_pipeline_smallwebrtc_impl(
|
||||
webrtc_connection: SmallWebRTCConnection,
|
||||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user_id: int,
|
||||
call_context_vars: dict = {},
|
||||
user_provider_id: str | None = None,
|
||||
) -> None:
|
||||
"""Run pipeline for WebRTC connections"""
|
||||
logger.debug(
|
||||
|
|
@ -300,7 +366,7 @@ async def run_pipeline_smallwebrtc(
|
|||
ambient_noise_config,
|
||||
is_realtime=is_realtime,
|
||||
)
|
||||
await _run_pipeline(
|
||||
await _run_pipeline_impl(
|
||||
transport,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
|
|
@ -323,6 +389,35 @@ async def _run_pipeline(
|
|||
user_provider_id: str | None = None,
|
||||
workflow_run=None,
|
||||
resolved_user_config=None,
|
||||
) -> None:
|
||||
"""Run the pipeline with active-call drain accounting."""
|
||||
register_active_call(workflow_run_id)
|
||||
try:
|
||||
await _run_pipeline_impl(
|
||||
transport,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
user_id,
|
||||
call_context_vars=call_context_vars,
|
||||
audio_config=audio_config,
|
||||
user_provider_id=user_provider_id,
|
||||
workflow_run=workflow_run,
|
||||
resolved_user_config=resolved_user_config,
|
||||
)
|
||||
finally:
|
||||
unregister_active_call(workflow_run_id)
|
||||
|
||||
|
||||
async def _run_pipeline_impl(
|
||||
transport,
|
||||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user_id: int,
|
||||
call_context_vars: dict = {},
|
||||
audio_config: AudioConfig = None,
|
||||
user_provider_id: str | None = None,
|
||||
workflow_run=None,
|
||||
resolved_user_config=None,
|
||||
) -> None:
|
||||
"""
|
||||
Run the pipeline with the given transport and configuration
|
||||
|
|
@ -624,16 +719,18 @@ async def _run_pipeline(
|
|||
|
||||
# Configure turn strategies based on STT provider, model, and workflow configuration
|
||||
if is_realtime:
|
||||
uses_external_turns = False
|
||||
# Realtime services still need user-turn tracking even when the model
|
||||
# itself owns speech generation and interruption behavior.
|
||||
user_turn_strategies, user_vad_analyzer = _create_realtime_user_turn_config(
|
||||
user_config.realtime.provider
|
||||
)
|
||||
else:
|
||||
# Deepgram Flux and supported Dograh managed Flux languages emit their
|
||||
# own turn boundaries, so the aggregator follows those external signals.
|
||||
# Other models use configurable turn detection.
|
||||
if stt_uses_flux_turns(user_config):
|
||||
# Some STT services emit their own turn boundaries, so the aggregator
|
||||
# follows those external signals. Other models use configurable turn
|
||||
# detection.
|
||||
uses_external_turns = stt_uses_external_turns(user_config)
|
||||
if uses_external_turns:
|
||||
user_turn_strategies = UserTurnStrategies(
|
||||
start=[
|
||||
VADUserTurnStartStrategy(),
|
||||
|
|
@ -663,9 +760,15 @@ async def _run_pipeline(
|
|||
stop=[SpeechTimeoutUserTurnStopStrategy()],
|
||||
)
|
||||
|
||||
user_turn_stop_timeout = _resolve_user_turn_stop_timeout(
|
||||
run_configs,
|
||||
uses_external_turns=uses_external_turns,
|
||||
)
|
||||
|
||||
user_params = LLMUserAggregatorParams(
|
||||
user_turn_strategies=user_turn_strategies,
|
||||
user_mute_strategies=user_mute_strategies,
|
||||
user_turn_stop_timeout=user_turn_stop_timeout,
|
||||
user_idle_timeout=max_user_idle_timeout,
|
||||
vad_analyzer=user_vad_analyzer,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,12 +21,13 @@ from pipecat.services.aws.llm import AWSBedrockLLMService, AWSBedrockLLMSettings
|
|||
from pipecat.services.azure.llm import AzureLLMService, AzureLLMSettings
|
||||
from pipecat.services.azure.stt import AzureSTTService, AzureSTTSettings
|
||||
from pipecat.services.azure.tts import AzureTTSService, AzureTTSSettings
|
||||
from pipecat.services.cartesia.stt import CartesiaSTTService
|
||||
from pipecat.services.cartesia.stt import CartesiaSTTService, CartesiaSTTSettings
|
||||
from pipecat.services.cartesia.tts import (
|
||||
CartesiaTTSService,
|
||||
CartesiaTTSSettings,
|
||||
GenerationConfig,
|
||||
)
|
||||
from pipecat.services.cartesia.turns.stt import CartesiaTurnsSTTService
|
||||
from pipecat.services.deepgram.flux.stt import (
|
||||
DeepgramFluxSTTService,
|
||||
DeepgramFluxSTTSettings,
|
||||
|
|
@ -106,11 +107,13 @@ def dograh_stt_uses_flux_language(language: str | None) -> bool:
|
|||
return language in DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS
|
||||
|
||||
|
||||
def stt_uses_flux_turns(user_config) -> bool:
|
||||
def stt_uses_external_turns(user_config) -> bool:
|
||||
if user_config.stt.provider == ServiceProviders.DEEPGRAM.value:
|
||||
return user_config.stt.model in DEEPGRAM_FLUX_MODELS
|
||||
if user_config.stt.provider == ServiceProviders.DOGRAH.value:
|
||||
return dograh_stt_uses_flux_language(getattr(user_config.stt, "language", None))
|
||||
if user_config.stt.provider == ServiceProviders.CARTESIA.value:
|
||||
return user_config.stt.model == "ink-2"
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -215,8 +218,20 @@ def create_stt_service(
|
|||
sample_rate=audio_config.transport_in_sample_rate,
|
||||
)
|
||||
elif user_config.stt.provider == ServiceProviders.CARTESIA.value:
|
||||
if user_config.stt.model == "ink-2":
|
||||
return CartesiaTurnsSTTService(
|
||||
api_key=user_config.stt.api_key,
|
||||
should_interrupt=False, # Let UserAggregator emit interruption frames.
|
||||
sample_rate=audio_config.transport_in_sample_rate,
|
||||
)
|
||||
|
||||
language = getattr(user_config.stt, "language", None) or "en"
|
||||
return CartesiaSTTService(
|
||||
api_key=user_config.stt.api_key,
|
||||
settings=CartesiaSTTSettings(
|
||||
model=user_config.stt.model,
|
||||
language=language,
|
||||
),
|
||||
sample_rate=audio_config.transport_in_sample_rate,
|
||||
)
|
||||
elif user_config.stt.provider == ServiceProviders.DOGRAH.value:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,15 @@ worker. The guarantees that matter for draining: register/unregister are
|
|||
idempotent, and the count only reaches zero when every registered run is gone.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from api.routes import main as main_routes
|
||||
from api.services.pipecat import active_calls
|
||||
from api.services.pipecat import run_pipeline as run_pipeline_module
|
||||
|
||||
|
||||
def setup_function():
|
||||
|
|
@ -14,6 +22,21 @@ def setup_function():
|
|||
active_calls._active_run_ids.clear()
|
||||
|
||||
|
||||
def _make_active_calls_client(
|
||||
monkeypatch,
|
||||
configured_secret: str | None = "test-dograh-devops-secret",
|
||||
) -> TestClient:
|
||||
monkeypatch.setattr("api.constants.DOGRAH_DEVOPS_SECRET", configured_secret)
|
||||
app = FastAPI()
|
||||
app.add_api_route(
|
||||
"/api/v1/health/active-calls",
|
||||
main_routes.active_calls,
|
||||
methods=["GET"],
|
||||
response_model=main_routes.ActiveCallsResponse,
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_starts_empty():
|
||||
assert active_calls.active_call_count() == 0
|
||||
|
||||
|
|
@ -51,3 +74,147 @@ def test_full_lifecycle_drains_to_zero():
|
|||
assert active_calls.active_call_count() == 1
|
||||
active_calls.unregister_active_call(42)
|
||||
assert active_calls.active_call_count() == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_pipeline_counts_call_during_setup(monkeypatch):
|
||||
entered_setup = asyncio.Event()
|
||||
release_setup = asyncio.Event()
|
||||
|
||||
async def fake_get_workflow_run(*args, **kwargs):
|
||||
entered_setup.set()
|
||||
await release_setup.wait()
|
||||
raise RuntimeError("setup failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_pipeline_module.db_client,
|
||||
"get_workflow_run",
|
||||
fake_get_workflow_run,
|
||||
)
|
||||
|
||||
task = asyncio.create_task(
|
||||
run_pipeline_module._run_pipeline(
|
||||
transport=object(),
|
||||
workflow_id=1,
|
||||
workflow_run_id=42,
|
||||
user_id=7,
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.wait_for(entered_setup.wait(), timeout=1.0)
|
||||
assert active_calls.active_call_count() == 1
|
||||
|
||||
release_setup.set()
|
||||
with pytest.raises(RuntimeError, match="setup failed"):
|
||||
await asyncio.wait_for(task, timeout=1.0)
|
||||
assert active_calls.active_call_count() == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webrtc_entrypoint_counts_call_during_setup(monkeypatch):
|
||||
entered_setup = asyncio.Event()
|
||||
release_setup = asyncio.Event()
|
||||
|
||||
async def fake_get_workflow(*args, **kwargs):
|
||||
entered_setup.set()
|
||||
await release_setup.wait()
|
||||
raise RuntimeError("setup failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_pipeline_module.db_client, "get_workflow", fake_get_workflow
|
||||
)
|
||||
|
||||
task = asyncio.create_task(
|
||||
run_pipeline_module.run_pipeline_smallwebrtc(
|
||||
webrtc_connection=object(),
|
||||
workflow_id=1,
|
||||
workflow_run_id=43,
|
||||
user_id=7,
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.wait_for(entered_setup.wait(), timeout=1.0)
|
||||
assert active_calls.active_call_count() == 1
|
||||
|
||||
release_setup.set()
|
||||
with pytest.raises(RuntimeError, match="setup failed"):
|
||||
await asyncio.wait_for(task, timeout=1.0)
|
||||
assert active_calls.active_call_count() == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telephony_entrypoint_counts_call_during_setup(monkeypatch):
|
||||
entered_setup = asyncio.Event()
|
||||
release_setup = asyncio.Event()
|
||||
|
||||
async def fake_get_workflow(*args, **kwargs):
|
||||
entered_setup.set()
|
||||
await release_setup.wait()
|
||||
raise RuntimeError("setup failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_pipeline_module.db_client, "get_workflow", fake_get_workflow
|
||||
)
|
||||
|
||||
task = asyncio.create_task(
|
||||
run_pipeline_module.run_pipeline_telephony(
|
||||
websocket=object(),
|
||||
provider_name="twilio",
|
||||
workflow_id=1,
|
||||
workflow_run_id=44,
|
||||
user_id=7,
|
||||
call_id="call-1",
|
||||
transport_kwargs={},
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.wait_for(entered_setup.wait(), timeout=1.0)
|
||||
assert active_calls.active_call_count() == 1
|
||||
|
||||
release_setup.set()
|
||||
with pytest.raises(RuntimeError, match="setup failed"):
|
||||
await asyncio.wait_for(task, timeout=1.0)
|
||||
assert active_calls.active_call_count() == 0
|
||||
|
||||
|
||||
def test_active_calls_route_requires_configured_secret(monkeypatch):
|
||||
client = _make_active_calls_client(monkeypatch, configured_secret=None)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/health/active-calls",
|
||||
headers={"X-Dograh-Devops-Secret": "test-dograh-devops-secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
def test_active_calls_route_rejects_missing_secret_header(monkeypatch):
|
||||
client = _make_active_calls_client(monkeypatch)
|
||||
|
||||
response = client.get("/api/v1/health/active-calls")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_active_calls_route_rejects_wrong_secret(monkeypatch):
|
||||
client = _make_active_calls_client(monkeypatch)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/health/active-calls",
|
||||
headers={"X-Dograh-Devops-Secret": "wrong"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_active_calls_route_returns_count_with_secret(monkeypatch):
|
||||
active_calls.register_active_call(42)
|
||||
client = _make_active_calls_client(monkeypatch)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/health/active-calls",
|
||||
headers={"X-Dograh-Devops-Secret": "test-dograh-devops-secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"active_calls": 1}
|
||||
|
|
|
|||
94
api/tests/test_cartesia_stt_service_factory.py
Normal file
94
api/tests/test_cartesia_stt_service_factory.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from api.services.configuration.options import (
|
||||
CARTESIA_INK_2_STT_LANGUAGES,
|
||||
CARTESIA_INK_WHISPER_STT_LANGUAGES,
|
||||
CARTESIA_STT_MODELS,
|
||||
)
|
||||
from api.services.configuration.registry import (
|
||||
CartesiaSTTConfiguration,
|
||||
ServiceProviders,
|
||||
)
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.service_factory import (
|
||||
create_stt_service,
|
||||
stt_uses_external_turns,
|
||||
)
|
||||
|
||||
|
||||
def _audio_config() -> AudioConfig:
|
||||
return AudioConfig(
|
||||
transport_in_sample_rate=16000,
|
||||
transport_out_sample_rate=16000,
|
||||
)
|
||||
|
||||
|
||||
def _cartesia_config(model: str, language: str = "en") -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
stt=SimpleNamespace(
|
||||
provider=ServiceProviders.CARTESIA.value,
|
||||
api_key="test-key",
|
||||
model=model,
|
||||
language=language,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_cartesia_stt_configuration_exposes_ink_2_and_ink_whisper_languages():
|
||||
config = CartesiaSTTConfiguration(api_key="test-key")
|
||||
language_schema = CartesiaSTTConfiguration.model_json_schema()["properties"][
|
||||
"language"
|
||||
]
|
||||
|
||||
assert config.provider == ServiceProviders.CARTESIA
|
||||
assert config.model == "ink-whisper"
|
||||
assert config.language == "en"
|
||||
assert CARTESIA_STT_MODELS == ["ink-2", "ink-whisper"]
|
||||
assert CARTESIA_INK_2_STT_LANGUAGES == ("en",)
|
||||
assert "es" in CARTESIA_INK_WHISPER_STT_LANGUAGES
|
||||
assert language_schema["model_options"]["ink-2"] == ["en"]
|
||||
assert "es" in language_schema["model_options"]["ink-whisper"]
|
||||
|
||||
|
||||
def test_cartesia_ink_2_uses_external_turns_and_turns_service():
|
||||
user_config = _cartesia_config("ink-2")
|
||||
|
||||
assert stt_uses_external_turns(user_config)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.services.pipecat.service_factory.CartesiaTurnsSTTService"
|
||||
) as turns_service,
|
||||
patch("api.services.pipecat.service_factory.CartesiaSTTService") as stt_service,
|
||||
):
|
||||
create_stt_service(user_config, _audio_config())
|
||||
|
||||
turns_service.assert_called_once()
|
||||
stt_service.assert_not_called()
|
||||
kwargs = turns_service.call_args.kwargs
|
||||
assert kwargs["api_key"] == "test-key"
|
||||
assert kwargs["sample_rate"] == 16000
|
||||
assert kwargs["should_interrupt"] is False
|
||||
|
||||
|
||||
def test_cartesia_ink_whisper_uses_manual_stt_service_with_model_and_language():
|
||||
user_config = _cartesia_config("ink-whisper", language="es")
|
||||
|
||||
assert not stt_uses_external_turns(user_config)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.services.pipecat.service_factory.CartesiaTurnsSTTService"
|
||||
) as turns_service,
|
||||
patch("api.services.pipecat.service_factory.CartesiaSTTService") as stt_service,
|
||||
):
|
||||
create_stt_service(user_config, _audio_config())
|
||||
|
||||
turns_service.assert_not_called()
|
||||
stt_service.assert_called_once()
|
||||
kwargs = stt_service.call_args.kwargs
|
||||
assert kwargs["api_key"] == "test-key"
|
||||
assert kwargs["sample_rate"] == 16000
|
||||
assert kwargs["settings"].model == "ink-whisper"
|
||||
assert kwargs["settings"].language == "es"
|
||||
|
|
@ -9,7 +9,7 @@ from api.services.pipecat.audio_config import AudioConfig
|
|||
from api.services.pipecat.service_factory import (
|
||||
create_stt_service,
|
||||
dograh_stt_uses_flux_language,
|
||||
stt_uses_flux_turns,
|
||||
stt_uses_external_turns,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -38,10 +38,10 @@ def test_dograh_flux_language_predicate_matches_multilingual_support():
|
|||
assert not dograh_stt_uses_flux_language("ar")
|
||||
|
||||
|
||||
def test_stt_uses_flux_turns_only_for_dograh_flux_supported_languages():
|
||||
assert stt_uses_flux_turns(_dograh_config("multi"))
|
||||
assert stt_uses_flux_turns(_dograh_config("es"))
|
||||
assert not stt_uses_flux_turns(_dograh_config("ar"))
|
||||
def test_stt_uses_external_turns_only_for_dograh_flux_supported_languages():
|
||||
assert stt_uses_external_turns(_dograh_config("multi"))
|
||||
assert stt_uses_external_turns(_dograh_config("es"))
|
||||
assert not stt_uses_external_turns(_dograh_config("ar"))
|
||||
|
||||
|
||||
def test_create_dograh_multi_uses_flux_service_without_language_hint():
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@ from pipecat.turns.user_stop import (
|
|||
)
|
||||
|
||||
from api.services.configuration.registry import ServiceProviders
|
||||
from api.services.pipecat.run_pipeline import _create_realtime_user_turn_config
|
||||
from api.services.pipecat.run_pipeline import (
|
||||
DEFAULT_USER_TURN_STOP_TIMEOUT,
|
||||
EXTERNAL_TURN_USER_STOP_TIMEOUT,
|
||||
_create_realtime_user_turn_config,
|
||||
_resolve_user_turn_stop_timeout,
|
||||
)
|
||||
|
||||
|
||||
def test_gemini_realtime_uses_local_vad_without_local_interruptions():
|
||||
|
|
@ -72,3 +77,27 @@ def test_unknown_realtime_providers_keep_local_vad():
|
|||
assert isinstance(strategies.start[0], VADUserTurnStartStrategy)
|
||||
assert len(strategies.stop) == 1
|
||||
assert isinstance(strategies.stop[0], SpeechTimeoutUserTurnStopStrategy)
|
||||
|
||||
|
||||
def test_external_turn_stt_uses_longer_stop_timeout():
|
||||
assert (
|
||||
_resolve_user_turn_stop_timeout({}, uses_external_turns=True)
|
||||
== EXTERNAL_TURN_USER_STOP_TIMEOUT
|
||||
)
|
||||
|
||||
|
||||
def test_standard_stt_keeps_default_stop_timeout():
|
||||
assert (
|
||||
_resolve_user_turn_stop_timeout({}, uses_external_turns=False)
|
||||
== DEFAULT_USER_TURN_STOP_TIMEOUT
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_config_can_override_user_turn_stop_timeout():
|
||||
assert (
|
||||
_resolve_user_turn_stop_timeout(
|
||||
{"user_turn_stop_timeout": "12.5"},
|
||||
uses_external_turns=True,
|
||||
)
|
||||
== 12.5
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue