mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
Merge remote-tracking branch 'origin/main' into feat/provisional-vad
# Conflicts: # api/routes/main.py # api/tests/test_active_calls.py # pipecat # scripts/rolling_update.sh
This commit is contained in:
commit
cc658131aa
28 changed files with 1101 additions and 31 deletions
101
.conductor/README.md
Normal file
101
.conductor/README.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Conductor workspace setup
|
||||
|
||||
This directory makes [Conductor](https://conductor.build) workspaces (git
|
||||
worktrees) self-contained: each one installs its own deps and runs its own
|
||||
backend + UI on a dedicated port range, so you can develop several branches at
|
||||
once without collisions.
|
||||
|
||||
## How it works
|
||||
|
||||
Conductor gives every workspace a block of **10 ports** starting at
|
||||
`$CONDUCTOR_PORT`. We use:
|
||||
|
||||
| Port | Service | Script |
|
||||
| --------------- | ------------------ | ----------------------- |
|
||||
| `CONDUCTOR_PORT` | UI (Next.js) | `run-ui.sh` |
|
||||
| `CONDUCTOR_PORT + 1` | Backend (uvicorn) | `run-backend.sh` |
|
||||
| `+2 .. +9` | reserved | — |
|
||||
|
||||
The UI sits on the base port so Conductor's **Open** button (`preview_urls`)
|
||||
lands on it directly — preview templates only substitute `$CONDUCTOR_PORT`, with
|
||||
no `+1` arithmetic. The UI is wired to its own workspace's backend
|
||||
(`NEXT_PUBLIC_BACKEND_URL`, `BACKEND_URL`) and the backend's CORS / `UI_APP_URL`
|
||||
point back at its own UI — all derived from `$CONDUCTOR_PORT`, so no two
|
||||
workspaces interfere.
|
||||
|
||||
### Shared, NOT per-workspace
|
||||
|
||||
Postgres, Redis, and MinIO run as a **single shared Docker stack** (compose
|
||||
project `dograh`). `setup.sh` brings it up idempotently with
|
||||
`COMPOSE_PROJECT_NAME=dograh` so every workspace reuses the same one instead of
|
||||
fighting over the fixed 5432/6379/9000 ports. All workspaces therefore share one
|
||||
database — fine for app servers, but it means the **Arq worker should run in only
|
||||
one workspace** (`run-worker.sh`), since a single worker drains the shared queue
|
||||
for everyone.
|
||||
|
||||
## The Run menu
|
||||
|
||||
> **Important:** a Conductor workspace runs **one run script at a time**.
|
||||
> `run_mode = "concurrent"` lets *different workspaces* run simultaneously — it
|
||||
> does **not** let you click two run buttons in the *same* workspace. That's why
|
||||
> **dev** starts the UI and backend together instead of relying on two buttons.
|
||||
|
||||
The Run dropdown offers:
|
||||
|
||||
- **dev** (default) — UI (`$CONDUCTOR_PORT`) **and** backend (`$CONDUCTOR_PORT+1`)
|
||||
together, via `concurrently`. **Use this day to day.**
|
||||
- **ui** — Next.js only, for debugging the frontend alone.
|
||||
- **backend** — uvicorn only, for debugging the API alone.
|
||||
- **worker** — Arq worker; start in just one workspace.
|
||||
|
||||
Hit **dev** and both servers come up; click Conductor's **Open** button to launch
|
||||
the UI. Start **worker** once (e.g. in your primary workspace) when you need
|
||||
background jobs — the same way you used to run a single arq worker in VS Code.
|
||||
|
||||
## Creating a new workspace
|
||||
|
||||
In Conductor: **New Workspace** → pick a branch. `setup.sh` then runs
|
||||
automatically and:
|
||||
|
||||
1. copies the gitignored env files from your main checkout (see
|
||||
[Environment files](#environment-files)),
|
||||
2. checks out the `pipecat` submodule,
|
||||
3. builds `venv` (Python 3.13) + installs backend/pipecat deps,
|
||||
4. `npm install` for the UI,
|
||||
5. ensures the shared Docker stack is up,
|
||||
6. runs `alembic upgrade head`.
|
||||
|
||||
The first setup is slow (deps); afterwards the Run buttons are instant.
|
||||
|
||||
## Environment files
|
||||
|
||||
The app's env files hold real secrets, so they're **gitignored** and never
|
||||
committed — a fresh worktree won't have them. `setup.sh` copies them from your
|
||||
main checkout (`$CONDUCTOR_ROOT_PATH`) into each new workspace:
|
||||
|
||||
| File | Used by |
|
||||
| ----------------------------- | -------------------------------- |
|
||||
| `api/.env` | backend (DB/Redis URLs, secrets) |
|
||||
| `api/.env.test` | backend test runs |
|
||||
| `ui/.env` | UI (backend URL, public config) |
|
||||
| `ui/.env.local` | UI secrets (Stack/PostHog/etc.) |
|
||||
| `ui/.env.sentry-build-plugin` | UI Sentry source-map upload |
|
||||
|
||||
The copy is idempotent (only fills in what's missing), so re-running setup won't
|
||||
clobber a workspace-local edit. **Add a new env file?** List it in the loop near
|
||||
the top of `setup.sh`.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------- | -------------------------------------------------- |
|
||||
| `settings.toml` | Conductor config: setup + run scripts, preview_urls |
|
||||
| `setup.sh` | One-time workspace bootstrap |
|
||||
| `run-dev.sh` | Default run: UI + backend together (`concurrently`) |
|
||||
| `run-ui.sh` | Foreground Next.js on `$CONDUCTOR_PORT` |
|
||||
| `run-backend.sh` | Foreground uvicorn on `$CONDUCTOR_PORT + 1` |
|
||||
| `run-worker.sh` | Foreground Arq worker (run in one workspace only) |
|
||||
|
||||
> Need machine-local tweaks (e.g. a different port base or skipping the worker)?
|
||||
> Put them in `.conductor/settings.local.toml`, which is personal and not
|
||||
> committed.
|
||||
30
.conductor/run-backend.sh
Executable file
30
.conductor/run-backend.sh
Executable file
|
|
@ -0,0 +1,30 @@
|
|||
#!/usr/bin/env bash
|
||||
# Conductor run script — Backend (FastAPI/uvicorn) for THIS workspace.
|
||||
#
|
||||
# Binds $CONDUCTOR_PORT + 1 and points CORS / UI_APP_URL at this workspace's UI
|
||||
# (which runs on $CONDUCTOR_PORT). Runs in the FOREGROUND with exec (no &) so
|
||||
# Conductor's SIGHUP cleanly tears it down.
|
||||
set -euo pipefail
|
||||
cd "${CONDUCTOR_WORKSPACE_PATH:-$PWD}"
|
||||
|
||||
UI_PORT="${CONDUCTOR_PORT:-8000}"
|
||||
BACKEND_PORT="$((UI_PORT + 1))"
|
||||
|
||||
# Load the workspace's api/.env, then override the port-specific bits. The
|
||||
# backend reads config via os.getenv, and these exports happen after the source,
|
||||
# so they win over the values copied from the main checkout (which assume 8000/3000).
|
||||
if [[ -f api/.env ]]; then set -a; # shellcheck disable=SC1091
|
||||
source api/.env; set +a; fi
|
||||
export FASTAPI_PORT="$BACKEND_PORT"
|
||||
export UI_APP_URL="http://localhost:${UI_PORT}"
|
||||
export CORS_ALLOWED_ORIGINS="http://localhost:${UI_PORT},http://127.0.0.1:${UI_PORT}"
|
||||
|
||||
if [[ ! -d venv ]]; then
|
||||
echo "ERROR: venv missing. Re-run workspace setup (.conductor/setup.sh)." >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
source venv/bin/activate
|
||||
|
||||
echo "[backend] workspace=${CONDUCTOR_WORKSPACE_NAME:-?} port=${BACKEND_PORT} ui=${UI_PORT}"
|
||||
exec uvicorn api.app:app --host 0.0.0.0 --port "$BACKEND_PORT" --reload --reload-dir api
|
||||
59
.conductor/run-dev.sh
Executable file
59
.conductor/run-dev.sh
Executable file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env bash
|
||||
# Conductor run script — full dev stack (UI + backend) for THIS workspace.
|
||||
#
|
||||
# A Conductor workspace runs ONE run script at a time (run_mode governs
|
||||
# concurrency ACROSS workspaces, not multiple run buttons within one). So to get
|
||||
# the UI AND the backend up together, we launch both with `concurrently`.
|
||||
#
|
||||
# Conductor stops a run with SIGHUP (then SIGKILL after 200ms). Neither npx nor
|
||||
# concurrently reliably tears down their child tree on SIGHUP, so we supervise:
|
||||
# trap the signal and recursively kill the whole tree ourselves. We do NOT exec,
|
||||
# so this shell stays alive to handle the trap.
|
||||
#
|
||||
# Ports: UI on $CONDUCTOR_PORT, backend on $CONDUCTOR_PORT + 1.
|
||||
set -uo pipefail
|
||||
cd "${CONDUCTOR_WORKSPACE_PATH:-$PWD}"
|
||||
|
||||
UI_PORT="${CONDUCTOR_PORT:-8000}"
|
||||
BACKEND_PORT="$((UI_PORT + 1))"
|
||||
|
||||
# Ensure node/npx is on PATH (load nvm + honor .nvmrc if needed).
|
||||
if ! command -v npx >/dev/null 2>&1; then
|
||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||
# shellcheck disable=SC1091
|
||||
[[ -s "$NVM_DIR/nvm.sh" ]] && . "$NVM_DIR/nvm.sh"
|
||||
command -v nvm >/dev/null 2>&1 && nvm use >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Prefer a locally-installed concurrently (fast); fall back to npx, which fetches
|
||||
# it once then caches — so this also works in workspaces created before it existed.
|
||||
if [[ -x ui/node_modules/.bin/concurrently ]]; then
|
||||
RUNNER=(ui/node_modules/.bin/concurrently)
|
||||
else
|
||||
RUNNER=(npx --yes concurrently)
|
||||
fi
|
||||
|
||||
# Recursively SIGTERM a process and every descendant (children first). npm/npx/
|
||||
# next don't reliably forward signals, so we signal each PID in the tree directly.
|
||||
kill_tree() {
|
||||
local pid=$1 child
|
||||
for child in $(pgrep -P "$pid" 2>/dev/null); do kill_tree "$child"; done
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
trap - HUP INT TERM EXIT
|
||||
[[ -n "${CHILD:-}" ]] && kill_tree "$CHILD"
|
||||
exit 0
|
||||
}
|
||||
trap shutdown HUP INT TERM EXIT
|
||||
|
||||
echo "[dev] workspace=${CONDUCTOR_WORKSPACE_NAME:-?} ui=:${UI_PORT} backend=:${BACKEND_PORT}"
|
||||
"${RUNNER[@]}" \
|
||||
--names "ui,backend" \
|
||||
--prefix-colors "magenta,cyan" \
|
||||
--kill-others \
|
||||
"bash .conductor/run-ui.sh" \
|
||||
"bash .conductor/run-backend.sh" &
|
||||
CHILD=$!
|
||||
wait "$CHILD"
|
||||
29
.conductor/run-ui.sh
Executable file
29
.conductor/run-ui.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/usr/bin/env bash
|
||||
# Conductor run script — UI (Next.js) for THIS workspace.
|
||||
#
|
||||
# Binds $CONDUCTOR_PORT (so Conductor's Open button / preview_urls land here) and
|
||||
# talks to this workspace's backend on $CONDUCTOR_PORT + 1. Foreground exec (no &)
|
||||
# so Conductor can stop it cleanly.
|
||||
set -euo pipefail
|
||||
cd "${CONDUCTOR_WORKSPACE_PATH:-$PWD}"
|
||||
|
||||
UI_PORT="${CONDUCTOR_PORT:-8000}"
|
||||
BACKEND_PORT="$((UI_PORT + 1))"
|
||||
BACKEND="http://localhost:${BACKEND_PORT}"
|
||||
|
||||
# Ensure node is on PATH (load nvm + honor .nvmrc if needed).
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||
# shellcheck disable=SC1091
|
||||
[[ -s "$NVM_DIR/nvm.sh" ]] && . "$NVM_DIR/nvm.sh"
|
||||
command -v nvm >/dev/null 2>&1 && nvm use >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Shell env overrides .env files in Next.js, so this points the UI at the right
|
||||
# backend for this workspace.
|
||||
export BACKEND_URL="$BACKEND"
|
||||
export NEXT_PUBLIC_BACKEND_URL="$BACKEND"
|
||||
|
||||
cd ui
|
||||
echo "[ui] workspace=${CONDUCTOR_WORKSPACE_NAME:-?} port=${UI_PORT} backend=${BACKEND}"
|
||||
exec npm run dev -- --port "$UI_PORT"
|
||||
21
.conductor/run-worker.sh
Executable file
21
.conductor/run-worker.sh
Executable file
|
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env bash
|
||||
# Conductor run script — Arq background worker.
|
||||
#
|
||||
# Run this in ONE workspace only. Every workspace shares the same Redis/Postgres,
|
||||
# so a single arq worker drains the task queue for all of them — multiple workers
|
||||
# would just fight over the same jobs. Foreground exec so Conductor stops it cleanly.
|
||||
set -euo pipefail
|
||||
cd "${CONDUCTOR_WORKSPACE_PATH:-$PWD}"
|
||||
|
||||
if [[ -f api/.env ]]; then set -a; # shellcheck disable=SC1091
|
||||
source api/.env; set +a; fi
|
||||
|
||||
if [[ ! -d venv ]]; then
|
||||
echo "ERROR: venv missing. Re-run workspace setup (.conductor/setup.sh)." >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
source venv/bin/activate
|
||||
|
||||
echo "[worker] arq worker on shared Redis — workspace=${CONDUCTOR_WORKSPACE_NAME:-?}"
|
||||
exec python -m arq api.tasks.arq.WorkerSettings --custom-log-dict api.tasks.arq.LOG_CONFIG
|
||||
54
.conductor/settings.toml
Normal file
54
.conductor/settings.toml
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Conductor project config — shared, committed so every contributor (and every
|
||||
# new workspace) gets the same per-worktree dev workflow.
|
||||
#
|
||||
# Each Conductor workspace is its own git worktree and gets its own 10-port range
|
||||
# starting at $CONDUCTOR_PORT. We use:
|
||||
# $CONDUCTOR_PORT -> ui (Next.js) <- Conductor's Open button lands here
|
||||
# $CONDUCTOR_PORT + 1 -> backend (FastAPI/uvicorn)
|
||||
# +2 .. +9 -> reserved for future per-workspace services
|
||||
#
|
||||
# Postgres/Redis/MinIO are a single SHARED Docker stack (project name "dograh"),
|
||||
# not per-workspace — see .conductor/setup.sh and .conductor/README.md.
|
||||
"$schema" = "https://conductor.build/schemas/settings.repo.schema.json"
|
||||
|
||||
# Conductor's Open button. The UI runs on $CONDUCTOR_PORT, so this opens it.
|
||||
# (Preview URL templates only substitute $CONDUCTOR_PORT — no "+1" arithmetic —
|
||||
# which is why the UI, the thing you actually open, sits on the base port.)
|
||||
[[preview_urls]]
|
||||
name = "UI"
|
||||
url = "http://localhost:$CONDUCTOR_PORT"
|
||||
|
||||
[scripts]
|
||||
# Runs once when a workspace is created: copies gitignored env files, inits the
|
||||
# pipecat submodule, builds the venv + node_modules, ensures the shared Docker
|
||||
# stack, and runs migrations.
|
||||
setup = "bash .conductor/setup.sh"
|
||||
|
||||
# concurrent => multiple WORKSPACES can run their dev servers at the same time
|
||||
# (each on its own CONDUCTOR_PORT range). NOTE: within a single workspace,
|
||||
# Conductor runs ONE run script at a time — that's why the default "dev" script
|
||||
# below starts the UI and backend together rather than relying on two buttons.
|
||||
run_mode = "concurrent"
|
||||
|
||||
# Full dev stack: UI ($CONDUCTOR_PORT) + backend ($CONDUCTOR_PORT+1) together.
|
||||
# Default Run button — this is the one to use day to day.
|
||||
[scripts.run.dev]
|
||||
command = "bash .conductor/run-dev.sh"
|
||||
icon = "play"
|
||||
default = true
|
||||
|
||||
# UI only (Next.js) on $CONDUCTOR_PORT — handy for debugging the frontend alone.
|
||||
[scripts.run.ui]
|
||||
command = "bash .conductor/run-ui.sh"
|
||||
icon = "play"
|
||||
|
||||
# Backend only (FastAPI/uvicorn) on $CONDUCTOR_PORT + 1 — debugging the API alone.
|
||||
[scripts.run.backend]
|
||||
command = "bash .conductor/run-backend.sh"
|
||||
icon = "server"
|
||||
|
||||
# Arq background worker. Start in ONE workspace only — all workspaces share the
|
||||
# same Redis/Postgres, so a single worker drains the queue for everyone.
|
||||
[scripts.run.worker]
|
||||
command = "bash .conductor/run-worker.sh"
|
||||
icon = "server"
|
||||
91
.conductor/setup.sh
Executable file
91
.conductor/setup.sh
Executable file
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env bash
|
||||
# Conductor setup script — runs ONCE when a workspace (git worktree) is created.
|
||||
#
|
||||
# A fresh worktree only has git-tracked files, so this recreates the rest: the
|
||||
# gitignored env files, the pipecat submodule checkout, a Python venv,
|
||||
# ui/node_modules, the shared local Docker stack, and the DB schema.
|
||||
#
|
||||
# Conductor injects: CONDUCTOR_ROOT_PATH (main checkout), CONDUCTOR_WORKSPACE_PATH,
|
||||
# CONDUCTOR_WORKSPACE_NAME, CONDUCTOR_PORT (first of 10), CONDUCTOR_IS_LOCAL.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="${CONDUCTOR_ROOT_PATH:-}"
|
||||
WS="${CONDUCTOR_WORKSPACE_PATH:-$PWD}"
|
||||
cd "$WS"
|
||||
|
||||
log() { printf '\n\033[1;36m[conductor-setup]\033[0m %s\n' "$*"; }
|
||||
|
||||
# 1) Copy the gitignored env files from the main checkout. These hold real
|
||||
# secrets, so they're never committed — a fresh worktree won't have them. We copy
|
||||
# only what's missing (idempotent), so re-running setup won't clobber local edits.
|
||||
# (See README "Environment files" for the canonical list.)
|
||||
if [[ -n "$ROOT" && "$ROOT" != "$WS" ]]; then
|
||||
log "Copying gitignored env files from $ROOT"
|
||||
for f in api/.env api/.env.test ui/.env ui/.env.local ui/.env.sentry-build-plugin; do
|
||||
if [[ ! -f "$f" && -f "$ROOT/$f" ]]; then
|
||||
mkdir -p "$(dirname "$f")"
|
||||
cp "$ROOT/$f" "$f"
|
||||
echo " copied $f"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# 2) pipecat submodule — REQUIRED here, NOT redundant with step 3.
|
||||
# A fresh git worktree has an empty pipecat/. setup_requirements.sh --dev
|
||||
# (step 3) deliberately SKIPS `git submodule update` (it assumes CI already
|
||||
# checked out submodules) but still runs `uv pip install -e ./pipecat`, which
|
||||
# fails unless the checkout is already on disk. So we populate it first.
|
||||
log "Initializing git submodules (pipecat)"
|
||||
git submodule update --init --recursive
|
||||
|
||||
# 3) Python venv with 3.13 + backend/pipecat deps ----------------------------
|
||||
# Bare `python3` may be 3.14 here; setup_requirements.sh requires 3.12/3.13.
|
||||
PY313="$(command -v python3.13 || true)"
|
||||
if [[ -z "$PY313" ]]; then
|
||||
echo "ERROR: python3.13 not found. Install it (e.g. brew install python@3.13)." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -d venv ]]; then
|
||||
log "Creating venv with $PY313 ($("$PY313" --version))"
|
||||
"$PY313" -m venv venv
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
source venv/bin/activate
|
||||
log "Installing backend + pipecat deps (uv) — this is the slow step"
|
||||
bash scripts/setup_requirements.sh --dev
|
||||
|
||||
# 4) UI deps -----------------------------------------------------------------
|
||||
ensure_node() {
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||
# shellcheck disable=SC1091
|
||||
[[ -s "$NVM_DIR/nvm.sh" ]] && . "$NVM_DIR/nvm.sh"
|
||||
command -v nvm >/dev/null 2>&1 && nvm use >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
ensure_node
|
||||
log "Installing UI deps (npm install)"
|
||||
( cd ui && npm install )
|
||||
|
||||
# 5) Shared local Docker stack (idempotent, pinned project name) -------------
|
||||
# All workspaces share ONE stack named "dograh" so they never collide on the
|
||||
# fixed Postgres/Redis/MinIO ports. If it's already up this is a no-op.
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
log "Ensuring shared Docker stack (COMPOSE_PROJECT_NAME=dograh)"
|
||||
COMPOSE_PROJECT_NAME=dograh docker compose -f docker-compose-local.yaml up -d \
|
||||
|| echo " (warning: could not start docker stack; start it manually)"
|
||||
else
|
||||
echo " (docker not found; start Postgres/Redis/MinIO yourself)"
|
||||
fi
|
||||
|
||||
# 6) DB migrations (best-effort; shared DB, alembic is idempotent) -----------
|
||||
if [[ -f api/.env ]]; then
|
||||
log "Running DB migrations (alembic upgrade head)"
|
||||
set -a; # shellcheck disable=SC1091
|
||||
source api/.env; set +a
|
||||
alembic -c api/alembic.ini upgrade head || echo " (warning: migrations skipped/failed — is the DB up?)"
|
||||
fi
|
||||
|
||||
PORT="${CONDUCTOR_PORT:-8000}"
|
||||
log "Setup complete for '${CONDUCTOR_WORKSPACE_NAME:-?}'."
|
||||
echo " Run menu: backend -> :${PORT} ui -> :$((PORT + 1)) worker -> shared arq"
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,6 +4,9 @@ __pycache__
|
|||
.env.prod
|
||||
.env.test
|
||||
|
||||
# Conductor personal/per-machine overrides (settings.toml IS committed)
|
||||
.conductor/settings.local.toml
|
||||
|
||||
# logs and run directory on production
|
||||
/logs/
|
||||
/run/
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ UI_APP_URL="http://localhost:3000"
|
|||
DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/postgres"
|
||||
REDIS_URL="redis://:redissecret@localhost:6379"
|
||||
|
||||
# Internal devops secret for deployment scripts and lifecycle hooks.
|
||||
# scripts/rolling_update.sh sends this to protected operational endpoints via
|
||||
# X-Dograh-Devops-Secret. Use a unique random value in production.
|
||||
DOGRAH_DEVOPS_SECRET="change-me-dograh-devops-secret"
|
||||
|
||||
# AWS S3 Configuration
|
||||
ENABLE_AWS_S3="false"
|
||||
# AWS_ACCESS_KEY_ID=""
|
||||
|
|
|
|||
|
|
@ -14,4 +14,6 @@ UI_APP_URL=http://localhost:3000
|
|||
DATABASE_URL="postgresql+asyncpg://postgres:postgres@localhost:5432/test_db"
|
||||
REDIS_URL="redis://:redissecret@localhost:6379/0"
|
||||
|
||||
DOGRAH_DEVOPS_SECRET="test-dograh-devops-secret"
|
||||
|
||||
MINIO_PUBLIC_ENDPOINT=http://localhost:9000
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ STACK_AUTH_PROJECT_ID = os.getenv("STACK_AUTH_PROJECT_ID")
|
|||
STACK_PUBLISHABLE_CLIENT_KEY = os.getenv("STACK_PUBLISHABLE_CLIENT_KEY")
|
||||
DOGRAH_MPS_SECRET_KEY = os.getenv("DOGRAH_MPS_SECRET_KEY", None)
|
||||
MPS_API_URL = os.getenv("MPS_API_URL", "https://services.dograh.com")
|
||||
DOGRAH_DEVOPS_SECRET = os.getenv("DOGRAH_DEVOPS_SECRET") or None
|
||||
|
||||
# Storage Configuration
|
||||
ENABLE_AWS_S3 = os.getenv("ENABLE_AWS_S3", "false").lower() == "true"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
from fastapi import APIRouter
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, status
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
|
@ -131,8 +134,35 @@ class ActiveCallsResponse(BaseModel):
|
|||
active_calls: int
|
||||
|
||||
|
||||
DOGRAH_DEVOPS_SECRET_HEADER = "X-Dograh-Devops-Secret"
|
||||
|
||||
|
||||
def _verify_devops_secret(
|
||||
configured_secret: str | None,
|
||||
provided_secret: str | None,
|
||||
) -> None:
|
||||
if not configured_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Devops secret is not configured",
|
||||
)
|
||||
if not provided_secret or not secrets.compare_digest(
|
||||
provided_secret,
|
||||
configured_secret,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Forbidden",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health/active-calls", response_model=ActiveCallsResponse)
|
||||
async def active_calls() -> ActiveCallsResponse:
|
||||
async def active_calls(
|
||||
x_dograh_devops_secret: Annotated[
|
||||
str | None,
|
||||
Header(alias=DOGRAH_DEVOPS_SECRET_HEADER),
|
||||
] = None,
|
||||
) -> ActiveCallsResponse:
|
||||
"""In-flight call count for THIS worker — the drain signal for deploys.
|
||||
|
||||
A deploy orchestrator polls this per worker and waits for zero before
|
||||
|
|
@ -141,6 +171,8 @@ async def active_calls() -> ActiveCallsResponse:
|
|||
count is per-process: one uvicorn per VM port (scripts/rolling_update.sh)
|
||||
or per Kubernetes pod (preStop hook). See api/services/pipecat/active_calls.py.
|
||||
"""
|
||||
from api.constants import DOGRAH_DEVOPS_SECRET
|
||||
from api.services.pipecat.active_calls import active_call_count
|
||||
|
||||
_verify_devops_secret(DOGRAH_DEVOPS_SECRET, x_dograh_devops_secret)
|
||||
return ActiveCallsResponse(active_calls=active_call_count())
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ from .azure import (
|
|||
AZURE_SPEECH_TTS_LANGUAGES,
|
||||
AZURE_SPEECH_TTS_VOICES,
|
||||
)
|
||||
from .cartesia import (
|
||||
CARTESIA_INK_2_STT_LANGUAGES,
|
||||
CARTESIA_INK_WHISPER_STT_LANGUAGES,
|
||||
CARTESIA_STT_LANGUAGES,
|
||||
CARTESIA_STT_MODELS,
|
||||
)
|
||||
from .deepgram import (
|
||||
DEEPGRAM_FLUX_MODELS,
|
||||
DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS,
|
||||
|
|
@ -59,6 +65,10 @@ __all__ = [
|
|||
"AZURE_SPEECH_STT_LANGUAGES",
|
||||
"AZURE_SPEECH_TTS_LANGUAGES",
|
||||
"AZURE_SPEECH_TTS_VOICES",
|
||||
"CARTESIA_INK_2_STT_LANGUAGES",
|
||||
"CARTESIA_INK_WHISPER_STT_LANGUAGES",
|
||||
"CARTESIA_STT_LANGUAGES",
|
||||
"CARTESIA_STT_MODELS",
|
||||
"DEEPGRAM_FLUX_MODELS",
|
||||
"DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES",
|
||||
"DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS",
|
||||
|
|
|
|||
105
api/services/configuration/options/cartesia.py
Normal file
105
api/services/configuration/options/cartesia.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
CARTESIA_STT_MODELS = ["ink-2", "ink-whisper"]
|
||||
CARTESIA_INK_2_STT_LANGUAGES = ("en",)
|
||||
CARTESIA_INK_WHISPER_STT_LANGUAGES = (
|
||||
"en",
|
||||
"zh",
|
||||
"de",
|
||||
"es",
|
||||
"ru",
|
||||
"ko",
|
||||
"fr",
|
||||
"ja",
|
||||
"pt",
|
||||
"tr",
|
||||
"pl",
|
||||
"ca",
|
||||
"nl",
|
||||
"ar",
|
||||
"sv",
|
||||
"it",
|
||||
"id",
|
||||
"hi",
|
||||
"fi",
|
||||
"vi",
|
||||
"he",
|
||||
"uk",
|
||||
"el",
|
||||
"ms",
|
||||
"cs",
|
||||
"ro",
|
||||
"da",
|
||||
"hu",
|
||||
"ta",
|
||||
"no",
|
||||
"th",
|
||||
"ur",
|
||||
"hr",
|
||||
"bg",
|
||||
"lt",
|
||||
"la",
|
||||
"mi",
|
||||
"ml",
|
||||
"cy",
|
||||
"sk",
|
||||
"te",
|
||||
"fa",
|
||||
"lv",
|
||||
"bn",
|
||||
"sr",
|
||||
"az",
|
||||
"sl",
|
||||
"kn",
|
||||
"et",
|
||||
"mk",
|
||||
"br",
|
||||
"eu",
|
||||
"is",
|
||||
"hy",
|
||||
"ne",
|
||||
"mn",
|
||||
"bs",
|
||||
"kk",
|
||||
"sq",
|
||||
"sw",
|
||||
"gl",
|
||||
"mr",
|
||||
"pa",
|
||||
"si",
|
||||
"km",
|
||||
"sn",
|
||||
"yo",
|
||||
"so",
|
||||
"af",
|
||||
"oc",
|
||||
"ka",
|
||||
"be",
|
||||
"tg",
|
||||
"sd",
|
||||
"gu",
|
||||
"am",
|
||||
"yi",
|
||||
"lo",
|
||||
"uz",
|
||||
"fo",
|
||||
"ht",
|
||||
"ps",
|
||||
"tk",
|
||||
"nn",
|
||||
"mt",
|
||||
"sa",
|
||||
"lb",
|
||||
"my",
|
||||
"bo",
|
||||
"tl",
|
||||
"mg",
|
||||
"as",
|
||||
"tt",
|
||||
"haw",
|
||||
"ln",
|
||||
"ha",
|
||||
"ba",
|
||||
"jw",
|
||||
"su",
|
||||
"yue",
|
||||
)
|
||||
CARTESIA_STT_LANGUAGES = CARTESIA_INK_WHISPER_STT_LANGUAGES
|
||||
|
|
@ -14,6 +14,10 @@ from api.services.configuration.options import (
|
|||
AZURE_SPEECH_STT_LANGUAGES,
|
||||
AZURE_SPEECH_TTS_LANGUAGES,
|
||||
AZURE_SPEECH_TTS_VOICES,
|
||||
CARTESIA_INK_2_STT_LANGUAGES,
|
||||
CARTESIA_INK_WHISPER_STT_LANGUAGES,
|
||||
CARTESIA_STT_LANGUAGES,
|
||||
CARTESIA_STT_MODELS,
|
||||
DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS,
|
||||
DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES,
|
||||
DEEPGRAM_LANGUAGES,
|
||||
|
|
@ -1323,9 +1327,6 @@ class DeepgramSTTConfiguration(BaseSTTConfiguration):
|
|||
)
|
||||
|
||||
|
||||
CARTESIA_STT_MODELS = ["ink-whisper"]
|
||||
|
||||
|
||||
@register_stt
|
||||
class CartesiaSTTConfiguration(BaseSTTConfiguration):
|
||||
model_config = CARTESIA_PROVIDER_MODEL_CONFIG
|
||||
|
|
@ -1335,6 +1336,17 @@ class CartesiaSTTConfiguration(BaseSTTConfiguration):
|
|||
description="Cartesia STT model.",
|
||||
json_schema_extra={"examples": CARTESIA_STT_MODELS},
|
||||
)
|
||||
language: str = Field(
|
||||
default="en",
|
||||
description="ISO 639-1 language code. ink-2 currently supports English only.",
|
||||
json_schema_extra={
|
||||
"examples": CARTESIA_STT_LANGUAGES,
|
||||
"model_options": {
|
||||
"ink-2": CARTESIA_INK_2_STT_LANGUAGES,
|
||||
"ink-whisper": CARTESIA_INK_WHISPER_STT_LANGUAGES,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
OPENAI_STT_MODELS = ["gpt-4o-transcribe"]
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ from api.services.pipecat.service_factory import (
|
|||
create_realtime_llm_service,
|
||||
create_stt_service,
|
||||
create_tts_service,
|
||||
stt_uses_flux_turns,
|
||||
stt_uses_external_turns,
|
||||
)
|
||||
from api.services.pipecat.tracing_config import (
|
||||
ensure_tracing,
|
||||
|
|
@ -97,6 +97,19 @@ from pipecat.utils.run_context import set_current_org_id, set_current_run_id
|
|||
# Setup tracing if enabled
|
||||
ensure_tracing()
|
||||
|
||||
DEFAULT_USER_TURN_STOP_TIMEOUT = 5.0
|
||||
EXTERNAL_TURN_USER_STOP_TIMEOUT = 30.0
|
||||
|
||||
|
||||
def _resolve_user_turn_stop_timeout(
|
||||
run_configs: dict, *, uses_external_turns: bool
|
||||
) -> float:
|
||||
if "user_turn_stop_timeout" in run_configs:
|
||||
return float(run_configs["user_turn_stop_timeout"])
|
||||
if uses_external_turns:
|
||||
return EXTERNAL_TURN_USER_STOP_TIMEOUT
|
||||
return DEFAULT_USER_TURN_STOP_TIMEOUT
|
||||
|
||||
|
||||
def _create_realtime_user_turn_config(provider: str):
|
||||
"""Return user turn strategies and optional local VAD for realtime providers."""
|
||||
|
|
@ -154,6 +167,34 @@ async def run_pipeline_telephony(
|
|||
user_id: int,
|
||||
call_id: str,
|
||||
transport_kwargs: dict,
|
||||
) -> None:
|
||||
"""Run a pipeline for any telephony provider."""
|
||||
# Register before any async setup so deploy drains see calls that are still
|
||||
# resolving DB/config/transport state.
|
||||
register_active_call(workflow_run_id)
|
||||
try:
|
||||
await _run_pipeline_telephony_impl(
|
||||
websocket,
|
||||
provider_name=provider_name,
|
||||
workflow_id=workflow_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
user_id=user_id,
|
||||
call_id=call_id,
|
||||
transport_kwargs=transport_kwargs,
|
||||
)
|
||||
finally:
|
||||
unregister_active_call(workflow_run_id)
|
||||
|
||||
|
||||
async def _run_pipeline_telephony_impl(
|
||||
websocket,
|
||||
*,
|
||||
provider_name: str,
|
||||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user_id: int,
|
||||
call_id: str,
|
||||
transport_kwargs: dict,
|
||||
) -> None:
|
||||
"""Run a pipeline for any telephony provider.
|
||||
|
||||
|
|
@ -227,7 +268,7 @@ async def run_pipeline_telephony(
|
|||
)
|
||||
|
||||
try:
|
||||
await _run_pipeline(
|
||||
await _run_pipeline_impl(
|
||||
transport,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
|
|
@ -251,6 +292,31 @@ async def run_pipeline_smallwebrtc(
|
|||
user_id: int,
|
||||
call_context_vars: dict = {},
|
||||
user_provider_id: str | None = None,
|
||||
) -> None:
|
||||
"""Run pipeline for WebRTC connections."""
|
||||
# Register before any async setup so deploy drains see calls that are still
|
||||
# resolving DB/config/transport state.
|
||||
register_active_call(workflow_run_id)
|
||||
try:
|
||||
await _run_pipeline_smallwebrtc_impl(
|
||||
webrtc_connection,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
user_id,
|
||||
call_context_vars=call_context_vars,
|
||||
user_provider_id=user_provider_id,
|
||||
)
|
||||
finally:
|
||||
unregister_active_call(workflow_run_id)
|
||||
|
||||
|
||||
async def _run_pipeline_smallwebrtc_impl(
|
||||
webrtc_connection: SmallWebRTCConnection,
|
||||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user_id: int,
|
||||
call_context_vars: dict = {},
|
||||
user_provider_id: str | None = None,
|
||||
) -> None:
|
||||
"""Run pipeline for WebRTC connections"""
|
||||
logger.debug(
|
||||
|
|
@ -300,7 +366,7 @@ async def run_pipeline_smallwebrtc(
|
|||
ambient_noise_config,
|
||||
is_realtime=is_realtime,
|
||||
)
|
||||
await _run_pipeline(
|
||||
await _run_pipeline_impl(
|
||||
transport,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
|
|
@ -323,6 +389,35 @@ async def _run_pipeline(
|
|||
user_provider_id: str | None = None,
|
||||
workflow_run=None,
|
||||
resolved_user_config=None,
|
||||
) -> None:
|
||||
"""Run the pipeline with active-call drain accounting."""
|
||||
register_active_call(workflow_run_id)
|
||||
try:
|
||||
await _run_pipeline_impl(
|
||||
transport,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
user_id,
|
||||
call_context_vars=call_context_vars,
|
||||
audio_config=audio_config,
|
||||
user_provider_id=user_provider_id,
|
||||
workflow_run=workflow_run,
|
||||
resolved_user_config=resolved_user_config,
|
||||
)
|
||||
finally:
|
||||
unregister_active_call(workflow_run_id)
|
||||
|
||||
|
||||
async def _run_pipeline_impl(
|
||||
transport,
|
||||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user_id: int,
|
||||
call_context_vars: dict = {},
|
||||
audio_config: AudioConfig = None,
|
||||
user_provider_id: str | None = None,
|
||||
workflow_run=None,
|
||||
resolved_user_config=None,
|
||||
) -> None:
|
||||
"""
|
||||
Run the pipeline with the given transport and configuration
|
||||
|
|
@ -624,16 +719,18 @@ async def _run_pipeline(
|
|||
|
||||
# Configure turn strategies based on STT provider, model, and workflow configuration
|
||||
if is_realtime:
|
||||
uses_external_turns = False
|
||||
# Realtime services still need user-turn tracking even when the model
|
||||
# itself owns speech generation and interruption behavior.
|
||||
user_turn_strategies, user_vad_analyzer = _create_realtime_user_turn_config(
|
||||
user_config.realtime.provider
|
||||
)
|
||||
else:
|
||||
# Deepgram Flux and supported Dograh managed Flux languages emit their
|
||||
# own turn boundaries, so the aggregator follows those external signals.
|
||||
# Other models use configurable turn detection.
|
||||
if stt_uses_flux_turns(user_config):
|
||||
# Some STT services emit their own turn boundaries, so the aggregator
|
||||
# follows those external signals. Other models use configurable turn
|
||||
# detection.
|
||||
uses_external_turns = stt_uses_external_turns(user_config)
|
||||
if uses_external_turns:
|
||||
user_turn_strategies = UserTurnStrategies(
|
||||
start=[
|
||||
VADUserTurnStartStrategy(),
|
||||
|
|
@ -663,9 +760,15 @@ async def _run_pipeline(
|
|||
stop=[SpeechTimeoutUserTurnStopStrategy()],
|
||||
)
|
||||
|
||||
user_turn_stop_timeout = _resolve_user_turn_stop_timeout(
|
||||
run_configs,
|
||||
uses_external_turns=uses_external_turns,
|
||||
)
|
||||
|
||||
user_params = LLMUserAggregatorParams(
|
||||
user_turn_strategies=user_turn_strategies,
|
||||
user_mute_strategies=user_mute_strategies,
|
||||
user_turn_stop_timeout=user_turn_stop_timeout,
|
||||
user_idle_timeout=max_user_idle_timeout,
|
||||
vad_analyzer=user_vad_analyzer,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,12 +21,13 @@ from pipecat.services.aws.llm import AWSBedrockLLMService, AWSBedrockLLMSettings
|
|||
from pipecat.services.azure.llm import AzureLLMService, AzureLLMSettings
|
||||
from pipecat.services.azure.stt import AzureSTTService, AzureSTTSettings
|
||||
from pipecat.services.azure.tts import AzureTTSService, AzureTTSSettings
|
||||
from pipecat.services.cartesia.stt import CartesiaSTTService
|
||||
from pipecat.services.cartesia.stt import CartesiaSTTService, CartesiaSTTSettings
|
||||
from pipecat.services.cartesia.tts import (
|
||||
CartesiaTTSService,
|
||||
CartesiaTTSSettings,
|
||||
GenerationConfig,
|
||||
)
|
||||
from pipecat.services.cartesia.turns.stt import CartesiaTurnsSTTService
|
||||
from pipecat.services.deepgram.flux.stt import (
|
||||
DeepgramFluxSTTService,
|
||||
DeepgramFluxSTTSettings,
|
||||
|
|
@ -106,11 +107,13 @@ def dograh_stt_uses_flux_language(language: str | None) -> bool:
|
|||
return language in DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS
|
||||
|
||||
|
||||
def stt_uses_flux_turns(user_config) -> bool:
|
||||
def stt_uses_external_turns(user_config) -> bool:
|
||||
if user_config.stt.provider == ServiceProviders.DEEPGRAM.value:
|
||||
return user_config.stt.model in DEEPGRAM_FLUX_MODELS
|
||||
if user_config.stt.provider == ServiceProviders.DOGRAH.value:
|
||||
return dograh_stt_uses_flux_language(getattr(user_config.stt, "language", None))
|
||||
if user_config.stt.provider == ServiceProviders.CARTESIA.value:
|
||||
return user_config.stt.model == "ink-2"
|
||||
return False
|
||||
|
||||
|
||||
|
|
@ -215,8 +218,20 @@ def create_stt_service(
|
|||
sample_rate=audio_config.transport_in_sample_rate,
|
||||
)
|
||||
elif user_config.stt.provider == ServiceProviders.CARTESIA.value:
|
||||
if user_config.stt.model == "ink-2":
|
||||
return CartesiaTurnsSTTService(
|
||||
api_key=user_config.stt.api_key,
|
||||
should_interrupt=False, # Let UserAggregator emit interruption frames.
|
||||
sample_rate=audio_config.transport_in_sample_rate,
|
||||
)
|
||||
|
||||
language = getattr(user_config.stt, "language", None) or "en"
|
||||
return CartesiaSTTService(
|
||||
api_key=user_config.stt.api_key,
|
||||
settings=CartesiaSTTSettings(
|
||||
model=user_config.stt.model,
|
||||
language=language,
|
||||
),
|
||||
sample_rate=audio_config.transport_in_sample_rate,
|
||||
)
|
||||
elif user_config.stt.provider == ServiceProviders.DOGRAH.value:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,15 @@ worker. The guarantees that matter for draining: register/unregister are
|
|||
idempotent, and the count only reaches zero when every registered run is gone.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from api.routes import main as main_routes
|
||||
from api.services.pipecat import active_calls
|
||||
from api.services.pipecat import run_pipeline as run_pipeline_module
|
||||
|
||||
|
||||
def setup_function():
|
||||
|
|
@ -14,6 +22,21 @@ def setup_function():
|
|||
active_calls._active_run_ids.clear()
|
||||
|
||||
|
||||
def _make_active_calls_client(
|
||||
monkeypatch,
|
||||
configured_secret: str | None = "test-dograh-devops-secret",
|
||||
) -> TestClient:
|
||||
monkeypatch.setattr("api.constants.DOGRAH_DEVOPS_SECRET", configured_secret)
|
||||
app = FastAPI()
|
||||
app.add_api_route(
|
||||
"/api/v1/health/active-calls",
|
||||
main_routes.active_calls,
|
||||
methods=["GET"],
|
||||
response_model=main_routes.ActiveCallsResponse,
|
||||
)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_starts_empty():
|
||||
assert active_calls.active_call_count() == 0
|
||||
|
||||
|
|
@ -51,3 +74,147 @@ def test_full_lifecycle_drains_to_zero():
|
|||
assert active_calls.active_call_count() == 1
|
||||
active_calls.unregister_active_call(42)
|
||||
assert active_calls.active_call_count() == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_pipeline_counts_call_during_setup(monkeypatch):
|
||||
entered_setup = asyncio.Event()
|
||||
release_setup = asyncio.Event()
|
||||
|
||||
async def fake_get_workflow_run(*args, **kwargs):
|
||||
entered_setup.set()
|
||||
await release_setup.wait()
|
||||
raise RuntimeError("setup failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_pipeline_module.db_client,
|
||||
"get_workflow_run",
|
||||
fake_get_workflow_run,
|
||||
)
|
||||
|
||||
task = asyncio.create_task(
|
||||
run_pipeline_module._run_pipeline(
|
||||
transport=object(),
|
||||
workflow_id=1,
|
||||
workflow_run_id=42,
|
||||
user_id=7,
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.wait_for(entered_setup.wait(), timeout=1.0)
|
||||
assert active_calls.active_call_count() == 1
|
||||
|
||||
release_setup.set()
|
||||
with pytest.raises(RuntimeError, match="setup failed"):
|
||||
await asyncio.wait_for(task, timeout=1.0)
|
||||
assert active_calls.active_call_count() == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webrtc_entrypoint_counts_call_during_setup(monkeypatch):
|
||||
entered_setup = asyncio.Event()
|
||||
release_setup = asyncio.Event()
|
||||
|
||||
async def fake_get_workflow(*args, **kwargs):
|
||||
entered_setup.set()
|
||||
await release_setup.wait()
|
||||
raise RuntimeError("setup failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_pipeline_module.db_client, "get_workflow", fake_get_workflow
|
||||
)
|
||||
|
||||
task = asyncio.create_task(
|
||||
run_pipeline_module.run_pipeline_smallwebrtc(
|
||||
webrtc_connection=object(),
|
||||
workflow_id=1,
|
||||
workflow_run_id=43,
|
||||
user_id=7,
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.wait_for(entered_setup.wait(), timeout=1.0)
|
||||
assert active_calls.active_call_count() == 1
|
||||
|
||||
release_setup.set()
|
||||
with pytest.raises(RuntimeError, match="setup failed"):
|
||||
await asyncio.wait_for(task, timeout=1.0)
|
||||
assert active_calls.active_call_count() == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telephony_entrypoint_counts_call_during_setup(monkeypatch):
|
||||
entered_setup = asyncio.Event()
|
||||
release_setup = asyncio.Event()
|
||||
|
||||
async def fake_get_workflow(*args, **kwargs):
|
||||
entered_setup.set()
|
||||
await release_setup.wait()
|
||||
raise RuntimeError("setup failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_pipeline_module.db_client, "get_workflow", fake_get_workflow
|
||||
)
|
||||
|
||||
task = asyncio.create_task(
|
||||
run_pipeline_module.run_pipeline_telephony(
|
||||
websocket=object(),
|
||||
provider_name="twilio",
|
||||
workflow_id=1,
|
||||
workflow_run_id=44,
|
||||
user_id=7,
|
||||
call_id="call-1",
|
||||
transport_kwargs={},
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.wait_for(entered_setup.wait(), timeout=1.0)
|
||||
assert active_calls.active_call_count() == 1
|
||||
|
||||
release_setup.set()
|
||||
with pytest.raises(RuntimeError, match="setup failed"):
|
||||
await asyncio.wait_for(task, timeout=1.0)
|
||||
assert active_calls.active_call_count() == 0
|
||||
|
||||
|
||||
def test_active_calls_route_requires_configured_secret(monkeypatch):
|
||||
client = _make_active_calls_client(monkeypatch, configured_secret=None)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/health/active-calls",
|
||||
headers={"X-Dograh-Devops-Secret": "test-dograh-devops-secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
def test_active_calls_route_rejects_missing_secret_header(monkeypatch):
|
||||
client = _make_active_calls_client(monkeypatch)
|
||||
|
||||
response = client.get("/api/v1/health/active-calls")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_active_calls_route_rejects_wrong_secret(monkeypatch):
|
||||
client = _make_active_calls_client(monkeypatch)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/health/active-calls",
|
||||
headers={"X-Dograh-Devops-Secret": "wrong"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_active_calls_route_returns_count_with_secret(monkeypatch):
|
||||
active_calls.register_active_call(42)
|
||||
client = _make_active_calls_client(monkeypatch)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1/health/active-calls",
|
||||
headers={"X-Dograh-Devops-Secret": "test-dograh-devops-secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"active_calls": 1}
|
||||
|
|
|
|||
94
api/tests/test_cartesia_stt_service_factory.py
Normal file
94
api/tests/test_cartesia_stt_service_factory.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from api.services.configuration.options import (
|
||||
CARTESIA_INK_2_STT_LANGUAGES,
|
||||
CARTESIA_INK_WHISPER_STT_LANGUAGES,
|
||||
CARTESIA_STT_MODELS,
|
||||
)
|
||||
from api.services.configuration.registry import (
|
||||
CartesiaSTTConfiguration,
|
||||
ServiceProviders,
|
||||
)
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.service_factory import (
|
||||
create_stt_service,
|
||||
stt_uses_external_turns,
|
||||
)
|
||||
|
||||
|
||||
def _audio_config() -> AudioConfig:
|
||||
return AudioConfig(
|
||||
transport_in_sample_rate=16000,
|
||||
transport_out_sample_rate=16000,
|
||||
)
|
||||
|
||||
|
||||
def _cartesia_config(model: str, language: str = "en") -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
stt=SimpleNamespace(
|
||||
provider=ServiceProviders.CARTESIA.value,
|
||||
api_key="test-key",
|
||||
model=model,
|
||||
language=language,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_cartesia_stt_configuration_exposes_ink_2_and_ink_whisper_languages():
|
||||
config = CartesiaSTTConfiguration(api_key="test-key")
|
||||
language_schema = CartesiaSTTConfiguration.model_json_schema()["properties"][
|
||||
"language"
|
||||
]
|
||||
|
||||
assert config.provider == ServiceProviders.CARTESIA
|
||||
assert config.model == "ink-whisper"
|
||||
assert config.language == "en"
|
||||
assert CARTESIA_STT_MODELS == ["ink-2", "ink-whisper"]
|
||||
assert CARTESIA_INK_2_STT_LANGUAGES == ("en",)
|
||||
assert "es" in CARTESIA_INK_WHISPER_STT_LANGUAGES
|
||||
assert language_schema["model_options"]["ink-2"] == ["en"]
|
||||
assert "es" in language_schema["model_options"]["ink-whisper"]
|
||||
|
||||
|
||||
def test_cartesia_ink_2_uses_external_turns_and_turns_service():
|
||||
user_config = _cartesia_config("ink-2")
|
||||
|
||||
assert stt_uses_external_turns(user_config)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.services.pipecat.service_factory.CartesiaTurnsSTTService"
|
||||
) as turns_service,
|
||||
patch("api.services.pipecat.service_factory.CartesiaSTTService") as stt_service,
|
||||
):
|
||||
create_stt_service(user_config, _audio_config())
|
||||
|
||||
turns_service.assert_called_once()
|
||||
stt_service.assert_not_called()
|
||||
kwargs = turns_service.call_args.kwargs
|
||||
assert kwargs["api_key"] == "test-key"
|
||||
assert kwargs["sample_rate"] == 16000
|
||||
assert kwargs["should_interrupt"] is False
|
||||
|
||||
|
||||
def test_cartesia_ink_whisper_uses_manual_stt_service_with_model_and_language():
|
||||
user_config = _cartesia_config("ink-whisper", language="es")
|
||||
|
||||
assert not stt_uses_external_turns(user_config)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.services.pipecat.service_factory.CartesiaTurnsSTTService"
|
||||
) as turns_service,
|
||||
patch("api.services.pipecat.service_factory.CartesiaSTTService") as stt_service,
|
||||
):
|
||||
create_stt_service(user_config, _audio_config())
|
||||
|
||||
turns_service.assert_not_called()
|
||||
stt_service.assert_called_once()
|
||||
kwargs = stt_service.call_args.kwargs
|
||||
assert kwargs["api_key"] == "test-key"
|
||||
assert kwargs["sample_rate"] == 16000
|
||||
assert kwargs["settings"].model == "ink-whisper"
|
||||
assert kwargs["settings"].language == "es"
|
||||
|
|
@ -9,7 +9,7 @@ from api.services.pipecat.audio_config import AudioConfig
|
|||
from api.services.pipecat.service_factory import (
|
||||
create_stt_service,
|
||||
dograh_stt_uses_flux_language,
|
||||
stt_uses_flux_turns,
|
||||
stt_uses_external_turns,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -38,10 +38,10 @@ def test_dograh_flux_language_predicate_matches_multilingual_support():
|
|||
assert not dograh_stt_uses_flux_language("ar")
|
||||
|
||||
|
||||
def test_stt_uses_flux_turns_only_for_dograh_flux_supported_languages():
|
||||
assert stt_uses_flux_turns(_dograh_config("multi"))
|
||||
assert stt_uses_flux_turns(_dograh_config("es"))
|
||||
assert not stt_uses_flux_turns(_dograh_config("ar"))
|
||||
def test_stt_uses_external_turns_only_for_dograh_flux_supported_languages():
|
||||
assert stt_uses_external_turns(_dograh_config("multi"))
|
||||
assert stt_uses_external_turns(_dograh_config("es"))
|
||||
assert not stt_uses_external_turns(_dograh_config("ar"))
|
||||
|
||||
|
||||
def test_create_dograh_multi_uses_flux_service_without_language_hint():
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@ from pipecat.turns.user_stop import (
|
|||
)
|
||||
|
||||
from api.services.configuration.registry import ServiceProviders
|
||||
from api.services.pipecat.run_pipeline import _create_realtime_user_turn_config
|
||||
from api.services.pipecat.run_pipeline import (
|
||||
DEFAULT_USER_TURN_STOP_TIMEOUT,
|
||||
EXTERNAL_TURN_USER_STOP_TIMEOUT,
|
||||
_create_realtime_user_turn_config,
|
||||
_resolve_user_turn_stop_timeout,
|
||||
)
|
||||
|
||||
|
||||
def test_gemini_realtime_uses_local_vad_without_local_interruptions():
|
||||
|
|
@ -72,3 +77,27 @@ def test_unknown_realtime_providers_keep_local_vad():
|
|||
assert isinstance(strategies.start[0], VADUserTurnStartStrategy)
|
||||
assert len(strategies.stop) == 1
|
||||
assert isinstance(strategies.stop[0], SpeechTimeoutUserTurnStopStrategy)
|
||||
|
||||
|
||||
def test_external_turn_stt_uses_longer_stop_timeout():
|
||||
assert (
|
||||
_resolve_user_turn_stop_timeout({}, uses_external_turns=True)
|
||||
== EXTERNAL_TURN_USER_STOP_TIMEOUT
|
||||
)
|
||||
|
||||
|
||||
def test_standard_stt_keeps_default_stop_timeout():
|
||||
assert (
|
||||
_resolve_user_turn_stop_timeout({}, uses_external_turns=False)
|
||||
== DEFAULT_USER_TURN_STOP_TIMEOUT
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_config_can_override_user_turn_stop_timeout():
|
||||
assert (
|
||||
_resolve_user_turn_stop_timeout(
|
||||
{"user_turn_stop_timeout": "12.5"},
|
||||
uses_external_turns=True,
|
||||
)
|
||||
== 12.5
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
2
pipecat
2
pipecat
|
|
@ -1 +1 @@
|
|||
Subproject commit d0ed1ca7ead5935cf3622e668f3d424f273bf086
|
||||
Subproject commit 5a4fa626cfba4f4509f7744b13bb83adae0992fe
|
||||
|
|
@ -29,6 +29,7 @@ NGINX_UPSTREAM_CONF="/etc/nginx/conf.d/dograh_upstream.conf"
|
|||
|
||||
HEALTH_CHECK_ENDPOINT="/api/v1/health"
|
||||
ACTIVE_CALLS_ENDPOINT="/api/v1/health/active-calls"
|
||||
DOGRAH_DEVOPS_SECRET_HEADER="X-Dograh-Devops-Secret"
|
||||
|
||||
# Load environment
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
|
|
@ -57,6 +58,15 @@ log_info() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] INFO: $*"; }
|
|||
log_warn() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARN: $*"; }
|
||||
log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2; }
|
||||
|
||||
if [[ -z "${DOGRAH_DEVOPS_SECRET:-}" ]]; then
|
||||
log_error "DOGRAH_DEVOPS_SECRET is not set. Add it to $ENV_FILE before running rolling_update.sh."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$DOGRAH_DEVOPS_SECRET" == "change-me-dograh-devops-secret" ]]; then
|
||||
log_error "DOGRAH_DEVOPS_SECRET still has the example placeholder value. Replace it in $ENV_FILE."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Band port calculation: band A = base, band B = base + 100
|
||||
band_base_port() {
|
||||
local band=$1
|
||||
|
|
@ -100,17 +110,38 @@ kill_process_tree() {
|
|||
}
|
||||
|
||||
# Active in-progress call count for a single worker, via its health endpoint.
|
||||
# A worker that is unreachable (already exited) or returns no/garbage body
|
||||
# reports 0, so it never blocks the drain.
|
||||
# A worker that is unreachable (already exited) reports 0, so it never blocks the
|
||||
# drain. Non-200 responses or malformed bodies are hard failures: otherwise an
|
||||
# auth/configuration error could be mistaken for a fully drained worker.
|
||||
count_active_calls_on_port() {
|
||||
local port=$1
|
||||
local body n
|
||||
body=$(curl -s --max-time 3 \
|
||||
local response http_code body n
|
||||
response=$(curl -sS --max-time 3 \
|
||||
-H "${DOGRAH_DEVOPS_SECRET_HEADER}: ${DOGRAH_DEVOPS_SECRET}" \
|
||||
-w $'\n%{http_code}' \
|
||||
"http://127.0.0.1:${port}${ACTIVE_CALLS_ENDPOINT}" 2>/dev/null || true)
|
||||
http_code="${response##*$'\n'}"
|
||||
body="${response%$'\n'*}"
|
||||
|
||||
if [[ "$http_code" == "000" ]]; then
|
||||
printf '0'
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$http_code" != "200" ]]; then
|
||||
log_error "uvicorn_${port} active-calls endpoint returned HTTP ${http_code}. Check DOGRAH_DEVOPS_SECRET in $ENV_FILE."
|
||||
return 1
|
||||
fi
|
||||
|
||||
n=$(printf '%s' "$body" \
|
||||
| grep -o '"active_calls"[[:space:]]*:[[:space:]]*[0-9]\+' \
|
||||
| grep -o '[0-9]\+$' || true)
|
||||
printf '%s' "${n:-0}"
|
||||
if [[ -z "$n" ]]; then
|
||||
log_error "uvicorn_${port} active-calls endpoint returned an invalid response body."
|
||||
return 1
|
||||
fi
|
||||
|
||||
printf '%s' "$n"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
|
|
@ -399,7 +430,10 @@ while true; do
|
|||
# Only poll workers still alive; an exited worker holds no calls.
|
||||
pidfile="$RUN_DIR/uvicorn_${port}.pid"
|
||||
if [[ -f "$pidfile" ]] && kill -0 "$(<"$pidfile")" 2>/dev/null; then
|
||||
active=$((active + $(count_active_calls_on_port "$port")))
|
||||
if ! call_count=$(count_active_calls_on_port "$port"); then
|
||||
exit 1
|
||||
fi
|
||||
active=$((active + call_count))
|
||||
fi
|
||||
done
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,15 @@ if [[ -f "$ENV_FILE" ]]; then
|
|||
set -a && . "$ENV_FILE" && set +a
|
||||
fi
|
||||
|
||||
if [[ -z "${DOGRAH_DEVOPS_SECRET:-}" ]]; then
|
||||
echo "ERROR: DOGRAH_DEVOPS_SECRET is not set. Add it to $ENV_FILE before starting production services."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$DOGRAH_DEVOPS_SECRET" == "change-me-dograh-devops-secret" ]]; then
|
||||
echo "ERROR: DOGRAH_DEVOPS_SECRET still has the example placeholder value. Replace it in $ENV_FILE."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
UVICORN_BASE_PORT=${UVICORN_BASE_PORT:-8000}
|
||||
CPU_CORES=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1)
|
||||
FASTAPI_WORKERS=${FASTAPI_WORKERS:-$CPU_CORES}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -176,6 +176,16 @@ export type AwsBedrockLlmConfiguration = {
|
|||
aws_region?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* ActiveCallsResponse
|
||||
*/
|
||||
export type ActiveCallsResponse = {
|
||||
/**
|
||||
* Active Calls
|
||||
*/
|
||||
active_calls: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* AmbientNoiseUploadRequest
|
||||
*/
|
||||
|
|
@ -1042,6 +1052,12 @@ export type CartesiaSttConfiguration = {
|
|||
* Cartesia STT model.
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
* Language
|
||||
*
|
||||
* ISO 639-1 language code. ink-2 currently supports English only.
|
||||
*/
|
||||
language?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -13838,3 +13854,38 @@ export type HealthApiV1HealthGetResponses = {
|
|||
};
|
||||
|
||||
export type HealthApiV1HealthGetResponse = HealthApiV1HealthGetResponses[keyof HealthApiV1HealthGetResponses];
|
||||
|
||||
export type ActiveCallsApiV1HealthActiveCallsGetData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* X-Dograh-Devops-Secret
|
||||
*/
|
||||
'X-Dograh-Devops-Secret'?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/v1/health/active-calls';
|
||||
};
|
||||
|
||||
export type ActiveCallsApiV1HealthActiveCallsGetErrors = {
|
||||
/**
|
||||
* Not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type ActiveCallsApiV1HealthActiveCallsGetError = ActiveCallsApiV1HealthActiveCallsGetErrors[keyof ActiveCallsApiV1HealthActiveCallsGetErrors];
|
||||
|
||||
export type ActiveCallsApiV1HealthActiveCallsGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: ActiveCallsResponse;
|
||||
};
|
||||
|
||||
export type ActiveCallsApiV1HealthActiveCallsGetResponse = ActiveCallsApiV1HealthActiveCallsGetResponses[keyof ActiveCallsApiV1HealthActiveCallsGetResponses];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue