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

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

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

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

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

|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
99
docs/deployment/authentication.mdx
Normal file
99
docs/deployment/authentication.mdx
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@
|
|||
"pages": [
|
||||
"deployment/introduction",
|
||||
"deployment/docker",
|
||||
"deployment/authentication",
|
||||
"deployment/custom-domain",
|
||||
"deployment/scaling",
|
||||
"deployment/update",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
docs/images/model-configuration-byok.png
Normal file
BIN
docs/images/model-configuration-byok.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
BIN
docs/images/model-configuration-dograh.png
Normal file
BIN
docs/images/model-configuration-dograh.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
BIN
docs/images/model-configuration-speech-to-speech.png
Normal file
BIN
docs/images/model-configuration-speech-to-speech.png
Normal file
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
Loading…
Add table
Add a link
Reference in a new issue