Merge branch 'main' into feat/vici-dial

This commit is contained in:
Abhishek Kumar 2026-07-20 14:24:49 +05:30
commit d6996be920
455 changed files with 33133 additions and 11135 deletions

View file

@ -30,8 +30,8 @@ services:
- dograh-ui-node_modules:/workspaces/dograh/ui/node_modules
- dograh-ts-validator-node_modules:/workspaces/dograh/api/mcp_server/ts_validator/node_modules
ports:
- "127.0.0.1:3000:3000"
- "127.0.0.1:8000:8000"
- "0.0.0.0:3000:3000"
- "0.0.0.0:8000:8000"
volumes:
dograh-venv:
dograh-ui-node_modules:

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.42.0"
}

5
.vscode/launch.json vendored
View file

@ -22,8 +22,9 @@
"args": [
"api.app:app",
"--reload",
"--host", "0.0.0.0",
"--port", "8000"
"--host", "0.0.0.0"
// Port comes from UVICORN_PORT in api/.env (per-worktree);
// unset -> uvicorn's default 8000. See scripts/worktree-assign-port.sh.
],
"cwd": "${workspaceFolder}",
"envFile": "${workspaceFolder}/api/.env",

View file

@ -1,3 +1,9 @@
{
"python.defaultInterpreterPath": "${workspaceFolder}/venv/bin/python"
"python.defaultInterpreterPath": "${workspaceFolder}/venv/bin/python",
"git.detectWorktrees": true,
"git.worktreeIncludeFiles": [
"api/.env",
"api/.env.test",
"ui/.env"
]
}

53
.vscode/tasks.json vendored Normal file
View file

@ -0,0 +1,53 @@
{
// Tasks that auto-run when a worktree folder is opened (runOptions.runOn:
// folderOpen). First time, VS Code asks to "Allow Automatic Tasks in Folder"
// (or set task.allowAutomaticTasks: "on" in User settings to skip the prompt).
// - "Assign worktree port" : fast; sets UVICORN_PORT + UI BACKEND_URLs
// - "Set up worktree environment" : heavy first time (submodule + venv +
// deps), instant skip after run-once via
// venv/.worktree-setup-complete. Follow it:
// tail -f logs/setup-worktree.log
"version": "2.0.0",
"tasks": [
{
"label": "Assign worktree port",
"type": "shell",
"command": "${workspaceFolder}/scripts/worktree-assign-port.sh",
"presentation": {
"reveal": "silent",
"panel": "shared",
"close": true
},
"runOptions": {
"runOn": "folderOpen"
},
"problemMatcher": []
},
{
"label": "Set up worktree environment",
"type": "shell",
"command": "${workspaceFolder}/scripts/setup-worktree.sh",
"args": ["--if-needed"],
"presentation": {
"reveal": "silent",
"panel": "dedicated"
},
"runOptions": {
"runOn": "folderOpen"
},
"problemMatcher": []
},
{
// Manual: force a full re-provision, ignoring the run-once sentinel.
// Run via: Tasks: Run Task -> "Re-setup worktree (force)".
"label": "Re-setup worktree (force)",
"type": "shell",
"command": "${workspaceFolder}/scripts/setup-worktree.sh",
"presentation": {
"reveal": "always",
"panel": "dedicated"
},
"problemMatcher": []
}
]
}

View file

@ -29,7 +29,7 @@ Contributor setup and service startup are documented in `docs/contribution/setup
## Environment Configuration
- `api/.env` - Backend environment variables. Source this when running diagnostic scripts or one-off services against the dev DB (e.g. `python -m api.services.admin_utils.local_exec`).
- `api/.env` - Backend environment variables. Source this when running repo-owned backend scripts against the dev DB (e.g. `python -m scripts.dump_docs_openapi`).
- `api/.env.test` - Test-only environment variables. Source this when running pytest so tests hit the test DB and never the dev/prod credentials in `api/.env`.
- `ui/.env` - Frontend environment variables
@ -39,6 +39,6 @@ Typical invocation:
# Tests
source venv/bin/activate && set -a && source api/.env.test && set +a && python -m pytest api/tests/...
# Diagnostics / scripts
source venv/bin/activate && set -a && source api/.env && set +a && python -m api.services.admin_utils.local_exec
# Backend scripts
source venv/bin/activate && set -a && source api/.env && set +a && python -m scripts.dump_docs_openapi
```

View file

@ -1,5 +1,125 @@
# Changelog
## 1.42.0 (2026-07-15)
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Features
* feat(tts): add xAI as a Voice (TTS) provider by @xTararAisx in https://github.com/dograh-hq/dograh/pull/476
* feat(auth): gate OSS signup behind ENABLE_SIGNUP flag by @prabhatlepton in https://github.com/dograh-hq/dograh/pull/514
* feat: show model pricing in configuration UI by @a6kme in https://github.com/dograh-hq/dograh/pull/528
* feat: add ElevenLabs realtime STT provider support (#512) by @mqasim41 in https://github.com/dograh-hq/dograh/pull/522
* feat(helm): add HPA for arq-worker + ui, ship a lean k3s prod example by @prabhatlepton in https://github.com/dograh-hq/dograh/pull/516
### Bug Fixes
* fix: gate OSS email/password auth endpoints outside local auth mode by @a6kme in https://github.com/dograh-hq/dograh/pull/500
* fix: forward billing-v2 protocol on textchat KB retrieval and node su… by @a6kme in https://github.com/dograh-hq/dograh/pull/503
* fix: fix agent stream contract with cloudonix by @a6kme in https://github.com/dograh-hq/dograh/pull/504
* fix(auth): allow invited org members to start workflow runs by @KomalSrinivasan in https://github.com/dograh-hq/dograh/pull/509
* fix: increase concurrency limit an handle it across all call paths by @a6kme in https://github.com/dograh-hq/dograh/pull/508
* fix: fix org scoped access for resources by @a6kme in https://github.com/dograh-hq/dograh/pull/517
* fix: fix realtime event ordering by @a6kme in https://github.com/dograh-hq/dograh/pull/534
* fix(quota): fail closed when quota verification errors (#331) by @amaanJvd in https://github.com/dograh-hq/dograh/pull/523
* fix: fix speech to speech model transitions by @a6kme in https://github.com/dograh-hq/dograh/pull/545
### Documentation
* docs: add video-embedded getting-started pages for API Trigger, Webhook, Telephony, Tools & KB by @rushilbh27 in https://github.com/dograh-hq/dograh/pull/535
### Other Changes
* Feat/enhanced timestamped transcript by @chewwbaka in https://github.com/dograh-hq/dograh/pull/501
* Add MiniMax M3 model option by @octo-patch in https://github.com/dograh-hq/dograh/pull/513
* Feat/dybamic transfer by @chewwbaka in https://github.com/dograh-hq/dograh/pull/521
* Paygent integration new with revert pipecat/realtime changes by @nihal0514 in https://github.com/dograh-hq/dograh/pull/539
* Cloudonix Transfer Feature by @greenfieldtech-nirs in https://github.com/dograh-hq/dograh/pull/542
* Improve outbound dialing error handling by @greenfieldtech-nirs in https://github.com/dograh-hq/dograh/pull/544
## New Contributors
* @KomalSrinivasan made their first contribution in https://github.com/dograh-hq/dograh/pull/509
* @prabhatlepton made their first contribution in https://github.com/dograh-hq/dograh/pull/514
* @amaanJvd made their first contribution in https://github.com/dograh-hq/dograh/pull/523
* @nihal0514 made their first contribution in https://github.com/dograh-hq/dograh/pull/539
**Full Changelog**: https://github.com/dograh-hq/dograh/compare/dograh-v1.41.0...dograh-v1.42.0
## 1.41.0 (2026-07-06)
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Features
* feat: client gen default configurations by @a6kme in https://github.com/dograh-hq/dograh/pull/499
### Bug Fixes
* fix: clean up ARI transferred call legs on participant hangup by @chewwbaka in https://github.com/dograh-hq/dograh/pull/498
**Full Changelog**: https://github.com/dograh-hq/dograh/compare/dograh-v1.40.0...dograh-v1.41.0
## 1.40.0 (2026-07-03)
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### Features
* feat: support inbound vonage calls by @chewwbaka in https://github.com/dograh-hq/dograh/pull/480
* feat: better interrupt strategies by @a6kme in https://github.com/dograh-hq/dograh/pull/479
* feat(webhooks): durable retrying delivery for final webhooks by @xTararAisx in https://github.com/dograh-hq/dograh/pull/478
* feat: add Helm chart for Kubernetes deployment by @a6kme in https://github.com/dograh-hq/dograh/pull/365
### Bug Fixes
* fix: fix initial greeting for realtime models by @a6kme in https://github.com/dograh-hq/dograh/pull/481
* fix: guard Chatwoot bubble toggle until holder is in the DOM by @pk-198 in https://github.com/dograh-hq/dograh/pull/485
### Documentation
* docs: fix dead entry points, add first-agent tutorial, explain unexplained features by @rushilbh27 in https://github.com/dograh-hq/dograh/pull/489
* docs: add missing cross-links for machine and human readability by @rushilbh27 in https://github.com/dograh-hq/dograh/pull/492
* docs: clarify Asterisk ARI websocket_client.conf URI and why /ws/ari 403s when tested directly by @mvanhorn in https://github.com/dograh-hq/dograh/pull/490
### Other Changes
* embed cal and chatwoot bubble missing fix by @pk-198 in https://github.com/dograh-hq/dograh/pull/483
* Docs/add japanese readme by @sscodeai in https://github.com/dograh-hq/dograh/pull/477
* Implement cost calculator for Tuber by @Mohamed-Mamdouh in https://github.com/dograh-hq/dograh/pull/471
## New Contributors
* @pk-198 made their first contribution in https://github.com/dograh-hq/dograh/pull/483
* @sscodeai made their first contribution in https://github.com/dograh-hq/dograh/pull/477
* @rushilbh27 made their first contribution in https://github.com/dograh-hq/dograh/pull/489
* @xTararAisx made their first contribution in https://github.com/dograh-hq/dograh/pull/478
**Full Changelog**: https://github.com/dograh-hq/dograh/compare/dograh-v1.39.0...dograh-v1.40.0
## 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

@ -1,377 +0,0 @@
# Voice AI on GCP — Private VPC Deployment Playbook
A step-by-step guide to deploying a voice AI stack (Dograh + Claude/Gemini + STT/TTS) entirely within a customer's GCP project, with no data leaving their VPC.
---
## Architecture overview
One VPC in the customer's GCP project. Inside it:
- A **GKE cluster** running Dograh (orchestration + Pipecat pipeline).
- A single **Private Service Connect (PSC) endpoint** to the `all-apis` bundle. This gives private access to *every* `*.googleapis.com` service in one shot — Vertex AI (Claude + Gemini), Cloud Speech-to-Text, Cloud Text-to-Speech, Cloud Storage, KMS, everything.
- Third-party TTS/STT (ElevenLabs or Deepgram) either as a Vertex Model Garden partner endpoint or as a Helm chart on the same GKE cluster.
- A **VPC Service Controls perimeter** around the project so leaked credentials can't exfiltrate data outside the perimeter.
```
┌─────────────────────────── Customer GCP Project ───────────────────────────┐
│ ┌─────────────────────── VPC: dograh-vpc ─────────────────────────────┐ │
│ │ │ │
│ │ ┌──────────────────┐ ┌──────────────────────────┐ │ │
│ │ │ GKE Cluster │ ────▶ │ PSC Endpoint │ ──▶ Vertex AI
│ │ │ (Dograh pods) │ │ 192.168.255.230 │ ──▶ Cloud STT
│ │ │ + optional │ │ target: all-apis bundle │ ──▶ Cloud TTS
│ │ │ Deepgram pods │ └──────────────────────────┘ │ │
│ │ └──────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ VPC Service Controls perimeter (deny egress outside perimeter) │
└────────────────────────────────────────────────────────────────────────────┘
```
Traffic from GKE pods to Vertex AI, STT, and TTS resolves via private DNS to the PSC IP and stays entirely on Google's private backbone. The model inference itself still runs on Vertex's managed GPUs, but no audio or prompt content is accessible to Google or Anthropic.
---
## Phase 1 — VPC and PSC endpoint to Google APIs
This is the single piece of plumbing that gives Dograh private access to Vertex AI (Claude/Gemini), Cloud STT, and Cloud TTS.
### 1.1 Set variables and enable APIs
```bash
export PROJECT_ID=$(gcloud config get-value project)
export NETWORK=dograh-vpc
export REGION=us-east1
export PSC_IP=192.168.255.230 # any unused internal IP
gcloud services enable \
compute.googleapis.com \
aiplatform.googleapis.com \
speech.googleapis.com \
texttospeech.googleapis.com \
dns.googleapis.com \
servicedirectory.googleapis.com \
container.googleapis.com
```
### 1.2 Create VPC and subnet
```bash
gcloud compute networks create $NETWORK \
--subnet-mode=custom \
--bgp-routing-mode=global \
--mtu=1460
gcloud compute networks subnets create dograh-subnet \
--network=$NETWORK \
--range=10.0.0.0/20 \
--region=$REGION \
--enable-private-ip-google-access
```
The `--enable-private-ip-google-access` flag is required for VMs without external IPs to reach Google APIs through the PSC endpoint.
### 1.3 Reserve internal IP and create the PSC forwarding rule
```bash
gcloud compute addresses create dograh-psc-ip \
--global \
--purpose=PRIVATE_SERVICE_CONNECT \
--addresses=$PSC_IP \
--network=$NETWORK
gcloud compute forwarding-rules create dograh-psc-googleapis \
--global \
--network=$NETWORK \
--address=dograh-psc-ip \
--target-google-apis-bundle=all-apis
```
### 1.4 Wire up private DNS
```bash
gcloud dns managed-zones create googleapis-private \
--description="Private DNS for googleapis.com" \
--dns-name="googleapis.com." \
--visibility="private" \
--networks=$NETWORK
gcloud dns record-sets create "googleapis.com." \
--zone=googleapis-private \
--type=A \
--ttl=300 \
--rrdatas=$PSC_IP
gcloud dns record-sets create "*.googleapis.com." \
--zone=googleapis-private \
--type=CNAME \
--ttl=300 \
--rrdatas="googleapis.com."
```
### 1.5 Verify
From any VM inside the VPC:
```bash
dig aiplatform.googleapis.com +short
# Should return 192.168.255.230, not a Google public IP
```
---
## Phase 2 — Enable Claude and Gemini in Model Garden
These run on Vertex's managed infrastructure. The PSC endpoint from Phase 1 gives the private network path.
In the Cloud Console → **Vertex AI****Model Garden**:
1. Search "Claude", select Claude Opus 4.7 (or whichever tier the customer needs), click **Enable**, accept Anthropic's terms.
2. Search "Gemini", enable Gemini 3 Pro (typically enabled by default).
> **Note:** Model Garden requires a one-time terms acceptance per model per project, and cannot be automated via Terraform or gcloud. Document this as a manual onboarding step.
The Python clients then work over the PSC endpoint with no code changes:
```python
from anthropic import AnthropicVertex
from google import genai
claude = AnthropicVertex(region="us-east5", project_id=PROJECT_ID)
gemini = genai.Client(vertexai=True, location="us-east1", project=PROJECT_ID)
```
### Region selection
- For **data residency**, use regional endpoints (`us-east5`, `europe-west1`, etc.) instead of `global`. ~10% pricing premium, but requests are guaranteed to stay in that region.
- For maximum availability and feature freshness, use `global`.
---
## Phase 3 — Deploy Dograh on GKE in the same VPC
### 3.1 Create a private GKE cluster
```bash
gcloud container clusters create dograh-cluster \
--region=$REGION \
--network=$NETWORK \
--subnetwork=dograh-subnet \
--enable-private-nodes \
--enable-private-endpoint \
--master-ipv4-cidr=172.16.0.0/28 \
--enable-ip-alias \
--num-nodes=3 \
--workload-pool=$PROJECT_ID.svc.id.goog
gcloud container clusters get-credentials dograh-cluster --region=$REGION
```
### 3.2 Install Dograh via Helm
```bash
helm install dograh ./charts/dograh -n dograh --create-namespace
```
Dograh pods inherit the VPC's DNS, so any call to `aiplatform.googleapis.com`, `speech.googleapis.com`, or `texttospeech.googleapis.com` automatically routes through the PSC endpoint.
### 3.3 Configure Workload Identity
This lets pods authenticate to Vertex AI without static service account keys.
```bash
# Kubernetes service account
kubectl create serviceaccount dograh-ksa -n dograh
# GCP service account
gcloud iam service-accounts create dograh-gsa
# Grant Vertex AI access
gcloud projects add-iam-policy-binding $PROJECT_ID \
--member="serviceAccount:dograh-gsa@$PROJECT_ID.iam.gserviceaccount.com" \
--role="roles/aiplatform.user"
# Bind KSA to GSA
gcloud iam service-accounts add-iam-policy-binding \
dograh-gsa@$PROJECT_ID.iam.gserviceaccount.com \
--member="serviceAccount:$PROJECT_ID.svc.id.goog[dograh/dograh-ksa]" \
--role="roles/iam.workloadIdentityUser"
# Annotate KSA
kubectl annotate serviceaccount dograh-ksa -n dograh \
iam.gke.io/gcp-service-account=dograh-gsa@$PROJECT_ID.iam.gserviceaccount.com
```
---
## Phase 4 — TTS and STT, pick your path
### Option A — All Google native (simplest)
Cloud Speech-to-Text v2 (Chirp 2) and Cloud Text-to-Speech (Chirp 3 HD voices) are already reachable through the PSC endpoint from Phase 1. **Zero additional setup.** Quality is solid for most CX use cases, though TTS expressiveness lags ElevenLabs and Cartesia.
### Option B — Deepgram self-hosted on GKE
Runs entirely in the customer's VPC, no egress to Deepgram cloud after licensing. Pure Helm chart.
**Prerequisites:** Engage Deepgram's enterprise sales to provision container image access and distribution credentials.
```bash
# GPU node pool (L4s are the sweet spot for Deepgram)
gcloud container node-pools create gpu-pool \
--cluster=dograh-cluster \
--region=$REGION \
--machine-type=g2-standard-12 \
--accelerator=type=nvidia-l4,count=1 \
--num-nodes=2 \
--node-locations=$REGION-b
# Optional: mirror Deepgram images from Quay to private Artifact Registry
gcloud artifacts repositories create deepgram \
--repository-format=docker \
--location=$REGION
# Install via Helm
helm repo add deepgram https://deepgram.github.io/self-hosted-resources
helm install dg-stt deepgram/deepgram-self-hosted \
-f values.yaml \
-n deepgram \
--create-namespace
```
**Constraints:**
- NVIDIA GPUs only.
- Dedicated GPUs only — no MIG or fractional allocation.
- Linux x86-64 only.
### Option C — ElevenLabs via Vertex AI Model Garden
ElevenLabs deploys as a partner model on Vertex AI, accessed via the same `aiplatform.googleapis.com` PSC path you already have. Setup is sales-led, not self-serve through Marketplace — contact ElevenLabs enterprise, they provision the partner model in the customer's project, you call it via the standard Vertex Prediction API.
---
## Phase 5 — VPC Service Controls perimeter
This is the lock that turns "data flows over private network" into "data *cannot* leave the perimeter, even if a service account key is leaked."
```bash
# Get the org's access policy ID
gcloud access-context-manager policies list --organization=YOUR_ORG_ID
# Create the perimeter
gcloud access-context-manager perimeters create dograh-perimeter \
--title="Dograh VPC-SC Perimeter" \
--resources=projects/$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)') \
--restricted-services=aiplatform.googleapis.com,speech.googleapis.com,texttospeech.googleapis.com,storage.googleapis.com,cloudkms.googleapis.com \
--policy=YOUR_POLICY_ID
```
Any call to a restricted API from outside the perimeter (e.g., a developer laptop with leaked credentials) is denied at the API layer with `PERMISSION_DENIED` / `violationReason: VPC_SERVICE_CONTROLS`.
### CMEK (recommended)
Enable Customer-Managed Encryption Keys on the project for at-rest encryption with customer-controlled keys. This is the line-item that passes "data encrypted with our keys" in security reviews.
```bash
# Create a key ring and key
gcloud kms keyrings create dograh-keyring --location=$REGION
gcloud kms keys create dograh-key \
--keyring=dograh-keyring \
--location=$REGION \
--purpose=encryption
```
Then attach the key to resources as needed (Vertex AI, Cloud Storage, GKE etcd, etc.).
---
## Phase 6 — Verification checklist
Run these from a Dograh pod inside the cluster:
```bash
# DNS resolves to PSC IP, not public Google IPs
dig aiplatform.googleapis.com +short
dig speech.googleapis.com +short
dig texttospeech.googleapis.com +short
# Vertex AI call succeeds over PSC
curl -H "Authorization: Bearer $(gcloud auth print-access-token)" \
"https://us-east1-aiplatform.googleapis.com/v1/projects/$PROJECT_ID/locations/us-east1/publishers/google/models/gemini-3-pro:generateContent" \
-d '{"contents":[{"role":"user","parts":[{"text":"ping"}]}]}'
# Cloud STT reachable
curl -H "Authorization: Bearer $(gcloud auth print-access-token)" \
"https://speech.googleapis.com/v2/projects/$PROJECT_ID/locations/global/recognizers"
# From outside the perimeter (e.g., laptop with valid creds), the Vertex call
# should return PERMISSION_DENIED with violationReason: VPC_SERVICE_CONTROLS
```
---
## Two things to flag in the customer conversation
### 1. PSC endpoint ≠ model runs in their VPC
With this setup, audio and prompts travel from GKE pods to Vertex AI over Google's private backbone — they never touch the public internet, and neither Anthropic nor Google has access to the content. But the model inference itself runs on Vertex's managed GPUs in Google's infrastructure.
For roughly 95% of enterprise security reviews, this is acceptable and accurately described as "in our VPC." If the customer is a defense, sovereign-cloud, or air-gapped buyer who requires the GPU itself to be in their data center, skip this playbook entirely and use **Google Distributed Cloud air-gapped** with Gemini — a different motion (hardware-shipped, sales-led, Dell + NVIDIA Blackwell appliance).
### 2. Model Garden enablement is manual
Claude and other partner models require a one-time terms acceptance in the Cloud Console that cannot be automated via Terraform or gcloud. For multi-customer rollouts, document this as a manual step in onboarding.
---
## Quick reference: full command sequence
```bash
# 1. Variables
export PROJECT_ID=$(gcloud config get-value project)
export NETWORK=dograh-vpc
export REGION=us-east1
export PSC_IP=192.168.255.230
# 2. APIs
gcloud services enable compute.googleapis.com aiplatform.googleapis.com \
speech.googleapis.com texttospeech.googleapis.com dns.googleapis.com \
servicedirectory.googleapis.com container.googleapis.com
# 3. VPC
gcloud compute networks create $NETWORK --subnet-mode=custom --bgp-routing-mode=global
gcloud compute networks subnets create dograh-subnet --network=$NETWORK \
--range=10.0.0.0/20 --region=$REGION --enable-private-ip-google-access
# 4. PSC endpoint
gcloud compute addresses create dograh-psc-ip --global \
--purpose=PRIVATE_SERVICE_CONNECT --addresses=$PSC_IP --network=$NETWORK
gcloud compute forwarding-rules create dograh-psc-googleapis --global \
--network=$NETWORK --address=dograh-psc-ip --target-google-apis-bundle=all-apis
# 5. Private DNS
gcloud dns managed-zones create googleapis-private --dns-name="googleapis.com." \
--visibility="private" --networks=$NETWORK --description="Private DNS"
gcloud dns record-sets create "googleapis.com." --zone=googleapis-private \
--type=A --ttl=300 --rrdatas=$PSC_IP
gcloud dns record-sets create "*.googleapis.com." --zone=googleapis-private \
--type=CNAME --ttl=300 --rrdatas="googleapis.com."
# 6. GKE
gcloud container clusters create dograh-cluster --region=$REGION \
--network=$NETWORK --subnetwork=dograh-subnet \
--enable-private-nodes --enable-private-endpoint \
--master-ipv4-cidr=172.16.0.0/28 --enable-ip-alias --num-nodes=3 \
--workload-pool=$PROJECT_ID.svc.id.goog
# 7. Manual step: enable Claude + Gemini in Model Garden via console
# 8. VPC-SC perimeter (after getting org policy ID)
gcloud access-context-manager perimeters create dograh-perimeter \
--title="Dograh VPC-SC Perimeter" \
--resources=projects/$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)') \
--restricted-services=aiplatform.googleapis.com,speech.googleapis.com,texttospeech.googleapis.com,storage.googleapis.com,cloudkms.googleapis.com \
--policy=YOUR_POLICY_ID
```

210
README.ja-JP.md Normal file
View file

@ -0,0 +1,210 @@
# Dograh AI
> 💡 **Notice**: This documentation is community-maintained. If you spot any translation inaccuracies or content that has drifted from the English version, please feel free to open a PR!
>
> 💡 **注記**: このドキュメントはコミュニティによって保守されています。翻訳の不正確さや英語版からの内容のずれを見つけた場合は、ぜひ PR を作成してください。
**オープンソースでセルフホスト可能な Vapi / Retell の代替手段** -- ビジュアルワークフロービルダーで本番向け音声エージェントを構築し、数分でテストし、MCP 経由で AI コーディングアシスタントに設計や編集を任せられます。
<p align="center">
<a href="https://app.dograh.com">
<img src="https://img.shields.io/badge/▶_クラウド版を試す-app.dograh.com-2563eb?style=for-the-badge" alt="クラウド版を試す">
</a>
&nbsp;
<a href="#-クイックスタート">
<img src="https://img.shields.io/badge/⚡_60秒でセルフホスト-コマンド1つ-111827?style=for-the-badge" alt="60秒でセルフホスト">
</a>
&nbsp;
<a href="https://join.slack.com/t/dograh-community/shared_invite/zt-3zjb5vwvl-j7hRz3_F1SOn5cH~jm5f5g">
<img src="https://img.shields.io/badge/💬_Slackに参加-コミュニティ-4A154B?style=for-the-badge&logo=slack" alt="Slackに参加">
</a>
</p>
<p align="center">
<a href="https://docs.dograh.com">📖 ドキュメント</a> &nbsp;·&nbsp;
<a href="LICENSE">📜 BSD 2-Clause</a> &nbsp;·&nbsp;
<a href="README.md">🌐 English</a> &nbsp;·&nbsp;
<a href="README.zh-CN.md">🌐 中文</a>
</p>
<p align="center">
<img src="docs/images/hero.gif" alt="Dograh の動作デモ -- ワークフローを構築し、音声エージェントを起動して会話する" width="80%">
</p>
- **100% オープンソース**でセルフホスト可能 -- Vapi や Retell と違い、ベンダーロックインはありません
- **完全な制御と透明性** -- すべてのコードが公開され、LLM / TTS / STT の統合も柔軟に差し替えられます
- **YC 卒業生と事業売却を経験した創業者が保守**し、音声 AI をオープンに保つことに取り組んでいます
<p align="center">
<a href="https://trendshift.io/repositories/31007" target="_blank"><img src="https://trendshift.io/api/badge/repositories/31007" alt="dograh-hq%2Fdograh | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
## 🎥 メディア掲載
<div align="center">
<a href="https://www.youtube.com/watch?v=xD9JEvfCH9k">
<img src="https://img.youtube.com/vi/xD9JEvfCH9k/maxresdefault.jpg" alt="Better Stack による Dograh 紹介" width="80%" style="border-radius: 8px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
</a>
<br>
<em><strong>Better Stack</strong> による実践レビュー -- Dograh を詳しく紹介</em>
</div>
<details>
<summary>📺 2 分のプロダクト紹介動画を見たい場合はこちら。</summary>
<div align="center">
<a href="https://youtu.be/9gPneyf9M9w">
<img src="docs/images/video_thumbnail_1.png" alt="Dograh AI のデモ動画を見る" width="70%" style="border-radius: 8px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
</a>
</div>
</details>
## ⚖️ Dograh vs Vapi vs Retell
音声 AI プラットフォームを評価しているチームに向けて、重要な観点を率直に比較します。
| | **Dograh** | **Vapi** | **Retell** |
|---|---|---|---|
| **ライセンス** | BSD 2-Clause (オープンソース) | プロプライエタリ | プロプライエタリ |
| **セルフホスト** | ✅ 可能 -- Docker コマンド 1 つ | ❌ SaaS のみ | ❌ SaaS のみ |
| **料金** | 無料(セルフホスト)・従量課金(クラウド) | 分単位課金の SaaS | 分単位課金の SaaS |
| **独自 LLM / STT / TTS の利用** | ✅ 任意のプロバイダー、または Dograh 標準スタック | 提供範囲内で設定可能 | 提供範囲内で設定可能 |
| **ソースコードレベルのカスタマイズ** | ✅ すべてのコードを自由に変更可能 | ❌ クローズドソース | ❌ クローズドソース |
| **データレジデンシー** | 自社インフラ、自社ルール | ベンダーのクラウド | ベンダーのクラウド |
| **ベンダーロックイン** | なし | あり | あり |
## 🚀 クイックスタート
##### ローカルマシンに Dograh をダウンロードしてセットアップ
> **注記**
> 製品改善のため、匿名の利用状況データを収集します。無効にするには、起動スクリプトを実行する前に `ENABLE_TELEMETRY=false` を設定してください。
> **注記**
> リモートサーバーでプラットフォームを実行したい場合は、[ドキュメント](https://docs.dograh.com/deployment/docker#option-2:-remote-server-deployment)を参照してください。
```bash
curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml && curl -o start_docker.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.sh && chmod +x start_docker.sh && ./start_docker.sh
```
> **⚡ AI エージェントにセットアップを任せたいですか?**
> **Claude Code** または **Codex** を使っている場合は、公式の [Dograh セットアップ skill](https://github.com/dograh-hq/dograh-plugins) をインストールすると、インストール、設定、トラブルシューティングをエージェントに任せられます。OS を検出し、適切なデプロイ方法を選び、Dograh 付属のセットアップスクリプトを実行して結果を検証します。
>
> ```text
> # Claude Code の場合
> /plugin marketplace add dograh-hq/dograh-plugins
> /plugin install dograh@dograh
> ```
>
> その後、新しいセッションを開始して _"set up Dograh"_ と依頼するか、`/dograh-setup` を実行してください。Codex も対応しています。詳しくは[プラグインリポジトリ](https://github.com/dograh-hq/dograh-plugins#install)を参照してください。
> **注記**
> 初回起動では、すべてのイメージをダウンロードするため 2-3 分かかる場合があります。起動後、http://localhost:3010 を開くと最初の AI 音声アシスタントを作成できます。
> よくある問題と解決策は 🔧 **[トラブルシューティング](docs/getting-started/troubleshooting.mdx)** を参照してください。
### 🎙️ 最初の音声ボット
1. ブラウザで [http://localhost:3010](http://localhost:3010) を開きます。
2. **Inbound(着信)** または **Outbound(発信)** を選び、ボットに名前を付けます(例: _リード判定_)。続けて用途を 5-10 語で説明します(例: _保険フォーム送信者の購入意向を確認_)。
3. **Test Agent** をクリックします。
4. **Test Audio** でブラウザからエージェントと会話するか、**Test Chat** でテキストベースに素早く反復します。Test Chat ではユーザー発話を編集または再実行でき、Dograh がその地点からエージェントの応答とノード遷移を再生成します。
> 🔑 **API キーは不要です。** Dograh には自動生成されるキーと、組み込みの LLM / TTS / STT スタックが付属しています。必要に応じて、独自の LLM、TTS、STT、または Twilio、Vonage、Telnyx などの電話連携プロバイダーをいつでも接続できます。
## MCP でエージェントを構築
Dograh には MCP サーバーが付属しているため、コーディングエージェントが Dograh ワークスペース内で直接作業できます。
Codex、Claude Code、Cursor、または任意の MCP クライアントを接続すると、既存エージェントの確認、Dograh ドキュメントの検索、ノードスキーマの取得、新しいワークフローの作成、自然言語からのドラフト編集保存ができます。
コーディングエージェントに音声エージェントの構築を依頼するときは、1 行のプロンプトだけでなく、ユースケース用の短いスクリプトを共有してください。エージェントのペルソナ、通話フロー、ルール、反論処理、成功基準、可能であればサンプル会話を含めると効果的です。
アシスタントの接続方法は [MCP ガイド](https://docs.dograh.com/integrations/mcp) を参照してください。
## 機能
### 音声エージェントビルダー
- Start ード、Agent ノード、グローバル指示、ツール、遷移、通話終了結果を備えたビジュアルワークフロービルダー
- ブラウザ音声テスト用の **Test Audio** と、高速なプロンプト反復用の **Test Chat** を備えた Test Agent パネル
- 本番ワークフロー向けの QA ード、ナレッジベース、Webhook、埋め込み、ツール呼び出し
### 音声と電話連携
- Twilio、Vonage、Telnyx、Plivo、Vobiz、Cloudonix、Asterisk ARI などの電話連携を標準搭載
- 対応する電話連携プロバイダーでは通話転送による有人対応が可能
- 独自の LLM、TTS、STT、電話連携プロバイダーを接続可能。成果物は同梱の MinIO または AWS / S3 互換ストレージに保存できます
### 開発者体験
- セルフホスト向けの 1 コマンド Docker セットアップ
- カスタマイズしやすい Python バックエンドとモジュラーなプロバイダー構成
- プログラムからのエージェント作成とアウトバウンド通話に対応する Python / Node SDK
## デプロイ方法
### ローカル開発
[ローカルセットアップ](https://docs.dograh.com/contribution/setup)を参照してください。
### セルフホストデプロイ
リモートサーバーへのデプロイや HTTPS 設定を含む詳しい手順は、[Docker デプロイガイド](https://docs.dograh.com/deployment/docker#option-2-remote-server-deployment)を参照してください。
### クラウド版
マネージドクラウド版は [https://www.dograh.com](https://www.dograh.com/) から利用できます。
## 📚 ドキュメント
完全なドキュメントは [https://docs.dograh.com](https://docs.dograh.com/) を参照してください。
## 📦 SDKs
- **Python SDK** -- [pypi.org/project/dograh-sdk](https://pypi.org/project/dograh-sdk/)
- **Node SDK** -- [npmjs.com/package/@dograh/sdk](https://www.npmjs.com/package/@dograh/sdk)
## 🤝 コミュニティとサポート
> 👋 **Better Stack の動画から来ましたか?** [固定された GitHub Discussion](https://github.com/orgs/dograh-hq/discussions/291) にユースケースを投稿してください。すべての返信を確認し、創業チームが初期ユーザーを直接オンボーディングします。
- **Slack** -- Dograh AI のコラボレーションの中心です。メンテナーとつながり、実装前に機能を相談し、セットアップの支援を受け、コントリビューション活動の最新情報を追えます。
- **GitHub Discussions** -- ユースケースを共有し、質問し、ワークフローのレシピを交換できます。
- **GitHub Issues** -- バグ報告や機能リクエストに利用してください。
👉 参加はこちら → [Dograh Community Slack](https://join.slack.com/t/dograh-community/shared_invite/zt-3zjb5vwvl-j7hRz3_F1SOn5cH~jm5f5g)
## 🙌 コントリビューション
コントリビューションを歓迎します。Dograh AI は 100% オープンソースであり、今後もそうあり続けます。
### はじめに
- このリポジトリを Fork する
- 機能ブランチを作成する(`git checkout -b feature/AmazingFeature`)
- 変更をコミットする(`git commit -m 'Add some AmazingFeature'`)
- ブランチへプッシュする(`git push origin feature/AmazingFeature`)
- Pull Request を作成する
## ⭐ Star 履歴
<img src="docs/images/star-history.png" alt="Dograh star history" width="80%">
## 📄 ライセンス
Dograh AI は [BSD 2-Clause License](LICENSE) のもとで公開されています。Dograh AI の構築に使われたプロジェクトと同じライセンスであり、互換性と、利用・変更・配布の自由を確保しています。
## 🏢 私たちについて
**Dograh** (Zansat Technologies Private Limited) が ❤️ を込めて開発しています。
創業チームは YC 卒業生と事業売却を経験した創業者で構成され、音声 AI をオープンで誰もが利用できるものに保つことに取り組んでいます。
<br><br><br>
<p align="center">
<a href="https://github.com/dograh-hq/dograh">⭐ GitHub で Star する</a> |
<a href="https://app.dograh.com">☁️ クラウド版を試す</a> |
<a href="https://join.slack.com/t/dograh-community/shared_invite/zt-3zjb5vwvl-j7hRz3_F1SOn5cH~jm5f5g">💬 Slack に参加</a>
</p>

View file

@ -1,6 +1,6 @@
# Dograh AI
**The open-source, self-hostable alternative to Vapi & Retell** — build production voice agents with a drag-and-drop workflow builder. From zero to a working bot in under 2 minutes.
**The open-source, self-hostable alternative to Vapi & Retell** — build production voice agents with a visual workflow builder, test them in minutes, and let AI coding assistants help design and edit them through MCP.
<p align="center">
<a href="https://app.dograh.com">
@ -19,7 +19,8 @@
<p align="center">
<a href="https://docs.dograh.com">📖 Docs</a> &nbsp;·&nbsp;
<a href="LICENSE">📜 BSD 2-Clause</a> &nbsp;·&nbsp;
<a href="README.zh-CN.md">🌐 中文</a>
<a href="README.zh-CN.md">🌐 中文</a> &nbsp;·&nbsp;
<a href="README.ja-JP.md">🌐 日本語</a>
</p>
<p align="center">
@ -97,37 +98,49 @@ curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/m
> **Note**
> First startup may take 2-3 minutes to download all images. Once running, open http://localhost:3010 to create your first AI voice assistant!
> For common issues and solutions, see 🔧 **[Troubleshooting](docs/troubleshooting.md)**.
> For common issues and solutions, see 🔧 **[Troubleshooting](docs/getting-started/troubleshooting.mdx)**.
### 🎙️ Your First Voice Bot
1. Open [http://localhost:3010](http://localhost:3010) in your browser.
2. Pick **Inbound** or **Outbound**, name your bot (e.g. _Lead Qualification_), and describe the use case in 510 words (e.g. _Screen insurance form submissions for purchase intent_).
3. Click **Web Call** — you're talking to your bot.
3. Click **Test Agent**.
4. Use **Test Audio** to talk to your agent in the browser, or **Test Chat** to iterate faster in text. In Test Chat, you can edit or replay user turns and Dograh will regenerate the agent's replies and node transitions from that point.
> 🔑 **No API keys needed.** Dograh ships with auto-generated keys and its own LLM / TTS / STT stack. Connect your own keys for LLM, TTS, STT, or Telephony (e.g. Twilio, Vonage, Telnyx) anytime.
## Build Agents with MCP
Dograh ships with an MCP server, so coding agents can work directly inside your Dograh workspace.
Connect Codex, Claude Code, Cursor, or any MCP client to inspect existing agents, search Dograh docs, fetch node schemas, create new workflows, and save draft edits from natural language.
When asking your coding agent to build a voice agent, share a short script for
the use case instead of only a one-line prompt. Include the agent persona, call
flow, rules, objection handling, success criteria, and a sample conversation if
you have one.
See the [MCP guide](https://docs.dograh.com/integrations/mcp) to connect your assistant.
## Features
### Voice Capabilities
### Voice Agent Builder
- Telephony: Built-in telephony integration like Twilio, Vonage, Vobiz, Cloudonix (easily add others), with support for transferring calls to human agents
- Languages: English support (expandable to other languages)
- Custom Models: Bring your own TTS/STT models
- Real-time Processing: Low-latency voice interactions
- Visual workflow builder with start nodes, agent nodes, global instructions, tools, transitions, and end-call outcomes
- Test Agent panel with **Test Audio** for browser voice testing and **Test Chat** for fast prompt iteration
- QA node, knowledge bases, webhooks, embeds, and tool calling for production workflows
### Voice & Telephony
- Built-in telephony integrations including Twilio, Vonage, Telnyx, Plivo, Vobiz, Cloudonix, and Asterisk ARI
- Human handoff with call transfer on supported telephony providers
- Bring your own LLM, TTS, STT, and telephony providers; store artifacts in bundled MinIO or AWS/S3-compatible storage
### Developer Experience
- Zero Config Start: Auto-generated API keys for instant testing
- Python-Based: Built on Python for easy customization
- Docker-First: Containerized for consistent deployments
- Modular Architecture: Swap components as needed
### Testing & Quality
- **Test Mode**: Try your agent end-to-end before publishing, with no production calls or data affected
- **In-Dashboard Web Calls**: Talk to your bot directly while building — no telephony setup required
- **QA Node**: A built-in workflow node that analyzes prompt quality across your other nodes
- One-command Docker setup for self-hosting
- Python backend and modular provider architecture for customization
- Python and Node SDKs for programmatic agent creation and outbound calls
## Deployment Options
@ -137,7 +150,7 @@ Refer [Local Setup](https://docs.dograh.com/contribution/setup)
### Self-Hosted Deployment
For detailed deployment instructions including remote server setup with HTTPS, see our [Docker Deployment Guide](https://docs.dograh.com/deployment/docker).
For detailed deployment instructions including remote server setup with HTTPS, see our [Docker Deployment Guide](https://docs.dograh.com/deployment/docker#option-2-remote-server-deployment).
### Cloud Version
@ -176,9 +189,7 @@ We love contributions! Dograh AI is 100% open source and we intend to keep it th
## ⭐ Star History
<a href="https://star-history.com/#dograh-hq/dograh&Date">
<img src="https://api.star-history.com/svg?repos=dograh-hq/dograh&type=Date" alt="Dograh star history" width="80%">
</a>
<img src="docs/images/star-history.png" alt="Dograh star history" width="80%">
## 📄 License
@ -192,7 +203,7 @@ Founded by YC alumni and exit founders committed to keeping voice AI open and ac
<br><br><br>
<p align="center">
<a href="https://github.com/dograh-hq/dograh/stargazers">⭐ Star us on GitHub</a> |
<a href="https://github.com/dograh-hq/dograh">⭐ Star us on GitHub</a> |
<a href="https://app.dograh.com">☁️ Try Cloud Version</a> |
<a href="https://join.slack.com/t/dograh-community/shared_invite/zt-3zjb5vwvl-j7hRz3_F1SOn5cH~jm5f5g">💬 Join Slack</a>
</p>

View file

@ -4,7 +4,7 @@
>
> 💡 **提示**:本文档由社区共同维护。如果您发现翻译不准确,或与英文版本存在出入,欢迎随时提交 PR!
**开源、可自托管的 Vapi 与 Retell 替代方案** —— 通过拖拽式工作流编辑器构建生产级语音智能体,2 分钟内即可上线一个能用的语音机器人
**开源、可自托管的 Vapi 与 Retell 替代方案** —— 使用可视化工作流构建器搭建生产级语音智能体,几分钟内完成测试,并让 AI 编码助手通过 MCP 帮你设计和编辑
<p align="center">
<a href="https://app.dograh.com">
@ -23,7 +23,8 @@
<p align="center">
<a href="https://docs.dograh.com">📖 文档</a> &nbsp;·&nbsp;
<a href="LICENSE">📜 BSD 2-Clause</a> &nbsp;·&nbsp;
<a href="README.md">🌐 English</a>
<a href="README.md">🌐 English</a> &nbsp;·&nbsp;
<a href="README.ja-JP.md">🌐 日本語</a>
</p>
<p align="center">
@ -97,37 +98,46 @@ curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/m
> **提示**
> 首次启动需要 2-3 分钟拉取所有镜像。启动完成后,打开 http://localhost:3010 即可创建你的第一个 AI 语音助手!
> 常见问题及解决方案请参见 🔧 **[故障排查](docs/troubleshooting.md)**。
> 常见问题及解决方案请参见 🔧 **[故障排查](docs/getting-started/troubleshooting.mdx)**。
### 🎙️ 你的第一个语音机器人
1. 在浏览器中打开 [http://localhost:3010](http://localhost:3010)。
2. 选择 **Inbound(呼入)****Outbound(外呼)**,为机器人命名(例如 _销售线索筛选_),再用 5-10 个词描述用途(例如 _筛选保险表单中的购买意向_)。
3. 点击 **Web Call**,直接和你的机器人对话。
3. 点击 **Test Agent**
4. 使用 **Test Audio** 在浏览器中和智能体语音对话,或使用 **Test Chat** 通过文本快速迭代。在 Test Chat 中,你可以编辑或重放用户消息,Dograh 会从该位置重新生成智能体回复和节点流转。
> 🔑 **无需 API Key。** Dograh 自带一套自动生成的密钥,以及内置的 LLM / TTS / STT 栈。你可以随时接入自己的 LLM、TTS、STT 或电信服务商(如 Twilio、Vonage、Telnyx)。
## 使用 MCP 构建智能体
Dograh 内置 MCP 服务器,因此编码智能体可以直接在你的 Dograh 工作区中操作。
连接 Codex、Claude Code、Cursor 或任何 MCP 客户端后,可以查看现有智能体、搜索 Dograh 文档、获取节点 schema、创建新工作流,并通过自然语言保存草稿修改。
让编码智能体构建语音智能体时,请分享一份面向该用例的简短脚本,而不是只给一行提示。脚本最好包含智能体 persona、通话流程、规则、异议处理、成功标准,以及可选的示例对话。
请参见 [MCP 指南](https://docs.dograh.com/integrations/mcp) 来连接你的助手。
## 功能特性
### 语音能力
### 语音智能体构建器
- 电信集成:内置 Twilio、Vonage、Vobiz、Cloudonix 等(其他厂商也易于扩展),支持转接到人工坐席
- 语言:支持英语(可扩展到其他语言)
- 自定义模型:可接入自己的 TTS / STT 模型
- 实时处理:低延迟语音交互
- 可视化工作流构建器,支持 start 节点、agent 节点、全局指令、工具、跳转和结束通话结果
- Test Agent 面板内置 **Test Audio** 用于浏览器语音测试,并提供 **Test Chat** 用于快速提示词迭代
- 面向生产工作流的 QA 节点、知识库、webhook、嵌入和工具调用
### 语音与电信
- 内置 Twilio、Vonage、Telnyx、Plivo、Vobiz、Cloudonix 和 Asterisk ARI 等电信集成
- 在支持的电信服务商上,可通过通话转接实现人工接管
- 可接入自己的 LLM、TTS、STT 和电信服务商;通话产物可存储在内置 MinIO 或 AWS / S3 兼容存储中
### 开发者体验
- 零配置启动:自动生成 API Key,即开即用
- 基于 Python:基于 Python 构建,便于二次开发
- Docker 优先:容器化部署,环境一致
- 模块化架构:按需替换各个组件
### 测试与质量
- **测试模式**:在发布前端到端试跑你的智能体,既不会产生真实通话,也不会影响生产数据
- **面板内 Web 通话**:在搭建过程中直接和机器人对话,无需配置任何电信服务
- **QA 节点**:内置的工作流节点,可分析其他节点中提示词的质量
- 一条命令即可完成自托管 Docker 部署
- Python 后端和模块化 provider 架构,便于定制
- Python 和 Node SDK,用于以编程方式创建智能体并发起外呼
## 部署方式
@ -137,7 +147,7 @@ curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/m
### 自托管部署
如需了解远程服务器部署及 HTTPS 配置的详细步骤,请参见我们的 [Docker 部署指南](https://docs.dograh.com/deployment/docker)。
如需了解远程服务器部署及 HTTPS 配置的详细步骤,请参见我们的 [Docker 部署指南](https://docs.dograh.com/deployment/docker#option-2-remote-server-deployment)。
### 云端版本
@ -171,9 +181,7 @@ curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/m
## ⭐ Star 历史
<a href="https://star-history.com/#dograh-hq/dograh&Date">
<img src="https://api.star-history.com/svg?repos=dograh-hq/dograh&type=Date" alt="Dograh 的 Star 历史" width="80%">
</a>
<img src="docs/images/star-history.png" alt="Dograh star history" width="80%">
## 📄 许可协议
@ -187,7 +195,7 @@ Dograh AI 基于 [BSD 2-Clause 协议](LICENSE)开源 —— 与构建 Dograh AI
<br><br><br>
<p align="center">
<a href="https://github.com/dograh-hq/dograh/stargazers">⭐ 给我们一个 Star</a> |
<a href="https://github.com/dograh-hq/dograh">⭐ 给我们一个 Star</a> |
<a href="https://app.dograh.com">☁️ 试用云端版本</a> |
<a href="https://join.slack.com/t/dograh-community/shared_invite/zt-3zjb5vwvl-j7hRz3_F1SOn5cH~jm5f5g">💬 加入 Slack</a>
</p>

View file

@ -2,6 +2,9 @@
ENVIRONMENT="local"
LOG_LEVEL="DEBUG"
# Set to "false" to disable public signup (invite-only installs)
ENABLE_SIGNUP="true"
# Change these values if you deploy the backend and frontend
# on any hosting provider with some DNS. Please ensure to
# provide the URL with scheme like http or https
@ -12,12 +15,24 @@ 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=""
# AWS_SECRET_ACCESS_KEY=""
# S3_BUCKET=""
# S3_REGION=""
# --- S3-compatible servers (MinIO, rustfs, Ceph, ...) ---
# Use the S3 backend (ENABLE_AWS_S3=true) against a non-AWS, S3-compatible
# server by overriding the endpoint and signing. Unlike the MinIO backend, the
# S3 backend emits real presigned URLs, so the bucket can stay private.
# S3_ENDPOINT_URL="" # e.g. https://s3.example.com (blank = AWS default)
# S3_SIGNATURE_VERSION="" # blank = botocore default; set "s3v4" if the server requires SigV4
# S3_ADDRESSING_STYLE="" # blank = auto; set "path" if the server / TLS cert requires path-style
# MinIO Configuration if using containerised MinIO instead of
# AWS S3

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
@ -144,7 +141,22 @@ ENV PYTHONUNBUFFERED=1
# Copy application code (chown at copy-time avoids a duplicate /app layer
# from a later `RUN chown -R`, which would double the on-disk size of /app).
COPY --chown=dograh:dograh ./api ./api
COPY --chown=dograh:dograh ./scripts/start_services_docker.sh ./scripts/start_services_docker.sh
# Entrypoint scripts.
# start_services_docker.sh — single-container (docker-compose) entrypoint
# that runs every service in one process tree.
# run_*.sh — per-service entrypoints used by the Helm chart,
# which runs each workload (web, arq-worker, ari-manager,
# campaign-orchestrator, migrate) as its own pod. Keep this list in sync
# with the command:[] entries in deploy/helm/dograh/templates/*.yaml.
COPY --chown=dograh:dograh \
./scripts/start_services_docker.sh \
./scripts/run_migrate.sh \
./scripts/run_web.sh \
./scripts/run_arq_worker.sh \
./scripts/run_ari_manager.sh \
./scripts/run_campaign_orchestrator.sh \
./scripts/
# ts_validator Node deps (built in ts-deps stage with full node:22-slim image).
# The validator runs as a short-lived subprocess from api/mcp_server/ts_bridge.py.

View file

@ -0,0 +1,215 @@
"""backfill org model configuration v2 from legacy user rows
Revision ID: 00b0201ad918
Revises: c52dc3cccfb0
Create Date: 2026-07-09 12:00:00.000000
"""
import json
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "00b0201ad918"
down_revision: Union[str, None] = "c52dc3cccfb0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# Organizations created before MODEL_CONFIGURATION_V2 only have per-user legacy
# MODEL_CONFIGURATION rows, and the application no longer falls back to those.
# This migration converts one member's legacy row per organization into an
# org-level v2 row. The conversion below is a frozen copy of
# convert_legacy_ai_model_configuration_to_v2 (api/services/configuration/
# ai_model_configuration.py at this revision) operating on raw JSON, so the
# migration never imports application code. API keys are deliberately carried
# over without validation: a dead key fails at call time exactly as it did
# before this migration.
_SPEED_MIN = 0.5
_SPEED_MAX = 2.0
_DEFAULT_VOICE = "default"
_DEFAULT_LANGUAGE = "multi"
# One candidate row per (organization, member with a legacy config row).
# Members are ordered the way the hosted migration script picked its
# representative user: users whose selected organization is this org first,
# then earliest created. The first member whose row converts wins.
_CANDIDATE_ROWS_SQL = """
SELECT ou.organization_id,
uc.configuration,
uc.last_validated_at
FROM organization_users ou
JOIN users u ON u.id = ou.user_id
JOIN user_configurations uc
ON uc.user_id = u.id AND uc.key = 'MODEL_CONFIGURATION'
WHERE NOT EXISTS (
SELECT 1
FROM organization_configurations oc
WHERE oc.organization_id = ou.organization_id
AND oc.key = 'MODEL_CONFIGURATION_V2'
)
ORDER BY ou.organization_id,
(u.selected_organization_id IS NOT DISTINCT FROM ou.organization_id) DESC,
u.created_at ASC NULLS LAST,
u.id ASC
"""
_INSERT_SQL = """
INSERT INTO organization_configurations
(organization_id, key, value, created_at, updated_at, last_validated_at)
VALUES
(:organization_id, 'MODEL_CONFIGURATION_V2', CAST(:value AS json),
now(), now(), :last_validated_at)
ON CONFLICT ON CONSTRAINT _organization_key_uc DO NOTHING
"""
def _section(configuration: dict, name: str) -> dict | None:
value = configuration.get(name)
return value if isinstance(value, dict) else None
def _single_api_key(service: dict) -> str | None:
key = service.get("api_key")
if isinstance(key, str) and key:
return key
if isinstance(key, list) and len(key) == 1 and isinstance(key[0], str) and key[0]:
return key[0]
return None
def _first_dograh_api_key(configuration: dict) -> str | None:
for name in ("llm", "tts", "stt", "embeddings", "realtime"):
service = _section(configuration, name)
if service is None or service.get("provider") != "dograh":
continue
key = _single_api_key(service)
if key:
return key
return None
def _has_dograh_provider(*services: dict | None) -> bool:
return any(
service is not None and service.get("provider") == "dograh"
for service in services
)
def _sanitized_speed(tts: dict | None) -> float:
speed = (tts or {}).get("speed", 1.0)
try:
speed = float(speed)
except (TypeError, ValueError):
return 1.0
if not _SPEED_MIN <= speed <= _SPEED_MAX:
return 1.0
return speed
def convert_legacy_configuration_to_v2(configuration: dict) -> dict | None:
"""Convert a legacy MODEL_CONFIGURATION JSON value to a v2 value.
Returns None when the legacy row is too incomplete to have produced a
working pipeline (such rows failed at pipeline startup before v2 too).
"""
llm = _section(configuration, "llm")
tts = _section(configuration, "tts")
stt = _section(configuration, "stt")
embeddings = _section(configuration, "embeddings")
realtime = _section(configuration, "realtime")
dograh_key = _first_dograh_api_key(configuration)
if dograh_key:
return {
"version": 2,
"mode": "dograh",
"dograh": {
"api_key": dograh_key,
"voice": (tts or {}).get("voice") or _DEFAULT_VOICE,
"speed": _sanitized_speed(tts),
"language": (stt or {}).get("language") or _DEFAULT_LANGUAGE,
},
}
if configuration.get("is_realtime"):
# BYOK schemas reject dograh providers; a dograh provider without a
# single resolvable key cannot be represented in v2.
if realtime is None or llm is None or _has_dograh_provider(llm, embeddings):
return None
section: dict = {"realtime": realtime, "llm": llm}
if embeddings is not None:
section["embeddings"] = embeddings
return {
"version": 2,
"mode": "byok",
"byok": {"mode": "realtime", "realtime": section},
}
if llm is None or tts is None or stt is None:
return None
if _has_dograh_provider(llm, tts, stt, embeddings):
return None
section = {"llm": llm, "tts": tts, "stt": stt}
if embeddings is not None:
section["embeddings"] = embeddings
return {
"version": 2,
"mode": "byok",
"byok": {"mode": "pipeline", "pipeline": section},
}
def upgrade() -> None:
connection = op.get_bind()
rows = connection.execute(sa.text(_CANDIDATE_ROWS_SQL)).mappings().all()
backfilled: set[int] = set()
seen: set[int] = set()
for row in rows:
organization_id = row["organization_id"]
seen.add(organization_id)
if organization_id in backfilled:
continue
configuration = row["configuration"]
if isinstance(configuration, str):
try:
configuration = json.loads(configuration)
except ValueError:
continue
if not isinstance(configuration, dict):
continue
try:
v2_value = convert_legacy_configuration_to_v2(configuration)
except Exception:
v2_value = None
if v2_value is None:
continue
connection.execute(
sa.text(_INSERT_SQL),
{
"organization_id": organization_id,
"value": json.dumps(v2_value),
"last_validated_at": row["last_validated_at"],
},
)
backfilled.add(organization_id)
skipped = sorted(seen - backfilled)
print(
f"Backfilled MODEL_CONFIGURATION_V2 for {len(backfilled)} organization(s); "
f"skipped {len(skipped)} with no convertible legacy configuration"
+ (f": {skipped}" if skipped else "")
)
def downgrade() -> None:
# Backfilled rows are indistinguishable from rows written by the
# application; leaving them in place is safe for older code.
pass

View file

@ -0,0 +1,119 @@
"""add webhook_deliveries
Durable, retrying outbound webhook delivery so a transient network error can't
permanently drop a workflow's final webhook.
Revision ID: b7e3c9a1d2f4
Revises: 91cc6ba3e1c7
Create Date: 2026-06-28 19:40:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b7e3c9a1d2f4"
down_revision: Union[str, None] = "91cc6ba3e1c7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"webhook_deliveries",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("delivery_uuid", sa.String(length=36), nullable=False),
sa.Column("workflow_run_id", sa.Integer(), nullable=False),
sa.Column("organization_id", sa.Integer(), nullable=False),
sa.Column("webhook_name", sa.String(), nullable=True),
sa.Column("endpoint_url", sa.String(), nullable=False),
sa.Column(
"http_method",
sa.String(),
nullable=False,
),
sa.Column(
"payload",
sa.JSON(),
nullable=False,
),
sa.Column("custom_headers", sa.JSON(), nullable=True),
sa.Column("credential_uuid", sa.String(length=36), nullable=True),
sa.Column("webhook_node_id", sa.String(), nullable=False),
sa.Column(
"status",
sa.Enum(
"pending",
"succeeded",
"dead_letter",
name="webhook_delivery_status",
),
server_default=sa.text("'pending'"),
nullable=False,
),
sa.Column(
"attempt_count",
sa.Integer(),
server_default=sa.text("0"),
nullable=False,
),
sa.Column(
"max_attempts",
sa.Integer(),
server_default=sa.text("5"),
nullable=False,
),
sa.Column("scheduled_for", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_status_code", sa.Integer(), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["workflow_run_id"], ["workflow_runs.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["organization_id"], ["organizations.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"workflow_run_id",
"webhook_node_id",
name="uq_webhook_deliveries_run_node",
),
)
op.create_index(
"ix_webhook_deliveries_delivery_uuid",
"webhook_deliveries",
["delivery_uuid"],
unique=True,
)
op.create_index(
"idx_webhook_deliveries_run",
"webhook_deliveries",
["workflow_run_id"],
unique=False,
)
# Partial index for the sweeper's hot path: due pending deliveries.
op.create_index(
"idx_webhook_deliveries_pending_scheduled",
"webhook_deliveries",
["scheduled_for"],
unique=False,
postgresql_where=sa.text("status = 'pending'"),
)
def downgrade() -> None:
op.drop_index(
"idx_webhook_deliveries_pending_scheduled",
table_name="webhook_deliveries",
)
op.drop_index("idx_webhook_deliveries_run", table_name="webhook_deliveries")
op.drop_index(
"ix_webhook_deliveries_delivery_uuid", table_name="webhook_deliveries"
)
op.drop_table("webhook_deliveries")
op.execute("DROP TYPE IF EXISTS webhook_delivery_status")

View file

@ -0,0 +1,29 @@
"""add last_validated_at on org config
Revision ID: c52dc3cccfb0
Revises: b7e3c9a1d2f4
Create Date: 2026-07-09 19:18:29.550267
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "c52dc3cccfb0"
down_revision: Union[str, None] = "b7e3c9a1d2f4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"organization_configurations",
sa.Column("last_validated_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("organization_configurations", "last_validated_at")

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"]
@ -30,6 +46,7 @@ CORS_ALLOWED_ORIGINS = [
o.strip() for o in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if o.strip()
]
AUTH_PROVIDER = os.getenv("AUTH_PROVIDER", "local")
ENABLE_SIGNUP = os.getenv("ENABLE_SIGNUP", "true").lower() == "true"
# Stack Auth public client config. These are safe to expose to the browser (the
# publishable client key is public by design, and the project id is non-sensitive),
# and are served to the UI at runtime via /api/v1/health so the frontend no longer
@ -38,13 +55,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")
@ -53,6 +76,17 @@ MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true"
# AWS S3 Configuration
S3_BUCKET = os.environ.get("S3_BUCKET")
S3_REGION = os.environ.get("S3_REGION", "us-east-1")
# Optional overrides for S3-compatible backends (e.g. MinIO, rustfs, Ceph).
# S3_ENDPOINT_URL: full URL of a custom S3 endpoint (e.g. "https://s3.example.com").
# Leave unset to use AWS's default endpoint resolution.
# S3_SIGNATURE_VERSION: botocore signature version used to sign requests and
# presigned URLs. Defaults to None (botocore's default, currently SigV2 for
# presigned URLs). Set to "s3v4" for S3-compatible servers that require SigV4.
# S3_ADDRESSING_STYLE: "auto" (default), "path", or "virtual". Many S3-compatible
# servers and TLS setups require "path".
S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL")
S3_SIGNATURE_VERSION = os.environ.get("S3_SIGNATURE_VERSION")
S3_ADDRESSING_STYLE = os.environ.get("S3_ADDRESSING_STYLE")
# Sentry configuration
SENTRY_DSN = os.getenv("SENTRY_DSN")
@ -73,7 +107,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:
@ -117,7 +151,11 @@ COUNTRY_CODES = {
"IE": "353", # Ireland
}
DEFAULT_ORG_CONCURRENCY_LIMIT = os.getenv("DEFAULT_ORG_CONCURRENCY_LIMIT", 2)
# Floor at 1 so a misconfigured env var (0 or negative) can't silently block
# every call in the deployment.
DEFAULT_ORG_CONCURRENCY_LIMIT = max(
1, int(os.getenv("DEFAULT_ORG_CONCURRENCY_LIMIT", "10"))
)
DEFAULT_CAMPAIGN_RETRY_CONFIG = {
"enabled": True,
"max_retries": 1,
@ -128,6 +166,18 @@ DEFAULT_CAMPAIGN_RETRY_CONFIG = {
}
# Outbound webhook delivery: bounded retry with exponential backoff.
# Delivery is persisted (see WebhookDeliveryModel) and retried by an ARQ task so a
# transient network error can't permanently drop a final webhook. After
# ``max_attempts`` transient failures the delivery is parked as ``dead_letter``.
DEFAULT_WEBHOOK_DELIVERY_CONFIG = {
"max_attempts": int(os.getenv("WEBHOOK_DELIVERY_MAX_ATTEMPTS", 5)),
"base_delay_seconds": int(os.getenv("WEBHOOK_DELIVERY_BASE_DELAY_SECONDS", 30)),
"max_delay_seconds": int(os.getenv("WEBHOOK_DELIVERY_MAX_DELAY_SECONDS", 600)),
"timeout_seconds": int(os.getenv("WEBHOOK_DELIVERY_TIMEOUT_SECONDS", 30)),
}
# Circuit breaker defaults for campaign call failure detection
DEFAULT_CIRCUIT_BREAKER_CONFIG = {
"enabled": True,
@ -138,7 +188,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

@ -14,6 +14,7 @@ from api.db.telephony_phone_number_client import TelephonyPhoneNumberClient
from api.db.tool_client import ToolClient
from api.db.user_client import UserClient
from api.db.webhook_credential_client import WebhookCredentialClient
from api.db.webhook_delivery_client import WebhookDeliveryClient
from api.db.workflow_client import WorkflowClient
from api.db.workflow_recording_client import WorkflowRecordingClient
from api.db.workflow_run_client import WorkflowRunClient
@ -37,6 +38,7 @@ class DBClient(
EmbedTokenClient,
AgentTriggerClient,
WebhookCredentialClient,
WebhookDeliveryClient,
ToolClient,
KnowledgeBaseClient,
WorkflowRecordingClient,
@ -62,6 +64,7 @@ class DBClient(
- EmbedTokenClient: handles embed token and session operations
- AgentTriggerClient: handles agent trigger operations for API-based call triggering
- WebhookCredentialClient: handles webhook credential operations
- WebhookDeliveryClient: handles durable outbound webhook delivery records
- ToolClient: handles tool operations for reusable HTTP API tools
- KnowledgeBaseClient: handles knowledge base document and vector search operations
- FolderClient: handles folder operations for grouping workflows (agents)

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

@ -205,11 +205,8 @@ class OrganizationConfigurationModel(Base):
key = Column(String, nullable=False)
value = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
last_validated_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
organization = relationship("OrganizationModel", back_populates="configurations")
@ -1022,6 +1019,103 @@ class ExternalCredentialModel(Base):
)
class WebhookDeliveryModel(Base):
"""Durable record of an outbound webhook delivery attempt.
Final webhooks (e.g. a workflow's "Final Webhook" node) must not be lost to a
single transient network error. Instead of firing the HTTP request inline and
forgetting it, we persist one row per webhook node per workflow run and let an
ARQ task drive delivery with bounded, backed-off retries. The row is the source
of truth: it survives worker restarts and a periodic sweeper re-enqueues any
``pending`` delivery whose ``scheduled_for`` is overdue. After ``max_attempts``
transient failures (or on a permanent 4xx) the row is parked as ``dead_letter``
for inspection rather than retried forever.
Mirrors the campaign retry pattern (``QueuedRunModel``): persisted state,
``scheduled_for`` gating, a hard attempt ceiling, and a terminal failure state.
"""
__tablename__ = "webhook_deliveries"
id = Column(Integer, primary_key=True, index=True)
# Stable idempotency key sent to the receiver so it can dedupe retries.
delivery_uuid = Column(
String(36),
unique=True,
nullable=False,
index=True,
default=lambda: str(uuid.uuid4()),
)
workflow_run_id = Column(
Integer,
ForeignKey("workflow_runs.id", ondelete="CASCADE"),
nullable=False,
)
organization_id = Column(
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
)
# Frozen request definition. The payload is rendered once at enqueue time so
# retries are deterministic. Secrets are NOT stored here: the auth header is
# re-resolved from ``credential_uuid`` at send time (honours rotation/revocation).
webhook_name = Column(String, nullable=True)
endpoint_url = Column(String, nullable=False)
http_method = Column(String, nullable=False, default="POST")
payload = Column(JSON, nullable=False, default=dict)
custom_headers = Column(JSON, nullable=True)
credential_uuid = Column(String(36), nullable=True)
# Workflow node that produced this delivery. Combined with workflow_run_id it
# is the per-run/per-node idempotency key, so a retried run_integrations does
# not create (and send) a duplicate delivery for the same node. Non-nullable:
# a NULL would be distinct under the unique constraint and defeat the dedupe.
webhook_node_id = Column(String, nullable=False)
status = Column(
Enum(
"pending",
"succeeded",
"dead_letter",
name="webhook_delivery_status",
),
nullable=False,
default="pending",
server_default="pending",
)
attempt_count = Column(Integer, nullable=False, default=0, server_default=text("0"))
max_attempts = Column(Integer, nullable=False, default=5, server_default=text("5"))
# When the next attempt becomes due. NULL once terminal (succeeded/dead_letter).
scheduled_for = Column(DateTime(timezone=True), nullable=True)
last_status_code = Column(Integer, nullable=True)
last_error = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(UTC),
)
__table_args__ = (
# Sweeper lookup: due pending deliveries.
Index(
"idx_webhook_deliveries_pending_scheduled",
"scheduled_for",
postgresql_where=text("status = 'pending'"),
),
Index("idx_webhook_deliveries_run", "workflow_run_id"),
# Per-run/per-node idempotency: one delivery per webhook node per run.
UniqueConstraint(
"workflow_run_id",
"webhook_node_id",
name="uq_webhook_deliveries_run_node",
),
)
class ToolModel(Base):
"""Model for storing reusable tools that can be invoked during workflows.

View file

@ -1,6 +1,7 @@
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import exists
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.future import select
@ -8,6 +9,7 @@ from api.db.base_client import BaseDBClient
from api.db.models import (
APIKeyModel,
OrganizationModel,
UserModel,
organization_users_association,
)
from api.utils.api_key import generate_api_key
@ -24,6 +26,22 @@ class OrganizationClient(BaseDBClient):
)
return result.scalars().first()
async def get_organization_users(self, organization_id: int) -> list[UserModel]:
"""Get all users linked to an organization (many-to-many)."""
async with self.async_session() as session:
result = await session.execute(
select(UserModel)
.join(
organization_users_association,
organization_users_association.c.user_id == UserModel.id,
)
.where(
organization_users_association.c.organization_id == organization_id
)
.order_by(UserModel.id)
)
return list(result.scalars().all())
async def get_or_create_organization_by_provider_id(
self, org_provider_id: str, user_id: int
) -> tuple[OrganizationModel, bool]:
@ -91,6 +109,24 @@ class OrganizationClient(BaseDBClient):
return organization, was_created
return organization, False
async def is_user_member_of_organization(
self, user_id: int, organization_id: int
) -> bool:
"""Return True if the user belongs to the given organization."""
async with self.async_session() as session:
result = await session.execute(
select(
exists().where(
(organization_users_association.c.user_id == user_id)
& (
organization_users_association.c.organization_id
== organization_id
)
)
)
)
return bool(result.scalar())
async def add_user_to_organization(
self, user_id: int, organization_id: int
) -> None:

View file

@ -1,3 +1,4 @@
from datetime import UTC, datetime
from typing import Any, Dict, List, Optional
from sqlalchemy.future import select
@ -33,10 +34,15 @@ class OrganizationConfigurationClient(BaseDBClient):
return result.scalars().all()
async def upsert_configuration(
self, organization_id: int, key: str, value: Any
self,
organization_id: int,
key: str,
value: Any,
last_validated_at: datetime | None = None,
) -> OrganizationConfigurationModel:
"""Create or update a configuration for an organization."""
async with self.async_session() as session:
now = datetime.now(UTC)
# First try to get existing configuration
result = await session.execute(
select(OrganizationConfigurationModel).where(
@ -49,12 +55,16 @@ class OrganizationConfigurationClient(BaseDBClient):
if config:
# Update existing configuration
config.value = value
config.updated_at = now
config.last_validated_at = last_validated_at
else:
# Create new configuration
config = OrganizationConfigurationModel(
organization_id=organization_id,
key=key,
value=value,
updated_at=now,
last_validated_at=last_validated_at,
)
session.add(config)
@ -66,6 +76,30 @@ class OrganizationConfigurationClient(BaseDBClient):
await session.refresh(config)
return config
async def mark_configuration_validated(
self, organization_id: int, key: str
) -> Optional[OrganizationConfigurationModel]:
"""Update the validation timestamp for an existing organization configuration."""
async with self.async_session() as session:
result = await session.execute(
select(OrganizationConfigurationModel).where(
OrganizationConfigurationModel.organization_id == organization_id,
OrganizationConfigurationModel.key == key,
)
)
config = result.scalars().first()
if not config:
return None
config.last_validated_at = datetime.now(UTC)
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(config)
return config
async def delete_configuration(self, organization_id: int, key: str) -> bool:
"""Delete a configuration for an organization."""
async with self.async_session() as session:

View file

@ -13,13 +13,10 @@ from api.db.models import (
OrganizationConfigurationModel,
OrganizationModel,
OrganizationUsageCycleModel,
UserConfigurationModel,
UserModel,
WorkflowModel,
WorkflowRunModel,
)
from api.enums import OrganizationConfigurationKey, UserConfigurationKey
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
from api.enums import OrganizationConfigurationKey
from api.utils.recording_artifacts import get_recording_storage_key
@ -139,9 +136,8 @@ class OrganizationUsageClient(BaseDBClient):
query = (
select(WorkflowRunModel)
.join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id)
.join(UserModel, WorkflowModel.user_id == UserModel.id)
.where(
UserModel.selected_organization_id == organization_id,
WorkflowModel.organization_id == organization_id,
WorkflowRunModel.usage_info.isnot(None),
)
.order_by(WorkflowRunModel.created_at.desc())
@ -277,9 +273,8 @@ class OrganizationUsageClient(BaseDBClient):
WorkflowRunModel.public_access_token,
)
.join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id)
.join(UserModel, WorkflowModel.user_id == UserModel.id)
.where(
UserModel.selected_organization_id == organization_id,
WorkflowModel.organization_id == organization_id,
WorkflowRunModel.usage_info.isnot(None),
)
.order_by(WorkflowRunModel.created_at.desc())
@ -316,7 +311,6 @@ class OrganizationUsageClient(BaseDBClient):
start_date: datetime,
end_date: datetime,
price_per_second_usd: float,
user_id: Optional[int] = None,
) -> dict:
"""Get daily usage breakdown for an organization with pricing."""
@ -344,22 +338,6 @@ class OrganizationUsageClient(BaseDBClient):
if pref_obj and pref_obj.value:
user_timezone = pref_obj.value.get("timezone") or user_timezone
if user_id:
config_result = await session.execute(
select(UserConfigurationModel).where(
UserConfigurationModel.user_id == user_id,
UserConfigurationModel.key
== UserConfigurationKey.MODEL_CONFIGURATION.value,
)
)
config_obj = config_result.scalar_one_or_none()
if config_obj and config_obj.configuration:
effective_config = EffectiveAIModelConfiguration.model_validate(
config_obj.configuration
)
if effective_config.timezone and user_timezone == "UTC":
user_timezone = effective_config.timezone
# Validate timezone string
try:
# Test if timezone is valid
@ -382,9 +360,8 @@ class OrganizationUsageClient(BaseDBClient):
func.count(WorkflowRunModel.id).label("call_count"),
)
.join(WorkflowModel, WorkflowModel.id == WorkflowRunModel.workflow_id)
.join(UserModel, UserModel.id == WorkflowModel.user_id)
.where(
UserModel.selected_organization_id == organization_id,
WorkflowModel.organization_id == organization_id,
WorkflowRunModel.created_at >= start_date,
WorkflowRunModel.created_at <= end_date,
WorkflowRunModel.is_completed == True,

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

@ -0,0 +1,244 @@
"""Database client for durable outbound webhook deliveries.
Persists one row per webhook node per workflow run and exposes the state
transitions the delivery task and sweeper need: create (pending), succeed,
schedule the next retry, and park as dead-letter. Mirrors the campaign retry
pattern -- the row is the source of truth, ``scheduled_for`` gates due work.
"""
from datetime import UTC, datetime, timedelta
from typing import List, Optional, Tuple
from loguru import logger
from sqlalchemy import or_, select, update
from sqlalchemy.exc import IntegrityError
from api.db.base_client import BaseDBClient
from api.db.models import WebhookDeliveryModel, WorkflowModel, WorkflowRunModel
class WebhookDeliveryClient(BaseDBClient):
"""Client for managing persisted webhook delivery records."""
async def create_webhook_delivery(
self,
workflow_run_id: int,
organization_id: int,
endpoint_url: str,
payload: dict,
max_attempts: int,
http_method: str = "POST",
webhook_name: Optional[str] = None,
custom_headers: Optional[list] = None,
credential_uuid: Optional[str] = None,
webhook_node_id: Optional[str] = None,
scheduled_for: Optional[datetime] = None,
) -> Tuple[WebhookDeliveryModel, bool]:
"""Get-or-create the ``pending`` delivery for this run + webhook node.
Idempotent on ``(workflow_run_id, webhook_node_id)``: a retried
``run_integrations`` returns the existing row instead of creating (and
sending) a duplicate. Returns ``(delivery, created)`` so the caller only
enqueues a send for a freshly-created row.
"""
async with self.async_session() as session:
run_scope_result = await session.execute(
select(WorkflowRunModel.id, WorkflowModel.organization_id)
.join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id)
.where(WorkflowRunModel.id == workflow_run_id)
)
run_scope = run_scope_result.one_or_none()
if run_scope is None:
raise ValueError(f"Workflow run {workflow_run_id} not found")
_, run_organization_id = run_scope
if run_organization_id is None:
raise ValueError(
f"Workflow run {workflow_run_id} is not associated with an organization"
)
if run_organization_id != organization_id:
raise ValueError(
f"Workflow run {workflow_run_id} belongs to organization "
f"{run_organization_id}, not {organization_id}"
)
delivery = WebhookDeliveryModel(
workflow_run_id=workflow_run_id,
organization_id=organization_id,
webhook_name=webhook_name,
webhook_node_id=webhook_node_id,
endpoint_url=endpoint_url,
http_method=http_method,
payload=payload,
custom_headers=custom_headers,
credential_uuid=credential_uuid,
max_attempts=max_attempts,
status="pending",
attempt_count=0,
scheduled_for=scheduled_for or datetime.now(UTC),
)
session.add(delivery)
try:
await session.commit()
except IntegrityError:
await session.rollback()
existing = await session.execute(
select(WebhookDeliveryModel).where(
WebhookDeliveryModel.workflow_run_id == workflow_run_id,
WebhookDeliveryModel.webhook_node_id == webhook_node_id,
)
)
row = existing.scalar_one_or_none()
if row is not None:
return row, False
# The violation was not the run+node uniqueness -- re-raise.
raise
await session.refresh(delivery)
return delivery, True
async def get_webhook_delivery(
self, delivery_id: int
) -> Optional[WebhookDeliveryModel]:
async with self.async_session() as session:
result = await session.execute(
select(WebhookDeliveryModel).where(
WebhookDeliveryModel.id == delivery_id
)
)
return result.scalar_one_or_none()
async def claim_webhook_delivery(
self, delivery_id: int, lease_seconds: int
) -> Optional[WebhookDeliveryModel]:
"""Atomically claim a pending, due delivery for one worker to process.
A conditional UPDATE pushes ``scheduled_for`` out by a short lease. Only
one concurrent worker can win -- the others re-evaluate the WHERE after
the first commits, see the future ``scheduled_for``, match nothing, and
get ``None``. This prevents the non-atomic ``status == 'pending'`` read
from letting two workers double-send the same delivery. If the winning
worker crashes mid-send, the lease expires and the sweeper re-enqueues it.
Returns the claimed row, or ``None`` if it was not claimable (already
claimed, not pending, or not yet due).
"""
now = datetime.now(UTC)
lease_until = now + timedelta(seconds=lease_seconds)
async with self.async_session() as session:
result = await session.execute(
update(WebhookDeliveryModel)
.where(
WebhookDeliveryModel.id == delivery_id,
WebhookDeliveryModel.status == "pending",
or_(
WebhookDeliveryModel.scheduled_for.is_(None),
WebhookDeliveryModel.scheduled_for <= now,
),
)
.values(scheduled_for=lease_until, updated_at=now)
)
await session.commit()
if result.rowcount == 0:
return None
fetched = await session.execute(
select(WebhookDeliveryModel).where(
WebhookDeliveryModel.id == delivery_id
)
)
return fetched.scalar_one_or_none()
async def mark_webhook_delivery_succeeded(
self, delivery_id: int, attempt_count: int, status_code: Optional[int]
) -> None:
async with self.async_session() as session:
await session.execute(
update(WebhookDeliveryModel)
.where(WebhookDeliveryModel.id == delivery_id)
.values(
status="succeeded",
attempt_count=attempt_count,
last_status_code=status_code,
last_error=None,
scheduled_for=None,
updated_at=datetime.now(UTC),
)
)
await session.commit()
async def schedule_webhook_delivery_retry(
self,
delivery_id: int,
attempt_count: int,
scheduled_for: datetime,
last_error: str,
last_status_code: Optional[int],
) -> None:
"""Record a transient failure and set when the next attempt is due."""
async with self.async_session() as session:
await session.execute(
update(WebhookDeliveryModel)
.where(WebhookDeliveryModel.id == delivery_id)
.values(
status="pending",
attempt_count=attempt_count,
scheduled_for=scheduled_for,
last_error=last_error[:2000] if last_error else last_error,
last_status_code=last_status_code,
updated_at=datetime.now(UTC),
)
)
await session.commit()
async def mark_webhook_delivery_dead_letter(
self,
delivery_id: int,
attempt_count: int,
last_error: str,
last_status_code: Optional[int],
) -> None:
"""Terminal failure: parked for inspection, never retried again."""
async with self.async_session() as session:
await session.execute(
update(WebhookDeliveryModel)
.where(WebhookDeliveryModel.id == delivery_id)
.values(
status="dead_letter",
attempt_count=attempt_count,
last_error=last_error[:2000] if last_error else last_error,
last_status_code=last_status_code,
scheduled_for=None,
updated_at=datetime.now(UTC),
)
)
await session.commit()
logger.warning(
f"Webhook delivery {delivery_id} dead-lettered after "
f"{attempt_count} attempts: {last_error}"
)
async def get_due_webhook_deliveries(
self, now: Optional[datetime] = None, limit: int = 100, after_id: int = 0
) -> List[WebhookDeliveryModel]:
"""One page of pending deliveries whose next attempt is due.
Used by the periodic sweeper to re-enqueue deliveries whose ARQ job was
lost (worker restart, Redis flush). The delivery task is idempotent, so a
spurious re-enqueue is harmless. Ordered by ``id`` and gated on
``after_id`` for keyset pagination -- re-enqueuing does not change a row's
due state, so the sweeper pages by id to drain the whole backlog instead
of re-reading the same first page forever.
"""
cutoff = now or datetime.now(UTC)
async with self.async_session() as session:
result = await session.execute(
select(WebhookDeliveryModel)
.where(
WebhookDeliveryModel.status == "pending",
WebhookDeliveryModel.scheduled_for.isnot(None),
WebhookDeliveryModel.scheduled_for <= cutoff,
WebhookDeliveryModel.id > after_id,
)
.order_by(WebhookDeliveryModel.id)
.limit(limit)
)
return list(result.scalars().all())

View file

@ -606,11 +606,9 @@ class WorkflowClient(BaseDBClient):
"""Get workflows by IDs for a specific organization"""
async with self.async_session() as session:
result = await session.execute(
select(WorkflowModel)
.join(WorkflowModel.user)
.where(
select(WorkflowModel).where(
WorkflowModel.id.in_(workflow_ids),
WorkflowModel.user.has(selected_organization_id=organization_id),
WorkflowModel.organization_id == organization_id,
)
)
return result.scalars().all()

View file

@ -40,14 +40,14 @@ class WorkflowRunClient(BaseDBClient):
workflow_query = (
select(WorkflowModel)
.options(joinedload(WorkflowModel.user))
.where(
WorkflowModel.id == workflow_id, WorkflowModel.user_id == user_id
)
.where(WorkflowModel.id == workflow_id)
)
if organization_id is not None:
workflow_query = workflow_query.where(
WorkflowModel.organization_id == organization_id
)
elif user_id is not None:
workflow_query = workflow_query.where(WorkflowModel.user_id == user_id)
workflow = await session.execute(workflow_query)
workflow = workflow.scalars().first()
@ -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,
@ -433,10 +438,10 @@ class WorkflowRunClient(BaseDBClient):
if not workflow_run:
return None, None
if not workflow_run.workflow or not workflow_run.workflow.user:
if not workflow_run.workflow:
return workflow_run, None
organization_id = workflow_run.workflow.user.selected_organization_id
organization_id = workflow_run.workflow.organization_id
return workflow_run, organization_id
async def ensure_public_access_token(self, workflow_run_id: int) -> Optional[str]:

View file

@ -17,6 +17,32 @@ class CallType(Enum):
OUTBOUND = "outbound"
class TelephonyCallStatus(str, Enum):
INITIATED = "initiated"
RINGING = "ringing"
IN_PROGRESS = "in-progress"
ANSWERED = "answered"
COMPLETED = "completed"
FAILED = "failed"
BUSY = "busy"
NO_ANSWER = "no-answer"
CANCELED = "canceled"
ERROR = "error"
@classmethod
def from_raw(cls, value: object) -> "TelephonyCallStatus | None":
if isinstance(value, cls):
return value
if value in (None, ""):
return None
try:
return cls(str(value).lower())
except ValueError:
return None
class WorkflowRunMode(Enum):
ARI = "ari"
PLIVO = "plivo"
@ -77,8 +103,6 @@ class WorkflowRunStatus(Enum):
class OrganizationConfigurationKey(Enum):
DISPOSITION_CODE_MAPPING = "DISPOSITION_CODE_MAPPING"
DISPOSITION_MESSAGE_TEMPLATE = "DISPOSITION_MESSAGE_TEMPLATE"
CONCURRENT_CALL_LIMIT = "CONCURRENT_CALL_LIMIT"
TELEPHONY_CONFIGURATION = (
"TELEPHONY_CONFIGURATION" # Stores all providers + active one
@ -176,3 +200,5 @@ class PostHogEvent(str, Enum):
SIGNED_IN = "signed_in"
ORGANIZATION_CREATED = "organization_created"
ORGANIZATION_USER_ASSOCIATED = "organization_user_associated"
# usage_* events track orgs hitting capacity/limit boundaries
USAGE_CONCURRENT_CALL_LIMIT_REACHED = "usage_concurrent_call_limit_reached"

View file

@ -14,6 +14,7 @@ class TelephonyError(Enum):
ACCOUNT_VALIDATION_FAILED = "ACCOUNT_VALIDATION_FAILED"
PHONE_NUMBER_NOT_CONFIGURED = "PHONE_NUMBER_NOT_CONFIGURED"
SIGNATURE_VALIDATION_FAILED = "SIGNATURE_VALIDATION_FAILED"
CONCURRENT_CALL_LIMIT = "CONCURRENT_CALL_LIMIT"
QUOTA_EXCEEDED = "QUOTA_EXCEEDED"
GENERAL_AUTH_FAILED = "GENERAL_AUTH_FAILED"
VALID = "VALID"
@ -26,6 +27,7 @@ TELEPHONY_ERROR_MESSAGES = {
TelephonyError.ACCOUNT_VALIDATION_FAILED: "Authentication error: Account credentials do not match. Please verify your account SID configuration in the dashboard matches your telephony provider settings.",
TelephonyError.PHONE_NUMBER_NOT_CONFIGURED: "Phone number not configured: This number is not set up for inbound calls in your account. Please add this number to your telephony configuration.",
TelephonyError.SIGNATURE_VALIDATION_FAILED: "Security error: Webhook signature validation failed. Please verify your auth token configuration and ensure requests are coming from your telephony provider.",
TelephonyError.CONCURRENT_CALL_LIMIT: "Service temporarily unavailable: Your account has reached its concurrent call limit. Please try again later.",
TelephonyError.QUOTA_EXCEEDED: "Service temporarily unavailable: Your account has exceeded usage limits. Please contact your administrator or upgrade your plan to continue receiving calls.",
TelephonyError.GENERAL_AUTH_FAILED: "Authentication failed: Please check your webhook URL configuration and ensure your telephony provider settings match your dashboard configuration.",
}

View file

@ -20,26 +20,26 @@ mistake the system has seen at least once.
"""
DOGRAH_MCP_INSTRUCTIONS = """\
You build and edit Dograh voice-AI workflows by emitting TypeScript that uses the `@dograh/sdk` package. Workflows are stored as JSON; this server projects them to TypeScript for editing and parses them back on save.
You build and edit Dograh voice-AI workflows **interactively** with the user using TypeScript that uses the `@dograh/sdk` package. Workflows are stored as JSON; this server projects them to TypeScript for editing and parses them back on save.
## Stages
## Planning and workflow creation
Every authoring session runs through three stages. Inject the right guidance at each by calling `get_voice_prompting_guide` before you write or revise prompts. Do not skip plan when creating; do not skip review when editing prompt-bearing fields.
Every authoring session runs through three stages. Inject the right guidance at each by calling `get_voice_prompting_guide` before you write or revise prompts. You must go through plan phase to gather context from the builder before attempting to build the agent.
1. **Plan** call `get_voice_prompting_guide` with `stage="plan"` first. Decide persona, ordered node list, edges, exit conditions, and tools/credentials needed. Enumerate available `list_node_types`, `list_tools`, `list_credentials`, `list_documents`, `list_recordings` as needed. Present a structured plan to the user and wait for confirmation before writing any code.
1. **Plan** call `get_voice_prompting_guide` with `stage="plan"` first. Ask the relevant contextual questions to the user and reinforce with guidelines from the prompting guide. Decide persona, ordered node list, edges, exit conditions, and tools/credentials needed. Present a structured plan to the user and wait for confirmation before writing any code.
2. **Create** call `get_voice_prompting_guide` with `stage="create"` and (when applicable) `node_type=<type>` before writing each node type's prompts. Drill into specific topics via `get_voice_prompting_guide` with `topic=<id>` only when complexity warrants it. Then emit TypeScript and call `create_workflow` (new) or `save_workflow` (edit).
2. **Create** **after** you have an approved plan from the user, get into this stage. call `get_voice_prompting_guide` with `stage="create"` and (when applicable) `node_type=<type>` before writing each node type's prompts. For a `globalNode`, you must then call `get_voice_prompting_guide` with `topic="common_guidelines"` and put that content in the global node as close to verbatim as possible, changing only details the user has updated. Drill into other topics via `get_voice_prompting_guide` with `topic=<id>` only when complexity warrants it. Then emit TypeScript and call `create_workflow` (new) or `save_workflow` (edit). Enumerate available `list_node_types`, `list_tools`, `list_credentials`, `list_documents`, `list_recordings` as needed.
3. **Review** after a successful save, read any `tips[]` returned and surface them to the user with proposed fixes. Call `get_voice_prompting_guide` with `stage="review"` to enumerate review-time concerns (instruction collision, missing handoff cues, success-criteria gaps).
3. **Review** **after** a successful save, call `get_voice_prompting_guide` with `stage="review"` to enumerate review-time concerns (instruction collision, missing handoff cues, success-criteria gaps).
The guide tool is the authoritative source for prompt-authoring craft (turn-taking, persona, readback, disfluencies). Product-mechanics questions (how a node type works at runtime, what `template_variables` resolve to) belong in `search_docs` / `read_doc` instead don't conflate the two.
The guide tool is the authoritative source for prompt-authoring craft (global guidelines, turn-taking, tool calls, success criteria, guardrails). Product-mechanics questions (how a node type works at runtime, what `template_variables` resolve to) belong in `search_docs` / `read_doc` instead don't conflate the two.
## Call order
## Helpers after planning stage to create workflow
### Creating a reusable tool
### Creating a reusable tool and using it in workflow
1. If authentication is needed, call `list_credentials` and use an existing `credential_uuid`; the user creates credential secrets in the UI.
2. Build a typed tool definition and call `create_tool`. The request schema is authoritative for allowed tool categories and config fields.
3. Use the returned `tool_uuid` in workflow node `tool_uuids`, then call `create_workflow` or `save_workflow`.
3. Use the returned `tool_uuid` in workflow node `tool_uuids`, then call `create_workflow` for a new workflow or `save_workflow` when editing an existing workflow.
### Reading documentation
1. `search_docs` use first for keyword or acronym lookup when the user is asking how Dograh works or how to configure something.
@ -50,7 +50,7 @@ The guide tool is the authoritative source for prompt-authoring craft (turn-taki
1. `list_workflows` locate the target workflow.
2. `get_workflow_code` fetch the current source for that workflow.
3. (optional) `list_node_types` / `get_node_type` consult before adding or editing a node type whose fields aren't already visible in the current code.
4. (optional) `get_voice_prompting_guide` with `stage="create"` and `node_type=<type>` call before revising any node's prompt field.
4. (optional) `get_voice_prompting_guide` with `stage="create"` and `node_type=<type>` call before revising any node's prompt field. When revising a `globalNode`, also call `get_voice_prompting_guide` with `topic="common_guidelines"` and preserve that content's structure and wording unless the user supplied a targeted change.
5. Mutate the code in place. Preserve existing nodes, edges, and variable names unless the task requires removing or renaming them.
6. `save_workflow` persist as a new draft. The published version is untouched.
@ -58,7 +58,7 @@ The guide tool is the authoritative source for prompt-authoring craft (turn-taki
1. Run the plan stage (see above) before any code.
2. Create a simple 1-node workflow with only `startCall` if the user just wants a starter. The user can iteratively add complexity by editing it.
3. `list_node_types` / `get_node_type` consult to learn the fields available on the node types you intend to use.
4. `get_voice_prompting_guide` with `stage="create"` and `node_type=<type>` call before writing each node's prompt.
4. `get_voice_prompting_guide` with `stage="create"` and `node_type=<type>` call before writing each node's prompt. For a `globalNode`, also call `get_voice_prompting_guide` with `topic="common_guidelines"` and place that content in the global node nearly verbatim, adapting only user-provided details such as language, persona, company, transfer target, or qualification scope.
5. Author SDK TypeScript from scratch. The `new Workflow({ name: "..." })` call is required `name` becomes the workflow's display name.
6. `create_workflow` persists a new workflow as version 1 (published). Returns the new `workflow_id`. For subsequent edits use `save_workflow` (which writes a draft).

View file

@ -36,8 +36,9 @@ async def get_voice_prompting_guide(
"""Fetch staged voice-prompting guidance for authoring Dograh workflows.
Call this BEFORE composing or revising any prompt field on a node. The
guide is the authoritative source for prompt-authoring craft (turn-taking,
persona, readback rules, disfluencies); product-mechanics questions
guide is the authoritative source for prompt-authoring craft (global
guidelines, turn-taking, tool calls, success criteria, guardrails);
product-mechanics questions
(how a node type works at runtime) belong in `search_docs` / `read_doc`.
Args:
@ -60,7 +61,9 @@ async def get_voice_prompting_guide(
Briefings are designed to be cheap read the lens, decide what to
drill into, then ask for full content for the 13 topics that matter
for the prompt you're about to write. Do not pull every topic.
for the prompt you're about to write. Always drill into
topic="common_guidelines" before writing or revising a globalNode so the
template content is actually read. Do not pull every topic.
"""
await authenticate_mcp_request()

View file

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

View file

@ -18,5 +18,5 @@ bcrypt==5.0.0
email-validator==2.3.0
posthog==7.19.1
fastmcp==3.2.4
tuner-pipecat-sdk==0.2.0
tuner-pipecat-sdk==0.2.4
PyNaCl==1.6.2

View file

@ -1,19 +1,15 @@
"""Agent-stream WebSocket endpoint.
A single ``/agent-stream/{workflow_uuid}`` socket where a caller can drive
an agent run by passing everything inline in the query string including
provider credentials. The standard ``/telephony/ws/...`` path requires a
``TelephonyConfigurationModel`` row stored in the org; this one does not.
A single ``/agent-stream/{provider_name}/{workflow_uuid}`` socket where a
caller can drive an agent run. The provider is part of the URL path;
provider-specific call metadata is read from that provider's stream protocol.
Auth: the workflow UUID itself acts as the identifier no API key.
Routing: when ``?provider=<registered>`` matches a telephony provider, we
dispatch to that provider's ``handle_external_websocket``. The raw-audio
branch (no provider) is reserved for a future protocol decision and
currently rejects with 1011.
Routing: when ``/{provider_name}`` matches a telephony provider, we
dispatch to that provider's ``handle_external_websocket``.
"""
import uuid
from typing import Optional
from fastapi import APIRouter, WebSocket
from loguru import logger
@ -22,38 +18,30 @@ from starlette.websockets import WebSocketDisconnect
from api.db import db_client
from api.enums import CallType, WorkflowRunState
from api.services.call_concurrency import (
CallConcurrencyLimitError,
call_concurrency,
)
from api.services.quota_service import authorize_workflow_run_start
from api.services.telephony import registry as telephony_registry
router = APIRouter(prefix="/agent-stream")
@router.websocket("/{workflow_uuid}")
@router.websocket("/{provider_name}/{workflow_uuid}")
async def agent_stream_websocket(
websocket: WebSocket,
provider_name: str,
workflow_uuid: str,
):
"""Generic agent-stream WebSocket.
Query params:
provider: registered telephony provider name (e.g. ``cloudonix``)
from / to / callId: call metadata persisted on the workflow run
...: provider-specific credentials/identifiers (e.g. ``session``,
``AccountSid``, ``CallSid`` for cloudonix)
Without ``provider`` the raw-audio branch is currently not implemented.
``provider_name`` is the registered telephony provider name
(e.g. ``cloudonix``).
"""
await websocket.accept()
params = dict(websocket.query_params)
provider_name: Optional[str] = params.get("provider")
if not provider_name:
logger.warning(
f"agent-stream raw audio branch not yet supported "
f"(workflow_uuid={workflow_uuid})"
)
await websocket.close(code=1011, reason="Raw audio stream not yet implemented")
return
params.pop("provider", None)
spec = telephony_registry.get_optional(provider_name)
if spec is None:
@ -67,36 +55,44 @@ async def agent_stream_websocket(
await websocket.close(code=1008, reason="Workflow not found")
return
try:
concurrency_slot = await call_concurrency.acquire_org_slot(
workflow.organization_id,
source=f"agent_stream:{provider_name}",
timeout=0,
)
except CallConcurrencyLimitError:
await websocket.close(code=1008, reason="Concurrent call limit reached")
return
numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000
workflow_run_name = f"WR-AGS-{numeric_suffix:08d}"
call_id = params.get("callId") or params.get("CallSid")
initial_context = {
**(workflow.template_context_variables or {}),
"provider": provider_name,
"caller_number": params.get("from"),
"called_number": params.get("to"),
"direction": "inbound",
}
workflow_run = await db_client.create_workflow_run(
workflow_run_name,
workflow.id,
provider_name,
user_id=workflow.user_id,
call_type=CallType.INBOUND,
initial_context=initial_context,
gathered_context={"call_id": call_id} if call_id else {},
logs={
"inbound_webhook": {
"domain": params.get("Domain"),
},
},
)
try:
workflow_run = await db_client.create_workflow_run(
workflow_run_name,
workflow.id,
provider_name,
user_id=workflow.user_id,
call_type=CallType.INBOUND,
initial_context=initial_context,
organization_id=workflow.organization_id,
)
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run.id)
except Exception:
await call_concurrency.release_slot(concurrency_slot)
raise
set_current_run_id(workflow_run.id)
set_current_org_id(workflow.organization_id)
quota_result = await authorize_workflow_run_start(
workflow_id=workflow.id,
organization_id=workflow.organization_id,
workflow_run_id=workflow_run.id,
)
if not quota_result.has_quota:
@ -104,36 +100,39 @@ async def agent_stream_websocket(
f"agent-stream quota exceeded for user {workflow.user_id}: "
f"{quota_result.error_message}"
)
await call_concurrency.release_workflow_run_slot(workflow_run.id)
await websocket.close(
code=1008, reason=quota_result.error_message or "Quota exceeded"
)
return
await db_client.update_workflow_run(
run_id=workflow_run.id, state=WorkflowRunState.RUNNING.value
)
provider_instance = spec.provider_cls({})
try:
await provider_instance.handle_external_websocket(
websocket,
organization_id=workflow.organization_id,
workflow_id=workflow.id,
user_id=workflow.user_id,
workflow_run_id=workflow_run.id,
params=params,
await db_client.update_workflow_run(
run_id=workflow_run.id, state=WorkflowRunState.RUNNING.value
)
except NotImplementedError as e:
logger.warning(f"agent-stream provider {provider_name} not supported: {e}")
provider_instance = spec.provider_cls({})
try:
await websocket.close(code=1011, reason=str(e))
except RuntimeError:
pass
except WebSocketDisconnect as e:
logger.info(f"agent-stream disconnected: code={e.code} reason={e.reason}")
except Exception as e:
logger.error(f"agent-stream error for run {workflow_run.id}: {e}")
try:
await websocket.close(1011, "Internal server error")
except RuntimeError:
pass
await provider_instance.handle_external_websocket(
websocket,
organization_id=workflow.organization_id,
workflow_id=workflow.id,
workflow_run_id=workflow_run.id,
params=params,
)
except NotImplementedError as e:
logger.warning(f"agent-stream provider {provider_name} not supported: {e}")
try:
await websocket.close(code=1011, reason=str(e))
except RuntimeError:
pass
except WebSocketDisconnect as e:
logger.info(f"agent-stream disconnected: code={e.code} reason={e.reason}")
except Exception as e:
logger.error(f"agent-stream error for run {workflow_run.id}: {e}")
try:
await websocket.close(1011, "Internal server error")
except RuntimeError:
pass
finally:
await call_concurrency.unregister_active_call(workflow_run.id)

View file

@ -1,11 +1,16 @@
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from api.constants import ENABLE_SIGNUP
from api.db import db_client
from api.db.models import UserModel
from api.enums import OrganizationConfigurationKey, PostHogEvent
from api.schemas.auth import AuthResponse, LoginRequest, SignupRequest, UserResponse
from api.services.auth.depends import create_user_configuration_with_mps_key, get_user
from api.services.auth.depends import (
create_user_configuration_with_mps_key,
get_user,
require_local_auth,
)
from api.services.configuration.ai_model_configuration import (
convert_legacy_ai_model_configuration_to_v2,
)
@ -18,8 +23,15 @@ router = APIRouter(
)
@router.post("/signup", response_model=AuthResponse)
@router.post(
"/signup",
response_model=AuthResponse,
dependencies=[Depends(require_local_auth)],
)
async def signup(request: SignupRequest):
if not ENABLE_SIGNUP:
raise HTTPException(status_code=403, detail="Signup is disabled")
# Check if email is already taken
existing_user = await db_client.get_user_by_email(request.email)
if existing_user:
@ -85,7 +97,11 @@ async def signup(request: SignupRequest):
)
@router.post("/login", response_model=AuthResponse)
@router.post(
"/login",
response_model=AuthResponse,
dependencies=[Depends(require_local_auth)],
)
async def login(request: LoginRequest):
# Look up user by email
user = await db_client.get_user_by_email(request.email)

View file

@ -351,9 +351,12 @@ async def create_campaign(
) -> CampaignResponse:
"""Create a new campaign"""
# Verify workflow exists and belongs to organization
workflow_name = await db_client.get_workflow_name(request.workflow_id, user.id)
if not workflow_name:
workflow = await db_client.get_workflow(
request.workflow_id, organization_id=user.selected_organization_id
)
if not workflow:
raise HTTPException(status_code=404, detail="Workflow not found")
workflow_name = workflow.name
# Validate source data (phone_number column and format)
sync_service = get_sync_service(request.source_type)
@ -364,9 +367,6 @@ async def create_campaign(
raise HTTPException(status_code=400, detail=validation_result.error.message)
# Validate template variables against source data columns
workflow = await db_client.get_workflow(
request.workflow_id, organization_id=user.selected_organization_id
)
if workflow:
from api.services.workflow.dto import ReactFlowDTO
from api.services.workflow.workflow_graph import WorkflowGraph
@ -512,7 +512,9 @@ async def get_campaign(
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
campaign.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(campaign.id)
cfg_name = await _get_telephony_configuration_name(
@ -552,6 +554,7 @@ async def start_campaign(
# model_overrides so we evaluate the keys this campaign will use).
quota_result = await authorize_workflow_run_start(
workflow_id=campaign.workflow_id,
organization_id=user.selected_organization_id,
actor_user=user,
)
if not quota_result.has_quota:
@ -565,7 +568,9 @@ async def start_campaign(
# Get updated campaign
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
campaign.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(campaign.id)
cfg_name = await _get_telephony_configuration_name(
@ -599,7 +604,9 @@ async def pause_campaign(
# Get updated campaign
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
campaign.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(campaign.id)
cfg_name = await _get_telephony_configuration_name(
@ -669,7 +676,9 @@ async def update_campaign(
# Re-fetch to return updated data
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
campaign.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(campaign.id)
cfg_name = await _get_telephony_configuration_name(
@ -838,7 +847,9 @@ async def redial_campaign(
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
workflow_name = await db_client.get_workflow_name(child.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
child.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(child.id)
cfg_name = await _get_telephony_configuration_name(
child.telephony_configuration_id, user.selected_organization_id
@ -877,6 +888,7 @@ async def resume_campaign(
# model_overrides so we evaluate the keys this campaign will use).
quota_result = await authorize_workflow_run_start(
workflow_id=campaign.workflow_id,
organization_id=user.selected_organization_id,
actor_user=user,
)
if not quota_result.has_quota:
@ -890,7 +902,9 @@ async def resume_campaign(
# Get updated campaign
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
workflow_name = await db_client.get_workflow_name(
campaign.workflow_id, organization_id=user.selected_organization_id
)
executed, total = await _get_campaign_stats(campaign.id)
cfg_name = await _get_telephony_configuration_name(

View file

@ -373,15 +373,10 @@ 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
# Try to get the organization's embeddings configuration
resolved_config = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
effective_config = resolved_config.effective
@ -405,22 +400,18 @@ 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.
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,
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,10 +71,15 @@ 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
force_turn_relay: bool
signup_enabled: bool
# Public Stack Auth client config — only populated when auth_provider == "stack".
# The UI reads these at runtime to initialize Stack, so they no longer need to
# be baked into the browser bundle at build time. Both are public values.
@ -84,27 +92,90 @@ async def health() -> HealthResponse:
from api.constants import (
APP_VERSION,
AUTH_PROVIDER,
BACKEND_API_ENDPOINT,
DEPLOYMENT_MODE,
ENABLE_SIGNUP,
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),
force_turn_relay=FORCE_TURN_RELAY,
signup_enabled=ENABLE_SIGNUP,
stack_project_id=STACK_AUTH_PROJECT_ID if is_stack else None,
stack_publishable_client_key=(
STACK_PUBLISHABLE_CLIENT_KEY if is_stack else None
),
)
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

@ -42,7 +42,6 @@ from api.schemas.telephony_phone_number import (
ProviderSyncStatus,
)
from api.services.auth.depends import (
_sync_posthog_organization_mps_billing_v2_status,
get_user,
get_user_with_selected_organization,
)
@ -61,6 +60,7 @@ from api.services.configuration.check_validity import UserConfigurationValidator
from api.services.configuration.defaults import DEFAULT_SERVICE_PROVIDERS
from api.services.configuration.masking import is_mask_of, mask_key, mask_user_config
from api.services.configuration.registry import (
DOGRAH_MULTILINGUAL_AUTODETECT_LANGUAGES,
DOGRAH_STT_LANGUAGES,
REGISTRY,
DograhTTSService,
@ -68,6 +68,7 @@ from api.services.configuration.registry import (
ServiceType,
)
from api.services.mps_billing import ensure_hosted_mps_billing_account_v2
from api.services.mps_service_key_client import mps_service_key_client
from api.services.organization_context import (
OrganizationContextResponse,
get_organization_context,
@ -144,6 +145,23 @@ class TelephonyConfigWarningsResponse(BaseModel):
"""
telnyx_missing_webhook_public_key_count: int
vonage_missing_signature_secret_count: int
class ModelConfigurationMetricPrice(BaseModel):
metric_code: str
display_name: str
unit: str
price_per_minute: float
currency: str
rounding_policy: str
class ModelConfigurationPricingResponse(BaseModel):
"""MPS-owned effective prices relevant to model configuration choices."""
platform_usage: ModelConfigurationMetricPrice | None = None
dograh_model: ModelConfigurationMetricPrice | None = None
@router.get("/context", response_model=OrganizationContextResponse)
@ -199,8 +217,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")
@ -208,8 +225,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,
)
@ -239,7 +260,6 @@ async def _model_configuration_v2_response(
configuration: OrganizationAIModelConfigurationV2 | None = None,
) -> OrganizationAIModelConfigurationResponse:
resolved = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
raw_configuration = (
@ -274,6 +294,7 @@ async def get_model_configuration_v2_defaults(
"step": DOGRAH_SPEED_STEP,
},
"languages": DOGRAH_STT_LANGUAGES,
"multilingual_languages": DOGRAH_MULTILINGUAL_AUTODETECT_LANGUAGES,
"defaults": {
"voice": DOGRAH_DEFAULT_VOICE,
"speed": 1.0,
@ -308,6 +329,34 @@ async def get_model_configuration_v2(
return await _model_configuration_v2_response(user=user)
@router.get(
"/model-configurations/v2/pricing",
response_model=ModelConfigurationPricingResponse,
)
async def get_model_configuration_pricing(
user: UserModel = Depends(get_user_with_selected_organization),
) -> ModelConfigurationPricingResponse:
"""Return the hosted organization prices shown in Model Configurations."""
if DEPLOYMENT_MODE == "oss":
return ModelConfigurationPricingResponse()
try:
pricing = await mps_service_key_client.get_billing_pricing(
user.selected_organization_id,
)
return ModelConfigurationPricingResponse.model_validate(pricing)
except Exception as exc:
logger.error(
"Failed to get MPS model-configuration pricing for organization {}: {}",
user.selected_organization_id,
exc,
)
raise HTTPException(
status_code=502,
detail="Failed to retrieve model configuration pricing",
) from exc
@router.put(
"/model-configurations/v2",
response_model=OrganizationAIModelConfigurationResponse,
@ -385,22 +434,21 @@ async def migrate_model_configuration_v2(
except ValueError as exc:
raise HTTPException(status_code=422, detail=exc.args[0])
billing_account_status = None
if DEPLOYMENT_MODE != "oss":
try:
billing_account_status = await ensure_hosted_mps_billing_account_v2(
await ensure_hosted_mps_billing_account_v2(
organization_id,
created_by=str(user.provider_id),
)
except Exception as exc:
logger.error(
"Failed to initialize MPS billing v2 account for organization {}: {}",
"Failed to initialize MPS billing account for organization {}: {}",
organization_id,
exc,
)
raise HTTPException(
status_code=502,
detail="Failed to initialize MPS billing v2 account",
detail="Failed to initialize MPS billing account",
)
await upsert_organization_ai_model_configuration_v2(
@ -411,14 +459,6 @@ async def migrate_model_configuration_v2(
organization_id=organization_id,
fallback_user_config=legacy,
)
if DEPLOYMENT_MODE != "oss":
_sync_posthog_organization_mps_billing_v2_status(
organization_id,
uses_mps_billing_v2=bool(
billing_account_status
and billing_account_status.get("billing_mode") == "v2"
),
)
return await _model_configuration_v2_response(
user=user,
configuration=configuration,

View file

@ -1,6 +1,6 @@
import json
from datetime import datetime, timedelta
from typing import Any, Dict, List, Literal, Optional
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
@ -29,12 +29,6 @@ class CurrentUsageResponse(BaseModel):
price_per_second_usd: Optional[float] = None
class MPSCreditsResponse(BaseModel):
total_credits_used: float
remaining_credits: float
total_quota: float
class MPSCreditPurchaseUrlResponse(BaseModel):
checkout_url: str
@ -69,7 +63,6 @@ class MPSCreditLedgerEntryResponse(BaseModel):
class MPSBillingCreditsResponse(BaseModel):
billing_version: Literal["legacy", "v2"]
total_credits_used: float = 0.0
remaining_credits: float = 0.0
total_quota: float = 0.0
@ -162,69 +155,13 @@ async def get_current_period_usage(user: UserModel = Depends(get_user)):
raise HTTPException(status_code=500, detail=str(e))
@router.get("/usage/mps-credits", response_model=MPSCreditsResponse)
async def get_mps_credits(user: UserModel = Depends(get_user)):
"""Get aggregated usage and quota from MPS.
OSS users: queries by provider_id (created_by).
Hosted users: queries by organization_id.
"""
try:
if DEPLOYMENT_MODE == "oss":
usage = await mps_service_key_client.get_usage_by_created_by(
str(user.provider_id)
)
else:
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
usage = await mps_service_key_client.get_usage_by_organization(
user.selected_organization_id
)
total_used = usage.get("total_credits_used", 0.0)
total_remaining = usage.get("remaining_credits", 0.0)
return MPSCreditsResponse(
total_credits_used=total_used,
remaining_credits=total_remaining,
total_quota=total_used + total_remaining,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to fetch MPS credits: {e}")
raise HTTPException(status_code=500, detail=str(e))
async def _get_mps_billing_account_status(
user: UserModel, organization_id: int
) -> Optional[dict]:
return await mps_service_key_client.get_billing_account_status(
organization_id=organization_id,
created_by=str(user.provider_id),
)
def _is_mps_billing_v2(account: Optional[dict]) -> bool:
return bool(account and account.get("billing_mode") == "v2")
async def _legacy_mps_credits_response(user: UserModel) -> MPSBillingCreditsResponse:
if DEPLOYMENT_MODE == "oss":
usage = await mps_service_key_client.get_usage_by_created_by(
str(user.provider_id)
)
else:
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
usage = await mps_service_key_client.get_usage_by_organization(
user.selected_organization_id
)
async def _oss_mps_credits_response(user: UserModel) -> MPSBillingCreditsResponse:
"""Aggregate per-key MPS credits for OSS deployments (no billing account)."""
usage = await mps_service_key_client.get_usage_by_created_by(str(user.provider_id))
total_used = float(usage.get("total_credits_used", 0.0))
total_remaining = float(usage.get("remaining_credits", 0.0))
return MPSBillingCreditsResponse(
billing_version="legacy",
total_credits_used=total_used,
remaining_credits=total_remaining,
total_quota=total_used + total_remaining,
@ -237,16 +174,15 @@ async def get_billing_credits(
limit: int = Query(50, ge=1, le=100),
user: UserModel = Depends(get_user),
):
"""Return legacy MPS credits or paginated v2 billing ledger details for the org."""
"""Return per-key MPS credits (OSS) or the org's paginated billing ledger."""
try:
if DEPLOYMENT_MODE == "oss" or not user.selected_organization_id:
return await _legacy_mps_credits_response(user)
if DEPLOYMENT_MODE == "oss":
return await _oss_mps_credits_response(user)
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
organization_id = user.selected_organization_id
account_status = await _get_mps_billing_account_status(user, organization_id)
if not _is_mps_billing_v2(account_status):
return await _legacy_mps_credits_response(user)
ledger = await mps_service_key_client.get_credit_ledger(
organization_id=organization_id,
page=page,
@ -287,7 +223,6 @@ async def get_billing_credits(
total_debits = float(ledger["total_debits_credits"])
return MPSBillingCreditsResponse(
billing_version="v2",
total_credits_used=total_debits,
remaining_credits=balance,
total_quota=balance + total_debits,
@ -350,7 +285,7 @@ async def get_billing_credits(
async def create_mps_credit_purchase_url(
user: UserModel = Depends(get_user_with_selected_organization),
):
"""Create a checkout URL for organizations using Dograh-managed MPS v2."""
"""Create a checkout URL for purchasing organization credits."""
if DEPLOYMENT_MODE == "oss":
raise HTTPException(
status_code=404,
@ -359,14 +294,6 @@ async def create_mps_credit_purchase_url(
organization_id = user.selected_organization_id
assert organization_id is not None
account_status = await _get_mps_billing_account_status(user, organization_id)
if not _is_mps_billing_v2(account_status):
raise HTTPException(
status_code=403,
detail=(
"Credit purchases are available only for organizations using billing v2"
),
)
try:
session = await mps_service_key_client.create_credit_purchase_url(
@ -585,7 +512,6 @@ async def get_daily_usage_breakdown(
start_date,
end_date,
org.price_per_second_usd,
user_id=user.id,
)
return breakdown

View file

@ -14,6 +14,10 @@ from pydantic import BaseModel
from api.db import db_client
from api.enums import TriggerState, WorkflowStatus
from api.services.call_concurrency import (
CallConcurrencyLimitError,
call_concurrency,
)
from api.services.quota_service import authorize_workflow_run_start
from api.services.telephony.factory import (
get_default_telephony_provider,
@ -243,15 +247,32 @@ async def _execute_resolved_target(
initial_context["api_key_created_by"] = api_key_created_by
initial_context.update(request.initial_context or {})
workflow_run = await db_client.create_workflow_run(
name=workflow_run_name,
workflow_id=target.workflow.id,
mode=workflow_run_mode,
initial_context=initial_context,
user_id=execution_user_id,
use_draft=use_draft,
organization_id=target.organization_id,
)
try:
concurrency_slot = await call_concurrency.acquire_org_slot(
target.organization_id,
source="public_agent",
timeout=0,
)
except CallConcurrencyLimitError:
raise HTTPException(
status_code=429,
detail="Concurrent call limit reached",
)
try:
workflow_run = await db_client.create_workflow_run(
name=workflow_run_name,
workflow_id=target.workflow.id,
mode=workflow_run_mode,
initial_context=initial_context,
user_id=execution_user_id,
use_draft=use_draft,
organization_id=target.organization_id,
)
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run.id)
except Exception:
await call_concurrency.release_slot(concurrency_slot)
raise
logger.info(
f"Created workflow run {workflow_run.id} for public agent "
@ -264,25 +285,30 @@ async def _execute_resolved_target(
# the MPS correlation id before the provider starts the call.
quota_result = await authorize_workflow_run_start(
workflow_id=target.workflow.id,
organization_id=target.organization_id,
workflow_run_id=workflow_run.id,
)
if not quota_result.has_quota:
await call_concurrency.release_workflow_run_slot(workflow_run.id)
raise HTTPException(status_code=402, detail=quota_result.error_message)
# 9. Construct webhook URL for telephony provider callback
backend_endpoint, _ = await get_backend_endpoints()
try:
backend_endpoint, _ = await get_backend_endpoints()
except Exception:
await call_concurrency.release_workflow_run_slot(workflow_run.id)
raise
webhook_endpoint = provider.WEBHOOK_ENDPOINT
webhook_url = (
f"{backend_endpoint}/api/v1/telephony/{webhook_endpoint}"
f"?workflow_id={target.workflow.id}"
f"&user_id={execution_user_id}"
f"&workflow_run_id={workflow_run.id}"
f"&organization_id={target.organization_id}"
)
# 10. Initiate call via telephony provider. workflow_id and user_id are
# required by providers that build the media WebSocket URL at dial time
# 10. Initiate call via telephony provider. workflow_id and organization_id
# are required by providers that build the media WebSocket URL at dial time
# (e.g. Telnyx, Cloudonix); without them the URL contains "None/None" and
# the stream connection fails.
try:
@ -291,12 +317,13 @@ async def _execute_resolved_target(
webhook_url=webhook_url,
workflow_run_id=workflow_run.id,
workflow_id=target.workflow.id,
user_id=execution_user_id,
organization_id=target.organization_id,
)
except Exception as e:
logger.warning(
f"Failed to initiate call for workflow run {workflow_run.id}: {e}"
)
await call_concurrency.release_workflow_run_slot(workflow_run.id)
raise HTTPException(
status_code=400,
detail=f"Failed to initiate call: {e}",

View file

@ -309,7 +309,11 @@ 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,
organization_id=embed_token.organization_id,
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

@ -8,7 +8,11 @@ from pydantic import BaseModel
from api.db import db_client
from api.db.models import UserModel
from api.services.auth.depends import get_superuser
from api.services.auth.stack_auth import stackauth
from api.services.auth.stack_auth import (
StackAuthSessionError,
StackAuthUserSearchError,
stackauth,
)
router = APIRouter(prefix="/superuser", tags=["superuser"])
@ -16,12 +20,14 @@ router = APIRouter(prefix="/superuser", tags=["superuser"])
class ImpersonateRequest(BaseModel):
"""Request payload for superadmin impersonation.
Either ``provider_user_id`` **or** ``user_id`` must be supplied. If both are
provided, ``provider_user_id`` takes precedence.
``provider_user_id``, ``user_id``, or ``email`` may be supplied. If more
than one is provided, ``provider_user_id`` takes precedence, followed by
``user_id`` and then ``email``.
"""
provider_user_id: str | None = None
user_id: int | None = None
email: str | None = None
class ImpersonateResponse(BaseModel):
@ -65,32 +71,79 @@ async def impersonate(
to create an impersonation session.
"""
provider_user_id: str | None = request.provider_user_id
provider_user_id = (
request.provider_user_id.strip() if request.provider_user_id else None
) or None
email = request.email.strip().lower() if request.email else None
# ------------------------------------------------------------------
# Fallback: resolve provider_user_id from internal ``user_id``
# Fallback: resolve provider_user_id from internal ``user_id`` or email.
# ------------------------------------------------------------------
if provider_user_id is None:
if request.user_id is None:
if request.user_id is not None:
db_user = await db_client.get_user_by_id(request.user_id)
if db_user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {request.user_id} not found.",
)
provider_user_id = db_user.provider_id
elif email:
db_user = await db_client.get_user_by_email(email)
if db_user is not None:
provider_user_id = db_user.provider_id
else:
try:
stack_users = await stackauth.find_users_by_email(email)
except StackAuthUserSearchError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Failed to search Stack Auth users.",
) from exc
if len(stack_users) == 1 and isinstance(stack_users[0].get("id"), str):
provider_user_id = stack_users[0]["id"]
elif len(stack_users) > 1:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Multiple Stack Auth users matched that email.",
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with email {email} not found.",
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Either 'provider_user_id' or 'user_id' must be provided.",
detail=(
"One of 'provider_user_id', 'user_id', or 'email' must be provided."
),
)
db_user = await db_client.get_user_by_id(request.user_id)
if db_user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {request.user_id} not found.",
)
provider_user_id = db_user.provider_id
# ------------------------------------------------------------------
# Call Stack Auth to create the impersonation session
# ------------------------------------------------------------------
session = await stackauth.impersonate(provider_user_id)
try:
session = await stackauth.impersonate(provider_user_id)
except StackAuthSessionError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Failed to create Stack Auth impersonation session.",
) from exc
if (
not isinstance(session, dict)
or "refresh_token" not in session
or "access_token" not in session
):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Failed to create Stack Auth impersonation session.",
)
return ImpersonateResponse(
refresh_token=session["refresh_token"],

View file

@ -21,10 +21,15 @@ 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
from api.services.call_concurrency import (
CallConcurrencyLimitError,
WorkflowRunSlotAlreadyBoundError,
call_concurrency,
)
from api.services.quota_service import authorize_workflow_run_start
from api.services.telephony.call_transfer_manager import get_call_transfer_manager
from api.services.telephony.factory import (
@ -139,70 +144,6 @@ async def initiate_call(
# Determine the workflow run mode based on provider type
workflow_run_mode = provider.PROVIDER_NAME
workflow_run_id = request.workflow_run_id
if not workflow_run_id:
# Merge template context variables (e.g. caller_number, called_number
# set in workflow settings for testing pre-call data fetch).
template_vars = workflow.template_context_variables or {}
numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000
workflow_run_name = f"WR-TEL-OUT-{numeric_suffix:08d}"
workflow_run = await db_client.create_workflow_run(
workflow_run_name,
workflow.id,
workflow_run_mode,
user_id=execution_user_id,
call_type=CallType.OUTBOUND,
initial_context={
**template_vars,
"phone_number": phone_number,
"called_number": phone_number,
"provider": provider.PROVIDER_NAME,
"telephony_configuration_id": telephony_configuration_id,
},
use_draft=True,
organization_id=user.selected_organization_id,
)
workflow_run_id = workflow_run.id
else:
workflow_run = await db_client.get_workflow_run(
workflow_run_id, organization_id=user.selected_organization_id
)
if not workflow_run:
raise HTTPException(status_code=400, detail="Workflow run not found")
if workflow_run.workflow_id != workflow.id:
raise HTTPException(
status_code=400,
detail="workflow_run_workflow_mismatch",
)
workflow_run_name = workflow_run.name
# Check Dograh quota after the run exists so hosted v2 can mint and store
# the MPS correlation id before initiating the call.
quota_result = await authorize_workflow_run_start(
workflow_id=workflow.id,
workflow_run_id=workflow_run_id,
actor_user=user,
)
if not quota_result.has_quota:
raise HTTPException(status_code=402, detail=quota_result.error_message)
# Construct webhook URL based on provider type
backend_endpoint, _ = await get_backend_endpoints()
webhook_endpoint = provider.WEBHOOK_ENDPOINT
webhook_url = (
f"{backend_endpoint}/api/v1/telephony/{webhook_endpoint}"
f"?workflow_id={workflow.id}"
f"&user_id={execution_user_id}"
f"&workflow_run_id={workflow_run_id}"
f"&organization_id={user.selected_organization_id}"
)
keywords = {"workflow_id": workflow.id, "user_id": execution_user_id}
# Resolve optional caller-ID. The config has already been validated against
# the user's organization, so filtering by config_id is sufficient for
# tenant isolation.
@ -220,14 +161,105 @@ async def initiate_call(
raise HTTPException(status_code=400, detail="from_phone_number_not_found")
from_number = phone_row.address_normalized
# Initiate call via provider
result = await provider.initiate_call(
to_number=phone_number,
webhook_url=webhook_url,
workflow_run_id = request.workflow_run_id
try:
concurrency_slot = await call_concurrency.acquire_org_slot(
user.selected_organization_id,
source="telephony_outbound",
timeout=0,
)
except CallConcurrencyLimitError:
raise HTTPException(status_code=429, detail="Concurrent call limit reached")
try:
if not workflow_run_id:
# Merge template context variables (e.g. caller_number, called_number
# set in workflow settings for testing pre-call data fetch).
template_vars = workflow.template_context_variables or {}
numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000
workflow_run_name = f"WR-TEL-OUT-{numeric_suffix:08d}"
workflow_run = await db_client.create_workflow_run(
workflow_run_name,
workflow.id,
workflow_run_mode,
user_id=execution_user_id,
call_type=CallType.OUTBOUND,
initial_context={
**template_vars,
"phone_number": phone_number,
"called_number": phone_number,
"provider": provider.PROVIDER_NAME,
"telephony_configuration_id": telephony_configuration_id,
},
use_draft=True,
organization_id=user.selected_organization_id,
)
workflow_run_id = workflow_run.id
else:
workflow_run = await db_client.get_workflow_run(
workflow_run_id, organization_id=user.selected_organization_id
)
if not workflow_run:
raise HTTPException(status_code=400, detail="Workflow run not found")
if workflow_run.workflow_id != workflow.id:
raise HTTPException(
status_code=400,
detail="workflow_run_workflow_mismatch",
)
workflow_run_name = workflow_run.name
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run_id)
except WorkflowRunSlotAlreadyBoundError:
raise HTTPException(
status_code=409,
detail="Workflow run already has an active call",
)
except Exception:
await call_concurrency.release_slot(concurrency_slot)
raise
# Check Dograh quota after the run exists so hosted v2 can mint and store
# the MPS correlation id before initiating the call.
quota_result = await authorize_workflow_run_start(
workflow_id=workflow.id,
organization_id=user.selected_organization_id,
workflow_run_id=workflow_run_id,
from_number=from_number,
**keywords,
actor_user=user,
)
if not quota_result.has_quota:
await call_concurrency.release_workflow_run_slot(workflow_run_id)
raise HTTPException(status_code=402, detail=quota_result.error_message)
try:
# Construct webhook URL based on provider type
backend_endpoint, _ = await get_backend_endpoints()
webhook_endpoint = provider.WEBHOOK_ENDPOINT
webhook_url = (
f"{backend_endpoint}/api/v1/telephony/{webhook_endpoint}"
f"?workflow_id={workflow.id}"
f"&workflow_run_id={workflow_run_id}"
f"&organization_id={user.selected_organization_id}"
)
keywords = {
"workflow_id": workflow.id,
"organization_id": user.selected_organization_id,
}
# Initiate call via provider
result = await provider.initiate_call(
to_number=phone_number,
webhook_url=webhook_url,
workflow_run_id=workflow_run_id,
from_number=from_number,
**keywords,
)
except Exception:
await call_concurrency.release_workflow_run_slot(workflow_run_id)
raise
# Store provider metadata and caller_number in workflow run context
gathered_context = {
@ -421,6 +453,7 @@ async def _validate_inbound_request(
async def _create_inbound_workflow_run(
workflow_id: int,
user_id: int,
organization_id: int,
provider: str,
normalized_data,
telephony_configuration_id: int,
@ -456,6 +489,7 @@ async def _create_inbound_workflow_run(
"raw_webhook_data": normalized_data.raw_data,
},
},
organization_id=organization_id,
)
logger.info(
@ -511,13 +545,13 @@ async def websocket_ari_endpoint(websocket: WebSocket):
query params (appended by the v() dial string option in externalMedia).
"""
workflow_id = websocket.query_params.get("workflow_id")
user_id = websocket.query_params.get("user_id")
organization_id = websocket.query_params.get("organization_id")
workflow_run_id = websocket.query_params.get("workflow_run_id")
if not workflow_id or not user_id or not workflow_run_id:
if not workflow_id or not organization_id or not workflow_run_id:
logger.error(
f"ARI WebSocket missing query params: "
f"workflow_id={workflow_id}, user_id={user_id}, workflow_run_id={workflow_run_id}"
f"ARI WebSocket missing query params: workflow_id={workflow_id}, "
f"organization_id={organization_id}, workflow_run_id={workflow_run_id}"
)
await websocket.close(code=4400, reason="Missing required query params")
return
@ -527,41 +561,64 @@ async def websocket_ari_endpoint(websocket: WebSocket):
await websocket.accept(subprotocol="media")
await _handle_telephony_websocket(
websocket, int(workflow_id), int(user_id), int(workflow_run_id)
websocket, int(workflow_id), int(organization_id), int(workflow_run_id)
)
@router.websocket("/ws/{workflow_id}/{user_id}/{workflow_run_id}")
@router.websocket("/ws/{workflow_id}/{organization_id}/{workflow_run_id}")
async def websocket_endpoint(
websocket: WebSocket, workflow_id: int, user_id: int, workflow_run_id: int
websocket: WebSocket, workflow_id: int, organization_id: int, workflow_run_id: int
):
"""WebSocket endpoint for real-time call handling - routes to provider-specific handlers."""
await websocket.accept()
await _handle_telephony_websocket(websocket, workflow_id, user_id, workflow_run_id)
await _handle_telephony_websocket(
websocket, workflow_id, organization_id, workflow_run_id
)
async def _handle_telephony_websocket(
websocket: WebSocket, workflow_id: int, user_id: int, workflow_run_id: int
websocket: WebSocket, workflow_id: int, organization_id: int, workflow_run_id: int
):
"""Shared WebSocket handler logic (connection already accepted)."""
"""Shared WebSocket handler logic (connection already accepted).
TODO(security): ``organization_id`` arrives in the URL the provider dials
back, so it is caller-supplied and unauthenticated this socket has no
signature check, and the id triple is a guessable bearer capability.
Scoping the lookups below by it prevents an accidental cross-org mismatch,
not a deliberate one. The real fix is a one-shot capability token minted at
run creation and redeemed here through an atomic
``initialized -> running`` compare-and-swap, which would also close the
read-then-write race on the state check further down.
"""
try:
# Set the run context
set_current_run_id(workflow_run_id)
# Get workflow run to determine provider type
workflow_run = await db_client.get_workflow_run(workflow_run_id)
workflow_run = await db_client.get_workflow_run(
workflow_run_id, organization_id=organization_id
)
if not workflow_run:
logger.error(f"Workflow run {workflow_run_id} not found")
logger.error(
f"Workflow run {workflow_run_id} not found for org {organization_id}"
)
await websocket.close(code=4404, reason="Workflow run not found")
return
# Get workflow for organization info. System lookup keyed only on the
# workflow_id (org is derived below) — use the explicit unscoped variant.
workflow = await db_client.get_workflow_by_id(workflow_id)
workflow = await db_client.get_workflow(
workflow_id, organization_id=organization_id
)
if not workflow:
logger.error(f"Workflow {workflow_id} not found")
logger.error(f"Workflow {workflow_id} not found for org {organization_id}")
await websocket.close(code=4404, reason="Workflow not found")
return
if workflow_run.workflow_id != workflow.id:
logger.error(
f"Workflow run {workflow_run_id} belongs to workflow "
f"{workflow_run.workflow_id}, not {workflow.id}"
)
await websocket.close(code=4400, reason="workflow_run_workflow_mismatch")
return
# Check workflow run state - only allow 'initialized' state
if workflow_run.state != WorkflowRunState.INITIALIZED.value:
@ -584,12 +641,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(
@ -619,7 +700,7 @@ async def _handle_telephony_websocket(
# Delegate to provider-specific handler
await provider.handle_websocket(
websocket, workflow_id, user_id, workflow_run_id
websocket, workflow_id, organization_id, workflow_run_id
)
except WebSocketDisconnect as e:
@ -660,7 +741,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}"
@ -741,40 +822,68 @@ async def handle_inbound_run(request: Request):
TelephonyError.SIGNATURE_VALIDATION_FAILED
)
# 5. Create workflow run + authorize quota before returning provider
# stream instructions.
workflow_run_id = await _create_inbound_workflow_run(
workflow_id,
user_id,
provider_class.PROVIDER_NAME,
normalized_data,
telephony_configuration_id=telephony_configuration_id,
from_phone_number_id=phone_row.id,
)
quota_result = await authorize_workflow_run_start(
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
)
if not quota_result.has_quota:
logger.warning(
f"User {user_id} has exceeded quota: {quota_result.error_message}"
try:
concurrency_slot = await call_concurrency.acquire_org_slot(
config.organization_id,
source=f"inbound:{provider_class.PROVIDER_NAME}",
timeout=0,
)
except CallConcurrencyLimitError:
return provider_class.generate_validation_error_response(
TelephonyError.QUOTA_EXCEEDED
TelephonyError.CONCURRENT_CALL_LIMIT
)
backend_endpoint, wss_backend_endpoint = await get_backend_endpoints()
websocket_url = (
f"{wss_backend_endpoint}/api/v1/telephony/ws/"
f"{workflow_id}/{user_id}/{workflow_run_id}"
)
workflow_run_id = None
try:
# 5. Create workflow run + authorize quota before returning provider
# stream instructions.
workflow_run_id = await _create_inbound_workflow_run(
workflow_id,
user_id,
config.organization_id,
provider_class.PROVIDER_NAME,
normalized_data,
telephony_configuration_id=telephony_configuration_id,
from_phone_number_id=phone_row.id,
)
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run_id)
return await provider_instance.start_inbound_stream(
websocket_url=websocket_url,
workflow_run_id=workflow_run_id,
normalized_data=normalized_data,
backend_endpoint=backend_endpoint,
)
quota_result = await authorize_workflow_run_start(
workflow_id=workflow_id,
organization_id=config.organization_id,
workflow_run_id=workflow_run_id,
)
if not quota_result.has_quota:
logger.warning(
f"User {user_id} has exceeded quota: {quota_result.error_message}"
)
await call_concurrency.release_workflow_run_slot(workflow_run_id)
return provider_class.generate_validation_error_response(
TelephonyError.QUOTA_EXCEEDED
)
backend_endpoint, wss_backend_endpoint = await get_backend_endpoints()
websocket_url = (
f"{wss_backend_endpoint}/api/v1/telephony/ws/"
f"{workflow_id}/{config.organization_id}/{workflow_run_id}"
)
return await provider_instance.start_inbound_stream(
websocket_url=websocket_url,
workflow_run_id=workflow_run_id,
normalized_data=normalized_data,
backend_endpoint=backend_endpoint,
)
except WorkflowRunSlotAlreadyBoundError:
return provider_class.generate_validation_error_response(
TelephonyError.CONCURRENT_CALL_LIMIT
)
except Exception:
if workflow_run_id:
await call_concurrency.release_workflow_run_slot(workflow_run_id)
else:
await call_concurrency.release_slot(concurrency_slot)
raise
except ValueError as e:
logger.error(f"/inbound/run request parsing error: {e}")
@ -847,7 +956,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}")
@ -876,38 +985,73 @@ async def handle_inbound_telephony(
logger.error(f"Request validation failed: {error_type}")
return provider_class.generate_validation_error_response(error_type)
# Create workflow run.
user_id = workflow_context["user_id"]
workflow_run_id = await _create_inbound_workflow_run(
workflow_id,
workflow_context["user_id"],
workflow_context["provider"],
normalized_data,
telephony_configuration_id=workflow_context["telephony_configuration_id"],
from_phone_number_id=workflow_context.get("from_phone_number_id"),
)
quota_result = await authorize_workflow_run_start(
workflow_id=workflow_id,
workflow_run_id=workflow_run_id,
)
if not quota_result.has_quota:
logger.warning(
f"User {user_id} has exceeded quota for inbound calls: {quota_result.error_message}"
organization_id = workflow_context["organization_id"]
try:
concurrency_slot = await call_concurrency.acquire_org_slot(
organization_id,
source=f"inbound_legacy:{workflow_context['provider']}",
timeout=0,
)
except CallConcurrencyLimitError:
return provider_class.generate_validation_error_response(
TelephonyError.QUOTA_EXCEEDED
TelephonyError.CONCURRENT_CALL_LIMIT
)
# Generate response URLs
backend_endpoint, wss_backend_endpoint = await get_backend_endpoints()
websocket_url = f"{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{workflow_context['user_id']}/{workflow_run_id}"
workflow_run_id = None
try:
# Create workflow run.
workflow_run_id = await _create_inbound_workflow_run(
workflow_id,
workflow_context["user_id"],
organization_id,
workflow_context["provider"],
normalized_data,
telephony_configuration_id=workflow_context[
"telephony_configuration_id"
],
from_phone_number_id=workflow_context.get("from_phone_number_id"),
)
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run_id)
response = await provider_instance.start_inbound_stream(
websocket_url=websocket_url,
workflow_run_id=workflow_run_id,
normalized_data=normalized_data,
backend_endpoint=backend_endpoint,
)
quota_result = await authorize_workflow_run_start(
workflow_id=workflow_id,
organization_id=organization_id,
workflow_run_id=workflow_run_id,
)
if not quota_result.has_quota:
logger.warning(
f"User {user_id} has exceeded quota for inbound calls: "
f"{quota_result.error_message}"
)
await call_concurrency.release_workflow_run_slot(workflow_run_id)
return provider_class.generate_validation_error_response(
TelephonyError.QUOTA_EXCEEDED
)
# Generate response URLs
backend_endpoint, wss_backend_endpoint = await get_backend_endpoints()
websocket_url = (
f"{wss_backend_endpoint}/api/v1/telephony/ws/"
f"{workflow_id}/{organization_id}/{workflow_run_id}"
)
response = await provider_instance.start_inbound_stream(
websocket_url=websocket_url,
workflow_run_id=workflow_run_id,
normalized_data=normalized_data,
backend_endpoint=backend_endpoint,
)
except WorkflowRunSlotAlreadyBoundError:
return provider_class.generate_validation_error_response(
TelephonyError.CONCURRENT_CALL_LIMIT
)
except Exception:
if workflow_run_id:
await call_concurrency.release_workflow_run_slot(workflow_run_id)
else:
await call_concurrency.release_slot(concurrency_slot)
raise
logger.info(
f"Generated {normalized_data.provider} response for call {normalized_data.call_id}"

View file

@ -10,9 +10,16 @@ from api.db.models import (
UserModel,
)
from api.schemas.onboarding_state import OnboardingState, OnboardingStateUpdate
from api.schemas.workflow_configurations import (
WorkflowConfigurationDefaults,
get_default_workflow_configurations,
)
from api.services.auth.depends import get_user
from api.services.configuration.ai_model_configuration import (
convert_legacy_ai_model_configuration_to_v2,
get_resolved_ai_model_configuration,
update_organization_ai_model_configuration_last_validated_at,
upsert_organization_ai_model_configuration_v2,
)
from api.services.configuration.check_validity import (
APIKeyStatusResponse,
@ -40,13 +47,14 @@ class AuthUserResponse(TypedDict):
is_superuser: bool
class DefaultConfigurationsResponse(TypedDict):
class DefaultConfigurationsResponse(BaseModel):
llm: dict[str, dict]
tts: dict[str, dict]
stt: dict[str, dict]
embeddings: dict[str, dict]
realtime: dict[str, dict]
default_providers: dict[str, str]
workflow_configurations: WorkflowConfigurationDefaults
@router.get("/configurations/defaults")
@ -73,8 +81,9 @@ async def get_default_configurations() -> DefaultConfigurationsResponse:
for provider, model_cls in REGISTRY[ServiceType.REALTIME].items()
},
"default_providers": DEFAULT_SERVICE_PROVIDERS,
"workflow_configurations": get_default_workflow_configurations(),
}
return configurations
return DefaultConfigurationsResponse(**configurations)
@router.get("/auth/user")
@ -99,12 +108,29 @@ class UserConfigurationRequestResponseSchema(BaseModel):
organization_pricing: dict[str, Union[float, str, bool]] | None = None
def _is_validation_cache_stale(
last_validated_at: datetime | None,
validity_ttl_seconds: int,
) -> bool:
if last_validated_at is None:
return True
has_timezone = (
last_validated_at.tzinfo is not None
and last_validated_at.utcoffset() is not None
)
if has_timezone:
now = datetime.now(last_validated_at.tzinfo)
else:
now = datetime.now()
return last_validated_at < now - timedelta(seconds=validity_ttl_seconds)
@router.get("/configurations/user")
async def get_user_configurations(
user: UserModel = Depends(get_user),
) -> UserConfigurationRequestResponseSchema:
resolved_config = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
masked_config = mask_user_config(resolved_config.effective)
@ -133,7 +159,11 @@ async def update_user_configurations(
request: UserConfigurationRequestResponseSchema,
user: UserModel = Depends(get_user),
) -> UserConfigurationRequestResponseSchema:
existing_config = await db_client.get_user_configurations(user.id)
existing_config = (
await get_resolved_ai_model_configuration(
organization_id=user.selected_organization_id,
)
).effective
incoming_dict = request.model_dump(exclude_none=True)
@ -146,6 +176,9 @@ async def update_user_configurations(
}
if incoming_dict:
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
# Merge via helper
try:
user_configurations = merge_user_configurations(
@ -169,8 +202,16 @@ async def update_user_configurations(
except ValueError as e:
raise HTTPException(status_code=422, detail=e.args[0])
user_configurations = await db_client.update_user_configuration(
user.id, user_configurations
try:
organization_configuration = convert_legacy_ai_model_configuration_to_v2(
user_configurations
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
await upsert_organization_ai_model_configuration_v2(
user.selected_organization_id,
organization_configuration,
)
else:
user_configurations = existing_config
@ -229,15 +270,13 @@ async def validate_user_configurations(
user: UserModel = Depends(get_user),
) -> APIKeyStatusResponse:
resolved_config = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
configurations = resolved_config.effective
if (
configurations.last_validated_at
and configurations.last_validated_at
< datetime.now() - timedelta(seconds=validity_ttl_seconds)
if _is_validation_cache_stale(
configurations.last_validated_at,
validity_ttl_seconds,
):
validator = UserConfigurationValidator()
try:
@ -246,7 +285,13 @@ async def validate_user_configurations(
organization_id=user.selected_organization_id,
created_by=user.provider_id,
)
await db_client.update_user_configuration_last_validated_at(user.id)
if (
resolved_config.source == "organization_v2"
and user.selected_organization_id is not None
):
await update_organization_ai_model_configuration_last_validated_at(
user.selected_organization_id
)
return status
except ValueError as e:
raise HTTPException(status_code=422, detail=e.args[0])

View file

@ -40,6 +40,11 @@ from api.routes.turn_credentials import (
generate_turn_credentials,
)
from api.services.auth.depends import get_user_ws
from api.services.call_concurrency import (
CallConcurrencyLimitError,
WorkflowRunSlotAlreadyBoundError,
call_concurrency,
)
from api.services.pipecat.run_pipeline import run_pipeline_smallwebrtc
from api.services.pipecat.ws_sender_registry import (
register_ws_sender,
@ -321,6 +326,9 @@ class SignalingManager:
workflow_id: int,
workflow_run_id: int,
user: UserModel,
organization_id: int,
enforce_call_concurrency: bool = False,
call_concurrency_source: str = "webrtc",
):
"""Handle WebSocket connection for signaling."""
await websocket.accept()
@ -337,7 +345,10 @@ class SignalingManager:
workflow_id,
workflow_run_id,
user,
organization_id,
connection_key,
enforce_call_concurrency,
call_concurrency_source,
)
except WebSocketDisconnect:
logger.info(f"WebSocket disconnected for {connection_id}")
@ -378,7 +389,10 @@ class SignalingManager:
workflow_id: int,
workflow_run_id: int,
user: UserModel,
organization_id: int,
connection_key: str,
enforce_call_concurrency: bool,
call_concurrency_source: str = "webrtc",
):
"""Handle incoming WebSocket messages."""
msg_type = message.get("type")
@ -386,7 +400,15 @@ class SignalingManager:
if msg_type == "offer":
await self._handle_offer(
ws, payload, workflow_id, workflow_run_id, user, connection_key
ws,
payload,
workflow_id,
workflow_run_id,
user,
organization_id,
connection_key,
enforce_call_concurrency,
call_concurrency_source,
)
elif msg_type == "ice-candidate":
await self._handle_ice_candidate(payload, connection_key)
@ -400,7 +422,10 @@ class SignalingManager:
workflow_id: int,
workflow_run_id: int,
user: UserModel,
organization_id: int,
connection_key: str,
enforce_call_concurrency: bool,
call_concurrency_source: str = "webrtc",
):
"""Handle offer message and create answer with ICE trickling."""
pc_id = payload.get("pc_id")
@ -420,14 +445,13 @@ class SignalingManager:
# Set run context for logging and tracing. org_id must be set before
# pc.initialize() so that aiortc's internal tasks inherit it.
set_current_run_id(workflow_run_id)
org_id = await db_client.get_workflow_organization_id(workflow_id)
if org_id:
set_current_org_id(org_id)
set_current_org_id(organization_id)
# Check Dograh quota before initiating the call (apply per-workflow
# model_overrides so we evaluate the keys this workflow will use).
quota_result = await authorize_workflow_run_start(
workflow_id=workflow_id,
organization_id=organization_id,
workflow_run_id=workflow_run_id,
actor_user=user,
)
@ -472,69 +496,124 @@ class SignalingManager:
}
)
else:
concurrency_slot = None
concurrency_bound = False
pipeline_started = False
pc = None
if enforce_call_concurrency:
try:
concurrency_slot = await call_concurrency.acquire_org_slot(
organization_id,
source=call_concurrency_source,
timeout=0,
)
await call_concurrency.bind_workflow_run(
concurrency_slot,
workflow_run_id,
)
concurrency_bound = True
except CallConcurrencyLimitError:
await ws.send_json(
{
"type": "error",
"payload": {
"error_type": "concurrency_limit_exceeded",
"message": "Concurrent call limit reached",
},
}
)
return
except WorkflowRunSlotAlreadyBoundError:
await ws.send_json(
{
"type": "error",
"payload": {
"error_type": "workflow_run_already_active",
"message": "Workflow run already has an active call",
},
}
)
return
# Create new connection using correct SmallWebRTC API
# Generate ICE servers with time-limited TURN credentials for this user
user_ice_servers = get_ice_servers(user_id=str(user.id))
pc = SmallWebRTCConnection(
ice_servers=user_ice_servers, connection_timeout_secs=60
)
# Set the pc_id before initialization so it's available in get_answer()
pc._pc_id = pc_id
# Initialize connection with offer
await pc.initialize(sdp=sdp, type=type_)
# Store peer connection using client's pc_id
self._track_peer_connection(connection_key, pc_id, pc)
# Register WebSocket sender for real-time feedback
async def ws_sender(message: dict):
if ws.application_state == WebSocketState.CONNECTED:
await ws.send_json(message)
register_ws_sender(workflow_run_id, ws_sender)
# Setup closed handler
@pc.event_handler("closed")
async def handle_disconnected(webrtc_connection: SmallWebRTCConnection):
logger.info(f"PeerConnection closed: {webrtc_connection.pc_id}")
owner_connection_id = self._forget_peer_connection(
webrtc_connection.pc_id
try:
user_ice_servers = get_ice_servers(user_id=str(user.id))
pc = SmallWebRTCConnection(
ice_servers=user_ice_servers, connection_timeout_secs=60
)
if owner_connection_id == connection_key:
await self._notify_call_ended_and_close_websocket(
ws,
workflow_run_id,
webrtc_connection.pc_id,
reason="peer_connection_closed",
# Set the pc_id before initialization so it's available in get_answer()
pc._pc_id = pc_id
# Initialize connection with offer
await pc.initialize(sdp=sdp, type=type_)
# Store peer connection using client's pc_id
self._track_peer_connection(connection_key, pc_id, pc)
# Register WebSocket sender for real-time feedback
async def ws_sender(message: dict):
if ws.application_state == WebSocketState.CONNECTED:
await ws.send_json(message)
register_ws_sender(workflow_run_id, ws_sender)
# Setup closed handler
@pc.event_handler("closed")
async def handle_disconnected(
webrtc_connection: SmallWebRTCConnection,
):
logger.info(f"PeerConnection closed: {webrtc_connection.pc_id}")
owner_connection_id = self._forget_peer_connection(
webrtc_connection.pc_id
)
if owner_connection_id == connection_key:
await self._notify_call_ended_and_close_websocket(
ws,
workflow_run_id,
webrtc_connection.pc_id,
reason="peer_connection_closed",
)
# Start pipeline in background
asyncio.create_task(
run_pipeline_smallwebrtc(
pc,
workflow_id,
workflow_run_id,
user.id,
call_context_vars,
user_provider_id=str(user.provider_id),
# Start pipeline in background
asyncio.create_task(
run_pipeline_smallwebrtc(
pc,
workflow_id,
workflow_run_id,
user.id,
call_context_vars,
user_provider_id=str(user.provider_id),
organization_id=organization_id,
)
)
)
pipeline_started = True
# Get answer after initialization
answer = pc.get_answer()
# Get answer after initialization
answer = pc.get_answer()
# Send answer immediately (ICE candidates will be sent separately via trickling)
await ws.send_json(
{
"type": "answer",
"payload": {
"sdp": filter_outbound_sdp(answer["sdp"]),
"type": answer["type"],
"pc_id": answer["pc_id"],
},
}
)
# Send answer immediately (ICE candidates will be sent separately via trickling)
await ws.send_json(
{
"type": "answer",
"payload": {
"sdp": filter_outbound_sdp(answer["sdp"]),
"type": answer["type"],
"pc_id": answer["pc_id"],
},
}
)
except Exception:
if pipeline_started and pc is not None:
try:
await pc.disconnect()
except Exception as e:
logger.debug(f"Failed to disconnect failed offer pc: {e}")
elif concurrency_bound:
await call_concurrency.release_workflow_run_slot(workflow_run_id)
elif concurrency_slot is not None:
await call_concurrency.release_slot(concurrency_slot)
raise
async def _handle_ice_candidate(self, payload: dict, connection_key: str):
"""Handle incoming ICE candidate from client.
@ -630,13 +709,33 @@ async def signaling_websocket(
user: UserModel = Depends(get_user_ws),
):
"""WebSocket endpoint for WebRTC signaling with ICE trickling."""
workflow_run = await db_client.get_workflow_run(workflow_run_id, user.id)
if not user.selected_organization_id:
raise HTTPException(status_code=400, detail="No organization selected")
workflow_run = await db_client.get_workflow_run(
workflow_run_id, organization_id=user.selected_organization_id
)
if not workflow_run:
logger.warning(f"workflow run {workflow_run_id} not found for user {user.id}")
logger.warning(
f"workflow run {workflow_run_id} not found for org "
f"{user.selected_organization_id}"
)
raise HTTPException(status_code=400, detail="Bad workflow_run_id")
if workflow_run.workflow_id != workflow_id:
logger.warning(
f"workflow run {workflow_run_id} belongs to workflow "
f"{workflow_run.workflow_id}, not {workflow_id}"
)
raise HTTPException(status_code=400, detail="workflow_run_workflow_mismatch")
await signaling_manager.handle_websocket(
websocket, workflow_id, workflow_run_id, user
websocket,
workflow_id,
workflow_run_id,
user,
user.selected_organization_id,
enforce_call_concurrency=True,
call_concurrency_source="webrtc",
)
@ -670,6 +769,17 @@ async def public_signaling_websocket(
await websocket.close(code=1008, reason="Invalid embed token")
return
workflow_run = await db_client.get_workflow_run(
embed_session.workflow_run_id,
organization_id=embed_token.organization_id,
)
if not workflow_run:
await websocket.close(code=1008, reason="Invalid workflow run")
return
if workflow_run.workflow_id != embed_token.workflow_id:
await websocket.close(code=1008, reason="workflow_run_workflow_mismatch")
return
# Enforce the embed token's allowed-domain policy on the public signaling
# path, mirroring the HTTP embed endpoints (issue #330). Without this a
# leaked or replayed session token could attach from an arbitrary origin.
@ -693,5 +803,11 @@ async def public_signaling_websocket(
# Handle the WebSocket connection using the existing signaling manager
await signaling_manager.handle_websocket(
websocket, embed_token.workflow_id, embed_session.workflow_run_id, user
websocket,
embed_token.workflow_id,
embed_session.workflow_run_id,
user,
embed_token.organization_id,
enforce_call_concurrency=True,
call_concurrency_source="public_embed",
)

View file

@ -18,6 +18,7 @@ from api.db.workflow_template_client import WorkflowTemplateClient
from api.enums import CallType, PostHogEvent, StorageBackend, WorkflowStatus
from api.schemas.ai_model_configuration import OrganizationAIModelConfigurationV2
from api.schemas.workflow import WorkflowRunResponseSchema
from api.schemas.workflow_configurations import WorkflowConfigurationDefaults
from api.sdk_expose import sdk_expose
from api.services.auth.depends import get_user
from api.services.configuration.ai_model_configuration import (
@ -284,7 +285,10 @@ class UpdateWorkflowRequest(BaseModel):
name: str | None = None
workflow_definition: dict | None = None
template_context_variables: dict | None = None
workflow_configurations: dict | None = None
# Typed so field constraints (e.g. the max_call_duration cap) are
# enforced by FastAPI; extra="allow" keeps passthrough keys like
# model_configuration_v2_override intact.
workflow_configurations: WorkflowConfigurationDefaults | None = None
class WorkflowVersionResponse(BaseModel):
@ -1039,7 +1043,13 @@ async def update_workflow(
# Validate model overrides. v2 uses a complete workflow-level model
# configuration; legacy v1 uses partial service overlays.
workflow_configurations = request.workflow_configurations
# exclude_unset keeps stored configs sparse: keys the request didn't
# send stay absent so runtime defaults keep applying to them.
workflow_configurations = (
request.workflow_configurations.model_dump(exclude_unset=True)
if request.workflow_configurations is not None
else None
)
if workflow_configurations and workflow_configurations.get(
WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY
):
@ -1080,7 +1090,6 @@ async def update_workflow(
)
if existing_v2_override_config is None:
resolved_config = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
v2_override = merge_ai_model_configuration_v2_secrets(
@ -1123,7 +1132,6 @@ async def update_workflow(
existing_configs,
)
resolved_config = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=user.selected_organization_id,
)
effective_config = resolved_config.effective

View file

@ -103,6 +103,7 @@ async def _ensure_text_chat_quota(
) -> None:
quota_result = await authorize_workflow_run_start(
workflow_id=workflow_id,
organization_id=user.selected_organization_id,
workflow_run_id=workflow_run_id,
actor_user=user,
)

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

@ -8,7 +8,6 @@ when the same schema is surfaced through MCP or SDK authoring flows.
from __future__ import annotations
import re
from datetime import datetime
from typing import Annotated, Any, Dict, List, Literal, Optional, Union
@ -181,14 +180,62 @@ class EndCallConfig(BaseModel):
)
class HttpTransferResolverConfig(BaseModel):
"""HTTP endpoint used to resolve transfer destination at call time."""
type: Literal["http"] = Field(default="http", description="Resolver type.")
url: str = Field(description="HTTP or HTTPS endpoint for transfer resolution.")
headers: Optional[Dict[str, str]] = Field(
default=None,
description="Static headers to include with every resolver request.",
)
credential_uuid: Optional[str] = Field(
default=None,
description="Reference to an external credential for resolver authentication.",
)
timeout_ms: int = Field(
default=3000,
ge=500,
le=5000,
description="Resolver request timeout in milliseconds.",
)
wait_message: Optional[str] = Field(
default=None,
description="Optional short message played while Dograh resolves routing.",
)
parameters: Optional[List[ToolParameter]] = Field(
default=None,
description="Parameters the model may provide when calling this transfer tool.",
)
preset_parameters: Optional[List[PresetToolParameter]] = Field(
default=None,
description=(
"Parameters injected by Dograh from fixed values or workflow context "
"templates."
),
)
@field_validator("url")
@classmethod
def validate_url(cls, v: str) -> str:
if not isinstance(v, str) or not v.startswith(("http://", "https://")):
raise ValueError("config.resolver.url must be an http(s) URL")
return v
class TransferCallConfig(BaseModel):
"""Configuration for Transfer Call tools."""
destination_source: Literal["static", "dynamic"] = Field(
default="static",
description="Whether transfer destination is static/template or resolved by HTTP.",
)
destination: str = Field(
default="",
description=(
"Phone number or SIP endpoint to transfer the call to, e.g. "
"+1234567890 or PJSIP/1234."
)
"Phone number, SIP endpoint, or template to transfer the call to, e.g. "
"+1234567890, PJSIP/1234, or {{initial_context.transfer_destination}}."
),
)
messageType: Literal["none", "custom", "audio"] = Field(
default="none", description="Type of message to play before transfer."
@ -205,26 +252,25 @@ class TransferCallConfig(BaseModel):
le=120,
description="Maximum seconds to wait for the destination to answer.",
)
parameters: Optional[List[ToolParameter]] = Field(
default=None,
description=(
"Parameters the model may provide when calling this transfer tool, "
"for example state, department, or transfer reason."
),
)
resolver: Optional[HttpTransferResolverConfig] = Field(
default=None,
description="Optional resolver that determines transfer routing at call time.",
)
@field_validator("destination")
@classmethod
def validate_destination(cls, v: str) -> str:
"""Validate that destination is a valid E.164 phone number or SIP endpoint."""
if not v.strip():
return v
e164_pattern = r"^\+[1-9]\d{1,14}$"
sip_pattern = r"^(PJSIP|SIP)/[\w\-\.@]+$"
is_valid_e164 = re.match(e164_pattern, v)
is_valid_sip = re.match(sip_pattern, v, re.IGNORECASE)
if not (is_valid_e164 or is_valid_sip):
@model_validator(mode="after")
def validate_destination_source_config(self):
if self.destination_source == "dynamic" and self.resolver is None:
raise ValueError(
"Destination must be a valid E.164 phone number "
"(e.g., +1234567890) or SIP endpoint (e.g., PJSIP/1234)"
"config.resolver is required when destination_source is dynamic"
)
return v
return self
class McpToolConfig(BaseModel):

View file

@ -0,0 +1,62 @@
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
DEFAULT_MAX_CALL_DURATION_SECONDS = 300
# Hard ceiling on configurable call duration. Must stay <= the concurrency
# rate limiter's stale_call_timeout (20 min): a call running past that has
# its slot purged as stale and the org concurrency limit under-counts.
MAX_CALL_DURATION_SECONDS = 1200
DEFAULT_MAX_USER_IDLE_TIMEOUT_SECONDS = 10.0
DEFAULT_SMART_TURN_STOP_SECS = 2.0
DEFAULT_TURN_START_STRATEGY = "default"
DEFAULT_TURN_START_MIN_WORDS = 3
DEFAULT_PROVISIONAL_VAD_PAUSE_SECS = 1.5
DEFAULT_TURN_STOP_STRATEGY = "transcription"
DEFAULT_CONTEXT_COMPACTION_ENABLED = False
class AmbientNoiseConfigurationDefaults(BaseModel):
model_config = ConfigDict(extra="allow")
enabled: bool = False
volume: float = 0.3
class WorkflowConfigurationDefaults(BaseModel):
model_config = ConfigDict(extra="allow")
@model_validator(mode="before")
@classmethod
def _treat_null_as_unset(cls, data):
# Stored configs (and older clients) carry explicit JSON nulls for
# keys the user never configured; dropping them lets the field
# defaults apply instead of failing validation.
if isinstance(data, dict):
return {k: v for k, v in data.items() if v is not None}
return data
ambient_noise_configuration: AmbientNoiseConfigurationDefaults = Field(
default_factory=AmbientNoiseConfigurationDefaults
)
max_call_duration: int = Field(
default=DEFAULT_MAX_CALL_DURATION_SECONDS,
gt=0,
le=MAX_CALL_DURATION_SECONDS,
)
max_user_idle_timeout: float = DEFAULT_MAX_USER_IDLE_TIMEOUT_SECONDS
smart_turn_stop_secs: float = DEFAULT_SMART_TURN_STOP_SECS
turn_start_strategy: Literal["default", "min_words", "provisional_vad"] = (
DEFAULT_TURN_START_STRATEGY
)
turn_start_min_words: int = DEFAULT_TURN_START_MIN_WORDS
provisional_vad_pause_secs: float = DEFAULT_PROVISIONAL_VAD_PAUSE_SECS
turn_stop_strategy: Literal["transcription", "turn_analyzer"] = (
DEFAULT_TURN_STOP_STRATEGY
)
dictionary: str = ""
context_compaction_enabled: bool = DEFAULT_CONTEXT_COMPACTION_ENABLED
def get_default_workflow_configurations() -> WorkflowConfigurationDefaults:
return WorkflowConfigurationDefaults()

View file

@ -14,14 +14,24 @@ from api.services.auth.stack_auth import stackauth
from api.services.configuration.registry import ServiceProviders
from api.services.mps_billing import ensure_hosted_mps_billing_account_v2
from api.services.posthog_client import (
POSTHOG_ORGANIZATION_GROUP_TYPE,
capture_event,
group_identify,
set_person_properties,
)
from api.utils.auth import decode_jwt_token
POSTHOG_ORGANIZATION_GROUP_TYPE = "organization"
POSTHOG_ORGANIZATION_USES_MPS_BILLING_V2_PROPERTY = "uses_mps_billing_v2"
async def require_local_auth() -> None:
"""Reject email/password auth requests outside OSS (local) deployments.
The auth router stays mounted in every mode so the OpenAPI spec and the
clients generated from it don't vary with AUTH_PROVIDER; the gate has to
happen at request time. Without it, the SaaS deployment accepts
unauthenticated signups that mint oss_* users bypassing Stack Auth.
"""
if AUTH_PROVIDER != "local":
raise HTTPException(status_code=404, detail="Not found")
async def get_user(
@ -180,7 +190,6 @@ def _sync_created_organization_to_posthog(
organization,
stack_user: dict | None = None,
created_by_provider_id: str | None = None,
uses_mps_billing_v2: bool | None = None,
) -> None:
"""Create/update the PostHog organization group for a newly-created org."""
try:
@ -196,10 +205,6 @@ def _sync_created_organization_to_posthog(
}
if created_by:
properties["created_by_provider_id"] = created_by
if uses_mps_billing_v2 is not None:
properties[POSTHOG_ORGANIZATION_USES_MPS_BILLING_V2_PROPERTY] = (
uses_mps_billing_v2
)
group_identify(
POSTHOG_ORGANIZATION_GROUP_TYPE,
@ -218,50 +223,6 @@ def _sync_created_organization_to_posthog(
logger.exception("Failed to sync created organization to PostHog")
def _sync_posthog_organization_group_properties(
*,
organization,
uses_mps_billing_v2: bool | None = None,
) -> None:
"""Update PostHog organization group properties without creating a person."""
try:
organization_id = int(organization.id)
properties = {
"organization_id": organization_id,
"organization_provider_id": getattr(organization, "provider_id", None),
"auth_provider": "stack",
}
if uses_mps_billing_v2 is not None:
properties[POSTHOG_ORGANIZATION_USES_MPS_BILLING_V2_PROPERTY] = (
uses_mps_billing_v2
)
group_identify(
POSTHOG_ORGANIZATION_GROUP_TYPE,
str(organization_id),
properties,
)
except Exception:
logger.exception("Failed to sync organization group properties to PostHog")
def _sync_posthog_organization_mps_billing_v2_status(
organization_id: int,
*,
uses_mps_billing_v2: bool,
) -> None:
"""Update the PostHog organization group with current MPS billing status."""
try:
organization_id = int(organization_id)
group_identify(
POSTHOG_ORGANIZATION_GROUP_TYPE,
str(organization_id),
{POSTHOG_ORGANIZATION_USES_MPS_BILLING_V2_PROPERTY: uses_mps_billing_v2},
)
except Exception:
logger.exception("Failed to sync organization billing status to PostHog")
def _associate_user_with_posthog_organization(
*,
user: UserModel,
@ -457,6 +418,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

@ -1,8 +1,17 @@
import os
from typing import Any
import aiohttp
class StackAuthUserSearchError(Exception):
"""Raised when Stack Auth user search fails unexpectedly."""
class StackAuthSessionError(Exception):
"""Raised when Stack Auth cannot create an impersonation session."""
class StackAuth:
def __init__(self):
self.project_id = os.environ.get("STACK_AUTH_PROJECT_ID")
@ -56,10 +65,56 @@ class StackAuth:
"is_impersonation": True,
}
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=data) as response:
response = await response.json()
return response
try:
async with aiohttp.ClientSession() as session:
async with session.post(url, headers=headers, json=data) as response:
if response.status >= 400:
raise StackAuthSessionError(
"Stack Auth session creation failed"
)
return await response.json()
except (aiohttp.ClientError, ValueError) as exc:
raise StackAuthSessionError("Stack Auth session creation failed") from exc
async def find_users_by_email(self, email: str) -> list[dict[str, Any]]:
"""Return Stack Auth users whose primary email exactly matches."""
normalized_email = email.strip().lower()
url = os.environ.get("STACK_AUTH_API_URL") + "/api/v1/users"
headers = {
"x-stack-access-type": "server",
"x-stack-project-id": self.project_id,
"x-stack-secret-server-key": self.secret_server_key,
}
params = {
"query": normalized_email,
"limit": "10",
}
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, params=params) as response:
if response.status >= 400:
raise StackAuthUserSearchError("Stack Auth user search failed")
payload = await response.json()
except (aiohttp.ClientError, ValueError) as exc:
raise StackAuthUserSearchError("Stack Auth user search failed") from exc
users = payload.get("items", []) if isinstance(payload, dict) else []
if not isinstance(users, list):
return []
return [
user
for user in users
if isinstance(user, dict)
and self._stack_user_has_email(user, normalized_email)
]
def _stack_user_has_email(self, user: dict[str, Any], email: str) -> bool:
primary_email = user.get("primary_email")
return isinstance(primary_email, str) and primary_email.lower() == email
# ------------------------------------------------------------------
# Team & user management helpers

View file

@ -0,0 +1,282 @@
import asyncio
import time
from dataclasses import dataclass
from loguru import logger
from api.constants import DEFAULT_ORG_CONCURRENCY_LIMIT
from api.db import db_client
from api.enums import OrganizationConfigurationKey, PostHogEvent
from api.services.campaign.rate_limiter import rate_limiter
from api.services.posthog_client import capture_event
@dataclass(frozen=True)
class CallConcurrencySlot:
organization_id: int
slot_id: str
max_concurrent: int
source: str
scope_key: str | None = None
class CallConcurrencyLimitError(Exception):
"""Raised when an org has no available concurrent call slots."""
def __init__(
self,
*,
organization_id: int,
source: str,
wait_time: float,
max_concurrent: int,
):
self.organization_id = organization_id
self.source = source
self.wait_time = wait_time
self.max_concurrent = max_concurrent
super().__init__(
f"Concurrent call limit reached for org {organization_id} "
f"(source={source}, limit={max_concurrent}, waited={wait_time:.1f}s)"
)
class WorkflowRunSlotAlreadyBoundError(Exception):
"""Raised when a workflow run already owns a concurrent call slot."""
def __init__(self, workflow_run_id: int):
self.workflow_run_id = workflow_run_id
super().__init__(
f"Workflow run {workflow_run_id} already has an active call slot"
)
class CallConcurrencyService:
def __init__(self):
self.default_concurrent_limit = int(DEFAULT_ORG_CONCURRENCY_LIMIT)
async def get_org_concurrent_limit(self, organization_id: int) -> int:
"""Get the concurrent call limit for an organization."""
try:
config = await db_client.get_configuration(
organization_id,
OrganizationConfigurationKey.CONCURRENT_CALL_LIMIT.value,
)
if config and config.value:
value = config.value.get("value")
if value is not None:
return int(value)
except Exception as e:
logger.warning(
f"Error getting concurrent limit for org {organization_id}: {e}"
)
return self.default_concurrent_limit
async def acquire_org_slot(
self,
organization_id: int,
*,
source: str,
timeout: float = 0,
scope_key: str | None = None,
scope_max_concurrent: int | None = None,
retry_interval: float = 1,
) -> CallConcurrencySlot:
"""Acquire a slot in the org-wide concurrency counter.
``scope_key``/``scope_max_concurrent`` additionally bound a secondary
counter (e.g. ``campaign:<id>``) so a source can cap its own
concurrency without measuring or being starved by unrelated calls
in the same org.
"""
max_concurrent = await self.get_org_concurrent_limit(organization_id)
if scope_max_concurrent is not None:
scope_max_concurrent = int(scope_max_concurrent)
wait_start = time.time()
while True:
acquisition = await rate_limiter.try_acquire_concurrent_slot_details(
organization_id,
max_concurrent,
scope_key=scope_key,
scope_max_concurrent=scope_max_concurrent,
)
if acquisition:
logger.info(
f"Acquired concurrent call slot for org {organization_id}: "
f"source={source}, active_calls="
f"{acquisition.active_count}/{max_concurrent}, "
f"slot_id={acquisition.slot_id}"
)
return CallConcurrencySlot(
organization_id=organization_id,
slot_id=acquisition.slot_id,
max_concurrent=max_concurrent,
source=source,
scope_key=scope_key,
)
wait_time = time.time() - wait_start
if wait_time >= timeout:
current_count = await rate_limiter.get_concurrent_count(organization_id)
scope_note = (
f", scope={scope_key} (limit={scope_max_concurrent})"
if scope_key
else ""
)
logger.warning(
f"Concurrent call limit reached for org {organization_id}: "
f"source={source}, active_calls={current_count}/{max_concurrent}"
f"{scope_note}, waited={wait_time:.1f}s"
)
properties = {
"event_source": "dograh",
"organization_id": organization_id,
"source": source,
"max_concurrent": max_concurrent,
"active_calls": current_count,
"waited_seconds": round(wait_time, 1),
}
if scope_key:
properties["scope_key"] = scope_key
properties["scope_max_concurrent"] = scope_max_concurrent
await self._notify_limit_reached(organization_id, properties)
raise CallConcurrencyLimitError(
organization_id=organization_id,
source=source,
wait_time=wait_time,
max_concurrent=max_concurrent,
)
logger.debug(
f"Waiting for concurrent call slot for org {organization_id}, "
f"source={source}, waited {wait_time:.1f}s"
)
await asyncio.sleep(min(retry_interval, max(0, timeout - wait_time)))
async def _notify_limit_reached(
self, organization_id: int, properties: dict
) -> None:
"""Fan the usage event out to every org member's provider_id, matching
how MPS emits org-scoped billing events (billing_posthog_service.py)
into the shared PostHog project. Never raises.
NOTE: intentionally NOT attaching ``$groups`` (organization) to this
event. PostHog evaluates a $groups event at both person and group
scope, which double-triggers person-scoped workflows enrolled on the
event. The org is still available as the ``organization_id`` property.
"""
try:
members = await db_client.get_organization_users(organization_id)
if not members:
logger.debug(
f"No users found for org {organization_id}; skipping "
"concurrent-call-limit PostHog event"
)
return
for member in members:
capture_event(
distinct_id=str(member.provider_id),
event=PostHogEvent.USAGE_CONCURRENT_CALL_LIMIT_REACHED,
properties=properties,
)
except Exception:
logger.exception(
"Failed to send concurrent-call-limit PostHog event for org "
f"{organization_id}"
)
async def bind_workflow_run(
self, slot: CallConcurrencySlot, workflow_run_id: int
) -> None:
stored = await rate_limiter.store_workflow_slot_mapping_if_absent(
workflow_run_id,
slot.organization_id,
slot.slot_id,
scope_key=slot.scope_key,
)
if stored:
return
await self.release_slot(slot)
raise WorkflowRunSlotAlreadyBoundError(workflow_run_id)
async def register_active_call(
self,
organization_id: int,
workflow_run_id: int,
*,
source: str,
timeout: float = 0,
scope_key: str | None = None,
scope_max_concurrent: int | None = None,
retry_interval: float = 1,
) -> CallConcurrencySlot:
slot = await self.acquire_org_slot(
organization_id,
source=source,
timeout=timeout,
scope_key=scope_key,
scope_max_concurrent=scope_max_concurrent,
retry_interval=retry_interval,
)
await self.bind_workflow_run(slot, workflow_run_id)
return slot
async def unregister_active_call(self, workflow_run_id: int) -> bool:
"""Release the run's slot without ever raising.
Callers invoke this from ``finally`` blocks during pipeline/socket
teardown; a cleanup failure must not mask the original exception.
The slot mapping survives a failed release, so a later cleanup path
(status callback, StasisEnd) or the Redis stale timeout recovers it.
"""
try:
return await self.release_workflow_run_slot(workflow_run_id)
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(
f"Failed to release concurrent call slot for workflow run "
f"{workflow_run_id}: {e}"
)
return False
async def release_slot(self, slot: CallConcurrencySlot | None) -> bool:
if slot is None:
return False
released = await rate_limiter.release_concurrent_slot(
slot.organization_id, slot.slot_id, scope_key=slot.scope_key
)
return bool(released)
async def release_workflow_run_slot(self, workflow_run_id: int) -> bool:
mapping = await rate_limiter.get_workflow_slot_mapping(workflow_run_id)
if not mapping:
return False
org_id, slot_id, scope_key = mapping
released = await rate_limiter.release_concurrent_slot(
org_id, slot_id, scope_key=scope_key
)
if released is None:
# Redis error while releasing — keep the mapping so a later
# cleanup path can retry instead of orphaning a live slot until
# the stale timeout.
logger.warning(
f"Failed to release concurrent slot for workflow run "
f"{workflow_run_id}; keeping mapping for retry"
)
return False
await rate_limiter.delete_workflow_slot_mapping(workflow_run_id)
if released:
logger.info(f"Released concurrent slot for workflow run {workflow_run_id}")
else:
logger.debug(
f"Concurrent slot mapping for workflow run {workflow_run_id} "
"had no live slot; deleted stale mapping"
)
return released
call_concurrency = CallConcurrencyService()

View file

@ -5,10 +5,14 @@ from typing import TYPE_CHECKING, Optional
from loguru import logger
from api.constants import DEFAULT_ORG_CONCURRENCY_LIMIT
from api.db import db_client
from api.db.models import QueuedRunModel, WorkflowRunModel
from api.enums import OrganizationConfigurationKey, WorkflowRunState
from api.enums import WorkflowRunState
from api.services.call_concurrency import (
CallConcurrencyLimitError,
CallConcurrencySlot,
call_concurrency,
)
from api.services.campaign.circuit_breaker import circuit_breaker
from api.services.campaign.errors import (
ConcurrentSlotAcquisitionError,
@ -29,9 +33,6 @@ if TYPE_CHECKING:
class CampaignCallDispatcher:
"""Manages rate-limited and concurrent-limited call dispatching"""
def __init__(self):
self.default_concurrent_limit = int(DEFAULT_ORG_CONCURRENCY_LIMIT)
async def get_provider_for_campaign(self, campaign) -> "TelephonyProvider":
"""Get the telephony provider pinned to this campaign's config. Falls back
to the org's default config for legacy campaigns whose
@ -53,18 +54,7 @@ class CampaignCallDispatcher:
async def get_org_concurrent_limit(self, organization_id: int) -> int:
"""Get the concurrent call limit for an organization."""
try:
config = await db_client.get_configuration(
organization_id,
OrganizationConfigurationKey.CONCURRENT_CALL_LIMIT.value,
)
if config and config.value:
return int(config.value["value"])
except Exception as e:
logger.warning(
f"Error getting concurrent limit for org {organization_id}: {e}"
)
return self.default_concurrent_limit
return await call_concurrency.get_org_concurrent_limit(organization_id)
async def process_batch(self, campaign_id: int, batch_size: int = 10) -> int:
"""
@ -119,12 +109,14 @@ class CampaignCallDispatcher:
)
# Acquire concurrent slot - waits until a slot is available
slot_id = await self.acquire_concurrent_slot(
concurrency_slot = await self.acquire_concurrent_slot(
campaign.organization_id, campaign
)
# Dispatch the call
workflow_run = await self.dispatch_call(queued_run, campaign, slot_id)
workflow_run = await self.dispatch_call(
queued_run, campaign, concurrency_slot
)
# Update queued run as processed
await db_client.update_queued_run(
@ -233,68 +225,61 @@ class CampaignCallDispatcher:
)
async def dispatch_call(
self, queued_run: QueuedRunModel, campaign: any, slot_id: str
self,
queued_run: QueuedRunModel,
campaign: any,
concurrency_slot: CallConcurrencySlot,
) -> Optional[WorkflowRunModel]:
"""Creates workflow run and initiates call. Requires a pre-acquired slot_id."""
"""Creates workflow run and initiates call. Requires a pre-acquired slot."""
from_number = None
workflow_run = None
slot_bound = False
# Get workflow details
workflow = await db_client.get_workflow_by_id(campaign.workflow_id)
if not workflow:
# Release slot before raising
await rate_limiter.release_concurrent_slot(
campaign.organization_id, slot_id
)
raise ValueError(f"Workflow {campaign.workflow_id} not found")
# Extract phone number
phone_number = queued_run.context_variables.get("phone_number")
if not phone_number:
# Release slot before raising
await rate_limiter.release_concurrent_slot(
campaign.organization_id, slot_id
)
raise ValueError(f"No phone number in queued run {queued_run.id}")
# Get provider for this campaign's pinned telephony config.
provider = await self.get_provider_for_campaign(campaign)
workflow_run_mode = provider.PROVIDER_NAME
# Acquire a unique from_number from the pool scoped to this campaign's
# telephony configuration so orgs with multiple configs don't leak
# caller IDs across configs.
from_number = await self.acquire_from_number(
campaign.organization_id,
telephony_configuration_id=campaign.telephony_configuration_id,
)
if from_number is None:
# Release concurrent slot before raising
await rate_limiter.release_concurrent_slot(
campaign.organization_id, slot_id
)
raise PhoneNumberPoolExhaustedError(
organization_id=campaign.organization_id
)
logger.info(f"Provider name: {provider.PROVIDER_NAME}")
logger.info(f"Queued run context: {queued_run.context_variables}")
# Merge context variables (queued_run context already includes retry info if applicable)
initial_context = {
**queued_run.context_variables,
"campaign_id": campaign.id,
"provider": provider.PROVIDER_NAME,
"source_uuid": queued_run.source_uuid,
"caller_number": from_number,
"called_number": phone_number,
"telephony_configuration_id": campaign.telephony_configuration_id,
}
logger.info(f"Final initial_context: {initial_context}")
# Create workflow run with queued_run_id tracking
workflow_run_name = f"WR-CAMPAIGN-{campaign.id}-{queued_run.id}"
try:
# Get workflow details
workflow = await db_client.get_workflow_by_id(campaign.workflow_id)
if not workflow:
raise ValueError(f"Workflow {campaign.workflow_id} not found")
# Extract phone number
phone_number = queued_run.context_variables.get("phone_number")
if not phone_number:
raise ValueError(f"No phone number in queued run {queued_run.id}")
# Get provider for this campaign's pinned telephony config.
provider = await self.get_provider_for_campaign(campaign)
workflow_run_mode = provider.PROVIDER_NAME
# Acquire a unique from_number from the pool scoped to this campaign's
# telephony configuration so orgs with multiple configs don't leak
# caller IDs across configs.
from_number = await self.acquire_from_number(
campaign.organization_id,
telephony_configuration_id=campaign.telephony_configuration_id,
)
if from_number is None:
raise PhoneNumberPoolExhaustedError(
organization_id=campaign.organization_id
)
logger.info(f"Provider name: {provider.PROVIDER_NAME}")
logger.info(f"Queued run context: {queued_run.context_variables}")
# Merge context variables (queued_run context already includes retry info if applicable)
initial_context = {
**queued_run.context_variables,
"campaign_id": campaign.id,
"provider": provider.PROVIDER_NAME,
"source_uuid": queued_run.source_uuid,
"caller_number": from_number,
"called_number": phone_number,
"telephony_configuration_id": campaign.telephony_configuration_id,
}
logger.info(f"Final initial_context: {initial_context}")
# Create workflow run with queued_run_id tracking
workflow_run_name = f"WR-CAMPAIGN-{campaign.id}-{queued_run.id}"
workflow_run = await db_client.create_workflow_run(
name=workflow_run_name,
workflow_id=campaign.workflow_id,
@ -303,12 +288,10 @@ class CampaignCallDispatcher:
initial_context=initial_context,
campaign_id=campaign.id,
queued_run_id=queued_run.id, # Link to queued run for retry tracking
organization_id=campaign.organization_id,
)
# Store slot_id mapping in Redis for cleanup later
await rate_limiter.store_workflow_slot_mapping(
workflow_run.id, campaign.organization_id, slot_id
)
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run.id)
slot_bound = True
# Store from_number mapping for cleanup on call completion
await rate_limiter.store_workflow_from_number_mapping(
@ -319,9 +302,10 @@ class CampaignCallDispatcher:
)
except Exception as e:
# Release slot and from_number on error
await rate_limiter.release_concurrent_slot(
campaign.organization_id, slot_id
)
if slot_bound and workflow_run:
await call_concurrency.release_workflow_run_slot(workflow_run.id)
else:
await call_concurrency.release_slot(concurrency_slot)
if from_number:
await rate_limiter.release_from_number(
campaign.organization_id,
@ -342,6 +326,7 @@ class CampaignCallDispatcher:
quota_result = await authorize_workflow_run_start(
workflow_id=campaign.workflow_id,
organization_id=campaign.organization_id,
workflow_run_id=workflow_run.id,
)
if not quota_result.has_quota:
@ -357,21 +342,7 @@ class CampaignCallDispatcher:
gathered_context={"error": error_message},
)
mapping = await rate_limiter.get_workflow_slot_mapping(workflow_run.id)
if mapping:
org_id, mapped_slot_id = mapping
await rate_limiter.release_concurrent_slot(org_id, mapped_slot_id)
await rate_limiter.delete_workflow_slot_mapping(workflow_run.id)
from_number_mapping = await rate_limiter.get_workflow_from_number_mapping(
workflow_run.id
)
if from_number_mapping:
fn_org_id, fn_number, fn_tcid = from_number_mapping
await rate_limiter.release_from_number(
fn_org_id, fn_number, telephony_configuration_id=fn_tcid
)
await rate_limiter.delete_workflow_from_number_mapping(workflow_run.id)
await self.release_call_slot(workflow_run.id)
raise ValueError(error_message)
@ -383,7 +354,6 @@ class CampaignCallDispatcher:
webhook_url = (
f"{backend_endpoint}/api/v1/telephony/{webhook_endpoint}"
f"?workflow_id={campaign.workflow_id}"
f"&user_id={campaign.created_by}"
f"&workflow_run_id={workflow_run.id}"
f"&organization_id={campaign.organization_id}"
)
@ -394,7 +364,7 @@ class CampaignCallDispatcher:
workflow_run_id=workflow_run.id,
from_number=from_number,
workflow_id=campaign.workflow_id,
user_id=campaign.created_by,
organization_id=campaign.organization_id,
)
# Store provider type and metadata in gathered_context
@ -446,23 +416,7 @@ class CampaignCallDispatcher:
reason="call_initiation_failed",
)
# Release concurrent slot on failure
mapping = await rate_limiter.get_workflow_slot_mapping(workflow_run.id)
if mapping:
org_id, slot_id = mapping
await rate_limiter.release_concurrent_slot(org_id, slot_id)
await rate_limiter.delete_workflow_slot_mapping(workflow_run.id)
# Release from_number on failure
from_number_mapping = await rate_limiter.get_workflow_from_number_mapping(
workflow_run.id
)
if from_number_mapping:
fn_org_id, fn_number, fn_tcid = from_number_mapping
await rate_limiter.release_from_number(
fn_org_id, fn_number, telephony_configuration_id=fn_tcid
)
await rate_limiter.delete_workflow_from_number_mapping(workflow_run.id)
await self.release_call_slot(workflow_run.id)
raise
@ -501,7 +455,7 @@ class CampaignCallDispatcher:
async def acquire_concurrent_slot(
self, organization_id: int, campaign: any, timeout: float = 600
) -> str:
) -> CallConcurrencySlot:
"""
Acquires a concurrent call slot - waits if necessary until a slot is available.
@ -510,54 +464,41 @@ class CampaignCallDispatcher:
campaign: The campaign object
timeout: Maximum time to wait for a slot (default 10 minutes)
Returns the slot_id which must be released when the call completes.
Returns the slot which must be released when the call completes.
Raises:
ConcurrentSlotAcquisitionError: If slot cannot be acquired within timeout
"""
# Get concurrent limit for organization
org_concurrent_limit = await self.get_org_concurrent_limit(organization_id)
# Check for campaign-level max_concurrency in orchestrator_metadata
# Check for campaign-level max_concurrency in orchestrator_metadata.
# It caps this campaign's own concurrent calls via a campaign-scoped
# counter — the org-wide limit still applies on top, but calls from
# other sources (WebRTC, inbound, other campaigns) don't count
# against the campaign's cap.
campaign_max_concurrency = None
if campaign.orchestrator_metadata:
campaign_max_concurrency = campaign.orchestrator_metadata.get(
"max_concurrency"
)
# Use the lower of campaign limit and org limit
if campaign_max_concurrency is not None:
max_concurrent = min(campaign_max_concurrency, org_concurrent_limit)
else:
max_concurrent = org_concurrent_limit
# Track wait time for alerting
wait_start = time.time()
# Wait until we can acquire a concurrent slot
while True:
slot_id = await rate_limiter.try_acquire_concurrent_slot(
organization_id, max_concurrent
try:
return await call_concurrency.acquire_org_slot(
organization_id,
source=f"campaign:{campaign.id}",
timeout=timeout,
scope_key=(
f"campaign:{campaign.id}"
if campaign_max_concurrency is not None
else None
),
scope_max_concurrent=campaign_max_concurrency,
retry_interval=1,
)
if slot_id:
return slot_id
# Check if we've been waiting too long
wait_time = time.time() - wait_start
if wait_time > timeout:
raise ConcurrentSlotAcquisitionError(
organization_id=organization_id,
campaign_id=campaign.id,
wait_time=wait_time,
)
logger.debug(
f"Attempting to get a slot for {organization_id} {campaign.id}, "
f"waited {wait_time:.1f}s"
)
# Wait before retrying
await asyncio.sleep(1)
except CallConcurrencyLimitError as e:
raise ConcurrentSlotAcquisitionError(
organization_id=organization_id,
campaign_id=campaign.id,
wait_time=e.wait_time,
) from e
async def acquire_from_number(
self,
@ -602,17 +543,9 @@ class CampaignCallDispatcher:
Release concurrent slot and from_number when a call completes.
Called by Twilio webhooks or workflow completion handlers.
"""
slot_released = False
mapping = await rate_limiter.get_workflow_slot_mapping(workflow_run_id)
if mapping:
org_id, slot_id = mapping
success = await rate_limiter.release_concurrent_slot(org_id, slot_id)
if success:
await rate_limiter.delete_workflow_slot_mapping(workflow_run_id)
logger.info(
f"Released concurrent slot for workflow run {workflow_run_id}"
)
slot_released = True
slot_released = await call_concurrency.release_workflow_run_slot(
workflow_run_id
)
# Release from_number back to its (org, telephony config) pool
from_number_mapping = await rate_limiter.get_workflow_from_number_mapping(

View file

@ -1,5 +1,6 @@
import time
import uuid
from dataclasses import dataclass
from typing import Optional
import redis.asyncio as aioredis
@ -8,6 +9,12 @@ from loguru import logger
from api.constants import REDIS_URL
@dataclass(frozen=True)
class ConcurrentSlotAcquisition:
slot_id: str
active_count: int
class RateLimiter:
"""Sliding window rate limiter to enforce strict per-second limits and concurrent call limits"""
@ -100,34 +107,71 @@ class RateLimiter:
Try to acquire a concurrent call slot.
Returns a unique slot_id if successful, None if limit reached.
"""
acquisition = await self.try_acquire_concurrent_slot_details(
organization_id, max_concurrent
)
return acquisition.slot_id if acquisition else None
async def try_acquire_concurrent_slot_details(
self,
organization_id: int,
max_concurrent: int = 20,
*,
scope_key: str | None = None,
scope_max_concurrent: int | None = None,
) -> Optional[ConcurrentSlotAcquisition]:
"""
Try to acquire a concurrent call slot.
Returns the slot_id and post-acquire active count if successful,
or None if the limit is reached.
When ``scope_key``/``scope_max_concurrent`` are provided, the slot is
also registered in a secondary counter (``concurrent_calls:<scope_key>``,
e.g. ``campaign:<id>``) and acquisition additionally requires that
counter to be below ``scope_max_concurrent``. Both counters are
updated atomically. The scope-scoped slot must be released with the
same ``scope_key``.
"""
redis_client = await self._get_redis()
concurrent_key = f"concurrent_calls:{organization_id}"
scope_concurrent_key = f"concurrent_calls:{scope_key}" if scope_key else ""
now = time.time()
stale_cutoff = now - self.stale_call_timeout
# Lua script for atomic operation
# Lua script for atomic operation across the org counter and the
# optional scope counter (empty scope key = org-only acquisition).
lua_script = """
local key = KEYS[1]
local scope_key = KEYS[2]
local now = tonumber(ARGV[1])
local max_concurrent = tonumber(ARGV[2])
local stale_cutoff = tonumber(ARGV[3])
local slot_id = ARGV[4]
-- Remove stale entries (older than 30 minutes)
local scope_max_concurrent = tonumber(ARGV[5])
-- Remove stale entries (older than the stale-call timeout)
redis.call('ZREMRANGEBYSCORE', key, 0, stale_cutoff)
-- Get current count
local current_count = redis.call('ZCARD', key)
if current_count < max_concurrent then
-- Add new slot
redis.call('ZADD', key, now, slot_id)
redis.call('EXPIRE', key, 3600) -- Expire after 1 hour
return slot_id
else
if current_count >= max_concurrent then
return nil
end
if scope_key ~= '' then
redis.call('ZREMRANGEBYSCORE', scope_key, 0, stale_cutoff)
if redis.call('ZCARD', scope_key) >= scope_max_concurrent then
return nil
end
redis.call('ZADD', scope_key, now, slot_id)
redis.call('EXPIRE', scope_key, 3600)
end
redis.call('ZADD', key, now, slot_id)
redis.call('EXPIRE', key, 3600) -- Expire after 1 hour
return {slot_id, current_count + 1}
"""
# Generate unique slot ID (timestamp + random component)
@ -136,22 +180,38 @@ class RateLimiter:
try:
result = await redis_client.eval(
lua_script,
1,
2,
concurrent_key,
scope_concurrent_key,
now,
max_concurrent,
stale_cutoff,
slot_id,
scope_max_concurrent if scope_max_concurrent is not None else 0,
)
if not result:
return None
acquired_slot_id, active_count = result
return ConcurrentSlotAcquisition(
slot_id=str(acquired_slot_id),
active_count=int(active_count),
)
return result
except Exception as e:
logger.error(f"Concurrent limiter error: {e}")
return None
async def release_concurrent_slot(self, organization_id: int, slot_id: str) -> bool:
async def release_concurrent_slot(
self,
organization_id: int,
slot_id: str,
scope_key: str | None = None,
) -> bool | None:
"""
Release a concurrent call slot.
Returns True if slot was released, False otherwise.
Release a concurrent call slot (and its scope counter entry, if any).
Returns True if the slot was released, False if it was already gone
(released/stale-expired), or None on a Redis error callers that
track cleanup state should keep it around for retry when None.
"""
if not slot_id:
return False
@ -161,6 +221,8 @@ class RateLimiter:
try:
removed = await redis_client.zrem(concurrent_key, slot_id)
if scope_key:
await redis_client.zrem(f"concurrent_calls:{scope_key}", slot_id)
if removed:
logger.debug(
f"Released concurrent slot {slot_id} for org {organization_id}"
@ -168,7 +230,7 @@ class RateLimiter:
return bool(removed)
except Exception as e:
logger.error(f"Error releasing concurrent slot: {e}")
return False
return None
async def get_concurrent_count(self, organization_id: int) -> int:
"""
@ -212,12 +274,62 @@ class RateLimiter:
logger.error(f"Error storing workflow slot mapping: {e}")
return False
async def store_workflow_slot_mapping_if_absent(
self,
workflow_run_id: int,
organization_id: int,
slot_id: str,
scope_key: str | None = None,
) -> bool:
"""
Store the workflow_run_id -> concurrent slot mapping only if no mapping
already exists. This prevents duplicate public/WebRTC starts for the
same workflow run from overwriting the cleanup pointer.
"""
redis_client = await self._get_redis()
mapping_key = f"workflow_slot_mapping:{workflow_run_id}"
lua_script = """
local key = KEYS[1]
local org_id = ARGV[1]
local slot_id = ARGV[2]
local ttl = tonumber(ARGV[3])
local scope_key = ARGV[4]
if redis.call('EXISTS', key) == 1 then
return 0
end
redis.call('HSET', key, 'org_id', org_id, 'slot_id', slot_id)
if scope_key ~= '' then
redis.call('HSET', key, 'scope_key', scope_key)
end
redis.call('EXPIRE', key, ttl)
return 1
"""
try:
stored = await redis_client.eval(
lua_script,
1,
mapping_key,
organization_id,
slot_id,
self.stale_call_timeout,
scope_key or "",
)
return bool(stored)
except Exception as e:
logger.error(f"Error storing workflow slot mapping if absent: {e}")
return False
async def get_workflow_slot_mapping(
self, workflow_run_id: int
) -> Optional[tuple[int, str]]:
) -> Optional[tuple[int, str, str | None]]:
"""
Get the concurrent slot mapping for a workflow run.
Returns (organization_id, slot_id) tuple or None if not found.
Returns (organization_id, slot_id, scope_key) or None if not found;
scope_key is None for slots acquired without a scope counter.
"""
redis_client = await self._get_redis()
mapping_key = f"workflow_slot_mapping:{workflow_run_id}"
@ -225,7 +337,11 @@ class RateLimiter:
try:
mapping = await redis_client.hgetall(mapping_key)
if mapping and "org_id" in mapping and "slot_id" in mapping:
return (int(mapping["org_id"]), mapping["slot_id"])
return (
int(mapping["org_id"]),
mapping["slot_id"],
mapping.get("scope_key") or None,
)
return None
except Exception as e:
logger.error(f"Error getting workflow slot mapping: {e}")

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import copy
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Literal
from loguru import logger
@ -11,7 +12,11 @@ from sqlalchemy.orm import selectinload
from api.constants import MPS_API_URL
from api.db import db_client
from api.db.models import WorkflowDefinitionModel, WorkflowModel
from api.db.models import (
OrganizationConfigurationModel,
WorkflowDefinitionModel,
WorkflowModel,
)
from api.enums import OrganizationConfigurationKey
from api.schemas.ai_model_configuration import (
DOGRAH_DEFAULT_LANGUAGE,
@ -55,35 +60,36 @@ class WorkflowAIModelConfigurationMigrationResult:
async def get_resolved_ai_model_configuration(
*,
user_id: int | None,
organization_id: int | None,
) -> ResolvedAIModelConfiguration:
organization_configuration = await get_organization_ai_model_configuration_v2(
organization_id
"""Resolve the effective model configuration for an organization."""
organization_configuration_row = (
await _get_organization_ai_model_configuration_v2_row(organization_id)
)
organization_configuration = _parse_organization_ai_model_configuration_v2(
organization_configuration_row,
organization_id,
)
if organization_configuration is not None:
effective = compile_ai_model_configuration_v2(organization_configuration)
if organization_configuration_row is not None:
effective.last_validated_at = (
organization_configuration_row.last_validated_at
)
return ResolvedAIModelConfiguration(
effective=compile_ai_model_configuration_v2(organization_configuration),
effective=effective,
source="organization_v2",
organization_configuration=organization_configuration,
)
if user_id is None:
return ResolvedAIModelConfiguration(
effective=EffectiveAIModelConfiguration(),
source="empty",
)
legacy = await db_client.get_user_configurations(user_id)
return ResolvedAIModelConfiguration(
effective=legacy,
source="legacy_user_v1" if _has_model_services(legacy) else "empty",
effective=EffectiveAIModelConfiguration(),
source="empty",
)
async def get_effective_ai_model_configuration_for_workflow(
*,
user_id: int | None,
organization_id: int | None,
workflow_configurations: dict | None,
) -> EffectiveAIModelConfiguration:
@ -97,7 +103,6 @@ async def get_effective_ai_model_configuration_for_workflow(
)
resolved_config = await get_resolved_ai_model_configuration(
user_id=user_id,
organization_id=organization_id,
)
return resolve_effective_config(
@ -109,12 +114,34 @@ async def get_effective_ai_model_configuration_for_workflow(
async def get_organization_ai_model_configuration_v2(
organization_id: int | None,
) -> OrganizationAIModelConfigurationV2 | None:
if organization_id is None:
return None
row = await db_client.get_configuration(
row = await _get_organization_ai_model_configuration_v2_row(organization_id)
return _parse_organization_ai_model_configuration_v2(row, organization_id)
async def update_organization_ai_model_configuration_last_validated_at(
organization_id: int,
) -> None:
await db_client.mark_configuration_validated(
organization_id,
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
)
async def _get_organization_ai_model_configuration_v2_row(
organization_id: int | None,
) -> OrganizationConfigurationModel | None:
if organization_id is None:
return None
return await db_client.get_configuration(
organization_id,
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
)
def _parse_organization_ai_model_configuration_v2(
row: OrganizationConfigurationModel | None,
organization_id: int | None,
) -> OrganizationAIModelConfigurationV2 | None:
if row is None or not row.value:
return None
try:
@ -135,6 +162,7 @@ async def upsert_organization_ai_model_configuration_v2(
organization_id,
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
configuration.model_dump(mode="json", exclude_none=True),
last_validated_at=datetime.now(UTC),
)
return configuration
@ -145,19 +173,12 @@ async def migrate_workflow_model_configurations_to_v2(
fallback_user_config: EffectiveAIModelConfiguration,
) -> WorkflowAIModelConfigurationMigrationResult:
workflows = await _list_workflows_for_model_configuration_migration(organization_id)
owner_configs: dict[int, EffectiveAIModelConfiguration] = {}
workflow_updates: list[tuple[int, dict]] = []
definition_updates: list[tuple[int, dict]] = []
migrated_workflow_ids: set[int] = set()
for workflow in workflows:
base_config = fallback_user_config
if workflow.user_id is not None:
if workflow.user_id not in owner_configs:
owner_configs[
workflow.user_id
] = await db_client.get_user_configurations(workflow.user_id)
base_config = owner_configs[workflow.user_id]
workflow_configs, workflow_changed = (
migrate_workflow_configuration_model_override_to_v2(
@ -316,6 +337,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"
@ -419,19 +441,6 @@ def _mask_secret_value(value):
return mask_key(value)
def _has_model_services(configuration: EffectiveAIModelConfiguration) -> bool:
return any(
service is not None
for service in (
configuration.llm,
configuration.tts,
configuration.stt,
configuration.embeddings,
configuration.realtime,
)
)
def _convert_any_dograh_legacy_configuration(
configuration: EffectiveAIModelConfiguration,
dograh_key: str,

View file

@ -64,6 +64,7 @@ class UserConfigurationValidator:
ServiceProviders.RIME.value: self._check_rime_api_key,
ServiceProviders.MINIMAX.value: self._check_minimax_api_key,
ServiceProviders.SMALLEST.value: self._check_smallest_api_key,
ServiceProviders.XAI.value: self._check_xai_api_key,
}
async def validate(
@ -376,6 +377,32 @@ class UserConfigurationValidator:
def _check_grok_realtime_api_key(self, model: str, api_key: str) -> bool:
return True
def _check_xai_api_key(self, model: str, api_key: str) -> bool:
# Use the TTS voices endpoint as a best-effort smoke test. Some xAI keys
# can be scoped in ways that block listing voices even though the key is
# still intended for TTS usage, so only a clear auth failure rejects save.
try:
response = httpx.get(
"https://api.x.ai/v1/tts/voices",
headers={"Authorization": f"Bearer {api_key}"},
timeout=10.0,
)
except httpx.RequestError:
raise ValueError(
"Could not connect to the xAI API. Please check your network "
"connection and try again."
)
if response.status_code == 200:
return True
if response.status_code == 401:
raise ValueError(
"Invalid xAI API key. The key was rejected by the xAI API. "
"Please check that your API key is correct and active. "
"You can verify your keys at "
"https://console.x.ai."
)
return True
def _check_ultravox_realtime_api_key(self, model: str, api_key: str) -> bool:
return True

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,
@ -16,6 +22,7 @@ from .deepgram import (
DEEPGRAM_LANGUAGES,
DEEPGRAM_STT_MODELS,
)
from .elevenlabs import ELEVENLABS_STT_LANGUAGES, ELEVENLABS_STT_MODELS
from .gladia import GLADIA_STT_LANGUAGES, GLADIA_STT_MODELS
from .google import (
GOOGLE_MODELS,
@ -59,6 +66,12 @@ __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",
"ELEVENLABS_STT_LANGUAGES",
"ELEVENLABS_STT_MODELS",
"DEEPGRAM_FLUX_MODELS",
"DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES",
"DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS",

View file

@ -1,6 +1,10 @@
AZURE_MODELS = ["gpt-4.1-mini"]
AZURE_REALTIME_MODELS = ["gpt-4o-realtime-preview"]
AZURE_REALTIME_MODELS = [
"gpt-realtime",
"gpt-realtime-1.5",
"gpt-realtime-mini",
]
AZURE_REALTIME_VOICES = [
"alloy",
"ash",
@ -12,6 +16,7 @@ AZURE_REALTIME_VOICES = [
"verse",
]
AZURE_REALTIME_API_VERSIONS = [
"v1",
"2025-04-01-preview",
"2024-10-01-preview",
"2024-12-17",

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

@ -0,0 +1,95 @@
ELEVENLABS_STT_MODELS = ("scribe_v2_realtime",)
ELEVENLABS_STT_LANGUAGES = (
"auto",
"af",
"am",
"ar",
"as",
"az",
"be",
"bg",
"bn",
"bs",
"ca",
"ceb",
"cs",
"cy",
"da",
"de",
"el",
"en",
"es",
"et",
"fa",
"fi",
"fil",
"fr",
"ga",
"gl",
"gu",
"ha",
"he",
"hi",
"hr",
"hu",
"hy",
"id",
"ig",
"is",
"it",
"ja",
"jv",
"ka",
"kk",
"km",
"kn",
"ko",
"ku",
"ky",
"lo",
"lt",
"lv",
"mi",
"mk",
"ml",
"mn",
"mr",
"ms",
"mt",
"my",
"ne",
"nl",
"no",
"ny",
"oc",
"or",
"pa",
"pl",
"ps",
"pt",
"ro",
"ru",
"sd",
"sk",
"sl",
"sn",
"so",
"sr",
"sv",
"sw",
"ta",
"te",
"tg",
"th",
"tr",
"uk",
"ur",
"uz",
"vi",
"wo",
"xh",
"yue",
"zh",
"zu",
)

View file

@ -1,6 +1,4 @@
GOOGLE_MODELS = (
"gemini-2.0-flash",
"gemini-2.0-flash-lite",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-3.5-flash",

View file

@ -14,9 +14,16 @@ 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,
DEEPGRAM_STT_MODELS,
ELEVENLABS_STT_LANGUAGES,
ELEVENLABS_STT_MODELS,
GLADIA_STT_LANGUAGES,
GLADIA_STT_MODELS,
GOOGLE_MODELS,
@ -87,6 +94,7 @@ class ServiceProviders(str, Enum):
GOOGLE_VERTEX_REALTIME = "google_vertex_realtime"
AZURE_REALTIME = "azure_realtime"
SMALLEST = "smallest"
XAI = "xai"
class BaseServiceConfiguration(BaseModel):
@ -117,6 +125,7 @@ class BaseServiceConfiguration(BaseModel):
ServiceProviders.AZURE_REALTIME,
ServiceProviders.SARVAM,
ServiceProviders.SMALLEST,
ServiceProviders.XAI,
]
api_key: str | list[str]
@ -251,6 +260,7 @@ GOOGLE_VERTEX_REALTIME_PROVIDER_MODEL_CONFIG = provider_model_config(
DEEPGRAM_PROVIDER_MODEL_CONFIG = provider_model_config("Deepgram")
ELEVENLABS_PROVIDER_MODEL_CONFIG = provider_model_config("ElevenLabs")
CARTESIA_PROVIDER_MODEL_CONFIG = provider_model_config("Cartesia")
XAI_PROVIDER_MODEL_CONFIG = provider_model_config("xAI")
INWORLD_PROVIDER_MODEL_CONFIG = provider_model_config(
"Inworld",
description=(
@ -315,7 +325,6 @@ OPENROUTER_MODELS = [
"openai/gpt-4.1-mini",
"anthropic/claude-sonnet-4",
"google/gemini-2.5-flash",
"google/gemini-2.0-flash",
"meta-llama/llama-3.3-70b-instruct",
"deepseek/deepseek-chat-v3-0324",
]
@ -350,7 +359,7 @@ class GoogleLLMService(BaseLLMConfiguration):
model_config = GOOGLE_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.GOOGLE] = ServiceProviders.GOOGLE
model: str = Field(
default="gemini-2.0-flash",
default="gemini-2.5-flash",
description="Gemini model on Google AI Studio (not Vertex).",
json_schema_extra={"examples": GOOGLE_MODELS, "allow_custom_input": True},
)
@ -527,6 +536,7 @@ class HuggingFaceLLMConfiguration(BaseLLMConfiguration):
MINIMAX_MODELS = [
"MiniMax-M2.7",
"MiniMax-M2.7-highspeed",
"MiniMax-M3",
]
@ -611,7 +621,7 @@ class OpenAIRealtimeLLMConfiguration(BaseLLMConfiguration):
GROK_REALTIME_MODELS = ["grok-voice-think-fast-1.0"]
GROK_REALTIME_VOICES = ["Ara", "Rex", "Sal", "Eve", "Leo"]
GROK_REALTIME_VOICES = ["ara", "rex", "sal", "eve", "leo"]
ULTRAVOX_REALTIME_MODELS = ["ultravox-v0.7", "fixie-ai/ultravox"]
@ -628,7 +638,7 @@ class GrokRealtimeLLMConfiguration(BaseLLMConfiguration):
},
)
voice: str = Field(
default="Ara",
default="ara",
description="Voice the model speaks in.",
json_schema_extra={
"examples": GROK_REALTIME_VOICES,
@ -746,7 +756,7 @@ class AzureRealtimeLLMConfiguration(BaseLLMConfiguration):
model_config = AZURE_REALTIME_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.AZURE_REALTIME] = ServiceProviders.AZURE_REALTIME
model: str = Field(
default="gpt-4o-realtime-preview",
default="gpt-realtime",
description="Azure OpenAI realtime deployment name.",
json_schema_extra={
"examples": AZURE_REALTIME_MODELS,
@ -765,8 +775,11 @@ class AzureRealtimeLLMConfiguration(BaseLLMConfiguration):
},
)
api_version: str = Field(
default="2025-04-01-preview",
description="Azure OpenAI API version.",
default="v1",
description=(
"Azure OpenAI Realtime protocol version. Use 'v1' for the GA API; "
"date-based versions select the deprecated preview endpoint."
),
json_schema_extra={
"examples": AZURE_REALTIME_API_VERSIONS,
},
@ -854,7 +867,10 @@ class ElevenlabsTTSConfiguration(BaseServiceConfiguration):
model: str = Field(
default="eleven_flash_v2_5",
description="ElevenLabs TTS model.",
json_schema_extra={"examples": ELEVENLABS_TTS_MODELS},
json_schema_extra={
"examples": ELEVENLABS_TTS_MODELS,
"allow_custom_input": True,
},
)
base_url: str = Field(
default="https://api.elevenlabs.io",
@ -1274,6 +1290,32 @@ class SmallestAITTSConfiguration(BaseTTSConfiguration):
)
XAI_TTS_VOICES = ["eve", "ara", "leo", "rex", "sal"]
@register_tts
class XAITTSConfiguration(BaseServiceConfiguration):
model_config = XAI_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.XAI] = ServiceProviders.XAI
voice: str = Field(
default="eve",
description="xAI voice persona.",
json_schema_extra={"examples": XAI_TTS_VOICES, "allow_custom_input": True},
)
language: str = Field(
default="en",
description="BCP-47 language code for synthesis (e.g. 'en', 'fr', 'de'), or 'auto' for automatic language detection.",
json_schema_extra={"allow_custom_input": True},
)
@computed_field
@property
def model(self) -> str:
# xAI TTS has no separate model selector; the voice fully specifies the
# output. A constant keeps the shared `.model` contract satisfied.
return "xai-tts"
TTSConfig = Annotated[
Union[
DeepgramTTSConfiguration,
@ -1290,6 +1332,7 @@ TTSConfig = Annotated[
MiniMaxTTSConfiguration,
AzureSpeechTTSConfiguration,
SmallestAITTSConfiguration,
XAITTSConfiguration,
],
Field(discriminator="provider"),
]
@ -1323,9 +1366,6 @@ class DeepgramSTTConfiguration(BaseSTTConfiguration):
)
CARTESIA_STT_MODELS = ["ink-whisper"]
@register_stt
class CartesiaSTTConfiguration(BaseSTTConfiguration):
model_config = CARTESIA_PROVIDER_MODEL_CONFIG
@ -1335,6 +1375,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"]
@ -1397,6 +1448,10 @@ class GoogleSTTConfiguration(BaseSTTConfiguration):
# Dograh STT Service
DOGRAH_STT_MODELS = ["default"]
DOGRAH_STT_LANGUAGES = DEEPGRAM_LANGUAGES
# Languages auto-detected when the Dograh STT language is "multi". Dograh STT runs
# Deepgram Flux multilingual under the hood, which only auto-detects this subset —
# not the full DOGRAH_STT_LANGUAGES list offered for explicit single-language selection.
DOGRAH_MULTILINGUAL_AUTODETECT_LANGUAGES = DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES
@register_stt
@ -1625,6 +1680,39 @@ SMALLEST_STT_LANGUAGES = [
]
@register_stt
class ElevenlabsSTTConfiguration(BaseSTTConfiguration):
model_config = ELEVENLABS_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.ELEVENLABS] = ServiceProviders.ELEVENLABS
model: str = Field(
default="scribe_v2_realtime",
description="ElevenLabs realtime STT model.",
json_schema_extra={
"examples": ELEVENLABS_STT_MODELS,
"allow_custom_input": True,
},
)
language: str = Field(
default="en",
description=(
"ISO 639-1 language code for transcription. "
"Use 'auto' to let ElevenLabs detect the language."
),
json_schema_extra={
"examples": ELEVENLABS_STT_LANGUAGES,
"allow_custom_input": True,
},
)
base_url: str = Field(
default="https://api.elevenlabs.io",
description=(
"ElevenLabs API base URL. Override to use a Data Residency endpoint "
"(e.g. https://api.eu.residency.elevenlabs.io) for GDPR / HIPAA / "
"regional compliance."
),
)
@register_stt
class SmallestAISTTConfiguration(BaseSTTConfiguration):
model_config = SMALLEST_PROVIDER_MODEL_CONFIG
@ -1659,6 +1747,7 @@ STTConfig = Annotated[
GladiaSTTConfiguration,
AzureSpeechSTTConfiguration,
SmallestAISTTConfiguration,
ElevenlabsSTTConfiguration,
],
Field(discriminator="provider"),
]
@ -1722,7 +1811,7 @@ class AzureOpenAIEmbeddingsConfiguration(BaseEmbeddingsConfiguration):
)
DOGRAH_EMBEDDING_MODELS = ["default"]
DOGRAH_EMBEDDING_MODELS = ["dograh_embedding_v1"]
@register_embeddings
@ -1730,7 +1819,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

@ -1,23 +1,51 @@
from abc import ABC, abstractmethod
from typing import Any, BinaryIO, Dict, Optional
from typing import Any, Dict, Optional, Protocol
class AsyncReadable(Protocol):
"""Anything exposing ``await .read() -> bytes`` (aiofiles handles, in-memory wrappers)."""
async def read(self) -> bytes: ...
class _AsyncBytesReader:
"""Async file-like wrapper over in-memory bytes for acreate_file()."""
def __init__(self, data: bytes):
self._data = data
async def read(self) -> bytes:
return self._data
class BaseFileSystem(ABC):
"""Abstract base class for filesystem operations."""
@abstractmethod
async def acreate_file(self, file_path: str, content: BinaryIO) -> bool:
async def acreate_file(self, file_path: str, content: AsyncReadable) -> bool:
"""Create a new file with the given content.
Args:
file_path: Path where the file should be created
content: File content as a binary stream
content: File content readable via ``await content.read()``
Returns:
bool: True if file was created successfully, False otherwise
"""
pass
async def acreate_file_from_bytes(self, file_path: str, data: bytes) -> bool:
"""Create a file directly from in-memory bytes (no local file needed).
Args:
file_path: Path where the file should be created
data: File content as bytes
Returns:
bool: True if file was created successfully, False otherwise
"""
return await self.acreate_file(file_path, _AsyncBytesReader(data))
@abstractmethod
async def aupload_file(self, local_path: str, destination_path: str) -> bool:
"""Upload a file from local path to destination.

View file

@ -1,11 +1,11 @@
import asyncio
import os
from datetime import datetime
from typing import BinaryIO, Optional
from typing import Optional
import aiofiles
from .base import BaseFileSystem
from .base import AsyncReadable, BaseFileSystem
class LocalFileSystem(BaseFileSystem):
@ -24,7 +24,7 @@ class LocalFileSystem(BaseFileSystem):
"""Get the full path by joining with base path."""
return os.path.join(self.base_path, file_path)
async def acreate_file(self, file_path: str, content: BinaryIO) -> bool:
async def acreate_file(self, file_path: str, content: AsyncReadable) -> bool:
try:
full_path = self._get_full_path(file_path)
os.makedirs(os.path.dirname(full_path), exist_ok=True)

View file

@ -1,12 +1,13 @@
import asyncio
import io
import json
from typing import Any, BinaryIO, Dict, Optional
from typing import Any, Dict, Optional
from loguru import logger
from minio import Minio
from minio.error import S3Error
from .base import BaseFileSystem
from .base import AsyncReadable, BaseFileSystem
class MinioFileSystem(BaseFileSystem):
@ -89,15 +90,16 @@ class MinioFileSystem(BaseFileSystem):
logger.debug(f"Bucket setup note: {e}")
pass
async def acreate_file(self, file_path: str, content: BinaryIO) -> bool:
async def acreate_file(self, file_path: str, content: AsyncReadable) -> bool:
try:
data = await content.read()
def _put():
# The MinIO SDK requires a stream with .read(), not raw bytes.
self.client.put_object(
self.bucket_name,
file_path,
data=bytes(data),
data=io.BytesIO(data),
length=len(data),
)

View file

@ -1,6 +1,6 @@
from typing import Any, BinaryIO, Dict, NoReturn, Optional
from typing import Any, Dict, NoReturn, Optional
from .base import BaseFileSystem
from .base import AsyncReadable, BaseFileSystem
class NullFileSystem(BaseFileSystem):
@ -16,7 +16,7 @@ class NullFileSystem(BaseFileSystem):
"Set ENVIRONMENT to a non-test value or inject a real filesystem fixture."
)
async def acreate_file(self, file_path: str, content: BinaryIO) -> bool:
async def acreate_file(self, file_path: str, content: AsyncReadable) -> bool:
self._fail("acreate_file")
async def aupload_file(self, local_path: str, destination_path: str) -> bool:

View file

@ -1,30 +1,65 @@
from typing import Any, BinaryIO, Dict, Optional
from typing import Any, Dict, Optional
import aioboto3
from botocore.config import Config
from botocore.exceptions import ClientError
from .base import BaseFileSystem
from .base import AsyncReadable, BaseFileSystem
class S3FileSystem(BaseFileSystem):
"""S3 implementation of the filesystem interface."""
def __init__(self, bucket_name: str, region_name: str = "us-east-1"):
def __init__(
self,
bucket_name: str,
region_name: str = "us-east-1",
endpoint_url: Optional[str] = None,
signature_version: Optional[str] = None,
addressing_style: Optional[str] = None,
):
"""Initialize S3 filesystem.
Args:
bucket_name: Name of the S3 bucket
region_name: AWS region name
endpoint_url: Optional custom S3 endpoint (e.g. for MinIO/rustfs).
``None`` uses AWS's default endpoint resolution.
signature_version: Optional botocore signature version (e.g.
``"s3v4"``). ``None`` keeps botocore's default signing behavior.
addressing_style: Optional S3 addressing style (``"path"`` /
``"virtual"`` / ``"auto"``). ``None`` keeps botocore's default.
"""
self.bucket_name = bucket_name
self.region_name = region_name
self.endpoint_url = endpoint_url
self.session = aioboto3.Session()
async def acreate_file(self, file_path: str, content: BinaryIO) -> bool:
# Build a botocore Config only when an override is requested so that the
# default behavior is byte-for-byte unchanged when no env vars are set.
config_kwargs: Dict[str, Any] = {}
if signature_version:
config_kwargs["signature_version"] = signature_version
if addressing_style:
config_kwargs["s3"] = {"addressing_style": addressing_style}
self._config = Config(**config_kwargs) if config_kwargs else None
def _client_kwargs(self) -> Dict[str, Any]:
"""Common kwargs for every ``session.client("s3", ...)`` call.
Only includes ``endpoint_url`` / ``config`` when configured, so default
deployments behave exactly as before.
"""
kwargs: Dict[str, Any] = {"region_name": self.region_name}
if self.endpoint_url:
kwargs["endpoint_url"] = self.endpoint_url
if self._config is not None:
kwargs["config"] = self._config
return kwargs
async def acreate_file(self, file_path: str, content: AsyncReadable) -> bool:
try:
async with self.session.client(
"s3", region_name=self.region_name
) as s3_client:
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
await s3_client.put_object(
Bucket=self.bucket_name, Key=file_path, Body=await content.read()
)
@ -34,9 +69,7 @@ class S3FileSystem(BaseFileSystem):
async def aupload_file(self, local_path: str, destination_path: str) -> bool:
try:
async with self.session.client(
"s3", region_name=self.region_name
) as s3_client:
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
await s3_client.upload_file(
local_path, self.bucket_name, destination_path
)
@ -59,9 +92,7 @@ class S3FileSystem(BaseFileSystem):
disposition on the response.
"""
try:
async with self.session.client(
"s3", region_name=self.region_name
) as s3_client:
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
params = {"Bucket": self.bucket_name, "Key": file_path}
# Make artifacts viewable inline in the browser when requested
@ -100,9 +131,7 @@ class S3FileSystem(BaseFileSystem):
async def aget_file_metadata(self, file_path: str) -> Optional[Dict[str, Any]]:
"""Get S3 object metadata."""
try:
async with self.session.client(
"s3", region_name=self.region_name
) as s3_client:
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
response = await s3_client.head_object(
Bucket=self.bucket_name, Key=file_path
)
@ -126,9 +155,7 @@ class S3FileSystem(BaseFileSystem):
) -> Optional[str]:
"""Generate a presigned PUT URL for direct file upload."""
try:
async with self.session.client(
"s3", region_name=self.region_name
) as s3_client:
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
url = await s3_client.generate_presigned_url(
"put_object",
Params={
@ -145,9 +172,7 @@ class S3FileSystem(BaseFileSystem):
async def adownload_file(self, source_path: str, local_path: str) -> bool:
"""Download a file from S3 to local path."""
try:
async with self.session.client(
"s3", region_name=self.region_name
) as s3_client:
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
await s3_client.download_file(self.bucket_name, source_path, local_path)
return True
except ClientError:
@ -156,9 +181,7 @@ class S3FileSystem(BaseFileSystem):
async def acopy_file(self, source_path: str, destination_path: str) -> bool:
"""Copy a file within S3 (server-side copy)."""
try:
async with self.session.client(
"s3", region_name=self.region_name
) as s3_client:
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
await s3_client.copy_object(
Bucket=self.bucket_name,
Key=destination_path,

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,107 @@
"""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 correlation id the same way the voice
path does.
"""
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(
*,
service_key: Optional[str],
) -> Optional[str]:
"""Mint an MPS correlation id for a managed embedding call made outside a run.
Matches the voice path's ``_authorize_oss_managed_v2_correlation``: the
correlation is minted via the bearer service-key endpoint, so it works for
hosted orgs and OSS keys alike. Returns ``None`` when minting fails; MPS
accepts un-correlated embedding calls.
"""
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.services.mps_service_key_client import mps_service_key_client
try:
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 it: {}",
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,
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(service_key=api_key)
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,32 @@
"""Paygent integration package.
Self-registers on import via ``register_package``. Auto-discovered by
``api/services/integrations/loader.py`` (scans all submodules of
``api.services.integrations`` except ``base``, ``loader``, and ``registry``).
Provides:
- ``PaygentNodeData`` Pydantic config node shown in the Dograh UI under
INTEGRATIONS "Paygent"
- ``create_runtime_sessions`` live-call observer that accumulates usage data
- ``run_completion`` post-call REST delivery to the Paygent API
"""
from __future__ import annotations
from api.services.integrations.base import IntegrationPackageSpec
from api.services.integrations.registry import register_package
from .completion import run_completion
from .node import NODE
from .runtime import create_runtime_sessions
PACKAGE = register_package(
IntegrationPackageSpec(
name="paygent",
nodes=(NODE,),
create_runtime_sessions=create_runtime_sessions,
run_completion=run_completion,
)
)
__all__ = ["PACKAGE"]

View file

@ -0,0 +1,315 @@
"""Paygent REST API client (pure httpx, no SDK).
All network I/O goes through ``post_paygent`` which is the single delivery
coroutine used by the completion handler. The individual tracker functions
(session, STT, TTS, LLM, STS, indicator) mirror the exact shape of the
Paygent REST API documented in ``paygent_sdk/voice_client.py``.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from pydantic import BaseModel, field_validator
_DEFAULT_BASE_URL = "https://cp-api.withpaygent.com"
_REQUEST_TIMEOUT = 15 # seconds generous for post-call delivery
# ---------------------------------------------------------------------------
# Config model
# ---------------------------------------------------------------------------
class PaygentDeliveryConfig(BaseModel):
"""Validated delivery configuration, filled from the node data."""
base_url: str = _DEFAULT_BASE_URL
api_key: str
agent_id: str
customer_id: str
@field_validator("api_key", "agent_id", "customer_id")
@classmethod
def _must_not_be_empty(cls, value: str) -> str:
if not value or not value.strip():
raise ValueError("must not be empty")
return value.strip()
@field_validator("base_url")
@classmethod
def _normalise_base_url(cls, value: str) -> str:
return (value or _DEFAULT_BASE_URL).rstrip("/")
# ---------------------------------------------------------------------------
# Live-call snapshot (collected during the call, delivered after)
# ---------------------------------------------------------------------------
@dataclass
class PaygentCallSnapshot:
"""Immutable snapshot produced at call-finish; passed to ``deliver``."""
session_id: str
agent_id: str
customer_id: str
is_realtime: bool
# Usage buckets filled from PipelineMetricsAggregator + user_config
stt_provider: str = ""
stt_model: str = ""
stt_audio_seconds: float = 0.0
llm_provider: str = ""
llm_model: str = ""
llm_prompt_tokens: int = 0
llm_completion_tokens: int = 0
llm_cached_tokens: int = 0
tts_provider: str = ""
tts_model: str = ""
tts_characters: int = 0
sts_provider: str = ""
sts_model: str = ""
sts_usage_metadata: dict[str, Any] | None = None
# Final call status / total duration seconds
call_disposition: str = "completed"
total_duration_seconds: int = 0
indicator: str = "per-minute-call"
def to_dict(self) -> dict[str, Any]:
return {
"session_id": self.session_id,
"agent_id": self.agent_id,
"customer_id": self.customer_id,
"is_realtime": self.is_realtime,
"stt": {
"provider": self.stt_provider,
"model": self.stt_model,
"audio_seconds": self.stt_audio_seconds,
},
"llm": {
"provider": self.llm_provider,
"model": self.llm_model,
"prompt_tokens": self.llm_prompt_tokens,
"completion_tokens": self.llm_completion_tokens,
"cached_tokens": self.llm_cached_tokens,
},
"tts": {
"provider": self.tts_provider,
"model": self.tts_model,
"characters": self.tts_characters,
},
"sts": {
"provider": self.sts_provider,
"model": self.sts_model,
"usage_metadata": self.sts_usage_metadata,
},
"call_disposition": self.call_disposition,
"total_duration_seconds": self.total_duration_seconds,
"indicator": self.indicator,
}
# ---------------------------------------------------------------------------
# REST delivery helpers
# ---------------------------------------------------------------------------
def _headers(api_key: str) -> dict[str, str]:
return {
"Content-Type": "application/json",
"paygent-api-key": api_key,
}
async def _post(
client: httpx.AsyncClient,
url: str,
api_key: str,
payload: dict[str, Any],
*,
label: str,
) -> None:
"""POST ``payload`` to ``url``; raises on 4xx/5xx or network failure.
Intentionally non-swallowing: callers in ``deliver()`` each wrap this in
their own try/except to build the ``errors`` list and the ``status`` field.
"""
resp = await client.post(url, json=payload, headers=_headers(api_key))
resp.raise_for_status()
async def deliver(
config: PaygentDeliveryConfig,
snapshot: PaygentCallSnapshot,
) -> dict[str, Any]:
"""
Execute the full Paygent REST call sequence for one completed call:
1. initialize_voice_session
2. track_stt (if STT is used, i.e. not realtime-only)
3. track_llm
4. track_tts (if TTS is used, i.e. not realtime-only)
5. track_sts (if realtime / STS model used)
6. set_indicator (always; marks end of session)
Returns a result dict merged into ``workflow_run.annotations``.
"""
base = config.base_url
api_key = config.api_key
session_id = snapshot.session_id
delivered_steps: list[str] = []
errors: list[str] = []
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client:
# 1. Initialize voice session ----------------------------------------
try:
await _post(
client,
f"{base}/api/v1/voice/session",
api_key,
{
"sessionId": session_id,
"agentId": snapshot.agent_id,
"customerId": snapshot.customer_id,
},
label="initialize_voice_session",
)
delivered_steps.append("session_init")
except Exception as exc:
errors.append(f"session_init: {exc}")
# 2. Track STT (only for non-realtime pipelines) ---------------------
if not snapshot.is_realtime and snapshot.stt_audio_seconds > 0:
try:
await _post(
client,
f"{base}/api/v1/voice/stt",
api_key,
{
"sessionId": session_id,
"audioMinutes": snapshot.stt_audio_seconds / 60.0,
"provider": snapshot.stt_provider,
"model": snapshot.stt_model,
"plan": "",
},
label="track_stt",
)
delivered_steps.append("track_stt")
except Exception as exc:
errors.append(f"track_stt: {exc}")
# 3. Track LLM -------------------------------------------------------
if snapshot.llm_prompt_tokens > 0 or snapshot.llm_completion_tokens > 0:
llm_payload: dict[str, Any] = {
"sessionId": session_id,
"provider": snapshot.llm_provider,
"model": snapshot.llm_model,
"plan": "",
"promptTokens": snapshot.llm_prompt_tokens,
"completionTokens": snapshot.llm_completion_tokens,
}
if snapshot.llm_cached_tokens > 0:
llm_payload["cachedTokens"] = snapshot.llm_cached_tokens
try:
await _post(
client,
f"{base}/api/v1/voice/llm",
api_key,
llm_payload,
label="track_llm",
)
delivered_steps.append("track_llm")
except Exception as exc:
errors.append(f"track_llm: {exc}")
# 4. Track TTS (only for non-realtime pipelines) ---------------------
if not snapshot.is_realtime and snapshot.tts_characters > 0:
try:
await _post(
client,
f"{base}/api/v1/voice/tts",
api_key,
{
"sessionId": session_id,
"provider": snapshot.tts_provider,
"model": snapshot.tts_model,
"plan": "",
"characters": snapshot.tts_characters,
},
label="track_tts",
)
delivered_steps.append("track_tts")
except Exception as exc:
errors.append(f"track_tts: {exc}")
# 5. Track STS (Speech-to-Speech) for Realtime Models ----------------
if snapshot.is_realtime:
metadata = snapshot.sts_usage_metadata or {}
# Only append connection minutes if we don't already have a rich token payload
# (e.g. from OpenAI Realtime or Gemini Live)
if (
"connection" not in metadata
and "prompt_tokens" not in metadata
and "input" not in metadata
):
metadata["connection"] = {
"minutes": snapshot.total_duration_seconds / 60.0
}
try:
await _post(
client,
f"{base}/api/v1/voice/speech-to-speech",
api_key,
{
"sessionId": session_id,
"provider": snapshot.sts_provider,
"model": snapshot.sts_model,
"plan": "",
"usageMetadata": metadata,
},
label="track_sts",
)
delivered_steps.append("track_sts")
except Exception as exc:
errors.append(f"track_sts: {exc}")
# 6. Set indicator (end-of-session marker) ---------------------------
try:
await _post(
client,
f"{base}/api/v1/voice/indicator",
api_key,
{
"sessionId": session_id,
"indicator": snapshot.indicator,
"totalDuration": snapshot.total_duration_seconds / 60.0,
},
label="set_indicator",
)
delivered_steps.append("set_indicator")
except Exception as exc:
errors.append(f"set_indicator: {exc}")
return _result(session_id, delivered_steps, errors)
def _result(
session_id: str,
delivered_steps: list[str],
errors: list[str],
) -> dict[str, Any]:
return {
"session_id": session_id,
"delivered_steps": delivered_steps,
"errors": errors,
"status": "ok" if not errors else ("partial" if delivered_steps else "failed"),
}

View file

@ -0,0 +1,704 @@
"""Paygent live-call collector.
Attaches to the pipecat pipeline as a ``BaseObserver`` to accumulate per-call
usage metrics (STT audio seconds, LLM tokens, TTS characters, STS metadata)
in memory during the call. No network I/O happens here; all delivery is
deferred to the post-call completion handler.
Design mirrors ``api/services/integrations/tuner/collector.py`` exactly:
- Attach to the task in ``PaygentRuntimeSession.attach``
- Build a serialisable snapshot in ``build_snapshot``
- Return it from ``on_call_finished`` so it lands in ``workflow_run.logs``
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any, Dict
from loguru import logger
from pipecat.frames.frames import (
CancelFrame,
EndFrame,
MetricsFrame,
StartFrame,
TTSTextFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
)
from pipecat.metrics.metrics import (
LLMTokenUsage,
LLMUsageMetricsData,
TTSUsageMetricsData,
)
from pipecat.observers.base_observer import BaseObserver, FramePushed
from pipecat.processors.frame_processor import FrameDirection
def _detect_provider(name: str, fallback: str = "unknown") -> str:
"""Map a processor/model name to a canonical Paygent provider slug dynamically."""
if not name:
return fallback
clean_name = name.lower().strip()
if "gemini" in clean_name:
return "google"
suffixes = [
"service",
"multimodallive",
"realtime",
"vertex",
"llm",
"tts",
"stt",
"helper",
"transport",
]
changed = True
while changed:
changed = False
for suffix in suffixes:
if clean_name.endswith(suffix):
clean_name = clean_name[: -len(suffix)].rstrip("_").rstrip("-")
changed = True
break
return clean_name or fallback
@dataclass
class _UsageAccumulator:
"""In-memory accumulator for per-call usage data."""
# STT
stt_audio_seconds: float = 0.0
# LLM (aggregated across all turns)
llm_prompt_tokens: int = 0
llm_completion_tokens: int = 0
llm_cached_tokens: int = 0
# TTS
tts_characters: int = 0
_has_tts_metrics: bool = False
# STS / realtime (last seen usage_metadata dict; callers merge these)
sts_usage_metadata: dict[str, Any] | None = None
# Call timing
call_start_abs_ns: int = field(default_factory=time.time_ns)
call_end_abs_ns: int | None = None
# STT: timestamp of when user started speaking; None when not speaking
_user_started_speaking_ns: int | None = field(default=None, repr=False)
@property
def total_duration_seconds(self) -> int:
if self.call_end_abs_ns is None:
return int((time.time_ns() - self.call_start_abs_ns) / 1_000_000_000)
return int((self.call_end_abs_ns - self.call_start_abs_ns) / 1_000_000_000)
def get_stt_audio_seconds(self) -> float:
"""Return measured STT audio seconds accumulated from the pipeline.
NOTE: This is the real measured STT audio duration collected from the
pipeline's STT metrics frames, NOT the total call wall-clock duration.
The call wall-clock duration is available separately via
``total_duration_seconds``.
"""
return self.stt_audio_seconds
def add_llm(self, usage: LLMTokenUsage) -> None:
self.llm_prompt_tokens += usage.prompt_tokens or 0
self.llm_completion_tokens += usage.completion_tokens or 0
self.llm_cached_tokens += (usage.cache_read_input_tokens or 0) + (
usage.cache_creation_input_tokens or 0
)
def add_tts_metrics(self, data: Any) -> None:
if not self._has_tts_metrics:
self._has_tts_metrics = True
self.tts_characters = 0 # Ignore manual count if metrics emit natively
# Extremely robust extraction
val = 0
if isinstance(data, (int, float)):
val = data
elif hasattr(data, "value"):
val = getattr(data, "value", 0) or 0
elif hasattr(data, "characters"):
val = getattr(data, "characters", 0) or 0
elif isinstance(data, dict):
val = data.get("value") or data.get("characters") or 0
try:
self.tts_characters += int(val or 0)
except Exception as exc:
logger.warning(
"[paygent] Failed to accumulate TTS characters (val={!r}): {}", val, exc
)
def add_tts_manual(self, text: str) -> None:
if not self._has_tts_metrics:
self.tts_characters += len(text)
def on_user_started_speaking(self) -> None:
"""Mark the start of a user utterance for STT audio metering."""
if self._user_started_speaking_ns is None:
self._user_started_speaking_ns = time.time_ns()
def on_user_stopped_speaking(self) -> None:
"""Accumulate the completed utterance duration into stt_audio_seconds."""
if self._user_started_speaking_ns is not None:
elapsed_s = (
time.time_ns() - self._user_started_speaking_ns
) / 1_000_000_000
self.stt_audio_seconds += elapsed_s
self._user_started_speaking_ns = None
def finalize(self) -> None:
if self.call_end_abs_ns is None:
self.call_end_abs_ns = time.time_ns()
# If user was mid-utterance when the call ended, close the interval.
self.on_user_stopped_speaking()
def _google_live_usage_to_sts_metadata(usage: Dict[str, Any]) -> Dict[str, Any]:
"""
Pure Python translation of Google GenAI Live usage_metadata to
Paygent's canonical speech-to-speech /api/v1/voice/speech-to-speech API schema.
"""
if not usage:
return {"schemaVersion": 1}
def _get_val(obj, *keys):
if not obj:
return None
for k in keys:
if isinstance(obj, dict):
if k in obj:
return obj[k]
else:
if hasattr(obj, k):
return getattr(obj, k)
return None
def _get_list(obj, *keys):
val = _get_val(obj, *keys)
if val is None:
return None
return list(val) if not isinstance(val, list) else val
def _optional_int(obj, *keys):
val = _get_val(obj, *keys)
if val is not None:
try:
return int(val)
except (TypeError, ValueError):
return None
return None
def _modality_token_count(details, modality_name):
if not details:
return 0
want = modality_name.upper()
total = 0
for d in details:
try:
mod = _get_val(d, "modality")
if mod is None:
continue
label = _get_val(mod, "name") or _get_val(mod, "value") or mod
if str(label).upper() != want:
continue
tc = _get_val(d, "token_count", "tokenCount")
total += int(tc or 0)
except Exception:
continue
return total
prompt_details = _get_list(usage, "prompt_tokens_details", "promptTokensDetails")
response_details = _get_list(
usage, "response_tokens_details", "responseTokensDetails"
)
tool_details = _get_list(
usage, "tool_use_prompt_tokens_details", "toolUsePromptTokensDetails"
)
cache_details = _get_list(usage, "cache_tokens_details", "cacheTokensDetails")
# input side: TEXT + DOCUMENT + AUDIO + IMAGE + VIDEO
text_in = _modality_token_count(prompt_details, "TEXT") + _modality_token_count(
tool_details, "TEXT"
)
audio_in = _modality_token_count(prompt_details, "AUDIO") + _modality_token_count(
tool_details, "AUDIO"
)
image_in = _modality_token_count(prompt_details, "IMAGE") + _modality_token_count(
tool_details, "IMAGE"
)
video_in = _modality_token_count(prompt_details, "VIDEO") + _modality_token_count(
tool_details, "VIDEO"
)
doc_as_text = _modality_token_count(
prompt_details, "DOCUMENT"
) + _modality_token_count(tool_details, "DOCUMENT")
text_in += doc_as_text
# fallback aggregate mapping
tutc = _optional_int(
usage, "tool_use_prompt_token_count", "toolUsePromptTokenCount"
)
if tutc is not None and not tool_details:
text_in += int(tutc)
ptc = _optional_int(usage, "prompt_token_count", "promptTokenCount")
if ptc is not None and not prompt_details and not tool_details:
text_in += int(ptc)
# output side: TEXT + DOCUMENT + AUDIO + VIDEO + THINKING
text_out = _modality_token_count(response_details, "TEXT") + _modality_token_count(
response_details, "DOCUMENT"
)
audio_out = _modality_token_count(
response_details, "AUDIO"
) + _modality_token_count(response_details, "VIDEO")
rtc = _optional_int(usage, "response_token_count", "responseTokenCount")
if text_out == 0 and audio_out == 0 and rtc is not None:
# Default fallback to audio output for STS audio connection
audio_out = int(rtc)
# Thinking / reasoning tokens (Gemini 2.5+ thinking models).
# Emitted as a separate output modality so Paygent has full billing visibility.
thinking_tokens = (
_optional_int(
usage,
"thoughts_token_count",
"thoughtsTokenCount",
"thinking_token_count",
"thinkingTokenCount",
)
or 0
)
# Cache breakdowns
cached_text = _modality_token_count(cache_details, "TEXT") + _modality_token_count(
cache_details, "DOCUMENT"
)
cached_audio = _modality_token_count(
cache_details, "AUDIO"
) + _modality_token_count(cache_details, "VIDEO")
cached_image = _modality_token_count(cache_details, "IMAGE")
cached_legacy = _optional_int(
usage, "cached_content_token_count", "cachedContentTokenCount"
)
# Build response payload
out = {"schemaVersion": 1}
# Input Side
inp = {}
if text_in > 0:
inp["text"] = {"tokens": text_in}
if audio_in > 0:
inp["audio"] = {"tokens": audio_in}
if image_in > 0:
inp["image"] = {"tokens": image_in}
if video_in > 0:
inp["video"] = {"tokens": video_in}
if inp:
out["input"] = inp
# Output Side
o = {}
if text_out > 0:
o["text"] = {"tokens": text_out}
if audio_out > 0:
o["audio"] = {"tokens": audio_out}
if thinking_tokens > 0:
o["thinking"] = {"tokens": thinking_tokens}
if o:
out["output"] = o
# Cached breakdown
has_split = bool(cached_text or cached_audio or cached_image)
if cached_legacy is not None and cached_legacy > 0 and not has_split:
out["cached"] = {"tokens": int(cached_legacy)}
elif has_split:
cd = {}
if cached_text > 0:
cd["text"] = {"tokens": cached_text}
if cached_audio > 0:
cd["audio"] = {"tokens": cached_audio}
if cached_image > 0:
cd["image"] = {"tokens": cached_image}
if cd:
out["cached"] = cd
return out
def _openai_realtime_usage_to_sts_metadata(usage: Dict[str, Any]) -> Dict[str, Any]:
"""
Pure Python translation of OpenAI Realtime usage_metadata to
Paygent's canonical speech-to-speech /api/v1/voice/speech-to-speech API schema.
"""
if not usage:
return {"schemaVersion": 1}
def _get_val(obj, *keys):
if not obj:
return None
for k in keys:
if isinstance(obj, dict):
if k in obj:
return obj[k]
else:
if hasattr(obj, k):
return getattr(obj, k)
return None
total_in = int(_get_val(usage, "input_tokens", "inputTokens") or 0)
total_out = int(_get_val(usage, "output_tokens", "outputTokens") or 0)
in_details = _get_val(usage, "input_token_details", "inputTokenDetails") or {}
out_details = _get_val(usage, "output_token_details", "outputTokenDetails") or {}
audio_in = int(_get_val(in_details, "audio_tokens", "audioTokens") or 0)
text_in = int(_get_val(in_details, "text_tokens", "textTokens") or 0)
image_in = int(_get_val(in_details, "image_tokens", "imageTokens") or 0)
cached_total = int(
_get_val(usage, "cached_tokens", "cachedTokens")
or _get_val(in_details, "cached_tokens", "cachedTokens")
or 0
)
cached_details = (
_get_val(in_details, "cached_tokens_details", "cachedTokensDetails") or {}
)
cached_audio = int(_get_val(cached_details, "audio_tokens", "audioTokens") or 0)
cached_text = int(_get_val(cached_details, "text_tokens", "textTokens") or 0)
cached_image = int(_get_val(cached_details, "image_tokens", "imageTokens") or 0)
if not (cached_audio or cached_text or cached_image):
cached_audio = int(
_get_val(in_details, "cached_audio_tokens", "cachedAudioTokens") or 0
)
cached_text = int(
_get_val(in_details, "cached_text_tokens", "cachedTextTokens") or 0
)
cached_image = int(
_get_val(in_details, "cached_image_tokens", "cachedImageTokens") or 0
)
audio_out = int(_get_val(out_details, "audio_tokens", "audioTokens") or 0)
text_out = int(_get_val(out_details, "text_tokens", "textTokens") or 0)
if not (text_in or audio_in or image_in) and total_in > 0:
text_in = total_in - cached_total
out = {"schemaVersion": 1}
inp = {}
if text_in > 0:
inp["text"] = {"tokens": text_in}
if audio_in > 0:
inp["audio"] = {"tokens": audio_in}
if image_in > 0:
inp["image"] = {"tokens": image_in}
if inp:
out["input"] = inp
o = {}
if text_out > 0:
o["text"] = {"tokens": text_out}
if audio_out > 0:
o["audio"] = {"tokens": audio_out}
if o:
out["output"] = o
has_split = bool(cached_text or cached_audio or cached_image)
if cached_total > 0 and not has_split:
out["cached"] = {"tokens": int(cached_total)}
elif has_split:
cd = {}
if cached_text > 0:
cd["text"] = {"tokens": cached_text}
if cached_audio > 0:
cd["audio"] = {"tokens": cached_audio}
if cached_image > 0:
cd["image"] = {"tokens": cached_image}
if cd:
out["cached"] = cd
return out
def _merge_sts_metadata(existing: dict, new: dict) -> dict:
if not existing:
return new
out = {"schemaVersion": 1}
for key in ("input", "output", "cached"):
e_val = existing.get(key, {})
n_val = new.get(key, {})
if not e_val and not n_val:
continue
merged_cat: dict = {}
# Prefer per-modality merge when either side has per-modality detail.
# Only use the flat aggregate{"tokens": N} form when neither side has
# any per-modality breakdown at all (e.g. legacy schema).
e_has_modalities = any(
m in e_val for m in ("text", "audio", "image", "video", "thinking")
)
n_has_modalities = any(
m in n_val for m in ("text", "audio", "image", "video", "thinking")
)
if e_has_modalities or n_has_modalities:
for modality in ("text", "audio", "image", "video", "thinking"):
e_mod = e_val.get(modality, {}).get("tokens", 0)
n_mod = n_val.get(modality, {}).get("tokens", 0)
total = e_mod + n_mod
if total > 0:
merged_cat[modality] = {"tokens": total}
# Also sum any lingering aggregate total so no tokens are lost
e_agg = e_val.get("tokens", 0) if not e_has_modalities else 0
n_agg = n_val.get("tokens", 0) if not n_has_modalities else 0
if e_agg or n_agg:
# Incorporate the unbroken-down side into the "text" bucket as
# a best-effort attribution rather than silently dropping it.
existing_text = merged_cat.get("text", {}).get("tokens", 0)
merged_cat["text"] = {"tokens": existing_text + e_agg + n_agg}
elif "tokens" in e_val or "tokens" in n_val:
merged_cat["tokens"] = e_val.get("tokens", 0) + n_val.get("tokens", 0)
if merged_cat:
out[key] = merged_cat
# retain any other keys, summing up numeric ones to keep metadata consistent
for k, v in existing.items():
if k not in ("schemaVersion", "input", "output", "cached"):
out[k] = v
for k, v in new.items():
if k not in ("schemaVersion", "input", "output", "cached"):
if (
k in out
and isinstance(out[k], (int, float))
and isinstance(v, (int, float))
):
out[k] = out[k] + v
else:
out[k] = v
return out
class PaygentCollector(BaseObserver):
"""Pipecat observer that accumulates usage data for a single call.
Accumulates:
- LLM token usage from ``MetricsFrame / LLMUsageMetricsData``
- TTS character usage from ``MetricsFrame / TTSUsageMetricsData``
- STT audio seconds from ``MetricsFrame`` (when exposed by the pipeline)
- Call start / end timestamps for ``total_duration_seconds``
Does **not** do any network I/O.
"""
def __init__(
self,
*,
workflow_run_id: int,
is_realtime: bool,
stt_provider: str = "",
stt_model: str = "",
llm_provider: str = "",
llm_model: str = "",
tts_provider: str = "",
tts_model: str = "",
sts_provider: str = "",
sts_model: str = "",
) -> None:
super().__init__()
self._workflow_run_id = workflow_run_id
self._is_realtime = is_realtime
self._stt_provider = stt_provider
self._stt_model = stt_model
self._llm_provider = llm_provider
self._llm_model = llm_model
self._tts_provider = tts_provider
self._tts_model = tts_model
self._sts_provider = sts_provider
self._sts_model = sts_model
self._acc = _UsageAccumulator()
self._call_disposition: str = "completed"
# Dedup guard: pipecat can re-deliver frames. This collector is created
# fresh per call (see create_runtime_sessions) so the set size is bounded
# by call duration. We intentionally do NOT trim the set: trimming would
# evict old IDs and allow re-delivered frames to be processed a second time.
self._seen_frame_ids: set[int] = set()
# ------------------------------------------------------------------
# Public hooks
# ------------------------------------------------------------------
def set_call_disposition(self, disposition: str | None) -> None:
if disposition:
self._call_disposition = disposition
def build_snapshot(self) -> dict[str, Any]:
"""Return a JSON-serialisable dict stored in ``workflow_run.logs``."""
self._acc.finalize()
stt_audio_sec = self._acc.get_stt_audio_seconds()
return {
"workflow_run_id": self._workflow_run_id,
"is_realtime": self._is_realtime,
"stt_provider": self._stt_provider,
"stt_model": self._stt_model,
"stt_audio_seconds": stt_audio_sec,
"llm_provider": self._llm_provider,
"llm_model": self._llm_model,
"llm_prompt_tokens": self._acc.llm_prompt_tokens,
"llm_completion_tokens": self._acc.llm_completion_tokens,
"llm_cached_tokens": self._acc.llm_cached_tokens,
"tts_provider": self._tts_provider,
"tts_model": self._tts_model,
"tts_characters": self._acc.tts_characters,
"sts_provider": self._sts_provider,
"sts_model": self._sts_model,
"sts_usage_metadata": self._acc.sts_usage_metadata,
"call_disposition": self._call_disposition,
"total_duration_seconds": self._acc.total_duration_seconds,
}
# ------------------------------------------------------------------
# BaseObserver implementation
# ------------------------------------------------------------------
async def on_push_frame(self, data: FramePushed) -> None: # type: ignore[override]
try:
# Only process downstream frames; ignore upstream (mic → STT direction)
if data.direction != FrameDirection.DOWNSTREAM:
return
frame = data.frame
# Dedup: per-call set; grows with the call but is GCd when the
# call ends. Never trim — trimming would reopen a re-delivery window.
if frame.id in self._seen_frame_ids:
return
self._seen_frame_ids.add(frame.id)
if isinstance(frame, StartFrame):
self._acc.call_start_abs_ns = time.time_ns()
elif isinstance(frame, MetricsFrame):
for item in frame.data:
if isinstance(item, LLMUsageMetricsData):
is_sts_frame = False
proc_lower = (item.processor or "").lower()
if getattr(self, "_is_realtime", False):
if "realtime" in proc_lower or "live" in proc_lower:
is_sts_frame = True
if is_sts_frame:
# Normalise the raw provider slug so that variants like
# "openai_realtime", "azure_realtime", etc. route correctly.
raw_provider = getattr(
self, "_sts_provider", ""
) or getattr(self, "_llm_provider", "")
provider = (
_detect_provider(raw_provider)
if raw_provider
else "unknown"
)
if provider not in ("grok", "ultravox"):
usage = item.value
raw_metadata = getattr(
usage, "raw_usage_metadata", None
)
if raw_metadata:
# OpenAI Realtime and Azure Realtime (azure→openai via _detect_provider)
# share the same wire format.
if provider in ("openai", "azure"):
new_meta = (
_openai_realtime_usage_to_sts_metadata(
raw_metadata
)
)
else:
new_meta = _google_live_usage_to_sts_metadata(
raw_metadata
)
else:
prompt_tokens = (
getattr(usage, "prompt_tokens", 0) or 0
)
completion_tokens = (
getattr(usage, "completion_tokens", 0) or 0
)
cached_tokens = (
getattr(usage, "cache_read_input_tokens", 0)
or getattr(usage, "cached_tokens", 0)
or 0
)
new_meta = {"schemaVersion": 1}
if prompt_tokens > 0:
new_meta.setdefault("input", {})["text"] = {
"tokens": prompt_tokens
}
if completion_tokens > 0:
new_meta.setdefault("output", {})["text"] = {
"tokens": completion_tokens
}
if cached_tokens > 0:
new_meta["cached"] = {"tokens": cached_tokens}
if hasattr(usage, "__dict__"):
for k, v in vars(usage).items():
if (
not k.startswith("_")
and v is not None
and k not in new_meta
):
new_meta[k] = v
self._acc.sts_usage_metadata = _merge_sts_metadata(
self._acc.sts_usage_metadata or {}, new_meta
)
else:
self._acc.add_llm(item.value)
elif isinstance(item, TTSUsageMetricsData):
chars_val = getattr(item, "value", 0) or 0
self._acc.add_tts_metrics(chars_val)
# STT usage is exposed as a float in TTSUsageMetricsData-like
# structure by some providers; we also pull from the aggregator
# snapshot at call-finish (see runtime.py) for robustness.
elif isinstance(frame, TTSTextFrame):
# Fallback character counting for providers that don't emit native TTS metrics.
# TTSTextFrame carries only the text actually sent to the TTS engine;
# using base TextFrame would incorrectly include user transcriptions.
self._acc.add_tts_manual(frame.text)
elif isinstance(frame, UserStartedSpeakingFrame):
# Measure real STT audio seconds from VAD events rather than
# relying on wall-clock time. Skipped for realtime pipelines
# which have no separate STT stage.
if not self._is_realtime:
self._acc.on_user_started_speaking()
elif isinstance(frame, UserStoppedSpeakingFrame):
if not self._is_realtime:
self._acc.on_user_stopped_speaking()
elif isinstance(frame, (EndFrame, CancelFrame)):
self._acc.finalize()
except Exception as exc:
logger.warning(
"[paygent] Unexpected error processing frame {!r} in collector: {}",
type(data.frame).__name__,
exc,
exc_info=True,
)

View file

@ -0,0 +1,193 @@
"""Paygent post-call completion handler.
Reads the ``paygent_snapshot`` that the runtime session stored in
``workflow_run.logs``, reconstructs the full ``PaygentCallSnapshot``, and
drives the ordered REST delivery sequence via ``client.deliver()``.
Mirrors ``tuner/completion.py`` exactly:
- validate each node with Pydantic
- skip disabled nodes
- read runtime snapshot from ``context.workflow_run.logs``
- build a ``PaygentDeliveryConfig`` per node
- call ``deliver(config, snapshot)``
- collect results keyed by ``paygent_{node_id}``
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from loguru import logger
from api.services.integrations.base import IntegrationCompletionContext
from .client import PaygentCallSnapshot, PaygentDeliveryConfig, deliver
from .node import PaygentNodeData
_DEFAULT_BASE_URL = "https://cp-api.withpaygent.com"
def _build_snapshot(
raw: dict[str, Any],
*,
workflow_run_id: int,
) -> PaygentCallSnapshot:
"""Reconstruct a ``PaygentCallSnapshot`` from the persisted log dict."""
return PaygentCallSnapshot(
# session_id is always the authoritative workflow_run_id; the persisted
# snapshot value is never used to override it, preventing billing drift
# if the log is stale or corrupted.
session_id=str(workflow_run_id),
agent_id=raw.get("agent_id", ""), # filled from node config below
customer_id=raw.get("customer_id", ""), # filled from node config below
is_realtime=raw.get("is_realtime", False),
stt_provider=raw.get("stt_provider", ""),
stt_model=raw.get("stt_model", ""),
stt_audio_seconds=float(raw.get("stt_audio_seconds", 0.0)),
llm_provider=raw.get("llm_provider", ""),
llm_model=raw.get("llm_model", ""),
llm_prompt_tokens=int(raw.get("llm_prompt_tokens", 0)),
llm_completion_tokens=int(raw.get("llm_completion_tokens", 0)),
llm_cached_tokens=int(raw.get("llm_cached_tokens", 0)),
tts_provider=raw.get("tts_provider", ""),
tts_model=raw.get("tts_model", ""),
tts_characters=int(raw.get("tts_characters", 0)),
sts_provider=raw.get("sts_provider", ""),
sts_model=raw.get("sts_model", ""),
sts_usage_metadata=raw.get("sts_usage_metadata"),
call_disposition=raw.get("call_disposition", "completed"),
total_duration_seconds=int(raw.get("total_duration_seconds", 0)),
)
async def run_completion(
nodes: list[dict[str, Any]],
context: IntegrationCompletionContext,
) -> dict[str, Any]:
"""Post-call completion handler: deliver usage data to Paygent REST API."""
results: dict[str, Any] = {}
raw_snapshot: dict[str, Any] | None = (context.workflow_run.logs or {}).get(
"paygent_snapshot"
)
for node in nodes:
node_id = node.get("id", "unknown")
# ---- Validate the node config via Pydantic -------------------------
try:
node_data = PaygentNodeData.model_validate(node.get("data", {}))
except Exception:
results[f"paygent_{node_id}"] = {"error": "validation_failed"}
continue
if not node_data.paygent_enabled:
continue
# ---- Guard: runtime snapshot must exist ----------------------------
if not raw_snapshot:
results[f"paygent_{node_id}"] = {"error": "missing_runtime_snapshot"}
continue
# ---- Build typed objects -------------------------------------------
snapshot = _build_snapshot(
raw_snapshot, workflow_run_id=context.workflow_run_id
)
# Inject node-level credentials into the snapshot
snapshot.agent_id = (node_data.paygent_agent_id or "").strip()
snapshot.customer_id = (node_data.paygent_customer_id or "").strip()
snapshot.indicator = (node_data.paygent_indicator or "per-minute-call").strip()
# Fallback to usage_info if snapshot has 0s (Pipecat metrics might be missing)
usage_info = context.workflow_run.usage_info or {}
try:
# Only fallback to pipeline-level llm usage if this is NOT a realtime pipeline.
# In realtime pipelines, the collector properly segregates STS and LLM tokens;
# falling back here would duplicate the STS tokens into the LLM bucket.
if (
snapshot.llm_prompt_tokens == 0
and snapshot.llm_completion_tokens == 0
and not snapshot.is_realtime
):
llm_providers: list[str] = []
llm_models: list[str] = []
for key, val in usage_info.get("llm", {}).items():
# Skip post-call QA analysis entries — they must not be billed
# as in-conversation LLM usage.
if key.startswith("QAAnalysis|||"):
continue
snapshot.llm_prompt_tokens += val.get("prompt_tokens", 0)
snapshot.llm_completion_tokens += val.get("completion_tokens", 0)
snapshot.llm_cached_tokens += val.get(
"cache_read_input_tokens", 0
) + val.get("cache_creation_input_tokens", 0)
parts = key.split("|||")
if len(parts) == 2:
llm_providers.append(parts[0])
llm_models.append(parts[1])
if not snapshot.llm_provider and llm_providers:
snapshot.llm_provider = ",".join(dict.fromkeys(llm_providers))
if not snapshot.llm_model and llm_models:
snapshot.llm_model = ",".join(dict.fromkeys(llm_models))
if snapshot.tts_characters == 0:
tts_providers: list[str] = []
tts_models: list[str] = []
for key, val in usage_info.get("tts", {}).items():
snapshot.tts_characters += val
parts = key.split("|||")
if len(parts) == 2:
tts_providers.append(parts[0])
tts_models.append(parts[1])
if not snapshot.tts_provider and tts_providers:
snapshot.tts_provider = ",".join(dict.fromkeys(tts_providers))
if not snapshot.tts_model and tts_models:
snapshot.tts_model = ",".join(dict.fromkeys(tts_models))
if snapshot.stt_audio_seconds == 0:
stt_providers: list[str] = []
stt_models: list[str] = []
for key, val in usage_info.get("stt", {}).items():
snapshot.stt_audio_seconds += val
parts = key.split("|||")
if len(parts) == 2:
stt_providers.append(parts[0])
stt_models.append(parts[1])
if not snapshot.stt_provider and stt_providers:
snapshot.stt_provider = ",".join(dict.fromkeys(stt_providers))
if not snapshot.stt_model and stt_models:
snapshot.stt_model = ",".join(dict.fromkeys(stt_models))
# Note: if STT audio seconds remain 0 after all fallbacks, we do NOT
# substitute total_duration_seconds — that would overbill wall-clock time
# (silence, hold, agent speech) as STT input.
except Exception as exc:
logger.warning(
"[paygent] Failed to apply usage_info fallback for run {}: {}",
context.workflow_run_id,
exc,
)
try:
config = PaygentDeliveryConfig(
api_key=(node_data.paygent_api_key or "").strip(),
agent_id=snapshot.agent_id,
customer_id=snapshot.customer_id,
)
except Exception as exc:
results[f"paygent_{node_id}"] = {"error": f"invalid_config: {exc}"}
continue
# ---- REST delivery -------------------------------------------------
try:
delivery_result = await deliver(config, snapshot)
results[f"paygent_{node_id}"] = {
**delivery_result,
"agent_id": snapshot.agent_id,
"customer_id": snapshot.customer_id,
"exported_at": datetime.now(UTC).isoformat(),
}
except Exception as exc:
results[f"paygent_{node_id}"] = {"error": str(exc)}
return results

View file

@ -0,0 +1,150 @@
from __future__ import annotations
from pydantic import model_validator
from api.services.integrations.base import IntegrationNodeRegistration
from api.services.workflow.node_data import BaseNodeData
from api.services.workflow.node_specs._base import (
GraphConstraints,
NodeCategory,
NodeExample,
PropertyType,
)
from api.services.workflow.node_specs.model_spec import (
build_spec,
node_spec,
spec_field,
)
@node_spec(
name="paygent",
display_name="Paygent",
description="Cost Tracking and Billing",
llm_hint=(
"Paygent is a post-call usage-tracking and billing integration. "
"It does not participate in the conversation graph and should not be connected to other nodes."
),
docs_url="https://docs.dograh.com/integrations/paygent",
category=NodeCategory.integration,
icon="CreditCard",
examples=[
NodeExample(
name="paygent_tracking",
data={
"name": "Paygent Tracking",
"paygent_enabled": True,
"paygent_api_key": "pg_live_xxxxxxxxxxxxxxxx",
"paygent_agent_id": "my-voice-agent-prod",
"paygent_customer_id": "org-123",
"paygent_indicator": "per-minute-call",
},
)
],
graph_constraints=GraphConstraints(
min_incoming=0, max_incoming=0, min_outgoing=0, max_outgoing=0, max_instances=1
),
property_order=(
"name",
"paygent_enabled",
"paygent_api_key",
"paygent_agent_id",
"paygent_customer_id",
"paygent_indicator",
),
field_overrides={
"name": {
"spec_default": "Paygent",
"description": "Short identifier for this Paygent configuration.",
},
"paygent_enabled": {
"display_name": "Enabled",
"description": "When false, Dograh skips all Paygent tracking for this call.",
},
"paygent_api_key": {
"display_name": "Paygent API Key",
"description": "API key used to authenticate requests to the Paygent REST API.",
"required": True,
},
"paygent_agent_id": {
"display_name": "Agent ID",
"description": "The agent identifier registered in your Paygent account.",
"required": True,
},
"paygent_customer_id": {
"display_name": "Customer ID",
"description": "Your Paygent customer / organisation ID.",
"required": True,
},
"paygent_indicator": {
"display_name": "Indicator",
"description": "The indicator event name sent at the end of the call (e.g. per-minute-call).",
"required": True,
"spec_default": "per-minute-call",
},
},
)
class PaygentNodeData(BaseNodeData):
paygent_enabled: bool = spec_field(
default=True,
ui_type=PropertyType.boolean,
display_name="Enabled",
description="When false, Dograh skips all Paygent tracking for this call.",
)
paygent_api_key: str | None = spec_field(
default=None,
ui_type=PropertyType.string,
display_name="Paygent API Key",
description="API key used to authenticate requests to the Paygent REST API.",
)
paygent_agent_id: str | None = spec_field(
default=None,
ui_type=PropertyType.string,
display_name="Agent ID",
description="The agent identifier registered in your Paygent account.",
)
paygent_customer_id: str | None = spec_field(
default=None,
ui_type=PropertyType.string,
display_name="Customer ID",
description="Your Paygent customer / organisation ID.",
)
paygent_indicator: str = spec_field(
default="per-minute-call",
ui_type=PropertyType.string,
display_name="Indicator",
description="The indicator event name sent at the end of the call (e.g. per-minute-call).",
)
@model_validator(mode="after")
def _validate_enabled_config(self) -> "PaygentNodeData":
if not self.paygent_enabled:
return self
missing: list[str] = []
if not self.paygent_api_key or not self.paygent_api_key.strip():
missing.append("paygent_api_key")
if not self.paygent_agent_id or not self.paygent_agent_id.strip():
missing.append("paygent_agent_id")
if not self.paygent_customer_id or not self.paygent_customer_id.strip():
missing.append("paygent_customer_id")
if not self.paygent_indicator or not self.paygent_indicator.strip():
missing.append("paygent_indicator")
if missing:
fields = ", ".join(missing)
raise ValueError(
f"Paygent node is enabled but missing required fields: {fields}"
)
return self
SPEC = build_spec(PaygentNodeData)
NODE = IntegrationNodeRegistration(
type_name="paygent",
data_model=PaygentNodeData,
node_spec=SPEC,
sensitive_fields=("paygent_api_key",),
)

View file

@ -0,0 +1,139 @@
"""Paygent runtime session.
Wires the ``PaygentCollector`` into the live pipecat pipeline exactly the way
``TunerRuntimeSession`` wires ``TunerCollector``.
Lifecycle:
1. ``create_runtime_sessions`` scans the workflow graph for an enabled
``paygent`` node and, if found, builds a collector from context metadata.
2. ``attach`` hooks the collector into the task as a pipeline observer so it
receives all ``MetricsFrame`` events during the call.
3. ``on_call_finished`` seals the snapshot and returns it to the generic
integration framework, which persists it in ``workflow_run.logs`` under
the key ``"paygent_snapshot"``.
"""
from __future__ import annotations
from typing import Any
from api.services.integrations.base import (
IntegrationRuntimeContext,
IntegrationRuntimeSession,
)
from .collector import PaygentCollector
def _label(provider: str | None, model: str | None) -> str:
"""Compose a human-readable ``provider/model`` label."""
if provider and model:
return f"{provider}/{model}"
return model or provider or ""
def _resolve_model_labels(
context: IntegrationRuntimeContext,
) -> tuple[str, str, str, str, str, str, str, str]:
"""Return (stt_provider, stt_model, llm_provider, llm_model,
tts_provider, tts_model, sts_provider, sts_model).
Mirrors the logic in ``tuner/runtime.py:_resolve_model_labels``.
"""
user_config = context.user_config
if context.is_realtime and user_config.realtime:
realtime_provider = getattr(user_config.realtime, "provider", "") or ""
realtime_model = getattr(user_config.realtime, "model", "") or ""
llm_provider = getattr(user_config.llm, "provider", "") or ""
llm_model = getattr(user_config.llm, "model", "") or ""
return (
"", # stt_provider (no separate STT in realtime)
"", # stt_model
llm_provider,
llm_model,
"", # tts_provider (no separate TTS in realtime)
"", # tts_model
realtime_provider,
realtime_model,
)
return (
getattr(user_config.stt, "provider", "") or "",
getattr(user_config.stt, "model", "") or "",
getattr(user_config.llm, "provider", "") or "",
getattr(user_config.llm, "model", "") or "",
getattr(user_config.tts, "provider", "") or "",
getattr(user_config.tts, "model", "") or "",
"", # sts_provider
"", # sts_model
)
class PaygentRuntimeSession(IntegrationRuntimeSession):
"""Thin wrapper that connects the collector to the pipeline task."""
name = "paygent"
def __init__(self, collector: PaygentCollector) -> None:
self._collector = collector
# --- IntegrationRuntimeSession protocol --------------------------------
def attach(self, task: Any) -> None:
"""Register the collector as a pipeline observer."""
task.add_observer(self._collector)
async def on_call_finished(
self,
*,
gathered_context: dict[str, Any],
) -> dict[str, Any] | None:
"""Seal the snapshot and hand it to the framework for persistence."""
self._collector.set_call_disposition(gathered_context.get("call_disposition"))
snapshot = self._collector.build_snapshot()
return {"paygent_snapshot": snapshot}
# ---------------------------------------------------------------------------
# Runtime session factory (called by the generic integration framework)
# ---------------------------------------------------------------------------
def create_runtime_sessions(
context: IntegrationRuntimeContext,
) -> list[IntegrationRuntimeSession]:
"""Return a ``PaygentRuntimeSession`` if a live, enabled paygent node exists."""
paygent_nodes = [
node
for node in context.workflow_graph.nodes.values()
if node.node_type == "paygent" and getattr(node.data, "paygent_enabled", True)
]
if not paygent_nodes:
return []
(
stt_provider,
stt_model,
llm_provider,
llm_model,
tts_provider,
tts_model,
sts_provider,
sts_model,
) = _resolve_model_labels(context)
collector = PaygentCollector(
workflow_run_id=context.workflow_run_id,
is_realtime=context.is_realtime,
stt_provider=stt_provider,
stt_model=stt_model,
llm_provider=llm_provider,
llm_model=llm_model,
tts_provider=tts_provider,
tts_model=tts_model,
sts_provider=sts_provider,
sts_model=sts_model,
)
return [PaygentRuntimeSession(collector)]

View file

@ -2,6 +2,8 @@ from __future__ import annotations
from typing import Any
from loguru import logger
from api.services.integrations.base import (
IntegrationCompletionContext,
IntegrationNodeRegistration,
@ -122,7 +124,17 @@ async def run_completion_handlers(
for package, nodes in iter_completion_packages(context.workflow_definition):
if package.run_completion is None:
continue
package_result = await package.run_completion(nodes, context)
try:
package_result = await package.run_completion(nodes, context)
except Exception as exc:
logger.exception(
f"Integration completion handler failed for package "
f"{package.name!r}: {exc}"
)
results[f"integration_{package.name}"] = {
"error": "completion_handler_failed"
}
continue
if package_result:
results.update(package_result)
return results

View file

@ -1,48 +1,21 @@
from __future__ import annotations
import time
from collections import deque
from dataclasses import dataclass
from typing import Any, Callable
from typing import Any
from loguru import logger
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
CancelFrame,
EndFrame,
FunctionCallInProgressFrame,
FunctionCallResultFrame,
MetricsFrame,
StartFrame,
UserStartedSpeakingFrame,
UserStoppedSpeakingFrame,
VADUserStoppedSpeakingFrame,
)
from pipecat.observers.base_observer import BaseObserver, FramePushed
from pipecat.observers.turn_tracking_observer import TurnTrackingObserver
from pipecat.observers.user_bot_latency_observer import UserBotLatencyObserver
from pipecat.processors.frame_processor import FrameDirection
from pipecat.utils.context.message_sanitization import strip_thought_ids_from_messages
from tuner_pipecat_sdk.accumulator import CallAccumulator
from tuner_pipecat_sdk.payload_builder import build_payload
from tuner_pipecat_sdk import Observer
from api.enums import WorkflowRunMode
TUNER_RECORDING_PLACEHOLDER = "pipecat://no-recording"
@dataclass(frozen=True)
class _PayloadConfig:
call_id: str
call_type: str
recording_url: str
asr_model: str
llm_model: str
tts_model: str
sip_call_id: str | None = None
sip_headers: dict[str, str] | None = None
agent_version: int | None = None
# Placeholder credentials for the SDK Observer's TunerConfig. Real BYOK credentials
# (api_key / workspace_id / agent_id) are per tuner node and are applied later during
# the deferred delivery phase (completion.py), so they are not known here. TunerConfig
# validators require a non-empty api_key/agent_id and a positive workspace_id, hence
# these placeholders.
_DEFERRED_API_KEY = "deferred"
_DEFERRED_WORKSPACE_ID = 1
_DEFERRED_AGENT_ID = "deferred"
def mode_to_tuner_call_type(mode: str | None) -> str:
@ -54,8 +27,15 @@ def mode_to_tuner_call_type(mode: str | None) -> str:
return "phone_call"
class TunerCollector(BaseObserver):
"""Collect runtime call metadata and build a deferred Tuner payload."""
class DeferredTunerObserver(Observer):
"""SDK ``Observer`` that builds the Tuner payload from the live frame stream but
defers delivery to the completion phase instead of POSTing on call end.
The SDK ``Observer`` normally fire-and-forgets ``post_call`` when the call ends.
Dograh instead snapshots the payload into ``workflow_run.logs`` and delivers it
later (``completion.py``) once per tuner node with that node's BYOK credentials,
after injecting the real ``recording_url`` and a locally-computed ``call_cost``.
"""
def __init__(
self,
@ -66,126 +46,33 @@ class TunerCollector(BaseObserver):
llm_model: str = "",
tts_model: str = "",
agent_version: int | None = None,
max_frames: int = 500,
) -> None:
super().__init__()
self._call_id = str(workflow_run_id)
self._call_type = call_type
self._asr_model = asr_model
self._llm_model = llm_model
self._tts_model = tts_model
self._agent_version = agent_version
self._acc = CallAccumulator()
self._acc.call_start_abs_ns = time.time_ns()
self._pipeline_start_rel_ns: int | None = None
self._context_provider: Callable[[], list[dict[str, Any]]] | None = None
self._processed_frames: set[int] = set()
self._frame_history: deque[int] = deque(maxlen=max_frames)
super().__init__(
api_key=_DEFERRED_API_KEY,
workspace_id=_DEFERRED_WORKSPACE_ID,
agent_id=_DEFERRED_AGENT_ID,
call_id=str(workflow_run_id),
call_type=call_type,
recording_url=TUNER_RECORDING_PLACEHOLDER,
asr_model=asr_model,
llm_model=llm_model,
tts_model=tts_model,
agent_version=agent_version,
)
def attach_context(self, provider: Callable[[], list[dict[str, Any]]]) -> None:
self._context_provider = provider
async def _flush(self) -> None:
# Suppress the SDK's runtime post_call; delivery is deferred (see class docstring).
return None
def set_disconnection_reason(self, reason: str | None) -> None:
if reason:
self._acc.set_disconnection_reason(reason)
def attach_turn_tracking_observer(
self, turn_tracker: TurnTrackingObserver | None
) -> None:
if turn_tracker is None:
return
@turn_tracker.event_handler("on_turn_started")
async def _on_turn_started(_tracker: Any, turn_number: int) -> None:
self._acc.on_turn_started(turn_number, time.time_ns())
@turn_tracker.event_handler("on_turn_ended")
async def _on_turn_ended(
_tracker: Any, turn_number: int, _duration: float, was_interrupted: bool
) -> None:
self._acc.on_turn_ended(turn_number, was_interrupted)
def attach_latency_observer(
self, latency_observer: UserBotLatencyObserver | None
) -> None:
if latency_observer is None:
return
@latency_observer.event_handler("on_latency_measured")
async def _on_latency_measured(_observer: Any, latency: float) -> None:
self._acc.on_latency_measured(latency)
@latency_observer.event_handler("on_latency_breakdown")
async def _on_latency_breakdown(_observer: Any, breakdown: Any) -> None:
self._acc.on_latency_breakdown(breakdown)
async def on_push_frame(self, data: FramePushed):
if data.direction != FrameDirection.DOWNSTREAM:
return
if data.frame.id in self._processed_frames:
return
self._processed_frames.add(data.frame.id)
self._frame_history.append(data.frame.id)
if len(self._processed_frames) > len(self._frame_history):
self._processed_frames = set(self._frame_history)
frame = data.frame
# data.timestamp is a pipeline-relative clock (ns since pipeline start).
# Convert to absolute ns so the accumulator's _rel_ms() works correctly.
if self._pipeline_start_rel_ns is None:
self._pipeline_start_rel_ns = data.timestamp
timestamp_ns = self._acc.call_start_abs_ns + (
data.timestamp - self._pipeline_start_rel_ns
)
if isinstance(frame, StartFrame):
self._acc.on_start(timestamp_ns)
elif isinstance(frame, FunctionCallInProgressFrame):
self._acc.on_function_call_in_progress(frame, timestamp_ns)
elif isinstance(frame, FunctionCallResultFrame):
self._acc.on_function_call_result(frame.tool_call_id, timestamp_ns)
elif isinstance(frame, MetricsFrame):
self._acc.on_metrics_frame(frame)
elif isinstance(frame, UserStartedSpeakingFrame):
self._acc.on_user_started_speaking(timestamp_ns)
elif isinstance(frame, UserStoppedSpeakingFrame):
self._acc.on_user_stopped_speaking(timestamp_ns)
self._acc.on_user_turn_stopped(timestamp_ns)
elif isinstance(frame, BotStartedSpeakingFrame):
self._acc.on_bot_started_speaking(timestamp_ns)
elif isinstance(frame, BotStoppedSpeakingFrame):
self._acc.on_bot_stopped(timestamp_ns)
elif isinstance(frame, VADUserStoppedSpeakingFrame):
self._acc.on_vad_stopped(timestamp_ns)
elif isinstance(frame, (CancelFrame, EndFrame)):
self._acc.on_call_end(timestamp_ns)
def build_payload_snapshot(
self,
*,
recording_url: str = TUNER_RECORDING_PLACEHOLDER,
) -> dict[str, Any] | None:
if self._context_provider is None:
logger.warning(
"[tuner] no context provider attached; skipping payload snapshot"
)
return None
transcript = strip_thought_ids_from_messages(list(self._context_provider()))
payload = build_payload(
self._acc,
_PayloadConfig(
call_id=self._call_id,
call_type=self._call_type,
recording_url=recording_url,
asr_model=self._asr_model,
llm_model=self._llm_model,
tts_model=self._tts_model,
agent_version=self._agent_version,
),
transcript,
)
self._config.recording_url = recording_url
payload = self._acc.build_payload(self._config, None)
return payload.to_dict()

View file

@ -11,6 +11,7 @@ from api.services.integrations.base import IntegrationCompletionContext
from .client import TunerDeliveryConfig, post_call
from .collector import TUNER_RECORDING_PLACEHOLDER
from .cost import compute_call_cost_cents
from .node import TunerNodeData
@ -55,6 +56,14 @@ async def run_completion(
payload = copy.deepcopy(payload_snapshot)
payload["recording_url"] = recording_url
call_cost = compute_call_cost_cents(
tuner_data,
context.workflow_run.usage_info,
transcript_segments=payload.get("transcript_with_tool_calls"),
)
if call_cost is not None:
payload["call_cost"] = call_cost
try:
config = TunerDeliveryConfig(
base_url=TUNER_BASE_URL,
@ -67,6 +76,7 @@ async def run_completion(
**delivery,
"workspace_id": tuner_data.tuner_workspace_id,
"agent_id": tuner_data.tuner_agent_id,
**({"call_cost": call_cost} if call_cost is not None else {}),
"exported_at": datetime.now(UTC).isoformat(),
}
except Exception as exc:

View file

@ -0,0 +1,131 @@
"""Per-call cost computation for the Tuner export.
Dograh no longer rates calls locally, so when a user wants Tuner to show a
cost they provide their own per-unit prices on the Tuner node (the "bring your
own keys" model). This module turns those rates plus the call's measured usage
(`workflow_run.usage_info`) into a single `call_cost` value in cents, which is
what Tuner's public API stores.
Rates are optional: a blank rate contributes nothing. Usage metrics come from
the pipeline aggregator and are reliable for LLM tokens and TTS characters.
STT seconds are not measured, so the STT and telephony rates are applied
per-minute against the call's wall-clock duration (an approximation).
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .node import TunerNodeData
def _sum_llm_tokens(usage_info: dict[str, Any]) -> tuple[int, int, int]:
"""Sum prompt, completion, and cached-input tokens across all llm entries.
Cached-input tokens (``cache_read_input_tokens``) are reported as a discounted
subset of ``prompt_tokens`` (OpenAI convention), not in addition to it.
"""
prompt_tokens = 0
completion_tokens = 0
cached_tokens = 0
for entry in (usage_info.get("llm") or {}).values():
if isinstance(entry, dict):
prompt_tokens += entry.get("prompt_tokens") or 0
completion_tokens += entry.get("completion_tokens") or 0
cached_tokens += entry.get("cache_read_input_tokens") or 0
return prompt_tokens, completion_tokens, cached_tokens
def _sum_tts_characters(usage_info: dict[str, Any]) -> int:
"""Sum TTS characters across every tts processor/model entry."""
total = 0
for value in (usage_info.get("tts") or {}).values():
if isinstance(value, (int, float)):
total += value
return int(total)
# Transcript roles that represent bot-spoken text sent to TTS. Excludes
# "user" (STT input) and "agent_function"/"agent_result" (tool calls).
_SPOKEN_ROLES = {"agent", "assistant", "bot"}
def _count_transcript_tts_characters(
transcript_segments: list[dict[str, Any]] | None,
) -> int:
"""Count characters of bot-spoken transcript turns (TTS proxy).
Used when the pipeline did not measure TTS characters directly (e.g. the
Deepgram websocket TTS service does not emit usage metrics). The spoken
transcript text closely matches what was sent to the TTS engine.
"""
if not transcript_segments:
return 0
total = 0
for segment in transcript_segments:
if isinstance(segment, dict) and segment.get("role") in _SPOKEN_ROLES:
total += len(segment.get("text") or "")
return total
def compute_call_cost_cents(
tuner_data: "TunerNodeData",
usage_info: dict[str, Any] | None,
transcript_segments: list[dict[str, Any]] | None = None,
) -> float | None:
"""Compute the call cost in cents from node rates and measured usage.
Returns ``None`` when cost calculation is disabled or no rates are
configured, so the caller can omit ``call_cost`` from the payload entirely
rather than report a misleading zero.
"""
if not tuner_data.cost_calculation_enabled:
return None
raw_rates = (
tuner_data.cost_llm_input_rate,
tuner_data.cost_llm_cached_input_rate,
tuner_data.cost_llm_output_rate,
tuner_data.cost_tts_rate,
tuner_data.cost_stt_rate,
tuner_data.cost_telephony_rate,
)
if all(rate is None for rate in raw_rates):
return None
usage_info = usage_info or {}
prompt_tokens, completion_tokens, cached_tokens = _sum_llm_tokens(usage_info)
# Prefer the pipeline-measured TTS characters; fall back to the spoken
# transcript when the TTS service did not report usage (e.g. Deepgram websocket).
tts_characters = _sum_tts_characters(usage_info)
if tts_characters == 0:
tts_characters = _count_transcript_tts_characters(transcript_segments)
duration_minutes = (usage_info.get("call_duration_seconds") or 0) / 60.0
llm_input_rate = tuner_data.cost_llm_input_rate or 0.0
cached_input_rate = tuner_data.cost_llm_cached_input_rate
llm_output_rate = tuner_data.cost_llm_output_rate or 0.0
tts_rate = tuner_data.cost_tts_rate or 0.0
stt_rate = tuner_data.cost_stt_rate or 0.0
telephony_rate = tuner_data.cost_telephony_rate or 0.0
# Cached tokens are a discounted subset of prompt tokens. Only split them out
# when a cached rate is configured; otherwise bill all prompt tokens normally.
if cached_input_rate is not None:
uncached_prompt_tokens = max(prompt_tokens - cached_tokens, 0)
llm_input_usd = (
uncached_prompt_tokens * llm_input_rate + cached_tokens * cached_input_rate
) / 1_000_000
else:
llm_input_usd = prompt_tokens * llm_input_rate / 1_000_000
cost_usd = (
llm_input_usd
+ completion_tokens * llm_output_rate / 1_000_000
+ tts_characters * tts_rate / 1_000
+ duration_minutes * stt_rate
+ duration_minutes * telephony_rate
)
return round(cost_usd * 100, 4)

View file

@ -5,9 +5,13 @@ from pydantic import model_validator
from api.services.integrations.base import IntegrationNodeRegistration
from api.services.workflow.node_data import BaseNodeData
from api.services.workflow.node_specs._base import (
DisplayOptions,
GraphConstraints,
NodeCategory,
NodeExample,
NumberInputOptions,
PropertyLayoutOptions,
PropertyRendererOptions,
PropertyType,
)
from api.services.workflow.node_specs.model_spec import (
@ -16,6 +20,13 @@ from api.services.workflow.node_specs.model_spec import (
spec_field,
)
# Cost rate fields are only shown once the user turns on cost calculation.
_COST_FIELDS_VISIBLE = DisplayOptions(show={"cost_calculation_enabled": [True]})
_COST_RATE_RENDERER_OPTIONS = PropertyRendererOptions(
layout=PropertyLayoutOptions(column_span=6),
number_input=NumberInputOptions(fractional=True),
)
@node_spec(
name="tuner",
@ -25,6 +36,7 @@ from api.services.workflow.node_specs.model_spec import (
"Tuner is a post-call observability export. It does not participate in the "
"conversation graph and should not be connected to other nodes."
),
docs_url="https://docs.dograh.com/integrations/tuner",
category=NodeCategory.integration,
icon="Activity",
examples=[
@ -48,6 +60,13 @@ from api.services.workflow.node_specs.model_spec import (
"tuner_agent_id",
"tuner_workspace_id",
"tuner_api_key",
"cost_calculation_enabled",
"cost_llm_input_rate",
"cost_llm_cached_input_rate",
"cost_llm_output_rate",
"cost_tts_rate",
"cost_stt_rate",
"cost_telephony_rate",
),
field_overrides={
"name": {
@ -103,6 +122,73 @@ class TunerNodeData(BaseNodeData):
description="Bearer token used when posting completed calls to Tuner.",
)
cost_calculation_enabled: bool = spec_field(
default=False,
ui_type=PropertyType.boolean,
display_name="Calculate cost",
description="Send a per-call cost to Tuner, computed from your own provider rates (BYOK). All rates below are optional.",
)
cost_llm_input_rate: float | None = spec_field(
default=None,
ge=0,
le=1000,
ui_type=PropertyType.number,
display_name="LLM input",
description="USD per 1M tokens",
display_options=_COST_FIELDS_VISIBLE,
renderer_options=_COST_RATE_RENDERER_OPTIONS,
)
cost_llm_cached_input_rate: float | None = spec_field(
default=None,
ge=0,
le=1000,
ui_type=PropertyType.number,
display_name="LLM cached input",
description="USD per 1M cached tokens",
display_options=_COST_FIELDS_VISIBLE,
renderer_options=_COST_RATE_RENDERER_OPTIONS,
)
cost_llm_output_rate: float | None = spec_field(
default=None,
ge=0,
le=1000,
ui_type=PropertyType.number,
display_name="LLM output",
description="USD per 1M tokens",
display_options=_COST_FIELDS_VISIBLE,
renderer_options=_COST_RATE_RENDERER_OPTIONS,
)
cost_tts_rate: float | None = spec_field(
default=None,
ge=0,
le=100,
ui_type=PropertyType.number,
display_name="TTS",
description="USD per 1K characters",
display_options=_COST_FIELDS_VISIBLE,
renderer_options=_COST_RATE_RENDERER_OPTIONS,
)
cost_stt_rate: float | None = spec_field(
default=None,
ge=0,
le=100,
ui_type=PropertyType.number,
display_name="STT",
description="USD per minute",
display_options=_COST_FIELDS_VISIBLE,
renderer_options=_COST_RATE_RENDERER_OPTIONS,
)
cost_telephony_rate: float | None = spec_field(
default=None,
ge=0,
le=100,
ui_type=PropertyType.number,
display_name="Telephony",
description="USD per minute",
display_options=_COST_FIELDS_VISIBLE,
renderer_options=_COST_RATE_RENDERER_OPTIONS,
)
@model_validator(mode="after")
def _validate_enabled_config(self):
if not self.tuner_enabled:

View file

@ -8,7 +8,7 @@ from api.services.integrations.base import (
IntegrationRuntimeSession,
)
from .collector import TunerCollector, mode_to_tuner_call_type
from .collector import DeferredTunerObserver, mode_to_tuner_call_type
def _format_model_label(provider: str | None, model: str | None) -> str:
@ -53,23 +53,25 @@ def _resolve_model_labels(context: IntegrationRuntimeContext) -> tuple[str, str,
class TunerRuntimeSession(IntegrationRuntimeSession):
name = "tuner"
def __init__(self, collector: TunerCollector) -> None:
self._collector = collector
def __init__(self, observer: DeferredTunerObserver) -> None:
self._observer = observer
def attach(self, task: Any) -> None:
self._collector.attach_turn_tracking_observer(task.turn_tracking_observer)
self._collector.attach_latency_observer(task.user_bot_latency_observer)
task.add_observer(self._collector)
self._observer.attach_turn_tracking_observer(task.turn_tracking_observer)
task.add_observer(self._observer)
# The SDK Observer wires latency into the accumulator via its own latency
# observer, which must itself be registered to receive frames.
task.add_observer(self._observer.latency_observer)
async def on_call_finished(
self,
*,
gathered_context: dict[str, Any],
) -> dict[str, Any] | None:
self._collector.set_disconnection_reason(
self._observer.set_disconnection_reason(
gathered_context.get("call_disposition")
)
payload = self._collector.build_payload_snapshot()
payload = self._observer.build_payload_snapshot()
if payload is None:
return None
return {"tuner_payload": payload}
@ -88,7 +90,7 @@ def create_runtime_sessions(
asr_model, llm_model, tts_model = _resolve_model_labels(context)
collector = TunerCollector(
observer = DeferredTunerObserver(
workflow_run_id=context.workflow_run_id,
call_type=mode_to_tuner_call_type(context.workflow_run.mode),
asr_model=asr_model,
@ -96,6 +98,5 @@ def create_runtime_sessions(
tts_model=tts_model,
agent_version=getattr(context.run_definition, "version_number", None),
)
collector.attach_context(context.context_messages_provider)
return [TunerRuntimeSession(collector)]
return [TunerRuntimeSession(observer)]

View file

@ -241,19 +241,12 @@ class MPSServiceKeyClient:
)
return False
async def check_service_key_usage(
self,
service_key: str,
organization_id: Optional[int] = None,
created_by: Optional[str] = None,
) -> dict:
async def check_service_key_usage(self, service_key: str) -> dict:
"""
Check the usage and quota of a service key.
Args:
service_key: The service key to check usage for
organization_id: Organization ID (for authenticated mode)
created_by: User provider ID (for OSS mode)
Returns:
Dictionary containing:
@ -321,39 +314,6 @@ class MPSServiceKeyClient:
response=response,
)
async def get_usage_by_organization(self, organization_id: int) -> dict:
"""
Get aggregated usage for all service keys belonging to an organization (hosted mode).
Args:
organization_id: The organization's ID
Returns:
Dictionary containing total_credits_used and remaining_credits
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.post(
f"{self.base_url}/api/v1/service-keys/usage/organization",
json={"organization_id": organization_id},
headers=self._get_headers(organization_id=organization_id),
)
if response.status_code == 200:
data = response.json()
return {
"total_credits_used": data.get("total_credits_used", 0.0),
"remaining_credits": data.get("remaining_credits", 0.0),
}
else:
logger.error(
f"Failed to get usage by organization: {response.status_code} - {response.text}"
)
raise httpx.HTTPStatusError(
f"Failed to get usage by organization: {response.text}",
request=response.request,
response=response,
)
async def create_credit_purchase_url(
self,
organization_id: int,
@ -422,30 +382,26 @@ class MPSServiceKeyClient:
response=response,
)
async def get_billing_account_status(
self,
organization_id: int,
created_by: Optional[str] = None,
) -> Optional[dict]:
"""Get an existing MPS v2 billing account without creating one."""
async def get_billing_pricing(self, organization_id: int) -> dict:
"""Return MPS-owned effective platform and Dograh model prices for an org."""
if DEPLOYMENT_MODE == "oss":
raise ValueError("OSS deployments do not fetch hosted billing prices")
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.base_url}/api/v1/billing/accounts/{organization_id}/status",
headers=self._get_headers(
organization_id=organization_id,
created_by=created_by,
),
f"{self.base_url}/api/v1/billing/accounts/{organization_id}/pricing",
headers=self._get_headers(organization_id=organization_id),
)
if response.status_code == 200:
return response.json()
logger.error(
"Failed to get MPS billing account status: "
"Failed to get MPS billing pricing: "
f"{response.status_code} - {response.text}"
)
raise httpx.HTTPStatusError(
f"Failed to get MPS billing account status: {response.text}",
f"Failed to get MPS billing pricing: {response.text}",
request=response.request,
response=response,
)

View file

@ -31,7 +31,6 @@ async def get_organization_context(user: UserModel) -> OrganizationContextRespon
)
resolved = await get_resolved_ai_model_configuration(
user_id=user.id,
organization_id=organization_id,
)
managed_service_version = resolved.effective.managed_service_version

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

@ -14,8 +14,10 @@ from api.services.pipecat.in_memory_buffers import (
)
from api.services.pipecat.pipeline_metrics_aggregator import PipelineMetricsAggregator
from api.services.pipecat.tracing_config import get_trace_url
from api.services.pipecat.transcript_log_coordinator import TranscriptLogCoordinator
from api.services.posthog_client import capture_event
from api.services.workflow.pipecat_engine import PipecatEngine
from api.services.workflow_run_artifacts import upload_workflow_run_artifacts
from api.tasks.arq import enqueue_job
from api.tasks.function_names import FunctionNames
from pipecat.frames.frames import (
@ -64,11 +66,13 @@ def register_event_handlers(
engine: PipecatEngine,
audio_buffer: AudioBufferProcessor,
in_memory_logs_buffer: InMemoryLogsBuffer,
transcript_log_coordinator: TranscriptLogCoordinator,
pipeline_metrics_aggregator: PipelineMetricsAggregator,
audio_config=AudioConfig,
pre_call_fetch_task: asyncio.Task | None = None,
user_provider_id: str | None = None,
integration_runtime_sessions: list[IntegrationRuntimeSession] | None = None,
include_transcript_end_timestamps: bool = False,
):
"""Register all event handlers for transport and task events.
@ -221,7 +225,12 @@ def register_event_handlers(
task: PipelineWorker,
_frame: Frame,
):
logger.debug(f"In on_pipeline_finished callback handler")
logger.debug("In on_pipeline_finished callback handler")
# Turn and feedback observers run on independent queues. Drain them
# before finalizing immutable transcripts and taking the DB snapshot.
await task.wait_for_observers()
await transcript_log_coordinator.flush()
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
@ -361,50 +370,51 @@ def register_event_handlers(
except Exception as e:
logger.error(f"Error saving workflow run logs: {e}", exc_info=True)
# Write buffers to temp files and enqueue combined processing task
audio_temp_path = None
user_audio_temp_path = None
bot_audio_temp_path = None
transcript_temp_path = None
# Upload artifacts straight from the in-memory buffers so nothing has
# to cross a process/host boundary via temp files. Must complete
# before the completion job is enqueued so QA and webhooks see the
# artifacts in storage.
try:
mixed_audio_wav = None
user_audio_wav = None
bot_audio_wav = None
if not in_memory_audio_buffers.mixed.is_empty:
audio_temp_path = (
await in_memory_audio_buffers.mixed.write_to_temp_file()
)
mixed_audio_wav = await in_memory_audio_buffers.mixed.to_wav_bytes()
else:
logger.debug("Audio buffer is empty, skipping upload")
if not in_memory_audio_buffers.user.is_empty:
user_audio_temp_path = (
await in_memory_audio_buffers.user.write_to_temp_file()
)
user_audio_wav = await in_memory_audio_buffers.user.to_wav_bytes()
else:
logger.debug("User audio buffer is empty, skipping upload")
if not in_memory_audio_buffers.bot.is_empty:
bot_audio_temp_path = (
await in_memory_audio_buffers.bot.write_to_temp_file()
)
bot_audio_wav = await in_memory_audio_buffers.bot.to_wav_bytes()
else:
logger.debug("Bot audio buffer is empty, skipping upload")
transcript_temp_path = in_memory_logs_buffer.write_transcript_to_temp_file()
if not transcript_temp_path:
transcript_text = in_memory_logs_buffer.generate_transcript_text(
include_end_timestamps=include_transcript_end_timestamps
)
if not transcript_text:
logger.debug("No transcript events in logs buffer, skipping upload")
await upload_workflow_run_artifacts(
workflow_run_id,
mixed_audio_wav=mixed_audio_wav,
user_audio_wav=user_audio_wav,
bot_audio_wav=bot_audio_wav,
transcript_text=transcript_text,
)
except Exception as e:
logger.error(f"Error preparing buffers for S3 upload: {e}", exc_info=True)
logger.error(f"Error uploading call artifacts: {e}", exc_info=True)
# Combined task: uploads artifacts, runs integrations (including QA),
# then calculates cost (so QA token usage is captured in usage_info)
# Combined task: runs integrations (including QA), then calculates
# cost (so QA token usage is captured in usage_info)
await enqueue_job(
FunctionNames.PROCESS_WORKFLOW_COMPLETION,
workflow_run_id,
audio_temp_path,
transcript_temp_path,
user_audio_temp_path,
bot_audio_temp_path,
)
# Return the buffer so it can be passed to other handlers

View file

@ -0,0 +1,39 @@
"""Dograh-specific Gemini adapter customizations."""
from typing import Any
from pipecat.adapters.schemas.tools_schema import AdapterType, ToolsSchema
from pipecat.adapters.services.gemini_adapter import GeminiLLMAdapter
class DograhGeminiJSONSchemaAdapter(GeminiLLMAdapter):
"""Use Gemini's full JSON Schema tool parameter field.
Pipecat's default Gemini adapter maps ``FunctionSchema.parameters`` into
``FunctionDeclaration.parameters``, which is backed by Google GenAI's
stricter OpenAPI-style ``Schema`` model. MCP and imported tools may contain
valid JSON Schema keywords such as ``const`` and ``not`` that are rejected
by that model. ``parameters_json_schema`` is the Google GenAI field intended
for full JSON Schema payloads.
"""
def to_provider_tools_format(
self, tools_schema: ToolsSchema
) -> list[dict[str, Any]]:
functions_schema = tools_schema.standard_tools
if functions_schema:
formatted_functions = []
for func in functions_schema:
func_dict = func.to_default_dict()
parameters = func_dict.pop("parameters")
func_dict["parameters_json_schema"] = parameters
formatted_functions.append(func_dict)
formatted_standard_tools = [{"function_declarations": formatted_functions}]
else:
formatted_standard_tools = []
custom_gemini_tools = []
if tools_schema.custom_tools:
custom_gemini_tools = tools_schema.custom_tools.get(AdapterType.GEMINI, [])
return formatted_standard_tools + custom_gemini_tools

View file

@ -1,6 +1,7 @@
import asyncio
import tempfile
import io
import wave
from copy import deepcopy
from datetime import UTC, datetime
from typing import List, Optional
@ -15,7 +16,7 @@ from pipecat.utils.enums import RealtimeFeedbackType
class InMemoryAudioBuffer:
"""Buffer audio data in memory during a call, then write to temp file on disconnect."""
"""Buffer audio data in memory during a call, then encode to WAV bytes on disconnect."""
def __init__(self, workflow_run_id: int, sample_rate: int, num_channels: int = 1):
self._workflow_run_id = workflow_run_id
@ -41,28 +42,30 @@ class InMemoryAudioBuffer:
f"Appended {len(pcm_data)} bytes to audio buffer. Total size: {self._total_size}"
)
async def write_to_temp_file(self) -> str:
"""Write audio data to a temporary WAV file and return the path."""
async def to_wav_bytes(self) -> bytes:
"""Encode the buffered PCM data as an in-memory WAV file."""
async with self._lock:
temp_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
logger.debug(
f"Writing audio buffer to temp file {temp_file.name} for workflow {self._workflow_run_id}"
)
chunks = list(self._chunks)
# Write WAV header and PCM data
with wave.open(temp_file.name, "wb") as wf:
def _encode() -> bytes:
wav_io = io.BytesIO()
with wave.open(wav_io, "wb") as wf:
wf.setnchannels(self._num_channels)
wf.setsampwidth(2) # 16-bit audio
wf.setframerate(self._sample_rate)
# Concatenate all chunks
for chunk in self._chunks:
for chunk in chunks:
wf.writeframes(chunk)
return wav_io.getvalue()
logger.info(
f"Successfully wrote {self._total_size} bytes of audio to {temp_file.name}"
)
return temp_file.name
# Encoding is mostly memcpy but can touch ~100MB; keep it off the event loop
data = await asyncio.to_thread(_encode)
logger.info(
f"Encoded {self._total_size} bytes of audio to {len(data)} WAV bytes "
f"for workflow {self._workflow_run_id}"
)
return data
@property
def is_empty(self) -> bool:
@ -102,7 +105,7 @@ class InMemoryLogsBuffer:
def __init__(self, workflow_run_id: int):
self._workflow_run_id = workflow_run_id
self._events: List[dict] = []
self._turn_counter = 0
self._current_turn: Optional[int] = None
self._current_node_id: Optional[str] = None
self._current_node_name: Optional[str] = None
@ -121,36 +124,44 @@ class InMemoryLogsBuffer:
"""Get the current node name."""
return self._current_node_name
async def append(self, event: dict):
"""Append a feedback event to the buffer with timestamp and current node."""
def set_current_turn(self, turn: int) -> None:
"""Set the fallback turn for non-transcript events."""
self._current_turn = turn
async def append(
self,
event: dict,
*,
timestamp: Optional[str] = None,
turn: Optional[int] = None,
node_id: Optional[str] = None,
node_name: Optional[str] = None,
use_current_node: bool = True,
):
"""Append an immutable event with optional correlation metadata."""
if use_current_node:
node_id = self._current_node_id if node_id is None else node_id
node_name = self._current_node_name if node_name is None else node_name
timestamped_event = stamp_realtime_feedback_event(
event,
timestamp=datetime.now(UTC).isoformat(),
turn=self._turn_counter,
node_id=self._current_node_id,
node_name=self._current_node_name,
deepcopy(event),
timestamp=timestamp or datetime.now(UTC).isoformat(timespec="milliseconds"),
turn=self._current_turn if turn is None else turn,
node_id=node_id,
node_name=node_name,
)
self._events.append(timestamped_event)
logger.trace(
f"Appended event {event.get('type')} to logs buffer for workflow {self._workflow_run_id}"
)
def increment_turn(self):
"""Increment turn counter (called on user transcription completion)."""
self._turn_counter += 1
logger.trace(
f"Incremented turn counter to {self._turn_counter} for workflow {self._workflow_run_id}"
)
def _sorted_events(self) -> List[dict]:
# Stable sort by the realtime (payload) timestamp when available, falling
# back to the buffer-append timestamp. Python's sort is stable, so events
# sharing a key retain their original insertion order — this keeps
# consecutive bot-text chunks of a single turn contiguous.
# Stable sort by the top-level event timestamp used by the persisted
# realtime feedback schema. Legacy events without one fall back to their
# payload timestamp. Events sharing a key retain insertion order.
return sorted(self._events, key=realtime_feedback_event_sort_key)
def get_events(self) -> List[dict]:
"""Get all events for final storage, ordered by realtime timestamp."""
"""Get all events for final storage, ordered by event timestamp."""
return self._sorted_events()
def contains_user_speech(self) -> bool:
@ -164,34 +175,15 @@ class InMemoryLogsBuffer:
return True
return False
def generate_transcript_text(self) -> str:
def generate_transcript_text(self, *, include_end_timestamps: bool = False) -> str:
"""Generate transcript text from logged events.
Filters for rtf-user-transcription (final) and rtf-bot-text events,
formats them as '[timestamp] user/assistant: text\\n'.
"""
return _generate_transcript_text(self._sorted_events())
def write_transcript_to_temp_file(self) -> Optional[str]:
"""Write transcript to a temporary text file and return the path.
Returns None if there are no transcript events.
"""
content = self.generate_transcript_text()
if not content:
return None
temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False)
logger.debug(
f"Writing transcript to temp file {temp_file.name} for workflow {self._workflow_run_id}"
return _generate_transcript_text(
self._sorted_events(), include_end_timestamps=include_end_timestamps
)
temp_file.write(content)
temp_file.close()
logger.info(
f"Successfully wrote {len(content)} chars of transcript to {temp_file.name}"
)
return temp_file.name
@property
def is_empty(self) -> bool:

View file

@ -3,6 +3,7 @@ from typing import Awaitable, Callable, Optional
from loguru import logger
from api.schemas.workflow_configurations import DEFAULT_MAX_CALL_DURATION_SECONDS
from pipecat.frames.frames import (
Frame,
HeartbeatFrame,
@ -23,7 +24,7 @@ class PipelineEngineCallbacksProcessor(FrameProcessor):
def __init__(
self,
max_call_duration_seconds: int = 300,
max_call_duration_seconds: int = DEFAULT_MAX_CALL_DURATION_SECONDS,
max_duration_end_task_callback: Optional[Callable[[], Awaitable[None]]] = None,
generation_started_callback: Optional[Callable[[], Awaitable[None]]] = None,
llm_text_frame_callback: Optional[Callable[[str], Awaitable[None]]] = None,

View file

@ -1,9 +1,9 @@
"""Dograh subclass of pipecat's Azure OpenAI Realtime LLM service.
Layers Dograh engine integration quirks (mute gating, TTSSpeakFrame greeting
trigger, LLMMessagesAppendFrame handling, deferred tool calls) onto pipecat's
AzureRealtimeLLMService, mirroring what DograhOpenAIRealtimeLLMService does
for the standard OpenAI Realtime endpoint.
trigger, LLMMessagesAppendFrame handling, workflow-control deferral) onto
pipecat's AzureRealtimeLLMService, mirroring what
DograhOpenAIRealtimeLLMService does for the standard OpenAI Realtime endpoint.
"""
import json
@ -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,
@ -39,7 +40,7 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
- User-mute audio gating
- TTSSpeakFrame as initial-response trigger
- One-off LLMMessagesAppendFrame handling
- Deferred tool calls until bot finishes speaking
- Workflow-control calls deferred until bot finishes speaking
- finalized=True on TranscriptionFrame for consistency
"""
@ -48,7 +49,8 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
self._user_is_muted: bool = False
self._handled_initial_context: bool = False
self._bot_is_speaking: bool = False
self._deferred_function_calls: list[FunctionCallFromLLM] = []
self._deferred_node_transition_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 — "
@ -75,7 +81,7 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
self._bot_is_speaking = True
elif isinstance(frame, BotStoppedSpeakingFrame):
self._bot_is_speaking = False
await self._run_pending_function_calls()
await self._run_pending_node_transition_function_calls()
await super().process_frame(frame, direction)
async def _handle_messages_append(self, frame: LLMMessagesAppendFrame):
@ -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,30 +228,38 @@ 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,
)
)
)
async def _run_pending_function_calls(self):
if not self._deferred_function_calls:
async def _run_pending_node_transition_function_calls(self):
if not self._deferred_node_transition_function_calls:
return
function_calls = self._deferred_function_calls
self._deferred_function_calls = []
function_calls = self._deferred_node_transition_function_calls
self._deferred_node_transition_function_calls = []
logger.debug(
f"{self}: executing {len(function_calls)} deferred function call(s) "
"after bot turn ended"
f"{self}: executing {len(function_calls)} deferred workflow-control "
"call(s) after bot turn ended"
)
await self.run_function_calls(function_calls)
async def _handle_evt_function_call_arguments_done(self, evt):
"""Run ordinary tools immediately and defer workflow-control calls."""
try:
args = json.loads(evt.arguments)
@ -211,10 +276,14 @@ class DograhAzureRealtimeLLMService(AzureRealtimeLLMService):
)
]
if self._bot_is_speaking:
self._deferred_function_calls.extend(function_calls)
is_node_transition = self._function_is_node_transition(
function_call_item.name
)
if self._bot_is_speaking and is_node_transition:
self._deferred_node_transition_function_calls.extend(function_calls)
logger.debug(
f"{self}: deferring function call {function_call_item.name} "
f"{self}: deferring workflow-control call "
f"{function_call_item.name} "
"until bot stops speaking"
)
else:

View file

@ -9,8 +9,8 @@ Layers Dograh engine integration quirks onto upstream-pristine
- **Reconnect on node transitions.** Gemini Live cannot update
``system_instruction`` mid-session, so a setting change triggers a
reconnect (deferred until the bot turn ends if currently responding).
- **Function-call deferral.** Tool calls emitted mid-turn are queued and run
when the bot stops speaking, to avoid racing the turn's audio.
- **Node-transition deferral.** Node-transition calls emitted mid-turn are
queued and run when the bot stops speaking, to avoid cutting off its audio.
- **User-mute audio gating.** ``UserMuteStarted/StoppedFrame`` from the
user aggregator gates whether incoming audio is forwarded to Gemini.
- **TTSSpeakFrame as greeting trigger.** The engine queues a TTSSpeakFrame
@ -18,10 +18,16 @@ Layers Dograh engine integration quirks onto upstream-pristine
it and runs the initial-context path.
"""
import asyncio
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,
@ -39,6 +45,18 @@ from pipecat.utils.tracing.service_decorators import traced_gemini_live
class DograhGeminiLiveLLMService(GeminiLiveLLMService):
"""Gemini Live with Dograh engine integration quirks. See module docstring."""
# Gemini input transcription is delivered independently from tool calls.
# Give late transcription messages a small window to arrive before running
# a node-transition function and tearing down the current Live connection.
_NODE_TRANSITION_TRANSCRIPTION_GRACE_SECONDS = 0.5
# Route tool schemas through Gemini's ``parameters_json_schema`` field so
# MCP/imported tools that use JSON Schema keywords (``const``, ``not``,
# nested ``anyOf``) rejected by the strict ``Schema`` model are accepted.
# Mirrors the non-realtime ``DograhGoogleLLMService`` fix;
# ``DograhGeminiLiveVertexLLMService`` inherits this via MRO.
adapter_class = DograhGeminiJSONSchemaAdapter
def __init__(self, **kwargs):
super().__init__(**kwargs)
# User-mute state, driven by broadcast UserMute{Started,Stopped}Frames.
@ -47,12 +65,19 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
# Guards initial-response triggering against double-firing across the
# initial TTSSpeakFrame and any LLMContextFrame that may arrive.
self._handled_initial_context: bool = False
# When a system_instruction change arrives mid-bot-turn, the reconnect
# is queued and drained when the turn ends.
self._reconnect_pending: bool = False
# 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] = []
# Node-transition calls emitted mid-bot-turn are deferred here so the
# transition does not tear down Gemini while it is still producing audio.
self._pending_node_transition_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
self._transition_function_call_task: asyncio.Task | None = None
# Intentional node changes use a fresh, context-seeded connection rather
# than a potentially stale session-resumption handle. The new connection
# remains gated until the function-call result has landed in LLMContext.
self._awaiting_node_transition_context: bool = False
self._node_transition_context_received: bool = False
self._node_transition_context_seed_started: bool = False
# ------------------------------------------------------------------
# Hooks from upstream GeminiLiveLLMService
@ -63,33 +88,109 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
# lets pre-call fetch populate template variables first.
return bool(self._settings.system_instruction)
def _requires_node_transition_context_aggregation(self) -> bool:
# A node transition replaces the current Gemini Live connection and
# seeds the new one from our local LLMContext. Wait for the upstream
# user aggregator to commit any final TranscriptionFrame before
# set_node() changes the prompt and starts that reconnect.
return True
async def cleanup(self) -> None:
"""Cancel a delayed transition before tearing down the Live session."""
if self._transition_function_call_task:
await self.cancel_task(self._transition_function_call_task)
self._transition_function_call_task = None
await super().cleanup()
async def _handle_changed_settings(self, changed: dict[str, Any]) -> set[str]:
if "system_instruction" not in changed:
return set()
# PipecatEngine updates system_instruction only from set_node(). The
# first set_node happens before a Live session exists; every later one
# is a node transition whose tool call has already been deferred until
# the current bot turn finishes.
if not self._session:
# First-time setting after deferred-connect.
await self._connect()
elif self._bot_is_responding:
# Bot is mid-turn — drain the reconnect when it ends so we don't
# cut the bot off mid-utterance.
self._reconnect_pending = True
else:
await self._reconnect()
await self._reconnect_for_node_transition()
return {"system_instruction"}
async def _run_or_defer_function_calls(
self, function_calls_llm: list[FunctionCallFromLLM]
):
if not self._contains_node_transition(function_calls_llm):
await super()._run_or_defer_function_calls(function_calls_llm)
return
# Keep a provider tool-call batch together. Splitting a mixed batch here
# would discard Pipecat's shared function-call group and could trigger an
# LLM run before every result from the original batch has arrived.
if self._bot_is_responding:
# Latest batch wins; Gemini emits tool calls as one batch per
# tool_call message, so this overwrite is intentional.
self._pending_function_calls = function_calls_llm
self._pending_node_transition_function_calls = function_calls_llm
logger.debug(
f"{self}: deferring {len(function_calls_llm)} function call(s) "
f"{self}: deferring {len(function_calls_llm)} node-transition "
"function call(s) "
"until bot turn ends"
)
return
await super()._run_or_defer_function_calls(function_calls_llm)
self._schedule_node_transition_function_calls(function_calls_llm)
def _contains_node_transition(
self, function_calls_llm: list[FunctionCallFromLLM]
) -> bool:
return any(self._is_node_transition(fc) for fc in function_calls_llm)
def _is_node_transition(self, function_call: FunctionCallFromLLM) -> bool:
return self._function_is_node_transition(function_call.function_name)
def _schedule_node_transition_function_calls(
self, function_calls_llm: list[FunctionCallFromLLM]
) -> None:
"""Run transition calls after late input transcription has settled."""
if (
self._transition_function_call_task
and not self._transition_function_call_task.done()
):
logger.warning(
f"{self}: node-transition function call already pending; "
"ignoring duplicate batch"
)
return
async def _run_after_transcription_grace() -> None:
try:
await asyncio.sleep(self._NODE_TRANSITION_TRANSCRIPTION_GRACE_SECONDS)
await self._flush_pending_user_transcription()
await self.run_function_calls(function_calls_llm)
finally:
self._transition_function_call_task = None
self._transition_function_call_task = self.create_task(
_run_after_transcription_grace(),
name=f"{self}::node-transition-function-calls",
)
async def _flush_pending_user_transcription(self) -> None:
"""Publish any punctuationless user transcript before a node handoff."""
if self._transcription_timeout_task:
if not self._transcription_timeout_task.done():
await self.cancel_task(self._transcription_timeout_task)
self._transcription_timeout_task = None
if not self._user_transcription_buffer:
return
text = self._user_transcription_buffer
self._user_transcription_buffer = ""
logger.debug(
f"{self}: flushing pending user transcription before node transition"
)
await self._push_user_transcription(text, result=None)
# ------------------------------------------------------------------
# State-transition side effects
@ -99,22 +200,35 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
was_responding = self._bot_is_responding
await super()._set_bot_is_responding(responding)
if was_responding and not responding:
await self._run_pending_function_calls()
if self._reconnect_pending:
self._reconnect_pending = False
await self._reconnect()
await self._run_pending_node_transition_function_calls()
async def _run_pending_function_calls(self):
"""Run any function calls deferred during the bot's last turn."""
if not self._pending_function_calls:
async def _run_pending_node_transition_function_calls(self):
"""Run any node-transition calls deferred during the bot's last turn."""
if not self._pending_node_transition_function_calls:
return
fcs = self._pending_function_calls
self._pending_function_calls = []
fcs = self._pending_node_transition_function_calls
self._pending_node_transition_function_calls = []
logger.debug(
f"{self}: executing {len(fcs)} deferred function call(s) "
f"{self}: executing {len(fcs)} deferred node-transition call(s) "
"after bot turn ended"
)
await self.run_function_calls(fcs)
self._schedule_node_transition_function_calls(fcs)
async def _reconnect_for_node_transition(self) -> None:
"""Start a fresh connection and wait to seed the completed context.
Gemini can report ``resumable=False`` while generating or executing a
function call. A workflow transition happens at exactly that boundary,
so using the last (older) resumption handle can omit the triggering user
turn. Use the local LLMContext as the source of truth for this intentional
handoff instead.
"""
self._awaiting_node_transition_context = True
self._node_transition_context_received = False
self._node_transition_context_seed_started = False
self._session_resumption_handle = None
await self._disconnect()
await self._connect(session_resumption_handle=None)
# ------------------------------------------------------------------
# Frame handling: mute, TTSSpeakFrame, BotStoppedSpeakingFrame flush
@ -132,10 +246,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 "
@ -145,9 +264,9 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
if isinstance(frame, BotStoppedSpeakingFrame):
# Belt-and-suspenders: the main drain happens in
# _set_bot_is_responding(False), but if Gemini delays turn_complete
# past the audible end of the turn, flushing here ensures pending
# function calls fire promptly.
await self._run_pending_function_calls()
# past the audible end of the turn, flushing here ensures a pending
# node transition fires promptly.
await self._run_pending_node_transition_function_calls()
# Fall through to super for the actual push.
await super().process_frame(frame, direction)
@ -165,6 +284,11 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
# ------------------------------------------------------------------
async def _handle_context(self, context: LLMContext):
if self._awaiting_node_transition_context:
self._context = context
self._node_transition_context_received = True
await self._maybe_seed_node_transition_context()
return
if not self._handled_initial_context:
self._handled_initial_context = True
self._context = context
@ -173,6 +297,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
@ -186,15 +353,48 @@ class DograhGeminiLiveLLMService(GeminiLiveLLMService):
f"In _handle_session_ready self._run_llm_when_session_ready: {self._run_llm_when_session_ready}"
)
self._session = session
if self._awaiting_node_transition_context:
# Do not accept realtime input until the function-call result frame
# has updated the shared context and that complete history is seeded.
self._ready_for_realtime_input = False
await self._maybe_seed_node_transition_context()
return
self._ready_for_realtime_input = True
if self._run_llm_when_session_ready:
# 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
# a handle (e.g. node transitions before any handle was issued) are
# followed by a function-call-result LLMContextFrame which feeds the
# updated-context branch in _handle_context.
async def _maybe_seed_node_transition_context(self) -> None:
if (
not self._awaiting_node_transition_context
or not self._node_transition_context_received
or not self._session
or self._node_transition_context_seed_started
):
return
self._node_transition_context_seed_started = True
try:
# The complete tool result is already present in the history being
# seeded, so mark it delivered locally instead of sending a provider
# tool response for a call that the fresh session never issued.
await self._process_completed_function_calls(send_new_results=False)
await self._create_initial_response()
self._awaiting_node_transition_context = False
self._node_transition_context_received = False
await self._drain_pending_tool_results()
finally:
self._node_transition_context_seed_started = False

View file

@ -11,8 +11,9 @@ Adds:
flow kicks off the bot's first response.
- **One-off LLMMessagesAppendFrame handling** for ephemeral realtime prompts
like user-idle checks, without mutating Dograh's local ``LLMContext``.
- **Function-call deferral** until the bot finishes speaking, to avoid racing
tool execution with the active audio turn.
- **Workflow-control deferral** so node transitions, call termination, and
transfers wait for any current bot audio to finish while ordinary tools run
immediately.
- **finalized=True on TranscriptionFrame** for parity with Dograh's other
realtime providers.
"""
@ -22,6 +23,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,7 +51,8 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
self._user_is_muted: bool = False
self._handled_initial_context: bool = False
self._bot_is_speaking: bool = False
self._deferred_function_calls: list[FunctionCallFromLLM] = []
self._deferred_node_transition_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 +65,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 "
@ -76,7 +83,7 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
self._bot_is_speaking = True
elif isinstance(frame, BotStoppedSpeakingFrame):
self._bot_is_speaking = False
await self._run_pending_function_calls()
await self._run_pending_node_transition_function_calls()
await super().process_frame(frame, direction)
async def _handle_messages_append(self, frame: LLMMessagesAppendFrame):
@ -120,6 +127,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
@ -184,19 +252,19 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
)
)
async def _run_pending_function_calls(self):
if not self._deferred_function_calls:
async def _run_pending_node_transition_function_calls(self):
if not self._deferred_node_transition_function_calls:
return
function_calls = self._deferred_function_calls
self._deferred_function_calls = []
function_calls = self._deferred_node_transition_function_calls
self._deferred_node_transition_function_calls = []
logger.debug(
f"{self}: executing {len(function_calls)} deferred function call(s) "
"after bot turn ended"
f"{self}: executing {len(function_calls)} deferred workflow-control "
"call(s) after bot turn ended"
)
await self.run_function_calls(function_calls)
async def _handle_evt_function_call_arguments_done(self, evt):
"""Process or defer tool calls until the bot finishes speaking."""
"""Run ordinary tools immediately and defer workflow-control calls."""
try:
args = json.loads(evt.arguments)
@ -214,10 +282,11 @@ class DograhGrokRealtimeLLMService(GrokRealtimeLLMService):
)
]
if self._bot_is_speaking:
self._deferred_function_calls.extend(function_calls)
is_node_transition = self._function_is_node_transition(function_name)
if self._bot_is_speaking and is_node_transition:
self._deferred_node_transition_function_calls.extend(function_calls)
logger.debug(
f"{self}: deferring function call {function_name} "
f"{self}: deferring workflow-control call {function_name} "
"until bot stops speaking"
)
else:

View file

@ -3,8 +3,8 @@
Layers Dograh engine integration quirks onto upstream-pristine
:class:`OpenAIRealtimeLLMService`. Substantially smaller than the Gemini
subclass because OpenAI Realtime supports runtime ``session.update`` for
both ``system_instruction`` and tools no reconnect/defer-tool-call
machinery needed.
both ``system_instruction`` and tools, so node changes do not require a
reconnect.
Adds:
@ -13,6 +13,9 @@ Adds:
flow kicks off the bot's first response.
- **One-off LLMMessagesAppendFrame handling** for ephemeral realtime prompts
like user-idle checks, without mutating Dograh's local ``LLMContext``.
- **Workflow-control deferral** so node transitions, call termination, and
transfers wait for any current bot audio to finish while ordinary tools run
immediately.
- **finalized=True on TranscriptionFrame** because every OpenAI
transcription via the ``completed`` event is final by construction.
"""
@ -22,6 +25,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,
@ -52,10 +56,11 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
# LLMContextFrame arrives, so upstream's "first arrival means
# self._context is None" check no longer works.
self._handled_initial_context: bool = False
# Track bot speech locally so tool calls can be deferred until the bot
# has finished speaking, matching Dograh's Gemini Live behavior.
# Track bot speech locally so workflow-control calls can wait until the
# bot has finished speaking without delaying ordinary tools.
self._bot_is_speaking: bool = False
self._deferred_function_calls: list[FunctionCallFromLLM] = []
self._deferred_node_transition_function_calls: list[FunctionCallFromLLM] = []
self._pending_initial_greeting_text: str | None = None
# ------------------------------------------------------------------
# Frame handling: mute, TTSSpeakFrame as greeting trigger
@ -73,11 +78,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 "
@ -93,7 +103,7 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
self._bot_is_speaking = True
elif isinstance(frame, BotStoppedSpeakingFrame):
self._bot_is_speaking = False
await self._run_pending_function_calls()
await self._run_pending_node_transition_function_calls()
await super().process_frame(frame, direction)
async def _handle_messages_append(self, frame: LLMMessagesAppendFrame):
@ -137,6 +147,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 +251,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,24 +264,26 @@ 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,
)
)
)
async def _run_pending_function_calls(self):
if not self._deferred_function_calls:
async def _run_pending_node_transition_function_calls(self):
if not self._deferred_node_transition_function_calls:
return
function_calls = self._deferred_function_calls
self._deferred_function_calls = []
function_calls = self._deferred_node_transition_function_calls
self._deferred_node_transition_function_calls = []
logger.debug(
f"{self}: executing {len(function_calls)} deferred function call(s) "
"after bot turn ended"
f"{self}: executing {len(function_calls)} deferred workflow-control "
"call(s) after bot turn ended"
)
await self.run_function_calls(function_calls)
async def _handle_evt_function_call_arguments_done(self, evt):
"""Process or defer tool calls until the bot finishes speaking."""
"""Run ordinary tools immediately and defer workflow-control calls."""
try:
args = json.loads(evt.arguments)
@ -232,10 +300,14 @@ class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService):
)
]
if self._bot_is_speaking:
self._deferred_function_calls.extend(function_calls)
is_node_transition = self._function_is_node_transition(
function_call_item.name
)
if self._bot_is_speaking and is_node_transition:
self._deferred_node_transition_function_calls.extend(function_calls)
logger.debug(
f"{self}: deferring function call {function_call_item.name} "
f"{self}: deferring workflow-control call "
f"{function_call_item.name} "
"until bot stops speaking"
)
else:

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}"'
)

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