Merge branch 'main' into chores/embed-cal-&-chatwoot-missing-error

This commit is contained in:
PK 2026-06-29 10:15:16 -04:00 committed by GitHub
commit a318359d1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
119 changed files with 4481 additions and 699 deletions

101
.conductor/README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

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

View file

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

View file

@ -1,5 +1,43 @@
# Changelog
## 1.39.0 (2026-06-27)
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Features
* feat(scripts): free trusted HTTPS via sslip.io for public-IP remote i… by @a6kme in https://github.com/dograh-hq/dograh/pull/460
### Bug Fixes
* fix: reject misrouted smallwebrtc runs on the telephony websocket by @mvanhorn in https://github.com/dograh-hq/dograh/pull/468
**Full Changelog**: https://github.com/dograh-hq/dograh/compare/dograh-v1.38.0...dograh-v1.39.0
## 1.38.0 (2026-06-25)
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Features
* feat(scripts): generate REDIS_PASSWORD on setup, plumb through compose by @tecnomanu in https://github.com/dograh-hq/dograh/pull/458
* feat(storage): support custom S3 endpoint, signature version, and addressing style by @skymoore in https://github.com/dograh-hq/dograh/pull/461
* feat(twilio): add Answering Machine Detection (AMD) support via telephony config by @nuthalapativarun in https://github.com/dograh-hq/dograh/pull/443
### Bug Fixes
* fix: support Gemini JSON schema tools by @snvtac in https://github.com/dograh-hq/dograh/pull/463
### Documentation
* docs: update Tuner integration to use Dograh provider by @mohamedsalem-bot in https://github.com/dograh-hq/dograh/pull/457
### Other Changes
* style(docs): add custom green scrollbar by @Gurkirat-Singh-bit in https://github.com/dograh-hq/dograh/pull/434
* Add Hostinger (managed-Traefik) deployment files by @a6kme in https://github.com/dograh-hq/dograh/pull/459
## New Contributors
* @Gurkirat-Singh-bit made their first contribution in https://github.com/dograh-hq/dograh/pull/434
* @tecnomanu made their first contribution in https://github.com/dograh-hq/dograh/pull/458
* @skymoore made their first contribution in https://github.com/dograh-hq/dograh/pull/461
* @snvtac made their first contribution in https://github.com/dograh-hq/dograh/pull/463
**Full Changelog**: https://github.com/dograh-hq/dograh/compare/dograh-v1.37.0...dograh-v1.38.0
## 1.37.0 (2026-06-19)
<!-- Release notes generated using configuration in .github/release.yml at main -->

View file

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

View file

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

View file

@ -59,56 +59,53 @@ RUN npm ci --omit=dev && npm cache clean --force
# Stage 3: Static ffmpeg binary (avoids apt ffmpeg pulling mesa/libllvm for
# hardware acceleration we don't use server-side).
#
# Resilient download: johnvansickle.com is the primary source but it's a single
# self-hosted host with no CDN and goes down intermittently. Use bounded-timeout
# retries, then fall back to a pinned BtbN/FFmpeg-Builds autobuild. Every archive
# is SHA256-verified before extraction. The two sources have different internal
# layouts, so locate the binaries with `find` rather than a fixed strip path.
# Source: BtbN/FFmpeg-Builds, served from GitHub's release-assets CDN (fast,
# highly available, multi-arch). We pin a specific build for reproducibility,
# but to a *month-end* autobuild tag — not a daily one. BtbN prunes daily
# autobuilds after ~2 weeks (the previous pin was a daily tag and started
# 404ing once GC'd), but keeps one month-end snapshot per month long-term
# (~2 years back). A dated tag's assets are immutable, so the per-arch sha256
# below never rots: builds stay reproducible AND integrity-verified.
#
# To upgrade ffmpeg: bump BTBN_TAG + BTBN_REV to a newer month-end autobuild
# and refresh the two sha256s. No download needed — read tag, revision and
# per-asset sha256 straight from the GitHub release-asset metadata:
# gh api repos/BtbN/FFmpeg-Builds/releases/tags/<tag> \
# --jq '.assets[] | select(.name|test("(linux64|linuxarm64)-gpl\\.tar\\.xz$")) | "\(.name) \(.digest)"'
#
# `--speed-limit/--speed-time` aborts a *stalled* transfer after 30s of <1KB/s
# (the cause of "stuck" builds) without killing a slow-but-progressing
# download; `--max-time` is a hard backstop; `--retry` rides out transient CDN
# hiccups. The archive nests binaries under bin/, so locate them with `find`.
FROM debian:trixie-slim AS ffmpeg-static
ARG TARGETARCH
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates xz-utils \
&& rm -rf /var/lib/apt/lists/* \
&& case "${TARGETARCH}" in \
amd64) \
primary_url="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" ; \
primary_sha256="abda8d77ce8309141f83ab8edf0596834087c52467f6badf376a6a2a4c87cf67" ; \
fallback_url="https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-30-13-19/ffmpeg-N-124681-gb8c5376eb4-linux64-gpl.tar.xz" ; \
fallback_sha256="6cfd689ee95ff128e89080af10c93f16e48760eb2acc124c5c8258dc922cc13b" ; \
;; \
arm64) \
primary_url="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz" ; \
primary_sha256="f4149bb2b0784e30e99bdda85471c9b5930d3402014e934a5098b41d0f7201b1" ; \
fallback_url="https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-30-13-19/ffmpeg-N-124681-gb8c5376eb4-linuxarm64-gpl.tar.xz" ; \
fallback_sha256="b90a31f1d0b030f5d8a3d11cfec736e369bd5a1371b19bf65421a07f72b1d547" ; \
;; \
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \
esac \
&& mkdir -p /tmp/ffmpeg \
&& ok= \
&& for source in \
"primary ${primary_sha256} ${primary_url}" \
"fallback ${fallback_sha256} ${fallback_url}" ; do \
source_name="${source%% *}" ; \
source_data="${source#* }" ; \
sha256="${source_data%% *}" ; \
url="${source_data#* }" ; \
echo "Downloading ffmpeg (${source_name}) from ${url}" ; \
if curl -fsSL --connect-timeout 20 --max-time 300 \
--retry 3 --retry-delay 5 --retry-all-errors \
-o /tmp/ffmpeg.tar.xz "${url}" \
&& echo "${sha256} /tmp/ffmpeg.tar.xz" | sha256sum -c - ; then ok=1 ; break ; fi ; \
rm -f /tmp/ffmpeg.tar.xz ; \
echo "ffmpeg source failed, trying next: ${url}" >&2 ; \
done \
&& [ -n "${ok}" ] || { echo "all ffmpeg download sources failed" >&2 ; exit 1 ; } \
&& tar -xJf /tmp/ffmpeg.tar.xz -C /tmp/ffmpeg \
&& ffmpeg_bin="$(find /tmp/ffmpeg -type f -name ffmpeg | head -n1)" \
&& ffprobe_bin="$(find /tmp/ffmpeg -type f -name ffprobe | head -n1)" \
&& [ -n "${ffmpeg_bin}" ] && [ -n "${ffprobe_bin}" ] \
&& mv "${ffmpeg_bin}" "${ffprobe_bin}" /usr/local/bin/ \
&& chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe \
&& rm -rf /tmp/ffmpeg /tmp/ffmpeg.tar.xz
ARG BTBN_TAG=autobuild-2026-05-31-13-22
ARG BTBN_REV=N-124714-g49a77d37be
RUN set -eu ; \
apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates xz-utils ; \
rm -rf /var/lib/apt/lists/* ; \
case "${TARGETARCH}" in \
amd64) btbn_arch=linux64 ; \
sha256=ee052121296e6479325e09c6097d48e72a4af472d18c2b94388b5405dcde6cce ;; \
arm64) btbn_arch=linuxarm64 ; \
sha256=e97545305043794cdf7b698d713e29291464e0c35bb8e0f3ff1f62e4c56eedd6 ;; \
*) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2 ; exit 1 ;; \
esac ; \
url="https://github.com/BtbN/FFmpeg-Builds/releases/download/${BTBN_TAG}/ffmpeg-${BTBN_REV}-${btbn_arch}-gpl.tar.xz" ; \
mkdir -p /tmp/ffmpeg ; cd /tmp/ffmpeg ; \
echo "Downloading ffmpeg (${BTBN_TAG}) from ${url}" ; \
curl -fsSL --connect-timeout 20 --speed-limit 1024 --speed-time 30 \
--max-time 600 --retry 3 --retry-delay 5 --retry-all-errors \
-o ffmpeg.tar.xz "${url}" ; \
echo "${sha256} ffmpeg.tar.xz" | sha256sum -c - ; \
tar -xJf ffmpeg.tar.xz ; \
ffmpeg_bin="$(find /tmp/ffmpeg -type f -name ffmpeg | head -n1)" ; \
ffprobe_bin="$(find /tmp/ffmpeg -type f -name ffprobe | head -n1)" ; \
[ -n "${ffmpeg_bin}" ] && [ -n "${ffprobe_bin}" ] ; \
mv "${ffmpeg_bin}" "${ffprobe_bin}" /usr/local/bin/ ; \
chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe ; \
rm -rf /tmp/ffmpeg
# Stage 4: Runtime - Minimal image with only runtime dependencies
FROM python:3.13-slim AS runner

View file

@ -19,7 +19,23 @@ LANGFUSE_PUBLIC_KEY = os.getenv("LANGFUSE_PUBLIC_KEY")
LANGFUSE_SECRET_KEY = os.getenv("LANGFUSE_SECRET_KEY")
# URLs for deployment
BACKEND_API_ENDPOINT = os.getenv("BACKEND_API_ENDPOINT", "http://localhost:8000")
#
# PUBLIC_BASE_URL is the single canonical origin a deployment is reached at
# (scheme + host, e.g. https://203-0-113-10.sslip.io). For a standard single-host
# install it is the only endpoint value an operator sets — the per-subsystem URLs
# below derive from it (and from PUBLIC_HOST for the TURN/ICE host). Each derived
# var can still be set explicitly to override it for a split deployment.
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL") or None
PUBLIC_HOST = os.getenv("PUBLIC_HOST") or None
# Public URL the backend builds webhook/callback/embed links from. Derives from
# PUBLIC_BASE_URL (public IP / domain), falling back to localhost for local dev.
# When this is a non-public address (localhost or a private/reserved IP) the host
# isn't reachable from the internet, so get_backend_endpoints() resolves a running
# Cloudflare tunnel's URL at runtime instead (see api/utils/common.py).
BACKEND_API_ENDPOINT = (
os.getenv("BACKEND_API_ENDPOINT") or PUBLIC_BASE_URL or "http://localhost:8000"
)
UI_APP_URL = os.getenv("UI_APP_URL", "http://localhost:3010")
DATABASE_URL = os.environ["DATABASE_URL"]
@ -38,13 +54,19 @@ 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"
# MinIO Configuration
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000")
MINIO_PUBLIC_ENDPOINT = os.getenv("MINIO_PUBLIC_ENDPOINT")
# Full URL (scheme + host) browsers use to reach object storage. Derives from
# PUBLIC_BASE_URL (remote nginx proxies /voice-audio/ to MinIO); set explicitly
# only to point object storage at a separate origin.
MINIO_PUBLIC_ENDPOINT = (
os.getenv("MINIO_PUBLIC_ENDPOINT") or PUBLIC_BASE_URL or "http://localhost:9000"
)
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin")
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin")
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "voice-audio")
@ -84,7 +106,7 @@ LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG").upper()
LOG_ROTATION_SIZE = os.getenv("LOG_ROTATION_SIZE", "100 MB")
LOG_RETENTION = os.getenv("LOG_RETENTION", "7 days")
LOG_COMPRESSION = os.getenv("LOG_COMPRESSION", "gz")
ENABLE_TELEMETRY = os.getenv("ENABLE_TELEMETRY", "false").lower() == "true"
ENABLE_TELEMETRY = os.getenv("ENABLE_TELEMETRY", "true").lower() == "true"
def _get_version() -> str:
@ -149,7 +171,9 @@ DEFAULT_CIRCUIT_BREAKER_CONFIG = {
TURN_SECRET = os.getenv("TURN_SECRET")
TURN_HOST = os.getenv("TURN_HOST", "localhost")
# Host browsers dial for TURN/ICE. Derives from PUBLIC_HOST; set explicitly only
# when the TURN server runs on a separate host from the app.
TURN_HOST = os.getenv("TURN_HOST") or PUBLIC_HOST or "localhost"
TURN_PORT = int(os.getenv("TURN_PORT", "3478"))
TURN_TLS_PORT = int(os.getenv("TURN_TLS_PORT", "5349"))
TURN_CREDENTIAL_TTL = int(os.getenv("TURN_CREDENTIAL_TTL", "86400"))

View file

@ -5,7 +5,7 @@ from pathlib import Path
from typing import List, Optional
from loguru import logger
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.orm import selectinload
from api.db.base_client import BaseDBClient
@ -300,6 +300,31 @@ class KnowledgeBaseClient(BaseDBClient):
logger.info(f"Created {len(chunks)} chunks")
return chunks
async def replace_chunks_for_document(
self,
document_id: int,
organization_id: int,
chunks: List[KnowledgeBaseChunkModel],
) -> List[KnowledgeBaseChunkModel]:
"""Replace all chunks for a document with a new precomputed batch."""
async with self.async_session() as session:
await session.execute(
delete(KnowledgeBaseChunkModel).where(
KnowledgeBaseChunkModel.document_id == document_id,
KnowledgeBaseChunkModel.organization_id == organization_id,
)
)
session.add_all(chunks)
await session.commit()
for chunk in chunks:
await session.refresh(chunk)
logger.info(
f"Replaced chunks for document {document_id}: {len(chunks)} chunks"
)
return chunks
async def get_chunks_for_document(
self,
document_id: int,

View file

@ -103,6 +103,30 @@ class TelephonyConfigurationClient(BaseDBClient):
)
return int(result.scalar() or 0)
async def count_vonage_configs_missing_signature_secret(
self, organization_id: int
) -> int:
"""Count Vonage configs in this org with no signature_secret."""
async with self.async_session() as session:
result = await session.execute(
select(func.count(TelephonyConfigurationModel.id)).where(
TelephonyConfigurationModel.organization_id == organization_id,
TelephonyConfigurationModel.provider == "vonage",
(
TelephonyConfigurationModel.credentials.op("->>")(
"signature_secret"
).is_(None)
)
| (
TelephonyConfigurationModel.credentials.op("->>")(
"signature_secret"
)
== ""
),
)
)
return int(result.scalar() or 0)
async def list_all_telephony_configurations_by_provider(
self, provider: str
) -> List[TelephonyConfigurationModel]:

View file

@ -93,12 +93,17 @@ class WorkflowRunClient(BaseDBClient):
else workflow.template_context_variables
)
merged_initial_context = {
**(default_context or {}),
**(initial_context or {}),
}
new_run = WorkflowRunModel(
name=name,
workflow=workflow,
mode=mode,
definition_id=target_def.id if target_def else None,
initial_context=initial_context or default_context,
initial_context=merged_initial_context,
gathered_context=gathered_context or {},
logs=logs or {},
campaign_id=campaign_id,

View file

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

View file

@ -373,11 +373,7 @@ async def search_chunks(
apply_managed_embeddings_base_url,
get_resolved_ai_model_configuration,
)
from api.services.configuration.registry import ServiceProviders
from api.services.gen_ai import (
AzureOpenAIEmbeddingService,
OpenAIEmbeddingService,
)
from api.services.gen_ai import build_embedding_service
# Try to get user's embeddings configuration
resolved_config = await get_resolved_ai_model_configuration(
@ -405,22 +401,20 @@ async def search_chunks(
effective_config.embeddings, "api_version", None
)
# Initialize embedding service based on provider
if embeddings_provider == ServiceProviders.AZURE.value and embeddings_endpoint:
embedding_service = AzureOpenAIEmbeddingService(
db_client=db_client,
api_key=embeddings_api_key,
endpoint=embeddings_endpoint,
model_id=embeddings_model or "text-embedding-3-small",
api_version=embeddings_api_version or "2024-02-15-preview",
)
else:
embedding_service = OpenAIEmbeddingService(
db_client=db_client,
api_key=embeddings_api_key,
model_id=embeddings_model or "text-embedding-3-small",
base_url=embeddings_base_url,
)
# Manual search runs outside any workflow run, so resolve the MPS
# correlation id here (mint only for orgs already on v2; never create one).
embedding_service = await build_embedding_service(
db_client=db_client,
provider=embeddings_provider,
api_key=embeddings_api_key,
model=embeddings_model,
base_url=embeddings_base_url,
endpoint=embeddings_endpoint,
api_version=embeddings_api_version,
organization_id=user.selected_organization_id,
created_by=str(user.provider_id),
resolve_correlation=True,
)
# Perform search
results = await embedding_service.search_similar_chunks(

View file

@ -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
@ -68,6 +71,10 @@ class HealthResponse(BaseModel):
status: str
version: str
backend_api_endpoint: str
# Public URL the deployment is reachable at when it sits behind a Cloudflare
# tunnel (the host has no public IP). null for a directly-reachable deployment.
# The UI shows this so operators know the URL telephony providers should call.
tunnel_url: str | None = None
deployment_mode: str
auth_provider: str
turn_enabled: bool
@ -84,21 +91,34 @@ async def health() -> HealthResponse:
from api.constants import (
APP_VERSION,
AUTH_PROVIDER,
BACKEND_API_ENDPOINT,
DEPLOYMENT_MODE,
FORCE_TURN_RELAY,
STACK_AUTH_PROJECT_ID,
STACK_PUBLISHABLE_CLIENT_KEY,
TURN_SECRET,
)
from api.utils.common import get_backend_endpoints
from api.utils.common import get_backend_endpoints, is_local_or_private_url
logger.debug("Health endpoint called")
backend_endpoint, _ = await get_backend_endpoints()
# tunnel_url is set only when a Cloudflare tunnel was actually resolved: the
# configured address isn't publicly reachable, but get_backend_endpoints found
# a public tunnel URL for it. This is the URL the UI shows for inbound webhooks.
# It stays null for a directly-reachable (public IP / domain) deployment, where
# backend_api_endpoint itself is the public URL.
tunnel_url = (
backend_endpoint
if is_local_or_private_url(BACKEND_API_ENDPOINT)
and not is_local_or_private_url(backend_endpoint)
else None
)
is_stack = AUTH_PROVIDER == "stack"
return HealthResponse(
status="ok",
version=APP_VERSION,
backend_api_endpoint=backend_endpoint,
backend_api_endpoint=BACKEND_API_ENDPOINT,
tunnel_url=tunnel_url,
deployment_mode=DEPLOYMENT_MODE,
auth_provider=AUTH_PROVIDER,
turn_enabled=bool(TURN_SECRET),
@ -108,3 +128,51 @@ async def health() -> HealthResponse:
STACK_PUBLISHABLE_CLIENT_KEY if is_stack else None
),
)
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(
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
sending SIGTERM, because uvicorn force-closes live call WebSockets (close
code 1012) on SIGTERM and would cut calls mid-conversation otherwise. The
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())

View file

@ -145,6 +145,7 @@ class TelephonyConfigWarningsResponse(BaseModel):
"""
telnyx_missing_webhook_public_key_count: int
vonage_missing_signature_secret_count: int
@router.get("/context", response_model=OrganizationContextResponse)
@ -200,8 +201,7 @@ async def get_telephony_providers_metadata(user: UserModel = Depends(get_user)):
async def get_telephony_config_warnings(user: UserModel = Depends(get_user)):
"""Return aggregated warning counts for the current org's telephony configs.
Today this surfaces only Telnyx configs missing ``webhook_public_key``;
additional warning types should be added as new fields on the response.
Surfaces provider configs missing webhook-verification credentials.
"""
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
@ -209,8 +209,12 @@ async def get_telephony_config_warnings(user: UserModel = Depends(get_user)):
telnyx_missing = await db_client.count_telnyx_configs_missing_webhook_public_key(
user.selected_organization_id
)
vonage_missing = await db_client.count_vonage_configs_missing_signature_secret(
user.selected_organization_id
)
return TelephonyConfigWarningsResponse(
telnyx_missing_webhook_public_key_count=telnyx_missing,
vonage_missing_signature_secret_count=vonage_missing,
)

View file

@ -309,7 +309,10 @@ async def initialize_embed_session(
workflow_id=embed_token.workflow_id,
mode=WorkflowRunMode.SMALLWEBRTC.value,
user_id=embed_token.created_by, # Use token creator as run owner
initial_context=init_request.context_variables,
initial_context={
**(init_request.context_variables or {}),
"provider": WorkflowRunMode.SMALLWEBRTC.value,
},
)
except Exception as e:
logger.error(f"Failed to create workflow run: {e}")

View file

@ -21,7 +21,7 @@ from starlette.websockets import WebSocketDisconnect
from api.db import db_client
from api.db.models import UserModel
from api.enums import CallType, WorkflowRunState
from api.enums import CallType, WorkflowRunMode, WorkflowRunState
from api.errors.telephony_errors import TelephonyError
from api.sdk_expose import sdk_expose
from api.services.auth.depends import get_user
@ -584,12 +584,36 @@ async def _handle_telephony_websocket(
provider_type = workflow_run.initial_context.get("provider")
logger.info(f"Extracted provider_type: {provider_type}")
if (
workflow_run.mode == WorkflowRunMode.SMALLWEBRTC.value
or provider_type == WorkflowRunMode.SMALLWEBRTC.value
):
logger.warning(
f"SmallWebRTC workflow run {workflow_run_id} reached telephony "
f"websocket; mode={workflow_run.mode}, provider={provider_type}"
)
await websocket.close(
code=4400,
reason=(
"smallwebrtc runs connect through the WebRTC signaling endpoint, "
"not the telephony websocket"
),
)
return
if not provider_type:
logger.error(
f"No provider type found in workflow run {workflow_run_id}. "
f"gathered_context: {workflow_run.gathered_context}, mode: {workflow_run.mode}"
)
await websocket.close(code=4400, reason="Provider type not found")
await websocket.close(
code=4400,
reason=(
f"No provider type found for workflow run {workflow_run_id} "
f"(mode: {workflow_run.mode}); telephony websocket requires "
"a telephony provider"
),
)
return
logger.info(
@ -660,7 +684,7 @@ async def handle_inbound_run(request: Request):
logger.error("Unable to detect provider for /inbound/run webhook")
return generic_hangup_response()
normalized_data = normalize_webhook_data(provider_class, webhook_data)
normalized_data = normalize_webhook_data(provider_class, webhook_data, headers)
logger.info(
f"/inbound/run normalized data — provider={normalized_data.provider} "
f"to={normalized_data.to_number} from={normalized_data.from_number}"
@ -847,7 +871,7 @@ async def handle_inbound_telephony(
logger.error("Unable to detect provider for webhook")
return generic_hangup_response()
normalized_data = normalize_webhook_data(provider_class, webhook_data)
normalized_data = normalize_webhook_data(provider_class, webhook_data, headers)
logger.info(f"Inbound call - Provider: {normalized_data.provider}")
logger.info(f"Normalized data: {normalized_data}")

View file

@ -176,7 +176,7 @@ def _compile_dograh_configuration(
embeddings=DograhEmbeddingsConfiguration(
provider=ServiceProviders.DOGRAH,
api_key=configuration.api_key,
model="default",
model="dograh_embedding_v1",
),
is_realtime=False,
managed_service_version=2,

View file

@ -457,6 +457,11 @@ async def create_user_configuration_with_mps_key(
"api_key": [service_key],
"model": "default",
},
"embeddings": {
"provider": ServiceProviders.DOGRAH.value,
"api_key": [service_key],
"model": "dograh_embedding_v1",
},
}
effective_config = EffectiveAIModelConfiguration(**configuration)
return effective_config

View file

@ -316,6 +316,7 @@ def convert_legacy_ai_model_configuration_to_v2(
def dograh_embeddings_base_url() -> str:
# AsyncOpenAI appends "/embeddings"; MPS exposes that under /api/v1/llm.
return f"{MPS_API_URL}/api/v1/llm"

View file

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

View 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

View file

@ -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"]
@ -1726,7 +1738,7 @@ class AzureOpenAIEmbeddingsConfiguration(BaseEmbeddingsConfiguration):
)
DOGRAH_EMBEDDING_MODELS = ["default"]
DOGRAH_EMBEDDING_MODELS = ["dograh_embedding_v1"]
@register_embeddings
@ -1734,7 +1746,7 @@ class DograhEmbeddingsConfiguration(BaseEmbeddingsConfiguration):
model_config = DOGRAH_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.DOGRAH] = ServiceProviders.DOGRAH
model: str = Field(
default="default",
default="dograh_embedding_v1",
description="Dograh-managed embedding model.",
json_schema_extra={"examples": DOGRAH_EMBEDDING_MODELS},
)

View file

@ -4,8 +4,11 @@ from .embedding import (
AzureEmbeddingAPIKeyNotConfiguredError,
AzureOpenAIEmbeddingService,
BaseEmbeddingService,
DograhEmbeddingService,
EmbeddingAPIKeyNotConfiguredError,
OpenAIEmbeddingService,
build_embedding_service,
resolve_embedding_correlation_id,
)
from .json_parser import parse_llm_json
@ -13,7 +16,10 @@ __all__ = [
"AzureEmbeddingAPIKeyNotConfiguredError",
"AzureOpenAIEmbeddingService",
"BaseEmbeddingService",
"DograhEmbeddingService",
"EmbeddingAPIKeyNotConfiguredError",
"OpenAIEmbeddingService",
"build_embedding_service",
"resolve_embedding_correlation_id",
"parse_llm_json",
]

View file

@ -5,12 +5,17 @@ from .azure_openai_service import (
AzureOpenAIEmbeddingService,
)
from .base import BaseEmbeddingService
from .dograh_service import DograhEmbeddingService
from .factory import build_embedding_service, resolve_embedding_correlation_id
from .openai_service import EmbeddingAPIKeyNotConfiguredError, OpenAIEmbeddingService
__all__ = [
"AzureEmbeddingAPIKeyNotConfiguredError",
"AzureOpenAIEmbeddingService",
"BaseEmbeddingService",
"DograhEmbeddingService",
"EmbeddingAPIKeyNotConfiguredError",
"OpenAIEmbeddingService",
"build_embedding_service",
"resolve_embedding_correlation_id",
]

View file

@ -0,0 +1,69 @@
"""Dograh-managed embedding service.
Routes embeddings through Dograh's managed proxy (MPS). This mirrors the managed
voice services (``DograhLLMService`` / ``DograhTTSService``): when a server-minted
MPS correlation id is present, it forwards the MPS billing v2 protocol
(``correlation_id`` + ``mps_billing_version``) in the request body so MPS can
authorize and attribute the call. With no correlation id (e.g. a v1 org) it
behaves like a plain OpenAI-compatible call, which MPS accepts.
Keeping this in a subclass keeps ``OpenAIEmbeddingService`` a generic
OpenAI-compatible client; only the managed path carries MPS-specific metadata,
so BYOK OpenAI/Azure requests never ship MPS fields to the real provider.
"""
from typing import Any, Dict, Optional
from api.db.db_client import DBClient
from .openai_service import DEFAULT_MODEL_ID, OpenAIEmbeddingService
# Protocol contract with MPS (see model_services
# api/services/model_service_correlations.py). Kept local to avoid coupling the
# app layer to the pipecat package, which defines its own copy for voice.
MPS_BILLING_VERSION_KEY = "mps_billing_version"
MPS_BILLING_VERSION_V2 = "2"
class DograhEmbeddingService(OpenAIEmbeddingService):
"""OpenAI-compatible embedding client pointed at Dograh's managed proxy."""
def __init__(
self,
db_client: DBClient,
api_key: Optional[str] = None,
model_id: str = DEFAULT_MODEL_ID,
base_url: Optional[str] = None,
correlation_id: Optional[str] = None,
):
"""Initialize the managed embedding service.
Args:
db_client: Database client for vector similarity search.
api_key: Dograh-managed MPS service key.
model_id: Embedding model/tier id (default: text-embedding-3-small).
base_url: MPS embeddings base URL.
correlation_id: Server-minted MPS correlation id. When set, the MPS
billing v2 protocol is forwarded with each request. When None,
requests are sent without the protocol (valid for v1 orgs).
"""
super().__init__(
db_client=db_client,
api_key=api_key,
model_id=model_id,
base_url=base_url,
)
self._correlation_id = correlation_id
def _request_kwargs(self) -> Dict[str, Any]:
"""Forward the MPS billing v2 protocol when a correlation id is present."""
if not self._correlation_id:
return {}
return {
"extra_body": {
"metadata": {
"correlation_id": self._correlation_id,
MPS_BILLING_VERSION_KEY: MPS_BILLING_VERSION_V2,
}
}
}

View file

@ -0,0 +1,137 @@
"""Factory for embedding services, including the Dograh-managed (MPS) path.
Centralizes the provider branching (Azure BYOK / Dograh-managed / OpenAI-compatible
BYOK) that was previously duplicated across document ingestion, the search route,
and the RAG tool, and resolves the MPS billing v2 protocol the same way the voice
path does: attach it only for orgs already on v2, and never create a billing
account to do so.
"""
from typing import Optional
from loguru import logger
from api.db.db_client import DBClient
from .azure_openai_service import AzureOpenAIEmbeddingService
from .base import BaseEmbeddingService
from .dograh_service import DograhEmbeddingService
from .openai_service import OpenAIEmbeddingService
DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"
DEFAULT_AZURE_API_VERSION = "2024-02-15-preview"
async def resolve_embedding_correlation_id(
*,
organization_id: Optional[int],
service_key: Optional[str],
created_by: Optional[str] = None,
) -> Optional[str]:
"""Resolve an MPS correlation id for a managed embedding call made outside a run.
Mirrors the voice path's gating:
- OSS deployments use a pasted hosted v2 key (v2 by definition), so mint
directly via the bearer endpoint matching ``_authorize_oss_managed_v2_correlation``.
- Hosted/SaaS: read the org's billing mode (no side effects) and mint only when
it is already v2. Minting for an already-v2 org is a no-op on the account.
Returns ``None`` when the call should be sent without the protocol; MPS accepts
un-gated embedding calls from v1 orgs. Never creates a v2 billing account.
"""
if not service_key:
return None
# Imported lazily to avoid import-time cycles between the gen_ai and service
# layers (matches the inline-import convention used elsewhere in the app).
from api.constants import DEPLOYMENT_MODE
from api.services.mps_service_key_client import mps_service_key_client
try:
if DEPLOYMENT_MODE == "oss":
minted = await mps_service_key_client.create_correlation_id(
service_key=service_key
)
return minted.get("correlation_id")
if organization_id is None:
return None
status = await mps_service_key_client.get_billing_account_status(
organization_id, created_by=created_by
)
if not status or status.get("billing_mode") != "v2":
return None
minted = await mps_service_key_client.create_correlation_id(
service_key=service_key
)
return minted.get("correlation_id")
except Exception as e:
logger.warning(
"Could not resolve MPS correlation id for managed embeddings; "
"sending without v2 protocol: {}",
e,
)
return None
async def build_embedding_service(
*,
db_client: DBClient,
provider: Optional[str],
api_key: Optional[str],
model: Optional[str],
base_url: Optional[str] = None,
endpoint: Optional[str] = None,
api_version: Optional[str] = None,
correlation_id: Optional[str] = None,
organization_id: Optional[int] = None,
created_by: Optional[str] = None,
resolve_correlation: bool = False,
) -> BaseEmbeddingService:
"""Construct the right embedding service for a provider/config.
Args:
correlation_id: A correlation id already available in context (e.g. the
running workflow's MPS correlation id). Used for the Dograh provider.
resolve_correlation: When True and no ``correlation_id`` is supplied, resolve
one for the Dograh provider via ``resolve_embedding_correlation_id``
(for calls made outside a workflow run: ingestion, manual search).
"""
from api.services.configuration.registry import ServiceProviders
model_id = model or DEFAULT_EMBEDDING_MODEL
if provider == ServiceProviders.AZURE.value and endpoint:
return AzureOpenAIEmbeddingService(
db_client=db_client,
api_key=api_key,
endpoint=endpoint,
model_id=model_id,
api_version=api_version or DEFAULT_AZURE_API_VERSION,
)
if provider == ServiceProviders.DOGRAH.value:
cid = correlation_id
if cid is None and resolve_correlation:
cid = await resolve_embedding_correlation_id(
organization_id=organization_id,
service_key=api_key,
created_by=created_by,
)
return DograhEmbeddingService(
db_client=db_client,
api_key=api_key,
model_id=model_id,
base_url=base_url,
correlation_id=cid,
)
return OpenAIEmbeddingService(
db_client=db_client,
api_key=api_key,
model_id=model_id,
base_url=base_url,
)

View file

@ -85,6 +85,14 @@ class OpenAIEmbeddingService(BaseEmbeddingService):
if not self._api_key_configured or self.client is None:
raise EmbeddingAPIKeyNotConfiguredError()
def _request_kwargs(self) -> Dict[str, Any]:
"""Extra kwargs merged into every embeddings.create() call.
Override hook for subclasses (e.g. DograhEmbeddingService injects the MPS
billing protocol here). The base service adds nothing.
"""
return {}
async def embed_texts(self, texts: List[str]) -> List[List[float]]:
"""Embed a batch of texts using OpenAI API.
@ -97,6 +105,7 @@ class OpenAIEmbeddingService(BaseEmbeddingService):
response = await self.client.embeddings.create(
input=texts,
model=self.model_id,
**self._request_kwargs(),
)
return [item.embedding for item in response.data]
except Exception as e:

View file

@ -0,0 +1,35 @@
"""In-process registry of active pipeline runs (live voice calls).
Each uvicorn worker tracks the calls it is currently running so a deploy
orchestrator can *drain* the worker before stopping it: poll the count, wait for
zero, then send SIGTERM. Sending SIGTERM while calls are live makes uvicorn
force-close their WebSockets (close code 1012), which cuts the calls instead of
letting them finish so the wait has to happen first.
The registry is deliberately per-process. That is exactly the unit that gets
drained: one uvicorn process per VM port (see ``scripts/rolling_update.sh``) or
one uvicorn process per Kubernetes pod (drained via a ``preStop`` hook). The
count is exposed read-only at ``GET /api/v1/health/active-calls`` and is also a
natural autoscaling signal (concurrent calls per worker).
Access is single-threaded (asyncio event loop), so no lock is needed. A set of
run ids rather than a bare counter keeps register/unregister idempotent and
makes the in-flight runs inspectable for debugging.
"""
_active_run_ids: set[int] = set()
def register_active_call(workflow_run_id: int) -> None:
"""Mark a pipeline run as active in this worker."""
_active_run_ids.add(workflow_run_id)
def unregister_active_call(workflow_run_id: int) -> None:
"""Mark a pipeline run as finished in this worker."""
_active_run_ids.discard(workflow_run_id)
def active_call_count() -> int:
"""Number of pipeline runs currently active in this worker."""
return len(_active_run_ids)

View file

@ -11,6 +11,7 @@ from typing import Any
from loguru import logger
from api.services.pipecat.realtime.static_greeting import format_static_greeting_prompt
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
@ -49,6 +50,7 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
self._handled_initial_context: bool = False
self._bot_is_speaking: bool = False
self._deferred_function_calls: list[FunctionCallFromLLM] = []
self._pending_initial_greeting_text: str | None = None
async def process_frame(self, frame: Frame, direction: FrameDirection):
if isinstance(frame, UserMuteStartedFrame):
@ -61,7 +63,11 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
return
if isinstance(frame, TTSSpeakFrame):
if not self._handled_initial_context:
await self._handle_context(self._context)
greeting_text = frame.text.strip() if frame.text else ""
if greeting_text:
await self._handle_initial_greeting(self._context, greeting_text)
else:
await self._handle_context(self._context)
else:
logger.warning(
f"{self}: TTSSpeakFrame after initial context already handled — "
@ -118,6 +124,57 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
self._context = context
await self._process_completed_function_calls(send_new_results=True)
async def _handle_initial_greeting(self, context: LLMContext, greeting_text: str):
if context is None:
logger.warning(
f"{self}: received initial greeting trigger before context was set"
)
return
self._handled_initial_context = True
self._context = context
await self._create_initial_greeting_response(greeting_text)
async def _create_initial_greeting_response(self, greeting_text: str):
if self._disconnecting:
return
if not self._api_session_ready:
self._pending_initial_greeting_text = greeting_text
self._run_llm_when_api_session_ready = True
return
self._pending_initial_greeting_text = None
await self._ensure_conversation_setup()
await self._send_manual_response_create(
instructions=format_static_greeting_prompt(greeting_text),
tool_choice="none",
)
async def _ensure_conversation_setup(self):
if not self._llm_needs_conversation_setup:
return
adapter = self.get_llm_adapter()
llm_invocation_params = adapter.get_llm_invocation_params(self._context)
for item in llm_invocation_params["messages"]:
evt = events.ConversationItemCreateEvent(item=item)
self._messages_added_manually[evt.item.id] = True
await self.send_client_event(evt)
await self._send_session_update()
self._llm_needs_conversation_setup = False
async def _handle_evt_session_updated(self, evt):
self._api_session_ready = True
if self._pending_initial_greeting_text is not None:
greeting_text = self._pending_initial_greeting_text
self._run_llm_when_api_session_ready = False
await self._create_initial_greeting_response(greeting_text)
elif self._run_llm_when_api_session_ready:
self._run_llm_when_api_session_ready = False
await self._create_response()
async def _send_user_audio(self, frame):
if self._user_is_muted:
return
@ -171,14 +228,21 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
return "\n".join(parts) if parts else None
return None
async def _send_manual_response_create(self):
async def _send_manual_response_create(
self,
*,
instructions: str | None = None,
tool_choice: str | None = None,
):
await self.push_frame(LLMFullResponseStartFrame())
await self.start_processing_metrics()
await self.start_ttfb_metrics()
await self.send_client_event(
events.ResponseCreateEvent(
response=events.ResponseProperties(
output_modalities=self._get_enabled_modalities()
output_modalities=self._get_enabled_modalities(),
instructions=instructions,
tool_choice=tool_choice,
)
)
)

View file

@ -20,11 +20,13 @@ Layers Dograh engine integration quirks onto upstream-pristine
from typing import Any
from google.genai.types import Content, Part
from loguru import logger
from api.services.pipecat.gemini_json_schema_adapter import (
DograhGeminiJSONSchemaAdapter,
)
from api.services.pipecat.realtime.static_greeting import format_static_greeting_prompt
from pipecat.frames.frames import (
BotStoppedSpeakingFrame,
Frame,
@ -63,6 +65,9 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
# Function calls emitted by Gemini mid-bot-turn are deferred here and
# invoked when the turn ends, so they don't race the turn's audio.
self._pending_function_calls: list[FunctionCallFromLLM] = []
# Text greeting captured from the first TTSSpeakFrame while the Gemini
# session is still connecting.
self._pending_initial_greeting_text: str | None = None
# ------------------------------------------------------------------
# Hooks from upstream GeminiLiveLLMService
@ -142,10 +147,15 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
if isinstance(frame, TTSSpeakFrame):
# Greeting trigger: the engine queues a TTSSpeakFrame to start the
# bot's first turn after node setup. Gemini Live renders its own
# audio, so we don't pass the frame through — we re-enter
# _handle_context to kick off the initial response.
# audio, so we don't pass the frame through. For configured static
# text greetings, ask Gemini to say the exact greeting; otherwise
# re-enter _handle_context to kick off the normal initial response.
if not self._handled_initial_context:
await self._handle_context(self._context)
greeting_text = frame.text.strip() if frame.text else ""
if greeting_text:
await self._handle_initial_greeting(self._context, greeting_text)
else:
await self._handle_context(self._context)
else:
logger.warning(
f"{self}: TTSSpeakFrame after initial context already "
@ -183,6 +193,49 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
self._context = context
await self._process_completed_function_calls(send_new_results=True)
async def _handle_initial_greeting(self, context: LLMContext, greeting_text: str):
"""Trigger the first Gemini turn with an exact static text greeting."""
if context is None:
logger.warning(
f"{self}: received initial greeting trigger before context was set"
)
return
self._handled_initial_context = True
self._context = context
await self._create_initial_greeting_response(greeting_text)
async def _create_initial_greeting_response(self, greeting_text: str):
"""Ask Gemini Live to speak the configured greeting exactly once."""
if self._disconnecting:
return
if not self._session:
self._pending_initial_greeting_text = greeting_text
self._run_llm_when_session_ready = True
return
self._pending_initial_greeting_text = None
prompt = format_static_greeting_prompt(greeting_text)
turn = Content(role="user", parts=[Part(text=prompt)])
logger.debug("Creating Gemini Live initial response from static greeting")
await self.start_ttfb_metrics()
try:
await self._session.send_client_content(
turns=[turn],
turn_complete=True,
)
# Gemini 3.x also needs a realtime-input nudge to begin inference.
if self._is_gemini_3:
await self._session.send_realtime_input(text=" ")
except Exception as e:
await self._handle_send_error(e)
self._ready_for_realtime_input = True
# ------------------------------------------------------------------
# Session lifecycle: drop upstream's automatic reconnect-seed and
# initial-context-seed paths. The TTSSpeakFrame trigger and the
@ -201,7 +254,12 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
# Context arrived before session was ready — fulfil the queued
# initial response now.
self._run_llm_when_session_ready = False
await self._create_initial_response()
if self._pending_initial_greeting_text is not None:
await self._create_initial_greeting_response(
self._pending_initial_greeting_text
)
else:
await self._create_initial_response()
await self._drain_pending_tool_results()
# Otherwise: no automatic seed. Reconnect after a session-resumption
# update relies on the server-side restored state; reconnects without

View file

@ -22,6 +22,7 @@ from typing import Any
from loguru import logger
from api.services.pipecat.realtime.static_greeting import format_static_greeting_prompt
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
@ -50,6 +51,7 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
self._handled_initial_context: bool = False
self._bot_is_speaking: bool = False
self._deferred_function_calls: list[FunctionCallFromLLM] = []
self._pending_initial_greeting_text: str | None = None
async def process_frame(self, frame: Frame, direction: FrameDirection):
if isinstance(frame, UserMuteStartedFrame):
@ -62,7 +64,11 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
return
if isinstance(frame, TTSSpeakFrame):
if not self._handled_initial_context:
await self._handle_context(self._context)
greeting_text = frame.text.strip() if frame.text else ""
if greeting_text:
await self._handle_initial_greeting(self._context, greeting_text)
else:
await self._handle_context(self._context)
else:
logger.warning(
f"{self}: TTSSpeakFrame after initial context already "
@ -120,6 +126,67 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
self._context = context
await self._process_completed_function_calls(send_new_results=True)
async def _handle_initial_greeting(self, context: LLMContext, greeting_text: str):
if context is None:
logger.warning(
f"{self}: received initial greeting trigger before context was set"
)
return
self._handled_initial_context = True
self._context = context
await self._create_initial_greeting_response(greeting_text)
async def _create_initial_greeting_response(self, greeting_text: str):
if self._disconnecting:
return
if not self._api_session_ready:
self._pending_initial_greeting_text = greeting_text
self._run_llm_when_api_session_ready = True
return
self._pending_initial_greeting_text = None
await self._ensure_conversation_setup()
item = events.ConversationItem(
type="message",
role="user",
content=[
events.ItemContent(
type="input_text",
text=format_static_greeting_prompt(greeting_text),
)
],
)
evt = events.ConversationItemCreateEvent(item=item)
self._messages_added_manually[evt.item.id] = True
await self.send_client_event(evt)
await self._send_manual_response_create()
async def _ensure_conversation_setup(self):
if not self._llm_needs_conversation_setup:
return
adapter = self.get_llm_adapter()
llm_invocation_params = adapter.get_llm_invocation_params(self._context)
for item in llm_invocation_params["messages"]:
evt = events.ConversationItemCreateEvent(item=item)
self._messages_added_manually[evt.item.id] = True
await self.send_client_event(evt)
await self._send_session_update()
self._llm_needs_conversation_setup = False
async def _handle_evt_session_updated(self, evt):
self._api_session_ready = True
if self._pending_initial_greeting_text is not None:
greeting_text = self._pending_initial_greeting_text
self._run_llm_when_api_session_ready = False
await self._create_initial_greeting_response(greeting_text)
elif self._run_llm_when_api_session_ready:
self._run_llm_when_api_session_ready = False
await self._create_response()
async def _send_user_audio(self, frame):
if self._user_is_muted:
return

View file

@ -22,6 +22,7 @@ from typing import Any
from loguru import logger
from api.services.pipecat.realtime.static_greeting import format_static_greeting_prompt
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
@ -56,6 +57,7 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
# has finished speaking, matching Dograh's Gemini Live behavior.
self._bot_is_speaking: bool = False
self._deferred_function_calls: list[FunctionCallFromLLM] = []
self._pending_initial_greeting_text: str | None = None
# ------------------------------------------------------------------
# Frame handling: mute, TTSSpeakFrame as greeting trigger
@ -73,11 +75,16 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
if isinstance(frame, TTSSpeakFrame):
# Greeting trigger: the engine queues a TTSSpeakFrame after node
# setup. OpenAI Realtime renders its own audio, so we don't pass
# the frame to TTS. Route through _handle_context so the initial
# response and later tool-result turns share the same context
# lifecycle even when Dograh has already pre-populated self._context.
# the frame to TTS. For configured static text greetings, ask the
# model to say the exact greeting; otherwise route through
# _handle_context so the initial response and later tool-result
# turns share the same context lifecycle.
if not self._handled_initial_context:
await self._handle_context(self._context)
greeting_text = frame.text.strip() if frame.text else ""
if greeting_text:
await self._handle_initial_greeting(self._context, greeting_text)
else:
await self._handle_context(self._context)
else:
logger.warning(
f"{self}: TTSSpeakFrame after initial context already "
@ -137,6 +144,57 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
self._context = context
await self._process_completed_function_calls(send_new_results=True)
async def _handle_initial_greeting(self, context: LLMContext, greeting_text: str):
if context is None:
logger.warning(
f"{self}: received initial greeting trigger before context was set"
)
return
self._handled_initial_context = True
self._context = context
await self._create_initial_greeting_response(greeting_text)
async def _create_initial_greeting_response(self, greeting_text: str):
if self._disconnecting:
return
if not self._api_session_ready:
self._pending_initial_greeting_text = greeting_text
self._run_llm_when_api_session_ready = True
return
self._pending_initial_greeting_text = None
await self._ensure_conversation_setup()
await self._send_manual_response_create(
instructions=format_static_greeting_prompt(greeting_text),
tool_choice="none",
)
async def _ensure_conversation_setup(self):
if not self._llm_needs_conversation_setup:
return
adapter = self.get_llm_adapter()
llm_invocation_params = adapter.get_llm_invocation_params(self._context)
for item in llm_invocation_params["messages"]:
evt = events.ConversationItemCreateEvent(item=item)
self._messages_added_manually[evt.item.id] = True
await self.send_client_event(evt)
await self._send_session_update()
self._llm_needs_conversation_setup = False
async def _handle_evt_session_updated(self, evt):
self._api_session_ready = True
if self._pending_initial_greeting_text is not None:
greeting_text = self._pending_initial_greeting_text
self._run_llm_when_api_session_ready = False
await self._create_initial_greeting_response(greeting_text)
elif self._run_llm_when_api_session_ready:
self._run_llm_when_api_session_ready = False
await self._create_response()
async def _send_user_audio(self, frame):
if self._user_is_muted:
return
@ -190,7 +248,12 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
return "\n".join(parts) if parts else None
return None
async def _send_manual_response_create(self):
async def _send_manual_response_create(
self,
*,
instructions: str | None = None,
tool_choice: str | None = None,
):
"""Trigger inference after manually appending conversation items."""
await self.push_frame(LLMFullResponseStartFrame())
await self.start_processing_metrics()
@ -198,7 +261,9 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
await self.send_client_event(
events.ResponseCreateEvent(
response=events.ResponseProperties(
output_modalities=self._get_enabled_modalities()
output_modalities=self._get_enabled_modalities(),
instructions=instructions,
tool_choice=tool_choice,
)
)
)

View file

@ -0,0 +1,8 @@
def format_static_greeting_prompt(greeting_text: str) -> str:
return (
"The phone call has just connected. Greet the caller now: "
"say the following opening line out loud, exactly as written, "
"in a natural spoken voice, and then stop and wait for the "
"caller to respond. Do not add anything before or after it.\n\n"
f'"{greeting_text}"'
)

View file

@ -72,7 +72,7 @@ class RealtimeFeedbackObserver(BaseObserver):
- TTFB metrics (LLM generation time only)
Logs buffer persistence (only final data for post-call analysis):
- Complete user transcripts per turn (via on_user_turn_stopped)
- Complete user transcripts per turn (via on_user_turn_message_added)
- Complete assistant transcripts per turn (via on_assistant_turn_stopped)
- Function calls and TTFB metrics
@ -300,13 +300,13 @@ def register_turn_log_handlers(
):
"""Register event handlers on aggregators to persist final turn transcripts.
Hooks into on_user_turn_stopped and on_assistant_turn_stopped to store
Hooks into on_user_turn_message_added and on_assistant_turn_stopped to store
complete turn text in the logs buffer. Works for both WebRTC and telephony
calls independent of WebSocket availability.
"""
@user_aggregator.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(aggregator, strategy, message):
@user_aggregator.event_handler("on_user_turn_message_added")
async def on_user_turn_message_added(aggregator, message):
logs_buffer.increment_turn()
try:
await logs_buffer.append(

View file

@ -11,6 +11,10 @@ from api.services.integrations import (
IntegrationRuntimeContext,
create_runtime_sessions,
)
from api.services.pipecat.active_calls import (
register_active_call,
unregister_active_call,
)
from api.services.pipecat.audio_config import AudioConfig, create_audio_config
from api.services.pipecat.event_handlers import (
register_audio_data_handler,
@ -46,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,
@ -93,52 +97,69 @@ 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."""
def external_provider_turn_config():
return (
UserTurnStrategies(
start=[ExternalUserTurnStartStrategy()],
stop=[ExternalUserTurnStopStrategy(wait_for_transcript=False)],
),
None,
)
def local_vad_turn_config(*, enable_interruptions: bool):
return (
UserTurnStrategies(
start=[
VADUserTurnStartStrategy(enable_interruptions=enable_interruptions)
],
stop=[SpeechTimeoutUserTurnStopStrategy(wait_for_transcript=False)],
),
SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
)
if provider in {
ServiceProviders.GOOGLE_REALTIME.value,
ServiceProviders.GOOGLE_VERTEX_REALTIME.value,
}:
# Let Gemini Live own barge-in via its server-side VAD, but keep local
# Silero VAD for early user-turn start and speaking-state tracking.
return (
UserTurnStrategies(
start=[VADUserTurnStartStrategy(enable_interruptions=False)],
stop=[SpeechTimeoutUserTurnStopStrategy()],
),
SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
)
return local_vad_turn_config(enable_interruptions=False)
if provider == ServiceProviders.OPENAI_REALTIME.value:
# OpenAI Realtime already emits speaking-state frames and interruption
# events from the provider, so the aggregator should follow those
# external signals rather than run its own local VAD.
return (
UserTurnStrategies(
start=[ExternalUserTurnStartStrategy()],
stop=[ExternalUserTurnStopStrategy()],
),
None,
)
if provider in {
ServiceProviders.OPENAI_REALTIME.value,
ServiceProviders.AZURE_REALTIME.value,
}:
# OpenAI-compatible Realtime services already emit speaking-state frames
# and interruption events from the provider, so the aggregator should
# follow those external signals rather than run its own local VAD.
return external_provider_turn_config()
if provider == ServiceProviders.GROK_REALTIME.value:
# Grok Voice Agent emits server-side speech-start/stop and
# interruption signals, so local VAD should stay out of the way.
return (
UserTurnStrategies(
start=[ExternalUserTurnStartStrategy()],
stop=[ExternalUserTurnStopStrategy()],
),
None,
)
return external_provider_turn_config()
if provider == ServiceProviders.ULTRAVOX_REALTIME.value:
# Ultravox does not emit user-turn frames, so local VAD supplies
# lifecycle signals for Dograh observers/controllers.
return local_vad_turn_config(enable_interruptions=True)
return (
UserTurnStrategies(
start=[VADUserTurnStartStrategy()],
stop=[SpeechTimeoutUserTurnStopStrategy()],
),
SileroVADAnalyzer(params=VADParams(stop_secs=0.2)),
)
return local_vad_turn_config(enable_interruptions=True)
async def run_pipeline_telephony(
@ -150,6 +171,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.
@ -223,7 +272,7 @@ async def run_pipeline_telephony(
)
try:
await _run_pipeline(
await _run_pipeline_impl(
transport,
workflow_id,
workflow_run_id,
@ -247,6 +296,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(
@ -296,7 +370,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,
@ -319,6 +393,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
@ -620,16 +723,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(),
@ -661,14 +766,23 @@ 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,
)
context_aggregator = LLMContextAggregatorPair(
context, assistant_params=assistant_params, user_params=user_params
context,
assistant_params=assistant_params,
user_params=user_params,
realtime_service_mode=is_realtime,
)
# Create usage metrics aggregator with engine's callback

View file

@ -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
@ -214,8 +217,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:

View file

@ -56,6 +56,15 @@ class NormalizedInboundData:
raw_data: Dict[str, Any] = field(default_factory=dict) # Original webhook data
@dataclass
class AnsweringMachineDetectionResult:
"""Standardized answering-machine detection result across providers."""
call_id: str
answered_by: str
raw_data: Dict[str, Any] = field(default_factory=dict)
class TelephonyProvider(ABC):
"""
Abstract base class for telephony providers.
@ -192,6 +201,23 @@ class TelephonyProvider(ABC):
"""
pass
def supports_answering_machine_detection(self) -> bool:
"""Return whether this provider can request answering-machine detection."""
return False
def apply_answering_machine_detection_call_params(
self,
data: Dict[str, Any],
) -> Dict[str, Any]:
"""Add provider-specific AMD parameters to an outbound call request."""
return data
def parse_answering_machine_detection_result(
self, data: Dict[str, Any]
) -> Optional[AnsweringMachineDetectionResult]:
"""Parse provider-specific callback data into a normalized AMD result."""
return None
@abstractmethod
async def handle_websocket(
self,

View file

@ -20,6 +20,7 @@ def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
"account_sid": value.get("account_sid"),
"auth_token": value.get("auth_token"),
"from_numbers": value.get("from_numbers", []),
"amd_enabled": value.get("amd_enabled", False),
}
@ -47,6 +48,15 @@ _UI_METADATA = ProviderUIMetadata(
type="string-array",
description="E.164-formatted Twilio phone numbers used for outbound calls",
),
ProviderUIField(
name="amd_enabled",
label="Answering Machine Detection",
type="boolean",
description=(
"Detect whether outbound calls are answered by a person or "
"machine. Twilio may bill AMD as an additional per-call feature."
),
),
],
)

View file

@ -16,6 +16,13 @@ class TwilioConfigurationRequest(BaseModel):
from_numbers: List[str] = Field(
default_factory=list, description="List of Twilio phone numbers"
)
amd_enabled: bool = Field(
default=False,
description=(
"Detect whether outbound calls are answered by a person or machine. "
"Twilio may bill AMD as an additional per-call feature."
),
)
class TwilioConfigurationResponse(BaseModel):
@ -25,3 +32,4 @@ class TwilioConfigurationResponse(BaseModel):
account_sid: str # Masked (e.g., "****************def0")
auth_token: str # Masked (e.g., "****************abc1")
from_numbers: List[str]
amd_enabled: bool = False

View file

@ -13,6 +13,7 @@ from twilio.request_validator import RequestValidator
from api.enums import TelephonyCallStatus, WorkflowRunMode
from api.services.telephony.base import (
AnsweringMachineDetectionResult,
CallInitiationResult,
NormalizedInboundData,
ProviderSyncResult,
@ -47,6 +48,7 @@ class TwilioProvider(TelephonyProvider):
self.account_sid = config.get("account_sid")
self.auth_token = config.get("auth_token")
self.from_numbers = config.get("from_numbers", [])
self.amd_enabled: bool = bool(config.get("amd_enabled", False))
# Handle both single number (string) and multiple numbers (list)
if isinstance(self.from_numbers, str):
@ -96,6 +98,8 @@ class TwilioProvider(TelephonyProvider):
}
)
data = self.apply_answering_machine_detection_call_params(data)
data.update(kwargs)
# Make the API request
@ -241,6 +245,31 @@ class TwilioProvider(TelephonyProvider):
"extra": data, # Include all original data
}
def supports_answering_machine_detection(self) -> bool:
"""Twilio supports AMD through the Voice Calls API."""
return True
def apply_answering_machine_detection_call_params(
self,
data: Dict[str, Any],
) -> Dict[str, Any]:
if self.amd_enabled:
data["MachineDetection"] = "Enable"
return data
def parse_answering_machine_detection_result(
self, data: Dict[str, Any]
) -> Optional[AnsweringMachineDetectionResult]:
answered_by = data.get("AnsweredBy")
if not answered_by:
return None
return AnsweringMachineDetectionResult(
call_id=data.get("CallSid", ""),
answered_by=answered_by,
raw_data=data,
)
async def handle_websocket(
self,
websocket: "WebSocket",

View file

@ -12,6 +12,7 @@ from pipecat.utils.run_context import set_current_run_id
from starlette.responses import HTMLResponse
from api.db import db_client
from api.services.telephony.base import TelephonyProvider
from api.services.telephony.factory import get_telephony_provider_for_run
from api.services.telephony.status_processor import (
StatusCallbackRequest,
@ -21,6 +22,28 @@ from api.services.telephony.status_processor import (
router = APIRouter()
async def _persist_amd_result_if_present(
*,
provider: TelephonyProvider,
workflow_run_id: int,
callback_data: dict,
) -> None:
amd_result = provider.parse_answering_machine_detection_result(callback_data)
if not amd_result:
return
try:
logger.info(
f"[run {workflow_run_id}] AMD result: AnsweredBy={amd_result.answered_by}"
)
await db_client.update_workflow_run(
run_id=workflow_run_id,
gathered_context={"answered_by": amd_result.answered_by},
)
except Exception as exc:
logger.warning(f"[run {workflow_run_id}] Failed to persist AMD result: {exc}")
@router.post("/twiml", include_in_schema=False)
async def handle_twiml_webhook(
workflow_id: int,
@ -49,6 +72,12 @@ async def handle_twiml_webhook(
)
raise HTTPException(status_code=401, detail="Invalid webhook signature")
await _persist_amd_result_if_present(
provider=provider,
workflow_run_id=workflow_run_id,
callback_data=callback_data,
)
response_content = await provider.get_webhook_response(
workflow_id, user_id, workflow_run_id
)
@ -111,6 +140,12 @@ async def handle_twilio_status_callback(
extra=parsed_data.get("extra", {}),
)
await _persist_amd_result_if_present(
provider=provider,
workflow_run_id=workflow_run_id,
callback_data=callback_data,
)
# Process the status update
await _process_status_update(workflow_run_id, status_update)

View file

@ -21,6 +21,7 @@ def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
"private_key": value.get("private_key"),
"api_key": value.get("api_key"),
"api_secret": value.get("api_secret"),
"signature_secret": value.get("signature_secret"),
"from_numbers": value.get("from_numbers", []),
}
@ -49,6 +50,13 @@ _UI_METADATA = ProviderUIMetadata(
type="password",
sensitive=True,
),
ProviderUIField(
name="signature_secret",
label="Signature Secret",
type="password",
sensitive=True,
description="Vonage signature secret for signed webhook verification",
),
ProviderUIField(
name="from_numbers",
label="Phone Numbers",

View file

@ -1,6 +1,6 @@
"""Vonage telephony configuration schemas."""
from typing import List, Literal
from typing import List, Literal, Optional
from pydantic import BaseModel, Field
@ -13,6 +13,10 @@ class VonageConfigurationRequest(BaseModel):
api_secret: str = Field(..., description="Vonage API Secret")
application_id: str = Field(..., description="Vonage Application ID")
private_key: str = Field(..., description="Private key for JWT generation")
signature_secret: Optional[str] = Field(
None,
description="Vonage signature secret used to verify signed webhooks",
)
from_numbers: List[str] = Field(
default_factory=list,
description="List of Vonage phone numbers (without + prefix)",
@ -27,4 +31,5 @@ class VonageConfigurationResponse(BaseModel):
api_key: str # Masked
api_secret: str # Masked
private_key: str # Masked
signature_secret: Optional[str] = None # Masked
from_numbers: List[str]

View file

@ -2,6 +2,7 @@
Vonage (Nexmo) implementation of the TelephonyProvider interface.
"""
import hashlib
import json
import random
import time
@ -44,12 +45,14 @@ class VonageProvider(TelephonyProvider):
- api_secret: Vonage API Secret
- application_id: Vonage Application ID
- private_key: Private key for JWT generation
- signature_secret: Signature secret for signed webhooks
- from_numbers: List of phone numbers to use
"""
self.api_key = config.get("api_key")
self.api_secret = config.get("api_secret")
self.application_id = config.get("application_id")
self.private_key = config.get("private_key")
self.signature_secret = config.get("signature_secret")
self.from_numbers = config.get("from_numbers", [])
# Handle both single number (string) and multiple numbers (list)
@ -186,17 +189,18 @@ class VonageProvider(TelephonyProvider):
Verify Vonage webhook signature for security.
Vonage uses JWT for webhook signatures.
"""
if not self.api_secret:
logger.error("No API secret available for webhook signature verification")
if not self.signature_secret:
logger.error(
"No signature secret available for Vonage webhook verification"
)
return False
try:
# Vonage sends JWT in Authorization header. Verify the JWT signature
decoded = jwt.decode(
jwt.decode(
signature,
self.api_secret,
self.signature_secret,
algorithms=["HS256"],
options={"verify_signature": True},
options={"verify_signature": True, "verify_aud": False},
)
return True
except jwt.InvalidTokenError:
@ -295,9 +299,13 @@ class VonageProvider(TelephonyProvider):
"ringing": TelephonyCallStatus.RINGING,
"answered": TelephonyCallStatus.ANSWERED,
"complete": TelephonyCallStatus.COMPLETED,
"completed": TelephonyCallStatus.COMPLETED,
"disconnected": TelephonyCallStatus.COMPLETED,
"failed": TelephonyCallStatus.FAILED,
"busy": TelephonyCallStatus.BUSY,
"timeout": TelephonyCallStatus.NO_ANSWER,
"unanswered": TelephonyCallStatus.NO_ANSWER,
"cancelled": TelephonyCallStatus.NO_ANSWER,
"rejected": TelephonyCallStatus.BUSY,
}
@ -349,6 +357,8 @@ class VonageProvider(TelephonyProvider):
if workflow_run.gathered_context
else None
)
if not call_uuid and workflow_run.gathered_context:
call_uuid = workflow_run.gathered_context.get("call_id")
if not call_uuid:
logger.error(
@ -400,26 +410,126 @@ class VonageProvider(TelephonyProvider):
"""
Determine if this provider can handle the incoming webhook.
"""
return False
claims = cls._decode_unverified_signed_claims(headers)
if claims.get("api_key") or claims.get("application_id"):
return True
return bool(
webhook_data.get("uuid")
and webhook_data.get("conversation_uuid")
and webhook_data.get("from")
and webhook_data.get("to")
)
@staticmethod
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
def parse_inbound_webhook(
webhook_data: Dict[str, Any], headers: Optional[Dict[str, str]] = None
) -> NormalizedInboundData:
"""
Parse Vonage-specific inbound webhook data into normalized format.
"""
claims = VonageProvider._decode_unverified_signed_claims(headers or {})
direction = webhook_data.get("direction") or "inbound"
status = webhook_data.get("status") or "started"
return NormalizedInboundData(
provider=VonageProvider.PROVIDER_NAME,
call_id=webhook_data.get("uuid", ""),
from_number=webhook_data.get("from", ""),
to_number=webhook_data.get("to", ""),
direction=webhook_data.get("direction", ""),
call_status=webhook_data.get("status", ""),
account_id=webhook_data.get("account_id"),
direction=direction,
call_status=status,
account_id=claims.get("api_key") or webhook_data.get("account_id"),
from_country=None,
to_country=None,
raw_data=webhook_data,
)
@staticmethod
def _header(headers: Dict[str, str], name: str) -> Optional[str]:
for key, value in headers.items():
if key.lower() == name.lower():
return value
return None
@classmethod
def _bearer_token(cls, headers: Dict[str, str]) -> Optional[str]:
auth_header = cls._header(headers, "authorization")
if not auth_header:
return None
parts = auth_header.split(None, 1)
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
return parts[1].strip()
@classmethod
def _decode_unverified_signed_claims(
cls, headers: Dict[str, str]
) -> Dict[str, Any]:
token = cls._bearer_token(headers)
if not token:
return {}
try:
claims = jwt.decode(
token,
options={
"verify_signature": False,
"verify_aud": False,
"verify_exp": False,
},
)
except jwt.InvalidTokenError:
return {}
return claims if isinstance(claims, dict) else {}
def _verify_signed_claims(
self, headers: Dict[str, str], body: str = ""
) -> Optional[Dict[str, Any]]:
token = self._bearer_token(headers)
if not token:
logger.warning("Missing Vonage Authorization bearer token")
return None
if not self.signature_secret:
logger.error("Missing Vonage signature_secret for signed webhook")
return None
try:
claims = jwt.decode(
token,
self.signature_secret,
algorithms=["HS256"],
options={"verify_signature": True, "verify_aud": False},
)
except jwt.InvalidTokenError as exc:
logger.warning(f"Invalid Vonage signed webhook JWT: {exc}")
return None
if claims.get("iss") != "Vonage":
logger.warning("Vonage signed webhook JWT has unexpected issuer")
return None
if self.api_key and claims.get("api_key") != self.api_key:
logger.warning("Vonage signed webhook api_key does not match config")
return None
claim_application_id = claims.get("application_id")
if (
self.application_id
and claim_application_id
and claim_application_id != self.application_id
):
logger.warning("Vonage signed webhook application_id does not match config")
return None
payload_hash = claims.get("payload_hash")
if payload_hash:
actual_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
if actual_hash != payload_hash:
logger.warning("Vonage signed webhook payload hash mismatch")
return None
return claims
@staticmethod
def validate_account_id(config_data: dict, webhook_account_id: str) -> bool:
"""Validate Vonage account_id from webhook matches configuration"""
@ -437,9 +547,10 @@ class VonageProvider(TelephonyProvider):
body: str = "",
) -> bool:
"""
Vonage inbound signature verification - minimalist implementation.
Verify Vonage signed webhook JWT and optional payload hash.
"""
return True
claims = self._verify_signed_claims(headers, body)
return claims is not None
async def configure_inbound(
self, address: str, webhook_url: Optional[str]
@ -486,6 +597,15 @@ class VonageProvider(TelephonyProvider):
),
)
if not self.signature_secret:
return ProviderSyncResult(
ok=False,
message=(
"Vonage signature_secret is required because inbound calls "
"use signed webhook verification"
),
)
app_endpoint = f"{self.base_url}/v2/applications/{self.application_id}"
auth = aiohttp.BasicAuth(self.api_key, self.api_secret)
@ -510,12 +630,18 @@ class VonageProvider(TelephonyProvider):
capabilities = app_data.get("capabilities") or {}
voice = capabilities.get("voice") or {}
webhooks = voice.get("webhooks") or {}
backend_endpoint, _ = await get_backend_endpoints()
webhooks["answer_url"] = {
"address": webhook_url,
"http_method": "POST",
}
webhooks["event_url"] = {
"address": f"{backend_endpoint}/api/v1/telephony/vonage/events",
"http_method": "POST",
}
voice["webhooks"] = webhooks
voice["signed_callbacks"] = True
capabilities["voice"] = voice
update_body = {
@ -561,13 +687,24 @@ class VonageProvider(TelephonyProvider):
"""
Generate NCCO response for inbound Vonage webhook.
"""
# Minimalist NCCO response for interface compliance
ncco_response = [
{
"action": "talk",
"text": "Vonage inbound calls are not currently supported.",
},
{"action": "hangup"},
"action": "connect",
"eventUrl": [
f"{backend_endpoint}/api/v1/telephony/vonage/events/{workflow_run_id}"
],
"endpoint": [
{
"type": "websocket",
"uri": websocket_url,
"content-type": "audio/l16;rate=16000",
"headers": {
"workflow_run_id": str(workflow_run_id),
"call_uuid": normalized_data.call_id,
},
}
],
}
]
return Response(

View file

@ -7,16 +7,12 @@ provider registry — see ProviderSpec.router.
import json
from typing import Optional
from fastapi import APIRouter, Request
from fastapi import APIRouter, HTTPException, Request
from loguru import logger
from pipecat.utils.run_context import set_current_run_id
from api.db import db_client
from api.services.telephony.factory import get_telephony_provider_for_run
from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
)
router = APIRouter()
@ -45,28 +41,33 @@ async def handle_ncco_webhook(
return json.loads(response_content)
@router.post("/vonage/events/{workflow_run_id}")
async def handle_vonage_events(
request: Request,
workflow_run_id: int,
):
"""Handle Vonage-specific event webhooks.
async def _read_json_body(request: Request) -> tuple[dict, str]:
body_bytes = await request.body()
try:
raw_body = body_bytes.decode("utf-8")
except UnicodeDecodeError as exc:
raise HTTPException(
status_code=400, detail="Webhook body is not valid UTF-8"
) from exc
try:
return json.loads(raw_body or "{}"), raw_body
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail="Webhook body is not JSON") from exc
Vonage sends all call events to a single endpoint.
Events include: started, ringing, answered, complete, failed, etc.
"""
async def _handle_vonage_event_request(request: Request, workflow_run_id: int):
set_current_run_id(workflow_run_id)
# Parse the event data
event_data = await request.json()
logger.info(f"[run {workflow_run_id}] Received Vonage event: {event_data}")
event_data, raw_body = await _read_json_body(request)
logger.info(
f"[run {workflow_run_id}] Received Vonage event "
f"uuid={event_data.get('uuid')} status={event_data.get('status')}"
)
# Get workflow run for processing
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
if not workflow_run:
logger.error(f"[run {workflow_run_id}] Workflow run not found")
return {"status": "error", "message": "Workflow run not found"}
# Get workflow and provider
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
if not workflow:
logger.error(f"[run {workflow_run_id}] Workflow not found")
@ -75,11 +76,18 @@ async def handle_vonage_events(
provider = await get_telephony_provider_for_run(
workflow_run, workflow.organization_id
)
signature_valid = await provider.verify_inbound_signature(
str(request.url), event_data, dict(request.headers), raw_body
)
if not signature_valid:
raise HTTPException(status_code=401, detail="Invalid webhook signature")
from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
)
# Parse the event data into generic format
parsed_data = provider.parse_status_callback(event_data)
# Create StatusCallbackRequest from parsed data
status_update = StatusCallbackRequest(
call_id=parsed_data["call_id"],
status=parsed_data["status"],
@ -90,8 +98,35 @@ async def handle_vonage_events(
extra=parsed_data.get("extra", {}),
)
# Process the status update
await _process_status_update(workflow_run_id, status_update)
# Return 204 No Content as expected by Vonage
return {"status": "ok"}
@router.post("/vonage/events/{workflow_run_id}")
async def handle_vonage_events(
request: Request,
workflow_run_id: int,
):
"""Handle Vonage-specific event webhooks.
Vonage sends all call events to a single endpoint.
Events include: started, ringing, answered, complete, failed, etc.
"""
return await _handle_vonage_event_request(request, workflow_run_id)
@router.post("/vonage/events")
async def handle_vonage_events_without_run(request: Request):
"""Handle application-level events by resolving the run from call UUID."""
event_data, _ = await _read_json_body(request)
call_id = event_data.get("uuid")
if call_id:
workflow_run = await db_client.get_workflow_run_by_call_id(call_id)
if workflow_run:
return await _handle_vonage_event_request(request, workflow_run.id)
logger.info(
"Received unmatched Vonage application event "
f"uuid={event_data.get('uuid')} status={event_data.get('status')}"
)
return {"status": "ok"}

View file

@ -40,7 +40,8 @@ class ProviderUIField:
name: str # Must match the Pydantic field name on config_request_cls
label: str
type: str # "text" | "password" | "textarea" | "string-array" | "number"
# "text" | "password" | "textarea" | "string-array" | "number" | "boolean"
type: str
required: bool = True
sensitive: bool = False # If true, mask when displaying stored value
description: Optional[str] = None

View file

@ -250,7 +250,6 @@ class _ToolDocumentRefsMixin(BaseModel):
"description": (
"Text spoken via TTS at the start of the call. Supports "
"{{template_variables}}. Leave empty to skip the greeting. "
"Not supported with realtime (speech-to-speech) models."
),
"display_options": DisplayOptions(show={"greeting_type": ["text"]}),
"placeholder": "Hi {{first_name}}, this is Sarah from Acme.",

View file

@ -13,8 +13,7 @@ from loguru import logger
from opentelemetry import trace
from api.db import db_client
from api.services.configuration.registry import ServiceProviders
from api.services.gen_ai import AzureOpenAIEmbeddingService, OpenAIEmbeddingService
from api.services.gen_ai import build_embedding_service
from api.services.pipecat.tracing_config import ensure_tracing
@ -266,33 +265,19 @@ async def _perform_retrieval(
"Model Configurations > Embedding."
)
if (
embeddings_provider == ServiceProviders.AZURE.value
and embeddings_endpoint
):
embedding_service = AzureOpenAIEmbeddingService(
db_client=db_client,
api_key=embeddings_api_key,
endpoint=embeddings_endpoint,
model_id=embeddings_model or "text-embedding-3-small",
api_version=embeddings_api_version or "2024-02-15-preview",
)
else:
default_headers = None
if (
embeddings_provider == ServiceProviders.DOGRAH.value
and correlation_id
):
default_headers = {
"X-Dograh-Correlation-Id": correlation_id,
}
embedding_service = OpenAIEmbeddingService(
db_client=db_client,
api_key=embeddings_api_key,
model_id=embeddings_model or "text-embedding-3-small",
base_url=embeddings_base_url,
default_headers=default_headers,
)
# Search runs inside a workflow run: reuse the run's MPS correlation
# id (present only for v2 orgs; None otherwise → sent without the
# protocol). The Dograh-managed path forwards it via request metadata.
embedding_service = await build_embedding_service(
db_client=db_client,
provider=embeddings_provider,
api_key=embeddings_api_key,
model=embeddings_model,
base_url=embeddings_base_url,
endpoint=embeddings_endpoint,
api_version=embeddings_api_version,
correlation_id=correlation_id,
)
results = await embedding_service.search_similar_chunks(
query=query,

View file

@ -12,12 +12,28 @@ from loguru import logger
from api.db import db_client
from api.db.models import KnowledgeBaseChunkModel
from api.services.configuration.registry import ServiceProviders
from api.services.gen_ai import AzureOpenAIEmbeddingService, OpenAIEmbeddingService
from api.services.gen_ai import build_embedding_service
from api.services.mps_service_key_client import mps_service_key_client
from api.services.storage import storage_fs
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
EMBEDDING_BATCH_SIZE = 64
async def _embed_texts_in_batches(
embedding_service,
texts: list[str],
batch_size: int = EMBEDDING_BATCH_SIZE,
) -> list[list[float]]:
"""Generate embeddings in bounded batches for provider/MPS stability."""
embeddings: list[list[float]] = []
for start in range(0, len(texts), batch_size):
batch = texts[start : start + batch_size]
logger.info(
f"Generating embedding batch {start // batch_size + 1} ({len(batch)} texts)"
)
embeddings.extend(await embedding_service.embed_texts(batch))
return embeddings
async def process_knowledge_base_document(
@ -121,42 +137,13 @@ async def process_knowledge_base_document(
mime_type=mime_type,
)
logger.info(f"Delegating document processing to MPS (mode={retrieval_mode})")
mps_response = await mps_service_key_client.process_document(
file_path=temp_file_path,
filename=filename,
content_type=mime_type or "application/octet-stream",
retrieval_mode=retrieval_mode,
max_tokens=max_tokens,
organization_id=organization_id,
created_by=created_by_provider_id,
)
docling_metadata = mps_response.get("docling_metadata", {})
if retrieval_mode == "full_document":
full_text = mps_response.get("full_text") or ""
await db_client.update_document_full_text(document_id, full_text)
await db_client.update_document_status(
document_id,
"completed",
total_chunks=0,
docling_metadata=docling_metadata,
)
logger.info(
f"Successfully processed full_document {document_id}. "
f"Text length: {len(full_text)} chars"
)
return
# Chunked mode: fetch user embedding config, embed, and persist chunks.
embeddings_provider = None
embeddings_api_key = None
embeddings_model = None
embeddings_base_url = None
embeddings_endpoint = None
embeddings_api_version = None
if document.created_by:
if retrieval_mode == "chunked" and document.created_by:
from api.services.configuration.ai_model_configuration import (
apply_managed_embeddings_base_url,
get_resolved_ai_model_configuration,
@ -188,6 +175,34 @@ async def process_knowledge_base_document(
f"model={embeddings_model}"
)
logger.info(f"Delegating document processing to MPS (mode={retrieval_mode})")
mps_response = await mps_service_key_client.process_document(
file_path=temp_file_path,
filename=filename,
content_type=mime_type or "application/octet-stream",
retrieval_mode=retrieval_mode,
max_tokens=max_tokens,
organization_id=organization_id,
created_by=created_by_provider_id,
)
docling_metadata = mps_response.get("docling_metadata", {})
if retrieval_mode == "full_document":
full_text = mps_response.get("full_text") or ""
await db_client.update_document_full_text(document_id, full_text)
await db_client.update_document_status(
document_id,
"completed",
total_chunks=0,
docling_metadata=docling_metadata,
)
logger.info(
f"Successfully processed full_document {document_id}. "
f"Text length: {len(full_text)} chars"
)
return
if not embeddings_api_key:
error_message = (
"API key not configured. Please set your API key in "
@ -199,21 +214,20 @@ async def process_knowledge_base_document(
)
return
if embeddings_provider == ServiceProviders.AZURE.value and embeddings_endpoint:
embedding_service = AzureOpenAIEmbeddingService(
db_client=db_client,
api_key=embeddings_api_key,
endpoint=embeddings_endpoint,
model_id=embeddings_model or "text-embedding-3-small",
api_version=embeddings_api_version or "2024-02-15-preview",
)
else:
embedding_service = OpenAIEmbeddingService(
db_client=db_client,
api_key=embeddings_api_key,
model_id=embeddings_model or "text-embedding-3-small",
base_url=embeddings_base_url,
)
# Ingestion runs outside any workflow run, so resolve the MPS correlation
# id here (mint only for orgs already on v2; never create an account).
embedding_service = await build_embedding_service(
db_client=db_client,
provider=embeddings_provider,
api_key=embeddings_api_key,
model=embeddings_model,
base_url=embeddings_base_url,
endpoint=embeddings_endpoint,
api_version=embeddings_api_version,
organization_id=organization_id,
created_by=created_by_provider_id,
resolve_correlation=True,
)
mps_chunks = mps_response.get("chunks", [])
if not mps_chunks:
@ -242,12 +256,21 @@ async def process_knowledge_base_document(
f"Generating embeddings for {len(chunk_texts)} chunks "
f"using {embedding_service.get_model_id()}"
)
embeddings = await embedding_service.embed_texts(chunk_texts)
embeddings = await _embed_texts_in_batches(embedding_service, chunk_texts)
if len(embeddings) != len(chunk_records):
raise ValueError(
"Embedding count mismatch: "
f"expected {len(chunk_records)}, got {len(embeddings)}"
)
for chunk_record, embedding in zip(chunk_records, embeddings):
chunk_record.embedding = embedding
logger.info("Storing chunks in database")
await db_client.create_chunks_batch(chunk_records)
await db_client.replace_chunks_for_document(
document_id=document_id,
organization_id=organization_id,
chunks=chunk_records,
)
await db_client.update_document_status(
document_id,
@ -262,9 +285,8 @@ async def process_knowledge_base_document(
)
except Exception as e:
logger.error(
f"Error processing knowledge base document {document_id}: {e}",
exc_info=True,
logger.exception(
"Error processing knowledge base document {}: {}", document_id, e
)
await db_client.update_document_status(
document_id, "failed", error_message=str(e)

View file

@ -76,6 +76,34 @@ def _signature(
return validator.compute_signature(url, form_data)
def test_twilio_provider_applies_answering_machine_detection_params():
provider = TwilioProvider(
{
"account_sid": "AC123",
"auth_token": "twilio-auth-token",
"from_numbers": ["+15551230002"],
"amd_enabled": True,
}
)
data = provider.apply_answering_machine_detection_call_params({"To": "+1555"})
assert provider.supports_answering_machine_detection() is True
assert data["MachineDetection"] == "Enable"
def test_twilio_provider_parses_answering_machine_detection_result():
provider = _provider()
result = provider.parse_answering_machine_detection_result(
{"CallSid": "CA123", "AnsweredBy": "machine_start"}
)
assert result is not None
assert result.call_id == "CA123"
assert result.answered_by == "machine_start"
@pytest.mark.asyncio
async def test_twiml_route_accepts_valid_signature_with_extra_query_param():
provider = _provider()
@ -251,3 +279,106 @@ async def test_twilio_status_callback_accepts_valid_signature():
assert result == {"status": "success"}
process_status.assert_awaited_once()
@pytest.mark.asyncio
async def test_twilio_status_callback_persists_answering_machine_detection_result():
provider = _provider()
form_data = {
"CallSid": "CA123",
"CallStatus": "completed",
"AnsweredBy": "machine_start",
}
request = _request(
path="/api/v1/telephony/twilio/status-callback/123",
query={},
form_data=form_data,
headers={
"x-twilio-signature": _signature(
provider,
path="/api/v1/telephony/twilio/status-callback/123",
query={},
form_data=form_data,
)
},
)
with (
patch("api.services.telephony.providers.twilio.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch(
"api.services.telephony.providers.twilio.routes._process_status_update",
new_callable=AsyncMock,
),
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(workflow_id=7)
)
db_client.get_workflow_by_id = AsyncMock(
return_value=SimpleNamespace(organization_id=11)
)
db_client.update_workflow_run = AsyncMock()
result = await handle_twilio_status_callback(
workflow_run_id=123, request=request
)
assert result == {"status": "success"}
db_client.update_workflow_run.assert_awaited_once_with(
run_id=123,
gathered_context={"answered_by": "machine_start"},
)
@pytest.mark.asyncio
async def test_twilio_status_callback_continues_when_amd_persistence_fails():
provider = _provider()
form_data = {
"CallSid": "CA123",
"CallStatus": "completed",
"AnsweredBy": "machine_start",
}
request = _request(
path="/api/v1/telephony/twilio/status-callback/123",
query={},
form_data=form_data,
headers={
"x-twilio-signature": _signature(
provider,
path="/api/v1/telephony/twilio/status-callback/123",
query={},
form_data=form_data,
)
},
)
with (
patch("api.services.telephony.providers.twilio.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
patch(
"api.services.telephony.providers.twilio.routes._process_status_update",
new_callable=AsyncMock,
) as process_status,
):
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(workflow_id=7)
)
db_client.get_workflow_by_id = AsyncMock(
return_value=SimpleNamespace(organization_id=11)
)
db_client.update_workflow_run = AsyncMock(side_effect=RuntimeError("db down"))
result = await handle_twilio_status_callback(
workflow_run_id=123, request=request
)
assert result == {"status": "success"}
process_status.assert_awaited_once()

View file

@ -0,0 +1,279 @@
import hashlib
import json
import sys
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import jwt
import pytest
from fastapi import HTTPException
from starlette.requests import Request
from api.services.telephony.providers.vonage.provider import VonageProvider
from api.services.telephony.providers.vonage.routes import handle_vonage_events
SIGNATURE_SECRET = "vonage-signature-secret"
def _body() -> str:
return json.dumps(
{
"from": "15551230001",
"to": "15551230002",
"uuid": "aaaaaaaa-bbbb-cccc-dddd-0123456789ab",
"conversation_uuid": "CON-aaaaaaaa-bbbb-cccc-dddd-0123456789ab",
"status": "answered",
"direction": "inbound",
},
separators=(",", ":"),
)
def _provider(**overrides) -> VonageProvider:
config = {
"api_key": "vonage-api-key",
"api_secret": "vonage-api-secret",
"application_id": "aaaaaaaa-bbbb-cccc-dddd-0123456789ab",
"private_key": "placeholder-private-key",
"signature_secret": SIGNATURE_SECRET,
"from_numbers": ["15551230002"],
}
config.update(overrides)
return VonageProvider(config)
def _signed_headers(
body: str,
*,
signature_secret: str = SIGNATURE_SECRET,
api_key: str = "vonage-api-key",
application_id: str = "aaaaaaaa-bbbb-cccc-dddd-0123456789ab",
) -> dict[str, str]:
token = jwt.encode(
{
"iat": int(time.time()),
"jti": "test-jti",
"iss": "Vonage",
"payload_hash": hashlib.sha256(body.encode("utf-8")).hexdigest(),
"api_key": api_key,
"application_id": application_id,
},
signature_secret,
algorithm="HS256",
)
return {"authorization": f"Bearer {token}"}
def _request(body: str, headers: dict[str, str]) -> Request:
async def receive():
return {
"type": "http.request",
"body": body.encode("utf-8"),
"more_body": False,
}
return Request(
{
"type": "http",
"method": "POST",
"path": "/api/v1/telephony/vonage/events/123",
"headers": [
(name.lower().encode("ascii"), value.encode("ascii"))
for name, value in headers.items()
],
},
receive,
)
@pytest.mark.asyncio
async def test_verify_inbound_signature_accepts_valid_vonage_signed_webhook():
body = _body()
provider = _provider()
result = await provider.verify_inbound_signature(
"https://example.test/api/v1/telephony/inbound/run",
json.loads(body),
_signed_headers(body),
body,
)
assert result is True
@pytest.mark.asyncio
async def test_verify_inbound_signature_rejects_tampered_payload():
body = _body()
provider = _provider()
result = await provider.verify_inbound_signature(
"https://example.test/api/v1/telephony/inbound/run",
json.loads(body),
_signed_headers(body),
body.replace("answered", "completed"),
)
assert result is False
@pytest.mark.asyncio
async def test_verify_inbound_signature_rejects_missing_signature_secret():
body = _body()
provider = _provider(signature_secret=None)
result = await provider.verify_inbound_signature(
"https://example.test/api/v1/telephony/inbound/run",
json.loads(body),
_signed_headers(body),
body,
)
assert result is False
@pytest.mark.asyncio
async def test_verify_inbound_signature_rejects_wrong_api_key_claim():
body = _body()
provider = _provider()
result = await provider.verify_inbound_signature(
"https://example.test/api/v1/telephony/inbound/run",
json.loads(body),
_signed_headers(body, api_key="other-api-key"),
body,
)
assert result is False
def test_parse_inbound_webhook_uses_signed_api_key_claim_for_account_id():
body = _body()
normalized = VonageProvider.parse_inbound_webhook(
json.loads(body), headers=_signed_headers(body)
)
assert normalized.provider == "vonage"
assert normalized.call_id == "aaaaaaaa-bbbb-cccc-dddd-0123456789ab"
assert normalized.account_id == "vonage-api-key"
assert normalized.direction == "inbound"
def test_can_handle_webhook_detects_signed_vonage_answer_payload():
body = _body()
assert VonageProvider.can_handle_webhook(json.loads(body), _signed_headers(body))
@pytest.mark.asyncio
async def test_start_inbound_stream_returns_websocket_ncco():
body = _body()
provider = _provider()
normalized = VonageProvider.parse_inbound_webhook(
json.loads(body), headers=_signed_headers(body)
)
response = await provider.start_inbound_stream(
websocket_url="wss://example.test/api/v1/telephony/ws/1/2/3",
workflow_run_id=123,
normalized_data=normalized,
backend_endpoint="https://example.test",
)
ncco = json.loads(response.body)
assert ncco == [
{
"action": "connect",
"eventUrl": ["https://example.test/api/v1/telephony/vonage/events/123"],
"endpoint": [
{
"type": "websocket",
"uri": "wss://example.test/api/v1/telephony/ws/1/2/3",
"content-type": "audio/l16;rate=16000",
"headers": {
"workflow_run_id": "123",
"call_uuid": "aaaaaaaa-bbbb-cccc-dddd-0123456789ab",
},
}
],
}
]
@pytest.mark.asyncio
async def test_vonage_events_route_verifies_signature_before_status_update():
body = _body()
provider = _provider()
with (
patch("api.services.telephony.providers.vonage.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.vonage.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
):
process_status = AsyncMock()
status_processor = SimpleNamespace(
StatusCallbackRequest=SimpleNamespace,
_process_status_update=process_status,
)
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(workflow_id=7)
)
db_client.get_workflow_by_id = AsyncMock(
return_value=SimpleNamespace(organization_id=11)
)
with patch.dict(
sys.modules,
{"api.services.telephony.status_processor": status_processor},
):
result = await handle_vonage_events(
_request(body, _signed_headers(body)), workflow_run_id=123
)
assert result == {"status": "ok"}
process_status.assert_awaited_once()
@pytest.mark.asyncio
async def test_vonage_events_route_rejects_invalid_signature_with_401():
body = _body()
provider = _provider()
with (
patch("api.services.telephony.providers.vonage.routes.db_client") as db_client,
patch(
"api.services.telephony.providers.vonage.routes.get_telephony_provider_for_run",
new_callable=AsyncMock,
return_value=provider,
),
):
process_status = AsyncMock()
status_processor = SimpleNamespace(
StatusCallbackRequest=SimpleNamespace,
_process_status_update=process_status,
)
db_client.get_workflow_run_by_id = AsyncMock(
return_value=SimpleNamespace(workflow_id=7)
)
db_client.get_workflow_by_id = AsyncMock(
return_value=SimpleNamespace(organization_id=11)
)
with (
patch.dict(
sys.modules,
{"api.services.telephony.status_processor": status_processor},
),
pytest.raises(HTTPException) as exc_info,
):
await handle_vonage_events(
_request(body, _signed_headers(body, signature_secret="wrong")),
workflow_run_id=123,
)
assert exc_info.value.status_code == 401
assert exc_info.value.detail == "Invalid webhook signature"
process_status.assert_not_awaited()

View file

@ -0,0 +1,220 @@
"""Unit tests for the per-worker active-call registry (deploy draining).
The registry backs GET /api/v1/health/active-calls, which scripts/rolling_update.sh
(and a k8s preStop hook) polls to wait for live calls to finish before stopping a
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():
# Module-level state — start each test from an empty registry.
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
def test_register_counts_distinct_runs():
active_calls.register_active_call(1)
active_calls.register_active_call(2)
assert active_calls.active_call_count() == 2
def test_register_is_idempotent():
# Registering the same run twice must not double-count, or the count could
# never drain to zero.
active_calls.register_active_call(1)
active_calls.register_active_call(1)
assert active_calls.active_call_count() == 1
def test_unregister_removes_run():
active_calls.register_active_call(1)
active_calls.register_active_call(2)
active_calls.unregister_active_call(1)
assert active_calls.active_call_count() == 1
def test_unregister_unknown_run_is_a_noop():
# discard() semantics: unregistering a run that was never registered (or was
# already removed) is safe and cannot push the count negative.
active_calls.unregister_active_call(999)
assert active_calls.active_call_count() == 0
def test_full_lifecycle_drains_to_zero():
active_calls.register_active_call(42)
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}

View file

@ -55,7 +55,7 @@ def test_dograh_v2_compiles_to_effective_managed_pipeline_with_embeddings():
assert effective.stt.provider == "dograh"
assert effective.stt.language == "multi"
assert effective.embeddings.provider == "dograh"
assert effective.embeddings.model == "default"
assert effective.embeddings.model == "dograh_embedding_v1"
assert effective.managed_service_version == 2

View file

@ -0,0 +1,88 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from pipecat.frames.frames import TTSSpeakFrame
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.openai.realtime import events
from api.services.pipecat.realtime.azure_realtime import (
DograhAzureRealtimeLLMService,
)
def _make_service() -> DograhAzureRealtimeLLMService:
service = DograhAzureRealtimeLLMService(
api_key="test-key",
base_url="wss://example.test/openai/realtime",
)
service._create_response = AsyncMock()
service._process_completed_function_calls = AsyncMock()
return service
@pytest.mark.asyncio
async def test_tts_greeting_sends_exact_static_greeting_prompt():
service = _make_service()
service._context = LLMContext([{"role": "user", "content": "Existing context"}])
service._api_session_ready = True
service.send_client_event = AsyncMock()
service.push_frame = AsyncMock()
service.start_processing_metrics = AsyncMock()
service.start_ttfb_metrics = AsyncMock()
await service.process_frame(
TTSSpeakFrame("Hi Sam, this is Sarah from Acme.", append_to_context=True),
FrameDirection.DOWNSTREAM,
)
sent_events = [call.args[0] for call in service.send_client_event.await_args_list]
assert isinstance(sent_events[0], events.ConversationItemCreateEvent)
assert sent_events[0].item.role == "user"
assert sent_events[0].item.content[0].text == "Existing context"
assert isinstance(sent_events[1], events.SessionUpdateEvent)
response_event = sent_events[-1]
assert isinstance(response_event, events.ResponseCreateEvent)
assert response_event.response.tool_choice == "none"
prompt = response_event.response.instructions
assert "The phone call has just connected. Greet the caller now:" in prompt
assert prompt.endswith('"Hi Sam, this is Sarah from Acme."')
assert service._llm_needs_conversation_setup is False
service._create_response.assert_not_awaited()
@pytest.mark.asyncio
async def test_tts_greeting_waits_for_session_updated_before_sending_prompt():
service = _make_service()
service._context = LLMContext([{"role": "user", "content": "Existing context"}])
await service.process_frame(
TTSSpeakFrame("Hello from Dograh.", append_to_context=True),
FrameDirection.DOWNSTREAM,
)
assert service._handled_initial_context is True
assert service._run_llm_when_api_session_ready is True
assert service._pending_initial_greeting_text == "Hello from Dograh."
service.send_client_event = AsyncMock()
service.push_frame = AsyncMock()
service.start_processing_metrics = AsyncMock()
service.start_ttfb_metrics = AsyncMock()
await service._handle_evt_session_updated(SimpleNamespace())
sent_events = [call.args[0] for call in service.send_client_event.await_args_list]
assert isinstance(sent_events[0], events.ConversationItemCreateEvent)
assert sent_events[0].item.content[0].text == "Existing context"
assert isinstance(sent_events[1], events.SessionUpdateEvent)
response_event = sent_events[-1]
assert isinstance(response_event, events.ResponseCreateEvent)
assert response_event.response.tool_choice == "none"
prompt = response_event.response.instructions
assert prompt.endswith('"Hello from Dograh."')
assert service._run_llm_when_api_session_ready is False
assert service._pending_initial_greeting_text is None
assert service._llm_needs_conversation_setup is False
service._create_response.assert_not_awaited()

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

View file

@ -0,0 +1,162 @@
"""Tests for the Dograh-managed embedding service and its correlation resolver."""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from api.services.gen_ai.embedding.dograh_service import DograhEmbeddingService
from api.services.gen_ai.embedding.factory import resolve_embedding_correlation_id
def _service_with_fake_client(correlation_id):
service = DograhEmbeddingService(
db_client=None,
api_key="sk-test",
model_id="text-embedding-3-small",
base_url=None,
correlation_id=correlation_id,
)
create = AsyncMock(
return_value=SimpleNamespace(data=[SimpleNamespace(embedding=[0.1, 0.2])])
)
service.client = SimpleNamespace(embeddings=SimpleNamespace(create=create))
return service, create
@pytest.mark.asyncio
async def test_dograh_embedding_forwards_v2_protocol_when_correlation_present():
service, create = _service_with_fake_client("corr-123")
await service.embed_texts(["hello"])
create.assert_awaited_once()
kwargs = create.await_args.kwargs
assert kwargs["input"] == ["hello"]
assert kwargs["model"] == "text-embedding-3-small"
assert kwargs["extra_body"] == {
"metadata": {
"correlation_id": "corr-123",
"mps_billing_version": "2",
}
}
@pytest.mark.asyncio
async def test_dograh_embedding_sends_plain_without_correlation():
service, create = _service_with_fake_client(None)
await service.embed_texts(["hello"])
create.assert_awaited_once()
# No correlation id (e.g. a v1 org) → no MPS metadata; MPS accepts plain calls.
assert "extra_body" not in create.await_args.kwargs
def _fake_mps_client(*, status_return=None, minted="minted"):
return SimpleNamespace(
get_billing_account_status=AsyncMock(return_value=status_return),
create_correlation_id=AsyncMock(return_value={"correlation_id": minted}),
)
@pytest.mark.asyncio
async def test_resolve_correlation_oss_mints_directly(monkeypatch):
fake = _fake_mps_client()
monkeypatch.setattr(
"api.services.mps_service_key_client.mps_service_key_client", fake
)
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "oss")
result = await resolve_embedding_correlation_id(
organization_id=None, service_key="sk-mps"
)
assert result == "minted"
fake.create_correlation_id.assert_awaited_once_with(service_key="sk-mps")
fake.get_billing_account_status.assert_not_awaited()
@pytest.mark.asyncio
async def test_resolve_correlation_hosted_v2_mints(monkeypatch):
fake = _fake_mps_client(status_return={"billing_mode": "v2"})
monkeypatch.setattr(
"api.services.mps_service_key_client.mps_service_key_client", fake
)
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
result = await resolve_embedding_correlation_id(
organization_id=42, service_key="sk-mps", created_by="user-1"
)
assert result == "minted"
fake.get_billing_account_status.assert_awaited_once_with(42, created_by="user-1")
fake.create_correlation_id.assert_awaited_once_with(service_key="sk-mps")
@pytest.mark.asyncio
async def test_resolve_correlation_hosted_v1_returns_none_without_minting(monkeypatch):
fake = _fake_mps_client(status_return={"billing_mode": "v1"})
monkeypatch.setattr(
"api.services.mps_service_key_client.mps_service_key_client", fake
)
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
result = await resolve_embedding_correlation_id(
organization_id=42, service_key="sk-mps"
)
assert result is None
fake.create_correlation_id.assert_not_awaited()
@pytest.mark.asyncio
async def test_resolve_correlation_hosted_no_account_returns_none(monkeypatch):
fake = _fake_mps_client(status_return=None)
monkeypatch.setattr(
"api.services.mps_service_key_client.mps_service_key_client", fake
)
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
result = await resolve_embedding_correlation_id(
organization_id=42, service_key="sk-mps"
)
assert result is None
fake.create_correlation_id.assert_not_awaited()
@pytest.mark.asyncio
async def test_resolve_correlation_no_service_key_returns_none(monkeypatch):
fake = _fake_mps_client(status_return={"billing_mode": "v2"})
monkeypatch.setattr(
"api.services.mps_service_key_client.mps_service_key_client", fake
)
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
result = await resolve_embedding_correlation_id(
organization_id=42, service_key=None
)
assert result is None
fake.get_billing_account_status.assert_not_awaited()
fake.create_correlation_id.assert_not_awaited()
@pytest.mark.asyncio
async def test_resolve_correlation_swallows_errors(monkeypatch):
fake = SimpleNamespace(
get_billing_account_status=AsyncMock(side_effect=RuntimeError("mps down")),
create_correlation_id=AsyncMock(),
)
monkeypatch.setattr(
"api.services.mps_service_key_client.mps_service_key_client", fake
)
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
# A transient MPS failure must not break embeddings — fall back to no protocol.
result = await resolve_embedding_correlation_id(
organization_id=42, service_key="sk-mps"
)
assert result is None

View file

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

View file

@ -3,7 +3,7 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from pipecat.frames.frames import TranscriptionFrame
from pipecat.frames.frames import TranscriptionFrame, TTSSpeakFrame
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameDirection
@ -21,6 +21,7 @@ class _TestDograhGeminiLiveLLMService(DograhGeminiLiveLLMService):
class _FakeSession:
def __init__(self):
self.send_client_content = AsyncMock()
self.send_tool_response = AsyncMock()
self.send_realtime_input = AsyncMock()
self.close = AsyncMock()
@ -108,3 +109,57 @@ async def test_user_transcription_matches_upstream_upstream_push_behavior():
assert frame.text == "Hi there"
assert frame.finalized is False
assert direction == FrameDirection.UPSTREAM
@pytest.mark.asyncio
async def test_tts_greeting_sends_exact_static_greeting_prompt_to_gemini():
service = _make_service()
service._context = LLMContext()
service._session = _FakeSession()
await service.process_frame(
TTSSpeakFrame("Hi Sam, this is Sarah from Acme.", append_to_context=True),
FrameDirection.DOWNSTREAM,
)
service._session.send_client_content.assert_awaited_once()
kwargs = service._session.send_client_content.await_args.kwargs
assert kwargs["turn_complete"] is True
turns = kwargs["turns"]
assert len(turns) == 1
assert turns[0].role == "user"
prompt = turns[0].parts[0].text
assert "The phone call has just connected. Greet the caller now:" in prompt
assert (
'Do not add anything before or after it.\n\n"Hi Sam, this is Sarah from Acme."'
in prompt
)
assert service._handled_initial_context is True
assert service._pending_initial_greeting_text is None
assert service._ready_for_realtime_input is True
@pytest.mark.asyncio
async def test_tts_greeting_waits_for_gemini_session_before_sending_prompt():
service = _make_service()
service._context = LLMContext()
await service.process_frame(
TTSSpeakFrame("Hello from Dograh.", append_to_context=True),
FrameDirection.DOWNSTREAM,
)
assert service._handled_initial_context is True
assert service._run_llm_when_session_ready is True
assert service._pending_initial_greeting_text == "Hello from Dograh."
session = _FakeSession()
await service._handle_session_ready(session)
session.send_client_content.assert_awaited_once()
prompt = session.send_client_content.await_args.kwargs["turns"][0].parts[0].text
assert prompt.endswith('"Hello from Dograh."')
assert service._run_llm_when_session_ready is False
assert service._pending_initial_greeting_text is None

View file

@ -37,17 +37,71 @@ async def test_initial_context_triggers_response_when_context_was_prepopulated()
@pytest.mark.asyncio
async def test_tts_greeting_uses_initial_context_handler():
async def test_tts_greeting_sends_exact_static_greeting_prompt():
service = _make_service()
service._context = LLMContext()
service._handle_context = AsyncMock()
service._context = LLMContext([{"role": "user", "content": "Existing context"}])
service._api_session_ready = True
service.send_client_event = AsyncMock()
service.push_frame = AsyncMock()
service.start_processing_metrics = AsyncMock()
service.start_ttfb_metrics = AsyncMock()
await service.process_frame(
TTSSpeakFrame("hello", append_to_context=True),
TTSSpeakFrame("Hi Sam, this is Sarah from Acme.", append_to_context=True),
FrameDirection.DOWNSTREAM,
)
service._handle_context.assert_awaited_once_with(service._context)
sent_events = [call.args[0] for call in service.send_client_event.await_args_list]
assert isinstance(sent_events[0], events.ConversationItemCreateEvent)
assert sent_events[0].item.role == "user"
assert sent_events[0].item.content[0].text == "Existing context"
assert isinstance(sent_events[1], events.SessionUpdateEvent)
greeting_event = sent_events[2]
assert isinstance(greeting_event, events.ConversationItemCreateEvent)
assert greeting_event.item.role == "user"
assert greeting_event.item.type == "message"
prompt = greeting_event.item.content[0].text
assert "The phone call has just connected. Greet the caller now:" in prompt
assert prompt.endswith('"Hi Sam, this is Sarah from Acme."')
assert isinstance(sent_events[-1], events.ResponseCreateEvent)
assert service._llm_needs_conversation_setup is False
service._create_response.assert_not_awaited()
@pytest.mark.asyncio
async def test_tts_greeting_waits_for_session_updated_before_sending_prompt():
service = _make_service()
service._context = LLMContext([{"role": "user", "content": "Existing context"}])
await service.process_frame(
TTSSpeakFrame("Hello from Dograh.", append_to_context=True),
FrameDirection.DOWNSTREAM,
)
assert service._handled_initial_context is True
assert service._run_llm_when_api_session_ready is True
assert service._pending_initial_greeting_text == "Hello from Dograh."
service.send_client_event = AsyncMock()
service.push_frame = AsyncMock()
service.start_processing_metrics = AsyncMock()
service.start_ttfb_metrics = AsyncMock()
await service._handle_evt_session_updated(SimpleNamespace())
sent_events = [call.args[0] for call in service.send_client_event.await_args_list]
assert isinstance(sent_events[0], events.ConversationItemCreateEvent)
assert sent_events[0].item.content[0].text == "Existing context"
assert isinstance(sent_events[1], events.SessionUpdateEvent)
greeting_event = sent_events[2]
assert isinstance(greeting_event, events.ConversationItemCreateEvent)
prompt = greeting_event.item.content[0].text
assert prompt.endswith('"Hello from Dograh."')
assert isinstance(sent_events[-1], events.ResponseCreateEvent)
assert service._run_llm_when_api_session_ready is False
assert service._pending_initial_greeting_text is None
assert service._llm_needs_conversation_setup is False
service._create_response.assert_not_awaited()
@pytest.mark.asyncio

View file

@ -0,0 +1,26 @@
import pytest
from api.tasks.knowledge_base_processing import _embed_texts_in_batches
class FakeEmbeddingService:
def __init__(self):
self.calls = []
async def embed_texts(self, texts):
self.calls.append(list(texts))
return [[float(len(text))] for text in texts]
@pytest.mark.asyncio
async def test_embed_texts_in_batches_preserves_order():
service = FakeEmbeddingService()
embeddings = await _embed_texts_in_batches(
service,
["a", "bb", "ccc", "dddd", "eeeee"],
batch_size=2,
)
assert service.calls == [["a", "bb"], ["ccc", "dddd"], ["eeeee"]]
assert embeddings == [[1.0], [2.0], [3.0], [4.0], [5.0]]

View file

@ -5,6 +5,7 @@ import pytest
from pipecat.frames.frames import TTSSpeakFrame
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.openai.realtime import events
from api.services.pipecat.realtime.openai_realtime import (
DograhOpenAIRealtimeLLMService,
@ -48,17 +49,69 @@ async def test_updated_context_uses_tool_result_path_after_initial_context():
@pytest.mark.asyncio
async def test_tts_greeting_uses_initial_context_handler():
async def test_tts_greeting_sends_exact_static_greeting_prompt():
service = _make_service()
service._context = LLMContext()
service._handle_context = AsyncMock()
service._api_session_ready = True
service.send_client_event = AsyncMock()
service.push_frame = AsyncMock()
service.start_processing_metrics = AsyncMock()
service.start_ttfb_metrics = AsyncMock()
await service.process_frame(
TTSSpeakFrame("hello", append_to_context=True),
TTSSpeakFrame("Hi Sam, this is Sarah from Acme.", append_to_context=True),
FrameDirection.DOWNSTREAM,
)
service._handle_context.assert_awaited_once_with(service._context)
sent_events = [call.args[0] for call in service.send_client_event.await_args_list]
assert not any(
isinstance(event, events.ConversationItemCreateEvent) for event in sent_events
)
assert isinstance(sent_events[0], events.SessionUpdateEvent)
response_event = sent_events[-1]
assert isinstance(response_event, events.ResponseCreateEvent)
assert response_event.response.tool_choice == "none"
prompt = response_event.response.instructions
assert "The phone call has just connected. Greet the caller now:" in prompt
assert prompt.endswith('"Hi Sam, this is Sarah from Acme."')
assert service._llm_needs_conversation_setup is False
service._create_response.assert_not_awaited()
@pytest.mark.asyncio
async def test_tts_greeting_waits_for_session_updated_before_sending_prompt():
service = _make_service()
service._context = LLMContext()
await service.process_frame(
TTSSpeakFrame("Hello from Dograh.", append_to_context=True),
FrameDirection.DOWNSTREAM,
)
assert service._handled_initial_context is True
assert service._run_llm_when_api_session_ready is True
assert service._pending_initial_greeting_text == "Hello from Dograh."
service.send_client_event = AsyncMock()
service.push_frame = AsyncMock()
service.start_processing_metrics = AsyncMock()
service.start_ttfb_metrics = AsyncMock()
await service._handle_evt_session_updated(SimpleNamespace())
sent_events = [call.args[0] for call in service.send_client_event.await_args_list]
assert not any(
isinstance(event, events.ConversationItemCreateEvent) for event in sent_events
)
assert isinstance(sent_events[0], events.SessionUpdateEvent)
response_event = sent_events[-1]
assert isinstance(response_event, events.ResponseCreateEvent)
assert response_event.response.tool_choice == "none"
prompt = response_event.response.instructions
assert prompt.endswith('"Hello from Dograh."')
assert service._run_llm_when_api_session_ready is False
assert service._pending_initial_greeting_text is None
assert service._llm_needs_conversation_setup is False
service._create_response.assert_not_awaited()

View file

@ -7,7 +7,23 @@ from pipecat.processors.frame_processor import FrameDirection
from pipecat.transports.base_output import BaseOutputTransport
from pipecat.transports.base_transport import TransportParams
from api.services.pipecat.realtime_feedback_observer import RealtimeFeedbackObserver
from api.services.pipecat.in_memory_buffers import InMemoryLogsBuffer
from api.services.pipecat.realtime_feedback_observer import (
RealtimeFeedbackObserver,
register_turn_log_handlers,
)
class _FakeAggregator:
def __init__(self):
self.handlers = {}
def event_handler(self, event_name):
def decorator(handler):
self.handlers[event_name] = handler
return handler
return decorator
def _frame_pushed(frame, direction, *, source=None):
@ -98,3 +114,33 @@ async def test_observer_waits_for_tts_text_from_output_transport():
"payload": {"text": "Hello"},
}
]
@pytest.mark.asyncio
async def test_turn_log_handlers_persist_user_message_added_events():
logs_buffer = InMemoryLogsBuffer(workflow_run_id=123)
user_aggregator = _FakeAggregator()
assistant_aggregator = _FakeAggregator()
register_turn_log_handlers(logs_buffer, user_aggregator, assistant_aggregator)
assert "on_user_turn_message_added" in user_aggregator.handlers
assert "on_user_turn_stopped" not in user_aggregator.handlers
await user_aggregator.handlers["on_user_turn_message_added"](
user_aggregator,
SimpleNamespace(
content="Hi there",
timestamp="2026-01-01T00:00:00+00:00",
),
)
events = logs_buffer.get_events()
assert len(events) == 1
assert events[0]["type"] == "rtf-user-transcription"
assert events[0]["payload"] == {
"text": "Hi there",
"final": True,
"timestamp": "2026-01-01T00:00:00+00:00",
}
assert events[0]["turn"] == 1

View file

@ -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():
@ -25,6 +30,7 @@ def test_gemini_realtime_uses_local_vad_without_local_interruptions():
assert strategies.start[0]._enable_interruptions is False
assert len(strategies.stop) == 1
assert isinstance(strategies.stop[0], SpeechTimeoutUserTurnStopStrategy)
assert strategies.stop[0].wait_for_transcript is False
def test_gemini_vertex_realtime_uses_same_turn_config_as_gemini_live():
@ -36,6 +42,9 @@ def test_gemini_vertex_realtime_uses_same_turn_config_as_gemini_live():
assert len(strategies.start) == 1
assert isinstance(strategies.start[0], VADUserTurnStartStrategy)
assert strategies.start[0]._enable_interruptions is False
assert len(strategies.stop) == 1
assert isinstance(strategies.stop[0], SpeechTimeoutUserTurnStopStrategy)
assert strategies.stop[0].wait_for_transcript is False
def test_openai_realtime_uses_provider_turn_frames_without_local_vad():
@ -49,6 +58,21 @@ def test_openai_realtime_uses_provider_turn_frames_without_local_vad():
assert strategies.start[0]._enable_interruptions is False
assert len(strategies.stop) == 1
assert isinstance(strategies.stop[0], ExternalUserTurnStopStrategy)
assert strategies.stop[0].wait_for_transcript is False
def test_azure_realtime_uses_provider_turn_frames_without_local_vad():
strategies, vad_analyzer = _create_realtime_user_turn_config(
ServiceProviders.AZURE_REALTIME.value
)
assert vad_analyzer is None
assert len(strategies.start) == 1
assert isinstance(strategies.start[0], ExternalUserTurnStartStrategy)
assert strategies.start[0]._enable_interruptions is False
assert len(strategies.stop) == 1
assert isinstance(strategies.stop[0], ExternalUserTurnStopStrategy)
assert strategies.stop[0].wait_for_transcript is False
def test_grok_realtime_uses_provider_turn_frames_without_local_vad():
@ -62,6 +86,21 @@ def test_grok_realtime_uses_provider_turn_frames_without_local_vad():
assert strategies.start[0]._enable_interruptions is False
assert len(strategies.stop) == 1
assert isinstance(strategies.stop[0], ExternalUserTurnStopStrategy)
assert strategies.stop[0].wait_for_transcript is False
def test_ultravox_realtime_uses_local_vad_with_local_interruptions():
strategies, vad_analyzer = _create_realtime_user_turn_config(
ServiceProviders.ULTRAVOX_REALTIME.value
)
assert isinstance(vad_analyzer, SileroVADAnalyzer)
assert len(strategies.start) == 1
assert isinstance(strategies.start[0], VADUserTurnStartStrategy)
assert strategies.start[0]._enable_interruptions is True
assert len(strategies.stop) == 1
assert isinstance(strategies.stop[0], SpeechTimeoutUserTurnStopStrategy)
assert strategies.stop[0].wait_for_transcript is False
def test_unknown_realtime_providers_keep_local_vad():
@ -70,5 +109,31 @@ def test_unknown_realtime_providers_keep_local_vad():
assert isinstance(vad_analyzer, SileroVADAnalyzer)
assert len(strategies.start) == 1
assert isinstance(strategies.start[0], VADUserTurnStartStrategy)
assert strategies.start[0]._enable_interruptions is True
assert len(strategies.stop) == 1
assert isinstance(strategies.stop[0], SpeechTimeoutUserTurnStopStrategy)
assert strategies.stop[0].wait_for_transcript is False
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
)

View file

@ -1,10 +1,12 @@
from types import SimpleNamespace
from unittest.mock import ANY, AsyncMock, Mock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.routes.telephony import router
from api.enums import WorkflowRunMode, WorkflowRunState
from api.routes.telephony import _handle_telephony_websocket, router
from api.services.auth.depends import get_user
@ -215,3 +217,41 @@ def test_initiate_call_rejects_existing_run_for_different_workflow():
mock_db.get_workflow_run.assert_awaited_once_with(501, organization_id=11)
assert not mock_db.create_workflow_run.called
assert provider.initiate_call.await_count == 0
@pytest.mark.asyncio
async def test_smallwebrtc_run_reaching_telephony_websocket_closes_without_running():
websocket = AsyncMock()
workflow_run = SimpleNamespace(
id=501,
workflow_id=33,
mode=WorkflowRunMode.SMALLWEBRTC.value,
state=WorkflowRunState.INITIALIZED.value,
initial_context={},
gathered_context={},
)
workflow = SimpleNamespace(id=33, organization_id=11)
provider_lookup = AsyncMock()
with (
patch("api.routes.telephony.db_client") as mock_db,
patch(
"api.routes.telephony.get_telephony_provider_for_run",
new=provider_lookup,
),
):
mock_db.get_workflow_run = AsyncMock(return_value=workflow_run)
mock_db.get_workflow_by_id = AsyncMock(return_value=workflow)
mock_db.update_workflow_run = AsyncMock()
await _handle_telephony_websocket(websocket, 33, 99, 501)
websocket.close.assert_awaited_once_with(
code=4400,
reason=(
"smallwebrtc runs connect through the WebRTC signaling endpoint, "
"not the telephony websocket"
),
)
assert mock_db.update_workflow_run.await_count == 0
assert provider_lookup.await_count == 0

View file

@ -0,0 +1,38 @@
from api.utils.template_renderer import render_template
def test_initial_context_prefix_resolves_against_flat_context():
context = {
"first_name": "Abhishek",
"runtime_configuration": {
"realtime_model": "gpt-realtime-2",
},
}
assert (
render_template("Hi {{initial_context.first_name | there}}", context)
== "Hi Abhishek"
)
assert (
render_template(
"Model {{initial_context.runtime_configuration.realtime_model}}", context
)
== "Model gpt-realtime-2"
)
def test_initial_context_prefix_prefers_explicit_initial_context():
context = {
"first_name": "Flat",
"initial_context": {
"first_name": "Nested",
},
}
assert render_template("Hi {{initial_context.first_name}}", context) == "Hi Nested"
def test_initial_context_prefix_uses_fallback_when_missing_from_both_contexts():
assert (
render_template("Hi {{initial_context.first_name | there}}", {}) == "Hi there"
)

View file

@ -606,3 +606,34 @@ class TestRunDefinitionBinding:
)
assert run.definition_id == draft.id
async def test_run_initial_context_merges_with_template_context(
self, db_session, workflow_with_v1
):
"""Explicit run context should augment template context, not replace it."""
workflow, user = workflow_with_v1
await db_session.save_workflow_draft(
workflow_id=workflow.id,
template_context_variables={
"company_name": "Acme",
"default_only": "kept",
},
)
await db_session.publish_workflow_draft(workflow.id)
run = await db_session.create_workflow_run(
name="Embed Run",
workflow_id=workflow.id,
mode="smallwebrtc",
user_id=user.id,
initial_context={
"company_name": "Override Co",
"provider": "smallwebrtc",
},
)
assert run.initial_context == {
"company_name": "Override Co",
"default_only": "kept",
"provider": "smallwebrtc",
}

View file

@ -3,6 +3,7 @@ Common utilities.
Shared functions used across the application.
"""
import ipaddress
import re
from loguru import logger
@ -22,6 +23,43 @@ def get_scheme(url: str) -> str | None:
return url[:idx]
def is_local_or_private_url(url: str) -> bool:
"""True when the URL's host is localhost or a private/reserved/loopback IP.
Such an address is not reachable from the public internet, so external callers
(telephony webhooks/callbacks) can't reach it directly — the backend resolves a
Cloudflare tunnel URL at runtime instead. A public IP or a hostname/domain
returns False (assumed publicly reachable).
"""
host = url
if "://" in host:
host = host.split("://", 1)[1]
host = host.split("/", 1)[0]
# Strip a :port suffix (skip bare IPv6, which contains multiple colons).
if host.count(":") == 1:
host = host.rsplit(":", 1)[0]
if host == "localhost" or host.endswith(".localhost"):
return True
try:
ip = ipaddress.ip_address(host)
except ValueError:
return False # hostname / domain -> assume publicly reachable
if (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_reserved
or ip.is_unspecified
):
return True
# Carrier-grade NAT (RFC 6598) — behind NAT, not publicly reachable. Kept in
# sync with scripts/lib/setup_common.sh:dograh_is_local_ipv4.
return isinstance(ip, ipaddress.IPv4Address) and ip in ipaddress.ip_network(
"100.64.0.0/10"
)
def _validate_url(url: str) -> None:
"""
Validate URL format and raise ValueError for invalid URLs.
@ -119,10 +157,11 @@ async def get_backend_endpoints() -> tuple[str, str]:
_validate_url(BACKEND_API_ENDPOINT)
if BACKEND_API_ENDPOINT:
# Handle localhost/127.0.0.1 special case - use tunnel URL if available
if "localhost" in BACKEND_API_ENDPOINT or "127.0.0.1" in BACKEND_API_ENDPOINT:
# Non-public address (localhost or a private/reserved IP) - the host isn't
# reachable from the internet, so prefer a running Cloudflare tunnel's URL.
if is_local_or_private_url(BACKEND_API_ENDPOINT):
logger.debug(
f"BACKEND_API_ENDPOINT is local ({BACKEND_API_ENDPOINT}), checking tunnel URL"
f"BACKEND_API_ENDPOINT is not publicly reachable ({BACKEND_API_ENDPOINT}), checking tunnel URL"
)
try:
tunnel_urls = await TunnelURLProvider.get_tunnel_urls()

View file

@ -3,6 +3,8 @@ Telephony helper utilities.
Common functions used across telephony operations.
"""
import inspect
from fastapi import Request
from loguru import logger
from starlette.responses import HTMLResponse
@ -119,9 +121,12 @@ def _test_number_formats_with_country_code(
return False
def normalize_webhook_data(provider_class, webhook_data):
def normalize_webhook_data(provider_class, webhook_data, headers=None):
"""Normalize webhook data using the provider's parse method"""
return provider_class.parse_inbound_webhook(webhook_data)
parse_method = provider_class.parse_inbound_webhook
if headers is not None and "headers" in inspect.signature(parse_method).parameters:
return parse_method(webhook_data, headers=headers)
return parse_method(webhook_data)
def generic_hangup_response():

View file

@ -12,6 +12,7 @@ from api.services.workflow.workflow_graph import TEMPLATE_VAR_PATTERN
_CURRENT_TIME_PREFIX = "current_time"
_CURRENT_WEEKDAY_PREFIX = "current_weekday"
_INITIAL_CONTEXT_PREFIX = "initial_context."
def get_nested_value(obj: Any, path: str) -> Any:
@ -184,8 +185,14 @@ def _render_string(template_str: str, context: Dict[str, Any]) -> str:
if builtin_value is not None:
return builtin_value
# Get value using nested path lookup
# Get value using nested path lookup. Prompts commonly reference
# initial_context.<key>, while some runtime callers pass the initial
# context itself as the render context.
value = get_nested_value(context, variable_path)
if value is None and variable_path.startswith(_INITIAL_CONTEXT_PREFIX):
value = get_nested_value(
context, variable_path[len(_INITIAL_CONTEXT_PREFIX) :]
)
# Apply fallback: new syntax {{var | default}} or legacy {{var | fallback:default}}
if filter_name is not None:

View file

@ -4,8 +4,21 @@ server {
listen 80;
server_name __DOGRAH_PUBLIC_HOST__;
# Redirect all HTTP to HTTPS
return 301 https://$host$request_uri;
# Serve Let's Encrypt HTTP-01 challenges out of the certs webroot that
# certbot --webroot writes into (./certs is bind-mounted here read-only).
# Only this path is exposed; local.crt/local.key are never served.
location ^~ /.well-known/acme-challenge/ {
root /etc/nginx/certs;
default_type "text/plain";
try_files $uri =404;
}
# Redirect everything else to HTTPS. This must live in a location block,
# not a server-level `return`, or it would fire before location matching
# and hijack the ACME challenge above.
location / {
return 301 https://$host$request_uri;
}
}
server {

View file

@ -1,3 +1,17 @@
# Dograh deployment stack — driven by the helper scripts, not by a bare
# `docker compose up`.
#
# This stack needs a generated .env (secrets, public host/URL) and, for the
# remote/TURN profiles, runtime nginx/coturn config rendered by the dograh-init
# service. The setup scripts create those for you — start with them:
#
# Local: ./start_docker.sh (Windows: .\start_docker.ps1)
# Remote server: sudo ./setup_remote.sh then ./remote_up.sh
#
# Running `docker compose up` against a fresh checkout will fail or come up
# misconfigured (e.g. OSS_JWT_SECRET is required). Full guide:
# https://docs.dograh.com/deployment/docker
services:
postgres:
image: pgvector/pgvector:pg17
@ -135,9 +149,18 @@ services:
ENVIRONMENT: "${ENVIRONMENT:-local}"
LOG_LEVEL: "INFO"
# Replace this environment variable if you are using a custom
# domain to host the stack
BACKEND_API_ENDPOINT: "${BACKEND_API_ENDPOINT:-http://localhost:8000}"
# Public origin for this deployment. The API derives BACKEND_API_ENDPOINT,
# MINIO_PUBLIC_ENDPOINT and TURN_HOST from PUBLIC_BASE_URL / PUBLIC_HOST when
# they are not set explicitly (see api/constants.py), so a standard remote
# install only needs PUBLIC_BASE_URL + PUBLIC_HOST in .env.
PUBLIC_BASE_URL: "${PUBLIC_BASE_URL:-}"
PUBLIC_HOST: "${PUBLIC_HOST:-}"
# Optional explicit override of the public URL the backend builds webhook /
# embed links from. Defaults to PUBLIC_BASE_URL. When the value is non-public
# (localhost or a private/reserved IP), the API resolves a running Cloudflare
# tunnel's URL at runtime instead (see api/utils/common.py).
BACKEND_API_ENDPOINT: "${BACKEND_API_ENDPOINT:-}"
# Database configuration (using containerized postgres)
DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres"
@ -169,10 +192,10 @@ services:
# MinIO
MINIO_ENDPOINT: "minio:9000"
# Full URL (with scheme) browsers use to reach MinIO. For remote
# deployments behind HTTPS, set MINIO_PUBLIC_ENDPOINT in .env to
# e.g. https://your-server.example.com (nginx proxies /voice-audio/).
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}"
# Full URL (with scheme) browsers use to reach MinIO. Defaults to
# PUBLIC_BASE_URL for remote deployments (nginx proxies /voice-audio/) and to
# http://localhost:9000 for local; override only for a separate object store.
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-}"
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-minioadmin}"
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-minioadmin}"
MINIO_BUCKET: "voice-audio"
@ -223,8 +246,6 @@ services:
condition: service_healthy
minio:
condition: service_healthy
cloudflared:
condition: service_started
healthcheck:
test:
[
@ -272,12 +293,35 @@ services:
networks:
- app-network
# Cloudflare tunnel for inbound webhook / WSS reachability when the host has no
# usable public IP (behind NAT or a firewall). Gated behind the "tunnel" profile
# so public-IP installs (served directly by nginx) never start it, and the api
# service no longer hard-depends on it. Two modes, chosen by the token:
# - CLOUDFLARE_TUNNEL_TOKEN set -> named tunnel with a stable hostname. Point
# its ingress at http://api:8000 in the Cloudflare dashboard and set
# BACKEND_API_ENDPOINT in .env to that hostname.
# - token unset -> quick tunnel with an ephemeral
# *.trycloudflare.com URL the API discovers from the metrics endpoint
# (api/utils/tunnel.py). Convenient for local dev.
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared-tunnel
command: tunnel --no-autoupdate --url http://api:8000 --metrics 0.0.0.0:2000
profiles: ["tunnel"]
restart: unless-stopped
environment:
# cloudflared automatically picks up the token from TUNNEL_TOKEN when
# running `tunnel run`. Leave empty to fall back to a quick tunnel.
TUNNEL_TOKEN: "${CLOUDFLARE_TUNNEL_TOKEN:-}"
# The cloudflared image is distroless (no `sh`), so a `sh -c` conditional
# entrypoint can't run. The image's own entrypoint is already
# `cloudflared --no-autoupdate`; pick the mode via a single command instead:
# - token set -> set CLOUDFLARED_COMMAND="tunnel run" in .env for a named
# tunnel (cloudflared reads TUNNEL_TOKEN from the env above).
# - token unset -> the default below runs a quick tunnel with an ephemeral
# *.trycloudflare.com URL the API discovers from the metrics endpoint.
command: ${CLOUDFLARED_COMMAND:-tunnel --url http://api:8000 --metrics 0.0.0.0:2000}
ports:
- "2000:2000" # Expose metrics endpoint
- "2000:2000" # metrics endpoint (quick-tunnel URL discovery)
networks:
- app-network

File diff suppressed because one or more lines are too long

View file

@ -3,10 +3,12 @@ title: "LLM"
description: "Voice Agents use LLM (Large Language Models), which are trained to understand the conversational context, and respond to users."
---
You can currently use OpenAI, Google, Groq, Azure and Dograh LLMs in LLM configuration. There are some models provided by default for you to choose from the drop down.
Dograh platform supports OpenAI, Google AI Studio, Google Vertex AI, Azure OpenAI, AWS Bedrock, Groq, OpenRouter, Hugging Face, MiniMax, Sarvam, and Dograh-managed LLMs. There are some models provided by default for you to choose from the drop down.
For locally deployed or self-hosted LLMs, Dograh also supports OpenAI-compatible endpoints such as Ollama and vLLM.
![Select Models from DropDown](../images/models_dropdown.png)
If you don't find a model in the drop down, you can always add a model manually.
![Select Models from DropDown](../images/add_model_manually.png)
![Select Models from DropDown](../images/add_model_manually.png)

View file

@ -3,6 +3,8 @@ title: "Transcriber"
description: "Voice Agents use STT (Speech to Text), to transcribe what the user speaks. This transcribed speech as text goes into an LLM to generate the response that gets played out to the user."
---
Dograh platform ships with Deepgram, Cartesia, OpenAI and Dograh transcribers by default. You can take a look at the providers documentation of which language to select for your language requirements.
Dograh platform supports Deepgram, OpenAI, Google, Azure Speech, AssemblyAI, Speechmatics, Cartesia, Gladia, Sarvam, Smallest AI, Hugging Face, and Dograh transcribers. You can take a look at the providers documentation of which language to select for your language requirements.
Example: Deepgram has their language support documentation at https://developers.deepgram.com/docs/models-languages-overview#nova-3
For locally deployed or self-hosted STT models, Dograh also supports Speaches, an OpenAI API-compatible server for streaming transcription.
Example: Deepgram has their language support documentation at https://developers.deepgram.com/docs/models-languages-overview#nova-3

View file

@ -3,8 +3,10 @@ title: "Voice"
description: "Voice Agents use TTS (Text to Speech), which generates audio that LLMs generate during the course of a conversation. This is the audio that the end user having the conversation listens to."
---
Dograh platform ships with Elevenlabs, Deepgram, OpenAI and Dograh TTS engines by default. There are some voices from the providers that we ship by default. You can refer to the providers API documentation to select a voice ID thats most relevant for your language requirement.
Dograh platform supports ElevenLabs, OpenAI, Google, Azure Speech, Deepgram, Cartesia, Smallest AI, MiniMax, Sarvam, Rime, Inworld, Camb.ai, and Dograh TTS engines. There are some voices from the providers that we ship by default. You can refer to the providers API documentation to select a voice ID that's most relevant for your language requirement.
If you dont find your favourite voice, you can always add the voice ID manually.
For locally deployed or self-hosted TTS models, Dograh also supports Speaches, an OpenAI API-compatible server for speech generation.
![Add Voice Manually](../images/add_tts_manually.png)
If you don't find your favourite voice, you can always add the voice ID manually.
![Add Voice Manually](../images/add_tts_manually.png)

View file

@ -5,6 +5,10 @@ description: "Deploy Dograh AI with custom domain names and SSL certificates"
Deploy Dograh AI with your own custom domain name for a professional production setup. By now, you should be able to create and test a voice agent by following the previous guide to setup the platform on a remote server using [Docker](docker#option-2%3A-remote-server-deployment)
<Tip>
**You don't need to own a domain just to remove the browser warning.** If your server has a public IP, the [remote setup](docker#option-2%3A-remote-server-deployment) already issues a free, auto-renewing Let's Encrypt certificate via [sslip.io](https://sslip.io) (e.g. `https://203-0-113-10.sslip.io`) — no DNS configuration required. Follow this Custom Domain guide only when you want your **own** domain name (e.g. `voice.yourcompany.com`).
</Tip>
## What is Custom Domain Deployment?
Custom domain deployment allows you to run Dograh AI with a personalized domain name (like `voice.yourcompany.com`) instead of using IP addresses. This setup includes:
@ -109,65 +113,50 @@ sudo apt install certbot -y
sudo yum install certbot -y
```
### Stop Dograh Services
### Point `.env` at your domain
Before generating certificates, stop the running Dograh services to free up port 80:
Update `.env` so the canonical remote settings use your domain — `dograh-init` reads these to render nginx's `server_name`. Replace `voice.yourcompany.com` with your actual domain throughout:
```bash
cd dograh
sudo docker compose --profile remote down
sed -i "s/^PUBLIC_HOST=.*/PUBLIC_HOST=voice.yourcompany.com/" .env
sed -i "s|^PUBLIC_BASE_URL=.*|PUBLIC_BASE_URL=https://voice.yourcompany.com|" .env
```
### Generate SSL Certificates
### Start services so nginx can answer the ACME challenge
Run Certbot to obtain SSL certificates for your domain:
Bring the stack up (or recreate it) through the validated wrapper. nginx serves the Let's Encrypt HTTP-01 challenge from `certs/.well-known/acme-challenge/` on port 80, so the stack must be **running** during issuance — there's no need to stop it:
```bash
sudo certbot certonly --standalone -d voice.yourcompany.com
./remote_up.sh
```
Replace `voice.yourcompany.com` with your actual domain name.
### Generate the SSL certificate (webroot)
Issue the certificate using the webroot challenge served by the running nginx:
```bash
sudo certbot certonly --webroot -w "$(pwd)/certs" -d voice.yourcompany.com
```
Certbot will:
1. Verify that you control the domain
2. Generate SSL certificates
3. Store them in `/etc/letsencrypt/live/voice.yourcompany.com/`
1. Write a challenge file under `certs/.well-known/acme-challenge/`
2. Have Let's Encrypt fetch it over HTTP (port 80) to verify you control the domain
3. Store the certificate in `/etc/letsencrypt/live/voice.yourcompany.com/`
<Note>
You'll be prompted to enter an email address for renewal notifications and agree to the terms of service.
You'll be prompted for an email address (for renewal notices) and to agree to the terms of service. Because nginx keeps serving traffic throughout, issuance and renewal happen with **no downtime** — unlike the older `--standalone` flow, which had to stop the stack to free port 80.
</Note>
### Copy Certificates to Dograh Directory
### Copy the certificate and load it
Copy the generated certificates to the dograh certs directory:
Copy the issued certificate into the `certs/` directory nginx reads, then restart nginx to load it:
```bash
cd dograh
sudo cp /etc/letsencrypt/live/voice.yourcompany.com/fullchain.pem certs/local.crt
sudo cp /etc/letsencrypt/live/voice.yourcompany.com/privkey.pem certs/local.key
sudo chmod 644 certs/local.crt certs/local.key
```
### Update Canonical Public URL Settings
Update `.env` so the canonical remote settings point at your domain:
```bash
nano dograh/.env
```
```bash
PUBLIC_HOST=voice.yourcompany.com
PUBLIC_BASE_URL=https://voice.yourcompany.com
```
### Start Dograh Services
Start Dograh through the validated startup wrapper so `dograh-init` regenerates nginx and coturn runtime config before Docker starts:
```bash
cd dograh
./remote_up.sh
sudo docker compose --profile remote restart nginx
```
### Access Your Application
@ -222,7 +211,7 @@ sudo certbot renew --dry-run
If Certbot fails to generate certificates:
1. **Port 80 blocked**: Ensure port 80 is open in your firewall and no service is using it
1. **Port 80 blocked**: Ensure port 80 is open in your firewall and reachable from the internet. With the webroot flow nginx must be **running** and serving the challenge on port 80 (don't stop the stack)
2. **DNS not propagated**: Wait for DNS changes to propagate and verify with `nslookup`
3. **Rate limits**: Let's Encrypt has rate limits. If you've exceeded them, wait before retrying

View file

@ -102,7 +102,7 @@ The script will prompt you for:
It creates `docker-compose.yaml`, a `.env` file with JWT and TURN credentials, and the small helper bundle that `dograh-init` uses to render coturn config at startup. Start the stack with the `local-turn` profile so coturn comes up alongside the other services:
```bash
docker compose --profile local-turn up --pull always
docker compose --profile local-turn --profile tunnel up --pull always
```
The application is still available at `http://localhost:3010`.
@ -123,13 +123,14 @@ Watch the video tutorial below for a step-by-step walkthrough of deploying Dogra
allowFullScreen
></iframe>
Deploy Dograh AI on a remote server to make it accessible from anywhere using your server's IP address. This setup includes HTTPS support via nginx reverse proxy with self-signed certificates. **We need to serve the application over HTTPS, since modern browsers only allow microphone permissions for websites being served over HTTPS**.
Deploy Dograh AI on a remote server to make it accessible from anywhere. If your server has a **public IP**, setup obtains a free, trusted HTTPS certificate automatically via [sslip.io](https://sslip.io) and Let's Encrypt — no domain name or DNS configuration required, and no browser warning. On a private/reserved IP it falls back to a self-signed certificate. **We need to serve the application over HTTPS, since modern browsers only allow microphone permissions for websites being served over HTTPS**.
<Warning>We highly recommend you set up the platform on a fresh server, so that there are less chances of confliciting dependencies, and ports from other applications.</Warning>
### Prerequisites
- A server with Docker and Docker Compose installed. It should have minimum of 8 GB RAM and 4 vCPUs.
- Root (`sudo`) access on the server — the setup script must be run as root and exits early otherwise.
- Public IP address for your server. You can also access the server using a local IP address in your VPC as long as its reachable from your browser.
- TCP Ports 80, 443, 3478, 5349 and UDP Ports 3478, 5349 and 49152:49200 reachable from Internet (Port 80 and 443 to access the UI and rest of the ports for WebRTC Signaling)
@ -140,9 +141,13 @@ Deploy Dograh AI on a remote server to make it accessible from anywhere using yo
Run the automated setup script that will configure everything for you:
```bash
curl -o setup_remote.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/setup_remote.sh && chmod +x setup_remote.sh && ./setup_remote.sh
curl -o setup_remote.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/setup_remote.sh && chmod +x setup_remote.sh && sudo ./setup_remote.sh
```
<Note>
Run with `sudo` — the script must run as root (it provisions Docker, binds ports 80/443, and installs a Let's Encrypt certificate with a system renewal hook) and exits early if it isn't. On a public IP it obtains and auto-renews a trusted certificate; on a private IP it falls back to a self-signed one, since Let's Encrypt can't validate a private address. Optional environment-variable overrides: `CERT_MODE=self-signed` forces a self-signed cert, `ACME_DOMAIN_SUFFIX=nip.io` switches to the nip.io pool if sslip.io is rate-limited, and `LETSENCRYPT_EMAIL=you@example.com` sets the address for expiry notices.
</Note>
The script will prompt you for:
- Your server's public IP address
- A password for the TURN server (optional, press Enter for default)
@ -152,7 +157,8 @@ The script will prompt you for:
It will automatically:
- Get the source — `docker-compose.yaml` only (prebuilt mode), or clone the full repo (build mode)
- Download the validated remote deployment helper bundle
- Generate SSL certificates
- Obtain a free trusted Let's Encrypt certificate via sslip.io and configure auto-renewal (public IP), or generate a self-signed certificate (private IP / no root)
- For a public IP, also start the stack and print your ready-to-use `https://…sslip.io` URL
- Create an environment file with TURN server configuration
- Validate the runtime config that `dograh-init` will render from `.env`
- Write a `docker-compose.override.yaml` with build directives (build mode only)
@ -163,7 +169,7 @@ It will automatically:
Please ensure that Docker Compose is installed on your machine before proceeding further. You can check whether its installed by running `docker compose version` command. If its not installed, please install it by following your server provider documentation.
</Note>
After the setup script completes, start Dograh. The script prints the exact command to run at the end — it differs slightly between modes:
After the setup script completes, start Dograh. For a **public-IP install the trusted-certificate flow already started the stack** and printed your `https://…sslip.io` URL — you can skip to [Access Your Application](#access-your-application). For a self-signed install, start it yourself (the script prints the exact command at the end — it differs slightly between modes):
<CodeGroup>
```bash Prebuilt mode
@ -180,13 +186,13 @@ First boot in build mode takes several minutes — Docker has to build both the
### Access Your Application
Your application will be available at
```
https://YOUR_SERVER_IP
```
Your application will be available at the URL the setup script printed at the end:
- **Public IP (trusted certificate):** `https://<your-ip-with-dashes>.sslip.io` — for example `https://203-0-113-10.sslip.io`. No browser warning.
- **Self-signed (private IP):** `https://YOUR_SERVER_IP`
<Note>
Since we are using a self-signed certificate, your browser will show a security warning. You can safely accept it to proceed.
With a self-signed certificate your browser shows a security warning you can safely accept. With the sslip.io Let's Encrypt certificate there is no warning.
</Note>
You should be able to create and test a voice agent now.
@ -196,7 +202,7 @@ You should be able to create and test a voice agent now.
- The remote deployment includes an nginx reverse proxy for HTTPS termination
- File downloads (transcripts, recordings) are automatically routed through nginx
- WebSocket connections for real-time features are properly proxied
- The setup uses self-signed certificates - browsers will show a security warning that you can safely accept for testing
- Public-IP installs get a trusted Let's Encrypt certificate (via sslip.io) that renews automatically; private-IP installs use a self-signed certificate (browser warning)
- The TURN server (coturn) is configured for WebRTC NAT traversal
- For production deployments with proper SSL and domain names, see the [Custom Domain](custom-domain) documentation
@ -213,7 +219,7 @@ The setup script creates the following files in the `dograh/` directory:
| `scripts/lib/setup_common.sh` | Shared deployment helper library |
| `deploy/templates/` | nginx and coturn runtime config templates |
| `generate_certificate.sh` | Script to regenerate SSL certificates |
| `certs/local.crt` | Self-signed SSL certificate |
| `certs/local.crt` | SSL certificate (Let's Encrypt via sslip.io, or self-signed) |
| `certs/local.key` | SSL private key |
| `.env` | Single source of truth for deployment settings (TURN secret, JWT secret, FastAPI worker count, public host/base URL) |

View file

@ -55,7 +55,7 @@ Number of FastAPI workers (uvicorn processes nginx will load-balance):
Press Enter for the default (`4`) or enter a different positive integer. Non-interactive callers (cloud-init, CI, Terraform) can set the value via environment variable instead:
```bash
SERVER_IP=... TURN_SECRET=... FASTAPI_WORKERS=8 ./setup_remote.sh
sudo SERVER_IP=... TURN_SECRET=... FASTAPI_WORKERS=8 ./setup_remote.sh
```
The script stores the value in **`.env`** (`FASTAPI_WORKERS=N`). The supported startup path (`./remote_up.sh`) preflights the `dograh-init` render from that value before every remote start, so nginx and the API worker count stay aligned.

View file

@ -65,7 +65,9 @@ Set these when `AUTH_PROVIDER=stack` to delegate sign-in to [Stack Auth](https:/
| Variable | Default | Description |
|---|---|---|
| `BACKEND_API_ENDPOINT` | `http://localhost:8000` | Internal URL of the backend API |
| `PUBLIC_BASE_URL` | `null` | Canonical public origin for the deployment (scheme + host, e.g. `https://203-0-113-10.sslip.io`). For a standard single-host install this is the only endpoint value you set — `BACKEND_API_ENDPOINT` and `MINIO_PUBLIC_ENDPOINT` derive from it |
| `PUBLIC_HOST` | `null` | Public host without scheme (e.g. `203-0-113-10.sslip.io`); `TURN_HOST` derives from it |
| `BACKEND_API_ENDPOINT` | `PUBLIC_BASE_URL`, else `http://localhost:8000` | Public URL the backend builds webhook / callback / embed links from. Set explicitly only to override the value derived from `PUBLIC_BASE_URL` |
| `UI_APP_URL` | `http://localhost:3010` | URL of the frontend application |
| `MPS_API_URL` | `https://services.dograh.com` | Dograh Managed Platform Services URL |
| `DOGRAH_MPS_SECRET_KEY` | `null` | **Required for non-OSS deployments.** Secret key for authenticating with MPS |
@ -82,7 +84,7 @@ Dograh uses **MinIO by default**, which is bundled with the self-hosted deployme
| Variable | Default | Description |
|---|---|---|
| `MINIO_ENDPOINT` | `localhost:9000` | MinIO server host and port |
| `MINIO_PUBLIC_ENDPOINT` | `null` | Publicly accessible MinIO URL (for download links) |
| `MINIO_PUBLIC_ENDPOINT` | `PUBLIC_BASE_URL`, else `http://localhost:9000` | Publicly accessible MinIO URL for download links. Derives from `PUBLIC_BASE_URL`; set explicitly only for a separate object-storage origin |
| `MINIO_ACCESS_KEY` | N/A | **Required for OSS deployments.** MinIO access key. Must be set to a secure value in production |
| `MINIO_SECRET_KEY` | N/A | **Required for OSS deployments.** MinIO secret key. Must be set to a secure value in production |
| `MINIO_BUCKET` | `voice-audio` | Bucket name for audio files |
@ -128,7 +130,7 @@ Presigned URLs point at `S3_ENDPOINT_URL`, so that host must be reachable from t
| Variable | Default | Description |
|---|---|---|
| `TURN_HOST` | `localhost` | TURN server hostname for WebRTC NAT traversal |
| `TURN_HOST` | `PUBLIC_HOST`, else `localhost` | TURN server hostname for WebRTC NAT traversal. Derives from `PUBLIC_HOST`; set explicitly only when TURN runs on a separate host |
| `TURN_PORT` | `3478` | TURN server port |
| `TURN_TLS_PORT` | `5349` | TURN server TLS port |
| `TURN_SECRET` | `null` | **Required for WebRTC.** Shared secret for TURN credential generation |

View file

@ -15,6 +15,7 @@ Before setting up Vonage integration, you'll need:
- Vonage Application with Voice capability enabled
- Application ID and Private Key from your Vonage Dashboard
- API Key and API Secret from your Vonage Dashboard
- Signature Secret from your Vonage Dashboard
- At least one Vonage phone number linked to the application
- Dograh AI instance running and accessible
@ -31,9 +32,11 @@ Before setting up Vonage integration, you'll need:
### Step 2: Get API Credentials
1. Find your **API Key** and **API Secret** in the dashboard under **API Settings**
2. Navigate to **Numbers** → **Your Numbers**
3. Copy your phone number(s)
4. Link your numbers to your application
2. Copy your **Signature Secret** from the same Vonage account. Dograh uses it
to verify [Vonage signed webhooks](https://developer.vonage.com/en/getting-started/concepts/webhooks).
3. Navigate to **Numbers** → **Your Numbers**
4. Copy your phone number(s)
5. Link each inbound number to your Voice application
### Step 3: Configure in Dograh AI
@ -44,6 +47,7 @@ Before setting up Vonage integration, you'll need:
- Private Key (entire key including BEGIN/END lines)
- API Key
- API Secret
- Signature Secret
4. Click **Save Configuration**
5. Open the configuration you just created and add at least one **phone number** (without `+` prefix, e.g. `14155551234`). The default caller ID is used for outbound calls.
@ -55,12 +59,24 @@ Before setting up Vonage integration, you'll need:
## Inbound Calling Setup
Vonage configures inbound webhooks at the **application level**, not per phone number. A single **Answer URL** on the Vonage application applies to every number linked to it. Dograh routes the call to the right agent based on the called number's inbound workflow assignment inside Dograh. **When you save an inbound workflow on a phone number, Dograh automatically pushes the webhook URL to your Vonage Application's Answer URL** (provided the credentials are correct).
Vonage routes inbound Voice API calls through a Voice application. The application owns the Answer URL and Event URL, and the phone number must be linked to that application. Dograh routes the call to the right agent based on the called number's inbound workflow assignment inside Dograh.
When you save an inbound workflow on a phone number, Dograh updates the configured Vonage application's Voice webhooks and enables signed callbacks, provided the API Key, API Secret, and Application ID are correct and a Signature Secret is configured.
<Warning>
Linking the phone number to the Voice application is required. If the number
is not linked, Vonage will not call Dograh's Answer URL, and you may hear a
busy or disconnected tone without seeing any Dograh application logs.
</Warning>
### Step 1: Link Phone Numbers to Your Vonage Application
1. Open the [Vonage Dashboard](https://dashboard.nexmo.com/)
2. Under **Numbers** → **Your Numbers**, link each number you want to use for inbound to the same Vonage Application whose ID you configured in Dograh
2. Go to **Numbers** → **Your Numbers**
3. Open each number you want to use for inbound calls
4. Set the number's Voice application to the same Vonage Application whose ID you configured in Dograh
Vonage's Numbers API describes this as the number's `app_id`: the application that handles inbound traffic to that number. See the [Numbers API reference](https://developer.vonage.com/en/api/numbers).
### Step 2: Assign an Inbound Workflow to the Phone Number in Dograh
@ -73,23 +89,26 @@ Vonage configures inbound webhooks at the **application level**, not per phone n
1. Open your Vonage Application in the [Vonage Dashboard](https://dashboard.nexmo.com/)
2. Under **Capabilities** → **Voice**, confirm:
- **Answer URL** is set to: `https://api.dograh.com/api/v1/telephony/inbound/run`
- **Answer URL** is set to: `https://<your-backend-domain>/api/v1/telephony/inbound/run`
- **HTTP Method** is `POST`
- **Event URL** is set to: `https://<your-backend-domain>/api/v1/telephony/vonage/events`
- **Event Method** is `POST`
- **Signed callbacks** are enabled
<Note>
Dograh pushed this URL automatically when you saved the inbound workflow
in Step 2. If the field is empty, shows a different URL, or Dograh
surfaced a sync warning on save, the auto-push failed — most often
because the API Key/Secret or Application ID in Dograh is incorrect.
Paste the URL into the field yourself, set the method to `POST`, and
save the application. On self-hosted Dograh, replace `api.dograh.com`
with your backend domain.
Dograh pushes these settings automatically when you save the inbound
workflow in Step 2. If the fields are empty, show a different URL, or
Dograh surfaced a sync warning on save, check the API Key, API Secret,
Application ID, and Signature Secret in Dograh, then save the inbound
workflow again. On self-hosted Dograh, the backend domain must be publicly
reachable by Vonage.
</Note>
### Step 4: Verify Setup
- Ensure your Dograh AI instance is publicly accessible
- Verify any firewalls allow Vonage's IP ranges
- Verify your public backend URL is reachable from the internet
- Use the [Vonage logs](https://dashboard.nexmo.com/logs) or Voice Inspector to confirm Vonage is sending the Answer webhook to Dograh
### Test Inbound Calling
@ -119,6 +138,13 @@ Vonage uses higher quality audio (16kHz) which provides:
- Check the Application ID is correct
- Ensure the private key hasn't been regenerated in Vonage Dashboard
</Accordion>
<Accordion title="Signed webhook validation failed">
- Verify the Signature Secret in Dograh matches the Vonage account's signature secret
- Ensure signed callbacks are enabled on the Vonage Voice application
- Check that the webhook request includes an `Authorization` header
- Confirm the application belongs to the same API Key saved in Dograh
</Accordion>
<Accordion title="Invalid phone number error">
- Remove the '+' prefix for Vonage (use `14155551234` not `+14155551234`)
@ -141,11 +167,18 @@ Vonage uses higher quality audio (16kHz) which provides:
</Accordion>
<Accordion title="Inbound calls not reaching voice agent">
- Verify the Vonage application's Answer URL is set to `https://api.dograh.com/api/v1/telephony/inbound/run`
- Verify the Vonage application's Answer URL is set to `https://<your-backend-domain>/api/v1/telephony/inbound/run`
- Ensure the Answer URL is publicly accessible
- Confirm the called number is linked to the correct Vonage application
- Confirm the called number exists in your Dograh telephony configuration and has an **Inbound workflow** assigned
</Accordion>
<Accordion title="Inbound call gives busy tone and Dograh shows no logs">
- Confirm the Vonage number is linked to the Voice application configured in Dograh
- Confirm the Voice application has the Dograh Answer URL set with method `POST`
- Confirm your backend domain is public; Vonage cannot call `localhost`
- Check Vonage logs for Answer URL delivery errors before debugging Dograh
</Accordion>
<Accordion title="Voice agent doesn't respond to inbound calls">
- Confirm the phone number has an **Inbound workflow** assigned in /telephony-configurations
@ -158,6 +191,7 @@ Vonage uses higher quality audio (16kHz) which provides:
## Best Practices
- **Security**: Private keys are stored securely in the database
- **Signed callbacks**: Keep Vonage signed callbacks enabled and keep the Signature Secret in Dograh up to date
- **Testing**: Use Vonage Voice Inspector for debugging call issues
- **Numbers**: Configure multiple numbers for redundancy
- **Monitoring**: Set up alerts in Vonage Dashboard for failures
@ -177,4 +211,4 @@ Check [Vonage pricing](https://www.vonage.com/communications-apis/voice/pricing/
- Test your Vonage integration with a simple workflow
- Configure VAD settings for optimal voice detection
- Set up monitoring and alerts
- Explore advanced features like call recording
- Explore advanced features like call recording

@ -1 +1 @@
Subproject commit afe5b6b6c90ad04557ad93b69f7f6ba662c2072e
Subproject commit 12af7a65c576ce52225c735917e44075d202ab1a

View file

@ -65,10 +65,25 @@ else
COMPOSE_CMD=(sudo docker compose)
fi
# Reconcile the Postgres role password with .env before starting the API.
# POSTGRES_PASSWORD only applies on first volume init, so an existing volume can
# hold a stale password the API would fail to authenticate against. Idempotent.
dograh_sync_postgres_password "$SCRIPT_DIR" "${COMPOSE_CMD[@]}"
# When SERVER_IP (sourced from .env above) is a private/reserved address the host
# has no public IP, so start the cloudflared service (tunnel profile) to make
# webhooks reachable. The backend resolves the tunnel's public URL at runtime using
# the same private-IP classification (api/utils/common.py:is_local_or_private_url),
# so the two stay in sync. A public-IP install runs nginx only.
PROFILE_ARGS=(--profile remote)
if dograh_is_local_ipv4 "${SERVER_IP:-}"; then
PROFILE_ARGS+=(--profile tunnel)
fi
if [[ "$MODE" == "build" ]]; then
CMD=("${COMPOSE_CMD[@]}" --profile remote up -d --build --force-recreate)
CMD=("${COMPOSE_CMD[@]}" "${PROFILE_ARGS[@]}" up -d --build --force-recreate)
else
CMD=("${COMPOSE_CMD[@]}" --profile remote up -d --pull always --force-recreate)
CMD=("${COMPOSE_CMD[@]}" "${PROFILE_ARGS[@]}" up -d --pull always --force-recreate)
fi
# Bash 3.2 on macOS treats "${empty_array[@]}" as unbound under `set -u`.

View file

@ -29,12 +29,15 @@ This directory now has a shared deployment model for OSS Docker installs. If you
- `scripts/lib/setup_common.sh` is the shared deployment helper library. It is sourced by `setup_local.sh`, `setup_remote.sh`, `update_remote.sh`, `setup_custom_domain.sh`, `run_dograh_init.sh`, and repo-root `remote_up.sh`.
- `setup_common.sh` must stay safe to source. It should not set shell options like `set -u` for callers.
- `.env` is the single operator-owned source of truth for remote deployment settings. Remote/runtime config should derive from it, not the other way around.
- Canonical remote keys in `.env`: `ENVIRONMENT`, `SERVER_IP`, `PUBLIC_HOST`, `PUBLIC_BASE_URL`, `BACKEND_API_ENDPOINT`, `MINIO_PUBLIC_ENDPOINT`, `TURN_HOST`, `TURN_SECRET`, `FASTAPI_WORKERS`, `OSS_JWT_SECRET`.
- Canonical remote keys in `.env`: `ENVIRONMENT`, `SERVER_IP`, `PUBLIC_HOST`, `PUBLIC_BASE_URL`, `TURN_SECRET`, `FASTAPI_WORKERS`, `OSS_JWT_SECRET`. `PUBLIC_BASE_URL` (+ `PUBLIC_HOST`, and `SERVER_IP` for coturn's literal `external-ip`) is the single endpoint source of truth.
- `BACKEND_API_ENDPOINT`, `MINIO_PUBLIC_ENDPOINT`, `TURN_HOST` are **derived in-app** from `PUBLIC_BASE_URL` / `PUBLIC_HOST` (`api/constants.py`) and are no longer written to a remote `.env`. `dograh_sync_remote_env_file` neither writes nor deletes them — new installs omit them, and a value an operator sets by hand is left untouched as an explicit override for a split deployment (separate object store / TURN host). `dograh_validate_remote_runtime_env` therefore no longer requires them or asserts they equal `PUBLIC_BASE_URL`.
- `remote_up.sh` is the supported remote startup entrypoint. It runs preflight via `dograh_prepare_remote_install`, runs `docker compose config -q`, then starts the stack.
- `docker-compose.yaml` uses a one-shot `dograh-init` service for profiles `remote` and `local-turn`.
- `cloudflared` is gated behind a `tunnel` profile (no longer always-on; `api` no longer `depends_on` it). `remote_up.sh` adds `--profile tunnel` when `SERVER_IP` is a private/reserved address (`dograh_is_local_ipv4`) — i.e. the host has no public IP; public-IP installs run `--profile remote` only and start no tunnel. Local installs opt in with `--profile tunnel`. The backend mirrors this exactly: `api/utils/common.py:is_local_or_private_url` decides when `get_backend_endpoints()` resolves the tunnel URL at runtime, so deploy-side and runtime stay in sync (keep the two IP classifiers aligned, incl. CGNAT 100.64.0.0/10).
- `cloudflared` picks a mode by token: with `CLOUDFLARE_TUNNEL_TOKEN` it runs a named tunnel (stable hostname — set `BACKEND_API_ENDPOINT` to it and point its Cloudflare-dashboard ingress at `http://api:8000`); without a token it runs a quick tunnel (ephemeral `*.trycloudflare.com`, discovered via the `:2000` metrics endpoint by `api/utils/tunnel.py`).
- `dograh-init` executes `scripts/run_dograh_init.sh`, which renders nginx/coturn runtime config into named volumes consumed by `nginx` and `coturn`.
- Remote nginx/coturn config is runtime-generated. Host-managed `nginx.conf` / `turnserver.conf` are legacy only; update flow may back them up and delete them, but current installs should not depend on them.
- `setup_remote.sh` writes `.env`, downloads the deployment helper bundle, generates self-signed certs, validates the init-based config, and tells operators to start via `./remote_up.sh` or `./remote_up.sh --build`.
- `setup_remote.sh` writes `.env`, downloads the deployment helper bundle, generates self-signed certs, validates the init-based config, and tells operators to start via `./remote_up.sh` or `./remote_up.sh --build`. It hard-requires root (a guard near the top exits non-root with a "re-run with sudo" message) because it provisions Docker, binds :80/:443, and installs a Let's Encrypt cert + system renewal hook. Cloud-init/user-data callers (e.g. `infrastructure/`) already run as root, so they pass; interactive callers must use `sudo`. Docs that invoke it (`docs/deployment/docker.mdx`, `docs/deployment/scaling.mdx`) and the hint printed by `setup_custom_domain.sh` all use `sudo`.
- `update_remote.sh` is the migration/upgrade path for prebuilt remote installs. It refreshes `docker-compose.yaml`, `remote_up.sh`, `scripts/run_dograh_init.sh`, `scripts/lib/setup_common.sh`, and `deploy/templates/*`, backs up touched files, removes legacy host `nginx.conf` / `turnserver.conf`, and revalidates the init-based path.
- `setup_custom_domain.sh` is certificate/domain glue only. It must not own nginx config. It updates canonical public URL keys in `.env`, copies Let's Encrypt certs into `certs/`, installs renewal hook, and restarts through `./remote_up.sh`.
- `setup_local.{sh,ps1}` has an interactive `Enable coturn? [y/N]` prompt unless `ENABLE_COTURN` is preset. If coturn is enabled, it downloads the minimal helper bundle needed for `local-turn` (`setup_common.sh`, `run_dograh_init.sh`, templates) and relies on `dograh-init` to render coturn config.

View file

@ -252,9 +252,11 @@ dograh_sync_remote_env_file() {
dograh_set_env_key "$env_file" SERVER_IP "$server_ip"
dograh_set_env_key "$env_file" PUBLIC_HOST "$public_host"
dograh_set_env_key "$env_file" PUBLIC_BASE_URL "$public_base_url"
dograh_set_env_key "$env_file" BACKEND_API_ENDPOINT "$public_base_url"
dograh_set_env_key "$env_file" MINIO_PUBLIC_ENDPOINT "$public_base_url"
dograh_set_env_key "$env_file" TURN_HOST "$public_host"
# BACKEND_API_ENDPOINT / MINIO_PUBLIC_ENDPOINT / TURN_HOST are derived in-app
# from PUBLIC_BASE_URL / PUBLIC_HOST (see api/constants.py), so sync neither
# writes nor removes them: new installs simply omit them, and any value an
# operator set by hand is left untouched as an explicit override.
}
dograh_validate_remote_runtime_env() {
@ -262,14 +264,12 @@ dograh_validate_remote_runtime_env() {
[[ -n "${TURN_SECRET:-}" ]] || dograh_fail "TURN_SECRET is missing"
[[ -n "${PUBLIC_HOST:-}" ]] || dograh_fail "PUBLIC_HOST is missing"
[[ -n "${PUBLIC_BASE_URL:-}" ]] || dograh_fail "PUBLIC_BASE_URL is missing"
[[ -n "${BACKEND_API_ENDPOINT:-}" ]] || dograh_fail "BACKEND_API_ENDPOINT is missing"
[[ -n "${MINIO_PUBLIC_ENDPOINT:-}" ]] || dograh_fail "MINIO_PUBLIC_ENDPOINT is missing"
[[ -n "${TURN_HOST:-}" ]] || dograh_fail "TURN_HOST is missing"
dograh_is_ipv4 "${SERVER_IP:-}" || dograh_fail "SERVER_IP must be a valid IPv4 address"
[[ "${PUBLIC_BASE_URL}" =~ ^https?:// ]] || dograh_fail "PUBLIC_BASE_URL must include http:// or https://"
[[ "${BACKEND_API_ENDPOINT}" == "${PUBLIC_BASE_URL}" ]] || dograh_fail "BACKEND_API_ENDPOINT must match PUBLIC_BASE_URL"
[[ "${MINIO_PUBLIC_ENDPOINT}" == "${PUBLIC_BASE_URL}" ]] || dograh_fail "MINIO_PUBLIC_ENDPOINT must match PUBLIC_BASE_URL"
[[ "${TURN_HOST}" == "${PUBLIC_HOST}" ]] || dograh_fail "TURN_HOST must match PUBLIC_HOST"
# BACKEND_API_ENDPOINT / MINIO_PUBLIC_ENDPOINT / TURN_HOST are derived in-app
# from PUBLIC_BASE_URL / PUBLIC_HOST (see api/constants.py), so they are not
# required here. When an operator sets them explicitly (split deployment),
# their value is honored as-is — no equality check.
}
dograh_uses_init_compose_layout() {
@ -401,6 +401,59 @@ dograh_preflight_remote_init_render() {
rm -rf "$tmp_root"
}
# Reconcile the running Postgres role password with POSTGRES_PASSWORD in .env.
#
# POSTGRES_PASSWORD only takes effect when the postgres data volume is first
# initialized. If the volume was created before .env had a generated password
# (e.g. an early start used the compose fallback `:-postgres`), or the password
# was later rotated, the role keeps its old password while the API connects with
# the .env value over TCP (pg_hba `scram-sha-256`) and dies with "password
# authentication failed for user postgres". start_docker.sh handles this for the
# OSS quickstart; the remote path (remote_up.sh) needs the same reconciliation.
#
# Bring postgres up on its own, then ALTER the role over the trusted local
# socket (pg_hba trusts `local`, so this works even when the password is
# currently mismatched). Idempotent: on a fresh volume it just re-sets the same
# value. Survives the later `--force-recreate` because the password lives in the
# data volume, not the container.
dograh_sync_postgres_password() {
local project_dir=$1
shift
local compose=("$@")
local env_file="$project_dir/.env"
local password=""
local ready=""
local i
[[ ${#compose[@]} -gt 0 ]] || compose=(docker compose)
if [[ -f "$env_file" ]]; then
password="$(awk -F= '/^POSTGRES_PASSWORD=/{sub(/^POSTGRES_PASSWORD=/, ""); print; exit}' "$env_file")"
fi
# No explicit password: the compose fallback (`:-postgres`) governs both the
# DB init and the API's DATABASE_URL, so the two already agree — nothing to do.
[[ -n "$password" ]] || return 0
dograh_info "Syncing Postgres password from .env..."
( cd "$project_dir" && "${compose[@]}" up -d postgres ) >/dev/null
for ((i = 0; i < 30; i++)); do
if ( cd "$project_dir" && "${compose[@]}" exec -T postgres pg_isready -U postgres ) >/dev/null 2>&1; then
ready=1
break
fi
sleep 1
done
[[ -n "$ready" ]] || dograh_fail "Postgres did not become ready while syncing POSTGRES_PASSWORD."
printf '%s\n' "ALTER USER postgres WITH PASSWORD :'pw';" \
| ( cd "$project_dir" && "${compose[@]}" exec -T postgres \
psql -U postgres -d postgres -v ON_ERROR_STOP=1 -v "pw=$password" ) >/dev/null \
|| dograh_fail "Failed to sync Postgres password from .env."
dograh_success "✓ Postgres password synced with .env"
}
dograh_prepare_remote_install() {
local project_dir=${1:-$(dograh_project_dir)}
local env_file="$project_dir/.env"
@ -410,6 +463,101 @@ dograh_prepare_remote_install() {
dograh_preflight_remote_init_render "$project_dir"
}
# ---------------------------------------------------------------------------
# TLS certificate helpers (self-signed bootstrap + Let's Encrypt via webroot)
# ---------------------------------------------------------------------------
# Map an IPv4 address to a public sslip.io / nip.io hostname, e.g.
# 203.0.113.10 -> 203-0-113-10.sslip.io. The hostname resolves back to the
# embedded IP from any public resolver, so Let's Encrypt can validate it over
# the HTTP-01 challenge without the operator owning a domain. Public IPs only:
# Let's Encrypt refuses to validate private/reserved addresses.
dograh_sslip_host_from_ip() {
local ip=$1
local suffix=${2:-sslip.io}
dograh_is_ipv4 "$ip" || dograh_fail "dograh_sslip_host_from_ip: '$ip' is not an IPv4 address"
printf '%s.%s\n' "${ip//./-}" "$suffix"
}
# Install certbot via the host package manager if it is not already present.
# Returns non-zero (instead of exiting) when no supported package manager is
# found or the install fails, so callers can fall back to a self-signed cert.
dograh_install_certbot() {
if command -v certbot >/dev/null 2>&1; then
return 0
fi
dograh_info "Installing Certbot..."
if command -v apt-get >/dev/null 2>&1; then
apt-get update -qq && apt-get install -y -qq certbot
elif command -v dnf >/dev/null 2>&1; then
dnf install -y -q certbot
elif command -v yum >/dev/null 2>&1; then
yum install -y -q certbot
else
dograh_warn "Could not detect a package manager (apt/dnf/yum) to install certbot."
return 1
fi
}
# Obtain (or renew) a Let's Encrypt certificate for $host using the webroot
# challenge served by the running nginx container out of <project>/certs, then
# copy the issued cert to certs/local.{crt,key} (the files nginx reads). This
# needs nginx already running and serving /.well-known/acme-challenge/ on :80.
# Returns non-zero on failure so callers can keep the self-signed cert.
dograh_issue_letsencrypt_webroot() {
local project_dir=$1
local host=$2
local email=${3:-}
local webroot="$project_dir/certs"
local live_dir="/etc/letsencrypt/live/$host"
local -a email_args
if [[ -n "$email" ]]; then
email_args=(--email "$email")
else
email_args=(--register-unsafely-without-email)
fi
mkdir -p "$webroot/.well-known/acme-challenge"
certbot certonly --webroot -w "$webroot" \
--non-interactive --agree-tos --keep-until-expiring \
"${email_args[@]}" \
-d "$host" || return 1
[[ -f "$live_dir/fullchain.pem" && -f "$live_dir/privkey.pem" ]] || return 1
cp "$live_dir/fullchain.pem" "$webroot/local.crt"
cp "$live_dir/privkey.pem" "$webroot/local.key"
chmod 644 "$webroot/local.crt" "$webroot/local.key"
}
# Install a certbot deploy hook so renewed certificates are copied into
# <project>/certs and nginx is restarted to load them. Renewal itself is driven
# by certbot's packaged systemd timer / cron; webroot renewals need no downtime
# because the running nginx serves the challenge.
dograh_install_cert_renewal_hook() {
local project_dir=$1
local host=$2
local hook_dir="/etc/letsencrypt/renewal-hooks/deploy"
local hook_path="$hook_dir/dograh-reload.sh"
mkdir -p "$hook_dir"
cat > "$hook_path" << HOOK_EOF
#!/bin/bash
cp /etc/letsencrypt/live/$host/fullchain.pem $project_dir/certs/local.crt
cp /etc/letsencrypt/live/$host/privkey.pem $project_dir/certs/local.key
chmod 644 $project_dir/certs/local.crt $project_dir/certs/local.key
cd $project_dir
docker compose --profile remote restart nginx 2>/dev/null || true
HOOK_EOF
chmod +x "$hook_path"
}
dograh_download_bundle_file_for_ref() {
local destination=$1
local remote_path=$2

View file

@ -28,6 +28,8 @@ NGINX_UPSTREAM_TEMPLATE="$BASE_DIR/nginx/dograh_upstream.conf.template"
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
@ -40,7 +42,9 @@ FASTAPI_WORKERS=${FASTAPI_WORKERS:-$CPU_CORES}
ARQ_WORKERS=${ARQ_WORKERS:-1}
# Tuning knobs (override via environment)
DRAIN_TIMEOUT=${DRAIN_TIMEOUT:-300} # seconds to wait for old workers to drain
DRAIN_TIMEOUT=${DRAIN_TIMEOUT:-300} # seconds to wait for active calls to finish
DRAIN_INTERVAL=${DRAIN_INTERVAL:-5} # seconds between active-call drain polls
STOP_TIMEOUT=${STOP_TIMEOUT:-30} # seconds to wait for drained workers to exit after SIGTERM
HEALTH_MAX_ATTEMPTS=${HEALTH_MAX_ATTEMPTS:-30} # per-worker health-check retries
HEALTH_INTERVAL=${HEALTH_INTERVAL:-2} # seconds between health-check retries
@ -54,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
@ -96,6 +109,41 @@ kill_process_tree() {
fi
}
# Active in-progress call count for a single worker, via its health endpoint.
# 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 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)
if [[ -z "$n" ]]; then
log_error "uvicorn_${port} active-calls endpoint returned an invalid response body."
return 1
fi
printf '%s' "$n"
}
###############################################################################
### ROLLBACK
###############################################################################
@ -366,9 +414,49 @@ log_info "nginx reloaded — traffic now routed to band $NEW_BAND"
### PHASE 5: DRAIN OLD WORKERS
###############################################################################
log_info "=== Phase 5: Draining old workers (band $OLD_BAND, timeout ${DRAIN_TIMEOUT}s) ==="
# nginx (Phase 4) already routes new calls to the new band, so the old band only
# holds calls still in progress. Wait for those to finish BEFORE signalling the
# workers: SIGTERM makes uvicorn force-close live call WebSockets (close code
# 1012), cutting calls mid-conversation. So we poll each old worker's in-flight
# call count and only stop once it reaches zero (or DRAIN_TIMEOUT elapses).
# Collect old worker PIDs
log_info "=== Phase 5a: Draining active calls from band $OLD_BAND (timeout ${DRAIN_TIMEOUT}s) ==="
drain_start=$(date +%s)
while true; do
active=0
for ((w = 0; w < FASTAPI_WORKERS; w++)); do
port=$((OLD_BASE + w))
# 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
if ! call_count=$(count_active_calls_on_port "$port"); then
exit 1
fi
active=$((active + call_count))
fi
done
if [[ $active -eq 0 ]]; then
log_info "Band $OLD_BAND fully drained — no active calls"
break
fi
elapsed=$(( $(date +%s) - drain_start ))
if [[ $elapsed -ge $DRAIN_TIMEOUT ]]; then
log_warn "Drain timeout reached (${DRAIN_TIMEOUT}s) with $active active call(s) still running — stopping anyway."
break
fi
log_info " Waiting for $active active call(s) to finish... (${elapsed}s / ${DRAIN_TIMEOUT}s)"
sleep "$DRAIN_INTERVAL"
done
log_info "=== Phase 5b: Stopping old workers (band $OLD_BAND, timeout ${STOP_TIMEOUT}s) ==="
# Calls are drained — now signal the workers and reap them. A drained worker
# exits within a second or two of SIGTERM; STOP_TIMEOUT bounds stragglers (e.g.
# a call that outlived DRAIN_TIMEOUT) before we force-kill.
OLD_PIDS=()
for ((w = 0; w < FASTAPI_WORKERS; w++)); do
port=$((OLD_BASE + w))
@ -385,7 +473,7 @@ for ((w = 0; w < FASTAPI_WORKERS; w++)); do
done
if [[ ${#OLD_PIDS[@]} -gt 0 ]]; then
start_time=$(date +%s)
stop_start=$(date +%s)
while true; do
all_dead=true
@ -397,13 +485,13 @@ if [[ ${#OLD_PIDS[@]} -gt 0 ]]; then
done
if $all_dead; then
log_info "All old workers exited gracefully"
log_info "All old workers exited"
break
fi
elapsed=$(( $(date +%s) - start_time ))
if [[ $elapsed -ge $DRAIN_TIMEOUT ]]; then
log_warn "Drain timeout reached (${DRAIN_TIMEOUT}s). Force-killing remaining old workers."
elapsed=$(( $(date +%s) - stop_start ))
if [[ $elapsed -ge $STOP_TIMEOUT ]]; then
log_warn "Stop timeout reached (${STOP_TIMEOUT}s). Force-killing remaining old workers."
for pid in "${OLD_PIDS[@]}"; do
if kill -0 "$pid" 2>/dev/null; then
kill_process_tree "$pid" "-KILL"
@ -414,11 +502,11 @@ if [[ ${#OLD_PIDS[@]} -gt 0 ]]; then
break
fi
log_info " Waiting for old workers to drain... (${elapsed}s / ${DRAIN_TIMEOUT}s)"
sleep 5
log_info " Waiting for old workers to exit... (${elapsed}s / ${STOP_TIMEOUT}s)"
sleep 2
done
else
log_warn "No old worker PIDs to drain"
log_warn "No old worker PIDs to stop"
fi
###############################################################################

View file

@ -43,7 +43,7 @@ if [[ ! -d "dograh" ]]; then
echo -e "${RED}Error: 'dograh' directory not found.${NC}"
echo -e "${YELLOW}Please run this script from the directory containing your Dograh installation.${NC}"
echo -e "${YELLOW}If you haven't set up Dograh yet, run the remote setup first:${NC}"
echo -e "${BLUE} curl -o setup_remote.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/setup_remote.sh && chmod +x setup_remote.sh && ./setup_remote.sh${NC}"
echo -e "${BLUE} curl -o setup_remote.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/setup_remote.sh && chmod +x setup_remote.sh && sudo ./setup_remote.sh${NC}"
exit 1
fi
@ -65,7 +65,7 @@ echo -e " Domain: ${BLUE}$DOMAIN_NAME${NC}"
echo -e " Email: ${BLUE}$EMAIL_ADDRESS${NC}"
echo ""
echo -e "${BLUE}[1/7] Verifying DNS configuration...${NC}"
echo -e "${BLUE}[1/6] Verifying DNS configuration...${NC}"
SERVER_IP="$(curl -s ifconfig.me || curl -s icanhazip.com || echo "")"
RESOLVED_IP="$(dig +short "$DOMAIN_NAME" | tail -1)"
@ -84,22 +84,14 @@ else
echo -e "${GREEN}✓ DNS is correctly configured (${RESOLVED_IP})${NC}"
fi
echo -e "${BLUE}[2/7] Installing Certbot...${NC}"
if command -v apt-get &> /dev/null; then
apt-get update -qq
apt-get install -y -qq certbot
elif command -v yum &> /dev/null; then
yum install -y -q certbot
elif command -v dnf &> /dev/null; then
dnf install -y -q certbot
else
dograh_fail "Could not detect package manager. Please install certbot manually."
fi
echo -e "${BLUE}[2/6] Installing Certbot...${NC}"
dograh_install_certbot || dograh_fail "Could not install certbot. Please install it manually and re-run."
echo -e "${GREEN}✓ Certbot installed${NC}"
echo -e "${BLUE}[3/7] Stopping Dograh services...${NC}"
echo -e "${BLUE}[3/6] Pointing .env at $DOMAIN_NAME and starting services...${NC}"
cd dograh
DOGRAH_DEPLOY_PROJECT_DIR="$(pwd)"
DOGRAH_PATH="$(pwd)"
if [[ ! -f remote_up.sh || ! -f scripts/lib/setup_common.sh ]]; then
dograh_download_remote_support_bundle "$(pwd)" "main"
@ -107,113 +99,74 @@ fi
dograh_require_init_compose_layout "$(pwd)"
if docker compose --profile remote ps --quiet 2>/dev/null | grep -q .; then
docker compose --profile remote down
echo -e "${GREEN}✓ Dograh services stopped${NC}"
else
echo -e "${YELLOW}⚠ No running services found${NC}"
fi
echo -e "${BLUE}[4/7] Generating Let's Encrypt SSL certificate...${NC}"
CERTBOT_OUTPUT=$(certbot certonly --standalone \
--non-interactive \
--agree-tos \
--email "$EMAIL_ADDRESS" \
-d "$DOMAIN_NAME" 2>&1) || {
echo -e "${RED}✗ Certificate generation failed${NC}"
echo ""
if echo "$CERTBOT_OUTPUT" | grep -qi "timeout\|firewall\|connection"; then
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${YELLOW} Port 80 appears to be blocked by a firewall.${NC}"
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
echo ""
echo -e "Let's Encrypt needs to connect to port 80 to verify domain ownership."
echo ""
elif echo "$CERTBOT_OUTPUT" | grep -qi "too many\|rate.limit"; then
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${YELLOW} Let's Encrypt rate limit reached.${NC}"
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
echo ""
echo "You've requested too many certificates recently."
echo "Please wait before trying again (usually 1 hour)."
echo ""
elif echo "$CERTBOT_OUTPUT" | grep -qi "dns\|resolve\|NXDOMAIN"; then
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${YELLOW} DNS resolution failed.${NC}"
echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}"
echo ""
echo "The domain '$DOMAIN_NAME' does not resolve to this server."
echo "Please verify your DNS A record is correctly configured."
echo ""
else
echo -e "${YELLOW}Certbot output:${NC}"
echo "$CERTBOT_OUTPUT"
echo ""
fi
echo -e "After fixing the issue, re-run this script:"
echo -e " ${BLUE}sudo ./setup_custom_domain.sh${NC}"
echo ""
exit 1
}
echo -e "${GREEN}✓ SSL certificate generated${NC}"
CERT_PATH="/etc/letsencrypt/live/$DOMAIN_NAME"
echo ""
echo -e "${BLUE}Certificate location:${NC}"
echo -e " ${CERT_PATH}/"
[[ -f "$CERT_PATH/fullchain.pem" ]] && echo -e " ${GREEN}${NC} fullchain.pem exists" || echo -e " ${RED}${NC} fullchain.pem NOT FOUND"
[[ -f "$CERT_PATH/privkey.pem" ]] && echo -e " ${GREEN}${NC} privkey.pem exists" || echo -e " ${RED}${NC} privkey.pem NOT FOUND"
echo ""
mkdir -p certs
cp "$CERT_PATH/fullchain.pem" certs/local.crt
cp "$CERT_PATH/privkey.pem" certs/local.key
chmod 644 certs/local.crt certs/local.key
echo -e "${GREEN}${NC} Certificates copied to certs/ directory"
echo ""
echo -e "${BLUE}[5/7] Updating canonical remote settings and validating init-based config...${NC}"
dograh_load_env_file .env
if [[ -z "${SERVER_IP:-}" ]]; then
SERVER_IP="$(dograh_infer_server_ip "$(pwd)" || true)"
fi
[[ -n "${SERVER_IP:-}" ]] || dograh_fail "Could not determine SERVER_IP from the existing install"
dograh_set_env_key .env SERVER_IP "$SERVER_IP"
dograh_set_env_key .env PUBLIC_HOST "$DOMAIN_NAME"
dograh_set_env_key .env PUBLIC_BASE_URL "https://$DOMAIN_NAME"
dograh_delete_env_key .env BACKEND_URL
# Switching domains is an explicit repoint of the whole deployment. Drop any
# legacy per-subsystem endpoint keys an older install pinned to the previous host
# so they re-derive from the new PUBLIC_BASE_URL / PUBLIC_HOST (see api/constants.py).
# No-op on current installs, which don't write these keys.
dograh_delete_env_key .env BACKEND_API_ENDPOINT
dograh_delete_env_key .env MINIO_PUBLIC_ENDPOINT
dograh_delete_env_key .env TURN_HOST
dograh_prepare_remote_install "$(pwd)"
echo -e "${GREEN}✓ .env synchronized and init-based config validated${NC}"
echo -e "${BLUE}[6/7] Setting up automatic certificate renewal...${NC}"
DOGRAH_PATH="$(pwd)"
# Bring the stack up (recreating it) so dograh-init re-renders nginx with the
# domain server_name and the ACME challenge location, served with the existing
# certificate. certbot --webroot then validates against the running nginx:
# no downtime, and (unlike --standalone) renewal keeps working later while
# nginx holds port 80.
./remote_up.sh
cat > /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh << HOOK_EOF
#!/bin/bash
cp /etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem $DOGRAH_PATH/certs/local.crt
cp /etc/letsencrypt/live/$DOMAIN_NAME/privkey.pem $DOGRAH_PATH/certs/local.key
chmod 644 $DOGRAH_PATH/certs/local.crt $DOGRAH_PATH/certs/local.key
echo -e "${BLUE}Waiting for nginx to answer on port 80...${NC}"
nginx_ready=0
for ((i=1; i<=60; i++)); do
if curl -s -o /dev/null --max-time 3 "http://127.0.0.1/"; then
nginx_ready=1
break
fi
sleep 2
done
[[ "$nginx_ready" == "1" ]] || dograh_fail "nginx did not come up on port 80; cannot run the ACME challenge."
echo -e "${GREEN}✓ Services running and serving the ACME challenge${NC}"
cd $DOGRAH_PATH
docker compose --profile remote restart nginx 2>/dev/null || true
HOOK_EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh
echo -e "${BLUE}[4/6] Obtaining Let's Encrypt certificate for $DOMAIN_NAME...${NC}"
if ! dograh_issue_letsencrypt_webroot "$(pwd)" "$DOMAIN_NAME" "$EMAIL_ADDRESS"; then
echo -e "${RED}✗ Certificate issuance failed${NC}"
echo ""
echo -e "${YELLOW}Common causes:${NC}"
echo " - Port 80 not reachable from the internet (open it in your firewall)"
echo " - DNS A record for $DOMAIN_NAME does not point to this server yet"
echo " - Let's Encrypt rate limit reached (wait, then retry)"
echo " - Upgrading an older install: run ./update_remote.sh first to refresh the"
echo " nginx template so it serves the ACME challenge, then re-run this script"
echo ""
echo -e "The stack is still running with the previous certificate."
echo -e "After fixing the issue, re-run: ${BLUE}sudo ./setup_custom_domain.sh${NC}"
echo ""
exit 1
fi
echo -e "${GREEN}✓ Certificate issued and copied to certs/${NC}"
echo -e "${BLUE}[5/6] Loading the new certificate (restarting nginx)...${NC}"
docker compose --profile remote restart nginx >/dev/null 2>&1 || true
echo -e "${GREEN}✓ nginx restarted${NC}"
echo -e "${BLUE}[6/6] Configuring automatic certificate renewal...${NC}"
dograh_install_cert_renewal_hook "$(pwd)" "$DOMAIN_NAME"
if certbot renew --dry-run --quiet; then
echo -e "${GREEN}✓ Auto-renewal configured and tested${NC}"
else
echo -e "${YELLOW}⚠ Auto-renewal test had issues, but certificates are installed${NC}"
echo -e "${YELLOW}⚠ Auto-renewal dry-run had issues, but the certificate is installed${NC}"
fi
echo ""
echo -e "${BLUE}[7/7] Starting Dograh services through validated startup wrapper...${NC}"
./remote_up.sh
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Custom Domain Setup Complete! ║${NC}"

View file

@ -307,13 +307,16 @@ Write-Host ''
if ($UseCoturn) {
Write-Warn 'To start Dograh with TURN, run:'
Write-Host ''
Write-Host ' docker compose --profile local-turn up --pull always' -ForegroundColor Blue
Write-Host ' docker compose --profile local-turn --profile tunnel up --pull always' -ForegroundColor Blue
} else {
Write-Warn 'To start Dograh, run:'
Write-Host ''
Write-Host ' docker compose up --pull always' -ForegroundColor Blue
Write-Host ' docker compose --profile tunnel up --pull always' -ForegroundColor Blue
}
Write-Host ''
Write-Host 'This starts a Cloudflare quick tunnel so inbound telephony webhooks can' -ForegroundColor Yellow
Write-Host 'reach your local API over a temporary public URL.' -ForegroundColor Yellow
Write-Host ''
Write-Warn 'Your application will be available at:'
Write-Host ''
Write-Host ' http://localhost:3010' -ForegroundColor Blue

View file

@ -211,13 +211,16 @@ echo ""
if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then
echo -e "${YELLOW}To start Dograh with TURN, run:${NC}"
echo ""
echo -e " ${BLUE}docker compose --profile local-turn up --pull always${NC}"
echo -e " ${BLUE}docker compose --profile local-turn --profile tunnel up --pull always${NC}"
else
echo -e "${YELLOW}To start Dograh, run:${NC}"
echo ""
echo -e " ${BLUE}docker compose up --pull always${NC}"
echo -e " ${BLUE}docker compose --profile tunnel up --pull always${NC}"
fi
echo ""
echo -e "${YELLOW}This starts a Cloudflare quick tunnel so inbound telephony webhooks can${NC}"
echo -e "${YELLOW}reach your local API over a temporary public URL.${NC}"
echo ""
echo -e "${YELLOW}Your application will be available at:${NC}"
echo ""
echo -e " ${BLUE}http://localhost:3010${NC}"

View file

@ -35,9 +35,17 @@ echo "║ Automated HTTPS deployment with TURN server ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo -e "${NC}"
# Get the public IP address (skip prompt if SERVER_IP is already set)
# This setup must run as root: it provisions Docker, binds privileged ports
# 80/443, and (for public IPs) installs a Let's Encrypt certificate plus a
# system renewal hook under /etc/letsencrypt — all of which require root. Stop
# early with clear guidance rather than getting halfway and degrading the install.
if [[ $EUID -ne 0 ]]; then
dograh_fail "setup_remote.sh must be run as root.\nRe-run with sudo:\n sudo ./setup_remote.sh"
fi
# Get the server IP address (skip prompt if SERVER_IP is already set)
if [[ -z "${SERVER_IP:-}" ]]; then
echo -e "${YELLOW}Enter your server's public IP address:${NC}"
echo -e "${YELLOW}Enter your server's IP address:${NC}"
read -p "> " SERVER_IP
fi
@ -49,6 +57,61 @@ if ! dograh_is_ipv4 "$SERVER_IP"; then
dograh_fail "Invalid IP address format"
fi
# Certificate strategy. CERT_MODE selects how HTTPS is secured:
# auto - public IP + root + docker -> sslip (trusted); otherwise self-signed
# sslip - free trusted Let's Encrypt cert via <ip>.sslip.io (public IP only)
# self-signed - generate a self-signed cert (browser shows a warning)
# Reserved for future private-network paths (not implemented yet):
# letsencrypt-dns, cloudflare-tunnel, external
CERT_MODE="${CERT_MODE:-auto}"
ACME_DOMAIN_SUFFIX="${ACME_DOMAIN_SUFFIX:-sslip.io}"
LETSENCRYPT_EMAIL="${LETSENCRYPT_EMAIL:-}"
if [[ "$CERT_MODE" == "auto" ]]; then
if dograh_is_local_ipv4 "$SERVER_IP"; then
CERT_MODE="self-signed"
dograh_warn "$SERVER_IP is a private IP — using a self-signed certificate."
dograh_warn "For a trusted cert, deploy on a public IP or a domain you own"
dograh_warn "(https://docs.dograh.com/deployment/custom-domain)."
elif ! command -v docker >/dev/null 2>&1; then
CERT_MODE="self-signed"
dograh_warn "Docker not found — skipping automatic Let's Encrypt setup and using a self-signed cert."
else
CERT_MODE="sslip"
fi
fi
case "$CERT_MODE" in
self-signed) ;;
sslip)
if dograh_is_local_ipv4 "$SERVER_IP"; then
dograh_fail "CERT_MODE=sslip needs a public IP; $SERVER_IP is private/reserved."
fi
command -v docker >/dev/null 2>&1 || dograh_fail "CERT_MODE=sslip needs Docker to serve the ACME challenge."
;;
letsencrypt-dns|cloudflare-tunnel|external)
dograh_fail "CERT_MODE=$CERT_MODE is reserved but not implemented yet. Use 'sslip' (public IP) or 'self-signed'."
;;
*)
dograh_fail "Unknown CERT_MODE '$CERT_MODE' (expected: auto, sslip, self-signed)."
;;
esac
if [[ "$CERT_MODE" == "sslip" ]]; then
PUBLIC_HOST_VALUE="$(dograh_sslip_host_from_ip "$SERVER_IP" "$ACME_DOMAIN_SUFFIX")"
CERT_DESC="Let's Encrypt via $ACME_DOMAIN_SUFFIX (trusted)"
else
PUBLIC_HOST_VALUE="$SERVER_IP"
CERT_DESC="self-signed (browser warning)"
fi
CERT_RESULT="$CERT_MODE"
if [[ "$CERT_MODE" == "sslip" && -z "$LETSENCRYPT_EMAIL" && -t 0 ]]; then
echo ""
echo -e "${YELLOW}Email for Let's Encrypt expiry notices (optional, press Enter to skip):${NC}"
read -p "> " LETSENCRYPT_EMAIL
fi
FORCE_TURN_RELAY="${FORCE_TURN_RELAY:-false}"
# Get the TURN secret (skip prompt if TURN_SECRET is already set)
@ -185,6 +248,8 @@ fi
echo ""
echo -e "${GREEN}Configuration:${NC}"
echo -e " Server IP: ${BLUE}$SERVER_IP${NC}"
echo -e " Public host: ${BLUE}$PUBLIC_HOST_VALUE${NC}"
echo -e " Certificate: ${BLUE}$CERT_DESC${NC}"
echo -e " TURN Secret: ${BLUE}********${NC}"
echo -e " Deploy mode: ${BLUE}$DEPLOY_MODE${NC}"
echo -e " Force TURN relay: ${BLUE}$FORCE_TURN_RELAY${NC}"
@ -240,7 +305,7 @@ openssl req -x509 -nodes -newkey rsa:2048 \\
-keyout certs/local.key \\
-out certs/local.crt \\
-days 365 \\
-subj "/CN=$SERVER_IP"
-subj "/CN=$PUBLIC_HOST_VALUE"
CERT_EOF
chmod +x generate_certificate.sh
echo -e "${GREEN}✓ generate_certificate.sh created${NC}"
@ -260,19 +325,16 @@ cat > .env << ENV_EOF
# Remote deployments run with production signaling and HTTPS defaults
ENVIRONMENT=production
# Canonical public host/base URL for this install.
# Canonical public host/base URL for this install. SERVER_IP stays the raw IP
# (coturn external-ip and validation need it); PUBLIC_HOST is the sslip.io
# hostname when using a trusted cert, otherwise the IP. BACKEND_API_ENDPOINT,
# MINIO_PUBLIC_ENDPOINT and TURN_HOST are derived from these by the API
# (see api/constants.py) — set them here only to override for a split deployment.
SERVER_IP=$SERVER_IP
PUBLIC_HOST=$SERVER_IP
PUBLIC_BASE_URL=https://$SERVER_IP
# Backend API endpoint (public URL the backend uses to build webhook/embed links)
BACKEND_API_ENDPOINT=https://$SERVER_IP
# Public URL browsers use to fetch objects from MinIO (proxied by nginx)
MINIO_PUBLIC_ENDPOINT=https://$SERVER_IP
PUBLIC_HOST=$PUBLIC_HOST_VALUE
PUBLIC_BASE_URL=https://$PUBLIC_HOST_VALUE
# TURN Server Configuration (time-limited credentials via TURN REST API)
TURN_HOST=$SERVER_IP
TURN_SECRET=$TURN_SECRET
# Relay-only ICE candidates for explicit TURN diagnostics
FORCE_TURN_RELAY=$FORCE_TURN_RELAY
@ -332,6 +394,46 @@ OVERRIDE_EOF
echo -e "${GREEN}✓ docker-compose.override.yaml created${NC}"
fi
if [[ "$CERT_MODE" == "sslip" ]]; then
echo ""
echo -e "${BLUE}Starting Dograh and requesting a trusted certificate for ${PUBLIC_HOST_VALUE}...${NC}"
if [[ "$DEPLOY_MODE" == "build" ]]; then
./remote_up.sh --build
else
./remote_up.sh
fi
echo -e "${BLUE}Waiting for nginx to answer on port 80...${NC}"
nginx_ready=0
for ((i=1; i<=60; i++)); do
if curl -s -o /dev/null --max-time 3 "http://127.0.0.1/"; then
nginx_ready=1
break
fi
sleep 2
done
if [[ "$nginx_ready" != "1" ]]; then
CERT_RESULT="self-signed"
dograh_warn "nginx did not become reachable on port 80 — skipping Let's Encrypt for now."
dograh_warn "The stack is running with the bootstrap self-signed certificate."
elif dograh_install_certbot && dograh_issue_letsencrypt_webroot "$(pwd)" "$PUBLIC_HOST_VALUE" "$LETSENCRYPT_EMAIL"; then
docker compose --profile remote restart nginx >/dev/null 2>&1 || true
dograh_install_cert_renewal_hook "$(pwd)" "$PUBLIC_HOST_VALUE"
CERT_RESULT="sslip"
dograh_success "✓ Trusted Let's Encrypt certificate installed; auto-renewal configured"
else
CERT_RESULT="self-signed"
echo ""
dograh_warn "Let's Encrypt issuance failed — the stack is running with the self-signed certificate."
dograh_warn "Common causes and fixes:"
dograh_warn " - Port 80 not reachable from the internet: open it in your firewall/security group"
dograh_warn " - Rate limited on ${ACME_DOMAIN_SUFFIX}: re-run with ACME_DOMAIN_SUFFIX=nip.io"
dograh_warn " - Then retry: sudo certbot certonly --webroot -w \"$(pwd)/certs\" -d ${PUBLIC_HOST_VALUE}"
fi
fi
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Setup Complete! ║${NC}"
@ -350,25 +452,42 @@ echo " - certs/local.crt"
echo " - certs/local.key"
echo " - .env"
echo ""
echo -e "${YELLOW}To start Dograh, run:${NC}"
echo ""
if [[ "$DEPLOY_MODE" != "build" || "${REPO_SOURCE:-}" != "existing" ]]; then
echo -e " ${BLUE}cd $(pwd)${NC}"
fi
if [[ "$DEPLOY_MODE" == "build" ]]; then
echo -e " ${BLUE}./remote_up.sh --build${NC}"
echo ""
echo -e "${YELLOW}A docker-compose.override.yaml has been created alongside${NC}"
echo -e "${YELLOW}docker-compose.yaml. Compose auto-loads it, so no -f flag is${NC}"
echo -e "${YELLOW}needed — it swaps the prebuilt images for local builds.${NC}"
if [[ "$CERT_MODE" == "sslip" ]]; then
if [[ "$CERT_RESULT" == "sslip" ]]; then
echo -e "${GREEN}Dograh is running with a trusted certificate at:${NC}"
echo ""
echo -e " ${BLUE}https://$PUBLIC_HOST_VALUE${NC}"
echo ""
echo -e "${GREEN}No browser warning — the certificate renews automatically before expiry.${NC}"
else
echo -e "${YELLOW}Dograh is running (with a temporary self-signed certificate) at:${NC}"
echo ""
echo -e " ${BLUE}https://$PUBLIC_HOST_VALUE${NC}"
echo ""
echo -e "${YELLOW}Let's Encrypt issuance did not complete (see the message above). Your${NC}"
echo -e "${YELLOW}browser will warn until a trusted certificate is issued.${NC}"
fi
else
echo -e " ${BLUE}./remote_up.sh${NC}"
echo -e "${YELLOW}To start Dograh, run:${NC}"
echo ""
if [[ "$DEPLOY_MODE" != "build" || "${REPO_SOURCE:-}" != "existing" ]]; then
echo -e " ${BLUE}cd $(pwd)${NC}"
fi
if [[ "$DEPLOY_MODE" == "build" ]]; then
echo -e " ${BLUE}./remote_up.sh --build${NC}"
echo ""
echo -e "${YELLOW}A docker-compose.override.yaml has been created alongside${NC}"
echo -e "${YELLOW}docker-compose.yaml. Compose auto-loads it, so no -f flag is${NC}"
echo -e "${YELLOW}needed — it swaps the prebuilt images for local builds.${NC}"
else
echo -e " ${BLUE}./remote_up.sh${NC}"
fi
echo ""
echo -e "${YELLOW}Your application will be available at:${NC}"
echo ""
echo -e " ${BLUE}https://$PUBLIC_HOST_VALUE${NC}"
echo ""
echo -e "${YELLOW}Note:${NC} Your browser will show a security warning for the self-signed"
echo "certificate. You can safely accept it to proceed."
fi
echo ""
echo -e "${YELLOW}Your application will be available at:${NC}"
echo ""
echo -e " ${BLUE}https://$SERVER_IP${NC}"
echo ""
echo -e "${YELLOW}Note:${NC} Your browser will show a security warning for the self-signed"
echo "certificate. You can safely accept it to proceed."
echo ""

View file

@ -207,10 +207,9 @@ if ([string]::IsNullOrEmpty($existingMinioRootPassword)) {
Write-Host ''
Write-Host "Docker registry: $Registry"
Write-Host "Telemetry enabled: $EnableTelemetry"
Write-Host ''
Write-Host 'This will run:'
Write-Host " `$env:REGISTRY = '$Registry'; `$env:ENABLE_TELEMETRY = '$EnableTelemetry'; docker compose up --pull always"
Write-Host " `$env:REGISTRY = '$Registry'; `$env:ENABLE_TELEMETRY = '$EnableTelemetry'; docker compose --profile tunnel up --pull always"
Write-Host ''
$answer = Read-Host 'Start Dograh now? [Y/n]'
@ -222,7 +221,7 @@ if ($answer -match '^[Nn]') {
$env:REGISTRY = $Registry
$env:ENABLE_TELEMETRY = $EnableTelemetry
Sync-PostgresPassword -Password (Get-DotEnvValue -Path $EnvFile -Key 'POSTGRES_PASSWORD')
docker compose up --pull always
docker compose --profile tunnel up --pull always
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}

View file

@ -200,10 +200,9 @@ fi
echo ""
echo "Docker registry: $REGISTRY"
echo "Telemetry enabled: $ENABLE_TELEMETRY"
echo ""
echo "This will run:"
echo " REGISTRY=$REGISTRY ENABLE_TELEMETRY=$ENABLE_TELEMETRY docker compose up --pull always"
echo " REGISTRY=$REGISTRY ENABLE_TELEMETRY=$ENABLE_TELEMETRY docker compose --profile tunnel up --pull always"
echo ""
if [[ ! -t 0 ]]; then
@ -222,4 +221,4 @@ esac
postgres_password="$(dotenv_value POSTGRES_PASSWORD || true)"
sync_postgres_password "$postgres_password"
REGISTRY="$REGISTRY" ENABLE_TELEMETRY="$ENABLE_TELEMETRY" docker compose up --pull always
REGISTRY="$REGISTRY" ENABLE_TELEMETRY="$ENABLE_TELEMETRY" docker compose --profile tunnel up --pull always

View file

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

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.YWer7ilGLp
# timestamp: 2026-06-24T16:36:26+00:00
# filename: dograh-openapi-XXXXXX.json.IQaxuC56sd
# timestamp: 2026-06-29T10:55:38+00:00
from __future__ import annotations

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