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

@ -49,7 +49,7 @@ RUN --mount=type=bind,source=api/requirements.txt,target=/tmp/req.txt \
# sys.prefix/nltk_data, so it travels with the venv on COPY/rsync.
RUN --mount=type=bind,source=pipecat,target=/tmp/pipecat,rw \
--mount=type=cache,target=/root/.cache/uv \
uv pip install '/tmp/pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb,mcp]' \
uv pip install '/tmp/pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb,mcp,inworld,smallest]' \
&& uv pip install --group /tmp/pipecat/pyproject.toml:dev \
&& uv pip uninstall opencv-python \
&& uv pip install opencv-python-headless \

View file

@ -29,7 +29,9 @@ services:
- dograh-venv:/workspaces/dograh/venv
- dograh-ui-node_modules:/workspaces/dograh/ui/node_modules
- dograh-ts-validator-node_modules:/workspaces/dograh/api/mcp_server/ts_validator/node_modules
ports:
- "127.0.0.1:3000:3000"
- "127.0.0.1:8000:8000"
volumes:
dograh-venv:
dograh-ui-node_modules:

4
.gitignore vendored
View file

@ -23,3 +23,7 @@ node_modules/
# Local self-signed TLS for the remote stack (regenerate with ./generate_certificate.sh).
# NEVER commit the private key.
certs/
# Superpowers brainstorm mockups (local only)
.superpowers/
docs/superpowers/
.gstack/

View file

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

View file

@ -1,5 +1,52 @@
# Changelog
## 1.37.0 (2026-06-19)
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Features
* feat: add Inworld TTS provider support by @manasseh-zw in https://github.com/dograh-hq/dograh/pull/420
### Bug Fixes
* fix(workflow): detect duplicate trigger paths when first node has no id by @Mubashirrrr in https://github.com/dograh-hq/dograh/pull/409
* fix(qa): tolerate non-dict JSON from QA LLM instead of crashing by @Mubashirrrr in https://github.com/dograh-hq/dograh/pull/408
* fix(devcontainer): expose UI/API ports for host access by @faisu in https://github.com/dograh-hq/dograh/pull/405
* fix: disable duplicate trigger nodes in workflow builder by @nuthalapativarun in https://github.com/dograh-hq/dograh/pull/402
* fix(ui): proxy WebSocket signaling upgrade so local web calls work (#425) by @yogi6969 in https://github.com/dograh-hq/dograh/pull/454
## New Contributors
* @faisu made their first contribution in https://github.com/dograh-hq/dograh/pull/405
* @yogi6969 made their first contribution in https://github.com/dograh-hq/dograh/pull/454
**Full Changelog**: https://github.com/dograh-hq/dograh/compare/dograh-v1.36.0...dograh-v1.37.0
## 1.36.0 (2026-06-18)
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Features
* feat: add Smallest AI TTS and STT provider integration by @harshitajain165 in https://github.com/dograh-hq/dograh/pull/444
* feat: refreshed user onboarding by @a6kme in https://github.com/dograh-hq/dograh/pull/430
* feat: add custom sarvam tts voice by @chewwbaka in https://github.com/dograh-hq/dograh/pull/449
* feat(examples): add load-and-edit workflow SDK example in Python and TypeScript by @nuthalapativarun in https://github.com/dograh-hq/dograh/pull/441
* feat(examples): add multi-node Workflow SDK example in Python and TypeScript by @nuthalapativarun in https://github.com/dograh-hq/dograh/pull/440
### Bug Fixes
* fix: add pace option in sarvam tts config by @chewwbaka in https://github.com/dograh-hq/dograh/pull/447
* fix(ui): release microphone stream on call teardown so a second test call works by @Aymenbenpakiss in https://github.com/dograh-hq/dograh/pull/446
* fix: add language field to CartesiaTTSConfiguration and pass to Cartesia TTS service by @nuthalapativarun in https://github.com/dograh-hq/dograh/pull/442
* fix: sync Smallest AI voice dropdown with selected model by @harshitajain165 in https://github.com/dograh-hq/dograh/pull/451
### Other Changes
* Validate workflow status filter to prevent 500 on invalid enum value by @a6kme in https://github.com/dograh-hq/dograh/pull/450
* allow self-hosters to enable Stack Auth via Dockerfile build args (v33.0) by @neggmmm in https://github.com/dograh-hq/dograh/pull/445
## New Contributors
* @harshitajain165 made their first contribution in https://github.com/dograh-hq/dograh/pull/444
* @Aymenbenpakiss made their first contribution in https://github.com/dograh-hq/dograh/pull/446
* @neggmmm made their first contribution in https://github.com/dograh-hq/dograh/pull/445
**Full Changelog**: https://github.com/dograh-hq/dograh/compare/dograh-v1.35.0...dograh-v1.36.0
## 1.35.0 (2026-06-12)
<!-- Release notes generated using configuration in .github/release.yml at main -->

View file

@ -84,6 +84,17 @@ An honest comparison on the axes that matter most to teams evaluating voice AI p
curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml && curl -o start_docker.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.sh && chmod +x start_docker.sh && ./start_docker.sh
```
> **⚡ Prefer an AI agent to set it up for you?**
> If you use **Claude Code** or **Codex**, install the official [Dograh setup skill](https://github.com/dograh-hq/dograh-plugins) and let your agent handle installation, configuration, and troubleshooting — it detects your OS, picks the right deploy path, runs Dograh's own setup scripts, and verifies the result.
>
> ```text
> # In Claude Code
> /plugin marketplace add dograh-hq/dograh-plugins
> /plugin install dograh@dograh
> ```
>
> Then start a new session and ask it to _"set up Dograh"_ (or run `/dograh-setup`). Codex is supported too — see the [plugin repo](https://github.com/dograh-hq/dograh-plugins#install).
> **Note**
> First startup may take 2-3 minutes to download all images. Once running, open http://localhost:3010 to create your first AI voice assistant!
> For common issues and solutions, see 🔧 **[Troubleshooting](docs/troubleshooting.md)**.

View file

@ -84,6 +84,17 @@
curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml && REGISTRY=ghcr.io/dograh-hq ENABLE_TELEMETRY=true docker compose up --pull always
```
> **⚡ 想让 AI 智能体帮你完成部署?**
> 如果你使用 **Claude Code****Codex**,可以安装官方的 [Dograh 部署技能(skill)](https://github.com/dograh-hq/dograh-plugins),让智能体替你完成安装、配置与排障——它会识别你的操作系统、选择合适的部署方式、运行 Dograh 自带的部署脚本并验证结果。
>
> ```text
> # 在 Claude Code 中
> /plugin marketplace add dograh-hq/dograh-plugins
> /plugin install dograh@dograh
> ```
>
> 然后开启一个新会话,让它 _"set up Dograh"_(或运行 `/dograh-setup`)。Codex 同样支持——详见[插件仓库](https://github.com/dograh-hq/dograh-plugins#install)。
> **提示**
> 首次启动需要 2-3 分钟拉取所有镜像。启动完成后,打开 http://localhost:3010 即可创建你的第一个 AI 语音助手!
> 常见问题及解决方案请参见 🔧 **[故障排查](docs/troubleshooting.md)**。

View file

@ -37,7 +37,7 @@ RUN --mount=type=bind,source=api/requirements.txt,target=/tmp/req.txt \
# sys.prefix/nltk_data, so it travels with the venv on COPY.
RUN --mount=type=bind,source=pipecat,target=/tmp/pipecat,rw \
--mount=type=cache,target=/root/.cache/uv \
uv pip install '/tmp/pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb,mcp]' \
uv pip install '/tmp/pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb,mcp,inworld,smallest]' \
&& uv pip uninstall opencv-python \
&& uv pip install opencv-python-headless \
&& python -c "import nltk; nltk.download('punkt_tab', download_dir='/opt/venv/nltk_data', quiet=True)"

View file

@ -0,0 +1,52 @@
"""add key to user_configurations
Turns user_configurations into a per-user keyed JSON store mirroring
organization_configurations. Existing rows (the legacy v1 AI model
configuration blob) are backfilled with key MODEL_CONFIGURATION.
Revision ID: 91cc6ba3e1c7
Revises: efe356f488f9
Create Date: 2026-06-12 21:04:25.561529
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "91cc6ba3e1c7"
down_revision: Union[str, None] = "efe356f488f9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Backfill existing rows (all legacy model-config blobs) via the server
# default, then drop the default — application code always supplies key.
op.add_column(
"user_configurations",
sa.Column(
"key",
sa.String(),
nullable=False,
server_default="MODEL_CONFIGURATION",
),
)
op.create_unique_constraint(
"_user_configuration_key_uc", "user_configurations", ["user_id", "key"]
)
op.alter_column("user_configurations", "key", server_default=None)
def downgrade() -> None:
op.drop_constraint(
"_user_configuration_key_uc", "user_configurations", type_="unique"
)
# Non-model-config rows (e.g. ONBOARDING) have no meaning in the old
# single-blob schema; the old code would read them as the user's model
# config, so they must not survive the downgrade.
op.execute("DELETE FROM user_configurations WHERE key != 'MODEL_CONFIGURATION'")
op.drop_column("user_configurations", "key")

View file

@ -0,0 +1,34 @@
"""add extra column in workflow runs
Revision ID: efe356f488f9
Revises: 384be6596b36
Create Date: 2026-06-16 12:24:30.081058
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "efe356f488f9"
down_revision: Union[str, None] = "384be6596b36"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"workflow_runs",
sa.Column(
"extra",
sa.JSON(),
server_default=sa.text("'{}'::json"),
nullable=False,
),
)
def downgrade() -> None:
op.drop_column("workflow_runs", "extra")

View file

@ -30,6 +30,12 @@ CORS_ALLOWED_ORIGINS = [
o.strip() for o in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if o.strip()
]
AUTH_PROVIDER = os.getenv("AUTH_PROVIDER", "local")
# Stack Auth public client config. These are safe to expose to the browser (the
# publishable client key is public by design, and the project id is non-sensitive),
# and are served to the UI at runtime via /api/v1/health so the frontend no longer
# needs them baked into the bundle at build time.
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")

View file

@ -10,6 +10,7 @@ from api.db.filters import apply_workflow_run_filters, get_workflow_run_order_cl
from api.db.models import CampaignModel, QueuedRunModel, WorkflowRunModel
from api.schemas.workflow import WorkflowRunResponseSchema
from api.services.workflow.run_usage_response import format_public_cost_info
from api.utils.recording_artifacts import get_recording_storage_key
class CampaignClient(BaseDBClient):
@ -45,9 +46,11 @@ class CampaignClient(BaseDBClient):
source_id=source_id,
created_by=user_id,
organization_id=organization_id,
retry_config=retry_config
if retry_config
else CampaignModel.retry_config.default.arg,
retry_config=(
retry_config
if retry_config
else CampaignModel.retry_config.default.arg
),
orchestrator_metadata=orchestrator_metadata,
telephony_configuration_id=telephony_configuration_id,
)
@ -216,6 +219,12 @@ class CampaignClient(BaseDBClient):
"is_completed": run.is_completed,
"recording_url": run.recording_url,
"transcript_url": run.transcript_url,
"user_recording_url": get_recording_storage_key(
run.extra, "user"
),
"bot_recording_url": get_recording_storage_key(
run.extra, "bot"
),
"cost_info": format_public_cost_info(
run.cost_info, run.usage_info
),
@ -270,9 +279,11 @@ class CampaignClient(BaseDBClient):
source_id=parent_campaign.source_id,
created_by=parent_campaign.created_by,
organization_id=parent_campaign.organization_id,
retry_config=retry_config
if retry_config
else CampaignModel.retry_config.default.arg,
retry_config=(
retry_config
if retry_config
else CampaignModel.retry_config.default.arg
),
orchestrator_metadata=child_meta,
rate_limit_per_second=parent_campaign.rate_limit_per_second,
total_rows=len(queued_runs_data),
@ -338,8 +349,7 @@ class CampaignClient(BaseDBClient):
# Retries create new queued_runs with suffixed source_uuids linked via
# parent_queued_run_id, so group by the ROOT queued_run using a
# recursive walk and pick the latest workflow_run across the tree.
sql = text(
f"""
sql = text(f"""
WITH RECURSIVE run_tree AS (
SELECT id AS root_id, id AS run_id
FROM queued_runs
@ -366,8 +376,7 @@ class CampaignClient(BaseDBClient):
JOIN latest_run_per_root lr ON lr.root_id = q0.id
WHERE q0.campaign_id = :cid
AND ({tag_filter})
"""
)
""")
async with self.async_session() as session:
result = await session.execute(sql, {"cid": campaign_id})

View file

@ -82,12 +82,24 @@ class UserModel(Base):
class UserConfigurationModel(Base):
"""Per-user keyed JSON store, mirroring organization_configurations.
Keys are defined in UserConfigurationKey. The legacy v1 AI model
configuration lives under MODEL_CONFIGURATION; last_validated_at is only
meaningful for that key.
"""
__tablename__ = "user_configurations"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
key = Column(String, nullable=False)
configuration = Column(JSON, nullable=False, default=dict)
last_validated_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint("user_id", "key", name="_user_configuration_key_uc"),
)
# New Organization model
class OrganizationModel(Base):
@ -532,6 +544,9 @@ class WorkflowRunModel(Base):
is_completed = Column(Boolean, default=False)
recording_url = Column(String, nullable=True)
transcript_url = Column(String, nullable=True)
extra = Column(
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
)
# Store storage backend as string enum (s3, minio)
storage_backend = Column(
Enum("s3", "minio", name="storage_backend"),

View file

@ -18,8 +18,9 @@ from api.db.models import (
WorkflowModel,
WorkflowRunModel,
)
from api.enums import OrganizationConfigurationKey
from api.enums import OrganizationConfigurationKey, UserConfigurationKey
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
from api.utils.recording_artifacts import get_recording_storage_key
class OrganizationUsageClient(BaseDBClient):
@ -226,6 +227,9 @@ class OrganizationUsageClient(BaseDBClient):
"call_duration_seconds": int(round(call_duration)),
"recording_url": run.recording_url,
"transcript_url": run.transcript_url,
"user_recording_url": get_recording_storage_key(run.extra, "user"),
"bot_recording_url": get_recording_storage_key(run.extra, "bot"),
"extra": run.extra,
"public_access_token": run.public_access_token,
"phone_number": phone_number,
"caller_number": caller_number,
@ -343,7 +347,9 @@ class OrganizationUsageClient(BaseDBClient):
if user_id:
config_result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id
UserConfigurationModel.user_id == user_id,
UserConfigurationModel.key
== UserConfigurationKey.MODEL_CONFIGURATION.value,
)
)
config_obj = config_result.scalar_one_or_none()

View file

@ -4,10 +4,12 @@ from datetime import datetime, timezone
from loguru import logger
from pydantic import ValidationError
from sqlalchemy import func
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.future import select
from api.db.base_client import BaseDBClient
from api.db.models import UserConfigurationModel, UserModel
from api.enums import UserConfigurationKey
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
@ -28,8 +30,6 @@ class UserClient(BaseDBClient):
# Use PostgreSQL's INSERT ... ON CONFLICT DO NOTHING
# This is atomic and handles race conditions at the database level
from sqlalchemy.dialects.postgresql import insert
stmt = insert(UserModel.__table__).values(
provider_id=provider_id,
created_at=datetime.now(timezone.utc),
@ -65,16 +65,52 @@ class UserClient(BaseDBClient):
)
return result.scalars().first()
async def _get_user_configuration_row(
self, session, user_id: int, key: str
) -> UserConfigurationModel | None:
result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id,
UserConfigurationModel.key == key,
)
)
return result.scalars().first()
async def get_user_configuration_value(self, user_id: int, key: str) -> dict | None:
"""Get the JSON value stored for a user under `key`, or None."""
async with self.async_session() as session:
row = await self._get_user_configuration_row(session, user_id, key)
return row.configuration if row else None
async def upsert_user_configuration_value(
self, user_id: int, key: str, value: dict
) -> dict:
"""Create or update the JSON value stored for a user under `key`."""
async with self.async_session() as session:
stmt = insert(UserConfigurationModel.__table__).values(
user_id=user_id,
key=key,
configuration=value,
)
stmt = stmt.on_conflict_do_update(
constraint="_user_configuration_key_uc",
set_={"configuration": stmt.excluded.configuration},
).returning(UserConfigurationModel.configuration)
try:
result = await session.execute(stmt)
await session.commit()
except Exception as e:
await session.rollback()
raise e
return result.scalar_one()
async def get_user_configurations(
self, user_id: int
) -> EffectiveAIModelConfiguration:
async with self.async_session() as session:
result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id
)
configuration_obj = await self._get_user_configuration_row(
session, user_id, UserConfigurationKey.MODEL_CONFIGURATION.value
)
configuration_obj = result.scalars().first()
if not configuration_obj:
return EffectiveAIModelConfiguration()
@ -97,38 +133,18 @@ class UserClient(BaseDBClient):
async def update_user_configuration(
self, user_id: int, configuration: EffectiveAIModelConfiguration
) -> EffectiveAIModelConfiguration:
async with self.async_session() as session:
result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id
)
)
configuration_obj = result.scalars().first()
if not configuration_obj:
configuration_obj = UserConfigurationModel(
user_id=user_id, configuration=configuration.model_dump()
)
session.add(configuration_obj)
else:
configuration_obj.configuration = configuration.model_dump()
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(configuration_obj)
return EffectiveAIModelConfiguration.model_validate(
configuration_obj.configuration
value = await self.upsert_user_configuration_value(
user_id,
UserConfigurationKey.MODEL_CONFIGURATION.value,
configuration.model_dump(),
)
return EffectiveAIModelConfiguration.model_validate(value)
async def update_user_configuration_last_validated_at(self, user_id: int) -> None:
async with self.async_session() as session:
result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id
)
configuration_obj = await self._get_user_configuration_row(
session, user_id, UserConfigurationKey.MODEL_CONFIGURATION.value
)
configuration_obj = result.scalars().first()
if not configuration_obj:
raise ValueError(f"User configuration with ID {user_id} not found")
configuration_obj.last_validated_at = datetime.now()

View file

@ -17,6 +17,7 @@ from api.db.models import (
from api.enums import CallType, StorageBackend
from api.schemas.workflow import WorkflowRunResponseSchema
from api.services.workflow.run_usage_response import format_public_cost_info
from api.utils.recording_artifacts import get_recording_storage_key
class WorkflowRunClient(BaseDBClient):
@ -188,13 +189,19 @@ class WorkflowRunClient(BaseDBClient):
"workflow_name": run.workflow.name if run.workflow else None,
"user_id": run.workflow.user_id if run.workflow else None,
"organization_id": organization.id if organization else None,
"organization_name": organization.provider_id
if organization
else None,
"organization_name": (
organization.provider_id if organization else None
),
"mode": run.mode,
"is_completed": run.is_completed,
"recording_url": run.recording_url,
"transcript_url": run.transcript_url,
"user_recording_url": get_recording_storage_key(
run.extra, "user"
),
"bot_recording_url": get_recording_storage_key(
run.extra, "bot"
),
"usage_info": run.usage_info,
"cost_info": run.cost_info,
"initial_context": run.initial_context,
@ -313,6 +320,12 @@ class WorkflowRunClient(BaseDBClient):
"is_completed": run.is_completed,
"recording_url": run.recording_url,
"transcript_url": run.transcript_url,
"user_recording_url": get_recording_storage_key(
run.extra, "user"
),
"bot_recording_url": get_recording_storage_key(
run.extra, "bot"
),
"cost_info": format_public_cost_info(
run.cost_info, run.usage_info
),
@ -340,6 +353,7 @@ class WorkflowRunClient(BaseDBClient):
logs: dict | None = None,
state: str | None = None,
annotations: dict | None = None,
extra: dict | None = None,
) -> WorkflowRunModel:
async with self.async_session() as session:
# Use SELECT FOR UPDATE to lock the row during the update
@ -362,7 +376,12 @@ class WorkflowRunClient(BaseDBClient):
if cost_info:
run.cost_info = cost_info
if initial_context:
run.initial_context = initial_context
# Merge initial context patches so independent call-start/runtime
# writers do not erase keys stored earlier in the run lifecycle.
run.initial_context = {
**(run.initial_context or {}),
**initial_context,
}
if gathered_context:
# Lets merge the incoming gathered context keys with the existing ones
run.gathered_context = {
@ -374,6 +393,8 @@ class WorkflowRunClient(BaseDBClient):
run.logs = {**run.logs, **logs}
if annotations:
run.annotations = {**run.annotations, **annotations}
if extra:
run.extra = {**run.extra, **extra}
if is_completed:
run.is_completed = is_completed
if state:

View file

@ -96,6 +96,15 @@ class OrganizationConfigurationKey(Enum):
MODEL_CONFIGURATION_PREFERENCES = "MODEL_CONFIGURATION_PREFERENCES" # Deprecated; read fallback for old org preferences
class UserConfigurationKey(Enum):
"""Keys for the per-user keyed JSON store (user_configurations)."""
MODEL_CONFIGURATION = (
"MODEL_CONFIGURATION" # Legacy per-user v1 AI model configuration
)
ONBOARDING = "ONBOARDING" # Post-signup onboarding state (gate, tooltips, actions)
class WorkflowStatus(Enum):
"""Workflow status values"""
@ -165,3 +174,5 @@ class PostHogEvent(str, Enum):
AGENT_EMBEDDED = "agent_embedded"
SIGNED_UP = "signed_up"
SIGNED_IN = "signed_in"
ORGANIZATION_CREATED = "organization_created"
ORGANIZATION_USER_ASSOCIATED = "organization_user_associated"

View file

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

View file

@ -16,7 +16,7 @@ msgpack==1.1.2
pgvector==0.4.2
bcrypt==5.0.0
email-validator==2.3.0
posthog==7.11.1
posthog==7.19.1
fastmcp==3.2.4
tuner-pipecat-sdk==0.2.0
PyNaCl==1.6.2

View file

@ -375,7 +375,7 @@ async def create_campaign(
if workflow_def:
try:
dto = ReactFlowDTO(**workflow_def)
graph = WorkflowGraph(dto)
graph = WorkflowGraph(dto, skip_instance_constraints_for={"trigger"})
required_vars = graph.get_required_template_variables()
if (

View file

@ -72,6 +72,11 @@ class HealthResponse(BaseModel):
auth_provider: str
turn_enabled: bool
force_turn_relay: bool
# Public Stack Auth client config — only populated when auth_provider == "stack".
# The UI reads these at runtime to initialize Stack, so they no longer need to
# be baked into the browser bundle at build time. Both are public values.
stack_project_id: str | None = None
stack_publishable_client_key: str | None = None
@router.get("/health", response_model=HealthResponse)
@ -81,12 +86,15 @@ async def health() -> HealthResponse:
AUTH_PROVIDER,
DEPLOYMENT_MODE,
FORCE_TURN_RELAY,
STACK_AUTH_PROJECT_ID,
STACK_PUBLISHABLE_CLIENT_KEY,
TURN_SECRET,
)
from api.utils.common import get_backend_endpoints
logger.debug("Health endpoint called")
backend_endpoint, _ = await get_backend_endpoints()
is_stack = AUTH_PROVIDER == "stack"
return HealthResponse(
status="ok",
version=APP_VERSION,
@ -95,4 +103,8 @@ async def health() -> HealthResponse:
auth_provider=AUTH_PROVIDER,
turn_enabled=bool(TURN_SECRET),
force_turn_relay=FORCE_TURN_RELAY,
stack_project_id=STACK_AUTH_PROJECT_ID if is_stack else None,
stack_publishable_client_key=(
STACK_PUBLISHABLE_CLIENT_KEY if is_stack else None
),
)

View file

@ -17,7 +17,10 @@ from api.enums import OrganizationConfigurationKey, PostHogEvent
from api.schemas.ai_model_configuration import (
DOGRAH_DEFAULT_LANGUAGE,
DOGRAH_DEFAULT_VOICE,
DOGRAH_SPEED_MAX,
DOGRAH_SPEED_MIN,
DOGRAH_SPEED_OPTIONS,
DOGRAH_SPEED_STEP,
OrganizationAIModelConfigurationResponse,
OrganizationAIModelConfigurationV2,
)
@ -38,7 +41,11 @@ from api.schemas.telephony_phone_number import (
PhoneNumberUpdateRequest,
ProviderSyncStatus,
)
from api.services.auth.depends import get_user, get_user_with_selected_organization
from api.services.auth.depends import (
_sync_posthog_organization_mps_billing_v2_status,
get_user,
get_user_with_selected_organization,
)
from api.services.configuration.ai_model_configuration import (
check_for_masked_keys_in_ai_model_configuration_v2,
compile_ai_model_configuration_v2,
@ -56,6 +63,7 @@ from api.services.configuration.masking import is_mask_of, mask_key, mask_user_c
from api.services.configuration.registry import (
DOGRAH_STT_LANGUAGES,
REGISTRY,
DograhTTSService,
ServiceProviders,
ServiceType,
)
@ -210,6 +218,13 @@ async def get_telephony_config_warnings(user: UserModel = Depends(get_user)):
# ---------------------------------------------------------------------------
def _dograh_allows_custom_voice() -> bool:
extra = DograhTTSService.model_fields["voice"].json_schema_extra
if isinstance(extra, dict):
return bool(extra.get("allow_custom_input", False))
return False
def _byok_provider_schemas(service_type: ServiceType) -> dict[str, dict]:
return {
provider: model_cls.model_json_schema()
@ -251,7 +266,13 @@ async def get_model_configuration_v2_defaults(
return {
"dograh": {
"voices": [DOGRAH_DEFAULT_VOICE],
"allow_custom_input": _dograh_allows_custom_voice(),
"speeds": list(DOGRAH_SPEED_OPTIONS),
"speed_range": {
"min": DOGRAH_SPEED_MIN,
"max": DOGRAH_SPEED_MAX,
"step": DOGRAH_SPEED_STEP,
},
"languages": DOGRAH_STT_LANGUAGES,
"defaults": {
"voice": DOGRAH_DEFAULT_VOICE,
@ -364,9 +385,10 @@ async def migrate_model_configuration_v2(
except ValueError as exc:
raise HTTPException(status_code=422, detail=exc.args[0])
billing_account_status = None
if DEPLOYMENT_MODE != "oss":
try:
await ensure_hosted_mps_billing_account_v2(
billing_account_status = await ensure_hosted_mps_billing_account_v2(
organization_id,
created_by=str(user.provider_id),
)
@ -389,6 +411,14 @@ async def migrate_model_configuration_v2(
organization_id=organization_id,
fallback_user_config=legacy,
)
if DEPLOYMENT_MODE != "oss":
_sync_posthog_organization_mps_billing_v2_status(
organization_id,
uses_mps_billing_v2=bool(
billing_account_status
and billing_account_status.get("billing_mode") == "v2"
),
)
return await _model_configuration_v2_response(
user=user,
configuration=configuration,

View file

@ -14,6 +14,7 @@ from api.services.auth.depends import get_user, get_user_with_selected_organizat
from api.services.mps_service_key_client import mps_service_key_client
from api.services.reports import generate_usage_runs_report_csv
from api.utils.artifacts import artifact_url
from api.utils.recording_artifacts import has_recording_track
router = APIRouter(prefix="/organizations")
@ -99,8 +100,12 @@ class WorkflowRunUsageResponse(BaseModel):
call_duration_seconds: int
recording_url: Optional[str] = None
transcript_url: Optional[str] = None
user_recording_url: Optional[str] = None
bot_recording_url: Optional[str] = None
recording_public_url: Optional[str] = None
transcript_public_url: Optional[str] = None
user_recording_public_url: Optional[str] = None
bot_recording_public_url: Optional[str] = None
public_access_token: Optional[str] = None
phone_number: Optional[str] = Field(
default=None,
@ -308,14 +313,18 @@ async def get_billing_credits(
aggregation_key=entry.get("aggregation_key"),
usage_event_id=_optional_int(entry.get("usage_event_id")),
workflow_run_id=_optional_int(entry.get("workflow_run_id")),
workflow_id=workflow_ids_by_run_id.get(
_optional_int(entry.get("workflow_run_id"))
)
if entry.get("workflow_run_id") is not None
else None,
billable_quantity=float(entry["billable_quantity"])
if entry.get("billable_quantity") is not None
else None,
workflow_id=(
workflow_ids_by_run_id.get(
_optional_int(entry.get("workflow_run_id"))
)
if entry.get("workflow_run_id") is not None
else None
),
billable_quantity=(
float(entry["billable_quantity"])
if entry.get("billable_quantity") is not None
else None
),
quantity_unit=entry.get("quantity_unit"),
metadata=entry.get("metadata") or {},
created_at=str(entry["created_at"]),
@ -478,6 +487,17 @@ async def get_usage_history(
public_access_token, "transcript"
)
run["recording_public_url"] = artifact_url(public_access_token, "recording")
run["user_recording_public_url"] = (
artifact_url(public_access_token, "user_recording")
if has_recording_track(run.get("extra"), "user")
else None
)
run["bot_recording_public_url"] = (
artifact_url(public_access_token, "bot_recording")
if has_recording_track(run.get("extra"), "bot")
else None
)
run.pop("extra", None)
return {
"runs": runs,

View file

@ -6,14 +6,16 @@ post-call processing for runs that execute integrations, QA, or campaign
reporting.
"""
from typing import Literal
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import RedirectResponse
from loguru import logger
from api.db import db_client
from api.services.storage import get_storage_for_backend
from api.utils.recording_artifacts import (
get_recording_storage_backend,
get_recording_storage_key,
)
router = APIRouter(prefix="/public/download")
@ -21,7 +23,7 @@ router = APIRouter(prefix="/public/download")
@router.get("/workflow/{token}/{artifact_type}")
async def download_workflow_artifact(
token: str,
artifact_type: Literal["recording", "transcript"],
artifact_type: str,
inline: bool = Query(
default=False, description="Display inline in browser instead of download"
),
@ -36,13 +38,15 @@ async def download_workflow_artifact(
Args:
token: The public access token (UUID format)
artifact_type: Type of artifact - "recording" or "transcript"
artifact_type: Type of artifact - "recording", "transcript",
"user_recording", or "bot_recording"
inline: If true, sets Content-Disposition to inline for browser preview
Returns:
RedirectResponse to the signed URL (302 redirect)
Raises:
HTTPException 400: If artifact type is unsupported
HTTPException 404: If token is invalid or artifact not found
"""
# 1. Lookup workflow run by token
@ -52,10 +56,26 @@ async def download_workflow_artifact(
raise HTTPException(status_code=404, detail="Invalid or expired token")
# 2. Get file path based on artifact type
artifact_storage_backend = None
if artifact_type == "recording":
file_path = workflow_run.recording_url
else: # transcript
elif artifact_type == "transcript":
file_path = workflow_run.transcript_url
elif artifact_type == "user_recording":
file_path = get_recording_storage_key(workflow_run.extra, "user")
artifact_storage_backend = get_recording_storage_backend(
workflow_run.extra, "user"
)
elif artifact_type == "bot_recording":
file_path = get_recording_storage_key(workflow_run.extra, "bot")
artifact_storage_backend = get_recording_storage_backend(
workflow_run.extra, "bot"
)
else:
logger.warning(
f"Unsupported artifact type: type={artifact_type}, workflow_run_id={workflow_run.id}"
)
raise HTTPException(status_code=400, detail="Unsupported artifact type")
if not file_path:
logger.warning(
@ -68,7 +88,9 @@ async def download_workflow_artifact(
# 3. Get storage backend for this workflow run
try:
storage = get_storage_for_backend(workflow_run.storage_backend)
storage = get_storage_for_backend(
artifact_storage_backend or workflow_run.storage_backend
)
except ValueError as e:
logger.error(f"Invalid storage backend: {workflow_run.storage_backend}")
raise HTTPException(status_code=500, detail="Storage configuration error")

View file

@ -40,14 +40,22 @@ class PresignedUploadUrlResponse(BaseModel):
router = APIRouter(prefix="/s3", tags=["s3"])
ORG_SCOPED_STORAGE_PREFIXES = ("campaigns", "knowledge_base")
def _extract_org_id_from_key(key: str) -> Optional[int]:
"""Try to extract an organization ID from a storage key.
Matches keys of the form ``{prefix}/{org_id}/...`` where *org_id* is a
positive integer. Returns ``None`` when the pattern does not match.
Matches known org-scoped keys of the form ``{prefix}/{org_id}/...`` where
*org_id* is a positive integer. Returns ``None`` when the pattern does not
match.
"""
parts = key.split("/")
if len(parts) >= 3 and parts[1].isdigit():
if (
len(parts) >= 3
and parts[0] in ORG_SCOPED_STORAGE_PREFIXES
and parts[1].isdigit()
):
return int(parts[1])
return None
@ -58,15 +66,20 @@ def _extract_legacy_workflow_run_id(key: str) -> Optional[int]:
Supports:
- ``transcripts/{run_id}.txt``
- ``recordings/{run_id}.wav``
- ``recordings/{run_id}/user.wav``
- ``recordings/{run_id}/bot.wav``
Returns ``None`` when the key does not match a legacy pattern.
"""
if key.startswith("transcripts/") and key.endswith(".txt"):
run_id_str = key[len("transcripts/") : -4]
elif key.startswith("recordings/") and key.endswith(".wav"):
run_id_str = key[len("recordings/") : -4]
else:
return None
recording_match = re.fullmatch(
r"recordings/(\d+)(?:\.wav|/(?:user|bot)\.wav)", key
)
if not recording_match:
return None
run_id_str = recording_match.group(1)
return int(run_id_str) if run_id_str.isdigit() else None
@ -89,8 +102,13 @@ async def _validate_and_extract_workflow_run_id(
"""
if key.startswith("transcripts/") and key.endswith(".txt"):
run_id_str = key[len("transcripts/") : -4] # strip prefix & suffix
elif key.startswith("recordings/") and key.endswith(".wav"):
run_id_str = key[len("recordings/") : -4]
elif key.startswith("recordings/"):
run_id = _extract_legacy_workflow_run_id(key)
if run_id is None:
raise HTTPException(
status_code=400, detail="Invalid workflow_run_id in key"
)
return run_id
elif allow_special_paths and key.startswith("voicemail_detections/"):
return None # Skip validation for these paths
else:
@ -159,9 +177,9 @@ async def get_signed_url(
"""Return a short-lived signed URL for a file stored on S3 / MinIO.
Access Control:
* Keys that embed an organization ID (``{prefix}/{org_id}/...``) are
authorized by matching the org_id against the requesting user's
organization.
* Known org-scoped keys (for example ``campaigns/{org_id}/...`` and
``knowledge_base/{org_id}/...``) are authorized by matching the org_id
against the requesting user's organization.
* Legacy keys (``recordings/{run_id}.wav``, ``transcripts/{run_id}.txt``)
are authorized via the workflow run they belong to.
* Superusers can request any key.

View file

@ -9,6 +9,7 @@ from api.db import db_client
from api.db.models import (
UserModel,
)
from api.schemas.onboarding_state import OnboardingState, OnboardingStateUpdate
from api.services.auth.depends import get_user
from api.services.configuration.ai_model_configuration import (
get_resolved_ai_model_configuration,
@ -26,6 +27,10 @@ from api.services.organization_preferences import (
get_organization_preferences,
upsert_organization_preferences,
)
from api.services.user_onboarding import (
get_onboarding_state,
update_onboarding_state,
)
router = APIRouter(prefix="/user")
@ -203,6 +208,21 @@ async def update_user_configurations(
return masked_config
@router.get("/onboarding-state")
async def get_user_onboarding_state(
user: UserModel = Depends(get_user),
) -> OnboardingState:
return await get_onboarding_state(user.id)
@router.put("/onboarding-state")
async def update_user_onboarding_state(
request: OnboardingStateUpdate,
user: UserModel = Depends(get_user),
) -> OnboardingState:
return await update_onboarding_state(user.id, request)
@router.get("/configurations/user/validate")
async def validate_user_configurations(
validity_ttl_seconds: int = Query(default=60, ge=0, le=86400),

View file

@ -19,7 +19,7 @@ import ipaddress
import os
from datetime import UTC, datetime
from enum import Enum
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Set
from aiortc import RTCIceServer
from aiortc.sdp import candidate_from_sdp
@ -246,6 +246,74 @@ class SignalingManager:
def __init__(self):
self._connections: Dict[str, WebSocket] = {}
self._peer_connections: Dict[str, SmallWebRTCConnection] = {}
self._connection_peer_ids: Dict[str, Set[str]] = {}
self._peer_connection_owners: Dict[str, str] = {}
def _track_peer_connection(
self, connection_id: str, pc_id: str, pc: SmallWebRTCConnection
) -> None:
self._peer_connections[pc_id] = pc
self._peer_connection_owners[pc_id] = connection_id
self._connection_peer_ids.setdefault(connection_id, set()).add(pc_id)
def _forget_peer_connection(self, pc_id: str) -> Optional[str]:
connection_id = self._peer_connection_owners.pop(pc_id, None)
self._peer_connections.pop(pc_id, None)
if connection_id:
peer_ids = self._connection_peer_ids.get(connection_id)
if peer_ids is not None:
peer_ids.discard(pc_id)
if not peer_ids:
self._connection_peer_ids.pop(connection_id, None)
return connection_id
async def _send_json_if_connected(
self, websocket: WebSocket, message: dict
) -> bool:
if websocket.application_state != WebSocketState.CONNECTED:
return False
try:
await websocket.send_json(message)
return True
except Exception as e:
logger.debug(f"Failed to send signaling WebSocket message: {e}")
return False
async def _close_websocket_if_connected(
self, websocket: WebSocket, code: int = 1000, reason: str = ""
) -> None:
if websocket.application_state != WebSocketState.CONNECTED:
return
try:
await websocket.close(code=code, reason=reason)
except Exception as e:
logger.debug(f"Failed to close signaling WebSocket: {e}")
async def _notify_call_ended_and_close_websocket(
self,
websocket: WebSocket,
workflow_run_id: int,
pc_id: str,
reason: str,
) -> None:
await self._send_json_if_connected(
websocket,
{
"type": "call-ended",
"payload": {
"workflow_run_id": workflow_run_id,
"pc_id": pc_id,
"reason": reason,
},
},
)
await self._close_websocket_if_connected(
websocket, code=1000, reason="call ended"
)
async def handle_websocket(
self,
@ -257,35 +325,51 @@ class SignalingManager:
"""Handle WebSocket connection for signaling."""
await websocket.accept()
connection_id = f"{workflow_id}:{workflow_run_id}:{user.id}"
self._connections[connection_id] = websocket
connection_key = f"{connection_id}:{id(websocket)}"
self._connections[connection_key] = websocket
try:
while True:
message = await websocket.receive_json()
await self._handle_message(
websocket, message, workflow_id, workflow_run_id, user
websocket,
message,
workflow_id,
workflow_run_id,
user,
connection_key,
)
except WebSocketDisconnect:
logger.info(f"WebSocket disconnected for {connection_id}")
except Exception as e:
logger.error(f"WebSocket error for {connection_id}: {e}")
if websocket.application_state == WebSocketState.DISCONNECTED:
logger.info(f"WebSocket disconnected for {connection_id}")
else:
logger.error(f"WebSocket error for {connection_id}: {e}")
finally:
# Cleanup
self._connections.pop(connection_id, None)
self._connections.pop(connection_key, None)
peer_ids = list(self._connection_peer_ids.pop(connection_key, set()))
# Unregister WebSocket sender for real-time feedback
unregister_ws_sender(workflow_run_id)
# Clean up all peer connections for this workflow run
# Clean up peer connections owned by this WebSocket.
# Note: In a WebSocket-based signaling approach (vs HTTP PATCH),
# we maintain our own connection map instead of relying on
# SmallWebRTCRequestHandler's _pcs_map. This is suitable for
# multi-worker FastAPI deployments where state cannot be shared.
for pc_id in list(self._peer_connections.keys()):
for pc_id in peer_ids:
self._peer_connection_owners.pop(pc_id, None)
pc = self._peer_connections.pop(pc_id, None)
if pc:
await pc.disconnect()
logger.debug(f"Disconnected peer connection: {pc_id}")
try:
await pc.disconnect()
logger.debug(f"Disconnected peer connection: {pc_id}")
except Exception as e:
logger.debug(
f"Failed to disconnect peer connection {pc_id}: {e}"
)
async def _handle_message(
self,
@ -294,17 +378,20 @@ class SignalingManager:
workflow_id: int,
workflow_run_id: int,
user: UserModel,
connection_key: str,
):
"""Handle incoming WebSocket messages."""
msg_type = message.get("type")
payload = message.get("payload", {})
if msg_type == "offer":
await self._handle_offer(ws, payload, workflow_id, workflow_run_id, user)
await self._handle_offer(
ws, payload, workflow_id, workflow_run_id, user, connection_key
)
elif msg_type == "ice-candidate":
await self._handle_ice_candidate(ws, payload, workflow_run_id)
await self._handle_ice_candidate(payload, connection_key)
elif msg_type == "renegotiate":
await self._handle_renegotiation(ws, payload, workflow_id, workflow_run_id)
await self._handle_renegotiation(ws, payload, connection_key)
async def _handle_offer(
self,
@ -313,6 +400,7 @@ class SignalingManager:
workflow_id: int,
workflow_run_id: int,
user: UserModel,
connection_key: str,
):
"""Handle offer message and create answer with ICE trickling."""
pc_id = payload.get("pc_id")
@ -320,6 +408,15 @@ class SignalingManager:
type_ = payload.get("type")
call_context_vars = payload.get("call_context_vars", {})
if not pc_id or not sdp or not type_:
await ws.send_json(
{
"type": "error",
"payload": {"message": "Missing offer fields"},
}
)
return
# Set run context for logging and tracing. org_id must be set before
# pc.initialize() so that aiortc's internal tasks inherit it.
set_current_run_id(workflow_run_id)
@ -347,7 +444,16 @@ class SignalingManager:
)
return
if pc_id and pc_id in self._peer_connections:
if pc_id in self._peer_connections:
if self._peer_connection_owners.get(pc_id) != connection_key:
await ws.send_json(
{
"type": "error",
"payload": {"message": "Peer connection already owned"},
}
)
return
# Reuse existing connection
logger.info(f"Reusing existing connection for pc_id: {pc_id}")
pc = self._peer_connections[pc_id]
@ -379,7 +485,7 @@ class SignalingManager:
await pc.initialize(sdp=sdp, type=type_)
# Store peer connection using client's pc_id
self._peer_connections[pc_id] = pc
self._track_peer_connection(connection_key, pc_id, pc)
# Register WebSocket sender for real-time feedback
async def ws_sender(message: dict):
@ -392,7 +498,16 @@ class SignalingManager:
@pc.event_handler("closed")
async def handle_disconnected(webrtc_connection: SmallWebRTCConnection):
logger.info(f"PeerConnection closed: {webrtc_connection.pc_id}")
self._peer_connections.pop(webrtc_connection.pc_id, None)
owner_connection_id = self._forget_peer_connection(
webrtc_connection.pc_id
)
if owner_connection_id == connection_key:
await self._notify_call_ended_and_close_websocket(
ws,
workflow_run_id,
webrtc_connection.pc_id,
reason="peer_connection_closed",
)
# Start pipeline in background
asyncio.create_task(
@ -421,9 +536,7 @@ class SignalingManager:
}
)
async def _handle_ice_candidate(
self, ws: WebSocket, payload: dict, workflow_run_id: int
):
async def _handle_ice_candidate(self, payload: dict, connection_key: str):
"""Handle incoming ICE candidate from client.
Uses SmallWebRTC's native ICE trickling support via add_ice_candidate().
@ -442,6 +555,9 @@ class SignalingManager:
if not pc:
logger.warning(f"No peer connection found for pc_id: {pc_id}")
return
if self._peer_connection_owners.get(pc_id) != connection_key:
logger.warning(f"Ignoring ICE candidate for unowned pc_id: {pc_id}")
return
if candidate_data:
candidate_str = candidate_data.get("candidate", "")
@ -466,7 +582,7 @@ class SignalingManager:
logger.debug(f"End of ICE candidates for pc_id: {pc_id}")
async def _handle_renegotiation(
self, ws: WebSocket, payload: dict, workflow_id: int, workflow_run_id: int
self, ws: WebSocket, payload: dict, connection_key: str
):
"""Handle renegotiation request."""
pc_id = payload.get("pc_id")
@ -479,6 +595,11 @@ class SignalingManager:
{"type": "error", "payload": {"message": "Peer connection not found"}}
)
return
if self._peer_connection_owners.get(pc_id) != connection_key:
await ws.send_json(
{"type": "error", "payload": {"message": "Peer connection not found"}}
)
return
pc = self._peer_connections[pc_id]
await pc.renegotiate(sdp=sdp, type=type_, restart_pc=restart_pc)

View file

@ -15,7 +15,7 @@ from api.db import db_client
from api.db.agent_trigger_client import TriggerPathConflictError
from api.db.models import UserModel
from api.db.workflow_template_client import WorkflowTemplateClient
from api.enums import CallType, PostHogEvent, StorageBackend
from api.enums import CallType, PostHogEvent, StorageBackend, WorkflowStatus
from api.schemas.ai_model_configuration import OrganizationAIModelConfigurationV2
from api.schemas.workflow import WorkflowRunResponseSchema
from api.sdk_expose import sdk_expose
@ -58,8 +58,15 @@ from api.services.workflow.trigger_paths import (
trigger_path_to_node_id,
validate_trigger_paths,
)
from api.services.workflow.workflow_graph import WorkflowGraph
from api.services.workflow.workflow_graph import (
WorkflowGraph,
validate_node_instance_constraints,
)
from api.utils.artifacts import artifact_url
from api.utils.recording_artifacts import (
get_recording_storage_key,
has_recording_track,
)
router = APIRouter(prefix="/workflow")
@ -188,6 +195,27 @@ def _validation_errors_http_exception(
)
def _node_instance_validation_errors(
workflow_definition: Optional[dict],
) -> list[WorkflowError]:
"""Validate spec-driven max_instances without requiring a complete draft."""
if not workflow_definition:
return []
nodes = workflow_definition.get("nodes")
if not isinstance(nodes, list):
return []
node_types = [
node.get("type")
for node in nodes
if isinstance(node, dict) and isinstance(node.get("type"), str)
]
return validate_node_instance_constraints(
node_types,
enforce_min_instances=False,
)
class CallDispositionCodes(BaseModel):
disposition_codes: list[str] = []
@ -380,6 +408,9 @@ async def create_workflow(
trigger_path_issues = validate_trigger_paths(workflow_definition)
if trigger_path_issues:
raise _trigger_path_validation_http_exception(trigger_path_issues)
instance_errors = _node_instance_validation_errors(workflow_definition)
if instance_errors:
raise _validation_errors_http_exception(instance_errors)
# Validate trigger path uniqueness BEFORE creating the workflow so we
# don't leave an orphaned workflow record when the trigger conflicts.
@ -574,6 +605,31 @@ async def get_workflow_count(
)
def _validate_status_filter(status: Optional[str]) -> List[str]:
"""Parse and validate a workflow ``status`` query filter.
Accepts a single value or a comma-separated list. Returns the list of
validated status values (empty when no filter was supplied). Any value
outside the ``workflow_status`` enum raises 422 so the request fails as a
clean client error instead of a 500 from the Postgres enum cast.
"""
if status is None or status == "":
return []
allowed = {s.value for s in WorkflowStatus}
requested = [s.strip() for s in status.split(",")]
invalid = sorted({s for s in requested if s not in allowed})
if invalid:
invalid_display = ["<empty>" if s == "" else s for s in invalid]
raise HTTPException(
status_code=422,
detail=(
f"Invalid workflow status filter: {invalid_display}. "
f"Allowed values: {sorted(allowed)}."
),
)
return requested
@router.get(
"/fetch",
**sdk_expose(
@ -593,21 +649,22 @@ async def get_workflows(
Returns a lightweight response with only essential fields for listing.
Use GET /workflow/fetch/{workflow_id} to get full workflow details.
"""
# Handle comma-separated status values
if status and "," in status:
# Split comma-separated values and fetch workflows for each status
status_list = [s.strip() for s in status.split(",")]
statuses = _validate_status_filter(status)
if statuses:
# Fetch workflows for each requested status and combine the results.
all_workflows = []
for status_value in status_list:
workflows = await db_client.get_all_workflows_for_listing(
organization_id=user.selected_organization_id, status=status_value
for status_value in statuses:
all_workflows.extend(
await db_client.get_all_workflows_for_listing(
organization_id=user.selected_organization_id,
status=status_value,
)
)
all_workflows.extend(workflows)
workflows = all_workflows
else:
# Single status or no status filter
# No status filter
workflows = await db_client.get_all_workflows_for_listing(
organization_id=user.selected_organization_id, status=status
organization_id=user.selected_organization_id, status=None
)
# Get run counts for all workflows in a single query
@ -816,10 +873,20 @@ async def get_workflows_summary(
),
) -> List[WorkflowSummaryResponse]:
"""Get minimal workflow information (id and name only) for all workflows"""
workflows = await db_client.get_all_workflows(
organization_id=user.selected_organization_id,
status=status,
)
statuses = _validate_status_filter(status)
if statuses:
workflows = []
for status_value in statuses:
workflows.extend(
await db_client.get_all_workflows(
organization_id=user.selected_organization_id,
status=status_value,
)
)
else:
workflows = await db_client.get_all_workflows(
organization_id=user.selected_organization_id, status=None
)
return [
WorkflowSummaryResponse(id=workflow.id, name=workflow.name)
for workflow in workflows
@ -950,6 +1017,9 @@ async def update_workflow(
trigger_path_issues = validate_trigger_paths(workflow_definition)
if trigger_path_issues:
raise _trigger_path_validation_http_exception(trigger_path_issues)
instance_errors = _node_instance_validation_errors(workflow_definition)
if instance_errors:
raise _validation_errors_http_exception(instance_errors, status_code=409)
if workflow_definition:
existing_workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
@ -1255,7 +1325,16 @@ async def get_workflow_run(
raise HTTPException(status_code=404, detail="Workflow run not found")
public_access_token = run.public_access_token
if (run.transcript_url or run.recording_url) and not public_access_token:
user_recording_url = get_recording_storage_key(run.extra, "user")
bot_recording_url = get_recording_storage_key(run.extra, "bot")
has_user_recording = has_recording_track(run.extra, "user")
has_bot_recording = has_recording_track(run.extra, "bot")
if (
run.transcript_url
or run.recording_url
or has_user_recording
or has_bot_recording
) and not public_access_token:
public_access_token = await db_client.ensure_public_access_token(run.id)
return {
@ -1266,8 +1345,20 @@ async def get_workflow_run(
"is_completed": run.is_completed,
"transcript_url": run.transcript_url,
"recording_url": run.recording_url,
"user_recording_url": user_recording_url,
"bot_recording_url": bot_recording_url,
"transcript_public_url": artifact_url(public_access_token, "transcript"),
"recording_public_url": artifact_url(public_access_token, "recording"),
"user_recording_public_url": (
artifact_url(public_access_token, "user_recording")
if has_user_recording
else None
),
"bot_recording_public_url": (
artifact_url(public_access_token, "bot_recording")
if has_bot_recording
else None
),
"public_access_token": public_access_token,
"cost_info": format_public_cost_info(run.cost_info, run.usage_info),
"usage_info": format_public_usage_info(run.usage_info),

View file

@ -18,6 +18,9 @@ from api.services.configuration.registry import (
TTSConfig,
)
DOGRAH_SPEED_MIN = 0.5
DOGRAH_SPEED_MAX = 2.0
DOGRAH_SPEED_STEP = 0.1
DOGRAH_SPEED_OPTIONS: tuple[float, ...] = (0.8, 1.0, 1.2)
DOGRAH_DEFAULT_VOICE = "default"
DOGRAH_DEFAULT_LANGUAGE = "multi"
@ -49,16 +52,9 @@ class EffectiveAIModelConfiguration(BaseModel):
class DograhManagedAIModelConfiguration(BaseModel):
api_key: str
voice: str = DOGRAH_DEFAULT_VOICE
speed: float = Field(default=1.0)
speed: float = Field(default=1.0, ge=DOGRAH_SPEED_MIN, le=DOGRAH_SPEED_MAX)
language: str = DOGRAH_DEFAULT_LANGUAGE
@model_validator(mode="after")
def validate_speed(self):
if self.speed not in DOGRAH_SPEED_OPTIONS:
allowed = ", ".join(str(speed) for speed in DOGRAH_SPEED_OPTIONS)
raise ValueError(f"Dograh speed must be one of: {allowed}")
return self
class BYOKPipelineAIModelConfiguration(BaseModel):
llm: LLMConfig

View file

@ -0,0 +1,47 @@
from datetime import datetime
from pydantic import BaseModel, Field
class OnboardingState(BaseModel):
"""Per-user onboarding state, stored under UserConfigurationKey.ONBOARDING.
Server-authoritative replacement for the browser-localStorage onboarding
store, so the post-signup gate and one-time tooltips hold across devices.
"""
# Post-signup onboarding form gate: set once on submit/skip.
completed_at: datetime | None = None
skipped: bool = False
# One-time UI affordances (tooltip keys, milestone action keys). Kept as
# free-form strings — the UI owns the vocabulary.
seen_tooltips: list[str] = Field(default_factory=list)
completed_actions: list[str] = Field(default_factory=list)
class OnboardingStateUpdate(BaseModel):
"""Partial update merged into the stored state.
Scalars overwrite when supplied; list entries are unioned into the stored
lists, so concurrent updates (e.g. two tabs marking different tooltips)
don't drop each other's items.
"""
completed_at: datetime | None = None
skipped: bool | None = None
seen_tooltips: list[str] | None = None
completed_actions: list[str] | None = None
def apply_to(self, state: OnboardingState) -> OnboardingState:
merged = state.model_copy(deep=True)
if self.completed_at is not None:
merged.completed_at = self.completed_at
if self.skipped is not None:
merged.skipped = self.skipped
for tooltip in self.seen_tooltips or []:
if tooltip not in merged.seen_tooltips:
merged.seen_tooltips.append(tooltip)
for action in self.completed_actions or []:
if action not in merged.completed_actions:
merged.completed_actions.append(action)
return merged

View file

@ -15,8 +15,12 @@ class WorkflowRunResponseSchema(BaseModel):
is_completed: bool
transcript_url: str | None
recording_url: str | None
user_recording_url: str | None = None
bot_recording_url: str | None = None
transcript_public_url: str | None = None
recording_public_url: str | None = None
user_recording_public_url: str | None = None
bot_recording_public_url: str | None = None
public_access_token: str | None = None
cost_info: Dict[str, Any] | None
usage_info: Dict[str, Any] | None = None

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:

View file

@ -27,6 +27,7 @@ from api.services.workflow.dto import (
)
from api.services.workflow.qa import run_per_node_qa_analysis
from api.utils.credential_auth import build_auth_header
from api.utils.recording_artifacts import get_recording_storage_key
from api.utils.template_renderer import render_template
@ -339,6 +340,10 @@ def _build_render_context(
Returns:
Dict containing all fields available for template rendering
"""
extra = workflow_run.extra or {}
user_recording_key = get_recording_storage_key(extra, "user")
bot_recording_key = get_recording_storage_key(extra, "bot")
context = {
# Top-level fields
"workflow_run_id": workflow_run.id,
@ -353,6 +358,7 @@ def _build_render_context(
"cost_info": workflow_run.usage_info or {},
# Annotations (includes QA results)
"annotations": workflow_run.annotations or {},
"extra": extra,
}
# Add public download URLs if token is available
@ -366,9 +372,17 @@ def _build_render_context(
context["transcript_url"] = (
f"{base_url}/transcript" if workflow_run.transcript_url else None
)
context["user_recording_url"] = (
f"{base_url}/user_recording" if user_recording_key else None
)
context["bot_recording_url"] = (
f"{base_url}/bot_recording" if bot_recording_key else None
)
else:
context["recording_url"] = workflow_run.recording_url
context["transcript_url"] = workflow_run.transcript_url
context["user_recording_url"] = user_recording_key
context["bot_recording_url"] = bot_recording_key
return context

View file

@ -12,11 +12,51 @@ from api.services.workflow_run_billing import (
from api.tasks.run_integrations import run_integrations_post_workflow_run
def _recording_metadata(storage_key: str, storage_backend: str, track: str) -> dict:
return {
"storage_key": storage_key,
"storage_backend": storage_backend,
"format": "wav",
"track": track,
}
async def _upload_temp_file(
workflow_run_id: int,
temp_file_path: str,
storage_key: str,
label: str,
) -> bool:
try:
if not os.path.exists(temp_file_path):
logger.warning(f"{label} temp file not found: {temp_file_path}")
return False
file_size = os.path.getsize(temp_file_path)
logger.debug(f"{label} file size: {file_size} bytes")
await storage_fs.aupload_file(temp_file_path, storage_key)
logger.info(f"Successfully uploaded {label}: {storage_key}")
return True
except Exception as e:
logger.error(f"Error uploading {label} for workflow {workflow_run_id}: {e}")
return False
finally:
if os.path.exists(temp_file_path):
try:
os.remove(temp_file_path)
logger.debug(f"Cleaned up temp {label} file: {temp_file_path}")
except Exception as e:
logger.warning(f"Failed to clean up temp {label} file: {e}")
async def process_workflow_completion(
_ctx,
workflow_run_id: int,
audio_temp_path: Optional[str] = None,
transcript_temp_path: Optional[str] = None,
user_audio_temp_path: Optional[str] = None,
bot_audio_temp_path: Optional[str] = None,
):
"""Process workflow completion: upload artifacts and run integrations.
@ -28,6 +68,8 @@ async def process_workflow_completion(
workflow_run_id: The workflow run ID
audio_temp_path: Optional path to temp audio file
transcript_temp_path: Optional path to temp transcript file
user_audio_temp_path: Optional path to temp user-track audio file
bot_audio_temp_path: Optional path to temp bot-track audio file
"""
run_id = str(workflow_run_id)
set_current_run_id(run_id)
@ -37,35 +79,55 @@ async def process_workflow_completion(
storage_backend = get_current_storage_backend()
# Step 1: Upload audio if provided
recordings_metadata: dict[str, dict] = {}
if audio_temp_path:
try:
if os.path.exists(audio_temp_path):
file_size = os.path.getsize(audio_temp_path)
logger.debug(f"Audio file size: {file_size} bytes")
recording_url = f"recordings/{workflow_run_id}.wav"
logger.info(
f"Uploading mixed audio to {storage_backend.name} - workflow_run_id: {workflow_run_id}"
)
if await _upload_temp_file(
workflow_run_id, audio_temp_path, recording_url, "mixed audio"
):
recordings_metadata["mixed"] = _recording_metadata(
recording_url, storage_backend.value, "mixed"
)
await db_client.update_workflow_run(
run_id=workflow_run_id,
recording_url=recording_url,
storage_backend=storage_backend.value,
)
recording_url = f"recordings/{workflow_run_id}.wav"
logger.info(
f"Uploading audio to {storage_backend.name} - workflow_run_id: {workflow_run_id}"
)
if user_audio_temp_path:
user_recording_url = f"recordings/{workflow_run_id}/user.wav"
logger.info(
f"Uploading user audio to {storage_backend.name} - workflow_run_id: {workflow_run_id}"
)
if await _upload_temp_file(
workflow_run_id, user_audio_temp_path, user_recording_url, "user audio"
):
recordings_metadata["user"] = _recording_metadata(
user_recording_url, storage_backend.value, "user"
)
await storage_fs.aupload_file(audio_temp_path, recording_url)
await db_client.update_workflow_run(
run_id=workflow_run_id,
recording_url=recording_url,
storage_backend=storage_backend.value,
)
logger.info(f"Successfully uploaded audio: {recording_url}")
else:
logger.warning(f"Audio temp file not found: {audio_temp_path}")
except Exception as e:
logger.error(f"Error uploading audio for workflow {workflow_run_id}: {e}")
finally:
if audio_temp_path and os.path.exists(audio_temp_path):
try:
os.remove(audio_temp_path)
logger.debug(f"Cleaned up temp audio file: {audio_temp_path}")
except Exception as e:
logger.warning(f"Failed to clean up temp audio file: {e}")
if bot_audio_temp_path:
bot_recording_url = f"recordings/{workflow_run_id}/bot.wav"
logger.info(
f"Uploading bot audio to {storage_backend.name} - workflow_run_id: {workflow_run_id}"
)
if await _upload_temp_file(
workflow_run_id, bot_audio_temp_path, bot_recording_url, "bot audio"
):
recordings_metadata["bot"] = _recording_metadata(
bot_recording_url, storage_backend.value, "bot"
)
if recordings_metadata:
await db_client.update_workflow_run(
run_id=workflow_run_id,
storage_backend=storage_backend.value,
extra={"recordings": recordings_metadata},
)
# Step 2: Upload transcript if provided
if transcript_temp_path:

View file

@ -0,0 +1,23 @@
{
"nodes": [
{
"id": "s1",
"type": "startCall",
"position": {"x": 0, "y": 0},
"data": {"name": "Start", "prompt": "Greet.", "is_start": true}
},
{
"id": "g1",
"type": "globalNode",
"position": {"x": 0, "y": 200},
"data": {"name": "Global A", "prompt": "Use a calm tone."}
},
{
"id": "g2",
"type": "globalNode",
"position": {"x": 0, "y": 400},
"data": {"name": "Global B", "prompt": "Keep answers short."}
}
],
"edges": []
}

View file

@ -0,0 +1,23 @@
{
"nodes": [
{
"id": "s1",
"type": "startCall",
"position": {"x": 0, "y": 0},
"data": {"name": "Start", "prompt": "Greet.", "is_start": true}
},
{
"id": "t1",
"type": "trigger",
"position": {"x": 0, "y": 200},
"data": {"name": "Trigger A", "trigger_path": "trigger_a"}
},
{
"id": "t2",
"type": "trigger",
"position": {"x": 0, "y": 400},
"data": {"name": "Trigger B", "trigger_path": "trigger_b"}
}
],
"edges": []
}

View file

@ -1,5 +1,5 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock
import pytest
from pydantic import ValidationError
@ -59,13 +59,27 @@ def test_dograh_v2_compiles_to_effective_managed_pipeline_with_embeddings():
assert effective.managed_service_version == 2
def test_dograh_v2_rejects_non_predefined_speed():
def test_dograh_v2_accepts_numeric_speed_in_registry_range():
config = OrganizationAIModelConfigurationV2(
mode="dograh",
dograh=DograhManagedAIModelConfiguration(
api_key="mps-secret",
speed=1.5,
),
)
effective = compile_ai_model_configuration_v2(config)
assert effective.tts.speed == 1.5
def test_dograh_v2_rejects_out_of_range_speed():
with pytest.raises(ValidationError):
OrganizationAIModelConfigurationV2(
mode="dograh",
dograh=DograhManagedAIModelConfiguration(
api_key="mps-secret",
speed=1.5,
speed=2.5,
),
)
@ -238,6 +252,33 @@ def test_legacy_all_dograh_pipeline_converts_to_dograh_v2():
assert config.dograh.api_key == "mps-secret"
def test_legacy_dograh_pipeline_conversion_preserves_numeric_speed():
legacy = EffectiveAIModelConfiguration(
llm=DograhLLMService(
provider="dograh",
api_key=["mps-secret"],
model="default",
),
tts=DograhTTSService(
provider="dograh",
api_key=["mps-secret"],
model="default",
voice="default",
speed=1.5,
),
stt=DograhSTTService(
provider="dograh",
api_key=["mps-secret"],
model="default",
),
)
config = convert_legacy_ai_model_configuration_to_v2(legacy)
assert config.mode == "dograh"
assert config.dograh.speed == 1.5
def test_legacy_mixed_dograh_pipeline_converts_to_dograh_v2():
legacy = EffectiveAIModelConfiguration(
llm=OpenAILLMService(
@ -401,6 +442,7 @@ async def test_migrate_model_configuration_v2_initializes_hosted_mps_billing(
ensure_billing = AsyncMock(return_value={"billing_mode": "v2"})
upsert = AsyncMock()
migrate_workflows = AsyncMock()
sync_posthog_billing = Mock()
monkeypatch.setattr(organization_routes, "DEPLOYMENT_MODE", "saas")
monkeypatch.setattr(
@ -438,6 +480,11 @@ async def test_migrate_model_configuration_v2_initializes_hosted_mps_billing(
"_model_configuration_v2_response",
AsyncMock(return_value=expected_response),
)
monkeypatch.setattr(
organization_routes,
"_sync_posthog_organization_mps_billing_v2_status",
sync_posthog_billing,
)
user = SimpleNamespace(
id=7,
@ -456,4 +503,5 @@ async def test_migrate_model_configuration_v2_initializes_hosted_mps_billing(
organization_id=42,
fallback_user_config=legacy,
)
sync_posthog_billing.assert_called_once_with(42, uses_mps_billing_v2=True)
assert response == expected_response

View file

@ -19,10 +19,13 @@ async def test_get_user_initializes_hosted_mps_billing_for_new_org(monkeypatch):
provider_id="stack-user-1",
selected_organization_id=None,
)
organization = SimpleNamespace(id=42)
organization = SimpleNamespace(id=42, provider_id="team-1")
existing_config = SimpleNamespace(llm=object(), tts=None, stt=None)
ensure_billing = AsyncMock(return_value={"billing_mode": "v2"})
group_calls = []
capture_calls = []
person_calls = []
monkeypatch.setattr(auth_depends, "AUTH_PROVIDER", "stack")
monkeypatch.setattr(
@ -60,9 +63,247 @@ async def test_get_user_initializes_hosted_mps_billing_for_new_org(monkeypatch):
"ensure_hosted_mps_billing_account_v2",
ensure_billing,
)
monkeypatch.setattr(
auth_depends,
"group_identify",
lambda *args, **kwargs: group_calls.append((args, kwargs)),
)
monkeypatch.setattr(
auth_depends,
"capture_event",
lambda *args, **kwargs: capture_calls.append((args, kwargs)),
)
monkeypatch.setattr(
auth_depends,
"set_person_properties",
lambda *args, **kwargs: person_calls.append((args, kwargs)),
)
result = await auth_depends.get_user(authorization="Bearer token")
assert result is user
assert result.selected_organization_id == 42
ensure_billing.assert_awaited_once_with(42, created_by="stack-user-1")
assert len(group_calls) == 1
group_args, group_kwargs = group_calls[0]
assert group_args == (
"organization",
"42",
{
"organization_id": 42,
"organization_provider_id": "team-1",
"auth_provider": "stack",
"created_by_provider_id": "stack-user-1",
},
)
assert group_kwargs == {"distinct_id": "stack-user-1"}
assert len(person_calls) == 1
person_args, person_kwargs = person_calls[0]
assert person_args == (
"stack-user-1",
{
"user_id": 7,
"user_provider_id": "stack-user-1",
"selected_organization_id": 42,
"selected_organization_provider_id": "team-1",
},
)
assert person_kwargs == {}
assert len(capture_calls) == 2
org_created_args, org_created_kwargs = capture_calls[0]
assert org_created_args == ()
assert org_created_kwargs["distinct_id"] == "stack-user-1"
assert org_created_kwargs["event"] == auth_depends.PostHogEvent.ORGANIZATION_CREATED
assert org_created_kwargs["groups"] == {"organization": "42"}
assert org_created_kwargs["properties"] == {
"organization_id": 42,
"organization_provider_id": "team-1",
"auth_provider": "stack",
"created_by_provider_id": "stack-user-1",
}
association_args, association_kwargs = capture_calls[1]
assert association_args == ()
assert association_kwargs["distinct_id"] == "stack-user-1"
assert (
association_kwargs["event"]
== auth_depends.PostHogEvent.ORGANIZATION_USER_ASSOCIATED
)
assert association_kwargs["groups"] == {"organization": "42"}
assert association_kwargs["properties"] == {
"user_id": 7,
"organization_id": 42,
"organization_provider_id": "team-1",
"auth_provider": "stack",
"organization_was_created": True,
}
def test_associate_user_with_posthog_org_supports_backfill_arguments(monkeypatch):
user = SimpleNamespace(
id=7,
email="user@example.com",
provider_id="stack-user-1",
selected_organization_id=99,
)
organization = SimpleNamespace(id=42, provider_id="team-1")
person_calls = []
capture_calls = []
monkeypatch.setattr(
auth_depends,
"set_person_properties",
lambda *args, **kwargs: person_calls.append((args, kwargs)),
)
monkeypatch.setattr(
auth_depends,
"capture_event",
lambda *args, **kwargs: capture_calls.append((args, kwargs)),
)
auth_depends._associate_user_with_posthog_organization(
user=user,
organization=organization,
user_distinct_id="stack-user-1",
org_was_created=False,
organization_ids=[42, 99],
selected_organization_id=99,
selected_organization_provider_id="team-99",
)
assert person_calls == [
(
(
"stack-user-1",
{
"user_id": 7,
"user_provider_id": "stack-user-1",
"selected_organization_id": 99,
"selected_organization_provider_id": "team-99",
"organization_ids": [42, 99],
"email": "user@example.com",
},
),
{},
)
]
assert len(capture_calls) == 1
_, capture_kwargs = capture_calls[0]
assert capture_kwargs["distinct_id"] == "stack-user-1"
assert (
capture_kwargs["event"]
== auth_depends.PostHogEvent.ORGANIZATION_USER_ASSOCIATED
)
assert capture_kwargs["groups"] == {"organization": "42"}
assert capture_kwargs["properties"] == {
"user_id": 7,
"organization_id": 42,
"organization_provider_id": "team-1",
"auth_provider": "stack",
"organization_was_created": False,
}
assert "backfilled" not in capture_kwargs["properties"]
def test_sync_created_organization_to_posthog_supports_billing_flag(monkeypatch):
organization = SimpleNamespace(id=42, provider_id="team-1")
group_calls = []
capture_calls = []
monkeypatch.setattr(
auth_depends,
"group_identify",
lambda *args, **kwargs: group_calls.append((args, kwargs)),
)
monkeypatch.setattr(
auth_depends,
"capture_event",
lambda *args, **kwargs: capture_calls.append((args, kwargs)),
)
auth_depends._sync_created_organization_to_posthog(
organization=organization,
created_by_provider_id="stack-user-1",
uses_mps_billing_v2=True,
)
_, group_kwargs = group_calls[0]
group_args, _ = group_calls[0]
assert group_args == (
"organization",
"42",
{
"organization_id": 42,
"organization_provider_id": "team-1",
"auth_provider": "stack",
"created_by_provider_id": "stack-user-1",
"uses_mps_billing_v2": True,
},
)
assert group_kwargs == {"distinct_id": "stack-user-1"}
_, capture_kwargs = capture_calls[0]
assert capture_kwargs["distinct_id"] == "stack-user-1"
assert capture_kwargs["properties"]["uses_mps_billing_v2"] is True
def test_sync_posthog_organization_group_properties_has_no_distinct_id(monkeypatch):
organization = SimpleNamespace(id=42, provider_id="team-1")
group_calls = []
monkeypatch.setattr(
auth_depends,
"group_identify",
lambda *args, **kwargs: group_calls.append((args, kwargs)),
)
auth_depends._sync_posthog_organization_group_properties(
organization=organization,
uses_mps_billing_v2=True,
)
assert group_calls == [
(
(
"organization",
"42",
{
"organization_id": 42,
"organization_provider_id": "team-1",
"auth_provider": "stack",
"uses_mps_billing_v2": True,
},
),
{},
)
]
def test_sync_posthog_organization_mps_billing_v2_status(monkeypatch):
group_calls = []
monkeypatch.setattr(
auth_depends,
"group_identify",
lambda *args, **kwargs: group_calls.append((args, kwargs)),
)
auth_depends._sync_posthog_organization_mps_billing_v2_status(
42,
uses_mps_billing_v2=True,
)
assert group_calls == [
(
(
"organization",
"42",
{"uses_mps_billing_v2": True},
),
{},
)
]

View file

@ -43,3 +43,35 @@ def test_create_cartesia_tts_service_passes_selected_model():
assert kwargs["api_key"] == "test-key"
assert kwargs["settings"].model == "sonic-3.5"
assert kwargs["settings"].voice == "test-voice-id"
def test_cartesia_tts_configuration_default_language_is_english():
config = CartesiaTTSConfiguration(api_key="test-key")
assert config.language == "en"
def test_create_cartesia_tts_service_passes_language_to_settings():
user_config = SimpleNamespace(
tts=SimpleNamespace(
provider=ServiceProviders.CARTESIA.value,
api_key="test-key",
model="sonic-3.5",
voice="test-voice-id",
speed=1.0,
volume=1.0,
language="tr",
)
)
audio_config = SimpleNamespace(
transport_out_sample_rate=24000,
transport_in_sample_rate=16000,
)
with patch(
"api.services.pipecat.service_factory.CartesiaTTSService"
) as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
assert kwargs["settings"].language == "tr"

View file

@ -0,0 +1,70 @@
from types import SimpleNamespace
from unittest.mock import patch
from pipecat.services.settings import NOT_GIVEN
from pipecat.transcriptions.language import Language
from api.services.configuration.registry import (
DeepgramSTTConfiguration,
ServiceProviders,
)
from api.services.pipecat.audio_config import AudioConfig
from api.services.pipecat.service_factory import create_stt_service
def test_deepgram_stt_schema_includes_flux_multilingual_language_options():
language_schema = DeepgramSTTConfiguration.model_json_schema()["properties"][
"language"
]
assert "flux-general-multi" in language_schema["model_options"]
assert "multi" in language_schema["model_options"]["flux-general-multi"]
assert "es" in language_schema["model_options"]["flux-general-multi"]
def test_create_deepgram_flux_multi_uses_flux_service_with_language_hint():
user_config = SimpleNamespace(
stt=SimpleNamespace(
provider=ServiceProviders.DEEPGRAM.value,
api_key="test-key",
model="flux-general-multi",
language="es",
)
)
audio_config = AudioConfig(
transport_in_sample_rate=16000,
transport_out_sample_rate=16000,
)
with patch(
"api.services.pipecat.service_factory.DeepgramFluxSTTService"
) as mock_service:
create_stt_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
assert kwargs["settings"].model == "flux-general-multi"
assert kwargs["settings"].language_hints == [Language.ES]
def test_create_deepgram_flux_multi_omits_auto_detect_language_hint():
user_config = SimpleNamespace(
stt=SimpleNamespace(
provider=ServiceProviders.DEEPGRAM.value,
api_key="test-key",
model="flux-general-multi",
language="multi",
)
)
audio_config = AudioConfig(
transport_in_sample_rate=16000,
transport_out_sample_rate=16000,
)
with patch(
"api.services.pipecat.service_factory.DeepgramFluxSTTService"
) as mock_service:
create_stt_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
assert kwargs["settings"].model == "flux-general-multi"
assert kwargs["settings"].language_hints is NOT_GIVEN

View file

@ -22,6 +22,20 @@ class _FakeWebSocket:
self.state = State.CLOSED
class _IterableFakeWebSocket(_FakeWebSocket):
def __init__(self, incoming_messages: list[dict]):
super().__init__()
self.incoming_messages = [json.dumps(message) for message in incoming_messages]
def __aiter__(self):
return self
async def __anext__(self) -> str:
if not self.incoming_messages:
raise StopAsyncIteration
return self.incoming_messages.pop(0)
def test_dograh_llm_uses_explicit_mps_correlation_id():
service = DograhLLMService(
api_key="mps-secret",
@ -108,3 +122,25 @@ async def test_dograh_tts_messages_use_explicit_mps_correlation_id(monkeypatch):
assert fake_ws.messages[1]["type"] == "create_context"
assert fake_ws.messages[1]["correlation_id"] == "mps-corr-123"
assert fake_ws.messages[1]["mps_billing_version"] == "2"
@pytest.mark.asyncio
async def test_dograh_tts_final_for_missing_context_is_ignored():
service = DograhTTSService(api_key="mps-secret")
service._websocket = _IterableFakeWebSocket(
[{"type": "final", "context_id": "ctx-already-removed"}]
)
service._remote_initialized_context_ids.add("ctx-already-removed")
remove_calls = []
async def fake_remove_audio_context(context_id: str):
remove_calls.append(context_id)
service.audio_context_available = lambda context_id: False
service.remove_audio_context = fake_remove_audio_context
await service._receive_messages()
assert remove_calls == []
assert "ctx-already-removed" not in service._remote_initialized_context_ids

View file

@ -0,0 +1,131 @@
from types import SimpleNamespace
from unittest.mock import patch
from api.services.configuration.check_validity import UserConfigurationValidator
from api.services.configuration.registry import (
REGISTRY,
HuggingFaceLLMConfiguration,
HuggingFaceSTTConfiguration,
ServiceProviders,
ServiceType,
)
from api.services.pipecat.service_factory import (
create_llm_service,
create_stt_service,
)
def test_huggingface_stt_configuration_defaults_and_registry():
config = HuggingFaceSTTConfiguration(api_key="hf_test")
assert config.provider == ServiceProviders.HUGGINGFACE
assert config.model == "openai/whisper-large-v3-turbo"
assert config.base_url == "https://router.huggingface.co/hf-inference"
assert config.return_timestamps is False
assert (
REGISTRY[ServiceType.STT][ServiceProviders.HUGGINGFACE]
is HuggingFaceSTTConfiguration
)
def test_huggingface_llm_configuration_defaults_and_registry():
config = HuggingFaceLLMConfiguration(api_key="hf_test")
assert config.provider == ServiceProviders.HUGGINGFACE
assert config.model == "openai/gpt-oss-120b:cerebras"
assert config.base_url == "https://router.huggingface.co/v1"
assert (
REGISTRY[ServiceType.LLM][ServiceProviders.HUGGINGFACE]
is HuggingFaceLLMConfiguration
)
def test_create_huggingface_llm_service_uses_openai_compatible_router():
user_config = SimpleNamespace(
llm=SimpleNamespace(
provider=ServiceProviders.HUGGINGFACE.value,
api_key="hf_test",
model="deepseek-ai/DeepSeek-R1:fastest",
base_url="https://router.huggingface.co/v1",
bill_to="demo-org",
)
)
with patch(
"api.services.pipecat.service_factory.HuggingFaceLLMService"
) as mock_service:
create_llm_service(user_config)
assert mock_service.call_count == 1
kwargs = mock_service.call_args.kwargs
assert kwargs["api_key"] == "hf_test"
assert kwargs["base_url"] == "https://router.huggingface.co/v1"
assert kwargs["bill_to"] == "demo-org"
assert kwargs["settings"].model == "deepseek-ai/DeepSeek-R1:fastest"
assert kwargs["settings"].temperature == 0.1
def test_create_huggingface_stt_service_uses_hosted_defaults():
user_config = SimpleNamespace(
stt=SimpleNamespace(
provider=ServiceProviders.HUGGINGFACE.value,
api_key="hf_test",
model="openai/whisper-large-v3-turbo",
base_url="https://router.huggingface.co/hf-inference",
bill_to="demo-org",
return_timestamps=True,
)
)
audio_config = SimpleNamespace(transport_in_sample_rate=16000)
with patch(
"api.services.pipecat.service_factory.HuggingFaceSTTService"
) as mock_service:
create_stt_service(user_config, audio_config)
assert mock_service.call_count == 1
kwargs = mock_service.call_args.kwargs
assert kwargs["api_key"] == "hf_test"
assert kwargs["base_url"] == "https://router.huggingface.co/hf-inference"
assert kwargs["bill_to"] == "demo-org"
assert kwargs["sample_rate"] == 16000
assert kwargs["settings"].model == "openai/whisper-large-v3-turbo"
assert kwargs["settings"].return_timestamps is True
def test_validator_accepts_huggingface_stt_token_format():
validator = UserConfigurationValidator()
assert (
validator._validate_service(
HuggingFaceSTTConfiguration(api_key="hf_test"),
"stt",
)
== []
)
assert (
validator._validate_service(
HuggingFaceLLMConfiguration(api_key="hf_test"),
"llm",
)
== []
)
def test_validator_rejects_non_huggingface_token_format():
validator = UserConfigurationValidator()
errors = validator._validate_service(
HuggingFaceSTTConfiguration(api_key="not-hf-token"),
"stt",
)
assert errors == [
{
"model": "stt",
"message": (
"Invalid Hugging Face API token format. Use a token that starts with "
"'hf_' and has Inference Providers permission."
),
}
]

View file

@ -0,0 +1,54 @@
from types import SimpleNamespace
from unittest.mock import patch
from api.services.configuration.registry import (
InworldTTSConfiguration,
ServiceProviders,
)
from api.services.pipecat.service_factory import create_tts_service
def test_inworld_tts_configuration_defaults():
config = InworldTTSConfiguration(api_key="test-key")
assert config.provider == ServiceProviders.INWORLD
assert config.model == "inworld-tts-2"
assert config.voice == "Ashley"
assert config.language == "en-US"
assert config.delivery_mode == "BALANCED"
def test_create_inworld_tts_service_uses_websocket_service_without_http_session():
user_config = SimpleNamespace(
tts=SimpleNamespace(
provider=ServiceProviders.INWORLD.value,
api_key="test-key",
model="inworld-tts-2",
voice="Ashley",
speed=1.1,
language="en-US",
delivery_mode="CREATIVE",
)
)
audio_config = SimpleNamespace(
transport_out_sample_rate=24000,
transport_in_sample_rate=16000,
)
with (
patch("api.services.pipecat.service_factory.aiohttp.ClientSession") as session,
patch("api.services.pipecat.service_factory.InworldTTSService") as mock_service,
):
create_tts_service(user_config, audio_config)
session.assert_not_called()
assert mock_service.call_count == 1
kwargs = mock_service.call_args.kwargs
assert kwargs["api_key"] == "test-key"
assert "aiohttp_session" not in kwargs
assert "streaming" not in kwargs
assert kwargs["settings"].model == "inworld-tts-2"
assert kwargs["settings"].voice == "Ashley"
assert kwargs["settings"].language == "en-US"
assert kwargs["settings"].speaking_rate == 1.1
assert kwargs["settings"].delivery_mode == "CREATIVE"

View file

@ -0,0 +1,66 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from api.mcp_server.tools.create_workflow import create_workflow
@pytest.mark.asyncio
async def test_create_workflow_rejects_duplicate_api_triggers():
user = MagicMock()
user.id = 1
user.selected_organization_id = 1
payload = {
"nodes": [
{
"id": "start-1",
"type": "startCall",
"position": {"x": 0, "y": 0},
"data": {"name": "Start", "prompt": "Greet."},
},
{
"id": "trigger-1",
"type": "trigger",
"position": {"x": 0, "y": 200},
"data": {"name": "Trigger A", "trigger_path": "support_west"},
},
{
"id": "trigger-2",
"type": "trigger",
"position": {"x": 0, "y": 400},
"data": {"name": "Trigger B", "trigger_path": "support_east"},
},
],
"edges": [],
}
with (
patch(
"api.mcp_server.tools.create_workflow.authenticate_mcp_request",
AsyncMock(return_value=user),
),
patch(
"api.mcp_server.tools.create_workflow.parse_code",
AsyncMock(
return_value={
"ok": True,
"workflowName": "duplicate-trigger-test",
"workflow": payload,
}
),
),
patch(
"api.mcp_server.tools.create_workflow.reconcile_positions",
return_value=payload,
),
patch(
"api.mcp_server.tools.create_workflow.db_client.create_workflow",
AsyncMock(),
) as create_mock,
):
result = await create_workflow(code="ignored")
assert result["created"] is False
assert result["error_code"] == "graph_validation"
assert "at most one API Trigger" in result["error"]
create_mock.assert_not_awaited()

View file

@ -244,6 +244,58 @@ const only = wf.addTyped(endCall({ name: "only", prompt: "bye" }));
update_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_graph_validation_catches_duplicate_api_triggers(mock_backends):
save_mock, update_mock = mock_backends
payload = {
"nodes": [
{
"id": "start-1",
"type": "startCall",
"position": {"x": 0, "y": 0},
"data": {"name": "Start", "prompt": "Greet."},
},
{
"id": "trigger-1",
"type": "trigger",
"position": {"x": 0, "y": 200},
"data": {"name": "Trigger A", "trigger_path": "support_west"},
},
{
"id": "trigger-2",
"type": "trigger",
"position": {"x": 0, "y": 400},
"data": {"name": "Trigger B", "trigger_path": "support_east"},
},
],
"edges": [],
}
with (
patch(
"api.mcp_server.tools.save_workflow.parse_code",
AsyncMock(
return_value={
"ok": True,
"workflowName": _FakeWorkflowModel.name,
"workflow": payload,
}
),
),
patch(
"api.mcp_server.tools.save_workflow.reconcile_positions",
return_value=payload,
),
):
result = await save_workflow(workflow_id=1, code="ignored")
assert result["saved"] is False
assert result["error_code"] == "graph_validation"
assert "at most one API Trigger" in result["error"]
save_mock.assert_not_awaited()
update_mock.assert_not_awaited()
# ─── Workflow not found / unauthorized ───────────────────────────────────

View file

@ -414,4 +414,9 @@ def test_to_mcp_dict_retains_authoring_signal_startcall():
]
# graph_constraints drops its null sub-fields.
assert projected["graph_constraints"] == {"min_incoming": 0, "max_incoming": 0}
assert projected["graph_constraints"] == {
"min_incoming": 0,
"max_incoming": 0,
"min_instances": 1,
"max_instances": 1,
}

View file

@ -0,0 +1,131 @@
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.routes.user import router
from api.schemas.onboarding_state import OnboardingState, OnboardingStateUpdate
from api.services.auth.depends import get_user
def _make_test_app():
app = FastAPI()
app.include_router(router)
mock_user = MagicMock()
mock_user.id = 1
mock_user.is_superuser = False
mock_user.selected_organization_id = None
app.dependency_overrides[get_user] = lambda: mock_user
return app
class TestOnboardingStateUpdateMerge:
def test_lists_union_without_duplicates(self):
state = OnboardingState(
seen_tooltips=["web_call"], completed_actions=["web_call_started"]
)
update = OnboardingStateUpdate(
seen_tooltips=["web_call", "customize_workflow"],
completed_actions=["welcome_form_completed"],
)
merged = update.apply_to(state)
assert merged.seen_tooltips == ["web_call", "customize_workflow"]
assert merged.completed_actions == [
"web_call_started",
"welcome_form_completed",
]
def test_omitted_fields_preserve_existing_state(self):
completed_at = datetime(2026, 6, 12, tzinfo=UTC)
state = OnboardingState(
completed_at=completed_at, skipped=True, seen_tooltips=["web_call"]
)
merged = OnboardingStateUpdate().apply_to(state)
assert merged.completed_at == completed_at
assert merged.skipped is True
assert merged.seen_tooltips == ["web_call"]
def test_scalars_overwrite_when_supplied(self):
state = OnboardingState()
completed_at = datetime(2026, 6, 12, tzinfo=UTC)
merged = OnboardingStateUpdate(
completed_at=completed_at, skipped=True
).apply_to(state)
assert merged.completed_at == completed_at
assert merged.skipped is True
class TestOnboardingStateRoutes:
def test_get_returns_defaults_when_no_row(self):
app = _make_test_app()
client = TestClient(app)
with patch(
"api.services.user_onboarding.db_client.get_user_configuration_value",
new=AsyncMock(return_value=None),
):
response = client.get("/user/onboarding-state")
assert response.status_code == 200
body = response.json()
assert body["completed_at"] is None
assert body["skipped"] is False
assert body["seen_tooltips"] == []
assert body["completed_actions"] == []
def test_get_returns_defaults_on_invalid_stored_value(self):
app = _make_test_app()
client = TestClient(app)
with patch(
"api.services.user_onboarding.db_client.get_user_configuration_value",
new=AsyncMock(return_value={"skipped": "not-a-bool"}),
):
response = client.get("/user/onboarding-state")
assert response.status_code == 200
assert response.json()["skipped"] is False
def test_put_merges_into_stored_state_and_persists(self):
app = _make_test_app()
client = TestClient(app)
existing = {"seen_tooltips": ["web_call"]}
upsert = AsyncMock(side_effect=lambda user_id, key, value: value)
with (
patch(
"api.services.user_onboarding.db_client.get_user_configuration_value",
new=AsyncMock(return_value=existing),
),
patch(
"api.services.user_onboarding.db_client.upsert_user_configuration_value",
new=upsert,
),
):
response = client.put(
"/user/onboarding-state",
json={
"completed_at": "2026-06-12T00:00:00Z",
"seen_tooltips": ["customize_workflow"],
},
)
assert response.status_code == 200
body = response.json()
assert body["seen_tooltips"] == ["web_call", "customize_workflow"]
assert body["completed_at"] is not None
upsert.assert_awaited_once()
user_id, key, stored = upsert.await_args.args
assert user_id == 1
assert key == "ONBOARDING"
assert stored["seen_tooltips"] == ["web_call", "customize_workflow"]

View file

@ -0,0 +1,31 @@
from types import SimpleNamespace
from unittest.mock import patch
from pipecat.services.openai._constants import OPENAI_SAMPLE_RATE
from api.services.configuration.registry import ServiceProviders
from api.services.pipecat.service_factory import create_tts_service
def test_create_openai_tts_service_uses_openai_pcm_sample_rate():
user_config = SimpleNamespace(
tts=SimpleNamespace(
provider=ServiceProviders.OPENAI.value,
api_key="test-key",
model="gpt-4o-mini-tts",
voice="alloy",
base_url=None,
)
)
audio_config = SimpleNamespace(
transport_out_sample_rate=16000,
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.OpenAITTSService") as mock_service:
create_tts_service(user_config, audio_config)
assert mock_service.call_count == 1
kwargs = mock_service.call_args.kwargs
assert kwargs["sample_rate"] == OPENAI_SAMPLE_RATE
assert kwargs["settings"].model == "gpt-4o-mini-tts"

View file

@ -0,0 +1,34 @@
from api.services import posthog_client
class FakePostHog:
def __init__(self):
self.group_identify_calls = []
def group_identify(self, *args, **kwargs):
self.group_identify_calls.append((args, kwargs))
def test_group_identify_uses_stable_server_distinct_id(monkeypatch):
fake_posthog = FakePostHog()
monkeypatch.setattr(posthog_client, "get_posthog", lambda: fake_posthog)
posthog_client.group_identify("organization", "42", {})
_, kwargs = fake_posthog.group_identify_calls[0]
assert kwargs["distinct_id"] == "server-group-identify"
def test_group_identify_preserves_real_distinct_id(monkeypatch):
fake_posthog = FakePostHog()
monkeypatch.setattr(posthog_client, "get_posthog", lambda: fake_posthog)
posthog_client.group_identify(
"organization",
"42",
{},
distinct_id="stack-user-1",
)
_, kwargs = fake_posthog.group_identify_calls[0]
assert kwargs["distinct_id"] == "stack-user-1"

View file

@ -0,0 +1,72 @@
"""Regression test for QA analysis when the LLM returns a non-dict JSON value.
``parse_llm_json`` is explicitly designed to return a list when the model emits
a top-level JSON array (see ``test_json_parser.py``). The QA analyzers then call
``parsed.get(...)`` on the result. For a list that raises ``AttributeError``,
which is NOT caught by the surrounding ``except (json.JSONDecodeError, ValueError)``
so a stray array response crashed the whole QA run instead of degrading to
empty results.
"""
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
import pytest
from api.services.workflow.qa import analysis as qa_analysis
@pytest.mark.asyncio
async def test_whole_call_qa_tolerates_array_llm_response():
"""A top-level JSON array from the QA LLM degrades to empty results."""
qa_data = SimpleNamespace(qa_system_prompt="Summarize: {transcript}")
workflow_run = SimpleNamespace(
logs={
"realtime_feedback_events": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi there"},
]
},
usage_info={"call_duration_seconds": 12},
)
warning_mock = Mock()
with (
patch.object(
qa_analysis, "build_conversation_structure", return_value=[{"x": 1}]
),
patch.object(qa_analysis, "format_transcript", return_value="user: hello"),
patch.object(qa_analysis, "compute_call_metrics", return_value={}),
patch.object(
qa_analysis,
"resolve_llm_config",
new=AsyncMock(return_value=("openai", "gpt-4o", "sk-test", {})),
),
patch.object(
qa_analysis, "create_llm_service_from_provider", return_value=object()
),
patch.object(
qa_analysis,
"_run_llm_inference",
new=AsyncMock(return_value='["tag1", "tag2"]'),
),
patch.object(qa_analysis, "setup_langfuse_parent_context", return_value=None),
patch.object(qa_analysis, "add_qa_span_to_trace", return_value=None),
patch.object(qa_analysis.logger, "warning", warning_mock),
):
# Before the fix this raised AttributeError: 'list' object has no
# attribute 'get'.
result = await qa_analysis._run_whole_call_qa_analysis(
qa_data, workflow_run, workflow_run_id=99
)
node_result = result["node_results"]["whole_call"]
assert node_result["tags"] == []
assert node_result["summary"] == ""
assert node_result["score"] is None
warning_mock.assert_called_once()
warning_message = warning_mock.call_args.args[0]
assert "non-object JSON" in warning_message
assert "run 99" in warning_message
assert "list" in warning_message
assert "tag1" not in warning_message

View file

@ -1,6 +1,7 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import httpx
import pytest
from api.services import quota_service
@ -284,6 +285,69 @@ async def test_authorize_workflow_run_managed_v2_stores_hosted_correlation(
)
@pytest.mark.asyncio
async def test_authorize_workflow_run_service_token_from_wrong_org_prompts_new_token(
monkeypatch,
):
api_key = "mps_sk_12345678"
get_config = AsyncMock(
return_value=_dograh_config(api_key, managed_service_version=2)
)
request = httpx.Request(
"POST",
"http://localhost:8004/api/v1/billing/accounts/42/run-authorization",
)
response = httpx.Response(
403,
json={"detail": "Service key organization mismatch"},
request=request,
)
authorize = AsyncMock(
side_effect=httpx.HTTPStatusError(
"Failed to authorize MPS workflow run start",
request=request,
response=response,
)
)
monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas")
_patch_workflow_context(monkeypatch)
monkeypatch.setattr(
quota_service,
"get_effective_ai_model_configuration_for_workflow",
get_config,
)
monkeypatch.setattr(
quota_service.mps_service_key_client,
"authorize_workflow_run_start",
authorize,
)
monkeypatch.setattr(
quota_service.mps_service_key_client,
"check_service_key_usage",
AsyncMock(),
)
result = await quota_service.authorize_workflow_run_start(
workflow_id=7,
workflow_run_id=88,
)
assert result.has_quota is False
assert result.error_code == "service_key_org_mismatch"
assert result.error_message == quota_service.SERVICE_TOKEN_ORG_MISMATCH_MESSAGE
assert "new service token from the Developers tab" in result.error_message
authorize.assert_awaited_once_with(
organization_id=42,
workflow_run_id=88,
service_key=api_key,
require_correlation_id=True,
minimum_credits=quota_service.MINIMUM_DOGRAH_CREDITS_FOR_CALL,
created_by="provider-123",
metadata={"dograh_user_id": "123", "workflow_id": 7},
)
@pytest.mark.asyncio
async def test_authorize_workflow_run_oss_uses_key_paths_not_workflow_org(
monkeypatch,

View file

@ -0,0 +1,30 @@
from api.routes.s3_signed_url import (
_extract_legacy_workflow_run_id,
_extract_org_id_from_key,
)
def test_split_recording_keys_are_workflow_run_artifacts_not_org_keys():
assert _extract_legacy_workflow_run_id("recordings/1855/user.wav") == 1855
assert _extract_legacy_workflow_run_id("recordings/1855/bot.wav") == 1855
assert _extract_org_id_from_key("recordings/1855/user.wav") is None
assert _extract_org_id_from_key("recordings/1855/bot.wav") is None
def test_legacy_recording_keys_do_not_fall_through_to_org_scoped_auth():
assert _extract_legacy_workflow_run_id("recordings/1855.wav") == 1855
assert _extract_legacy_workflow_run_id("recordings/1855/other.wav") is None
assert _extract_org_id_from_key("recordings/1855.wav") is None
assert _extract_org_id_from_key("recordings/1855/other.wav") is None
def test_known_org_scoped_keys_extract_org_id():
assert _extract_org_id_from_key("campaigns/42/source.csv") == 42
assert _extract_org_id_from_key("knowledge_base/42/document/file.pdf") == 42
assert _extract_legacy_workflow_run_id("campaigns/42/source.csv") is None
def test_unknown_numeric_prefix_is_not_treated_as_org_scoped():
assert _extract_org_id_from_key("unknown/42/file.wav") is None

View file

@ -7,6 +7,7 @@ from pipecat.transcriptions.language import Language
from api.services.configuration.registry import (
SarvamLLMConfiguration,
SarvamTTSConfiguration,
ServiceProviders,
)
from api.services.pipecat.audio_config import AudioConfig
@ -14,6 +15,7 @@ from api.services.pipecat.service_factory import (
create_llm_service,
create_llm_service_from_provider,
create_stt_service,
create_tts_service,
)
@ -112,3 +114,94 @@ class TestSarvamSTTServiceFactory:
kwargs = mock_service.call_args.kwargs
assert kwargs["settings"].language == expected_language
class TestSarvamTTSServiceFactory:
def test_sarvam_tts_configuration_defaults(self):
config = SarvamTTSConfiguration(api_key="test-key")
assert config.provider == ServiceProviders.SARVAM
assert config.model == "bulbul:v2"
assert config.voice == "anushka"
assert config.language == "hi-IN"
assert config.speed == 1.0
def test_sarvam_tts_voice_schema_allows_custom_model_specific_options(self):
voice_schema = SarvamTTSConfiguration.model_json_schema()["properties"]["voice"]
assert voice_schema["allow_custom_input"] is True
assert "bulbul:v2" in voice_schema["model_options"]
assert "bulbul:v3" in voice_schema["model_options"]
def test_create_sarvam_tts_service_maps_speed_to_pace(self):
user_config = SimpleNamespace(
tts=SimpleNamespace(
provider=ServiceProviders.SARVAM.value,
api_key="test-key",
model="bulbul:v2",
voice="anushka",
language="hi-IN",
speed=1.25,
)
)
audio_config = AudioConfig(
transport_in_sample_rate=16000, transport_out_sample_rate=16000
)
with patch(
"api.services.pipecat.service_factory.SarvamTTSService"
) as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
assert kwargs["api_key"] == "test-key"
assert kwargs["settings"].model == "bulbul:v2"
assert kwargs["settings"].voice == "anushka"
assert kwargs["settings"].language == Language.HI
assert kwargs["settings"].pace == 1.25
def test_create_sarvam_tts_service_normalizes_custom_voice_id(self):
user_config = SimpleNamespace(
tts=SimpleNamespace(
provider=ServiceProviders.SARVAM.value,
api_key="test-key",
model="bulbul:v2",
voice=" Rehan ",
language="hi-IN",
speed=1.0,
)
)
audio_config = AudioConfig(
transport_in_sample_rate=16000, transport_out_sample_rate=16000
)
with patch(
"api.services.pipecat.service_factory.SarvamTTSService"
) as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
assert kwargs["settings"].voice == "rehan"
def test_create_sarvam_tts_service_defaults_blank_voice_id(self):
user_config = SimpleNamespace(
tts=SimpleNamespace(
provider=ServiceProviders.SARVAM.value,
api_key="test-key",
model="bulbul:v2",
voice=" ",
language="hi-IN",
speed=1.0,
)
)
audio_config = AudioConfig(
transport_in_sample_rate=16000, transport_out_sample_rate=16000
)
with patch(
"api.services.pipecat.service_factory.SarvamTTSService"
) as mock_service:
create_tts_service(user_config, audio_config)
kwargs = mock_service.call_args.kwargs
assert kwargs["settings"].voice == "anushka"

View file

@ -0,0 +1,80 @@
from types import SimpleNamespace
from unittest.mock import patch
from api.services.configuration.check_validity import UserConfigurationValidator
from api.services.configuration.registry import (
REGISTRY,
ServiceProviders,
ServiceType,
SmallestAISTTConfiguration,
SmallestAITTSConfiguration,
)
from api.services.pipecat.service_factory import create_tts_service
def test_smallest_tts_configuration_defaults_and_registry():
config = SmallestAITTSConfiguration(api_key="test-key")
assert config.provider == ServiceProviders.SMALLEST
assert config.model == "lightning_v3.1"
assert config.voice == "sophia"
assert config.language == "en"
assert config.speed == 1.0
assert (
REGISTRY[ServiceType.TTS][ServiceProviders.SMALLEST]
is SmallestAITTSConfiguration
)
def test_smallest_stt_configuration_defaults_and_registry():
config = SmallestAISTTConfiguration(api_key="test-key")
assert config.provider == ServiceProviders.SMALLEST
assert config.model == "pulse"
assert config.language == "en"
assert (
REGISTRY[ServiceType.STT][ServiceProviders.SMALLEST]
is SmallestAISTTConfiguration
)
def test_validator_accepts_smallest_services():
validator = UserConfigurationValidator()
assert (
validator._validate_service(
SmallestAITTSConfiguration(api_key="test-key"),
"tts",
)
== []
)
assert (
validator._validate_service(
SmallestAISTTConfiguration(api_key="test-key"),
"stt",
)
== []
)
def test_create_smallest_tts_service_normalizes_hyphenated_model_values():
user_config = SimpleNamespace(
tts=SimpleNamespace(
provider=ServiceProviders.SMALLEST.value,
api_key="test-key",
model="lightning-v3.1",
voice="sophia",
language="en",
speed=1.0,
)
)
audio_config = SimpleNamespace(transport_in_sample_rate=16000)
with patch(
"api.services.pipecat.service_factory.SmallestTTSService"
) as mock_service:
create_tts_service(user_config, audio_config)
assert mock_service.call_count == 1
kwargs = mock_service.call_args.kwargs
assert kwargs["settings"].model == "lightning_v3.1"

View file

@ -9,6 +9,7 @@ from api.services.workflow.text_chat_session_service import (
TextChatTurnNotFoundError,
_reload_text_chat_session,
build_pending_text_chat_turn,
execute_pending_text_chat_turn,
truncate_text_chat_future_turns,
validate_text_chat_turn_cursor,
)
@ -77,6 +78,36 @@ async def test_reload_text_chat_session_uses_run_id_to_resolve_organization(
get_text_session.assert_awaited_once_with(123, organization_id=77)
@pytest.mark.asyncio
async def test_execute_pending_turn_surfaces_original_exception_message(monkeypatch):
session = WorkflowRunTextSessionModel(workflow_run_id=42)
session.session_data = {
"turns": [{"id": "turn-1", "status": "pending"}],
"cursor_turn_id": "turn-1",
}
session.checkpoint = None
monkeypatch.setattr(
text_chat_session_service,
"execute_text_chat_pending_turn",
AsyncMock(side_effect=RuntimeError("Workflow has 2 start nodes")),
)
monkeypatch.setattr(
text_chat_session_service,
"_mark_pending_turn_failed",
AsyncMock(),
)
with pytest.raises(
TextChatSessionExecutionError, match="Workflow has 2 start nodes"
):
await execute_pending_text_chat_turn(
workflow_id=1,
run_id=42,
text_session=session,
)
@pytest.mark.asyncio
async def test_reload_text_chat_session_raises_when_run_organization_is_missing(
monkeypatch,

View file

@ -54,3 +54,37 @@ def test_validate_trigger_paths_rejects_long_and_duplicate_paths():
in messages
)
assert "Trigger path is duplicated in this workflow." in messages
def test_validate_trigger_paths_detects_duplicate_when_first_node_has_no_id():
"""A duplicate trigger path must be flagged even when the first node sharing
that path has no ``id`` (``node.get("id")`` is None).
Regression: the duplicate check previously used ``seen_paths.get(path)`` and
treated a ``None`` result as "not seen yet", so a first node with a missing
id (stored as None) made every later node with the same path slip through
undetected.
"""
workflow_definition = {
"nodes": [
# No "id" key -> node_id resolves to None.
{"type": "trigger", "data": {"trigger_path": "sales_agent"}},
{
"id": "trigger-2",
"type": "trigger",
"data": {"trigger_path": "sales_agent"},
},
],
"edges": [],
}
issues = validate_trigger_paths(workflow_definition)
messages = [issue.message for issue in issues]
assert "Trigger path is duplicated in this workflow." in messages
duplicate_issue = next(
issue
for issue in issues
if issue.message == "Trigger path is duplicated in this workflow."
)
assert duplicate_issue.node_id == "trigger-2"

View file

@ -0,0 +1,98 @@
import pytest
from sqlalchemy import select
from sqlalchemy.dialects import postgresql
from api.db.models import UserConfigurationModel
from api.db.user_client import UserClient
from api.enums import UserConfigurationKey
class _FakeResult:
def __init__(self, value: dict):
self._value = value
def scalar_one(self) -> dict:
return self._value
class _FakeSession:
def __init__(self, result_value: dict):
self.result_value = result_value
self.statements = []
self.committed = False
self.rolled_back = False
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
return False
async def execute(self, stmt):
self.statements.append(stmt)
return _FakeResult(self.result_value)
async def commit(self):
self.committed = True
async def rollback(self):
self.rolled_back = True
@pytest.mark.asyncio
async def test_upsert_user_configuration_value_uses_atomic_conflict_update():
result_value = {"completed_actions": ["web_call_started"]}
fake_session = _FakeSession(result_value)
client = UserClient.__new__(UserClient)
client.async_session = lambda: fake_session
value = await client.upsert_user_configuration_value(
86,
UserConfigurationKey.ONBOARDING.value,
result_value,
)
assert value == result_value
assert fake_session.committed is True
assert fake_session.rolled_back is False
assert len(fake_session.statements) == 1
compiled = str(fake_session.statements[0].compile(dialect=postgresql.dialect()))
assert "ON CONFLICT ON CONSTRAINT _user_configuration_key_uc DO UPDATE" in compiled
assert "configuration = excluded.configuration" in compiled
assert "last_validated_at" not in compiled
@pytest.mark.asyncio
async def test_upsert_user_configuration_value_updates_existing_row(
db_session,
async_session,
):
user, _ = await db_session.get_or_create_user_by_provider_id(
"user-config-upsert-test"
)
first = await db_session.upsert_user_configuration_value(
user.id,
UserConfigurationKey.ONBOARDING.value,
{"skipped": False},
)
second = await db_session.upsert_user_configuration_value(
user.id,
UserConfigurationKey.ONBOARDING.value,
{"skipped": True},
)
assert first == {"skipped": False}
assert second == {"skipped": True}
result = await async_session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user.id,
UserConfigurationModel.key == UserConfigurationKey.ONBOARDING.value,
)
)
rows = result.scalars().all()
assert len(rows) == 1
assert rows[0].configuration == {"skipped": True}

View file

@ -47,3 +47,38 @@ def test_create_workflow_rejects_invalid_trigger_path_before_db_write():
assert detail["errors"][0]["field"] == "data.trigger_path"
assert "single URL path segment" in detail["errors"][0]["message"]
assert mock_db.mock_calls == []
def test_create_workflow_rejects_duplicate_api_triggers_before_db_write():
app = _make_test_app()
client = TestClient(app)
with patch("api.routes.workflow.db_client") as mock_db:
response = client.post(
"/workflow/create/definition",
json={
"name": "Support Agent",
"workflow_definition": {
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {"trigger_path": "support_west"},
},
{
"id": "trigger-2",
"type": "trigger",
"data": {"trigger_path": "support_east"},
},
],
"edges": [],
},
},
)
assert response.status_code == 422
detail = response.json()["detail"]
assert detail["is_valid"] is False
assert detail["errors"][0]["kind"] == "workflow"
assert "at most one API Trigger" in detail["errors"][0]["message"]
assert mock_db.mock_calls == []

View file

@ -72,14 +72,24 @@ _SCENARIOS = [
(
"no_start_node",
["no_start_node"],
["Workflow has no start node"],
["Workflow must have at least one Start Call"],
),
# Two startCall nodes — surfaced separately from no_start_node so
# the editor can show a count-specific message.
(
"multiple_start_nodes",
["multiple_start_nodes:2"],
["Workflow has 2 start nodes"],
["Workflow can have at most one Start Call"],
),
(
"multiple_trigger_nodes",
["max_instances_1:trigger:2"],
["Workflow can have at most one API Trigger"],
),
(
"multiple_global_nodes",
["max_instances_1:globalNode:2"],
["Workflow can have at most one Global Node"],
),
]
@ -122,3 +132,35 @@ def test_workflow_graph_rejects_violations(name, expected_graph_messages):
assert any(expected in m for m in actual_messages), (
f"Expected substring {expected!r} not found in graph errors: {actual_messages}"
)
def test_workflow_graph_can_skip_duplicate_api_trigger_check_for_runtime():
raw, _ = _load("multiple_trigger_nodes")
dto = ReactFlowDTO.model_validate_json(raw)
WorkflowGraph(dto, skip_instance_constraints_for={"trigger"})
def test_workflow_graph_start_semantics_come_from_node_type_not_legacy_flag():
dto = ReactFlowDTO.model_validate(
{
"nodes": [
{
"id": "start-1",
"type": "startCall",
"position": {"x": 0, "y": 0},
"data": {
"name": "Start",
"prompt": "Greet.",
"is_start": False,
},
}
],
"edges": [],
}
)
graph = WorkflowGraph(dto)
assert graph.start_node_id == "start-1"
assert graph.nodes["start-1"].is_start is True

View file

@ -2,6 +2,7 @@ from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
@ -50,3 +51,99 @@ def test_workflow_fetch_list_includes_workflow_uuid():
"workflow_uuid": workflow.workflow_uuid,
}
]
def test_workflow_fetch_invalid_status_returns_422_without_db_query():
"""A status outside the workflow_status enum (e.g. 'published') must fail
as a clean 422 instead of a 500 from the Postgres enum cast."""
app = _make_test_app()
client = TestClient(app)
with patch("api.routes.workflow.db_client") as mock_db:
mock_db.get_all_workflows_for_listing = AsyncMock()
mock_db.get_workflow_run_counts = AsyncMock()
response = client.get("/workflow/fetch?status=published")
assert response.status_code == 422
assert "published" in response.json()["detail"]
# The invalid value must never reach the database layer.
mock_db.get_all_workflows_for_listing.assert_not_called()
def test_workflow_fetch_valid_single_status_passes_through():
app = _make_test_app()
client = TestClient(app)
with patch("api.routes.workflow.db_client") as mock_db:
mock_db.get_all_workflows_for_listing = AsyncMock(return_value=[])
mock_db.get_workflow_run_counts = AsyncMock(return_value={})
response = client.get("/workflow/fetch?status=active")
assert response.status_code == 200
mock_db.get_all_workflows_for_listing.assert_awaited_once_with(
organization_id=11, status="active"
)
def test_workflow_fetch_comma_separated_status_queries_each_value():
app = _make_test_app()
client = TestClient(app)
with patch("api.routes.workflow.db_client") as mock_db:
mock_db.get_all_workflows_for_listing = AsyncMock(return_value=[])
mock_db.get_workflow_run_counts = AsyncMock(return_value={})
response = client.get("/workflow/fetch?status=active,archived")
assert response.status_code == 200
assert mock_db.get_all_workflows_for_listing.await_count == 2
statuses = {
call.kwargs["status"]
for call in mock_db.get_all_workflows_for_listing.await_args_list
}
assert statuses == {"active", "archived"}
def test_workflow_fetch_mixed_valid_and_invalid_status_returns_422():
app = _make_test_app()
client = TestClient(app)
with patch("api.routes.workflow.db_client") as mock_db:
mock_db.get_all_workflows_for_listing = AsyncMock()
mock_db.get_workflow_run_counts = AsyncMock()
response = client.get("/workflow/fetch?status=active,published")
assert response.status_code == 422
mock_db.get_all_workflows_for_listing.assert_not_called()
@pytest.mark.parametrize("status", [" ", ",", "active,,archived"])
def test_workflow_fetch_blank_status_token_returns_422_without_db_query(status: str):
app = _make_test_app()
client = TestClient(app)
with patch("api.routes.workflow.db_client") as mock_db:
mock_db.get_all_workflows_for_listing = AsyncMock()
mock_db.get_workflow_run_counts = AsyncMock()
response = client.get("/workflow/fetch", params={"status": status})
assert response.status_code == 422
assert "<empty>" in response.json()["detail"]
mock_db.get_all_workflows_for_listing.assert_not_called()
def test_workflow_summary_blank_status_token_returns_422_without_db_query():
app = _make_test_app()
client = TestClient(app)
with patch("api.routes.workflow.db_client") as mock_db:
mock_db.get_all_workflows = AsyncMock()
response = client.get("/workflow/summary", params={"status": ","})
assert response.status_code == 422
mock_db.get_all_workflows.assert_not_called()

View file

@ -5,6 +5,7 @@ import pytest
from api.services import workflow_run_billing as workflow_run_billing_mod
from api.services.workflow_run_billing import (
_is_usage_not_ready_error,
report_completed_workflow_run_platform_usage,
report_workflow_run_platform_usage,
)
@ -24,6 +25,16 @@ def _make_workflow_run():
)
def test_is_usage_not_ready_error_detects_mps_409():
exc = Exception("Failed to report platform usage")
exc.response = SimpleNamespace(
status_code=409,
text='{"detail":"usage_not_ready"}',
)
assert _is_usage_not_ready_error(exc) is True
@pytest.mark.asyncio
async def test_report_workflow_run_platform_usage_reports_hosted_completion(
monkeypatch,

View file

@ -0,0 +1,35 @@
from typing import Literal
RecordingTrack = Literal["mixed", "user", "bot"]
def get_recording_storage_key(extra: dict | None, track: RecordingTrack) -> str | None:
recordings = (extra or {}).get("recordings", {})
if not isinstance(recordings, dict):
return None
artifact = recordings.get(track)
if isinstance(artifact, str):
return artifact
if isinstance(artifact, dict):
storage_key = artifact.get("storage_key")
return storage_key if isinstance(storage_key, str) else None
return None
def get_recording_storage_backend(
extra: dict | None, track: RecordingTrack
) -> str | None:
recordings = (extra or {}).get("recordings", {})
if not isinstance(recordings, dict):
return None
artifact = recordings.get(track)
if isinstance(artifact, dict):
storage_backend = artifact.get("storage_backend")
return storage_backend if isinstance(storage_backend, str) else None
return None
def has_recording_track(extra: dict | None, track: RecordingTrack) -> bool:
return bool(get_recording_storage_key(extra, track))

View file

@ -164,6 +164,17 @@ services:
# from this value and nginx load-balances across them with least_conn.
FASTAPI_WORKERS: "${FASTAPI_WORKERS:-1}"
# Trust X-Forwarded-* headers from any peer so uvicorn honors nginx's
# `X-Forwarded-Proto: https`. nginx runs as its own container and reaches
# uvicorn from a Docker-network IP (not loopback), but uvicorn trusts only
# 127.0.0.1 by default — so without this it ignores the header, request.url
# comes back as http://…, and inbound webhook signature checks fail for
# every provider at once (telephony providers sign the public https URL, so
# the recomputed signature won't match). "*" trusts all peers; the api port
# (8000) is published, so firewall/harden it at the host — or narrow this
# to your Docker bridge subnet — if that exposure matters to you.
FORWARDED_ALLOW_IPS: "*"
# Langfuse — credentials can be set here or per-organization via the UI
# at /settings. Tracing is automatically active when credentials are
# available; uncomment to set defaults for all organizations.
@ -211,9 +222,13 @@ services:
ui:
image: ${REGISTRY:-dograhai}/dograh-ui:latest
environment:
# Bind the Next.js standalone server to all interfaces
HOSTNAME: "0.0.0.0"
# Server-side URL (SSR, internal Docker network)
BACKEND_URL: "${BACKEND_URL:-http://api:8000}"
NODE_ENV: "oss"
# Flag to enable/ disable posthog
ENABLE_TELEMETRY: "${ENABLE_TELEMETRY:-true}"
@ -229,7 +244,7 @@ services:
test:
[
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://localhost:3010 || exit 1",
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3010 || exit 1",
]
interval: 30s
timeout: 10s

File diff suppressed because one or more lines are too long

View file

@ -15,5 +15,12 @@ Please note that you must copy and keep the API key secretly, since this is the
### Service Keys
Service Keys are the keys which you generate to be used in [Model Configurations](inference-providers). In order to generate that, you can go to `/api-keys` and create a new key.
<Note>
You can use a Service Key created in Dograh Cloud (`https://app.dograh.com/api-keys`) in your self-hosted Dograh deployment. Create the Service Key from your Dograh Cloud account, then paste it into **Model Configurations** in your self-hosted instance to use Dograh-managed inference providers. You can purchase Dograh credits in Dograh Cloud; billing happens on the Cloud account that owns the Service Key.
</Note>
![Create a new Service Key](../images/service-keys.png)
<Warning>
Service Keys are scoped to the Dograh Cloud account that created them. You cannot use a Service Key from one cloud-hosted account in another cloud-hosted account; create a new Service Key from the account where you want to use it.
</Warning>

View file

@ -1,187 +1,93 @@
---
title: "Model Configurations"
description: "Voice Agents need AI Models to work, like LLM (Large Language Model), TTS (Voice) and STT (Transcriber). You can use any of your faviourite providers with Dograh Platform to run your Voice Agent."
description: "Configure the speech-to-speech, Dograh-managed, or bring-your-own-key models your Dograh agents use."
---
## How Model Configuration Works
## How model configurations work
Dograh uses a **two-level configuration system** for AI models:
Model Configurations define the default AI model setup for your organization. Agents use this configuration unless you set agent-level model overrides in the agent settings.
1. **Global configuration** — A single set of model settings (LLM, TTS, STT) that applies to **all agents** by default.
2. **Agent-level overrides** — Optional per-agent settings that override the global configuration for specific services.
To configure models, open **Models** in your Dograh dashboard:
If no overrides are set for an agent, it uses the global configuration as-is.
<Note>
Agent-level overrides are **selective** — you can override only the services you want to change. For example, you can override just the LLM provider for a specific agent while keeping the global TTS and STT settings. There is no need to reconfigure every service.
</Note>
## Global Configuration
The global configuration is the default model setup shared across all your agents. Dograh ships with its own models by default — when you sign up on https://app.dograh.com or set up the platform on your self-hosted infrastructure, you get some Dograh model credits to start with.
To configure the global models, go to **Model Configurations** in your dashboard:
- **Hosted:** `https://app.dograh.com/model-configurations`
- **Self-hosted:** `http://localhost:3010/model-configurations`
- **Local development:** `http://localhost:3000/model-configurations`
![Model Configuration](../images/service-configuration.png)
The Models page has three top-level sections:
From here you can configure each service:
| Service | What it does |
|---------|-------------|
| **LLM** | The language model that generates responses (e.g., OpenAI GPT-4.1, Anthropic Claude) |
| **TTS (Voice)** | The text-to-speech model that converts responses to spoken audio (e.g., ElevenLabs, Cartesia) |
| **STT (Transcriber)** | The speech-to-text model that transcribes user speech (e.g., Deepgram, AssemblyAI) |
| **Realtime** | A single speech-to-speech model that handles LLM, TTS, and STT in one (e.g., Gemini Live) |
Select a provider from the dropdown and configure the API key, model, and any provider-specific settings. For Dograh's own models, see [Service Keys](api-keys) for instructions on creating Service Keys.
## Agent-Level Model Overrides
You can override the global model configuration for any individual agent. This is useful when different agents have different requirements — for example, a customer support agent might use a faster, cheaper LLM while a sales agent uses a more capable one.
### Configuring overrides
1. Open the agent you want to customize.
2. Go to **Settings** in the agent detail page.
3. Select the **Model Overrides** tab.
4. You will see tabs for each service: **LLM**, **Voice** (TTS), and **Transcriber** (STT).
5. Toggle **Override** on for the service you want to change.
6. Configure the provider, model, and other settings as needed.
7. Save your changes.
### Selective overrides
Each service can be toggled independently. When an override is **off** for a service, the agent inherits the global setting for that service. When an override is **on**, the agent uses the override setting instead.
| LLM Override | TTS Override | STT Override | Result |
|---|---|---|---|
| Off | Off | Off | Agent uses global config for all services |
| On | Off | Off | Agent uses custom LLM, global TTS and STT |
| Off | On | Off | Agent uses global LLM and STT, custom TTS |
| On | On | On | Agent uses custom config for all services |
For example, if you only want to change the voice for a specific agent:
1. Leave the LLM and Transcriber overrides **off**.
2. Toggle the Voice override **on**.
3. Select a different TTS provider or voice.
4. The agent will use your custom voice while still using the global LLM and STT.
### Realtime mode override
You can also switch an individual agent to use a **Realtime** provider (such as Gemini Live) even if the global configuration uses standard LLM + TTS + STT. Toggle the **Realtime** switch in the Model Overrides tab, then configure the realtime provider, model, and voice.
| Section | When to use it |
|---------|----------------|
| **Speech to Speech** | Use a realtime speech-to-speech model for the live conversation. You still configure an LLM alongside it for variable extraction and QA. |
| **Dograh** | Use Dograh-managed LLM, voice, and transcriber models behind one Dograh Service Key. |
| **BYOK** | Bring your own provider keys and configure LLM, Voice, Transcriber, and Embedding models separately. |
<Note>
When an agent uses a Realtime provider, it replaces the separate TTS and STT services with a single speech-to-speech model. An **LLM** is still required alongside the Realtime model — it's used for out-of-band tasks like variable extraction and QA analysis, which the realtime service does not handle. Context compaction is not applicable in Realtime mode and is ignored if enabled.
Model settings are organization-scoped. If no agent-level override is set, every agent in the organization uses the saved global configuration.
</Note>
## Gemini 3.1 Live
## Speech to Speech
Gemini 3.1 Live is Google's realtime multimodal API that handles both LLM and voice in a single model. Instead of configuring separate LLM, TTS, and STT services, Gemini Live acts as an all-in-one realtime provider — it processes speech input, generates a response, and speaks it back, all over a single streaming connection.
Use **Speech to Speech** when you want a realtime model to handle the live spoken conversation directly. In this mode, the realtime model handles speech input and speech output, so you do not configure separate Voice and Transcriber services.
Dograh supports Gemini 3.1 Live as a **Realtime** provider. The default model is `gemini-3.1-flash-live-preview`.
![Speech to Speech model configuration](../images/model-configuration-speech-to-speech.png)
### Available Voices
The Speech to Speech section has nested tabs:
You can choose from the following built-in voices:
| Tab | What to configure |
|-----|-------------------|
| **Realtime Model** | The speech-to-speech provider, model, voice, and API key. |
| **LLM** | A standard LLM used for non-realtime tasks such as variable extraction and QA analysis. |
| **Embedding** | An embedding model used by features that need embeddings, such as retrieval from knowledge base content. |
| Voice | Description |
|-------|-------------|
| Puck | Default voice |
| Charon | — |
| Kore | — |
| Fenrir | — |
| Aoede | — |
<Warning>
An LLM is still required when you use Speech to Speech. The realtime model handles the live voice conversation, but Dograh uses the LLM for analysis tasks that happen outside the live audio stream.
</Warning>
### Getting a Gemini API Key
## Dograh
To use Gemini 3.1 Live with Dograh, you need a Google Gemini API key. Follow these steps:
Use **Dograh** when you want Dograh to manage the model providers for you. This path uses one Dograh Service Key for Dograh-managed models instead of separate provider keys for LLM, Voice, and Transcriber.
1. Go to [Google AI Studio](https://aistudio.google.com/).
2. Sign in with your Google account.
3. Click on **Get API Key** in the left sidebar.
4. Click **Create API Key**.
5. Select an existing Google Cloud project or create a new one.
6. Copy the generated API key and store it securely.
![Dograh model configuration](../images/model-configuration-dograh.png)
<Note>
The Gemini API key is different from a Google Cloud service account key. You specifically need a **Gemini API key** from Google AI Studio for use with Dograh.
</Note>
Configure:
### Configuring Gemini 3.1 Live in Dograh
| Field | What it controls |
|-------|------------------|
| **Voice** | The Dograh-managed voice to use. |
| **Speed** | The voice playback speed. |
| **Language** | The language behavior, including multilingual auto-detect when available. |
| **API Key** | Your Dograh Service Key. Create Service Keys from **Developers**. |
1. Go to **Model Configurations** in your Dograh dashboard (`https://app.dograh.com/model-configurations` for hosted or `http://localhost:3010/model-configurations` for local).
2. Under the **Realtime** section, select `google_realtime` as the provider.
3. Paste your Gemini API key.
4. Select the model (`gemini-3.1-flash-live-preview` is available by default, or you can enter a model name manually).
5. Choose a voice from the dropdown (default is `Puck`).
6. Select the language (currently `en` is supported).
For details on creating and using Service Keys, see [API Keys and Service Keys](api-keys#service-keys).
<Note>
When using a Realtime provider like Gemini Live, you do not need to configure separate TTS and STT services — the realtime model handles speech in and out. However, you **must** still configure an **LLM** under the LLM tab: it powers variable extraction and QA analysis, which the realtime service does not perform.
</Note>
## BYOK
## Gemini Live on Vertex AI
Use **BYOK** when you want to bring your own provider accounts and API keys. This gives you separate control over each model category.
If you want to run Gemini Live through your own Google Cloud project — for billing consolidation, VPC controls, regional residency, or enterprise IAM — Dograh also supports Gemini Live via **Vertex AI** as a separate provider (`google_vertex_realtime`). The default model is `google/gemini-live-2.5-flash-native-audio`.
![BYOK model configuration](../images/model-configuration-byok.png)
Unlike Google AI Studio (which uses a single Gemini API key), Vertex AI authenticates with a **service account** belonging to your Google Cloud project.
The BYOK section has nested tabs:
### Prerequisites
| Tab | What to configure |
|-----|-------------------|
| **LLM** | The chat or reasoning model provider, model, optional base URL, and API key. |
| **Voice** | The text-to-speech provider, voice, model, speed, optional base URL, and API key. |
| **Transcriber** | The speech-to-text provider, model, language, and API key. |
| **Embedding** | The embedding provider, model, and API key. |
1. A Google Cloud project with billing enabled.
2. The Vertex AI API enabled on that project:
Provider-specific fields appear only when they apply. For example, OpenAI-compatible LLM providers can expose a **Base URL** field, ElevenLabs voices can expose a voice ID, and transcribers can expose language options.
```bash
gcloud services enable aiplatform.googleapis.com --project=YOUR_PROJECT_ID
```
## Agent-level model overrides
3. A service account with the **Vertex AI User** role (`roles/aiplatform.user`) on the project:
You can override the organization model configuration for an individual agent. This is useful when different agents need different models, voices, transcribers, or providers.
```bash
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
--member="serviceAccount:YOUR_SA@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/aiplatform.user"
```
To configure an override:
4. A **JSON** key for that service account (P12 keys are not supported).
1. Open the agent.
2. Go to **Settings**.
3. Open **Model Overrides**.
4. Enable the override for the service you want to customize.
5. Configure the provider, model, and keys for that service.
6. Save the agent settings.
### Creating the service account key
1. In the GCP Console, go to **IAM & Admin → Service Accounts**.
2. Pick an existing service account (or create a new one).
3. Open the **Keys** tab → **Add Key → Create new key**.
4. Choose **JSON** as the key type and click **Create**.
5. The key file will download to your computer — store it securely and treat it as a secret.
<Note>
Always pick **JSON**, not P12. The Vertex AI client libraries used by Dograh only accept service-account JSON keys; P12 is a legacy format retained for older Google Workspace integrations.
</Note>
### Configuring Vertex AI Realtime in Dograh
1. Go to **Model Configurations** in your Dograh dashboard.
2. Enable the **Realtime** toggle.
3. Under the **Realtime** section, select `google_vertex_realtime` as the provider.
4. Fill in the fields:
| Field | What to put in |
|---|---|
| **Model** | Vertex publisher/model id, e.g. `google/gemini-live-2.5-flash-native-audio` |
| **Voice** | One of the built-in voices (Puck, Charon, Kore, Fenrir, Aoede) |
| **Language** | BCP-47 code (e.g. `en-US`) |
| **Project Id** | The `project_id` value from your service-account JSON |
| **Location** | GCP region where the model is available (e.g. `us-east4`) |
| **Credentials** | Paste the **entire contents** of the service-account JSON file |
| **API Key** | Leave blank — Vertex AI does not use API keys |
5. Save the configuration.
<Note>
Paste the whole JSON file into the **Credentials** field — including `private_key`, `client_email`, and all other entries. Don't try to extract individual fields. If `Credentials` is left blank, Dograh falls back to **Application Default Credentials (ADC)** from the host environment, which is useful when running Dograh on a GCP VM or GKE pod with an attached service account.
</Note>
<Note>
IAM changes can take up to ~60 seconds to propagate. If you see `Permission 'aiplatform.endpoints.predict' denied`, wait a minute and retry — or double-check that the role was granted to the same service account whose JSON you pasted.
</Note>
Agent-level overrides are selective. For example, you can override only the Voice service for one agent while it continues to use the organization-level LLM and Transcriber configuration.

View file

@ -6,6 +6,23 @@ description: Set up the Dograh contributor environment with the devcontainer-fir
If the steps below do not work for you, please open an issue on [GitHub](https://github.com/dograh-hq/dograh/issues).
</Note>
<Tip>
**Using Claude Code or Codex?** Install the official Dograh setup skill and let your agent walk you through the contributor setup — it covers both the devcontainer and host-managed paths, runs Dograh's own scripts, and verifies the stack is healthy.
<CodeGroup>
```text Claude Code
/plugin marketplace add dograh-hq/dograh-plugins
/plugin install dograh@dograh
```
```text Codex
codex plugin marketplace add dograh-hq/dograh-plugins
codex plugin add dograh@dograh
```
</CodeGroup>
Start a new session, then ask it to *"set up Dograh for development"* (or run `/dograh-setup develop` in Claude Code). More at [dograh-hq/dograh-plugins](https://github.com/dograh-hq/dograh-plugins).
</Tip>
### Recommended: Devcontainer Setup
#### System Requirements

View file

@ -0,0 +1,99 @@
---
title: "Authentication"
description: "Configure how self-hosted Dograh authenticates users — the default local provider, or Stack Auth for social login"
---
Self-hosted Dograh ships with a built-in **local** authentication provider (email + password, backed by a signed JWT). This is the default and needs no external service.
To offer social logins (Google, GitHub, and others), you can delegate sign-in to **[Stack Auth](https://stack-auth.com)**. Enabling it is a **runtime** configuration change — set a few environment variables and restart. The prebuilt `dograhai/dograh-api` and `dograhai/dograh-ui` images work as-is; you do **not** need to rebuild or build from source.
<Note>
The active provider is controlled by the backend `AUTH_PROVIDER` variable (`local` by default). The frontend discovers the provider — and, for Stack, its public client config — at runtime from the backend's `/api/v1/health` response, so the browser bundle never needs Stack values baked in at build time.
</Note>
## How it works
1. The backend reads `AUTH_PROVIDER` and the Stack settings from its environment.
2. When `AUTH_PROVIDER=stack`, `/api/v1/health` returns the **public** Stack client config (project id + publishable client key).
3. The UI fetches that at runtime and initializes the Stack SDK in the browser.
4. The **secret server key** is used only server-side (by the backend and the UI's server runtime) and is never sent to the browser.
## Prerequisites
A Stack Auth project. Create one in the [Stack Auth dashboard](https://app.stack-auth.com) and configure the social login providers you want to offer.
## Step 1 — Collect your Stack credentials
From your project in the [Stack Auth dashboard](https://app.stack-auth.com), gather:
| Value | Sensitivity |
|---|---|
| **Project ID** | Public |
| **Publishable client key** | Public (safe to expose in the browser) |
| **Secret server key** | Secret — keep server-side only |
| **API base URL** | Public. For Stack's hosted service this is `https://api.stack-auth.com` |
## Step 2 — Configure the backend (`api`)
Set these on the `api` service. Add them to the `environment:` block of the `api` service in your `docker-compose.yaml`:
```yaml docker-compose.yaml
services:
api:
environment:
AUTH_PROVIDER: "stack"
STACK_AUTH_PROJECT_ID: "<your-project-id>"
STACK_PUBLISHABLE_CLIENT_KEY: "<your-publishable-client-key>"
STACK_SECRET_SERVER_KEY: "<your-secret-server-key>"
STACK_AUTH_API_URL: "https://api.stack-auth.com"
```
## Step 3 — Configure the UI (`ui`)
The UI runs server-side code (SSR pages and the `/handler/*` auth routes) that calls Stack with the secret server key, so the `ui` service needs that one value too:
```yaml docker-compose.yaml
services:
ui:
environment:
STACK_SECRET_SERVER_KEY: "<your-secret-server-key>"
```
<Note>
The `ui` service does **not** need the project id or publishable client key — it receives those from the backend at runtime via `/api/v1/health`. Only the secret server key (used server-side) is set here.
</Note>
## Step 4 — Restart and verify
Recreate the containers so they pick up the new environment:
```bash
docker compose up -d
```
Confirm the backend reports the active provider and the public client config:
```bash
curl -s http://localhost:8000/api/v1/health
# expect: "auth_provider":"stack", plus "stack_project_id" and "stack_publishable_client_key"
```
Then open the UI. The sign-in page should now present your configured Stack Auth social login options instead of the local email/password form.
## Environment variable reference
| Variable | Service | Secret | Notes |
|---|---|---|---|
| `AUTH_PROVIDER` | `api` | — | Set to `stack` (default `local`) |
| `STACK_AUTH_PROJECT_ID` | `api` | No | Stack project ID; served to the UI at runtime |
| `STACK_PUBLISHABLE_CLIENT_KEY` | `api` | No | Publishable key; served to the UI at runtime |
| `STACK_SECRET_SERVER_KEY` | `api` + `ui` | **Yes** | Server-side only — never exposed to the browser |
| `STACK_AUTH_API_URL` | `api` | No | Stack REST API base URL |
<Warning>
`STACK_SECRET_SERVER_KEY` is the only secret here. Keep it out of any client-visible config and never bake it into an image. The project ID and publishable client key are public by design — the backend deliberately serves them to the browser so Stack can initialize at runtime.
</Warning>
## Reverting to local auth
Remove the variables above (or set `AUTH_PROVIDER=local`) and restart. The UI detects `local` from the backend at runtime and falls back to the built-in email/password flow — no rebuild required.

View file

@ -8,6 +8,23 @@ Dograh AI can be deployed using Docker in two main configurations. Choose the op
- **Option 1**: For local development and testing on your own machine
- **Option 2**: For remote server deployment with HTTPS (using IP address). If you also have a custom domain, you can first deploy Dograh stack on your server using steps in this document and then proceed to the [Custom Domain](deployment/custom-domain) section.
<Tip>
**Using Claude Code or Codex?** Install the official Dograh setup skill and let your agent drive either deployment below — it orients to your OS, picks local vs remote, runs Dograh's own setup scripts, and verifies the result with a built-in health check.
<CodeGroup>
```text Claude Code
/plugin marketplace add dograh-hq/dograh-plugins
/plugin install dograh@dograh
```
```text Codex
codex plugin marketplace add dograh-hq/dograh-plugins
codex plugin add dograh@dograh
```
</CodeGroup>
Start a new session, then ask it to *"set up Dograh"* (or run `/dograh-setup` in Claude Code). More at [dograh-hq/dograh-plugins](https://github.com/dograh-hq/dograh-plugins).
</Tip>
## Option 1: Local Docker Deployment
Watch the video tutorial below for a step-by-step walkthrough of setting up Dograh AI locally with Docker.

View file

@ -22,7 +22,7 @@ The relevant required variables for each mode are noted in the descriptions belo
|---|---|---|
| `ENVIRONMENT` | `local` | Runtime environment. Affects logging and behaviour. One of `local`, `production`, `test` |
| `DEPLOYMENT_MODE` | `oss` | Deployment mode. Use `oss` for self-hosted |
| `AUTH_PROVIDER` | `local` | Authentication provider. Use `local` for OSS |
| `AUTH_PROVIDER` | `local` | Authentication provider. `local` (default) uses the built-in email/password flow. Set to `stack` to delegate to Stack Auth for social login — see [Authentication](/deployment/authentication) for the full setup |
---
@ -48,6 +48,19 @@ Never use the placeholder `OSS_JWT_SECRET` in a production deployment. Generate
---
## Authentication (Stack Auth)
Set these when `AUTH_PROVIDER=stack` to delegate sign-in to [Stack Auth](https://stack-auth.com) for social login. The project id and publishable client key are public and are served to the browser at runtime via `/api/v1/health`; the secret server key stays server-side. See [Authentication](/deployment/authentication) for the full walkthrough.
| Variable | Default | Description |
|---|---|---|
| `STACK_AUTH_PROJECT_ID` | `null` | **Required for `stack`.** Stack project ID (public) |
| `STACK_PUBLISHABLE_CLIENT_KEY` | `null` | **Required for `stack`.** Stack publishable client key (public) |
| `STACK_SECRET_SERVER_KEY` | `null` | **Required for `stack`.** Stack secret server key — server-side only, also set on the `ui` service. Keep secret |
| `STACK_AUTH_API_URL` | `null` | **Required for `stack`.** Stack REST API base URL (e.g. `https://api.stack-auth.com`) |
---
## URLs
| Variable | Default | Description |

View file

@ -157,6 +157,7 @@
"pages": [
"deployment/introduction",
"deployment/docker",
"deployment/authentication",
"deployment/custom-domain",
"deployment/scaling",
"deployment/update",

View file

@ -39,6 +39,23 @@ Invoke-WebRequest -OutFile start_docker.ps1 https://raw.githubusercontent.com/do
```
</CodeGroup>
<Tip>
**Using an AI coding agent?** If you work in **Claude Code** or **Codex**, install the official Dograh setup skill and let your agent handle installation, configuration, and troubleshooting for you. It orients to your OS, picks the right deploy path, runs Dograh's own setup scripts, and verifies the result. Install it once:
<CodeGroup>
```text Claude Code
/plugin marketplace add dograh-hq/dograh-plugins
/plugin install dograh@dograh
```
```text Codex
codex plugin marketplace add dograh-hq/dograh-plugins
codex plugin add dograh@dograh
```
</CodeGroup>
Then start a new session and ask it to *"set up Dograh"* (in Claude Code you can also run `/dograh-setup`). See [dograh-hq/dograh-plugins](https://github.com/dograh-hq/dograh-plugins) for details.
</Tip>
Please check [Prerequisites](getting-started/prerequisites) for the system requirements and [Troubleshooting](getting-started/troubleshooting) for common issues.
## Next Steps

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Some files were not shown because too many files have changed in this diff Show more