diff --git a/.agents/skills/review-agents-md/SKILL.md b/.agents/skills/review-agents-md/SKILL.md new file mode 100644 index 0000000..ecb703e --- /dev/null +++ b/.agents/skills/review-agents-md/SKILL.md @@ -0,0 +1,206 @@ +--- +name: review-agents-md +description: Audit Dograh `AGENTS.md` files for drift against the live repo and for bad scope boundaries between parent and child docs. Use when the user asks to review existing AGENTS files, identify stale guidance, decide whether a subtree needs its own `AGENTS.md`, or update the `AGENTS.md` hierarchy under the repo root, `api/`, or `ui/`. +--- + +# Review AGENTS.md + +Audit first. Report drift, missing coverage, and wrong ownership boundaries before editing docs unless the user explicitly asks for patches. + +## Freshness Rule + +Treat the repo as source of truth. + +- Trust current code and current directory layout over any `AGENTS.md`, `README.md`, or this skill's references. +- If prose and code disagree, report the prose as stale. +- If a reference file in this skill disagrees with the repo, trust the repo and mention the drift. + +## Workflow + +### 0. Refresh the seam reference before using it + +If subagents are available, refresh `references/dograh-seams.md` before relying on it. + +- Spawn exactly one subagent for this maintenance pass. +- Tell the subagent to inspect the live repo. +- Limit its patch set to `.agents/skills/review-agents-md/references/dograh-seams.md`. +- Also allow `.agents/skills/review-agents-md/scripts/inventory_agents_md.py`, but only if the helper itself needs a repo-specific fix. +- Tell the subagent not to recurse into this same seam-refresh workflow. This is a one-level maintenance pass, not an infinite self-audit loop. +- Tell the subagent not to review or patch any repo `AGENTS.md` files yet. Its job is only to refresh the seam reference and helper. +- After the subagent returns, review its diff quickly before using `dograh-seams.md` in the main audit. + +Use a prompt shaped like: + +```text +Review and refresh .agents/skills/review-agents-md/references/dograh-seams.md against the live Dograh repo. Patch only that file, and patch .agents/skills/review-agents-md/scripts/inventory_agents_md.py only if needed. Do not recurse into another seam-refresh pass. Do not review or edit any AGENTS.md files yet. +``` + +If subagents are not available, do the same seam refresh locally before continuing. + +### 1. Inventory the current hierarchy + +Run the helper first from the repo root: + +```bash +python .agents/skills/review-agents-md/scripts/inventory_agents_md.py +``` + +This prints: + +- every discovered `AGENTS.md` +- child `AGENTS.md` ownership boundaries +- immediate child directories for each scope +- large uncovered subtrees that may deserve their own `AGENTS.md` + +Then confirm with direct file discovery when needed: + +```bash +rg --files -g 'AGENTS.md' . +find api ui -name AGENTS.md | sort +``` + +### 2. Read top-down before judging details + +Read in this order: + +1. repo root `AGENTS.md` +2. `api/AGENTS.md` +3. `ui/AGENTS.md` +4. deeper `AGENTS.md` files under those trees + +For each file, write a one-line ownership statement in your notes: + +- what subtree it owns +- what shared rules it should contain +- which deeper docs, if any, should own implementation details instead + +### 3. Verify each doc against the live code + +Check directory trees, route aggregators, registration points, and extension seams instead of relying on filenames mentioned in prose. + +Dograh files worth checking early: + +- `api/routes/main.py` +- `api/routes/telephony.py` +- `api/services/integrations/loader.py` +- `api/services/integrations/registry.py` +- `api/services/pipecat/run_pipeline.py` +- `api/tasks/run_integrations.py` +- `api/services/telephony/registry.py` +- `api/services/telephony/factory.py` +- `api/services/telephony/providers/__init__.py` +- `ui/src/app/` +- `ui/src/components/` +- `ui/src/lib/auth/` +- `ui/src/client/` + +Read [dograh-seams.md](references/dograh-seams.md) when you need a fast repo-specific starting map. + +### 4. Apply the hierarchy tests + +Use these tests for every scope: + +- `parent-fit`: The parent doc explains immediate child systems, shared invariants, and navigation for the subtree it owns. +- `child-fit`: A deeper doc owns local extension contracts, module-specific gotchas, and file-level patterns for its own subtree. +- `no-duplication`: The parent does not restate detailed child implementation guidance that should live in the child doc. +- `downward-pointing`: The doc should point contributors toward the next relevant subdirectory or deeper `AGENTS.md` instead of trying to explain the whole subtree itself. +- `no-gaps`: If a large or extension-heavy subtree has rules the parent cannot explain cleanly in a few lines, flag a missing child `AGENTS.md`. +- `no-drift`: File trees, commands, extension points, and architecture claims still match the code. + +### 5. Dograh-specific review heuristics + +#### Root `AGENTS.md` + +Expect root to stay high-level. + +- It should describe the top-level project shape, shared stack, and shared local-development expectations. +- It should mention top-level applications and support directories that matter to contributors. +- It should not try to document backend-internal extension contracts or frontend component internals. +- If a top-level directory materially matters to contributors and is missing from root guidance, report `missing-parent-coverage`. + +#### `api/AGENTS.md` + +Expect `api/AGENTS.md` to orient a contributor across backend domains, not to document every local contract in full. + +- It should point to where routes, services, DB access, schemas, tasks, tests, and security invariants live. +- It should accurately describe where workflow execution lives. In current Dograh, that spans `api/services/workflow/`, `api/services/pipecat/`, and post-call work in `api/tasks/run_integrations.py`. +- It should accurately describe telephony as a substantial subsystem, not just as one route file. +- It should mention integration extensibility and defer package-level rules to `api/services/integrations/AGENTS.md`. +- If `api/services/telephony/` or `api/services/workflow/` are complex enough that the parent doc becomes vague or overloaded, report `missing-child-agents`. + +#### `api/services/integrations/AGENTS.md` + +Expect this file to own the integration package contract. + +- It should explain package registration, node model/spec patterns, runtime collection, completion handlers, optional routes, import discipline, and testing expectations. +- It should match the live registry/loader path instead of describing central manual wiring that no longer exists. +- It should not require edits to `workflow/dto.py`, `run_pipeline.py`, or route aggregation unless the generic framework genuinely changed. + +#### `ui/AGENTS.md` + +Expect `ui/AGENTS.md` to orient contributors across the frontend without documenting individual feature internals. + +- It should describe the App Router layout under `ui/src/app/`. +- It should point to reusable feature components under `ui/src/components/`. +- It should mention generated API client usage under `ui/src/client/`. +- It should mention auth readiness constraints under `ui/src/lib/auth/`. +- It should not describe removed folders or outdated stack details. + +### 6. Classify findings + +Use these categories: + +- `stale`: prose mentions files, commands, flows, or architecture that no longer match the repo +- `missing-parent-coverage`: a parent scope omits a major subsystem it should orient the reader to +- `missing-child-agents`: a deep subtree likely needs its own `AGENTS.md` +- `wrong-level`: content belongs in a parent or child scope instead +- `extra-detail`: a parent doc is too implementation-specific for its level + +### 7. Report format + +List findings first, ordered by severity. + +Use this shape: + +```text +: -> -> +``` + +Examples: + +```text +api/AGENTS.md: missing-child-agents -> telephony is a large extension surface with provider registration, transport, routes, and config rules but has no local AGENTS.md -> add api/services/telephony/AGENTS.md and keep api/AGENTS.md at navigation level +api/services/integrations/AGENTS.md: stale -> says central DTO edits are required for new integrations, but registry-based discovery handles node resolution -> update the doc to describe the registry path only +ui/AGENTS.md: wrong-level -> describes individual workflow-builder component behavior instead of frontend navigation rules -> move that detail to a deeper doc or remove it +``` + +After findings, include: + +- open questions or assumptions +- optional patch plan, only if the user asked for fixes or clearly wants them next + +## Editing Rules + +If the user wants the docs fixed: + +- patch the smallest set of `AGENTS.md` files that restores a clean hierarchy +- add a new `AGENTS.md` only when a subtree has distinct local rules or extension contracts +- keep parent docs short and navigational +- let child docs own local implementation rules +- avoid copying the same guidance into parent and child files +- prefer folders over files when writing navigation guidance, unless a file is the only real seam +- when adding a new child `AGENTS.md`, start with the shortest useful contract; avoid tutorial-style prose +- point the reader downward toward the next relevant subdirectory or child `AGENTS.md` +- if a draft feels forced or over-explained, compress it again + +## Useful Commands + +```bash +python .agents/skills/review-agents-md/scripts/inventory_agents_md.py +rg --files -g 'AGENTS.md' . +find api ui -name AGENTS.md | sort +find api/services -maxdepth 2 -type d | sort +find ui/src -maxdepth 2 -type d | sort +rg -n "include_router|all_routers" api/routes/main.py api/services/integrations +rg -n "register\\(|ProviderSpec|register_package|create_runtime_sessions|run_completion" api/services/telephony api/services/integrations +``` diff --git a/.agents/skills/review-agents-md/agents/openai.yaml b/.agents/skills/review-agents-md/agents/openai.yaml new file mode 100644 index 0000000..099cf0a --- /dev/null +++ b/.agents/skills/review-agents-md/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Review AGENTS.md" + short_description: "Audit AGENTS.md scope and drift" + default_prompt: "Use $review-agents-md to audit AGENTS.md files for drift, missing coverage, and wrong scope boundaries." diff --git a/.agents/skills/review-agents-md/references/dograh-seams.md b/.agents/skills/review-agents-md/references/dograh-seams.md new file mode 100644 index 0000000..96390a6 --- /dev/null +++ b/.agents/skills/review-agents-md/references/dograh-seams.md @@ -0,0 +1,126 @@ +# Dograh Seams + +Use this file as a starting map, not as source of truth. Verify every claim against the live repo. + +## Dynamic discovery only + +Do not record the current `AGENTS.md` inventory in this file. + +- discover the live hierarchy with `rg --files -g 'AGENTS.md' .` +- use the helper script to identify uncovered hot spots +- treat any baked-in inventory of existing `AGENTS.md` files as drift-prone and remove it + +## Root-level anchors + +The repo root still revolves around these contributor-relevant directories: + +- `api/` +- `ui/` +- `scripts/` +- `docs/` +- `pipecat/` + +Current code-heavy top-level subtrees that can matter during hierarchy reviews: + +- `evals/` for evaluation tooling, including `evals/visualizer/` +- `sdk/` for packaged SDK work, especially `sdk/python/` and `sdk/typescript/` + +Root `AGENTS.md` should stay at this level. + +## Backend anchors + +### Route aggregation + +- REST routers are aggregated in `api/routes/main.py`. +- Telephony has its main cross-provider route file at `api/routes/telephony.py`. +- Integration package routers are mounted through `api.services.integrations.all_routers()`. +- Node-type metadata is exposed from `api/routes/node_types.py`. + +### Workflow execution + +Workflow execution is not a single folder. + +- workflow graph, DTOs, node data, node-spec generation, QA, and tool helpers live under `api/services/workflow/` +- live pipeline execution lives under `api/services/pipecat/` +- Dograh-specific realtime provider adapters live under `api/services/pipecat/realtime/` +- post-call QA, registered integrations, and webhook execution live in `api/tasks/run_integrations.py` + +If `api/AGENTS.md` implies workflow execution lives in only one place, treat that as suspicious. + +### Node spec and SDK seam + +- core node specs are registered lazily from `api/services/workflow/dto.py` by `api/services/workflow/node_specs/__init__.py` +- integration node specs are merged through `api.services.integrations.all_node_specs()` +- the frontend and SDK-facing node catalog is served from `api/routes/node_types.py` + +### Telephony + +Current telephony architecture is registry-driven. + +- importing `api.services.telephony` eagerly loads `api/services/telephony/providers/` so provider packages self-register +- provider registration and `ProviderSpec` live in `api/services/telephony/registry.py` +- provider lookup, org-scoped config normalization, inbound matching, and run-scoped resolution live in `api/services/telephony/factory.py` +- per-provider HTTP routers live in `api/services/telephony/providers//routes.py` and are auto-mounted by `api/routes/telephony.py` +- provider-local implementations live in `api/services/telephony/providers//` +- current provider packages include `ari`, `cloudonix`, `plivo`, `telnyx`, `twilio`, `vobiz`, and `vonage` +- not every provider has an HTTP route module; for example, `ari` is transport-focused and skipped by the auto-mounter + +### Integrations + +Current integrations are also registry-driven. + +- package discovery lives in `api/services/integrations/loader.py` via `pkgutil.iter_modules(...)` +- package registration and runtime/completion orchestration live in `api/services/integrations/registry.py` +- shared package/session context types live in `api/services/integrations/base.py` +- a concrete package example exists at `api/services/integrations/tuner/` + +## Frontend anchors + +### Navigation and pages + +- page routes live under `ui/src/app/` +- `ui/src/app/layout.tsx` composes the global frontend providers and `AppLayout` +- runtime config handlers live under `ui/src/app/api/config/` +- auth/session handlers live under `ui/src/app/api/auth/` +- feature coverage should be discovered from the current `ui/src/app/` tree, not maintained as a static list here + +### Components and feature slices + +- shared primitives live under `ui/src/components/ui/` +- workflow builder primitives live under `ui/src/components/flow/` +- reusable workflow UI lives under `ui/src/components/workflow/` +- workflow run UI lives under `ui/src/components/workflow-runs/` +- telephony-related UI lives under `ui/src/components/telephony/` +- layout components live under `ui/src/components/layout/` +- workflow feature code is split between reusable components and route-local code under `ui/src/app/workflow/[workflowId]/`, especially `components/`, `contexts/`, `hooks/`, `stores/`, `utils/`, and nested `run/[runId]/` + +### Client and auth + +- generated API client code lives under `ui/src/client/`, with generated subtrees in `ui/src/client/client/` and `ui/src/client/core/` +- auth exports live in `ui/src/lib/auth/index.ts` +- auth provider wrappers live under `ui/src/lib/auth/providers/` +- server-side auth helpers live in `ui/src/lib/auth/server.ts` +- `AuthProvider` chooses between the Stack and local wrappers after fetching `/api/config/auth`, so docs that treat auth as compile-time static are suspicious + +## Known drift example from the audit + +`api/services/telephony/README.md` is still stale in the current repo snapshot: + +- it described flat provider files like `twilio_provider.py` and `vonage_provider.py` +- it told contributors to add schemas to `api/schemas/telephony_config.py` +- it referenced legacy patterns such as direct `TwilioService` usage + +The live code instead uses provider packages under `providers//`, registry-driven provider resolution, and route auto-mounting from `api/routes/telephony.py`. Use this as a reminder that prose in adjacent docs may have drifted even when the code is coherent. + +## Hotspot heuristics + +These are review prompts, not frozen conclusions. + +- pay extra attention to deep subtrees that define extension contracts, registration points, or multi-file execution paths +- in Dograh, common examples include telephony, workflow execution, generated SDK surfaces, and other service subtrees that span many files + +Ask: + +- does the parent doc have enough room to explain this subtree accurately without becoming overloaded? +- does the subtree have distinct extension rules, registration points, or local pitfalls? +- would a contributor benefit from a dedicated `AGENTS.md` here? diff --git a/.agents/skills/review-agents-md/scripts/inventory_agents_md.py b/.agents/skills/review-agents-md/scripts/inventory_agents_md.py new file mode 100644 index 0000000..7f629f8 --- /dev/null +++ b/.agents/skills/review-agents-md/scripts/inventory_agents_md.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +"""Inventory AGENTS.md scopes and large uncovered subtrees. + +Run from the repo root: + + python .agents/skills/review-agents-md/scripts/inventory_agents_md.py + +The output is intentionally heuristic. It helps with discovery; it does not +decide by itself whether a subtree must have its own AGENTS.md. +""" + +from __future__ import annotations + +import argparse +import os +from dataclasses import dataclass +from pathlib import Path + +IGNORE_DIRS = { + ".git", + ".hg", + ".svn", + ".next", + ".turbo", + ".venv", + "venv", + "node_modules", + "__pycache__", + ".pytest_cache", + ".ruff_cache", + ".mypy_cache", + ".cursor", + ".claude", + ".idea", + ".vscode", + "dist", + "build", +} + +CODE_EXTENSIONS = { + ".py", + ".ts", + ".tsx", + ".js", + ".jsx", + ".mjs", + ".cjs", +} + + +@dataclass(frozen=True) +class DirSummary: + path: Path + code_files: int + nested_dirs: int + has_agents_here: bool + descendant_agents: int + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Summarize AGENTS.md coverage and likely missing deeper scopes.", + ) + parser.add_argument( + "roots", + nargs="*", + default=["."], + help="Directories to scan. Defaults to the current directory.", + ) + parser.add_argument( + "--hotspot-threshold", + type=int, + default=12, + help="Minimum code-file count for a subtree to be listed as a hotspot.", + ) + return parser.parse_args() + + +def should_skip_dir(name: str) -> bool: + return name in IGNORE_DIRS or name.startswith(".") + + +def walk_dirs(root: Path): + for current_root, dirnames, filenames in os.walk(root): + dirnames[:] = sorted(d for d in dirnames if not should_skip_dir(d)) + yield Path(current_root), dirnames, filenames + + +def discover_agents(roots: list[Path]) -> list[Path]: + found: set[Path] = set() + for root in roots: + for current_root, _dirnames, filenames in walk_dirs(root): + if "AGENTS.md" in filenames: + found.add((current_root / "AGENTS.md").resolve()) + return sorted(found) + + +def count_nested_dirs(root: Path) -> int: + count = 0 + for current_root, dirnames, _filenames in walk_dirs(root): + if Path(current_root) == root: + count += len(dirnames) + continue + count += len(dirnames) + return count + + +def count_code_files(root: Path) -> int: + count = 0 + for _current_root, _dirnames, filenames in walk_dirs(root): + for filename in filenames: + if Path(filename).suffix in CODE_EXTENSIONS: + count += 1 + return count + + +def descendant_agents(root: Path, agents: list[Path]) -> list[Path]: + root_resolved = root.resolve() + return [ + agent + for agent in agents + if agent.parent != root_resolved and root_resolved in agent.parents + ] + + +def immediate_child_dirs(root: Path) -> list[Path]: + children = [] + for child in sorted(root.iterdir()): + if child.is_dir() and not should_skip_dir(child.name): + children.append(child) + return children + + +def summarize_child(child: Path, agents: list[Path]) -> DirSummary: + desc_agents = descendant_agents(child, agents) + return DirSummary( + path=child, + code_files=count_code_files(child), + nested_dirs=count_nested_dirs(child), + has_agents_here=(child / "AGENTS.md").exists(), + descendant_agents=len(desc_agents), + ) + + +def format_path(path: Path, cwd: Path) -> str: + try: + return str(path.resolve().relative_to(cwd)) + except ValueError: + return str(path.resolve()) + + +def nested_hotspots(summary: DirSummary, agents: list[Path], threshold: int) -> list[DirSummary]: + if summary.has_agents_here: + return [] + if summary.code_files < threshold: + return [] + + nested_summaries = [ + summarize_child(child, agents) for child in immediate_child_dirs(summary.path) + ] + return [ + nested + for nested in nested_summaries + if ( + not nested.has_agents_here + and nested.code_files >= threshold + and nested.descendant_agents == 0 + ) + ] + + +def print_scope(scope_dir: Path, agents: list[Path], cwd: Path, threshold: int) -> None: + scope_agent = scope_dir / "AGENTS.md" + child_agents = descendant_agents(scope_dir, agents) + + print(f"AGENTS: {format_path(scope_agent, cwd)}") + + if child_agents: + print(" child AGENTS:") + for agent in child_agents: + print(f" - {format_path(agent, cwd)}") + else: + print(" child AGENTS: none") + + children = immediate_child_dirs(scope_dir) + if not children: + print(" immediate child dirs: none") + print() + return + + print(" immediate child dirs:") + summaries = [summarize_child(child, agents) for child in children] + for summary in summaries: + marker = "has AGENTS" if summary.has_agents_here else "no AGENTS" + extra = "" + if summary.descendant_agents and not summary.has_agents_here: + extra = f", {summary.descendant_agents} deeper AGENTS" + print( + " - " + f"{format_path(summary.path, cwd)}/ -> " + f"{summary.code_files} code files, " + f"{summary.nested_dirs} nested dirs, " + f"{marker}{extra}" + ) + + hotspots = [ + summary + for summary in summaries + if ( + not summary.has_agents_here + and summary.code_files >= threshold + and summary.descendant_agents == 0 + ) + ] + if hotspots: + print(" hotspot children without AGENTS:") + for summary in hotspots: + print( + " - " + f"{format_path(summary.path, cwd)}/ -> " + f"{summary.code_files} code files, {summary.nested_dirs} nested dirs" + ) + else: + print(" hotspot children without AGENTS: none") + + second_level_hotspots = [] + for summary in summaries: + for nested in nested_hotspots(summary, agents, threshold): + second_level_hotspots.append((summary.path, nested)) + + if second_level_hotspots: + print(" nested hotspot children under umbrella dirs:") + for parent, nested in second_level_hotspots: + print( + " - " + f"{format_path(nested.path, cwd)}/ -> " + f"{nested.code_files} code files, {nested.nested_dirs} nested dirs " + f"(under {format_path(parent, cwd)}/)" + ) + else: + print(" nested hotspot children under umbrella dirs: none") + + print() + + +def main() -> int: + args = parse_args() + cwd = Path.cwd().resolve() + roots = [Path(root).resolve() for root in args.roots] + agents = discover_agents(roots) + + if not agents: + print("No AGENTS.md files found.") + return 0 + + print("# AGENTS inventory") + print(f"roots: {', '.join(format_path(root, cwd) for root in roots)}") + print() + + scope_dirs = sorted(agent.parent for agent in agents) + for scope_dir in scope_dirs: + print_scope(scope_dir, agents, cwd, args.hotspot_threshold) + + print("Notes:") + print("- This is a heuristic inventory, not an automatic decision engine.") + print("- A hotspot is only a candidate for a deeper AGENTS.md.") + print("- Always verify architecture claims against live code before reporting drift.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.agents/skills/review-pr/SKILL.md b/.agents/skills/review-pr/SKILL.md new file mode 100644 index 0000000..a01d2ce --- /dev/null +++ b/.agents/skills/review-pr/SKILL.md @@ -0,0 +1,285 @@ +--- +name: review-pr +description: Review a Dograh pull request, branch diff, or pasted patch for repo-specific security and correctness risks that are not obvious from generic FastAPI, Next.js, or Python conventions. Use when the user asks to review a PR, audit a diff, check whether changes are safe to merge, review their own changes, or asks what to look for in a Dograh PR. Focus on tenant isolation, route auth, webhook signing and org derivation, DB layering, worker-sync, migrations, generated SDK usage, and test hazards. +--- + +# Reviewing PRs (dograh) + +This skill is for reviewing any PR, including PRs written by maintainers. Focus on Dograh-specific regression risks. Skip generic lint, formatting, and type-check comments unless they connect to one of the repo-specific issues below. + +The main failure modes in this repo are: + +- Missing org scoping on request-reachable reads or writes +- Authless routes or websockets +- Trusting unsigned webhook fields +- SQL written outside `api/db/*_client.py` +- Per-worker cache state updated without worker sync +- UI calls that bypass the generated SDK +- Migrations that are not safe on existing production data + +## How to drive the review + +1. Get the diff: + - GitHub PR: `gh pr diff ` or `gh pr view --json files,additions,deletions` + - Local branch: `git diff origin/main...HEAD` +2. Bucket changed files into the sections below. +3. Read the current repo as source of truth before finalizing findings: + - `api/AGENTS.md` for org scoping and worker-sync + - `ui/AGENTS.md` for generated client rules + - Touched models, DB clients, routes, services, and migrations +4. Run only the sections relevant to the changed files. +5. Report findings as `: -> -> `. + +## Freshness rule + +Treat this file as review policy and navigation, not as a frozen inventory. + +- If the current repo conflicts with this skill, trust the repo and mention the drift. +- Do not rely on static allowlists or exact line numbers from this file. +- Review against the code in the PR and the current local repo, not against old prose. + +### File to section map + +| Path pattern in diff | Sections to run | +| ----------------------------------------------------- | --------------- | +| `api/routes/*.py` | 1, 2, 8 | +| `api/db/*_client.py`, `api/db/models.py` | 2, 3 | +| `api/services/**/*.py` | 2, 3, 4 | +| `api/tasks/*.py` | 2, 3, 5 | +| `api/alembic/versions/*.py` | 6 | +| `api/mcp_server/**`, `api/services/workflow/mcp_*.py` | 1, 2, 7 | +| `ui/**` | 9 | +| `api/constants.py`, anything `os.getenv` | 10 | +| `api/tests/**` | 11 | +| `api/schemas/*.py` | 12 | + +--- + +## 1. Route authentication (`api/routes/*.py`) + +There is no global auth middleware. Each route declares its own auth behavior. Forgetting one creates a silently public endpoint. + +Common auth deps from `api.services.auth.depends`: + +- `get_user` +- `get_user_ws` +- `get_superuser` + +Checks: + +- A new `@router.(...)` handler with no auth dependency is public. Treat that as a finding unless the current file already establishes a deliberate public auth pattern such as public token auth, signed webhook auth, or an equivalent websocket token flow. +- `get_user` on an impersonation, cross-org, or global reporting endpoint should usually be `get_superuser`. +- A route that reimplements bearer or API-key parsing instead of using the shared auth dependency is a finding. +- A websocket handler without `Depends(get_user_ws)` and without a clear public token path is a finding. +- A PR tightening `CORSMiddleware` to a fixed origin list needs strong justification. Dograh relies on cross-origin embedding; endpoint auth is the real control. + +Useful commands: + +```bash +rg -n "Depends\\((get_user|get_user_ws|get_superuser)\\)" api/routes +rg -n "@router\\.(get|post|put|delete|patch|websocket)" api/routes +``` + +--- + +## 2. Organization scoping (the cross-tenant rule) + +This is the highest priority rule in the repo. Every request-reachable read or write of an org-scoped resource must filter or validate by `organization_id`. + +Use `api/AGENTS.md` as the canonical summary. + +Determine scope from the current code: + +- Direct scope: the model has `organization_id` +- Indirect scope: the model reaches an org through a parent FK or relationship +- Legacy spelling may exist in old migrations or old code, but new runtime code should use `organization_id` + +Checks: + +- Any `*_by_id(...)` call in a route handler is suspicious. If request-reachable and unscoped, it is usually a finding. +- New `list_*` or `get_*` endpoints must filter in SQL, not in Python after `.all()`. +- If a request writes an FK to another org-scoped resource, the route must first fetch that target row with `user.selected_organization_id` and reject if it does not belong to the org. +- Services called from routes must preserve scoping. If a service method or DB client call drops `organization_id`, trace the caller. +- Background tasks do not get org context for free. They must reload the parent row and derive org from there. +- Webhooks must derive org from a signed or otherwise authenticated identifier, not from caller-supplied body fields like `organization_id`. +- New runtime code should use canonical `organization_id`, not `org_id`, `tenant_id`, or `organisation_id`. + +Useful commands: + +```bash +rg -n "_by_id\\(" api/routes api/services api/tasks +rg -n "db_client\\.get_\\w+\\(" api/routes api/services api/tasks +rg -n "organization_id|selected_organization_id" api/routes api/services api/tasks api/db +``` + +--- + +## 3. DB query layering (`api/db/` is the only home for SQL) + +Production SQL belongs in `api/db/*_client.py`. Routes, services, and tasks should call DB client methods, not write SQLAlchemy directly. + +Checks: + +- `select`, `update`, `delete`, `insert`, `AsyncSession`, `sessionmaker`, or `async_session` in `api/routes/`, `api/services/`, or `api/tasks/` is a finding. +- `api/services/admin_utils/` is the exception. It is not a template for production code. +- Session lifecycle belongs inside the DB client. +- New DB client parameters should use canonical `organization_id`. + +Useful commands: + +```bash +rg -n "(from sqlalchemy|AsyncSession|sessionmaker|async_session)" api/routes api/services api/tasks +``` + +--- + +## 4. Worker sync - multi-process state coherence (`api/services/worker_sync/`) + +Production runs multiple workers. Per-process mutable caches become stale unless updates are broadcast. + +Use `api/AGENTS.md` as the canonical summary. + +Checks: + +- A new module-level or class-level mutable cache written by an endpoint needs a `WorkerSyncManager` broadcast path. +- Local invalidation alone is not enough if other workers can still serve stale state. +- If a PR introduces a new cached object, the diff should usually contain all three: + - the broadcast call + - the event type or equivalent signal definition + - the handler registration that reloads fresh state + +--- + +## 5. Background tasks (`api/tasks/`, ARQ) + +Checks: + +- User-triggered enqueue paths must validate org ownership before enqueue. +- Tasks that accept IDs and reload rows must derive org from those rows, not assume shared context. +- Tasks must be idempotent or explicitly retry-safe. +- Only real task entrypoints belong in `api/tasks/arq.py::WorkerSettings.functions`. +- Secret logging rules from section 10 apply here too. + +--- + +## 6. Migrations (`api/alembic/versions/`) + +Checks: + +- `upgrade()` and `downgrade()` should both exist and be meaningfully reversible unless the change truly cannot be reversed. +- Adding a `NOT NULL` column to a populated table needs a safe default or a backfill before the constraint. +- Tightening nullable to non-nullable needs the backfill before `alter_column(..., nullable=False)`. +- New JSON columns should match the table's existing JSON or JSONB conventions. +- Large backfills in a migration should be questioned; they often belong out-of-band. +- Indexes on large tables need concurrent-safe handling. +- Do not turn historical migration naming into a finding by itself. Review the migration being changed, not old untouched migration prose. + +--- + +## 7. MCP server (`api/mcp_server/`) + +Checks: + +- New tools should use `authenticate_mcp_request()`, not reimplement API-key validation. +- New tool DB lookups should preserve org scoping just like REST routes. +- Tools that call external URLs must validate those URLs and consider SSRF. + +--- + +## 8. Telephony and webhook handlers + +Checks: + +- New provider webhook flows should implement `verify_inbound_signature()` or the provider equivalent. +- Minimal pre-verification work may be required to identify the candidate config, but the route should not do unrelated workflow, user, or stateful work before verification. +- Org derivation should come from provider identifiers that are validated by the webhook auth flow, then the derived org/config should drive downstream lookups. +- A webhook must not trust raw body `organization_id`. +- If a webhook references a phone number, validate that the number exists for the derived org. + +--- + +## 9. UI (`ui/`) - generated SDK only + +Use `ui/AGENTS.md` as the canonical summary. + +The frontend should talk to the backend through `ui/src/client/`. Raw `fetch` to internal `/api/v1/` routes is suspicious by default. + +Checks: + +- `fetch('/api/v1/...')` or `fetch(\`${backendUrl}/api/v1/...\`)` in app code is usually a finding unless the current code proves a narrow exception. +- Hardcoded backend URLs are a finding. +- Manual `Authorization` header construction in regular components is a finding; auth should be injected centrally. +- SDK calls fired before auth state is ready are a finding. +- Local interfaces that duplicate generated types are a finding. +- If backend API shape changed and the UI consumes it, `ui/src/client/` should usually change too. + +Useful commands: + +```bash +rg -n "fetch\\(['\"\\`].*api/v1" ui/src +rg -n "Authorization" ui/src +``` + +--- + +## 10. Logging, secrets, constants + +Checks: + +- New code should use Loguru, not stdlib `logging`. +- New `os.getenv(...)` outside `api/constants.py` is a finding. +- Do not log API keys, bearer tokens, credentials, full webhook bodies, or PII. + +Common offender shapes: + +- `logger.info(f"config: {config}")` +- `logger.debug(request_body)` +- Logging raw config or user configuration rows + +--- + +## 11. Tests (`api/tests/`) + +Checks: + +- Async waits in tests should use `asyncio.wait_for(...)` or another bounded timeout pattern. +- Tests should run against `.env.test`, not `.env`. +- Integration tests should not be neutered by replacing real DB behavior with mocks just to make the test pass. +- Tests that depend on mutable shared DB state across test cases are suspicious. + +--- + +## 12. Schemas (`api/schemas/`) + +Checks: + +- New response schemas should not expose internal FKs or IDs unless the caller genuinely needs them. +- Request schemas that accept org-scoped FK values are a trigger to inspect the corresponding route for section 2 ownership validation. + +--- + +## Final pass: shape the report + +Present findings in three buckets: + +- **Blocker** + - Missing org scope on a request-reachable lookup + - Route added without auth and without a proven deliberate public auth mechanism + - Webhook without signature verification, or significant unrelated work done before verification + - Migration without safe backfill or without a meaningful downgrade + - UI bypasses generated SDK for internal API calls + - Secrets logged + +- **Should-fix** + - Cached state mutated without worker sync + - JSON vs JSONB inconsistency + - Response schema leaks internal identifiers + - Backend API changed without client regen where UI consumes it + - Test path can hang indefinitely + +- **Nit** + - Naming inconsistencies + - Minor convention drift + - Low-risk schema or report-shape cleanup + +Cite `file:line` for each finding. Skip anything a formatter, linter, or IDE would already catch unless it connects to one of the repo-specific risks above. diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 72550b2..bcc3764 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.29.0" + ".": "1.31.0" } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index b74bf7a..3147249 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,21 +25,7 @@ dograh/ ## Local Development -### Starting Services - -```bash -# Start infrastructure services (postgres, redis, minio) -./scripts/start_services_dev.sh - -# Stop all services -./scripts/stop_services.sh -``` - -On Windows (PowerShell): -```powershell -.\scripts\start_services_dev.ps1 -.\scripts\stop_services.ps1 -``` +Contributor setup and service startup are documented in `docs/contribution/setup.mdx`. ## Environment Configuration diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c15044..58cd31a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog +## [1.31.0](https://github.com/dograh-hq/dograh/compare/dograh-v1.30.1...dograh-v1.31.0) (2026-05-21) + + +### Features + +* add agent skills to review PR ([#320](https://github.com/dograh-hq/dograh/issues/320)) ([151bf77](https://github.com/dograh-hq/dograh/commit/151bf77e40476b63000c1e88d2f348d5d2791344)) +* add chat based testing for voice agent ([#308](https://github.com/dograh-hq/dograh/issues/308)) ([d97d1d7](https://github.com/dograh-hq/dograh/commit/d97d1d72cd1a414442b8b9f66d8312950c06978c)) +* add Review AGENTS.md Skill ([d93d7af](https://github.com/dograh-hq/dograh/commit/d93d7aff4d5308ee17c55855f0ffd1ed9f90449f)) +* add Tuner Integration to Dograh ([#311](https://github.com/dograh-hq/dograh/issues/311)) ([5f28c1b](https://github.com/dograh-hq/dograh/commit/5f28c1b2a9b17ed19f8a2b4118d1d4eb8c4249a7)) +* **mcp:** add search_docs tool over docs corpus (closes [#295](https://github.com/dograh-hq/dograh/issues/295)) ([#316](https://github.com/dograh-hq/dograh/issues/316)) ([5762095](https://github.com/dograh-hq/dograh/commit/5762095edfa585fa078ba70d486bc7af14708457)) +* **mcp:** generic MCP tool source with per-node function filtering ([#301](https://github.com/dograh-hq/dograh/issues/301)) ([75839f9](https://github.com/dograh-hq/dograh/commit/75839f9de5eb26ccc296235af36058e442d10d58)) + + +### Bug Fixes + +* **security:** bump python-multipart 0.0.20 -> 0.0.27 ([#332](https://github.com/dograh-hq/dograh/issues/332)) ([332754a](https://github.com/dograh-hq/dograh/commit/332754a809ec14b9164c698fb3eff682b1d9d446)) +* **stt:** align Speechmatics language registry with official transcription codes ([#317](https://github.com/dograh-hq/dograh/issues/317)) ([afa78fe](https://github.com/dograh-hq/dograh/commit/afa78fe859e51d45b12dedd01613f2c24ffc7f65)) +* **webRTC:** LAN IP filtering ([#333](https://github.com/dograh-hq/dograh/issues/333)) ([af66372](https://github.com/dograh-hq/dograh/commit/af66372b655f05f4fc8e778ec58902e15ce25531)) + + +### Documentation + +* add Simplified Chinese translation of README ([#305](https://github.com/dograh-hq/dograh/issues/305)) ([5b1e398](https://github.com/dograh-hq/dograh/commit/5b1e3980b1982506aa334d19ab594db04ef9e19c)) + +## [1.30.1](https://github.com/dograh-hq/dograh/compare/dograh-v1.30.0...dograh-v1.30.1) (2026-05-17) + + +### Bug Fixes + +* fix race between context init and keepalive for Dograh TTS ([ba7d45f](https://github.com/dograh-hq/dograh/commit/ba7d45fde054e30eb717f7912283d71647bdce2c)) + +## [1.30.0](https://github.com/dograh-hq/dograh/compare/dograh-v1.29.0...dograh-v1.30.0) (2026-05-16) + + +### Features + +* add openai realtime models ([#298](https://github.com/dograh-hq/dograh/issues/298)) ([2381a80](https://github.com/dograh-hq/dograh/commit/2381a803ade54f6c8d1db572e0f6c3301dd74c20)) + + +### Bug Fixes + +* force FORCE_TURN_RELAY for local IPs in setup ([fc04f31](https://github.com/dograh-hq/dograh/commit/fc04f31639e0d326525d6840ca117babe2b25ea8)) +* provider resolution in telephony cost calculation post workflow integration calls ([0523dcb](https://github.com/dograh-hq/dograh/commit/0523dcb079410803a54deec49afda98cbb96e7bd)) + + +### Documentation + +* add telnyx to telephony providers supporting call transfer ([4ff1f57](https://github.com/dograh-hq/dograh/commit/4ff1f576f0a5e079466318d6e99d27eada6abc9e)) +* update README.md ([ea13492](https://github.com/dograh-hq/dograh/commit/ea13492a894af11410c7c54500f4bdc6fa0c2cda)) + ## [1.29.0](https://github.com/dograh-hq/dograh/compare/dograh-v1.28.0...dograh-v1.29.0) (2026-05-13) diff --git a/README.md b/README.md index 7688342..369767c 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,71 @@ # Dograh AI -

If you find value in this project, please STAR the Github repository to help others discover our FOSS platform!

+**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.

- - Docs: https://docs.dograh.com + + Try the Cloud - - Deepwiki: https://deepwiki.com/dograh-hq/dograh - - - License: BSD 2-Clause +   + + Self-host in 60s +   - Slack Community - - - Docker Ready + Join Slack

-**The open-source alternative to Vapi** - Dograh helps you build your own voice agents with an easy drag-and-drop workflow builder. It's the fastest way to build voice AI agents - from zero to working bot in under 2 minutes (our hard SLA standards). +

+ 📖 Docs  ·  + 📜 BSD 2-Clause  ·  + 🌐 中文 +

-- **100% open source**, self-hostable platform - no vendor lock-in, unlike proprietary solutions like Vapi -- **Full control & transparency** - every line of code is open, with built-in AI testing personas and flexible LLM/TTS/STT integration -- **Maintained by YC alumni and exit founders**, ensuring the future of voice AI stays open, not monopolized +

+ Dograh in action — build a workflow, launch a voice agent, talk to it +

-## 🎥 Demo Video +- **100% open source**, self-hostable — no vendor lock-in, unlike Vapi or Retell +- **Full control & transparency** — every line of code is open, with flexible LLM / TTS / STT integration +- **Maintained by YC alumni and exit founders**, committed to keeping voice AI open + +## 🎥 Featured + +
+ + Dograh featured by Better Stack + +
+ Featured by Better Stack — a hands-on look at Dograh +
+ +
+📺 Prefer a 2-minute product walkthrough? Click here.
- Watch Dograh AI Demo Video + Watch Dograh AI Demo Video -
- Click to watch a 2-minute demo of Dograh AI in action
+
+ +## ⚖️ Dograh vs Vapi vs Retell + +An honest comparison on the axes that matter most to teams evaluating voice AI platforms. + +| | **Dograh** | **Vapi** | **Retell** | +|---|---|---|---| +| **License** | BSD 2-Clause (open source) | Proprietary | Proprietary | +| **Self-hostable** | ✅ Yes — one Docker command | ❌ SaaS only | ❌ SaaS only | +| **Pricing** | Free (self-host) · usage-based (cloud) | Per-minute SaaS | Per-minute SaaS | +| **Bring your own LLM / STT / TTS** | ✅ Any provider, or use Dograh's stack | Configurable within their integrations | Configurable within their integrations | +| **Source-level customization** | ✅ Every line is yours to modify | ❌ Closed source | ❌ Closed source | +| **Data residency** | Your infra, your rules | Their cloud | Their cloud | +| **Vendor lock-in** | None | Full | Full | + + ## 🚀 Get Started ##### Download and setup Dograh on your Local Machine @@ -56,30 +86,17 @@ curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/m ### 🎙️ Your First Voice Bot -1. **Open Dashboard**: Launch [http://localhost:3010](http://localhost:3010) on your browser -2. **Choose Call Type**: Select **Inbound** or **Outbound** calling. -3. **Name Your Bot**: Use a short two-word name (e.g., _Lead Qualification_). -4. **Describe Use Case**: In 5–10 words (e.g., _Screen insurance form submissions for purchase intent_). -5. **Launch**: Your bot is ready! Open the bot and click **Web Call** to talk to it. -6. **No API Keys Needed**: We auto-generate Dograh API keys so you can start immediately. You can switch to your own keys anytime. -7. **Default Access**: Includes Dograh’s own LLMs, STT, and TTS stack by default. -8. **Bring Your Own Keys**: Optionally connect your own API keys for LLMs, STT, TTS, or telephony providers like Twilio. +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 5–10 words (e.g. _Screen insurance form submissions for purchase intent_). +3. Click **Web Call** — you're talking to your bot. -## Quick Summary - -⚡ **Open-source alternative to Vapi** - 2-minute setup with hard SLA standards - -- 🔧 **No vendor lock-in**: Self-hostable platform vs proprietary SaaS solutions -- 🤖 **AI Testing Personas**: Test your bots with LoopTalk AI that mimics real customer interactions -- 🔓 **100% Open Source**: Every line of code is open - no hidden logic, no black boxes (unlike Vapi) -- 🔄 **Flexible Integration**: Bring your own LLM, TTS, or STT - or use Dograh's APIs -- ☁️ **Deploy anywhere**: Self-host or use our hosted version at app.dograh.com +> 🔑 **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. ## Features ### Voice Capabilities -- Telephony: Built-in telephony integration like Twilio, Vonage, Vobiz, Cloudonix (easily add others) +- 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 @@ -93,13 +110,9 @@ curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/m ### Testing & Quality -- LoopTalk (Beta): Create AI personas to test your voice agents -- Workflow Testing: Test specific workflow IDs with automated calls -- Real-world Simulation: AI personas that mimic actual customer behavior - -## Architecture - -Architecture diagram _(coming soon)_ +- **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 ## Deployment Options @@ -111,10 +124,6 @@ Refer [Local Setup](https://docs.dograh.com/contribution/setup) For detailed deployment instructions including remote server setup with HTTPS, see our [Docker Deployment Guide](https://docs.dograh.com/deployment/docker). -### Production (Self-Hosted) - -Production guide coming soon. [Drop in a message](https://join.slack.com/t/dograh-community/shared_invite/zt-3czr47sw5-MSg1J0kJ7IMPOCHF~03auQ) for assistance. - ### Cloud Version Visit [https://www.dograh.com](https://www.dograh.com/) for our managed cloud offering. @@ -123,14 +132,18 @@ Visit [https://www.dograh.com](https://www.dograh.com/) for our managed cloud of You can go to [https://docs.dograh.com](https://docs.dograh.com/) for our documentation. +## 📦 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) + ## 🤝Community & Support -- GitHub Issues: Report bugs or request features -- Slack: Our Slack community is not just for support — it’s the cornerstone of Dograh AI contributions. Here, you can: - - Connect with maintainers and other contributors - - Discuss issues and features before coding - - Get help with setup and debugging - - Stay up to date with contribution sprints +> 👋 **Coming from the Better Stack video?** Drop your use case in our [pinned GitHub Discussion](https://github.com/orgs/dograh-hq/discussions/291) — we read every reply and the founders personally onboard early adopters. + +- **Slack** — the cornerstone of Dograh AI contributions. Connect with maintainers, discuss features before coding, get help with setup, and stay current on contribution sprints. +- **GitHub Discussions** — share use cases, ask questions, swap workflow recipes. +- **GitHub Issues** — report bugs or request features. 👉 Join us → [Dograh Community Slack](https://join.slack.com/t/dograh-community/shared_invite/zt-3czr47sw5-MSg1J0kJ7IMPOCHF~03auQ) @@ -146,6 +159,12 @@ We love contributions! Dograh AI is 100% open source and we intend to keep it th - Push to the branch (git push origin feature/AmazingFeature) - Open a Pull Request +## ⭐ Star History + + + Dograh star history + + ## 📄 License Dograh AI is licensed under the [BSD 2-Clause License](LICENSE)- the same license as projects that were used in building Dograh AI, ensuring compatibility and freedom to use, modify, and distribute. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..0ab8448 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,182 @@ +# 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 替代方案** —— 通过拖拽式工作流编辑器构建生产级语音智能体,2 分钟内即可上线一个能用的语音机器人。 + +

+ + 体验云端版本 + +   + + 60 秒自托管 + +   + + 加入 Slack + +

+ +

+ 📖 文档  ·  + 📜 BSD 2-Clause  ·  + 🌐 English +

+ +

+ Dograh 实战演示 —— 搭建工作流、启动语音智能体、直接对话 +

+ +- **100% 开源**,可自托管 —— 不像 Vapi 或 Retell,没有任何厂商绑定 +- **完全可控且透明** —— 每一行代码都是开放的,LLM / TTS / STT 集成灵活可换 +- **由 YC 校友与连续创业者维护**,致力于让语音 AI 始终保持开放 + +## 🎥 媒体推荐 + +
+ + Better Stack 介绍 Dograh + +
+ Better Stack 上手实测 —— 深入体验 Dograh +
+ +
+📺 想看 2 分钟产品快速演示?点这里。 + + + +
+ +## ⚖️ Dograh vs Vapi vs Retell + +针对正在评估语音 AI 平台的团队,这里是一份在最关键的维度上诚实的对比。 + +| | **Dograh** | **Vapi** | **Retell** | +|---|---|---|---| +| **协议** | BSD 2-Clause(开源) | 闭源 | 闭源 | +| **可自托管** | ✅ 可以 —— 一条 Docker 命令 | ❌ 仅 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 && REGISTRY=ghcr.io/dograh-hq ENABLE_TELEMETRY=true docker compose up --pull always +``` + +> **提示** +> 首次启动需要 2-3 分钟拉取所有镜像。启动完成后,打开 http://localhost:3010 即可创建你的第一个 AI 语音助手! +> 常见问题及解决方案请参见 🔧 **[故障排查](docs/troubleshooting.md)**。 + +### 🎙️ 你的第一个语音机器人 + +1. 在浏览器中打开 [http://localhost:3010](http://localhost:3010)。 +2. 选择 **Inbound(呼入)** 或 **Outbound(外呼)**,为机器人命名(例如 _销售线索筛选_),再用 5-10 个词描述用途(例如 _筛选保险表单中的购买意向_)。 +3. 点击 **Web Call**,直接和你的机器人对话。 + +> 🔑 **无需 API Key。** Dograh 自带一套自动生成的密钥,以及内置的 LLM / TTS / STT 栈。你可以随时接入自己的 LLM、TTS、STT 或电信服务商(如 Twilio、Vonage、Telnyx)。 + +## 功能特性 + +### 语音能力 + +- 电信集成:内置 Twilio、Vonage、Vobiz、Cloudonix 等(其他厂商也易于扩展),支持转接到人工坐席 +- 语言:支持英语(可扩展到其他语言) +- 自定义模型:可接入自己的 TTS / STT 模型 +- 实时处理:低延迟语音交互 + +### 开发者体验 + +- 零配置启动:自动生成 API Key,即开即用 +- 基于 Python:基于 Python 构建,便于二次开发 +- Docker 优先:容器化部署,环境一致 +- 模块化架构:按需替换各个组件 + +### 测试与质量 + +- **测试模式**:在发布前端到端试跑你的智能体,既不会产生真实通话,也不会影响生产数据 +- **面板内 Web 通话**:在搭建过程中直接和机器人对话,无需配置任何电信服务 +- **QA 节点**:内置的工作流节点,可分析其他节点中提示词的质量 + +## 部署方式 + +### 本地开发 + +参见[本地部署](https://docs.dograh.com/contribution/setup)。 + +### 自托管部署 + +如需了解远程服务器部署及 HTTPS 配置的详细步骤,请参见我们的 [Docker 部署指南](https://docs.dograh.com/deployment/docker)。 + +### 云端版本 + +托管云版本请访问 [https://www.dograh.com](https://www.dograh.com/)。 + +## 📚 文档 + +完整文档请访问 [https://docs.dograh.com](https://docs.dograh.com/)。 + +## 🤝 社区与支持 + +> 👋 **从 Better Stack 视频过来的朋友?** 欢迎在我们[置顶的 GitHub Discussion](https://github.com/orgs/dograh-hq/discussions/291) 里留下你的使用场景 —— 每一条留言我们都会看,创始团队会亲自对接早期用户。 + +- **Slack** —— Dograh AI 协作的主阵地。在这里和维护者交流、在动手前讨论功能、获取部署帮助,并跟进每一轮贡献活动。 +- **GitHub Discussions** —— 分享使用场景、提问、交流工作流配方。 +- **GitHub Issues** —— 报告 bug 或提交功能请求。 + +👉 加入我们 → [Dograh 社区 Slack](https://join.slack.com/t/dograh-community/shared_invite/zt-3czr47sw5-MSg1J0kJ7IMPOCHF~03auQ) + +## 🙌 参与贡献 + +我们欢迎一切贡献!Dograh AI 100% 开源,也会一直保持下去。 + +### 入门步骤 + +- Fork 本仓库 +- 创建特性分支(`git checkout -b feature/AmazingFeature`) +- 提交你的改动(`git commit -m 'Add some AmazingFeature'`) +- 推送到该分支(`git push origin feature/AmazingFeature`) +- 提交一个 Pull Request + +## ⭐ Star 历史 + + + Dograh 的 Star 历史 + + +## 📄 许可协议 + +Dograh AI 基于 [BSD 2-Clause 协议](LICENSE)开源 —— 与构建 Dograh AI 时所采用的项目使用相同的协议,确保兼容性,以及自由使用、修改和分发的权利。 + +## 🏢 关于我们 + +由 **Dograh**(Zansat Technologies Private Limited)用 ❤️ 打造。 +创始团队由 YC 校友与连续创业者组成,致力于让语音 AI 始终开放、人人可用。 + +


+ +

+ ⭐ 给我们一个 Star | + ☁️ 试用云端版本 | + 💬 加入 Slack +

diff --git a/api/AGENTS.md b/api/AGENTS.md index f57e8e8..3988ee7 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -6,35 +6,44 @@ FastAPI backend for the Dograh voice AI platform. ``` api/ -├── app.py # Application entry point, FastAPI setup ├── routes/ # API endpoint handlers -├── services/ # Business logic and integrations +├── services/ # Domain logic, runtime systems, and extension seams ├── db/ # Database models and data access ├── schemas/ # Pydantic request/response schemas -├── tasks/ # Background jobs (ARQ) -├── utils/ # Utility functions +├── tasks/ # Background jobs and post-call work +├── mcp_server/ # MCP surface exposed by the backend +├── utils/ # Shared utilities ├── alembic/ # Database migrations -├── constants.py # Environment variables and constants └── tests/ # Test suite ``` ## Where to Find Things -| Looking for... | Go to... | -| ---------------------- | ------------------------------------------------------------------------ | -| API endpoints | `routes/` - each file is a router module, aggregated in `routes/main.py` | -| Business logic | `services/` - organized by domain (telephony, workflow, campaign, etc.) | -| Database models | `db/models.py` | -| Database queries | `db/*_client.py` files (repository pattern) | -| Request/response types | `schemas/` | -| Background tasks | `tasks/` - uses ARQ for async job processing | -| Environment config | `constants.py` | +| Looking for... | Go to... | +| ---------------------------- | ----------------------------------------------------------------------------- | +| API endpoints | `routes/` - domain routers mounted under `/api/v1` | +| Workflow graph and node data | `services/workflow/` | +| Live pipeline runtime | `services/pipecat/` | +| Telephony providers/call flow| `services/telephony/` | +| Third-party integrations | `services/integrations/` | +| Campaign and other domains | `services/` | +| Database access | `db/` | +| Request/response types | `schemas/` | +| Background jobs | `tasks/` | +| MCP backend surface | `mcp_server/` | +| Tests | `tests/` | ## API Structure - All routes are mounted at `/api/v1` prefix -- Routes are organized by domain (workflow, telephony, campaign, user, etc.) -- `routes/main.py` aggregates all routers +- Routes are organized by domain under `routes/` +- Workflow execution spans `services/workflow/`, `services/pipecat/`, and `tasks/` +- Telephony is a full subsystem under `services/telephony/`, with provider-specific packages under `services/telephony/providers/` +- Integrations extend through `services/integrations/`; package-specific rules should live in that subtree's own `AGENTS.md` + +## Routes vs Service Layer + +**Keep route handlers thin** — parse/validate the request, resolve auth and `organization_id`, delegate, shape the response. Domain logic (orchestration, business rules, external calls, computation) belongs in `services/`. Before adding logic to a handler, find its home: extend an existing `services//` module that owns the concern (see *Where to Find Things*) before adding a focused new module; never a catch-all. Keep DB access in `db/` clients — routes call services, services call DB clients. Litmus test: if `tasks/`, `mcp_server/`, or another route could reuse it, it must live in `services/` to be importable. ## Database Migrations diff --git a/api/Dockerfile b/api/Dockerfile index bcda1a6..b871000 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -20,7 +20,7 @@ RUN pip install --user --no-cache-dir -r requirements.txt && \ # Copy and install pipecat from local submodule COPY pipecat /tmp/pipecat -RUN pip install --user --no-cache-dir '/tmp/pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb]' && \ +RUN pip install --user --no-cache-dir '/tmp/pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb,mcp]' && \ # Swap opencv-python (pulled by pipecat[webrtc]) for opencv-python-headless # to drop X11/Qt dependencies that otherwise require libxcb etc. in runner. pip uninstall -y opencv-python && \ diff --git a/api/alembic/versions/0a1b2c3d4e5f_add_mcp_in_toolcategory.py b/api/alembic/versions/0a1b2c3d4e5f_add_mcp_in_toolcategory.py new file mode 100644 index 0000000..34b006a --- /dev/null +++ b/api/alembic/versions/0a1b2c3d4e5f_add_mcp_in_toolcategory.py @@ -0,0 +1,64 @@ +"""add mcp in ToolCategory + +Revision ID: 0a1b2c3d4e5f +Revises: 4c1f1e3e8ef2 +Create Date: 2026-05-16 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +from alembic_postgresql_enum import TableReference + +# revision identifiers, used by Alembic. +revision: str = "0a1b2c3d4e5f" +down_revision: Union[str, None] = "4c1f1e3e8ef2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values( + enum_schema="public", + enum_name="tool_category", + new_values=[ + "http_api", + "end_call", + "transfer_call", + "calculator", + "native", + "integration", + "mcp", + ], + affected_columns=[ + TableReference( + table_schema="public", table_name="tools", column_name="category" + ) + ], + enum_values_to_rename=[], + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.sync_enum_values( + enum_schema="public", + enum_name="tool_category", + new_values=[ + "http_api", + "end_call", + "transfer_call", + "calculator", + "native", + "integration", + ], + affected_columns=[ + TableReference( + table_schema="public", table_name="tools", column_name="category" + ) + ], + enum_values_to_rename=[], + ) diff --git a/api/alembic/versions/19d2a4b6c8ef_rename_integrations_organisation_id.py b/api/alembic/versions/19d2a4b6c8ef_rename_integrations_organisation_id.py new file mode 100644 index 0000000..fc394e1 --- /dev/null +++ b/api/alembic/versions/19d2a4b6c8ef_rename_integrations_organisation_id.py @@ -0,0 +1,34 @@ +"""rename integrations organisation_id to organization_id + +Revision ID: 19d2a4b6c8ef +Revises: 0a1b2c3d4e5f + +Create Date: 2026-05-19 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "19d2a4b6c8ef" +down_revision: Union[str, None] = "0a1b2c3d4e5f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.alter_column( + "integrations", + "organisation_id", + new_column_name="organization_id", + ) + + +def downgrade() -> None: + op.alter_column( + "integrations", + "organization_id", + new_column_name="organisation_id", + ) diff --git a/api/alembic/versions/2f638891cbb6_add_workflow_run_text_sessions.py b/api/alembic/versions/2f638891cbb6_add_workflow_run_text_sessions.py new file mode 100644 index 0000000..384f0f8 --- /dev/null +++ b/api/alembic/versions/2f638891cbb6_add_workflow_run_text_sessions.py @@ -0,0 +1,64 @@ +"""add workflow_run_text_sessions + +Revision ID: 2f638891cbb6 +Revises: 19d2a4b6c8ef +Create Date: 2026-05-18 12:58:58.573381 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "2f638891cbb6" +down_revision: Union[str, None] = "19d2a4b6c8ef" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "workflow_run_text_sessions", + sa.Column("workflow_run_id", sa.Integer(), nullable=False), + sa.Column( + "revision", sa.Integer(), server_default=sa.text("0"), nullable=False + ), + sa.Column( + "session_data", + sa.JSON(), + server_default=sa.text("'{}'::json"), + nullable=False, + ), + sa.Column( + "checkpoint", + sa.JSON(), + server_default=sa.text("'{}'::json"), + nullable=False, + ), + 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.PrimaryKeyConstraint("workflow_run_id"), + ) + op.create_index( + "ix_workflow_run_text_sessions_updated_at", + "workflow_run_text_sessions", + ["updated_at"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + "ix_workflow_run_text_sessions_updated_at", + table_name="workflow_run_text_sessions", + ) + op.drop_table("workflow_run_text_sessions") + # ### end Alembic commands ### diff --git a/api/alembic/versions/4c1f1e3e8ef2_drop_looptalk_tables.py b/api/alembic/versions/4c1f1e3e8ef2_drop_looptalk_tables.py new file mode 100644 index 0000000..4647aba --- /dev/null +++ b/api/alembic/versions/4c1f1e3e8ef2_drop_looptalk_tables.py @@ -0,0 +1,204 @@ +"""drop_looptalk_tables + +Revision ID: 4c1f1e3e8ef2 +Revises: 6499c608d0f6 +Create Date: 2026-05-16 14:46:18.296517 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "4c1f1e3e8ef2" +down_revision: Union[str, None] = "6499c608d0f6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Drop child table first so its FK to looptalk_test_sessions is removed before the parent is dropped. + op.drop_index( + op.f("ix_looptalk_conversations_session_id"), + table_name="looptalk_conversations", + ) + op.drop_table("looptalk_conversations") + op.drop_index( + op.f("ix_looptalk_test_sessions_group_id"), table_name="looptalk_test_sessions" + ) + op.drop_index( + op.f("ix_looptalk_test_sessions_load_test_group_id"), + table_name="looptalk_test_sessions", + ) + op.drop_index( + op.f("ix_looptalk_test_sessions_org_id"), table_name="looptalk_test_sessions" + ) + op.drop_index( + op.f("ix_looptalk_test_sessions_status"), table_name="looptalk_test_sessions" + ) + op.drop_table("looptalk_test_sessions") + sa.Enum( + "pending", "running", "completed", "failed", name="test_session_status" + ).drop(op.get_bind()) + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + sa.Enum( + "pending", "running", "completed", "failed", name="test_session_status" + ).create(op.get_bind()) + op.create_table( + "looptalk_conversations", + sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column("test_session_id", sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column("duration_seconds", sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column( + "actor_recording_url", sa.VARCHAR(), autoincrement=False, nullable=True + ), + sa.Column( + "adversary_recording_url", sa.VARCHAR(), autoincrement=False, nullable=True + ), + sa.Column( + "combined_recording_url", sa.VARCHAR(), autoincrement=False, nullable=True + ), + sa.Column( + "transcript", + postgresql.JSON(astext_type=sa.Text()), + autoincrement=False, + nullable=False, + ), + sa.Column( + "metrics", + postgresql.JSON(astext_type=sa.Text()), + autoincrement=False, + nullable=False, + ), + sa.Column( + "created_at", + postgresql.TIMESTAMP(timezone=True), + autoincrement=False, + nullable=True, + ), + sa.Column( + "ended_at", + postgresql.TIMESTAMP(timezone=True), + autoincrement=False, + nullable=True, + ), + sa.ForeignKeyConstraint( + ["test_session_id"], + ["looptalk_test_sessions.id"], + name=op.f("looptalk_conversations_test_session_id_fkey"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("looptalk_conversations_pkey")), + ) + op.create_index( + op.f("ix_looptalk_conversations_session_id"), + "looptalk_conversations", + ["test_session_id"], + unique=False, + ) + op.create_table( + "looptalk_test_sessions", + sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column("organization_id", sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column("name", sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column( + "status", + postgresql.ENUM( + "pending", + "running", + "completed", + "failed", + name="test_session_status", + create_type=False, + ), + autoincrement=False, + nullable=False, + ), + sa.Column( + "actor_workflow_id", sa.INTEGER(), autoincrement=False, nullable=False + ), + sa.Column( + "adversary_workflow_id", sa.INTEGER(), autoincrement=False, nullable=False + ), + sa.Column( + "load_test_group_id", sa.VARCHAR(), autoincrement=False, nullable=True + ), + sa.Column("test_index", sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column( + "config", + postgresql.JSON(astext_type=sa.Text()), + autoincrement=False, + nullable=False, + ), + sa.Column( + "results", + postgresql.JSON(astext_type=sa.Text()), + autoincrement=False, + nullable=False, + ), + sa.Column("error", sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column( + "created_at", + postgresql.TIMESTAMP(timezone=True), + autoincrement=False, + nullable=True, + ), + sa.Column( + "started_at", + postgresql.TIMESTAMP(timezone=True), + autoincrement=False, + nullable=True, + ), + sa.Column( + "completed_at", + postgresql.TIMESTAMP(timezone=True), + autoincrement=False, + nullable=True, + ), + sa.ForeignKeyConstraint( + ["actor_workflow_id"], + ["workflows.id"], + name=op.f("looptalk_test_sessions_actor_workflow_id_fkey"), + ), + sa.ForeignKeyConstraint( + ["adversary_workflow_id"], + ["workflows.id"], + name=op.f("looptalk_test_sessions_adversary_workflow_id_fkey"), + ), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.id"], + name=op.f("looptalk_test_sessions_organization_id_fkey"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("looptalk_test_sessions_pkey")), + ) + op.create_index( + op.f("ix_looptalk_test_sessions_status"), + "looptalk_test_sessions", + ["status"], + unique=False, + ) + op.create_index( + op.f("ix_looptalk_test_sessions_org_id"), + "looptalk_test_sessions", + ["organization_id"], + unique=False, + ) + op.create_index( + op.f("ix_looptalk_test_sessions_load_test_group_id"), + "looptalk_test_sessions", + ["load_test_group_id"], + unique=False, + ) + op.create_index( + op.f("ix_looptalk_test_sessions_group_id"), + "looptalk_test_sessions", + ["load_test_group_id"], + unique=False, + ) + # ### end Alembic commands ### diff --git a/api/alembic/versions/6bd9f67ec994_add_folders_and_workflow_folder_id.py b/api/alembic/versions/6bd9f67ec994_add_folders_and_workflow_folder_id.py new file mode 100644 index 0000000..b61cd49 --- /dev/null +++ b/api/alembic/versions/6bd9f67ec994_add_folders_and_workflow_folder_id.py @@ -0,0 +1,61 @@ +"""add folders and workflow folder_id + +Revision ID: 6bd9f67ec994 +Revises: 2f638891cbb6 +Create Date: 2026-05-22 12:52:30.737380 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "6bd9f67ec994" +down_revision: Union[str, None] = "2f638891cbb6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "folders", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("organization_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("organization_id", "name", name="uq_folder_org_name"), + ) + op.create_index( + op.f("ix_folders_organization_id"), "folders", ["organization_id"], unique=False + ) + op.add_column("workflows", sa.Column("folder_id", sa.Integer(), nullable=True)) + op.create_index( + op.f("ix_workflows_folder_id"), "workflows", ["folder_id"], unique=False + ) + op.create_foreign_key( + "fk_workflows_folder_id", + "workflows", + "folders", + ["folder_id"], + ["id"], + ondelete="SET NULL", + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("fk_workflows_folder_id", "workflows", type_="foreignkey") + op.drop_index(op.f("ix_workflows_folder_id"), table_name="workflows") + op.drop_column("workflows", "folder_id") + op.drop_index(op.f("ix_folders_organization_id"), table_name="folders") + op.drop_table("folders") + # ### end Alembic commands ### diff --git a/api/constants.py b/api/constants.py index a98612f..ef949eb 100644 --- a/api/constants.py +++ b/api/constants.py @@ -143,11 +143,4 @@ FORCE_TURN_RELAY = os.getenv("FORCE_TURN_RELAY", "false").lower() == "true" OSS_JWT_SECRET = os.getenv("OSS_JWT_SECRET", "change-me-in-production") OSS_JWT_EXPIRY_HOURS = int(os.getenv("OSS_JWT_EXPIRY_HOURS", "720")) # 30 days -# REMOVE-AFTER 2026-05-15: transitional flag. When True, Telnyx webhook -# signature verification is skipped for configs that have no -# webhook_public_key set (existing configs predating the field). Set in prod -# through 2026-05-15 to give users time to add their key; once removed, -# configs without a key will fail signature verification. -TELNYX_WEBHOOK_VERIFICATION_OPTIONAL = ( - os.getenv("TELNYX_WEBHOOK_VERIFICATION_OPTIONAL", "false").lower() == "true" -) +TUNER_BASE_URL = os.getenv("TUNER_BASE_URL", "https://api.usetuner.ai") diff --git a/api/db/db_client.py b/api/db/db_client.py index 35bf800..de98cf1 100644 --- a/api/db/db_client.py +++ b/api/db/db_client.py @@ -2,9 +2,9 @@ from api.db.agent_trigger_client import AgentTriggerClient from api.db.api_key_client import APIKeyClient from api.db.campaign_client import CampaignClient from api.db.embed_token_client import EmbedTokenClient +from api.db.folder_client import FolderClient from api.db.integration_client import IntegrationClient from api.db.knowledge_base_client import KnowledgeBaseClient -from api.db.looptalk_client import LoopTalkClient from api.db.organization_client import OrganizationClient from api.db.organization_configuration_client import OrganizationConfigurationClient from api.db.organization_usage_client import OrganizationUsageClient @@ -17,19 +17,20 @@ from api.db.webhook_credential_client import WebhookCredentialClient from api.db.workflow_client import WorkflowClient from api.db.workflow_recording_client import WorkflowRecordingClient from api.db.workflow_run_client import WorkflowRunClient +from api.db.workflow_run_text_session_client import WorkflowRunTextSessionClient from api.db.workflow_template_client import WorkflowTemplateClient class DBClient( WorkflowClient, WorkflowRunClient, + WorkflowRunTextSessionClient, UserClient, OrganizationClient, OrganizationConfigurationClient, OrganizationUsageClient, IntegrationClient, WorkflowTemplateClient, - LoopTalkClient, CampaignClient, ReportsClient, APIKeyClient, @@ -41,6 +42,7 @@ class DBClient( WorkflowRecordingClient, TelephonyConfigurationClient, TelephonyPhoneNumberClient, + FolderClient, ): """ Unified database client that combines all specialized database operations. @@ -54,7 +56,6 @@ class DBClient( - OrganizationUsageClient: handles organization usage and quota operations - IntegrationClient: handles integration operations - WorkflowTemplateClient: handles workflow template operations - - LoopTalkClient: handles LoopTalk testing operations - CampaignClient: handles campaign operations - ReportsClient: handles reports and analytics operations - APIKeyClient: handles API key operations @@ -63,6 +64,7 @@ class DBClient( - WebhookCredentialClient: handles webhook credential operations - 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) """ pass diff --git a/api/db/folder_client.py b/api/db/folder_client.py new file mode 100644 index 0000000..ac5974e --- /dev/null +++ b/api/db/folder_client.py @@ -0,0 +1,115 @@ +from sqlalchemy import func +from sqlalchemy.exc import IntegrityError +from sqlalchemy.future import select + +from api.db.base_client import BaseDBClient +from api.db.models import FolderModel, WorkflowModel +from api.enums import WorkflowStatus + + +class FolderNameConflictError(Exception): + """Raised when a folder name already exists within the organization.""" + + +class FolderClient(BaseDBClient): + async def create_folder(self, name: str, organization_id: int) -> FolderModel: + async with self.async_session() as session: + folder = FolderModel(name=name, organization_id=organization_id) + session.add(folder) + try: + await session.commit() + except IntegrityError: + await session.rollback() + raise FolderNameConflictError( + f"A folder named '{name}' already exists." + ) + await session.refresh(folder) + return folder + + async def get_folder( + self, folder_id: int, organization_id: int + ) -> FolderModel | None: + """Fetch a single folder scoped to the organization (tenant isolation).""" + async with self.async_session() as session: + result = await session.execute( + select(FolderModel).where( + FolderModel.id == folder_id, + FolderModel.organization_id == organization_id, + ) + ) + return result.scalar_one_or_none() + + async def list_folders(self, organization_id: int) -> list[FolderModel]: + async with self.async_session() as session: + result = await session.execute( + select(FolderModel) + .where(FolderModel.organization_id == organization_id) + .order_by(FolderModel.name.asc()) + ) + return result.scalars().all() + + async def rename_folder( + self, folder_id: int, name: str, organization_id: int + ) -> FolderModel: + async with self.async_session() as session: + result = await session.execute( + select(FolderModel).where( + FolderModel.id == folder_id, + FolderModel.organization_id == organization_id, + ) + ) + folder = result.scalar_one_or_none() + if folder is None: + raise ValueError(f"Folder with id {folder_id} not found") + + folder.name = name + try: + await session.commit() + except IntegrityError: + await session.rollback() + raise FolderNameConflictError( + f"A folder named '{name}' already exists." + ) + await session.refresh(folder) + return folder + + async def delete_folder(self, folder_id: int, organization_id: int) -> bool: + """Delete a folder. Member workflows are un-filed (folder_id -> NULL) + via the ON DELETE SET NULL foreign key, never deleted. + """ + async with self.async_session() as session: + result = await session.execute( + select(FolderModel).where( + FolderModel.id == folder_id, + FolderModel.organization_id == organization_id, + ) + ) + folder = result.scalar_one_or_none() + if folder is None: + return False + + await session.delete(folder) + await session.commit() + return True + + async def get_active_workflow_counts_by_folder( + self, organization_id: int + ) -> dict[int, int]: + """Return {folder_id: active_workflow_count} for the organization. + + Only counts active (non-archived) workflows with a non-NULL folder_id. + """ + async with self.async_session() as session: + result = await session.execute( + select( + WorkflowModel.folder_id, + func.count(WorkflowModel.id).label("count"), + ) + .where( + WorkflowModel.organization_id == organization_id, + WorkflowModel.folder_id.is_not(None), + WorkflowModel.status == WorkflowStatus.ACTIVE.value, + ) + .group_by(WorkflowModel.folder_id) + ) + return {folder_id: count for folder_id, count in result.all()} diff --git a/api/db/integration_client.py b/api/db/integration_client.py index e1a7c54..6837be7 100644 --- a/api/db/integration_client.py +++ b/api/db/integration_client.py @@ -14,7 +14,7 @@ class IntegrationClient(BaseDBClient): async with self.async_session() as session: result = await session.execute( select(IntegrationModel).where( - IntegrationModel.organisation_id == organization_id + IntegrationModel.organization_id == organization_id ) ) return result.scalars().all() @@ -23,7 +23,7 @@ class IntegrationClient(BaseDBClient): self, integration_id: str, provider: str, - organisation_id: int, + organization_id: int, connection_details: dict, created_by: int = None, is_active: bool = True, @@ -32,7 +32,7 @@ class IntegrationClient(BaseDBClient): async with self.async_session() as session: new_integration = IntegrationModel( integration_id=integration_id, - organisation_id=organisation_id, + organization_id=organization_id, created_by=created_by, is_active=is_active, provider=provider, @@ -96,7 +96,7 @@ class IntegrationClient(BaseDBClient): async with self.async_session() as session: result = await session.execute( select(IntegrationModel).where( - IntegrationModel.organisation_id == organization_id, + IntegrationModel.organization_id == organization_id, IntegrationModel.is_active == True, ) ) diff --git a/api/db/looptalk_client.py b/api/db/looptalk_client.py deleted file mode 100644 index 8f65968..0000000 --- a/api/db/looptalk_client.py +++ /dev/null @@ -1,297 +0,0 @@ -from datetime import UTC, datetime -from typing import Any, Dict, List, Optional -from uuid import uuid4 - -from sqlalchemy.future import select -from sqlalchemy.orm import selectinload - -from api.db.base_client import BaseDBClient -from api.db.models import ( - LoopTalkConversation, - LoopTalkTestSession, - WorkflowModel, -) - - -class LoopTalkClient(BaseDBClient): - """Database client for LoopTalk testing operations.""" - - async def create_test_session( - self, - organization_id: int, - name: str, - actor_workflow_id: int, - adversary_workflow_id: int, - config: Dict[str, Any], - load_test_group_id: Optional[str] = None, - test_index: Optional[int] = None, - ) -> LoopTalkTestSession: - """Create a new LoopTalk test session.""" - async with self.async_session() as session: - test_session = LoopTalkTestSession( - organization_id=organization_id, - name=name, - actor_workflow_id=actor_workflow_id, - adversary_workflow_id=adversary_workflow_id, - config=config, - load_test_group_id=load_test_group_id, - test_index=test_index, - status="pending", - ) - session.add(test_session) - await session.commit() - await session.refresh(test_session) - return test_session - - async def get_test_session( - self, test_session_id: int, organization_id: int - ) -> Optional[LoopTalkTestSession]: - """Get a test session by ID.""" - async with self.async_session() as session: - result = await session.execute( - select(LoopTalkTestSession) - .options( - selectinload(LoopTalkTestSession.actor_workflow).selectinload( - WorkflowModel.released_definition - ), - selectinload(LoopTalkTestSession.adversary_workflow).selectinload( - WorkflowModel.released_definition - ), - selectinload(LoopTalkTestSession.conversations), - ) - .where( - LoopTalkTestSession.id == test_session_id, - LoopTalkTestSession.organization_id == organization_id, - ) - ) - return result.scalar_one_or_none() - - async def list_test_sessions( - self, - organization_id: int, - status: Optional[str] = None, - load_test_group_id: Optional[str] = None, - limit: int = 20, - offset: int = 0, - ) -> List[LoopTalkTestSession]: - """List test sessions with optional filtering.""" - async with self.async_session() as session: - query = select(LoopTalkTestSession).where( - LoopTalkTestSession.organization_id == organization_id - ) - - if status: - # "active" is a virtual status used by the UI to represent - # both "pending" and "running" sessions. Translate it into - # the real enum values stored in the database to avoid - # invalid enum casting errors (e.g. asyncpg InvalidTextRepresentationError). - if status == "active": - query = query.where( - LoopTalkTestSession.status.in_(["pending", "running"]) - ) - else: - query = query.where(LoopTalkTestSession.status == status) - - if load_test_group_id: - query = query.where( - LoopTalkTestSession.load_test_group_id == load_test_group_id - ) - - query = ( - query.order_by(LoopTalkTestSession.created_at.desc()) - .limit(limit) - .offset(offset) - ) - - result = await session.execute(query) - return result.scalars().all() - - async def update_test_session_status( - self, - test_session_id: int, - status: str, - error: Optional[str] = None, - results: Optional[Dict[str, Any]] = None, - ) -> LoopTalkTestSession: - """Update test session status and related fields.""" - async with self.async_session() as session: - result = await session.execute( - select(LoopTalkTestSession).where( - LoopTalkTestSession.id == test_session_id - ) - ) - test_session = result.scalar_one() - - test_session.status = status - - if status == "running": - test_session.started_at = datetime.now(UTC) - elif status in ["completed", "failed"]: - test_session.completed_at = datetime.now(UTC) - - if error: - test_session.error = error - - if results: - test_session.results = results - - await session.commit() - await session.refresh(test_session) - return test_session - - async def create_conversation(self, test_session_id: int) -> LoopTalkConversation: - """Create a new conversation for a test session.""" - async with self.async_session() as session: - conversation = LoopTalkConversation(test_session_id=test_session_id) - session.add(conversation) - await session.commit() - await session.refresh(conversation) - return conversation - - async def update_conversation( - self, - conversation_id: int, - duration_seconds: Optional[int] = None, - actor_recording_url: Optional[str] = None, - adversary_recording_url: Optional[str] = None, - combined_recording_url: Optional[str] = None, - transcript: Optional[Dict[str, Any]] = None, - metrics: Optional[Dict[str, Any]] = None, - ended_at: Optional[datetime] = None, - ) -> LoopTalkConversation: - """Update conversation details.""" - async with self.async_session() as session: - result = await session.execute( - select(LoopTalkConversation).where( - LoopTalkConversation.id == conversation_id - ) - ) - conversation = result.scalar_one() - - if duration_seconds is not None: - conversation.duration_seconds = duration_seconds - if actor_recording_url: - conversation.actor_recording_url = actor_recording_url - if adversary_recording_url: - conversation.adversary_recording_url = adversary_recording_url - if combined_recording_url: - conversation.combined_recording_url = combined_recording_url - if transcript: - conversation.transcript = transcript - if metrics: - conversation.metrics = metrics - if ended_at: - conversation.ended_at = ended_at - - await session.commit() - await session.refresh(conversation) - return conversation - - # Note: Turn tracking is handled by Langfuse, not stored in our database - - async def create_load_test_group( - self, - organization_id: int, - name_prefix: str, - actor_workflow_id: int, - adversary_workflow_id: int, - config: Dict[str, Any], - test_count: int, - ) -> List[LoopTalkTestSession]: - """Create multiple test sessions for load testing.""" - load_test_group_id = str(uuid4()) - test_sessions = [] - - async with self.async_session() as session: - for i in range(test_count): - test_session = LoopTalkTestSession( - organization_id=organization_id, - name=f"{name_prefix} - Test {i + 1}", - actor_workflow_id=actor_workflow_id, - adversary_workflow_id=adversary_workflow_id, - config=config, - load_test_group_id=load_test_group_id, - test_index=i, - status="pending", - ) - session.add(test_session) - test_sessions.append(test_session) - - await session.commit() - - # Refresh all sessions - for test_session in test_sessions: - await session.refresh(test_session) - - return test_sessions - - async def get_load_test_group_stats( - self, load_test_group_id: str, organization_id: int - ) -> Dict[str, Any]: - """Get statistics for a load test group.""" - from sqlalchemy import case, func - - async with self.async_session() as session: - # Get status counts using SQL aggregation - counts_result = await session.execute( - select( - func.count().label("total"), - func.sum( - case((LoopTalkTestSession.status == "pending", 1), else_=0) - ).label("pending"), - func.sum( - case((LoopTalkTestSession.status == "running", 1), else_=0) - ).label("running"), - func.sum( - case((LoopTalkTestSession.status == "completed", 1), else_=0) - ).label("completed"), - func.sum( - case((LoopTalkTestSession.status == "failed", 1), else_=0) - ).label("failed"), - ).where( - LoopTalkTestSession.load_test_group_id == load_test_group_id, - LoopTalkTestSession.organization_id == organization_id, - ) - ) - counts = counts_result.one() - - # Get session details (still needed for the sessions list) - sessions_result = await session.execute( - select( - LoopTalkTestSession.id, - LoopTalkTestSession.name, - LoopTalkTestSession.status, - LoopTalkTestSession.test_index, - LoopTalkTestSession.created_at, - LoopTalkTestSession.started_at, - LoopTalkTestSession.completed_at, - LoopTalkTestSession.error, - ).where( - LoopTalkTestSession.load_test_group_id == load_test_group_id, - LoopTalkTestSession.organization_id == organization_id, - ) - ) - sessions = sessions_result.all() - - stats = { - "total": counts.total or 0, - "pending": counts.pending or 0, - "running": counts.running or 0, - "completed": counts.completed or 0, - "failed": counts.failed or 0, - "sessions": [ - { - "id": s.id, - "name": s.name, - "status": s.status, - "test_index": s.test_index, - "created_at": s.created_at, - "started_at": s.started_at, - "completed_at": s.completed_at, - "error": s.error, - } - for s in sessions - ], - } - - return stats diff --git a/api/db/models.py b/api/db/models.py index 905aa03..ee70296 100644 --- a/api/db/models.py +++ b/api/db/models.py @@ -292,8 +292,10 @@ class IntegrationModel(Base): __tablename__ = "integrations" id = Column(Integer, primary_key=True, index=True) - integration_id = Column(String, nullable=False, index=True) # Nango Connection ID - organisation_id = Column(Integer, ForeignKey("organizations.id"), nullable=False) + integration_id = Column( + String, nullable=False, index=True + ) # External connection ID + organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False) provider = Column(String, nullable=False) created_by = Column(Integer, ForeignKey("users.id")) is_active = Column(Boolean, default=True, nullable=False) @@ -350,6 +352,32 @@ class WorkflowDefinitionModel(Base): workflow_runs = relationship("WorkflowRunModel", back_populates="definition") +class FolderModel(Base): + """A folder for grouping workflows (agents) within an organization. + + Folders are flat (no nesting) and org-scoped. A workflow belongs to at + most one folder via ``WorkflowModel.folder_id``; a NULL folder_id means + the workflow is "Uncategorized". + """ + + __tablename__ = "folders" + + id = Column(Integer, primary_key=True, index=True) + organization_id = Column( + Integer, ForeignKey("organizations.id"), nullable=False, index=True + ) + organization = relationship("OrganizationModel") + name = Column(String, nullable=False) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + + workflows = relationship("WorkflowModel", back_populates="folder") + + # Folder names must be unique within an organization. + __table_args__ = ( + UniqueConstraint("organization_id", "name", name="uq_folder_org_name"), + ) + + class WorkflowModel(Base): __tablename__ = "workflows" id = Column(Integer, primary_key=True, index=True) @@ -364,6 +392,15 @@ class WorkflowModel(Base): user = relationship("UserModel", back_populates="workflows") organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=True) organization = relationship("OrganizationModel") + # Optional folder for grouping in the agents list. NULL = "Uncategorized". + # ON DELETE SET NULL: deleting a folder un-files its agents, never deletes them. + folder_id = Column( + Integer, + ForeignKey("folders.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + folder = relationship("FolderModel", back_populates="workflows") name = Column(String, index=True, nullable=False) status = Column( Enum(*[status.value for status in WorkflowStatus], name="workflow_status"), @@ -482,6 +519,12 @@ class WorkflowRunModel(Base): queued_run_id = Column(Integer, ForeignKey("queued_runs.id"), nullable=True) queued_run = relationship("QueuedRunModel", foreign_keys=[queued_run_id]) public_access_token = Column(String(36), nullable=True) + text_session = relationship( + "WorkflowRunTextSessionModel", + back_populates="workflow_run", + uselist=False, + cascade="all, delete-orphan", + ) # Indexes __table_args__ = ( @@ -501,85 +544,41 @@ class WorkflowRunModel(Base): ) -# LoopTalk Testing Models -class LoopTalkTestSession(Base): - __tablename__ = "looptalk_test_sessions" +class WorkflowRunTextSessionModel(Base): + __tablename__ = "workflow_run_text_sessions" - id = Column(Integer, primary_key=True, index=True) - organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False) - name = Column(String, nullable=False) - status = Column( - Enum("pending", "running", "completed", "failed", name="test_session_status"), + workflow_run_id = Column( + Integer, + ForeignKey("workflow_runs.id", ondelete="CASCADE"), + primary_key=True, + ) + workflow_run = relationship("WorkflowRunModel", back_populates="text_session") + revision = Column( + Integer, nullable=False, - default="pending", + default=0, + server_default=text("0"), + ) + session_data = Column( + JSON, + nullable=False, + default=dict, + server_default=text("'{}'::json"), + ) + checkpoint = Column( + JSON, + nullable=False, + default=dict, + server_default=text("'{}'::json"), ) - - # Workflow configuration - actor_workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=False) - adversary_workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=False) - - # Load testing configuration - load_test_group_id = Column(String, nullable=True, index=True) - test_index = Column(Integer, nullable=True) - - # Test metadata - config = Column(JSON, nullable=False, default=dict) - results = Column(JSON, nullable=False, default=dict) - error = Column(String, nullable=True) - - # Timestamps created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) - started_at = Column(DateTime(timezone=True), nullable=True) - completed_at = Column(DateTime(timezone=True), nullable=True) - - # Relationships - organization = relationship("OrganizationModel") - actor_workflow = relationship("WorkflowModel", foreign_keys=[actor_workflow_id]) - adversary_workflow = relationship( - "WorkflowModel", foreign_keys=[adversary_workflow_id] - ) - conversations = relationship("LoopTalkConversation", back_populates="test_session") - - # Indexes for performance - __table_args__ = ( - Index("ix_looptalk_test_sessions_org_id", "organization_id"), - Index("ix_looptalk_test_sessions_group_id", "load_test_group_id"), - Index("ix_looptalk_test_sessions_status", "status"), + updated_at = Column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), ) - -class LoopTalkConversation(Base): - __tablename__ = "looptalk_conversations" - - id = Column(Integer, primary_key=True, index=True) - test_session_id = Column( - Integer, ForeignKey("looptalk_test_sessions.id"), nullable=False - ) - - # Conversation metadata - duration_seconds = Column(Integer, nullable=True) - # Note: Turn tracking is handled by Langfuse, not stored here - - # Audio recording URLs - actor_recording_url = Column(String, nullable=True) - adversary_recording_url = Column(String, nullable=True) - combined_recording_url = Column(String, nullable=True) - - # Transcripts (if needed for quick access) - transcript = Column(JSON, nullable=False, default=dict) - - # Metrics - metrics = Column(JSON, nullable=False, default=dict) - - # Timestamps - created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) - ended_at = Column(DateTime(timezone=True), nullable=True) - - # Relationships - test_session = relationship("LoopTalkTestSession", back_populates="conversations") - - # Indexes - __table_args__ = (Index("ix_looptalk_conversations_session_id", "test_session_id"),) + __table_args__ = (Index("ix_workflow_run_text_sessions_updated_at", "updated_at"),) class OrganizationUsageCycleModel(Base): @@ -636,8 +635,8 @@ class CampaignModel(Base): ) # Source configuration - source_type = Column(String, nullable=False, default="google-sheet") - source_id = Column(String, nullable=False) # Sheet URL + source_type = Column(String, nullable=False, default="csv") + source_id = Column(String, nullable=False) # CSV file key # State management state = Column( diff --git a/api/db/organization_usage_client.py b/api/db/organization_usage_client.py index 008ea8c..578cbb3 100644 --- a/api/db/organization_usage_client.py +++ b/api/db/organization_usage_client.py @@ -151,9 +151,9 @@ class OrganizationUsageClient(BaseDBClient): async def update_usage_after_run( self, organization_id: int, - actual_tokens: int, - duration_seconds: int = 0, - charge_usd: float = None, + actual_tokens: float, + duration_seconds: float = 0, + charge_usd: float | None = None, ) -> None: """Update usage after a workflow run completes with actual token count and duration. @@ -354,6 +354,7 @@ class OrganizationUsageClient(BaseDBClient): "caller_number": caller_number, "called_number": called_number, "call_type": run.call_type, + "mode": run.mode, "disposition": disposition, "initial_context": run.initial_context, "gathered_context": run.gathered_context, diff --git a/api/db/workflow_client.py b/api/db/workflow_client.py index 3c1eb6e..33e08c1 100644 --- a/api/db/workflow_client.py +++ b/api/db/workflow_client.py @@ -372,6 +372,8 @@ class WorkflowClient(BaseDBClient): WorkflowModel.name, WorkflowModel.status, WorkflowModel.created_at, + WorkflowModel.folder_id, + WorkflowModel.workflow_uuid, ) ) @@ -425,8 +427,26 @@ class WorkflowClient(BaseDBClient): return result.scalar_one_or_none() async def get_workflow( - self, workflow_id: int, user_id: int = None, organization_id: int = None + self, + workflow_id: int, + user_id: int | None = None, + organization_id: int | None = None, ) -> WorkflowModel | None: + """Fetch a workflow by id, scoped to a tenant. + + Scoping is mandatory: pass ``organization_id`` (preferred) or + ``user_id``. A fully unscoped lookup would let a request-supplied id + reach another tenant's workflow. System/runtime paths that only have a + ``workflow_id`` and derive the org from the workflow itself (e.g. + inbound telephony routing) must call ``get_workflow_by_id`` instead — + the explicit unscoped variant. + """ + if user_id is None and organization_id is None: + raise ValueError( + "get_workflow requires organization_id (preferred) or user_id " + "for tenant scoping; use get_workflow_by_id for unscoped " + "system lookups." + ) async with self.async_session() as session: query = ( select(WorkflowModel) @@ -448,6 +468,13 @@ class WorkflowClient(BaseDBClient): return result.scalars().first() async def get_workflow_by_id(self, workflow_id: int) -> WorkflowModel | None: + """Fetch a workflow by id WITHOUT tenant scoping. + + Explicit unscoped variant of ``get_workflow``. Only for system/runtime + contexts that legitimately have just a workflow_id and derive the org + from the workflow itself (e.g. inbound telephony). Never call this with + a request-supplied id on a user-facing path. + """ async with self.async_session() as session: result = await session.execute( select(WorkflowModel) @@ -609,7 +636,7 @@ class WorkflowClient(BaseDBClient): self, workflow_id: int, status: str, - organization_id: int = None, + organization_id: int, ) -> WorkflowModel: """ Update the status of a workflow. @@ -617,7 +644,9 @@ class WorkflowClient(BaseDBClient): Args: workflow_id: The ID of the workflow to update status: The new status (active/archived) - organization_id: The organization ID + organization_id: The organization ID. Required and always filtered + on: this is a mutation, so an unscoped query would let a caller + archive another org's workflow (tenant-isolation bypass). Returns: The updated WorkflowModel @@ -632,12 +661,12 @@ class WorkflowClient(BaseDBClient): selectinload(WorkflowModel.current_definition), selectinload(WorkflowModel.released_definition), ) - .where(WorkflowModel.id == workflow_id) + .where( + WorkflowModel.id == workflow_id, + WorkflowModel.organization_id == organization_id, + ) ) - if organization_id: - query = query.where(WorkflowModel.organization_id == organization_id) - result = await session.execute(query) workflow = result.scalars().first() @@ -654,6 +683,47 @@ class WorkflowClient(BaseDBClient): await session.refresh(workflow) return workflow + async def move_workflow_to_folder( + self, + workflow_id: int, + folder_id: int | None, + organization_id: int, + ) -> WorkflowModel: + """Set (or clear) a workflow's folder. + + Pass ``folder_id=None`` to move the workflow to "Uncategorized". The + caller must validate that ``folder_id`` belongs to ``organization_id`` + before calling (the FK only proves the folder exists, not ownership). + + ``organization_id`` is required and always filtered on: this is a + mutation, so an unscoped query would let a caller move another org's + workflow (tenant-isolation bypass). + + Raises: + ValueError: If the workflow is not found within the organization. + """ + async with self.async_session() as session: + query = select(WorkflowModel).where( + WorkflowModel.id == workflow_id, + WorkflowModel.organization_id == organization_id, + ) + + result = await session.execute(query) + workflow = result.scalars().first() + + if not workflow: + raise ValueError(f"Workflow with ID {workflow_id} not found") + + workflow.folder_id = folder_id + + try: + await session.commit() + except Exception as e: + await session.rollback() + raise e + await session.refresh(workflow) + return workflow + async def get_workflow_run_count(self, workflow_id: int) -> int: """Get the count of runs for a workflow.""" async with self.async_session() as session: diff --git a/api/db/workflow_run_client.py b/api/db/workflow_run_client.py index 4a91dfb..57c3e02 100644 --- a/api/db/workflow_run_client.py +++ b/api/db/workflow_run_client.py @@ -32,16 +32,22 @@ class WorkflowRunClient(BaseDBClient): campaign_id: int = None, queued_run_id: int = None, use_draft: bool = False, + organization_id: int | None = None, ) -> WorkflowRunModel: async with self.async_session() as session: - # Get workflow and user to check organization - workflow = await session.execute( + workflow_query = ( select(WorkflowModel) .options(joinedload(WorkflowModel.user)) .where( WorkflowModel.id == workflow_id, WorkflowModel.user_id == user_id ) ) + if organization_id is not None: + workflow_query = workflow_query.where( + WorkflowModel.organization_id == organization_id + ) + + workflow = await session.execute(workflow_query) workflow = workflow.scalars().first() if not workflow: raise ValueError(f"Workflow with ID {workflow_id} not found") diff --git a/api/db/workflow_run_text_session_client.py b/api/db/workflow_run_text_session_client.py new file mode 100644 index 0000000..c8d042e --- /dev/null +++ b/api/db/workflow_run_text_session_client.py @@ -0,0 +1,124 @@ +from sqlalchemy.future import select +from sqlalchemy.orm import joinedload + +from api.db.base_client import BaseDBClient +from api.db.models import ( + WorkflowModel, + WorkflowRunModel, + WorkflowRunTextSessionModel, +) + + +class WorkflowRunTextSessionRevisionConflictError(Exception): + def __init__(self, expected_revision: int, actual_revision: int): + self.expected_revision = expected_revision + self.actual_revision = actual_revision + super().__init__( + "Workflow run text session revision conflict: " + f"expected {expected_revision}, found {actual_revision}" + ) + + +class WorkflowRunTextSessionClient(BaseDBClient): + async def ensure_workflow_run_text_session( + self, + workflow_run_id: int, + session_data: dict | None = None, + checkpoint: dict | None = None, + ) -> WorkflowRunTextSessionModel: + async with self.async_session() as session: + result = await session.execute( + select(WorkflowRunTextSessionModel) + .where(WorkflowRunTextSessionModel.workflow_run_id == workflow_run_id) + .with_for_update() + ) + text_session = result.scalars().first() + if text_session: + return text_session + + run_result = await session.execute( + select(WorkflowRunModel).where(WorkflowRunModel.id == workflow_run_id) + ) + workflow_run = run_result.scalars().first() + if not workflow_run: + raise ValueError(f"Workflow run with ID {workflow_run_id} not found") + + text_session = WorkflowRunTextSessionModel( + workflow_run_id=workflow_run_id, + session_data=session_data or {}, + checkpoint=checkpoint or {}, + ) + session.add(text_session) + try: + await session.commit() + except Exception as e: + await session.rollback() + raise e + await session.refresh(text_session) + return text_session + + async def get_workflow_run_text_session( + self, + workflow_run_id: int, + *, + organization_id: int, + ) -> WorkflowRunTextSessionModel | None: + async with self.async_session() as session: + query = ( + select(WorkflowRunTextSessionModel) + .options( + joinedload(WorkflowRunTextSessionModel.workflow_run).joinedload( + WorkflowRunModel.workflow + ) + ) + .join(WorkflowRunTextSessionModel.workflow_run) + .join(WorkflowRunModel.workflow) + .where(WorkflowRunTextSessionModel.workflow_run_id == workflow_run_id) + .where(WorkflowModel.organization_id == organization_id) + ) + + result = await session.execute(query) + return result.scalars().first() + + async def update_workflow_run_text_session( + self, + workflow_run_id: int, + *, + session_data: dict | None = None, + checkpoint: dict | None = None, + expected_revision: int | None = None, + ) -> WorkflowRunTextSessionModel: + async with self.async_session() as session: + result = await session.execute( + select(WorkflowRunTextSessionModel) + .where(WorkflowRunTextSessionModel.workflow_run_id == workflow_run_id) + .with_for_update() + ) + text_session = result.scalars().first() + if not text_session: + raise ValueError( + f"Workflow run text session with run ID {workflow_run_id} not found" + ) + + if ( + expected_revision is not None + and text_session.revision != expected_revision + ): + raise WorkflowRunTextSessionRevisionConflictError( + expected_revision=expected_revision, + actual_revision=text_session.revision, + ) + + if session_data is not None: + text_session.session_data = session_data + if checkpoint is not None: + text_session.checkpoint = checkpoint + text_session.revision += 1 + + try: + await session.commit() + except Exception as e: + await session.rollback() + raise e + await session.refresh(text_session) + return text_session diff --git a/api/enums.py b/api/enums.py index b7655b1..1255705 100644 --- a/api/enums.py +++ b/api/enums.py @@ -27,6 +27,7 @@ class WorkflowRunMode(Enum): TELNYX = "telnyx" WEBRTC = "webrtc" SMALLWEBRTC = "smallwebrtc" + TEXTCHAT = "textchat" # Historical, not used anymore. Don't # use and don't remove @@ -133,6 +134,7 @@ class ToolCategory(Enum): CALCULATOR = "calculator" # Built-in calculator tool NATIVE = "native" # Built-in integrations (future: dtmf_input) INTEGRATION = "integration" # Third-party integrations (future: Google Calendar, Salesforce, etc.) + MCP = "mcp" # Customer-provided MCP server exposing a tool catalog class ToolStatus(Enum): diff --git a/api/mcp_server/auth.py b/api/mcp_server/auth.py index 33a4a46..6c19d61 100644 --- a/api/mcp_server/auth.py +++ b/api/mcp_server/auth.py @@ -18,7 +18,9 @@ async def authenticate_mcp_request() -> UserModel: the `langfuse.user.id` / `langfuse.session.id` attributes make the span filterable in the Langfuse UI. """ - headers = get_http_headers() + # FastMCP strips Authorization by default unless explicitly included. + # Preserve it here so Bearer API keys work for MCP tool invocations. + headers = get_http_headers(include={"authorization"}) api_key = headers.get("x-api-key") if not api_key: auth = headers.get("authorization", "") diff --git a/api/mcp_server/instructions.py b/api/mcp_server/instructions.py index f0b2618..3c0b3af 100644 --- a/api/mcp_server/instructions.py +++ b/api/mcp_server/instructions.py @@ -7,6 +7,14 @@ handling, hard constraints). Design-level per-field guidance belongs in each `PropertySpec.llm_hint`; it flows out through `get_node_type` and doesn't need to be repeated here. +Tool names, parameters, and per-tool `error_code` values are NOT +authoritative here — they reach the model dynamically via `tools/list` +from each tool's own signature and docstring. Reference tools by bare +name and describe orchestration; do not restate signatures (they drift) +or re-enumerate error codes (document those on the tool itself). +`test_mcp_instructions_drift.py` fails if this guide names a tool that +is not registered, or if a tool's error codes aren't in its docstring. + Extend based on real LLM failures — every bullet below ideally maps to a mistake the system has seen at least once. """ @@ -16,18 +24,23 @@ You build and edit Dograh voice-AI workflows by emitting TypeScript that uses th ## Call order +### 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. +2. `read_doc` — fetch the full page once one result looks likely. Prefer this over reasoning from search summaries alone. +3. `list_docs` — use when the user wants to browse a topic area or when search terms are too vague. Call it with no arguments for the top-level sections; returned section paths feed back into `list_docs`, returned page paths feed into `read_doc`. + ### Editing an existing workflow 1. `list_workflows` — locate the target workflow. -2. `get_workflow_code(workflow_id)` — fetch the current source. -3. (optional) `list_node_types` / `get_node_type(name)` — consult before adding or editing a node type whose fields aren't already visible in the current code. +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. Mutate the code in place. Preserve existing nodes, edges, and variable names unless the task requires removing or renaming them. -5. `save_workflow(workflow_id, code)` — persist as a new draft. The published version is untouched. +5. `save_workflow` — persist as a new draft. The published version is untouched. ### Creating a new workflow 1. Create a simple 1-node workflow with only `startCall`. The user can iteratively add complexity by editing it. -2. `list_node_types` / `get_node_type(name)` — consult to learn the fields available on the node types you intend to use. +2. `list_node_types` / `get_node_type` — consult to learn the fields available on the node types you intend to use. 3. Author SDK TypeScript from scratch. The `new Workflow({ name: "..." })` call is required — `name` becomes the workflow's display name. -4. `create_workflow(code)` — persists a new workflow as version 1 (published). Returns the new `workflow_id`. For subsequent edits use `save_workflow(workflow_id, code)` (which writes a draft). +4. `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). ## Allowed source shape @@ -68,14 +81,7 @@ Example: ## Iterating on errors -`save_workflow` and `create_workflow` return one of: -- `parse_error` — Disallowed construct (see grammar above) or malformed TypeScript. -- `validation_error` — Node data failed spec validation (unknown field, missing required, wrong type, bad `options` value). -- `graph_validation` — Structural rule broken (missing startCall, unreachable node, edge to/from wrong node type). -- `missing_name` — (`create_workflow` only) `new Workflow({ name })` is absent or empty. -- `bridge_error` — Internal; retry once, then surface to the user. - -Every error carries `line` and `column`. Fix at that location and resubmit the **complete source** — this tool does not accept patches. +A failed `save_workflow` / `create_workflow` returns a result with `saved`/`created` set to false, a machine-readable `error_code`, and a human-readable `error` message — carrying `line` and `column` when the problem is locatable in your source. The full set of `error_code` values and their meanings is documented on each tool (visible in its description). Read the `error` message, fix at the reported location, and resubmit the **complete source** — these tools do not accept patches. If a failure looks internal or transient rather than a problem with your code, retry once before surfacing it to the user. ## Field conventions diff --git a/api/mcp_server/server.py b/api/mcp_server/server.py index 12ad42e..5deef6c 100644 --- a/api/mcp_server/server.py +++ b/api/mcp_server/server.py @@ -1,4 +1,5 @@ from fastmcp import FastMCP +from mcp.types import ToolAnnotations from api.mcp_server.instructions import DOGRAH_MCP_INSTRUCTIONS from api.mcp_server.tools.catalog import ( @@ -8,6 +9,7 @@ from api.mcp_server.tools.catalog import ( list_tools, ) from api.mcp_server.tools.create_workflow import create_workflow +from api.mcp_server.tools.docs_search import list_docs, read_doc, search_docs from api.mcp_server.tools.get_workflow_code import get_workflow_code from api.mcp_server.tools.node_types import get_node_type, list_node_types from api.mcp_server.tools.save_workflow import save_workflow @@ -29,3 +31,13 @@ for _tool in ( save_workflow, ): mcp.tool(_tool) + +_DOCS_TOOL_ANNOTATIONS = ToolAnnotations( + readOnlyHint=True, + idempotentHint=True, + destructiveHint=False, + openWorldHint=False, +) + +for _tool in (list_docs, read_doc, search_docs): + mcp.tool(_tool, annotations=_DOCS_TOOL_ANNOTATIONS) diff --git a/api/mcp_server/tools/_workflow_projection.py b/api/mcp_server/tools/_workflow_projection.py new file mode 100644 index 0000000..57225d6 --- /dev/null +++ b/api/mcp_server/tools/_workflow_projection.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +from api.db import db_client +from api.mcp_server.ts_bridge import generate_code + + +@dataclass(frozen=True) +class WorkflowProjectionSource: + payload: dict[str, Any] | None + version: Literal["draft", "published", "legacy"] + version_number: int | None + + +async def select_workflow_projection_source(workflow: Any) -> WorkflowProjectionSource: + """Choose the same working copy across read and save MCP tools. + + Draft wins over published because that's what a human editor would + be mutating. Legacy `workflow_definition` is the final fallback for + older rows that predate versioned definitions. + """ + draft = await db_client.get_draft_version(workflow.id) + if draft is not None and draft.workflow_json: + return WorkflowProjectionSource( + payload=draft.workflow_json, + version="draft", + version_number=draft.version_number, + ) + + released = workflow.released_definition + if released is not None and released.workflow_json: + return WorkflowProjectionSource( + payload=released.workflow_json, + version="published", + version_number=released.version_number, + ) + + return WorkflowProjectionSource( + payload=workflow.workflow_definition or None, + version="legacy", + version_number=None, + ) + + +async def project_workflow_to_sdk_view(workflow: Any) -> dict[str, Any]: + source = await select_workflow_projection_source(workflow) + code = await generate_code(source.payload or {}, workflow_name=workflow.name or "") + return { + "name": workflow.name or "", + "version": source.version, + "version_number": source.version_number, + "code": code, + } diff --git a/api/mcp_server/tools/create_workflow.py b/api/mcp_server/tools/create_workflow.py index 38e4037..f9a87fc 100644 --- a/api/mcp_server/tools/create_workflow.py +++ b/api/mcp_server/tools/create_workflow.py @@ -12,10 +12,10 @@ Execution flow mirrors `save_workflow`: 4. Persist via `db_client.create_workflow` — workflow row + v1 published definition in a single transaction. -Error codes surfaced to the LLM match `save_workflow`. An additional -`missing_name` error is returned when the source omits -`new Workflow({ name: "..." })` — the name is required and there is no -prior workflow to fall back to. +Each failure path returns an `error_code` via `_error_result`. Those +codes and their meanings are documented in the `create_workflow` +docstring (the description shipped to the LLM via `tools/list`); keep the +two in sync — `test_mcp_instructions_drift.py` enforces it. """ from __future__ import annotations @@ -34,6 +34,10 @@ from api.mcp_server.ts_bridge import TsBridgeError, parse_code from api.services.posthog_client import capture_event from api.services.workflow.dto import ReactFlowDTO from api.services.workflow.layout import reconcile_positions +from api.services.workflow.trigger_paths import ( + extract_trigger_paths, + validate_trigger_paths, +) from api.services.workflow.workflow_graph import WorkflowGraph @@ -53,20 +57,6 @@ def _format_errors(errors: list[dict[str, Any]]) -> str: return "\n".join(parts) -def _extract_trigger_paths(workflow_definition: dict) -> list[str]: - """Mirror of `routes.workflow.extract_trigger_paths` — kept local so the - MCP layer doesn't depend on the route module.""" - if not workflow_definition: - return [] - paths: list[str] = [] - for node in workflow_definition.get("nodes") or []: - if node.get("type") == "trigger": - trigger_path = (node.get("data") or {}).get("trigger_path") - if trigger_path: - paths.append(trigger_path) - return paths - - @traced_tool async def create_workflow(code: str) -> dict[str, Any]: """Parse SDK TypeScript and create a new published workflow. @@ -86,6 +76,22 @@ async def create_workflow(code: str) -> dict[str, Any]: On success the new workflow is published as version 1. Use `save_workflow(workflow_id, code)` for subsequent edits — those go to a draft. + + On failure the result has `created: false`, a machine-readable + `error_code`, and a human-readable `error` (with file:line:column + where the problem is locatable). Resubmit the full corrected source — + patches are not accepted. Possible `error_code` values: + - `parse_error` — disallowed construct or malformed TypeScript. + - `validation_error` — node data failed spec validation (unknown + field, missing required, wrong type, option out of range). + - `schema_validation` — wire-format (DTO) rejection; rare. + - `graph_validation` — structural rule broken (e.g. no start node, + unreachable node, edge to/from the wrong node type). + - `missing_name` — `new Workflow({ name })` is absent or empty; the + name is required and there is no prior workflow to fall back to. + - `trigger_path_conflict` — a trigger node's path is already used by + another workflow in this organization; rename it and resubmit. + - `bridge_error` — internal/transient; retry once, then surface it. """ user = await authenticate_mcp_request() @@ -113,6 +119,12 @@ async def create_workflow(code: str) -> dict[str, Any]: # 1b. New workflow — no prior version to reconcile against; layout # places new nodes adjacent to their first incoming neighbor. payload = reconcile_positions(payload, None) + trigger_path_issues = validate_trigger_paths(payload) + if trigger_path_issues: + return _error_result( + "validation_error", + "\n".join(issue.message for issue in trigger_path_issues), + ) # 2. Pydantic shape check (defence in depth — parser is spec-driven). try: @@ -128,7 +140,7 @@ async def create_workflow(code: str) -> dict[str, Any]: # 4. Reject upfront if any trigger path collides with another workflow's # trigger in this org so we don't leave an orphan workflow record. - trigger_paths = _extract_trigger_paths(payload) + trigger_paths = extract_trigger_paths(payload) if trigger_paths: try: await db_client.assert_trigger_paths_available( diff --git a/api/mcp_server/tools/docs_search.py b/api/mcp_server/tools/docs_search.py new file mode 100644 index 0000000..b679e44 --- /dev/null +++ b/api/mcp_server/tools/docs_search.py @@ -0,0 +1,704 @@ +"""MCP docs discovery tools over the Mintlify docs tree. + +The docs surface is intentionally split into three steps: + +- ``list_docs`` for lightweight navigation over the published hierarchy +- ``search_docs`` for keyword lookup across the visible docs catalog +- ``read_doc`` for the full content of one chosen page (or one section) + +The runtime index is derived from ``docs/docs.json`` plus the referenced +``.mdx``/``.md`` files. That keeps navigation, ordering, and visibility in +sync with the published docs rather than indexing every file under ``docs/``. +""" + +from __future__ import annotations + +import json +import os +import re +from collections import Counter +from dataclasses import dataclass, replace +from functools import lru_cache +from pathlib import Path +from typing import Any + +import yaml +from fastapi import HTTPException + +from api.mcp_server.auth import authenticate_mcp_request +from api.mcp_server.tracing import traced_tool + +DOCS_SEARCH_MAX_LIMIT = 25 +DOCS_LIST_MAX_DEPTH = 3 +_ROOT_SECTION_PATH = "__root__" + +_TOKEN_RE = re.compile(r"[A-Za-z0-9_]+") +_FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.DOTALL) +_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$", re.MULTILINE) +_STOPWORDS = { + "a", + "an", + "and", + "are", + "at", + "be", + "by", + "can", + "do", + "for", + "from", + "how", + "i", + "if", + "in", + "is", + "it", + "me", + "my", + "of", + "on", + "or", + "the", + "to", + "what", + "when", + "where", + "with", + "you", + "your", +} + + +@dataclass(frozen=True) +class DocSection: + title: str + slug: str + level: int + content: str + + +@dataclass(frozen=True) +class DocPage: + path: str + file_path: str + title: str + description: str + llm_hint: str + aliases: tuple[str, ...] + breadcrumb: tuple[str, ...] + content: str + sections: tuple[DocSection, ...] + order: int + + def breadcrumb_text(self) -> str: + return " > ".join(self.breadcrumb) + + def routing_hint(self) -> str: + return self.llm_hint or self.description + + def to_catalog_dict(self, section: DocSection | None = None) -> dict: + data = { + "kind": "page", + "path": self.path, + "title": self.title, + "breadcrumb": self.breadcrumb_text(), + "llm_hint": self.routing_hint(), + } + if section is not None: + data["section_title"] = section.title + data["section_slug"] = section.slug + return _compact_dict(data) + + def to_read_dict(self, section: DocSection | None = None) -> dict: + active_section = section + content = self.content + if active_section is not None: + content = active_section.content + + return _compact_dict( + { + "path": self.path, + "title": self.title, + "breadcrumb": self.breadcrumb_text(), + "llm_hint": self.routing_hint(), + "section_title": active_section.title if active_section else None, + "section_slug": active_section.slug if active_section else None, + "content": content, + "sections": [ + {"title": sec.title, "slug": sec.slug} + for sec in self.sections + if sec.title and sec.slug + ], + } + ) + + +@dataclass(frozen=True) +class NavSection: + path: str + title: str + breadcrumb: tuple[str, ...] + children: tuple[tuple[str, str], ...] + descendant_page_count: int = 0 + + def breadcrumb_text(self) -> str: + return " > ".join(self.breadcrumb) + + def to_mcp_dict(self) -> dict: + hint = None + if self.descendant_page_count: + hint = f"Browse {self.descendant_page_count} docs in this section." + return _compact_dict( + { + "kind": "section", + "path": self.path, + "title": self.title, + "breadcrumb": self.breadcrumb_text(), + "llm_hint": hint, + "has_children": bool(self.children), + "child_count": len(self.children), + "page_count": self.descendant_page_count, + } + ) + + +@dataclass(frozen=True) +class DocsIndex: + pages_by_path: dict[str, DocPage] + sections_by_path: dict[str, NavSection] + + +def _compact_dict(data: dict[str, Any]) -> dict[str, Any]: + return { + key: value for key, value in data.items() if value not in (None, "", [], (), {}) + } + + +def _slugify(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return slug or "section" + + +def _coerce_docs_root(candidate: Path) -> Path | None: + candidate = candidate.expanduser().resolve() + if (candidate / "docs.json").is_file(): + return candidate + nested = candidate / "docs" + if (nested / "docs.json").is_file(): + return nested + return None + + +def _resolve_docs_root() -> Path | None: + """Return the path to the on-disk docs tree, or None if not found.""" + override = os.environ.get("DOGRAH_DOCS_PATH") + if override: + resolved = _coerce_docs_root(Path(override)) + if resolved is not None: + return resolved + + docker_default = _coerce_docs_root(Path("/app/docs")) + if docker_default is not None: + return docker_default + + for parent in Path(__file__).resolve().parents: + resolved = _coerce_docs_root(parent / "docs") + if resolved is not None: + return resolved + + return None + + +def _split_frontmatter(contents: str) -> tuple[dict[str, Any], str]: + match = _FRONTMATTER_RE.match(contents) + if not match: + return {}, contents + try: + frontmatter = yaml.safe_load(match.group(1)) or {} + except yaml.YAMLError: + return {}, contents + if not isinstance(frontmatter, dict): + frontmatter = {} + return frontmatter, contents[match.end() :].lstrip("\n") + + +def _strip_frontmatter(contents: str) -> str: + """Drop the YAML frontmatter block from a docs page body.""" + return _split_frontmatter(contents)[1] + + +def _clean_heading_text(raw: str) -> str: + text = re.sub(r"\s*\{#.*\}\s*$", "", raw.strip()) + return " ".join(text.split()) + + +def _extract_page_title(contents: str, fallback: str) -> str: + """Pull a human-readable title for a docs page.""" + frontmatter, body = _split_frontmatter(contents) + title = frontmatter.get("title") + if isinstance(title, str) and title.strip(): + return title.strip() + + match = _HEADING_RE.search(body) + if match: + return _clean_heading_text(match.group(2)) + + return fallback + + +def _normalize_text(value: Any) -> str: + if isinstance(value, str): + return " ".join(value.strip().split()) + return "" + + +def _normalize_aliases(value: Any) -> tuple[str, ...]: + if isinstance(value, str): + aliases = [value] + elif isinstance(value, list): + aliases = [item for item in value if isinstance(item, str)] + else: + aliases = [] + return tuple(alias.strip() for alias in aliases if alias.strip()) + + +def _extract_sections(body: str) -> tuple[DocSection, ...]: + matches = list(_HEADING_RE.finditer(body)) + stripped_body = body.strip() + if not matches: + if not stripped_body: + return () + return ( + DocSection( + title="Overview", + slug="overview", + level=1, + content=stripped_body, + ), + ) + + sections: list[DocSection] = [] + preamble = body[: matches[0].start()].strip() + if preamble: + sections.append( + DocSection( + title="Overview", + slug="overview", + level=1, + content=preamble, + ) + ) + + for index, match in enumerate(matches): + start = match.start() + end = matches[index + 1].start() if index + 1 < len(matches) else len(body) + title = _clean_heading_text(match.group(2)) + sections.append( + DocSection( + title=title or "Section", + slug=_slugify(title or "section"), + level=len(match.group(1)), + content=body[start:end].strip(), + ) + ) + return tuple(sections) + + +def _tokenize_text(text: str) -> list[str]: + return [ + token + for token in _TOKEN_RE.findall(text.lower()) + if len(token) >= 2 and token not in _STOPWORDS + ] + + +def _tokenize_query(query: str) -> list[str]: + """Split a user query into lowercased keyword terms.""" + seen: set[str] = set() + terms: list[str] = [] + for token in _TOKEN_RE.findall(query.lower()): + if len(token) < 2 or token in _STOPWORDS or token in seen: + continue + seen.add(token) + terms.append(token) + return terms + + +def _resolve_doc_file(root: Path, route_path: str) -> Path | None: + candidates = ( + root / f"{route_path}.mdx", + root / f"{route_path}.md", + root / route_path / "index.mdx", + root / route_path / "index.md", + ) + for candidate in candidates: + if candidate.is_file(): + return candidate + return None + + +def _build_doc_page( + root: Path, + route_path: str, + *, + breadcrumb: tuple[str, ...], + order: int, +) -> DocPage | None: + file_path = _resolve_doc_file(root, route_path) + if file_path is None: + return None + try: + contents = file_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return None + + frontmatter, body = _split_frontmatter(contents) + fallback = route_path.rsplit("/", 1)[-1].replace("-", " ").title() + title = _extract_page_title(contents, fallback=fallback) + description = _normalize_text(frontmatter.get("description")) + llm_hint = _normalize_text(frontmatter.get("llm_hint")) + aliases = _normalize_aliases(frontmatter.get("aliases")) + content = body.strip() + + return DocPage( + path=route_path, + file_path=file_path.relative_to(root).as_posix(), + title=title, + description=description, + llm_hint=llm_hint, + aliases=aliases, + breadcrumb=breadcrumb, + content=content, + sections=_extract_sections(content), + order=order, + ) + + +def _score_counter(counter: Counter[str], term: str, *, weight: int, cap: int) -> int: + return min(counter.get(term, 0), cap) * weight + + +def _normalized_phrase(text: str) -> str: + return " ".join(_tokenize_text(text)) + + +def _score_section(section: DocSection, terms: list[str]) -> int: + title_counts = Counter(_tokenize_text(section.title)) + body_counts = Counter(_tokenize_text(section.content)) + score = 0 + matched_terms = 0 + for term in terms: + term_score = _score_counter( + title_counts, term, weight=7, cap=2 + ) + _score_counter(body_counts, term, weight=1, cap=4) + if term_score: + matched_terms += 1 + score += term_score + score += matched_terms * 4 + + phrase = " ".join(terms) + if phrase and phrase in _normalized_phrase(section.content): + score += 6 + return score + + +def _score_page(page: DocPage, terms: list[str]) -> tuple[int, DocSection | None]: + if not terms: + return 0, None + + path_counts = Counter(_tokenize_text(page.path)) + title_counts = Counter(_tokenize_text(page.title)) + breadcrumb_counts = Counter(_tokenize_text(" ".join(page.breadcrumb))) + hint_counts = Counter(_tokenize_text(page.routing_hint())) + alias_counts = Counter(_tokenize_text(" ".join(page.aliases))) + + score = 0 + matched_terms = 0 + for term in terms: + term_score = ( + _score_counter(path_counts, term, weight=6, cap=3) + + _score_counter(title_counts, term, weight=10, cap=2) + + _score_counter(breadcrumb_counts, term, weight=4, cap=2) + + _score_counter(hint_counts, term, weight=7, cap=3) + + _score_counter(alias_counts, term, weight=7, cap=3) + ) + if term_score: + matched_terms += 1 + score += term_score + + best_section = None + best_section_score = 0 + for section in page.sections: + section_score = _score_section(section, terms) + if section_score > best_section_score: + best_section = section + best_section_score = section_score + + if score == 0 and best_section_score == 0: + return 0, None + + score += matched_terms * 8 + best_section_score + + phrase = " ".join(terms) + if phrase: + if phrase in _normalized_phrase(page.title): + score += 12 + elif phrase in _normalized_phrase(page.routing_hint()): + score += 8 + elif phrase in _normalized_phrase(page.path): + score += 8 + elif best_section is not None and phrase in _normalized_phrase( + best_section.content + ): + score += 4 + + return score, best_section + + +def _set_descendant_counts( + sections_by_path: dict[str, NavSection], + section_path: str, +) -> int: + section = sections_by_path[section_path] + page_count = 0 + for child_kind, child_path in section.children: + if child_kind == "page": + page_count += 1 + else: + page_count += _set_descendant_counts(sections_by_path, child_path) + sections_by_path[section_path] = replace(section, descendant_page_count=page_count) + return page_count + + +@lru_cache(maxsize=1) +def _docs_index() -> DocsIndex: + root = _resolve_docs_root() + if root is None: + return DocsIndex(pages_by_path={}, sections_by_path={}) + + try: + docs_config = json.loads((root / "docs.json").read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return DocsIndex(pages_by_path={}, sections_by_path={}) + + pages_by_path: dict[str, DocPage] = {} + sections_by_path: dict[str, NavSection] = {} + page_order = 0 + + def ensure_unique_section_path(base_path: str) -> str: + if base_path not in sections_by_path: + return base_path + suffix = 2 + while f"{base_path}-{suffix}" in sections_by_path: + suffix += 1 + return f"{base_path}-{suffix}" + + def walk_pages( + items: list[Any], + *, + section_path: str, + section_title: str, + ancestor_breadcrumb: tuple[str, ...], + ) -> None: + nonlocal page_order + children: list[tuple[str, str]] = [] + page_breadcrumb = ancestor_breadcrumb + (section_title,) + + for item in items: + if isinstance(item, str): + route_path = item.strip("/") + if not route_path: + continue + if route_path not in pages_by_path: + page = _build_doc_page( + root, + route_path, + breadcrumb=page_breadcrumb, + order=page_order, + ) + if page is not None: + pages_by_path[route_path] = page + page_order += 1 + if route_path in pages_by_path: + children.append(("page", route_path)) + continue + + if not isinstance(item, dict): + continue + group_title = str(item.get("group", "")).strip() + nested_pages = item.get("pages") + if not group_title or not isinstance(nested_pages, list): + continue + + child_path = ensure_unique_section_path( + f"{section_path}/{_slugify(group_title)}" + ) + walk_pages( + nested_pages, + section_path=child_path, + section_title=group_title, + ancestor_breadcrumb=page_breadcrumb, + ) + children.append(("section", child_path)) + + sections_by_path[section_path] = NavSection( + path=section_path, + title=section_title, + breadcrumb=ancestor_breadcrumb, + children=tuple(children), + ) + + root_children: list[tuple[str, str]] = [] + tabs = docs_config.get("navigation", {}).get("tabs", []) + for tab in tabs: + if not isinstance(tab, dict): + continue + tab_title = str(tab.get("tab", "")).strip() or "Docs" + for group in tab.get("groups", []): + if not isinstance(group, dict): + continue + group_title = str(group.get("group", "")).strip() + group_pages = group.get("pages") + if not group_title or not isinstance(group_pages, list): + continue + top_level_path = ensure_unique_section_path( + f"{_slugify(tab_title)}/{_slugify(group_title)}" + ) + walk_pages( + group_pages, + section_path=top_level_path, + section_title=group_title, + ancestor_breadcrumb=(tab_title,), + ) + root_children.append(("section", top_level_path)) + + sections_by_path[_ROOT_SECTION_PATH] = NavSection( + path=_ROOT_SECTION_PATH, + title="Docs", + breadcrumb=(), + children=tuple(root_children), + ) + _set_descendant_counts(sections_by_path, _ROOT_SECTION_PATH) + + return DocsIndex(pages_by_path=pages_by_path, sections_by_path=sections_by_path) + + +def _get_page_or_404(path: str) -> DocPage: + page = _docs_index().pages_by_path.get(path.strip("/")) + if page is None: + raise HTTPException(status_code=404, detail=f"Unknown docs page: {path!r}") + return page + + +def _find_section(page: DocPage, section: str) -> DocSection | None: + target = section.strip().lower() + for candidate in page.sections: + if candidate.slug.lower() == target or candidate.title.lower() == target: + return candidate + return None + + +def _expand_nav_entries( + index: DocsIndex, + section_path: str, + depth: int, +) -> list[dict]: + section = index.sections_by_path[section_path] + results: list[dict] = [] + for child_kind, child_path in section.children: + if child_kind == "section": + child_section = index.sections_by_path[child_path] + results.append(child_section.to_mcp_dict()) + if depth > 1: + results.extend(_expand_nav_entries(index, child_path, depth - 1)) + else: + results.append(index.pages_by_path[child_path].to_catalog_dict()) + return results + + +@traced_tool +async def list_docs(path: str | None = None, depth: int = 1) -> list[dict]: + """Browse the Dograh docs hierarchy before reading a page in full. + + ``path`` addresses navigation sections exposed by this tool. Page paths + returned by ``search_docs`` and ``read_doc`` are the published docs routes + instead, for example ``voice-agent/tools/mcp-tool``. + """ + await authenticate_mcp_request() + + if depth < 1 or depth > DOCS_LIST_MAX_DEPTH: + raise ValueError(f"`depth` must be between 1 and {DOCS_LIST_MAX_DEPTH}.") + + index = _docs_index() + if not index.sections_by_path: + return [] + + if path is None: + return _expand_nav_entries(index, _ROOT_SECTION_PATH, depth) + + normalized = path.strip("/") + if normalized in index.sections_by_path: + return _expand_nav_entries(index, normalized, depth) + if normalized in index.pages_by_path: + return [index.pages_by_path[normalized].to_catalog_dict()] + + raise HTTPException(status_code=404, detail=f"Unknown docs section: {path!r}") + + +@traced_tool +async def read_doc(path: str, section: str | None = None) -> dict: + """Read one docs page after you have narrowed to a likely match.""" + await authenticate_mcp_request() + + if not isinstance(path, str) or not path.strip(): + raise ValueError("`path` must be a non-empty string.") + + page = _get_page_or_404(path) + active_section = None + if section is not None: + active_section = _find_section(page, section) + if active_section is None: + raise HTTPException( + status_code=404, + detail=f"Unknown section {section!r} for docs page {path!r}", + ) + return page.to_read_dict(section=active_section) + + +@traced_tool +async def search_docs(query: str, limit: int = 5) -> list[dict]: + """Search the Dograh documentation and return a lean ranked shortlist. + + Use this first for keyword or acronym lookup. Once the right page looks + likely, call ``read_doc(path)`` instead of reasoning from summaries alone. + """ + await authenticate_mcp_request() + + if not isinstance(query, str) or not query.strip(): + raise ValueError("`query` must be a non-empty string.") + if limit < 1: + raise ValueError("`limit` must be at least 1.") + + terms = _tokenize_query(query) + if not terms: + raise ValueError( + "`query` must contain at least one non-stopword alphanumeric term." + ) + + index = _docs_index() + if not index.pages_by_path: + return [] + + capped_limit = min(limit, DOCS_SEARCH_MAX_LIMIT) + ranked: list[tuple[int, int, DocPage, DocSection | None]] = [] + for page in index.pages_by_path.values(): + score, best_section = _score_page(page, terms) + if score <= 0: + continue + ranked.append((score, page.order, page, best_section)) + + ranked.sort(key=lambda item: (-item[0], item[1], item[2].path)) + return [ + page.to_catalog_dict(section=best_section) + for _, _, page, best_section in ranked[:capped_limit] + ] diff --git a/api/mcp_server/tools/get_workflow_code.py b/api/mcp_server/tools/get_workflow_code.py index d0c99c4..bb657ac 100644 --- a/api/mcp_server/tools/get_workflow_code.py +++ b/api/mcp_server/tools/get_workflow_code.py @@ -18,8 +18,9 @@ from fastapi import HTTPException from api.db import db_client from api.mcp_server.auth import authenticate_mcp_request +from api.mcp_server.tools._workflow_projection import project_workflow_to_sdk_view from api.mcp_server.tracing import traced_tool -from api.mcp_server.ts_bridge import TsBridgeError, generate_code +from api.mcp_server.ts_bridge import TsBridgeError @traced_tool @@ -39,31 +40,14 @@ async def get_workflow_code(workflow_id: int) -> dict[str, Any]: if not workflow: raise HTTPException(status_code=404, detail=f"Workflow {workflow_id} not found") - # Draft wins over published — editing a draft is the normal flow. - # `current_definition` (is_current=True) is the published row, so we - # fetch the draft explicitly. If the latest draft was just published, - # no draft row exists and we fall through to `released_definition`. - draft = await db_client.get_draft_version(workflow_id) - released = workflow.released_definition - - if draft is not None and draft.workflow_json: - payload = draft.workflow_json - source = "draft" - elif released is not None and released.workflow_json: - payload = released.workflow_json - source = "published" - else: - payload = workflow.workflow_definition or {} - source = "legacy" - try: - code = await generate_code(payload, workflow_name=workflow.name or "") + view = await project_workflow_to_sdk_view(workflow) except TsBridgeError as e: raise HTTPException(status_code=500, detail=f"Failed to generate code: {e}") return { "workflow_id": workflow_id, - "name": workflow.name or "", - "version": source, - "code": code, + "name": view["name"], + "version": view["version"], + "code": view["code"], } diff --git a/api/mcp_server/tools/node_types.py b/api/mcp_server/tools/node_types.py index 04e8c55..83d2369 100644 --- a/api/mcp_server/tools/node_types.py +++ b/api/mcp_server/tools/node_types.py @@ -40,15 +40,17 @@ async def list_node_types() -> dict: @traced_tool async def get_node_type(name: str) -> dict: - """Fetch the full schema for a node type, including every property's - type, default, conditional visibility rules, and LLM-readable - description, plus worked examples. + """Fetch the authoring schema for a node type: each property's name, + type, default, requiredness, enum options, validation bounds, and + LLM-readable description, plus worked examples and graph constraints. - Use the property `description` and the `examples` list to understand - semantics — types alone are not enough. + UI-only metadata (display labels, placeholders, conditional visibility + rules, renderer hints) is intentionally omitted — set only the fields + you need. Use the property `description`/`llm_hint` and the `examples` + list to understand semantics; types alone are not enough. """ await authenticate_mcp_request() spec = get_spec(name) if spec is None: raise HTTPException(status_code=404, detail=f"Unknown node type: {name!r}") - return spec.model_dump(mode="json") + return spec.to_mcp_dict() diff --git a/api/mcp_server/tools/save_workflow.py b/api/mcp_server/tools/save_workflow.py index 41130d7..9ef552f 100644 --- a/api/mcp_server/tools/save_workflow.py +++ b/api/mcp_server/tools/save_workflow.py @@ -10,16 +10,12 @@ Execution flow: 4. Save as a new draft via `db_client.save_workflow_draft` — the published version stays intact, so edits are rollback-safe. -Error codes surfaced to the LLM: - parse_error — TS parse failed or a disallowed construct was used - validation_error — node data failed spec validation (unknown field, - missing required, wrong type, option out of range) - schema_validation — ReactFlowDTO Pydantic rejection (rare; parser bug) - graph_validation — semantic graph rule broken (e.g. no start node) - bridge_error — Node subprocess failed before returning JSON - -All LLM-facing errors include file:line:column where available so the -LLM can correct its code directly. +Each failure path returns an `error_code` via `_error_result`. Those +codes and their meanings are documented in the `save_workflow` docstring +(the description shipped to the LLM via `tools/list`); keep the two in +sync — `test_mcp_instructions_drift.py` enforces it. All LLM-facing +errors include file:line:column where available so the LLM can correct +its code directly. """ from __future__ import annotations @@ -32,28 +28,21 @@ from pydantic import ValidationError as PydanticValidationError from api.db import db_client from api.mcp_server.auth import authenticate_mcp_request +from api.mcp_server.tools._workflow_projection import ( + select_workflow_projection_source, +) from api.mcp_server.tracing import traced_tool from api.mcp_server.ts_bridge import TsBridgeError, parse_code from api.services.workflow.dto import ReactFlowDTO from api.services.workflow.layout import reconcile_positions +from api.services.workflow.trigger_paths import validate_trigger_paths from api.services.workflow.workflow_graph import WorkflowGraph async def _previous_workflow_json(workflow: Any) -> dict[str, Any] | None: - """Same selection priority as `get_workflow_code` — the version the - LLM saw is the version we reconcile against. - - `current_definition` (is_current=True) is the published row, so the - draft must be fetched explicitly. If no draft exists (e.g. the last - draft was just published), fall through to `released_definition`. - """ - draft = await db_client.get_draft_version(workflow.id) - if draft is not None and draft.workflow_json: - return draft.workflow_json - released = workflow.released_definition - if released is not None and released.workflow_json: - return released.workflow_json - return workflow.workflow_definition or None + """Match the agent-facing read tools' source selection.""" + source = await select_workflow_projection_source(workflow) + return source.payload def _error_result(code: str, message: str, **extra: Any) -> dict[str, Any]: @@ -91,6 +80,18 @@ async def save_workflow(workflow_id: int, code: str) -> dict[str, Any]: On success the draft version is saved; the published version is untouched. + + On failure the result has `saved: false`, a machine-readable + `error_code`, and a human-readable `error` (with file:line:column + where the problem is locatable). Resubmit the full corrected source — + patches are not accepted. Possible `error_code` values: + - `parse_error` — disallowed construct or malformed TypeScript. + - `validation_error` — node data failed spec validation (unknown + field, missing required, wrong type, option out of range). + - `schema_validation` — wire-format (DTO) rejection; rare. + - `graph_validation` — structural rule broken (e.g. no start node, + unreachable node, edge to/from the wrong node type). + - `bridge_error` — internal/transient; retry once, then surface it. """ user = await authenticate_mcp_request() @@ -121,6 +122,12 @@ async def save_workflow(workflow_id: int, code: str) -> dict[str, Any]: # here we fill them back in from what was there before, and pick # approximate placements for newly-introduced nodes. payload = reconcile_positions(payload, await _previous_workflow_json(workflow)) + trigger_path_issues = validate_trigger_paths(payload) + if trigger_path_issues: + return _error_result( + "validation_error", + "\n".join(issue.message for issue in trigger_path_issues), + ) # 2. Pydantic shape check (defence in depth — parser is spec-driven). try: diff --git a/api/mcp_server/tools/workflows.py b/api/mcp_server/tools/workflows.py index af5f165..9a741f3 100644 --- a/api/mcp_server/tools/workflows.py +++ b/api/mcp_server/tools/workflows.py @@ -2,7 +2,9 @@ from fastapi import HTTPException from api.db import db_client from api.mcp_server.auth import authenticate_mcp_request +from api.mcp_server.tools._workflow_projection import project_workflow_to_sdk_view from api.mcp_server.tracing import traced_tool +from api.mcp_server.ts_bridge import TsBridgeError @traced_tool @@ -10,9 +12,9 @@ async def list_workflows(status: str | None = "active") -> list[dict]: """List agents (workflows) in the caller's organization. Returns id, name, status, and created_at for each agent. Use - `get_workflow` to fetch a single agent's full definition. Defaults - to active agents; pass `status="archived"` to list archived agents, - or `status=None` to list all. + `get_workflow` to fetch a single agent's current SDK view and + metadata. Defaults to active agents; pass `status="archived"` to + list archived agents, or `status=None` to list all. """ user = await authenticate_mcp_request() workflows = await db_client.get_all_workflows_for_listing( @@ -32,7 +34,11 @@ async def list_workflows(status: str | None = "active") -> list[dict]: @traced_tool async def get_workflow(workflow_id: int) -> dict: - """Fetch a single agent by id, including its current published definition.""" + """Fetch a single agent by id, projected into the SDK code view. + + Output shape: + {"id": int, "name": str, "status": str, "version": "draft" | "published" | "legacy", "version_number": int | None, "code": ""} + """ user = await authenticate_mcp_request() workflow = await db_client.get_workflow( workflow_id, organization_id=user.selected_organization_id @@ -40,11 +46,16 @@ async def get_workflow(workflow_id: int) -> dict: if not workflow: raise HTTPException(status_code=404, detail=f"Workflow {workflow_id} not found") - current = workflow.current_definition + try: + view = await project_workflow_to_sdk_view(workflow) + except TsBridgeError as e: + raise HTTPException(status_code=500, detail=f"Failed to generate code: {e}") + return { "id": workflow.id, - "name": workflow.name, + "name": view["name"], "status": workflow.status, - "definition": current.workflow_json if current else None, - "version_number": current.version_number if current else None, + "version": view["version"], + "version_number": view["version_number"], + "code": view["code"], } diff --git a/api/mcp_server/ts_bridge.py b/api/mcp_server/ts_bridge.py index ec9de3d..98eddba 100644 --- a/api/mcp_server/ts_bridge.py +++ b/api/mcp_server/ts_bridge.py @@ -18,6 +18,7 @@ import json from pathlib import Path from typing import Any +from api.services.workflow.dto import EdgeDataDTO from api.services.workflow.node_specs import all_specs _VALIDATOR_ENTRY = Path(__file__).resolve().parent / "ts_validator" / "src" / "index.ts" @@ -31,6 +32,10 @@ def _specs_payload() -> list[dict[str, Any]]: return [s.model_dump(mode="json") for s in all_specs()] +def _edge_field_names() -> list[str]: + return list(EdgeDataDTO.model_fields.keys()) + + async def _invoke(request: dict[str, Any]) -> dict[str, Any]: proc = await asyncio.create_subprocess_exec( "node", @@ -65,6 +70,7 @@ async def generate_code(workflow: dict[str, Any], *, workflow_name: str = "") -> "command": "generate", "workflow": workflow, "specs": _specs_payload(), + "edgeFieldNames": _edge_field_names(), "workflowName": workflow_name, } ) @@ -89,5 +95,6 @@ async def parse_code(code: str) -> dict[str, Any]: "command": "parse", "code": code, "specs": _specs_payload(), + "edgeFieldNames": _edge_field_names(), } ) diff --git a/api/mcp_server/ts_validator/src/generate.ts b/api/mcp_server/ts_validator/src/generate.ts index 031b1a4..8f9438b 100644 --- a/api/mcp_server/ts_validator/src/generate.ts +++ b/api/mcp_server/ts_validator/src/generate.ts @@ -14,9 +14,18 @@ import type { export function generateCode( workflow: WireWorkflow, specs: NodeSpec[], - opts: { workflowName?: string } = {}, + opts: { workflowName?: string; edgeFieldNames?: string[] } = {}, ): GenerateResult { const specByName = new Map(specs.map((s) => [s.name, s])); + const edgeFieldNames = new Set( + opts.edgeFieldNames ?? [ + "label", + "condition", + "transition_speech", + "transition_speech_type", + "transition_speech_recording_id", + ], + ); // Catch unknown node types up-front — otherwise we'd emit an import // line for a factory that doesn't exist. @@ -97,7 +106,7 @@ export function generateCode( ], }; } - const cleanedEdge = pickEdgeFields(edge.data); + const cleanedEdge = pickEdgeFields(edge.data, edgeFieldNames); const edgeOpts = renderObject(cleanedEdge, 0); lines.push(`wf.edge(${src}, ${tgt}, ${edgeOpts});`); } @@ -210,22 +219,13 @@ function stripUnknown( return out; } -// Edge schema is fixed (no NodeSpec for edges). Mirrors the allowed -// fields on `Workflow.edge(...)` in both SDKs. -const KNOWN_EDGE_FIELDS = new Set([ - "label", - "condition", - "transition_speech", - "transition_speech_type", - "transition_speech_recording_id", -]); - function pickEdgeFields( data: Record, + knownEdgeFields: Set, ): Record { const out: Record = {}; for (const [k, v] of Object.entries(data)) { - if (KNOWN_EDGE_FIELDS.has(k)) out[k] = v; + if (knownEdgeFields.has(k)) out[k] = v; } return out; } diff --git a/api/mcp_server/ts_validator/src/index.ts b/api/mcp_server/ts_validator/src/index.ts index 3154fa9..98209d8 100644 --- a/api/mcp_server/ts_validator/src/index.ts +++ b/api/mcp_server/ts_validator/src/index.ts @@ -11,6 +11,7 @@ interface GenerateRequest { command: "generate"; workflow: WireWorkflow; specs: NodeSpec[]; + edgeFieldNames: string[]; workflowName?: string; } @@ -18,6 +19,7 @@ interface ParseRequest { command: "parse"; code: string; specs: NodeSpec[]; + edgeFieldNames: string[]; } type Request = GenerateRequest | ParseRequest; @@ -49,11 +51,16 @@ async function main(): Promise { } if (req.command === "generate") { - writeResult(generateCode(req.workflow, req.specs, { workflowName: req.workflowName })); + writeResult( + generateCode(req.workflow, req.specs, { + workflowName: req.workflowName, + edgeFieldNames: req.edgeFieldNames, + }), + ); return; } if (req.command === "parse") { - writeResult(parseCode(req.code, req.specs)); + writeResult(parseCode(req.code, req.specs, req.edgeFieldNames)); return; } writeResult({ diff --git a/api/mcp_server/ts_validator/src/parse.ts b/api/mcp_server/ts_validator/src/parse.ts index 6e6b7b9..02c2d50 100644 --- a/api/mcp_server/ts_validator/src/parse.ts +++ b/api/mcp_server/ts_validator/src/parse.ts @@ -25,8 +25,19 @@ import type { WireNode, } from "./types.ts"; -export function parseCode(code: string, specs: NodeSpec[]): ParseResult { +export function parseCode( + code: string, + specs: NodeSpec[], + edgeFieldNames: string[] = [ + "label", + "condition", + "transition_speech", + "transition_speech_type", + "transition_speech_recording_id", + ], +): ParseResult { const specByName = new Map(specs.map((s) => [s.name, s])); + const allowedEdgeFieldNames = new Set(edgeFieldNames); const sourceFile = ts.createSourceFile( "workflow.ts", code, @@ -335,6 +346,12 @@ export function parseCode(code: string, specs: NodeSpec[]): ParseResult { addError(stmt, "`edge` requires a non-empty `condition` string."); return; } + for (const key of Object.keys(optsObj)) { + if (!allowedEdgeFieldNames.has(key)) { + addError(stmt, `Unknown edge field: \`${key}\`.`); + return; + } + } edges.push({ id: `${src.id}-${tgt.id}`, source: src.id, diff --git a/api/pyproject.toml b/api/pyproject.toml index 10da675..b937d41 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,5 +1,5 @@ [project] name = "dograh-api" -version = "1.29.0" +version = "1.31.0" description = "Backend API for Dograh voice AI platform" requires-python = ">=3.12" diff --git a/api/requirements.txt b/api/requirements.txt index 4801572..844738d 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -9,7 +9,7 @@ arq==0.26.3 twilio==9.8.0 minio==7.2.16 alembic-postgresql-enum==1.8.0 -python-multipart==0.0.20 +python-multipart==0.0.27 sentry-sdk[fastapi]==2.38.0 sqlalchemy[asyncio]==2.0.43 msgpack==1.1.2 @@ -18,4 +18,5 @@ bcrypt==5.0.0 email-validator==2.3.0 posthog==7.11.1 fastmcp==3.2.4 +tuner-pipecat-sdk==0.2.0 PyNaCl==1.6.2 diff --git a/api/routes/campaign.py b/api/routes/campaign.py index 407cef7..cb5f541 100644 --- a/api/routes/campaign.py +++ b/api/routes/campaign.py @@ -152,8 +152,8 @@ class CircuitBreakerConfigResponse(BaseModel): class CreateCampaignRequest(BaseModel): name: str = Field(..., min_length=1, max_length=255) workflow_id: int - source_type: str = Field(..., pattern="^(google-sheet|csv)$") - source_id: str # Google Sheet URL or CSV file key + source_type: str = Field(..., pattern="^csv$") + source_id: str # CSV file key # Optional during the legacy → multi-config migration window. Required in # a follow-up. When omitted, the dispatcher falls back to the org's # default config. @@ -929,8 +929,6 @@ async def get_campaign_source_download_url( user: UserModel = Depends(get_user), ) -> CampaignSourceDownloadResponse: """Get presigned download URL for campaign CSV source file - - Only works for CSV source type. For Google Sheets, use the source_id directly. Validates that the campaign belongs to the user's organization for security. """ # Verify campaign exists and belongs to organization diff --git a/api/routes/folder.py b/api/routes/folder.py new file mode 100644 index 0000000..245e6ce --- /dev/null +++ b/api/routes/folder.py @@ -0,0 +1,99 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field, field_validator + +from api.db import db_client +from api.db.folder_client import FolderNameConflictError +from api.db.models import UserModel +from api.services.auth.depends import get_user + +router = APIRouter(prefix="/folder") + + +class FolderResponse(BaseModel): + id: int + name: str + created_at: datetime + + +class CreateFolderRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + + @field_validator("name") + @classmethod + def strip_name(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("Folder name cannot be empty") + return v + + +class UpdateFolderRequest(CreateFolderRequest): + pass + + +@router.get("/") +async def list_folders( + user: UserModel = Depends(get_user), +) -> list[FolderResponse]: + """List all folders in the authenticated user's organization.""" + folders = await db_client.list_folders( + organization_id=user.selected_organization_id + ) + return [ + FolderResponse(id=f.id, name=f.name, created_at=f.created_at) for f in folders + ] + + +@router.post("/") +async def create_folder( + request: CreateFolderRequest, + user: UserModel = Depends(get_user), +) -> FolderResponse: + """Create a new folder in the authenticated user's organization.""" + try: + folder = await db_client.create_folder( + name=request.name, + organization_id=user.selected_organization_id, + ) + except FolderNameConflictError as e: + raise HTTPException(status_code=409, detail=str(e)) + return FolderResponse(id=folder.id, name=folder.name, created_at=folder.created_at) + + +@router.put("/{folder_id}") +async def rename_folder( + folder_id: int, + request: UpdateFolderRequest, + user: UserModel = Depends(get_user), +) -> FolderResponse: + """Rename a folder owned by the authenticated user's organization.""" + try: + folder = await db_client.rename_folder( + folder_id=folder_id, + name=request.name, + organization_id=user.selected_organization_id, + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + except FolderNameConflictError as e: + raise HTTPException(status_code=409, detail=str(e)) + return FolderResponse(id=folder.id, name=folder.name, created_at=folder.created_at) + + +@router.delete("/{folder_id}") +async def delete_folder( + folder_id: int, + user: UserModel = Depends(get_user), +) -> dict[str, bool]: + """Delete a folder. Member agents are moved to "Uncategorized", not deleted.""" + deleted = await db_client.delete_folder( + folder_id=folder_id, + organization_id=user.selected_organization_id, + ) + if not deleted: + raise HTTPException( + status_code=404, detail=f"Folder with id {folder_id} not found" + ) + return {"success": True} diff --git a/api/routes/integration.py b/api/routes/integration.py deleted file mode 100644 index ae4d98c..0000000 --- a/api/routes/integration.py +++ /dev/null @@ -1,266 +0,0 @@ -""" -Route for 3rd party integrations. Currently being backed by nango. -""" - -from dataclasses import dataclass -from typing import Any, Dict, List, Optional, TypedDict - -from fastapi import APIRouter, Depends, HTTPException, Request -from loguru import logger -from pydantic import BaseModel - -from api.db import db_client -from api.db.models import UserModel -from api.services.auth.depends import get_user -from api.services.integrations.nango import nango_service - -router = APIRouter(prefix="/integration") - - -@dataclass -class IntegrationResponse: - id: int - integration_id: str - organisation_id: int - created_by: Optional[int] - provider: str - is_active: bool - created_at: str - action: str - provider_data: dict - - -class SessionResponse(TypedDict): - session_token: str - expires_at: str - - -class WebhookResponse(TypedDict): - status: str - message: str - - -class UpdateIntegrationRequest(BaseModel): - selected_files: List[Dict[str, Any]] - - -class AccessTokenResponse(BaseModel): - access_token: Optional[str] - refresh_token: Optional[str] - expires_at: Optional[str] - connection_id: str - - -def build_integration_response(integration) -> IntegrationResponse: - """Build a standardized integration response with provider-specific data.""" - provider_data = {} - - if integration.provider == "google-sheet": - # For Google Sheets, include selected_files - provider_data["selected_files"] = integration.connection_details.get( - "selected_files", [] - ) - elif integration.provider == "slack": - # For Slack, include channel information - channel = integration.connection_details.get("connection_config", {}).get( - "incoming_webhook.channel" - ) - if channel: - provider_data["channel"] = channel - - return IntegrationResponse( - id=integration.id, - integration_id=integration.integration_id, - organisation_id=integration.organisation_id, - created_by=integration.created_by, - provider=integration.provider, - is_active=integration.is_active, - created_at=integration.created_at.isoformat(), - action=integration.action, - provider_data=provider_data, - ) - - -@router.get("/") -async def get_integrations( - user: UserModel = Depends(get_user), -) -> list[IntegrationResponse]: - """ - Get all integrations for the user's selected organization. - - Returns: - List of integrations associated with the user's selected organization - """ - if not user.selected_organization_id: - raise HTTPException( - status_code=400, detail="No organization selected for the user" - ) - - integrations = await db_client.get_integrations_by_organization_id( - user.selected_organization_id - ) - - return [build_integration_response(integration) for integration in integrations] - - -@router.post("/session") -async def create_session( - user: UserModel = Depends(get_user), -) -> SessionResponse: - """ - Create a Nango session for the user's selected organization. - - Returns: - Session token and ID for the created session - """ - if not user.selected_organization_id: - raise HTTPException( - status_code=400, detail="No organization selected for the user" - ) - - try: - session_data = await nango_service.create_session( - user_id=str(user.id), organization_id=user.selected_organization_id - ) - - return { - "session_token": session_data["data"]["token"], - "expires_at": session_data["data"]["expires_at"], - } - except ValueError as e: - raise HTTPException(status_code=500, detail=str(e)) - except Exception as e: - raise HTTPException( - status_code=500, detail=f"Failed to create session: {str(e)}" - ) - - -@router.put("/{integration_id}") -async def update_integration( - integration_id: int, - request: UpdateIntegrationRequest, - user: UserModel = Depends(get_user), -) -> IntegrationResponse: - """ - Update an integration's selected files (for Google Sheets). - - Args: - integration_id: The ID of the integration to update - request: The update request containing selected files - user: The authenticated user - - Returns: - Updated integration details - """ - if not user.selected_organization_id: - raise HTTPException( - status_code=400, detail="No organization selected for the user" - ) - - # Get the integration first to verify ownership - integrations = await db_client.get_integrations_by_organization_id( - user.selected_organization_id - ) - - integration = next((i for i in integrations if i.id == integration_id), None) - if not integration: - raise HTTPException(status_code=404, detail="Integration not found") - - # Only allow updating selected_files for google-sheet provider - if integration.provider != "google-sheet": - raise HTTPException( - status_code=400, - detail="This endpoint only supports updating Google Sheet integrations", - ) - - # Update the connection_details with the new selected_files - updated_connection_details = integration.connection_details.copy() - updated_connection_details["selected_files"] = request.selected_files - - # Update the integration - updated_integration = await db_client.update_integration_connection_details( - integration_id=integration_id, connection_details=updated_connection_details - ) - - if not updated_integration: - raise HTTPException(status_code=500, detail="Failed to update integration") - - return build_integration_response(updated_integration) - - -@router.get("/{integration_id}/access-token") -async def get_integration_access_token( - integration_id: int, - user: UserModel = Depends(get_user), -) -> AccessTokenResponse: - """ - Get the latest access token for an integration from Nango. - - Args: - integration_id: The ID of the integration - user: The authenticated user - - Returns: - Dict containing access token and expiration info - """ - if not user.selected_organization_id: - raise HTTPException( - status_code=400, detail="No organization selected for the user" - ) - - # Get the integration to verify ownership and get connection details - integrations = await db_client.get_integrations_by_organization_id( - user.selected_organization_id - ) - - integration = next((i for i in integrations if i.id == integration_id), None) - if not integration: - raise HTTPException(status_code=404, detail="Integration not found") - - try: - # Fetch the latest access token from Nango - token_data = await nango_service.get_access_token( - connection_id=integration.integration_id, - provider_config_key=integration.provider, - ) - - # Extract relevant fields - return AccessTokenResponse( - access_token=token_data.get("credentials", {}).get("access_token"), - refresh_token=token_data.get("credentials", {}).get("refresh_token"), - expires_at=token_data.get("credentials", {}).get("expires_at"), - connection_id=integration.integration_id, - ) - - except Exception as e: - logger.error(f"Failed to get access token: {str(e)}") - raise HTTPException( - status_code=500, detail=f"Failed to fetch access token: {str(e)}" - ) - - -@router.post("/webhook", include_in_schema=False) -async def handle_nango_webhook( - request: Request, -) -> WebhookResponse: - """ - Handle Nango integration webhook requests. - - Processes webhook events from Nango when integrations are created/updated - and stores the integration details in the database. - - Args: - request: The raw FastAPI request object - - Returns: - WebhookResponse with status and message - """ - raw_body = await request.body() - - # Get signature from headers (you may need to adjust the header name) - signature = request.headers.get("X-Nango-Signature") - - # Use the nango service to process the webhook - result = await nango_service.process_webhook(raw_body, signature) - - return result diff --git a/api/routes/looptalk.py b/api/routes/looptalk.py deleted file mode 100644 index c8073eb..0000000 --- a/api/routes/looptalk.py +++ /dev/null @@ -1,316 +0,0 @@ -from datetime import datetime -from typing import Any, Dict, List, Optional - -from fastapi import ( - APIRouter, - BackgroundTasks, - Depends, - HTTPException, - WebSocket, -) -from pydantic import BaseModel, Field - -from api.db import db_client -from api.db.models import UserModel -from api.services.auth.depends import get_user -from api.services.looptalk.orchestrator import LoopTalkTestOrchestrator - -router = APIRouter(prefix="/looptalk") - - -# Request/Response Models -class CreateTestSessionRequest(BaseModel): - name: str - actor_workflow_id: int - adversary_workflow_id: int - config: Dict[str, Any] = Field(default_factory=dict) - - -class StartTestSessionRequest(BaseModel): - test_session_id: int - - -class CreateLoadTestRequest(BaseModel): - name_prefix: str - actor_workflow_id: int - adversary_workflow_id: int - test_count: int = Field(ge=1, le=10) - config: Dict[str, Any] = Field(default_factory=dict) - - -class TestSessionResponse(BaseModel): - id: int - name: str - status: str - actor_workflow_id: int - adversary_workflow_id: int - load_test_group_id: Optional[str] - test_index: Optional[int] - config: Dict[str, Any] - results: Optional[Dict[str, Any]] - error: Optional[str] - created_at: datetime - started_at: Optional[datetime] - completed_at: Optional[datetime] - - -class ConversationResponse(BaseModel): - id: int - test_session_id: int - duration_seconds: Optional[int] - actor_recording_url: Optional[str] - adversary_recording_url: Optional[str] - combined_recording_url: Optional[str] - transcript: Optional[Dict[str, Any]] - metrics: Optional[Dict[str, Any]] - created_at: datetime - ended_at: Optional[datetime] - - -# Note: Turn tracking is handled by Langfuse, not exposed via API - - -class LoadTestStatsResponse(BaseModel): - total: int - pending: int - running: int - completed: int - failed: int - sessions: List[Dict[str, Any]] - - -# Singleton orchestrator instance -_orchestrator: Optional[LoopTalkTestOrchestrator] = None - - -def get_orchestrator() -> LoopTalkTestOrchestrator: - """Get or create the LoopTalk orchestrator instance.""" - global _orchestrator - if _orchestrator is None: - _orchestrator = LoopTalkTestOrchestrator(db_client=db_client) - return _orchestrator - - -@router.post("/test-sessions", response_model=TestSessionResponse) -async def create_test_session( - request: CreateTestSessionRequest, user: UserModel = Depends(get_user) -): - """Create a new LoopTalk test session.""" - - # Verify user has access to both workflows - actor_workflow = await db_client.get_workflow(request.actor_workflow_id, user.id) - if not actor_workflow: - raise HTTPException(status_code=404, detail="Actor workflow not found") - - adversary_workflow = await db_client.get_workflow( - request.adversary_workflow_id, user.id - ) - if not adversary_workflow: - raise HTTPException(status_code=404, detail="Adversary workflow not found") - - # Create test session - test_session = await db_client.create_test_session( - organization_id=user.selected_organization_id, - name=request.name, - actor_workflow_id=request.actor_workflow_id, - adversary_workflow_id=request.adversary_workflow_id, - config=request.config, - ) - - return test_session - - -@router.get("/test-sessions", response_model=List[TestSessionResponse]) -async def list_test_sessions( - status: Optional[str] = None, - load_test_group_id: Optional[str] = None, - limit: int = 20, - offset: int = 0, - user: UserModel = Depends(get_user), -): - """List LoopTalk test sessions.""" - - test_sessions = await db_client.list_test_sessions( - organization_id=user.selected_organization_id, - status=status, - load_test_group_id=load_test_group_id, - limit=limit, - offset=offset, - ) - - return test_sessions - - -@router.get("/test-sessions/{test_session_id}", response_model=TestSessionResponse) -async def get_test_session(test_session_id: int, user: UserModel = Depends(get_user)): - """Get a specific test session.""" - - test_session = await db_client.get_test_session( - test_session_id=test_session_id, organization_id=user.selected_organization_id - ) - - if not test_session: - raise HTTPException(status_code=404, detail="Test session not found") - - return test_session - - -@router.post("/test-sessions/{test_session_id}/start") -async def start_test_session( - test_session_id: int, - background_tasks: BackgroundTasks, - user: UserModel = Depends(get_user), - orchestrator: LoopTalkTestOrchestrator = Depends(get_orchestrator), -): - """Start a LoopTalk test session.""" - - # Verify test session exists and user has access - test_session = await db_client.get_test_session( - test_session_id=test_session_id, organization_id=user.selected_organization_id - ) - - if not test_session: - raise HTTPException(status_code=404, detail="Test session not found") - - if test_session.status != "pending": - raise HTTPException( - status_code=400, - detail=f"Test session is {test_session.status}, not pending", - ) - - # Start test session in background - background_tasks.add_task( - orchestrator.start_test_session, - test_session_id=test_session_id, - organization_id=user.selected_organization_id, - ) - - return {"message": "Test session starting", "test_session_id": test_session_id} - - -@router.post("/test-sessions/{test_session_id}/stop") -async def stop_test_session( - test_session_id: int, - user: UserModel = Depends(get_user), - orchestrator: LoopTalkTestOrchestrator = Depends(get_orchestrator), -): - """Stop a running test session.""" - - # Verify test session exists and user has access - test_session = await db_client.get_test_session( - test_session_id=test_session_id, organization_id=user.selected_organization_id - ) - - if not test_session: - raise HTTPException(status_code=404, detail="Test session not found") - - if test_session.status != "running": - raise HTTPException( - status_code=400, - detail=f"Test session is {test_session.status}, not running", - ) - - # Stop test session - result = await orchestrator.stop_test_session(test_session_id=test_session_id) - - return result - - -@router.get("/test-sessions/{test_session_id}/conversation") -async def get_test_session_conversation( - test_session_id: int, user: UserModel = Depends(get_user) -): - """Get conversation details for a test session.""" - - # Verify test session exists and user has access - test_session = await db_client.get_test_session( - test_session_id=test_session_id, organization_id=user.selected_organization_id - ) - - if not test_session: - raise HTTPException(status_code=404, detail="Test session not found") - - # Get conversation - if test_session.conversations: - conversation = test_session.conversations[ - 0 - ] # For now, one conversation per session - - # Note: Turn details are available in Langfuse, not here - return { - "conversation": conversation, - "message": "Turn details are tracked in Langfuse", - } - - return {"conversation": None} - - -@router.post("/load-tests", response_model=Dict[str, Any]) -async def create_load_test( - request: CreateLoadTestRequest, - background_tasks: BackgroundTasks, - user: UserModel = Depends(get_user), - orchestrator: LoopTalkTestOrchestrator = Depends(get_orchestrator), -): - """Create and start a load test.""" - - # Verify user has access to both workflows - actor_workflow = await db_client.get_workflow(request.actor_workflow_id, user.id) - if not actor_workflow: - raise HTTPException(status_code=404, detail="Actor workflow not found") - - adversary_workflow = await db_client.get_workflow( - request.adversary_workflow_id, user.id - ) - if not adversary_workflow: - raise HTTPException(status_code=404, detail="Adversary workflow not found") - - # Start load test in background - result = await orchestrator.start_load_test( - organization_id=user.selected_organization_id, - name_prefix=request.name_prefix, - actor_workflow_id=request.actor_workflow_id, - adversary_workflow_id=request.adversary_workflow_id, - config=request.config, - test_count=request.test_count, - ) - - return result - - -@router.get( - "/load-tests/{load_test_group_id}/stats", response_model=LoadTestStatsResponse -) -async def get_load_test_stats( - load_test_group_id: str, user: UserModel = Depends(get_user) -): - """Get statistics for a load test group.""" - - stats = await db_client.get_load_test_group_stats( - load_test_group_id=load_test_group_id, - organization_id=user.selected_organization_id, - ) - - return stats - - -@router.get("/active-tests") -async def get_active_tests( - orchestrator: LoopTalkTestOrchestrator = Depends(get_orchestrator), - user: UserModel = Depends(get_user), -): - """Get information about currently active test sessions.""" - - return orchestrator.get_active_test_info() - - -@router.websocket("/test-sessions/{test_session_id}/audio-stream") -async def audio_stream_websocket( - websocket: WebSocket, - test_session_id: int, - role: str = "mixed", # "actor", "adversary", or "mixed" - token: Optional[str] = None, -): - """WebSocket endpoint for real-time audio streaming from LoopTalk test sessions.""" - # TODO: to be implemented - pass diff --git a/api/routes/main.py b/api/routes/main.py index d1a424e..6de59b1 100644 --- a/api/routes/main.py +++ b/api/routes/main.py @@ -6,9 +6,8 @@ from api.routes.agent_stream import router as agent_stream_router from api.routes.auth import router as auth_router from api.routes.campaign import router as campaign_router from api.routes.credentials import router as credentials_router -from api.routes.integration import router as integration_router +from api.routes.folder import router as folder_router from api.routes.knowledge_base import router as knowledge_base_router -from api.routes.looptalk import router as looptalk_router from api.routes.node_types import router as node_types_router from api.routes.organization import router as organization_router from api.routes.organization_usage import router as organization_usage_router @@ -27,6 +26,8 @@ from api.routes.webrtc_signaling import router as webrtc_signaling_router from api.routes.workflow import router as workflow_router from api.routes.workflow_embed import router as workflow_embed_router from api.routes.workflow_recording import router as workflow_recording_router +from api.routes.workflow_text_chat import router as workflow_text_chat_router +from api.services.integrations import all_routers router = APIRouter( tags=["main"], @@ -36,15 +37,14 @@ router = APIRouter( router.include_router(telephony_router) router.include_router(superuser_router) router.include_router(workflow_router) +router.include_router(workflow_text_chat_router) router.include_router(user_router) router.include_router(campaign_router) router.include_router(credentials_router) router.include_router(tool_router) -router.include_router(integration_router) router.include_router(organization_router) router.include_router(s3_router) router.include_router(service_keys_router) -router.include_router(looptalk_router) router.include_router(organization_usage_router) router.include_router(reports_router) router.include_router(webrtc_signaling_router) @@ -55,10 +55,14 @@ router.include_router(public_download_router) router.include_router(workflow_embed_router) router.include_router(knowledge_base_router) router.include_router(workflow_recording_router) +router.include_router(folder_router) router.include_router(auth_router) router.include_router(node_types_router) router.include_router(agent_stream_router) +for _integration_router in all_routers(): + router.include_router(_integration_router) + class HealthResponse(BaseModel): status: str diff --git a/api/routes/organization_usage.py b/api/routes/organization_usage.py index 8f0638a..15ebdbe 100644 --- a/api/routes/organization_usage.py +++ b/api/routes/organization_usage.py @@ -57,6 +57,7 @@ class WorkflowRunUsageResponse(BaseModel): caller_number: Optional[str] = None called_number: Optional[str] = None call_type: Optional[str] = None + mode: Optional[str] = None disposition: Optional[str] = None initial_context: Optional[Dict[str, Any]] = None gathered_context: Optional[Dict[str, Any]] = None diff --git a/api/routes/public_agent.py b/api/routes/public_agent.py index b911da0..93d3f1e 100644 --- a/api/routes/public_agent.py +++ b/api/routes/public_agent.py @@ -1,18 +1,19 @@ -"""Public API endpoints for agent triggers. +"""Public API endpoints for public agent execution. These endpoints are accessible with API key authentication and allow external systems to programmatically trigger phone calls. """ import random -from typing import Optional +from dataclasses import dataclass +from typing import Awaitable, Callable, Optional from fastapi import APIRouter, Header, HTTPException from loguru import logger from pydantic import BaseModel from api.db import db_client -from api.enums import TriggerState +from api.enums import TriggerState, WorkflowStatus from api.services.quota_service import check_dograh_quota_by_user_id from api.services.telephony.factory import ( get_default_telephony_provider, @@ -39,6 +40,14 @@ class TriggerCallResponse(BaseModel): workflow_run_name: str +@dataclass +class ResolvedAgentTarget: + workflow: object + organization_id: int + identifier_type: str + identifier_value: str + + def trigger_exists_in_workflow(workflow_definition: dict, trigger_path: str) -> bool: """Check if trigger node exists in workflow definition. @@ -57,72 +66,133 @@ def trigger_exists_in_workflow(workflow_definition: dict, trigger_path: str) -> return False -async def _initiate_call( - uuid: str, - request: TriggerCallRequest, - x_api_key: str, - *, - use_draft: bool, -) -> TriggerCallResponse: - """Shared core for production and test trigger endpoints. - - When ``use_draft`` is True the latest draft definition is executed; - otherwise the published (released) definition is used. - """ - # 1. Validate API key +async def _validate_api_key(x_api_key: str): + """Validate the org API key used to invoke a public agent endpoint.""" api_key = await db_client.validate_api_key(x_api_key) if not api_key: raise HTTPException(status_code=401, detail="Invalid API key") + return api_key - # 2. Lookup agent trigger by UUID - trigger = await db_client.get_agent_trigger_by_path(uuid) + +def _ensure_workflow_is_active(workflow) -> None: + if workflow.status != WorkflowStatus.ACTIVE.value: + raise HTTPException(status_code=404, detail="Workflow is not active") + + +def _get_execution_user_id(workflow) -> int: + if workflow.user_id is None: + raise HTTPException( + status_code=409, + detail="Workflow has no execution owner", + ) + return workflow.user_id + + +async def _get_workflow_definition_for_execution(workflow, *, use_draft: bool) -> dict: + """Return the definition that would execute for this public agent request.""" + if use_draft: + draft = await db_client.get_draft_version(workflow.id) + if draft: + return draft.workflow_json + + if workflow.released_definition is None: + raise HTTPException( + status_code=404, detail="Workflow has no published definition" + ) + + return workflow.released_definition.workflow_json + + +async def _resolve_trigger_target( + trigger_path: str, + organization_id: int, + *, + use_draft: bool, +) -> ResolvedAgentTarget: + """Resolve a trigger UUID to a workflow, scoped to the API key's org.""" + trigger = await db_client.get_agent_trigger_by_path(trigger_path) if not trigger: raise HTTPException(status_code=404, detail="Agent trigger not found") - # 3. Validate organization match (API key org must match trigger org) - if api_key.organization_id != trigger.organization_id: + if organization_id != trigger.organization_id: raise HTTPException(status_code=403, detail="Access denied") - # 4. Validate trigger is active if trigger.state != TriggerState.ACTIVE.value: raise HTTPException(status_code=404, detail="Agent trigger is not active") - # 4.5 Check Dograh quota before initiating the call (apply the trigger's - # workflow's model_overrides so we evaluate the keys this run will use). + workflow = await db_client.get_workflow( + trigger.workflow_id, + organization_id=organization_id, + ) + if not workflow: + raise HTTPException(status_code=404, detail="Workflow not found") + + _ensure_workflow_is_active(workflow) + workflow_definition = await _get_workflow_definition_for_execution( + workflow, + use_draft=use_draft, + ) + if not trigger_exists_in_workflow(workflow_definition, trigger_path): + raise HTTPException( + status_code=404, + detail="Trigger not found in the selected Agent", + ) + + return ResolvedAgentTarget( + workflow=workflow, + organization_id=organization_id, + identifier_type="trigger_path", + identifier_value=trigger_path, + ) + + +async def _resolve_workflow_uuid_target( + workflow_uuid: str, + organization_id: int, + *, + use_draft: bool, +) -> ResolvedAgentTarget: + """Resolve a workflow UUID directly, scoped to the API key's org.""" + workflow = await db_client.get_workflow_by_uuid(workflow_uuid, organization_id) + if not workflow: + raise HTTPException(status_code=404, detail="Workflow not found") + + _ensure_workflow_is_active(workflow) + await _get_workflow_definition_for_execution(workflow, use_draft=use_draft) + + return ResolvedAgentTarget( + workflow=workflow, + organization_id=organization_id, + identifier_type="workflow_uuid", + identifier_value=workflow_uuid, + ) + + +async def _execute_resolved_target( + target: ResolvedAgentTarget, + request: TriggerCallRequest, + *, + use_draft: bool, + api_key_id: int | None, + api_key_created_by: int | None, +) -> TriggerCallResponse: + """Shared execution path once the target workflow has been resolved.""" + execution_user_id = _get_execution_user_id(target.workflow) + + # Check Dograh quota using the workflow owner's config and model overrides. quota_result = await check_dograh_quota_by_user_id( - api_key.created_by, workflow_id=trigger.workflow_id + execution_user_id, + workflow_id=target.workflow.id, ) if not quota_result.has_quota: raise HTTPException(status_code=402, detail=quota_result.error_message) - # 5. Get workflow and resolve the definition (published vs draft) - workflow = await db_client.get_workflow_by_id(trigger.workflow_id) - if not workflow: - raise HTTPException(status_code=404, detail="Workflow not found") - - if use_draft: - draft = await db_client.get_draft_version(trigger.workflow_id) - # Fall back to the published definition when no draft exists, so the - # test URL always runs *something* — typically the same agent the - # production URL would run. - workflow_definition = ( - draft.workflow_json if draft else workflow.released_definition.workflow_json - ) - else: - workflow_definition = workflow.released_definition.workflow_json - - # Validate trigger node still exists in the resolved definition - if not trigger_exists_in_workflow(workflow_definition, uuid): - raise HTTPException( - status_code=404, - detail="Trigger not found in the published Agent", - ) - - # 6. Get telephony provider — either the caller-specified config (validated - # against the trigger's org) or the org's default config. + # Get telephony provider — either the caller-specified config (validated + # against the workflow's org) or the org's default config. if request.telephony_configuration_id is not None: cfg = await db_client.get_telephony_configuration_for_org( - request.telephony_configuration_id, trigger.organization_id + request.telephony_configuration_id, + target.organization_id, ) if not cfg: raise HTTPException( @@ -130,7 +200,7 @@ async def _initiate_call( ) try: provider = await get_telephony_provider_by_id( - cfg.id, trigger.organization_id + cfg.id, target.organization_id ) except ValueError: raise HTTPException( @@ -140,14 +210,14 @@ async def _initiate_call( resolved_cfg_id = cfg.id else: try: - provider = await get_default_telephony_provider(trigger.organization_id) + provider = await get_default_telephony_provider(target.organization_id) except ValueError: raise HTTPException( status_code=400, detail="Telephony provider not configured for this organization", ) default_cfg = await db_client.get_default_telephony_configuration( - trigger.organization_id + target.organization_id ) resolved_cfg_id = default_cfg.id if default_cfg else None @@ -164,24 +234,36 @@ async def _initiate_call( # 8. Create workflow run mode_label = "TEST" if use_draft else "API" workflow_run_name = f"WR-{mode_label}-{random.randint(1000, 9999)}" + initial_context = { + "provider": provider.PROVIDER_NAME, + "phone_number": request.phone_number, + "trigger_mode": "test" if use_draft else "production", + "telephony_configuration_id": resolved_cfg_id, + "agent_identifier": target.identifier_value, + "agent_identifier_type": target.identifier_type, + "workflow_uuid": target.workflow.workflow_uuid, + } + if target.identifier_type == "trigger_path": + initial_context["agent_uuid"] = target.identifier_value + if api_key_id is not None: + initial_context["api_key_id"] = api_key_id + if api_key_created_by is not None: + 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=trigger.workflow_id, + workflow_id=target.workflow.id, mode=workflow_run_mode, - initial_context={ - "provider": provider.PROVIDER_NAME, - "phone_number": request.phone_number, - "agent_uuid": uuid, - "trigger_mode": "test" if use_draft else "production", - "telephony_configuration_id": resolved_cfg_id, - **(request.initial_context or {}), - }, - user_id=api_key.created_by, + initial_context=initial_context, + user_id=execution_user_id, use_draft=use_draft, + organization_id=target.organization_id, ) logger.info( - f"Created workflow run {workflow_run.id} for API trigger {uuid} " + f"Created workflow run {workflow_run.id} for public agent " + f"{target.identifier_type}={target.identifier_value} " f"(mode={'test' if use_draft else 'production'}) " f"to phone number {request.phone_number}" ) @@ -192,10 +274,10 @@ async def _initiate_call( webhook_url = ( f"{backend_endpoint}/api/v1/telephony/{webhook_endpoint}" - f"?workflow_id={trigger.workflow_id}" - f"&user_id={api_key.created_by}" + f"?workflow_id={target.workflow.id}" + f"&user_id={execution_user_id}" f"&workflow_run_id={workflow_run.id}" - f"&organization_id={trigger.organization_id}" + f"&organization_id={target.organization_id}" ) # 10. Initiate call via telephony provider. workflow_id and user_id are @@ -207,8 +289,8 @@ async def _initiate_call( to_number=request.phone_number, webhook_url=webhook_url, workflow_run_id=workflow_run.id, - workflow_id=trigger.workflow_id, - user_id=api_key.created_by, + workflow_id=target.workflow.id, + user_id=execution_user_id, ) except Exception as e: logger.warning( @@ -221,7 +303,7 @@ async def _initiate_call( logger.info( f"Call initiated successfully for workflow run {workflow_run.id} " - f"via trigger {uuid}" + f"via {target.identifier_type}={target.identifier_value}" ) return TriggerCallResponse( @@ -231,6 +313,30 @@ async def _initiate_call( ) +async def _initiate_call( + identifier: str, + request: TriggerCallRequest, + x_api_key: str, + *, + use_draft: bool, + target_resolver: Callable[..., Awaitable[ResolvedAgentTarget]], +) -> TriggerCallResponse: + """Resolve the requested public target, then execute the common call flow.""" + api_key = await _validate_api_key(x_api_key) + target = await target_resolver( + identifier, + api_key.organization_id, + use_draft=use_draft, + ) + return await _execute_resolved_target( + target, + request, + use_draft=use_draft, + api_key_id=api_key.id, + api_key_created_by=api_key.created_by, + ) + + @router.post("/{uuid}", response_model=TriggerCallResponse) async def initiate_call( uuid: str, @@ -241,7 +347,13 @@ async def initiate_call( Executes the workflow's currently released definition. """ - return await _initiate_call(uuid, request, x_api_key, use_draft=False) + return await _initiate_call( + uuid, + request, + x_api_key, + use_draft=False, + target_resolver=_resolve_trigger_target, + ) @router.post("/test/{uuid}", response_model=TriggerCallResponse) @@ -255,4 +367,42 @@ async def initiate_call_test( Useful for verifying changes before publishing. Falls back to the published definition when no draft exists. """ - return await _initiate_call(uuid, request, x_api_key, use_draft=True) + return await _initiate_call( + uuid, + request, + x_api_key, + use_draft=True, + target_resolver=_resolve_trigger_target, + ) + + +@router.post("/workflow/{workflow_uuid}", response_model=TriggerCallResponse) +async def initiate_call_by_workflow_uuid( + workflow_uuid: str, + request: TriggerCallRequest, + x_api_key: str = Header(..., alias="X-API-Key"), +): + """Initiate a phone call against the published workflow identified by UUID.""" + return await _initiate_call( + workflow_uuid, + request, + x_api_key, + use_draft=False, + target_resolver=_resolve_workflow_uuid_target, + ) + + +@router.post("/test/workflow/{workflow_uuid}", response_model=TriggerCallResponse) +async def initiate_call_test_by_workflow_uuid( + workflow_uuid: str, + request: TriggerCallRequest, + x_api_key: str = Header(..., alias="X-API-Key"), +): + """Initiate a phone call against the latest draft of the workflow by UUID.""" + return await _initiate_call( + workflow_uuid, + request, + x_api_key, + use_draft=True, + target_resolver=_resolve_workflow_uuid_target, + ) diff --git a/api/routes/public_download.py b/api/routes/public_download.py index 38793d9..c84cc24 100644 --- a/api/routes/public_download.py +++ b/api/routes/public_download.py @@ -1,8 +1,9 @@ """Public download endpoints for workflow recordings and transcripts. These endpoints provide secure, token-based public access to workflow artifacts -without requiring authentication. Tokens are generated on-demand when webhooks -are executed and included in the webhook payload. +without requiring authentication. Tokens are generated on-demand during +post-call processing for runs that execute integrations, QA, or campaign +reporting. """ from typing import Literal diff --git a/api/routes/s3_signed_url.py b/api/routes/s3_signed_url.py index 2ea2dda..f0008ae 100644 --- a/api/routes/s3_signed_url.py +++ b/api/routes/s3_signed_url.py @@ -79,7 +79,7 @@ async def _validate_and_extract_workflow_run_id( Args: key: S3 object key - allow_special_paths: If True, allows looptalk/voicemail paths + allow_special_paths: If True, allows voicemail paths Returns: workflow_run_id if found, None for special paths (when allowed) @@ -91,10 +91,7 @@ async def _validate_and_extract_workflow_run_id( run_id_str = key[len("transcripts/") : -4] # strip prefix & suffix elif key.startswith("recordings/") and key.endswith(".wav"): run_id_str = key[len("recordings/") : -4] - elif allow_special_paths and ( - key.startswith("looptalk/") or key.startswith("voicemail_detections/") - ): - # Allow looptalk and voicemail paths for debugging (only if explicitly allowed) + elif allow_special_paths and key.startswith("voicemail_detections/"): return None # Skip validation for these paths else: raise HTTPException(status_code=400, detail="Invalid key format") @@ -258,7 +255,7 @@ async def get_file_metadata( f"METADATA: Using stored {backend} for metadata request - key: {key}" ) else: - # Fallback to current storage for legacy records or looptalk/voicemail files + # Fallback to current storage for legacy records or voicemail files storage = storage_fs current_backend = StorageBackend.get_current_backend() logger.warning( diff --git a/api/routes/telephony.py b/api/routes/telephony.py index bede46a..86bbbc0 100644 --- a/api/routes/telephony.py +++ b/api/routes/telephony.py @@ -25,7 +25,7 @@ from api.enums import CallType, 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.quota_service import check_dograh_quota, check_dograh_quota_by_user_id +from api.services.quota_service import check_dograh_quota_by_user_id from api.services.telephony.call_transfer_manager import get_call_transfer_manager from api.services.telephony.factory import ( get_all_telephony_providers, @@ -60,6 +60,15 @@ class InitiateCallRequest(BaseModel): from_phone_number_id: int | None = None +def _get_execution_user_id(workflow) -> int: + if workflow.user_id is None: + raise HTTPException( + status_code=409, + detail="Workflow has no execution owner", + ) + return workflow.user_id + + @router.post( "/initiate-call", **sdk_expose( @@ -107,15 +116,6 @@ async def initiate_call( detail="telephony_not_configured", ) - # Check Dograh quota before initiating the call (apply per-workflow - # model_overrides so the keys we will actually use are the ones checked). - quota_result = await check_dograh_quota(user, workflow_id=request.workflow_id) - if not quota_result.has_quota: - raise HTTPException(status_code=402, detail=quota_result.error_message) - - # Determine the workflow run mode based on provider type - workflow_run_mode = provider.PROVIDER_NAME - phone_number = request.phone_number or user_configuration.test_phone_number if not phone_number: @@ -125,25 +125,38 @@ async def initiate_call( "configuration", ) + 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") + execution_user_id = _get_execution_user_id(workflow) + + # Check Dograh quota before initiating the call (apply per-workflow + # model_overrides so the keys we will actually use are the ones checked). + quota_result = await check_dograh_quota_by_user_id( + execution_user_id, workflow_id=workflow.id + ) + if not quota_result.has_quota: + raise HTTPException(status_code=402, detail=quota_result.error_message) + + # 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: - # Fetch workflow to merge template context variables (e.g. caller_number, - # called_number set in workflow settings for testing pre-call data fetch) - 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") + # 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, - request.workflow_id, + workflow.id, workflow_run_mode, - user_id=user.id, + user_id=execution_user_id, call_type=CallType.OUTBOUND, initial_context={ **template_vars, @@ -153,12 +166,20 @@ async def initiate_call( "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, user.id) + 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 # Construct webhook URL based on provider type @@ -168,13 +189,13 @@ async def initiate_call( webhook_url = ( f"{backend_endpoint}/api/v1/telephony/{webhook_endpoint}" - f"?workflow_id={request.workflow_id}" - f"&user_id={user.id}" + 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": request.workflow_id, "user_id": user.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 @@ -292,6 +313,7 @@ async def _detect_provider(webhook_data: dict, headers: dict): async def _validate_inbound_request( workflow_id: int, + webhook_url: str, provider_class, normalized_data, webhook_data: dict, @@ -304,7 +326,9 @@ async def _validate_inbound_request( """ from api.services.telephony import registry as telephony_registry - workflow = await db_client.get_workflow(workflow_id) + # System lookup: inbound routing only has the workflow_id and derives the + # org/user from the workflow itself, so use the explicit unscoped variant. + workflow = await db_client.get_workflow_by_id(workflow_id) if not workflow: return False, TelephonyError.WORKFLOW_NOT_FOUND, {}, None @@ -361,8 +385,6 @@ async def _validate_inbound_request( # Verify webhook signature using the matched config's credentials. The # provider extracts its own signature/timestamp/nonce headers from the # dict, so this dispatcher stays generic. - backend_endpoint, _ = await get_backend_endpoints() - webhook_url = f"{backend_endpoint}/api/v1/telephony/inbound/{workflow_id}" provider_instance = await get_telephony_provider_by_id( telephony_configuration_id, organization_id ) @@ -527,8 +549,9 @@ async def _handle_telephony_websocket( await websocket.close(code=4404, reason="Workflow run not found") return - # Get workflow for organization info - workflow = await db_client.get_workflow(workflow_id) + # 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) if not workflow: logger.error(f"Workflow {workflow_id} not found") await websocket.close(code=4404, reason="Workflow not found") @@ -697,13 +720,11 @@ async def handle_inbound_run(request: Request): user_id = workflow.user_id # 3. Verify webhook signature against the matched config's credentials. - backend_endpoint, wss_backend_endpoint = await get_backend_endpoints() - webhook_url = f"{backend_endpoint}/api/v1/telephony/inbound/run" provider_instance = await get_telephony_provider_by_id( telephony_configuration_id, config.organization_id ) signature_valid = await provider_instance.verify_inbound_signature( - webhook_url, webhook_data, headers, raw_body + str(request.url), webhook_data, headers, raw_body ) if not signature_valid: logger.warning( @@ -736,6 +757,7 @@ async def handle_inbound_run(request: Request): from_phone_number_id=phone_row.id, ) + 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}" @@ -836,6 +858,7 @@ async def handle_inbound_telephony( provider_instance, ) = await _validate_inbound_request( workflow_id, + str(request.url), provider_class, normalized_data, webhook_data, diff --git a/api/routes/tool.py b/api/routes/tool.py index 80fb45d..b7fa97e 100644 --- a/api/routes/tool.py +++ b/api/routes/tool.py @@ -1,10 +1,12 @@ """API routes for managing tools.""" +import asyncio import re from datetime import datetime from typing import Annotated, Any, Dict, List, Literal, Optional, Union from fastapi import APIRouter, Depends, HTTPException +from loguru import logger from pydantic import BaseModel, Field, field_validator from api.db import db_client @@ -13,9 +15,23 @@ from api.enums import PostHogEvent, ToolCategory, ToolStatus from api.sdk_expose import sdk_expose from api.services.auth.depends import get_user from api.services.posthog_client import capture_event +from api.services.workflow.mcp_tool_session import discover_mcp_tools +from api.services.workflow.tools.mcp_tool import ( + McpDefinitionError, + validate_mcp_definition, +) +from api.services.workflow.tools.mcp_tool import ( + McpToolConfig as SharedMcpToolConfig, +) +from api.services.workflow.tools.mcp_tool import ( + McpToolDefinition as SharedMcpToolDefinition, +) router = APIRouter(prefix="/tools") +McpToolConfig = SharedMcpToolConfig +McpToolDefinition = SharedMcpToolDefinition + # Request/Response schemas class ToolParameter(BaseModel): @@ -29,6 +45,20 @@ class ToolParameter(BaseModel): ) +class PresetToolParameter(BaseModel): + """A parameter injected by Dograh at runtime.""" + + name: str = Field(description="Parameter name (used as key in request body)") + type: str = Field(description="Parameter type: string, number, or boolean") + value_template: str = Field( + description="Fixed value or template, e.g. {{initial_context.phone_number}}" + ) + required: bool = Field( + default=True, + description="Whether the parameter must resolve to a non-empty value", + ) + + class HttpApiConfig(BaseModel): """Configuration for HTTP API tools.""" @@ -43,6 +73,10 @@ class HttpApiConfig(BaseModel): parameters: Optional[List[ToolParameter]] = Field( default=None, description="Parameters that the tool accepts from LLM" ) + preset_parameters: Optional[List[PresetToolParameter]] = Field( + default=None, + description="Parameters injected by Dograh from fixed values or workflow context templates", + ) timeout_ms: Optional[int] = Field( default=5000, description="Request timeout in milliseconds" ) @@ -165,6 +199,7 @@ ToolDefinition = Annotated[ EndCallToolDefinition, TransferCallToolDefinition, CalculatorToolDefinition, + McpToolDefinition, ], Field(discriminator="type"), ] @@ -230,6 +265,14 @@ class ToolResponse(BaseModel): from_attributes = True +class McpRefreshResponse(BaseModel): + """Result of re-discovering an MCP server's tool catalog.""" + + tool_uuid: str + discovered_tools: list = Field(default_factory=list) + error: Optional[str] = None + + def build_tool_response(tool, include_created_by: bool = False) -> ToolResponse: """Build a response from a tool model.""" created_by = None @@ -318,6 +361,52 @@ async def list_tools( return [build_tool_response(tool) for tool in tools] +async def _fetch_credential(credential_uuid: Optional[str], organization_id: int): + """Best-effort credential lookup for MCP auth. A missing/failed credential + degrades to ``None`` (unauthenticated) rather than failing the request.""" + if not credential_uuid: + return None + try: + return await db_client.get_credential_by_uuid(credential_uuid, organization_id) + except Exception as e: # noqa: BLE001 + logger.warning(f"MCP: credential fetch failed: {e}") + return None + + +async def _populate_discovered_tools(definition: dict, *, organization_id: int) -> dict: + """Best-effort: for an MCP definition, connect to the server, list its + tools, and overwrite ``config.discovered_tools``. Never raises and never + blocks tool save — a dead server yields ``discovered_tools: []``. Non-MCP + definitions pass through untouched.""" + if not isinstance(definition, dict) or definition.get("type") != "mcp": + return definition + try: + cfg = validate_mcp_definition(definition) + except McpDefinitionError: + return definition + + credential = await _fetch_credential(cfg.get("credential_uuid"), organization_id) + + # Run discovery in an isolated asyncio task so an anyio cancel-scope + # CancelledError doesn't bleed into the parent task and corrupt the + # subsequent DB write. _run() never raises (degrades to []). + async def _run() -> list: + try: + return await discover_mcp_tools( + url=cfg["url"], + credential=credential, + timeout_secs=cfg["timeout_secs"], + sse_read_timeout_secs=cfg["sse_read_timeout_secs"], + ) + except BaseException as e: # noqa: BLE001 + logger.warning(f"MCP discovery failed; caching empty list: {e}") + return [] + + discovered = await asyncio.ensure_future(_run()) + definition["config"]["discovered_tools"] = discovered + return definition + + @router.post("/") async def create_tool( request: CreateToolRequest, @@ -339,11 +428,16 @@ async def create_tool( validate_category(request.category) + definition = await _populate_discovered_tools( + request.definition.model_dump(), + organization_id=user.selected_organization_id, + ) + tool = await db_client.create_tool( organization_id=user.selected_organization_id, user_id=user.id, name=request.name, - definition=request.definition.model_dump(), + definition=definition, category=request.category, description=request.description, icon=request.icon, @@ -392,6 +486,67 @@ async def get_tool( return build_tool_response(tool, include_created_by=True) +@router.post("/{tool_uuid}/mcp/refresh") +async def refresh_mcp_tools( + tool_uuid: str, + user: UserModel = Depends(get_user), +) -> McpRefreshResponse: + """Re-discover an MCP tool's server catalog and overwrite the cached + ``definition.config.discovered_tools``. Server down → 200 with error + (cache not overwritten on transient failure).""" + if not user.selected_organization_id: + raise HTTPException( + status_code=400, detail="No organization selected for the user" + ) + + tool = await db_client.get_tool_by_uuid( + tool_uuid, user.selected_organization_id, include_archived=True + ) + if not tool: + raise HTTPException(status_code=404, detail="Tool not found") + if tool.category != ToolCategory.MCP.value: + raise HTTPException(status_code=400, detail="Tool is not an MCP tool") + + try: + cfg = validate_mcp_definition(tool.definition) + except McpDefinitionError as e: + raise HTTPException(status_code=400, detail=f"Invalid MCP definition: {e}") + + credential = await _fetch_credential( + cfg.get("credential_uuid"), user.selected_organization_id + ) + + try: + discovered = await discover_mcp_tools( + url=cfg["url"], + credential=credential, + timeout_secs=cfg["timeout_secs"], + sse_read_timeout_secs=cfg["sse_read_timeout_secs"], + ) + except Exception as e: # noqa: BLE001 + logger.warning(f"MCP refresh discovery failed: {e}") + discovered = [] + + if not discovered: + error = ( + f"Could not reach the MCP server at {cfg['url']} " + f"(or it exposes no tools). Previously cached list retained." + ) + # Do NOT clobber a previously-good cache with [] on a transient outage. + return McpRefreshResponse(tool_uuid=tool_uuid, discovered_tools=[], error=error) + + new_def = dict(tool.definition or {}) + new_def["config"] = {**new_def.get("config", {}), "discovered_tools": discovered} + await db_client.update_tool( + tool_uuid=tool_uuid, + organization_id=user.selected_organization_id, + definition=new_def, + ) + return McpRefreshResponse( + tool_uuid=tool_uuid, discovered_tools=discovered, error=None + ) + + @router.put("/{tool_uuid}") async def update_tool( tool_uuid: str, @@ -416,12 +571,21 @@ async def update_tool( if request.status: validate_status(request.status) + definition = ( + await _populate_discovered_tools( + request.definition.model_dump(), + organization_id=user.selected_organization_id, + ) + if request.definition + else None + ) + tool = await db_client.update_tool( tool_uuid=tool_uuid, organization_id=user.selected_organization_id, name=request.name, description=request.description, - definition=request.definition.model_dump() if request.definition else None, + definition=definition, icon=request.icon, icon_color=request.icon_color, status=request.status, diff --git a/api/routes/webrtc_signaling.py b/api/routes/webrtc_signaling.py index e39b13d..f4be425 100644 --- a/api/routes/webrtc_signaling.py +++ b/api/routes/webrtc_signaling.py @@ -18,6 +18,7 @@ import asyncio import ipaddress import os from datetime import UTC, datetime +from enum import Enum from typing import Dict, List, Optional from aiortc import RTCIceServer @@ -49,6 +50,63 @@ from api.services.quota_service import check_dograh_quota router = APIRouter(prefix="/ws") +class NonRelayFilterPolicy(Enum): + """What to filter from non-relay ICE candidates. Relay candidates always pass.""" + + NONE = "none" # filter nothing — pass all candidates + PRIVATE = "private" # filter non-relay candidates with private/CGNAT IPs + ALL = "all" # filter all non-relay candidates (relay-only mode) + + +def is_local_or_cgnat_ip(ip_str: str) -> bool: + """Return True for RFC1918, loopback, link-local, and CGNAT addresses.""" + + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + return False + + is_cgnat = ip.version == 4 and ip in ipaddress.ip_network("100.64.0.0/10") + return ip.is_private or ip.is_loopback or ip.is_link_local or is_cgnat + + +def resolve_ice_filter_policies( + environment: str, + force_turn_relay: bool, + server_ip: str, +) -> tuple[NonRelayFilterPolicy, NonRelayFilterPolicy]: + """Resolve outbound and inbound non-relay filtering for this deployment.""" + + private_lan_deployment = ( + environment != Environment.LOCAL.value and is_local_or_cgnat_ip(server_ip) + ) + + if force_turn_relay: + # Relay-only diagnostics stay explicit. On private LAN deployments we + # must still accept inbound private candidates for relay<->host pairs. + outbound_policy = NonRelayFilterPolicy.ALL + inbound_policy = ( + NonRelayFilterPolicy.NONE + if private_lan_deployment + else NonRelayFilterPolicy.PRIVATE + ) + return outbound_policy, inbound_policy + + if environment == Environment.LOCAL.value or private_lan_deployment: + return NonRelayFilterPolicy.NONE, NonRelayFilterPolicy.NONE + + # Public remote deployment: drop private-IP host candidates to avoid + # coturn denied-peer-ip errors against Docker bridge and LAN interfaces. + return NonRelayFilterPolicy.PRIVATE, NonRelayFilterPolicy.PRIVATE + + +ICE_OUTBOUND_POLICY, ICE_INBOUND_POLICY = resolve_ice_filter_policies( + ENVIRONMENT, + FORCE_TURN_RELAY, + os.getenv("SERVER_IP", ""), +) + + def is_private_ip_candidate(candidate_str: str) -> bool: """Check if ICE candidate contains a private IP address or CGNAT IP Address. @@ -69,61 +127,58 @@ def is_private_ip_candidate(candidate_str: str) -> bool: if "typ" in parts: typ_index = parts.index("typ") ip_str = parts[typ_index - 2] - ip = ipaddress.ip_address(ip_str) - is_cgnat = ip in ipaddress.ip_network("100.64.0.0/10") - return ip.is_private or is_cgnat + return is_local_or_cgnat_ip(ip_str) except (ValueError, IndexError): pass return False -def filter_outbound_sdp(sdp: str) -> str: - """Strip ICE candidates from an outbound answer SDP based on env config. +def _keep_candidate(candidate_str: str, policy: NonRelayFilterPolicy) -> bool: + """Return True if this ICE candidate should be kept under the given policy. - Two filters apply: - - 1. In non-LOCAL environments, drop host candidates with private/CGNAT IPs. - aiortc gathers host candidates from every interface on the box, including - Docker bridges (172.17.0.1, 172.18.0.1). Advertising those to the browser - causes coturn "peer IP X denied" errors when the browser asks TURN to - permit them. - - 2. When FORCE_TURN_RELAY is set, drop every non-relay candidate so the - only path the browser can use is via TURN. Lets you verify TURN - connectivity end-to-end — if TURN is broken, the call simply fails. + Relay candidates always pass — a relay with a private IP (LAN TURN server) + must never be dropped regardless of policy. """ - if ENVIRONMENT == Environment.LOCAL.value and not FORCE_TURN_RELAY: + if " typ relay" in candidate_str: + return True + if policy == NonRelayFilterPolicy.NONE: + return True + if policy == NonRelayFilterPolicy.ALL: + return False + # PRIVATE: drop non-relay candidates with private/CGNAT IPs + return not is_private_ip_candidate(candidate_str) + + +def filter_outbound_sdp(sdp: str) -> str: + """Strip ICE candidates from an outbound answer SDP based on ICE_OUTBOUND_POLICY.""" + if ICE_OUTBOUND_POLICY == NonRelayFilterPolicy.NONE: return sdp lines = sdp.split("\r\n") filtered: List[str] = [] - dropped_non_relay = 0 + dropped = 0 kept_relay = 0 for line in lines: if line.startswith("a=candidate:"): candidate_str = line[2:] - if FORCE_TURN_RELAY and " typ relay" not in candidate_str: - dropped_non_relay += 1 + if not _keep_candidate(candidate_str, ICE_OUTBOUND_POLICY): + dropped += 1 continue - if ENVIRONMENT != Environment.LOCAL.value and is_private_ip_candidate( - candidate_str - ): - continue - if FORCE_TURN_RELAY: + if " typ relay" in candidate_str: kept_relay += 1 filtered.append(line) - if FORCE_TURN_RELAY: + if ICE_OUTBOUND_POLICY == NonRelayFilterPolicy.ALL: if kept_relay == 0: logger.warning( "FORCE_TURN_RELAY is on but the answer SDP has no relay candidates " - f"(dropped {dropped_non_relay} non-relay). TURN may be unreachable; " + f"(dropped {dropped} non-relay). TURN may be unreachable; " "the connection will fail." ) else: logger.info( f"FORCE_TURN_RELAY: kept {kept_relay} relay candidates, " - f"dropped {dropped_non_relay} non-relay" + f"dropped {dropped} non-relay" ) return "\r\n".join(filtered) @@ -370,9 +425,7 @@ class SignalingManager: Uses SmallWebRTC's native ICE trickling support via add_ice_candidate(). Candidates are parsed using aiortc's candidate_from_sdp() for proper formatting, consistent with SmallWebRTCRequestHandler.handle_patch_request(). - - In non-local environments, private IP candidates are filtered out to prevent - TURN relay errors when coturn blocks private IP ranges (denied-peer-ip). + Candidates are filtered according to ICE_INBOUND_POLICY before being added. """ pc_id = payload.get("pc_id") candidate_data = payload.get("candidate") @@ -389,13 +442,9 @@ class SignalingManager: if candidate_data: candidate_str = candidate_data.get("candidate", "") - # Filter out private IP candidates in non-local environments - # This prevents TURN relay errors when coturn blocks private IP ranges - if ENVIRONMENT != Environment.LOCAL.value and is_private_ip_candidate( - candidate_str - ): + if not _keep_candidate(candidate_str, ICE_INBOUND_POLICY): logger.debug( - f"Skipping private IP candidate in {ENVIRONMENT}: {candidate_str[:50]}..." + f"Dropping inbound candidate per policy ({ICE_INBOUND_POLICY.value}): {candidate_str[:50]}..." ) return diff --git a/api/routes/workflow.py b/api/routes/workflow.py index 4b2bb6d..808ef21 100644 --- a/api/routes/workflow.py +++ b/api/routes/workflow.py @@ -32,99 +32,16 @@ from api.services.storage import storage_fs from api.services.workflow.dto import ReactFlowDTO, sanitize_workflow_definition from api.services.workflow.duplicate import duplicate_workflow from api.services.workflow.errors import ItemKind, WorkflowError +from api.services.workflow.trigger_paths import ( + TriggerPathIssue, + ensure_trigger_paths, + extract_trigger_paths, + regenerate_trigger_uuids, + trigger_path_to_node_id, + validate_trigger_paths, +) from api.services.workflow.workflow_graph import WorkflowGraph - -def extract_trigger_paths(workflow_definition: dict) -> List[str]: - """Extract trigger UUIDs from workflow definition. - - Args: - workflow_definition: The workflow definition JSON - - Returns: - List of trigger UUIDs found in the workflow - """ - if not workflow_definition: - return [] - - nodes = workflow_definition.get("nodes", []) - trigger_paths = [] - - for node in nodes: - if node.get("type") == "trigger": - trigger_path = node.get("data", {}).get("trigger_path") - if trigger_path: - trigger_paths.append(trigger_path) - - return trigger_paths - - -def _trigger_path_to_node_id(workflow_definition: dict) -> dict[str, str]: - """Map each trigger node's trigger_path to its node id.""" - if not workflow_definition: - return {} - out: dict[str, str] = {} - for node in workflow_definition.get("nodes", []): - if node.get("type") == "trigger": - tp = node.get("data", {}).get("trigger_path") - if tp: - out[tp] = node.get("id") - return out - - -def regenerate_trigger_uuids(workflow_definition: dict) -> dict: - """Regenerate UUIDs for all trigger nodes in a workflow definition. - - This should be called when creating a new workflow from a template or - duplicating a workflow to avoid trigger UUID conflicts. - - Args: - workflow_definition: The workflow definition JSON - - Returns: - Updated workflow definition with new trigger UUIDs - """ - if not workflow_definition: - return workflow_definition - - # Deep copy to avoid modifying the original - import copy - - updated_definition = copy.deepcopy(workflow_definition) - - nodes = updated_definition.get("nodes", []) - for node in nodes: - if node.get("type") == "trigger": - # Generate a new UUID for this trigger - if "data" not in node: - node["data"] = {} - node["data"]["trigger_path"] = str(uuid.uuid4()) - - return updated_definition - - -def ensure_trigger_paths(workflow_definition: Optional[dict]) -> Optional[dict]: - """Mint a UUID for any trigger node that's missing ``data.trigger_path``. - - Trigger nodes that already carry a non-empty trigger_path are left - untouched so stable IDs survive edits. The input is not mutated; the - returned dict is what should be persisted and echoed in the response. - """ - if not workflow_definition: - return workflow_definition - - import copy - - out = copy.deepcopy(workflow_definition) - for node in out.get("nodes") or []: - if node.get("type") != "trigger": - continue - data = node.setdefault("data", {}) - if not data.get("trigger_path"): - data["trigger_path"] = str(uuid.uuid4()) - return out - - router = APIRouter(prefix="/workflow") @@ -139,7 +56,7 @@ def _trigger_conflict_http_exception( """Build a 409 with the same detail shape as validate's 422 so the editor can highlight the offending trigger node(s) using the same code path.""" path_to_node = ( - _trigger_path_to_node_id(workflow_definition) if workflow_definition else {} + trigger_path_to_node_id(workflow_definition) if workflow_definition else {} ) errors: list[WorkflowError] = [ WorkflowError( @@ -159,6 +76,24 @@ def _trigger_conflict_http_exception( ) +def _trigger_path_validation_http_exception( + issues: list[TriggerPathIssue], +) -> HTTPException: + errors = [ + WorkflowError( + kind=ItemKind.node, + id=issue.node_id, + field="data.trigger_path", + message=issue.message, + ) + for issue in issues + ] + return HTTPException( + status_code=422, + detail=ValidateWorkflowResponse(is_valid=False, errors=errors).model_dump(), + ) + + async def _validate_workflow_definition( workflow_definition: Optional[dict], exclude_workflow_id: Optional[int] = None, @@ -187,6 +122,17 @@ async def _validate_workflow_definition( except ValueError as e: errors.extend(e.args[0]) + # ----------- Trigger Path Format Check ------------ + for issue in validate_trigger_paths(workflow_definition): + errors.append( + WorkflowError( + kind=ItemKind.node, + id=issue.node_id, + field="data.trigger_path", + message=issue.message, + ) + ) + # ----------- Trigger Path Conflict Check ------------ trigger_paths = extract_trigger_paths(workflow_definition) if trigger_paths: @@ -195,7 +141,7 @@ async def _validate_workflow_definition( exclude_workflow_id=exclude_workflow_id, ) if conflicts: - path_to_node = _trigger_path_to_node_id(workflow_definition) + path_to_node = trigger_path_to_node_id(workflow_definition) for conflicting_path in conflicts: errors.append( WorkflowError( @@ -251,6 +197,14 @@ class WorkflowListResponse(BaseModel): status: str created_at: datetime total_runs: int + folder_id: int | None = None + workflow_uuid: str | None = None + + +class MoveWorkflowToFolderRequest(BaseModel): + """Move a workflow into a folder, or to "Uncategorized" when null.""" + + folder_id: int | None = None class WorkflowCountResponse(BaseModel): @@ -404,6 +358,9 @@ async def create_workflow( # Auto-mint trigger_path for any trigger node that didn't ship one so # clients don't need to generate UUIDs themselves. workflow_definition = ensure_trigger_paths(request.workflow_definition) + trigger_path_issues = validate_trigger_paths(workflow_definition) + if trigger_path_issues: + raise _trigger_path_validation_http_exception(trigger_path_issues) # Validate trigger path uniqueness BEFORE creating the workflow so we # don't leave an orphaned workflow record when the trigger conflicts. @@ -641,6 +598,8 @@ async def get_workflows( status=workflow.status, created_at=workflow.created_at, total_runs=run_counts.get(workflow.id, 0), + folder_id=workflow.folder_id, + workflow_uuid=workflow.workflow_uuid, ) for workflow in workflows ] @@ -883,6 +842,48 @@ async def update_workflow_status( raise HTTPException(status_code=500, detail=str(e)) +@router.put("/{workflow_id}/folder") +async def move_workflow_to_folder( + workflow_id: int, + request: MoveWorkflowToFolderRequest, + user: UserModel = Depends(get_user), +) -> WorkflowListResponse: + """Move a workflow into a folder, or to "Uncategorized" (folder_id=null). + + Validates that the target folder belongs to the caller's organization — + the FK alone proves the folder exists, not that the caller may use it. + """ + # Validate target folder ownership (tenant isolation) unless un-filing. + if request.folder_id is not None: + folder = await db_client.get_folder( + request.folder_id, organization_id=user.selected_organization_id + ) + if folder is None: + raise HTTPException( + status_code=404, + detail=f"Folder with id {request.folder_id} not found", + ) + + try: + workflow = await db_client.move_workflow_to_folder( + workflow_id=workflow_id, + folder_id=request.folder_id, + organization_id=user.selected_organization_id, + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + run_count = await db_client.get_workflow_run_count(workflow.id) + return WorkflowListResponse( + id=workflow.id, + name=workflow.name, + status=workflow.status, + created_at=workflow.created_at, + total_runs=run_count, + folder_id=workflow.folder_id, + ) + + @router.put( "/{workflow_id}", **sdk_expose( @@ -917,6 +918,9 @@ async def update_workflow( # response echoes workflow_definition so the client picks up the new # UUID without a refetch. workflow_definition = ensure_trigger_paths(workflow_definition) + trigger_path_issues = validate_trigger_paths(workflow_definition) + if trigger_path_issues: + raise _trigger_path_validation_http_exception(trigger_path_issues) if workflow_definition: existing_workflow = await db_client.get_workflow( workflow_id, organization_id=user.selected_organization_id @@ -1081,7 +1085,12 @@ async def create_workflow_run( user: The user to create the workflow run for """ run = await db_client.create_workflow_run( - request.name, workflow_id, request.mode, user.id, use_draft=True + request.name, + workflow_id, + request.mode, + user.id, + use_draft=True, + organization_id=user.selected_organization_id, ) return { "id": run.id, diff --git a/api/routes/workflow_text_chat.py b/api/routes/workflow_text_chat.py new file mode 100644 index 0000000..71d1b90 --- /dev/null +++ b/api/routes/workflow_text_chat.py @@ -0,0 +1,282 @@ +from datetime import datetime +from typing import Any, Dict +from uuid import uuid4 + +from fastapi import APIRouter, Depends, HTTPException +from pipecat.utils.run_context import set_current_run_id +from pydantic import BaseModel, Field + +from api.db import db_client +from api.db.models import UserModel, WorkflowRunTextSessionModel +from api.enums import WorkflowRunMode +from api.services.auth.depends import get_user +from api.services.quota_service import check_dograh_quota +from api.services.workflow.text_chat_session_service import ( + TextChatPendingTurnLostError, + TextChatSessionExecutionError, + TextChatSessionRevisionConflictError, + TextChatTurnNotFoundError, + append_text_chat_user_message, + default_text_chat_checkpoint, + default_text_chat_session_data, + execute_pending_text_chat_turn, + initialize_text_chat_session, + normalize_text_chat_checkpoint, + normalize_text_chat_session_data, + rewind_text_chat_session_state, +) + +router = APIRouter(prefix="/workflow", tags=["workflow-text-chat"]) + + +class CreateTextChatSessionRequest(BaseModel): + name: str | None = None + initial_context: Dict[str, Any] | None = None + annotations: Dict[str, Any] | None = None + + +class AppendTextChatMessageRequest(BaseModel): + text: str = Field(min_length=1) + expected_revision: int | None = None + + +class RewindTextChatSessionRequest(BaseModel): + cursor_turn_id: str | None = None + expected_revision: int | None = None + + +class WorkflowRunTextSessionResponse(BaseModel): + workflow_run_id: int + workflow_id: int + name: str + mode: str + state: str + is_completed: bool + revision: int + initial_context: Dict[str, Any] | None = None + gathered_context: Dict[str, Any] | None = None + annotations: Dict[str, Any] | None = None + session_data: Dict[str, Any] + checkpoint: Dict[str, Any] + created_at: datetime + updated_at: datetime | None = None + + +def _get_state_value(state: Any) -> str: + return state.value if hasattr(state, "value") else str(state) + + +def _build_response( + text_session: WorkflowRunTextSessionModel, +) -> WorkflowRunTextSessionResponse: + workflow_run = text_session.workflow_run + return WorkflowRunTextSessionResponse( + workflow_run_id=workflow_run.id, + workflow_id=workflow_run.workflow_id, + name=workflow_run.name, + mode=workflow_run.mode, + state=_get_state_value(workflow_run.state), + is_completed=workflow_run.is_completed, + revision=text_session.revision, + initial_context=workflow_run.initial_context, + gathered_context=workflow_run.gathered_context, + annotations=workflow_run.annotations, + session_data=normalize_text_chat_session_data(text_session.session_data), + checkpoint=normalize_text_chat_checkpoint(text_session.checkpoint), + created_at=text_session.created_at, + updated_at=text_session.updated_at, + ) + + +def _revision_conflict_detail(e: Any) -> dict[str, Any]: + return { + "message": "Text chat session revision conflict", + "expected_revision": e.expected_revision, + "actual_revision": e.actual_revision, + } + + +def _require_selected_organization_id(user: UserModel) -> int: + if user.selected_organization_id is None: + raise HTTPException(status_code=403, detail="Organization context is required") + return user.selected_organization_id + + +async def _ensure_text_chat_quota(user: UserModel, workflow_id: int) -> None: + quota_result = await check_dograh_quota(user, workflow_id=workflow_id) + if not quota_result.has_quota: + raise HTTPException(status_code=402, detail=quota_result.error_message) + + +async def _load_text_session_or_404( + workflow_id: int, + run_id: int, + user: UserModel, +) -> WorkflowRunTextSessionModel: + set_current_run_id(run_id) + organization_id = _require_selected_organization_id(user) + text_session = await db_client.get_workflow_run_text_session( + run_id, organization_id=organization_id + ) + if not text_session or not text_session.workflow_run: + raise HTTPException(status_code=404, detail="Text chat session not found") + if text_session.workflow_run.workflow_id != workflow_id: + raise HTTPException(status_code=404, detail="Text chat session not found") + if text_session.workflow_run.mode != WorkflowRunMode.TEXTCHAT.value: + raise HTTPException( + status_code=400, detail="Workflow run is not a text chat session" + ) + return text_session + + +async def _execute_pending_turn_response( + *, + workflow_id: int, + run_id: int, + text_session: WorkflowRunTextSessionModel, +) -> WorkflowRunTextSessionResponse: + try: + updated_text_session = await execute_pending_text_chat_turn( + workflow_id=workflow_id, + run_id=run_id, + text_session=text_session, + ) + except TextChatSessionRevisionConflictError as e: + raise HTTPException(status_code=409, detail=_revision_conflict_detail(e)) + except TextChatPendingTurnLostError as e: + raise HTTPException(status_code=500, detail=str(e)) + except TextChatSessionExecutionError as e: + raise HTTPException(status_code=500, detail=str(e)) + + return _build_response(updated_text_session) + + +@router.post( + "/{workflow_id}/text-chat/sessions", + response_model=WorkflowRunTextSessionResponse, +) +async def create_text_chat_session( + workflow_id: int, + request: CreateTextChatSessionRequest, + user: UserModel = Depends(get_user), +) -> WorkflowRunTextSessionResponse: + organization_id = _require_selected_organization_id(user) + await _ensure_text_chat_quota(user, workflow_id) + + session_name = request.name or f"WR-TEXT-{uuid4().hex[:6].upper()}" + try: + workflow_run = await db_client.create_workflow_run( + name=session_name, + workflow_id=workflow_id, + mode=WorkflowRunMode.TEXTCHAT.value, + user_id=user.id, + initial_context=request.initial_context, + use_draft=True, + organization_id=organization_id, + ) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + set_current_run_id(workflow_run.id) + + annotations = { + "tester": { + "source": "workflow_editor", + "modality": "text", + } + } + if request.annotations: + annotations = {**annotations, **request.annotations} + workflow_run = await db_client.update_workflow_run( + workflow_run.id, + annotations=annotations, + ) + + text_session = await db_client.ensure_workflow_run_text_session( + workflow_run.id, + session_data=default_text_chat_session_data(), + checkpoint=default_text_chat_checkpoint(), + ) + + try: + text_session = await initialize_text_chat_session( + run_id=workflow_run.id, + text_session=text_session, + ) + except TextChatSessionRevisionConflictError as e: + raise HTTPException(status_code=409, detail=_revision_conflict_detail(e)) + + return await _execute_pending_turn_response( + workflow_id=workflow_id, + run_id=workflow_run.id, + text_session=text_session, + ) + + +@router.get( + "/{workflow_id}/text-chat/sessions/{run_id}", + response_model=WorkflowRunTextSessionResponse, +) +async def get_text_chat_session( + workflow_id: int, + run_id: int, + user: UserModel = Depends(get_user), +) -> WorkflowRunTextSessionResponse: + text_session = await _load_text_session_or_404(workflow_id, run_id, user) + return _build_response(text_session) + + +@router.post( + "/{workflow_id}/text-chat/sessions/{run_id}/messages", + response_model=WorkflowRunTextSessionResponse, +) +async def append_text_chat_message( + workflow_id: int, + run_id: int, + request: AppendTextChatMessageRequest, + user: UserModel = Depends(get_user), +) -> WorkflowRunTextSessionResponse: + text_session = await _load_text_session_or_404(workflow_id, run_id, user) + await _ensure_text_chat_quota(user, workflow_id) + + try: + text_session = await append_text_chat_user_message( + run_id=run_id, + text_session=text_session, + user_text=request.text, + expected_revision=request.expected_revision, + ) + except TextChatSessionRevisionConflictError as e: + raise HTTPException(status_code=409, detail=_revision_conflict_detail(e)) + + return await _execute_pending_turn_response( + workflow_id=workflow_id, + run_id=run_id, + text_session=text_session, + ) + + +@router.post( + "/{workflow_id}/text-chat/sessions/{run_id}/rewind", + response_model=WorkflowRunTextSessionResponse, +) +async def rewind_text_chat_session( + workflow_id: int, + run_id: int, + request: RewindTextChatSessionRequest, + user: UserModel = Depends(get_user), +) -> WorkflowRunTextSessionResponse: + text_session = await _load_text_session_or_404(workflow_id, run_id, user) + try: + text_session = await rewind_text_chat_session_state( + run_id=run_id, + text_session=text_session, + cursor_turn_id=request.cursor_turn_id, + expected_revision=request.expected_revision, + ) + except TextChatTurnNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except TextChatSessionRevisionConflictError as e: + raise HTTPException(status_code=409, detail=_revision_conflict_detail(e)) + + return _build_response(text_session) diff --git a/api/services/auth/depends.py b/api/services/auth/depends.py index ecb793f..7ffabfb 100644 --- a/api/services/auth/depends.py +++ b/api/services/auth/depends.py @@ -129,22 +129,6 @@ async def get_user( return user_model -async def get_user_optional( - authorization: Annotated[str | None, Header()] = None, - x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None, -) -> UserModel | None: - """ - Same as get_user but returns None instead of raising 401 if unauthorized. - Useful for endpoints that need to work both with and without auth. - """ - try: - return await get_user(authorization, x_api_key) - except HTTPException as e: - if e.status_code == 401: - return None - raise - - async def _handle_oss_auth(authorization: str | None) -> UserModel: """ Handle authentication for OSS deployment mode. diff --git a/api/services/campaign/campaign_call_dispatcher.py b/api/services/campaign/campaign_call_dispatcher.py index ffe9a90..e00ddb6 100644 --- a/api/services/campaign/campaign_call_dispatcher.py +++ b/api/services/campaign/campaign_call_dispatcher.py @@ -20,8 +20,8 @@ from api.utils.common import get_backend_endpoints if TYPE_CHECKING: # Type-only — importing api.services.telephony eagerly triggers the # provider package init, which can pull in this module via the routes - # chain and create a circular import. Runtime calls below go through - # ``factory.get_telephony_provider`` (lazy import inside the method). + # chain and create a circular import. Runtime calls below lazy-import the + # factory helpers inside methods instead. from api.services.telephony.base import TelephonyProvider @@ -31,12 +31,6 @@ class CampaignCallDispatcher: def __init__(self): self.default_concurrent_limit = int(DEFAULT_ORG_CONCURRENCY_LIMIT) - async def get_telephony_provider(self, organization_id: int) -> "TelephonyProvider": - """Get telephony provider instance for specific organization (default config).""" - from api.services.telephony.factory import get_default_telephony_provider - - return await get_default_telephony_provider(organization_id) - 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 @@ -302,7 +296,6 @@ class CampaignCallDispatcher: f"?workflow_id={campaign.workflow_id}" f"&user_id={campaign.created_by}" f"&workflow_run_id={workflow_run.id}" - f"&campaign_id={campaign.id}" f"&organization_id={campaign.organization_id}" ) diff --git a/api/services/campaign/source_sync.py b/api/services/campaign/source_sync.py index 5393b65..1f0ee67 100644 --- a/api/services/campaign/source_sync.py +++ b/api/services/campaign/source_sync.py @@ -183,9 +183,7 @@ class CampaignSourceSyncService(ABC): async def get_source_credentials( self, organization_id: int, source_type: str ) -> Dict[str, Any]: - """Gets OAuth tokens or API credentials via Nango""" - # This would be implemented to work with Nango service - # For now, returning placeholder + """Gets source credentials when a sync service requires them.""" logger.info( f"Getting credentials for org {organization_id}, source {source_type}" ) diff --git a/api/services/campaign/source_sync_factory.py b/api/services/campaign/source_sync_factory.py index 2e05f1b..8725f4f 100644 --- a/api/services/campaign/source_sync_factory.py +++ b/api/services/campaign/source_sync_factory.py @@ -1,15 +1,12 @@ from api.services.campaign.source_sync import CampaignSourceSyncService from api.services.campaign.sources.csv import CSVSyncService -from api.services.campaign.sources.google_sheets import GoogleSheetsSyncService def get_sync_service(source_type: str) -> CampaignSourceSyncService: """Returns appropriate sync service based on source type""" services = { - "google-sheet": GoogleSheetsSyncService, "csv": CSVSyncService, - # Add more as needed: "hubspot": HubSpotSyncService, } service_class = services.get(source_type) diff --git a/api/services/campaign/sources/__init__.py b/api/services/campaign/sources/__init__.py index 3c283ee..1a4dc5d 100644 --- a/api/services/campaign/sources/__init__.py +++ b/api/services/campaign/sources/__init__.py @@ -1,5 +1,3 @@ """Campaign source sync services""" -from .google_sheets import GoogleSheetsSyncService - -__all__ = ["GoogleSheetsSyncService"] +__all__: list[str] = [] diff --git a/api/services/campaign/sources/google_sheets.py b/api/services/campaign/sources/google_sheets.py deleted file mode 100644 index ea473f5..0000000 --- a/api/services/campaign/sources/google_sheets.py +++ /dev/null @@ -1,224 +0,0 @@ -import re -from typing import Any, Dict, List, Optional - -import httpx -from loguru import logger - -from api.db import db_client -from api.services.campaign.source_sync import ( - CampaignSourceSyncService, - ValidationError, - ValidationResult, -) -from api.services.integrations.nango import NangoService - - -class GoogleSheetsSyncService(CampaignSourceSyncService): - """Implementation for Google Sheets synchronization""" - - def __init__(self): - self.nango_service = NangoService() - self.sheets_api_base = "https://sheets.googleapis.com/v4/spreadsheets" - - async def _get_access_token(self, organization_id: int) -> str: - """Get OAuth access token for Google Sheets via Nango.""" - integrations = await db_client.get_integrations_by_organization_id( - organization_id - ) - integration = None - for intg in integrations: - if intg.provider == "google-sheet" and intg.is_active: - integration = intg - break - - if not integration: - raise ValueError("Google Sheets integration not found or inactive") - - token_data = await self.nango_service.get_access_token( - connection_id=integration.integration_id, provider_config_key="google-sheet" - ) - return token_data["credentials"]["access_token"] - - async def _fetch_all_sheet_data( - self, sheet_url: str, organization_id: int - ) -> List[List[str]]: - """Fetch all data from a Google Sheet. Returns all rows including header.""" - access_token = await self._get_access_token(organization_id) - sheet_id = self._extract_sheet_id(sheet_url) - - metadata = await self._get_sheet_metadata(sheet_id, access_token) - if not metadata.get("sheets"): - raise ValueError("No sheets found in the spreadsheet") - - sheet_name = metadata["sheets"][0]["properties"]["title"] - - return await self._fetch_sheet_data(sheet_id, f"{sheet_name}!A:Z", access_token) - - async def validate_source( - self, source_id: str, organization_id: Optional[int] = None - ) -> ValidationResult: - """Validate a Google Sheet source for campaign creation.""" - if organization_id is None: - return ValidationResult( - is_valid=False, - error=ValidationError( - message="Organization ID is required for Google Sheets validation" - ), - ) - - # Validate URL format first - pattern = r"/spreadsheets/d/([a-zA-Z0-9-_]+)" - if not re.search(pattern, source_id): - return ValidationResult( - is_valid=False, - error=ValidationError( - message=f"Invalid Google Sheets URL: {source_id}" - ), - ) - - try: - rows = await self._fetch_all_sheet_data(source_id, organization_id) - except ValueError as e: - return ValidationResult( - is_valid=False, - error=ValidationError(message=str(e)), - ) - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error fetching Google Sheet: {e.response.status_code}") - return ValidationResult( - is_valid=False, - error=ValidationError( - message=f"Failed to fetch Google Sheet data: {e.response.status_code}" - ), - ) - except Exception as e: - logger.error(f"Error fetching Google Sheet: {e}") - return ValidationResult( - is_valid=False, - error=ValidationError(message="Failed to fetch Google Sheet data"), - ) - - if not rows or len(rows) < 2: - return ValidationResult( - is_valid=False, - error=ValidationError( - message="Google Sheet must have a header row and at least one data row" - ), - ) - - headers = rows[0] - data_rows = rows[1:] - - return self.validate_source_data(headers, data_rows) - - async def sync_source_data(self, campaign_id: int) -> int: - """ - Fetches data from Google Sheets and creates queued_runs - """ - # Get campaign - campaign = await db_client.get_campaign_by_id(campaign_id) - if not campaign: - raise ValueError(f"Campaign {campaign_id} not found") - - rows = await self._fetch_all_sheet_data( - campaign.source_id, campaign.organization_id - ) - - if not rows or len(rows) < 2: - logger.warning(f"No data found in sheet for campaign {campaign_id}") - return 0 - - headers = self.normalize_headers(rows[0]) - data_rows = rows[1:] - - sheet_id = self._extract_sheet_id(campaign.source_id) - - queued_runs = [] - for idx, row_values in enumerate(data_rows, 1): - # Pad row to match headers length - padded_row = row_values + [""] * (len(headers) - len(row_values)) - - # Create context variables dict - context_vars = dict(zip(headers, padded_row)) - - # Skip if no phone number - if not context_vars.get("phone_number"): - logger.debug(f"Skipping row {idx}: no phone_number") - continue - - # Generate unique source UUID - source_uuid = f"sheet_{sheet_id}_row_{idx}" - - queued_runs.append( - { - "campaign_id": campaign_id, - "source_uuid": source_uuid, - "context_variables": context_vars, - "state": "queued", - } - ) - - # Bulk insert - if queued_runs: - await db_client.bulk_create_queued_runs(queued_runs) - logger.info( - f"Created {len(queued_runs)} queued runs for campaign {campaign_id}" - ) - - # Update campaign total_rows - await db_client.update_campaign( - campaign_id=campaign_id, - total_rows=len(queued_runs), - source_sync_status="completed", - ) - - return len(queued_runs) - - async def _fetch_sheet_data( - self, sheet_id: str, range: str, access_token: str - ) -> List[List[str]]: - """Fetch data from Google Sheets API""" - url = f"{self.sheets_api_base}/{sheet_id}/values/{range}" - headers = {"Authorization": f"Bearer {access_token}"} - - async with httpx.AsyncClient() as client: - response = await client.get(url, headers=headers) - response.raise_for_status() - - data = response.json() - return data.get("values", []) - - async def _get_sheet_metadata( - self, sheet_id: str, access_token: str - ) -> Dict[str, Any]: - """Get sheet metadata including sheet names""" - url = f"{self.sheets_api_base}/{sheet_id}" - headers = {"Authorization": f"Bearer {access_token}"} - - logger.debug(f"Fetching sheet metadata from URL: {url}") - logger.debug(f"Using sheet_id: {sheet_id}") - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - response.raise_for_status() - return response.json() - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error {e.response.status_code} for URL: {url}") - logger.error(f"Response body: {e.response.text}") - raise - except Exception as e: - logger.error(f"Error fetching sheet metadata: {e}") - raise - - def _extract_sheet_id(self, sheet_url: str) -> str: - """ - Extract sheet ID from various Google Sheets URL formats: - - https://docs.google.com/spreadsheets/d/{id}/edit - - https://docs.google.com/spreadsheets/d/{id}/edit#gid=0 - """ - pattern = r"/spreadsheets/d/([a-zA-Z0-9-_]+)" - match = re.search(pattern, sheet_url) - if match: - return match.group(1) - raise ValueError(f"Invalid Google Sheets URL: {sheet_url}") diff --git a/api/services/configuration/check_validity.py b/api/services/configuration/check_validity.py index a78434d..721884b 100644 --- a/api/services/configuration/check_validity.py +++ b/api/services/configuration/check_validity.py @@ -47,11 +47,16 @@ class UserConfigurationValidator: ServiceProviders.CAMB.value: self._check_camb_api_key, ServiceProviders.AWS_BEDROCK.value: self._check_aws_bedrock_api_key, ServiceProviders.SPEACHES.value: self._check_speaches_api_key, + ServiceProviders.GOOGLE_VERTEX.value: self._check_google_vertex_llm_api_key, ServiceProviders.OPENAI_REALTIME.value: self._check_openai_api_key, + ServiceProviders.GROK_REALTIME.value: self._check_grok_realtime_api_key, + ServiceProviders.ULTRAVOX_REALTIME.value: self._check_ultravox_realtime_api_key, ServiceProviders.GOOGLE_REALTIME.value: self._check_google_api_key, + ServiceProviders.GOOGLE_VERTEX_REALTIME.value: self._check_google_vertex_realtime_api_key, ServiceProviders.ASSEMBLYAI.value: self._check_assemblyai_api_key, ServiceProviders.GLADIA.value: self._check_gladia_api_key, ServiceProviders.RIME.value: self._check_rime_api_key, + ServiceProviders.MINIMAX.value: self._check_minimax_api_key, } async def validate( @@ -116,6 +121,36 @@ class UserConfigurationValidator: return [{"model": service_name, "message": str(e)}] return [] + # Vertex Realtime uses service-account credentials (or ADC) instead of api_key + if provider == ServiceProviders.GOOGLE_VERTEX_REALTIME.value: + try: + if not self._check_google_vertex_realtime_api_key( + provider, service_config + ): + return [ + { + "model": service_name, + "message": f"Invalid {provider} configuration", + } + ] + except ValueError as e: + return [{"model": service_name, "message": str(e)}] + return [] + + # Vertex LLM uses service-account credentials (or ADC) instead of api_key + if provider == ServiceProviders.GOOGLE_VERTEX.value: + try: + if not self._check_google_vertex_llm_api_key(provider, service_config): + return [ + { + "model": service_name, + "message": f"Invalid {provider} configuration", + } + ] + except ValueError as e: + return [{"model": service_name, "message": str(e)}] + return [] + # AWS Bedrock uses AWS credentials instead of api_key if provider == ServiceProviders.AWS_BEDROCK.value: try: @@ -130,6 +165,19 @@ class UserConfigurationValidator: return [{"model": service_name, "message": str(e)}] return [] + # MiniMax TTS requires a group_id alongside the API key. + # LLM configs don't expose group_id, so only check when the field exists. + if provider == ServiceProviders.MINIMAX.value and hasattr( + service_config, "group_id" + ): + if not getattr(service_config, "group_id", None): + return [ + { + "model": service_name, + "message": "group_id is required for MiniMax TTS", + } + ] + api_key = service_config.api_key try: @@ -205,6 +253,12 @@ class UserConfigurationValidator: def _check_openrouter_api_key(self, model: str, api_key: str) -> bool: return True + def _check_grok_realtime_api_key(self, model: str, api_key: str) -> bool: + return True + + def _check_ultravox_realtime_api_key(self, model: str, api_key: str) -> bool: + return True + def _check_speechmatics_api_key(self, model: str, api_key: str) -> bool: return True @@ -216,6 +270,20 @@ class UserConfigurationValidator: raise ValueError("base_url is required for Speaches services") return True + def _check_google_vertex_realtime_api_key(self, model: str, service_config) -> bool: + if not getattr(service_config, "project_id", None): + raise ValueError("project_id is required for Google Vertex Realtime") + if not getattr(service_config, "location", None): + raise ValueError("location is required for Google Vertex Realtime") + return True + + def _check_google_vertex_llm_api_key(self, model: str, service_config) -> bool: + if not getattr(service_config, "project_id", None): + raise ValueError("project_id is required for Google Vertex") + if not getattr(service_config, "location", None): + raise ValueError("location is required for Google Vertex") + return True + def _check_aws_bedrock_api_key(self, model: str, service_config) -> bool: if not service_config.aws_access_key or not service_config.aws_secret_key: raise ValueError("AWS access key and secret key are required for Bedrock") @@ -229,3 +297,8 @@ class UserConfigurationValidator: def _check_rime_api_key(self, model: str, api_key: str) -> bool: return True + + def _check_minimax_api_key(self, model: str, api_key: str) -> bool: + # MiniMax doesn't publish a cheap key-validation endpoint; trust the key + # at save time and surface auth errors at first call (same as Rime/Sarvam). + return True diff --git a/api/services/configuration/masking.py b/api/services/configuration/masking.py index 7676bfb..f1ed1f6 100644 --- a/api/services/configuration/masking.py +++ b/api/services/configuration/masking.py @@ -13,31 +13,40 @@ from typing import Any, Dict, Optional from api.schemas.user_configuration import UserConfiguration from api.services.configuration.registry import ServiceConfig +from api.services.integrations import get_node_secret_fields VISIBLE_CHARS = 4 # number of trailing characters to reveal MASK_CHAR = "*" MASK_MARKER = "***" # substring that indicates a masked key +SERVICE_SECRET_FIELDS = ("api_key", "credentials", "aws_access_key", "aws_secret_key") -def contains_masked_key(api_key: str | list[str] | None) -> bool: - """Return True if *api_key* looks like a masked placeholder.""" - if api_key is None: +def contains_masked_key(value: str | list[str] | None) -> bool: + """Return True if *value* looks like a masked placeholder.""" + if value is None: return False - keys = api_key if isinstance(api_key, list) else [api_key] + keys = value if isinstance(value, list) else [value] return any(MASK_MARKER in k for k in keys) def check_for_masked_keys(config: "UserConfiguration") -> None: - """Raise ValueError if any service in *config* still has a masked API key.""" + """Raise ValueError if any service in *config* still has a masked secret.""" for field in ("llm", "tts", "stt", "embeddings", "realtime"): service = getattr(config, field, None) if service is None: continue - if contains_masked_key(service.get_all_api_keys()): - raise ValueError( - f"The {field} api_key appears to be masked. " - "Please provide the actual API key, not the masked value." - ) + for secret_field in SERVICE_SECRET_FIELDS: + if not hasattr(service, secret_field): + continue + if secret_field == "api_key" and hasattr(service, "get_all_api_keys"): + secret_value = service.get_all_api_keys() + else: + secret_value = getattr(service, secret_field, None) + if contains_masked_key(secret_value): + raise ValueError( + f"The {field} {secret_field} appears to be masked. " + "Please provide the actual value, not the masked value." + ) def mask_key(real_key: str, visible: int = VISIBLE_CHARS) -> str: @@ -104,12 +113,14 @@ def _mask_service(service_cfg: Optional[ServiceConfig]) -> Optional[Dict[str, An # Work on a dict copy so we don't mutate original models data = service_cfg.model_dump() - if "api_key" in data and data["api_key"]: - raw = data["api_key"] + for secret_field in SERVICE_SECRET_FIELDS: + if secret_field not in data or not data[secret_field]: + continue + raw = data[secret_field] if isinstance(raw, list): - data["api_key"] = [mask_key(k) for k in raw] + data[secret_field] = [mask_key(k) for k in raw] else: - data["api_key"] = mask_key(raw) + data[secret_field] = mask_key(raw) return data @@ -129,14 +140,22 @@ def mask_user_config(config: UserConfiguration) -> Dict[str, Any]: # --------------------------------------------------------------------------- -# Workflow definition helpers – mask / merge QA-node API keys +# Workflow definition helpers – mask / merge node API keys # --------------------------------------------------------------------------- -_QA_API_KEY_FIELD = "qa_api_key" +_NODE_SECRET_FIELDS: dict[str, tuple[str, ...]] = { + "qa": ("qa_api_key",), +} + + +def _secret_fields_for_node_type(node_type: str | None) -> tuple[str, ...]: + if not node_type: + return () + return _NODE_SECRET_FIELDS.get(node_type, ()) or get_node_secret_fields(node_type) def mask_workflow_definition(workflow_definition: Optional[Dict]) -> Optional[Dict]: - """Return a *shallow copy* of *workflow_definition* with QA-node API keys masked.""" + """Return a copy of *workflow_definition* with node secret fields masked.""" if not workflow_definition: return workflow_definition @@ -144,47 +163,46 @@ def mask_workflow_definition(workflow_definition: Optional[Dict]) -> Optional[Di masked = copy.deepcopy(workflow_definition) for node in masked.get("nodes", []): - if node.get("type") != "qa": + secret_fields = _secret_fields_for_node_type(node.get("type")) + if not secret_fields: continue data = node.get("data", {}) - raw_key = data.get(_QA_API_KEY_FIELD) - if raw_key: - data[_QA_API_KEY_FIELD] = mask_key(raw_key) + for field in secret_fields: + raw_key = data.get(field) + if raw_key: + data[field] = mask_key(raw_key) return masked def merge_workflow_api_keys( incoming_definition: Optional[Dict], existing_definition: Optional[Dict] ) -> Optional[Dict]: - """Preserve real QA-node API keys when the incoming value is a masked placeholder. - - For each QA node in *incoming_definition*, if its ``qa_api_key`` equals - the masked form of the corresponding node in *existing_definition*, the - real key is restored so it is never lost. - """ + """Preserve real node secret fields when the incoming value is masked.""" if not incoming_definition or not existing_definition: return incoming_definition - # Build lookup: node-id → data for existing QA nodes - existing_qa: Dict[str, Dict] = {} + existing_nodes: Dict[str, Dict] = {} for node in existing_definition.get("nodes", []): - if node.get("type") == "qa": - existing_qa[node["id"]] = node.get("data", {}) + if _secret_fields_for_node_type(node.get("type")): + existing_nodes[node["id"]] = node.get("data", {}) for node in incoming_definition.get("nodes", []): - if node.get("type") != "qa": + secret_fields = _secret_fields_for_node_type(node.get("type")) + if not secret_fields: continue data = node.get("data", {}) - incoming_key = data.get(_QA_API_KEY_FIELD) - if not incoming_key: - continue - old_data = existing_qa.get(node["id"]) + old_data = existing_nodes.get(node["id"]) if not old_data: continue - old_key = old_data.get(_QA_API_KEY_FIELD, "") - if old_key and is_mask_of(incoming_key, old_key): - data[_QA_API_KEY_FIELD] = old_key + for field in secret_fields: + incoming_key = data.get(field) + if not incoming_key: + continue + + old_key = old_data.get(field, "") + if old_key and is_mask_of(incoming_key, old_key): + data[field] = old_key return incoming_definition diff --git a/api/services/configuration/merge.py b/api/services/configuration/merge.py index 992637f..937060d 100644 --- a/api/services/configuration/merge.py +++ b/api/services/configuration/merge.py @@ -7,7 +7,10 @@ stored, while honouring masked API keys. from typing import Dict from api.schemas.user_configuration import UserConfiguration -from api.services.configuration.masking import resolve_masked_api_keys +from api.services.configuration.masking import ( + SERVICE_SECRET_FIELDS, + resolve_masked_api_keys, +) SERVICE_FIELDS = ("llm", "tts", "stt", "embeddings", "realtime") @@ -45,18 +48,16 @@ def merge_user_configurations( and incoming_cfg.get("provider") != old_cfg.get("provider") ) - incoming_api_key = incoming_cfg.get("api_key") - if not provider_changed: - # conditional preservation of api_key - if incoming_api_key is not None: - if old_cfg and "api_key" in old_cfg: - incoming_cfg["api_key"] = resolve_masked_api_keys( - incoming_api_key, old_cfg["api_key"] - ) - else: - if "api_key" in old_cfg: - incoming_cfg["api_key"] = old_cfg["api_key"] + for secret_field in SERVICE_SECRET_FIELDS: + incoming_secret = incoming_cfg.get(secret_field) + if incoming_secret is not None: + if old_cfg and secret_field in old_cfg: + incoming_cfg[secret_field] = resolve_masked_api_keys( + incoming_secret, old_cfg[secret_field] + ) + elif secret_field in old_cfg: + incoming_cfg[secret_field] = old_cfg[secret_field] merged[service_name] = incoming_cfg diff --git a/api/services/configuration/options/__init__.py b/api/services/configuration/options/__init__.py new file mode 100644 index 0000000..43598dd --- /dev/null +++ b/api/services/configuration/options/__init__.py @@ -0,0 +1,49 @@ +from .deepgram import DEEPGRAM_LANGUAGES, DEEPGRAM_STT_MODELS +from .gladia import GLADIA_STT_LANGUAGES, GLADIA_STT_MODELS +from .google import ( + GOOGLE_MODELS, + GOOGLE_REALTIME_LANGUAGES, + GOOGLE_REALTIME_MODELS, + GOOGLE_REALTIME_VOICES, + GOOGLE_STT_LANGUAGES, + GOOGLE_STT_MODELS, + GOOGLE_TTS_LANGUAGES, + GOOGLE_TTS_MODELS, + GOOGLE_TTS_VOICES, + GOOGLE_VERTEX_REALTIME_LANGUAGES, + GOOGLE_VERTEX_REALTIME_MODELS, + GOOGLE_VERTEX_REALTIME_VOICES, +) +from .sarvam import ( + SARVAM_LANGUAGES, + SARVAM_STT_MODELS, + SARVAM_TTS_MODELS, + SARVAM_V2_VOICES, + SARVAM_V3_VOICES, +) +from .speechmatics import SPEECHMATICS_STT_LANGUAGES + +__all__ = [ + "DEEPGRAM_LANGUAGES", + "DEEPGRAM_STT_MODELS", + "GLADIA_STT_LANGUAGES", + "GLADIA_STT_MODELS", + "GOOGLE_MODELS", + "GOOGLE_REALTIME_LANGUAGES", + "GOOGLE_REALTIME_MODELS", + "GOOGLE_REALTIME_VOICES", + "GOOGLE_STT_LANGUAGES", + "GOOGLE_STT_MODELS", + "GOOGLE_TTS_LANGUAGES", + "GOOGLE_TTS_MODELS", + "GOOGLE_TTS_VOICES", + "GOOGLE_VERTEX_REALTIME_LANGUAGES", + "GOOGLE_VERTEX_REALTIME_MODELS", + "GOOGLE_VERTEX_REALTIME_VOICES", + "SARVAM_LANGUAGES", + "SARVAM_STT_MODELS", + "SARVAM_TTS_MODELS", + "SARVAM_V2_VOICES", + "SARVAM_V3_VOICES", + "SPEECHMATICS_STT_LANGUAGES", +] diff --git a/api/services/configuration/options/deepgram.py b/api/services/configuration/options/deepgram.py new file mode 100644 index 0000000..fffa564 --- /dev/null +++ b/api/services/configuration/options/deepgram.py @@ -0,0 +1,84 @@ +DEEPGRAM_STT_MODELS = ("nova-3-general", "flux-general-en", "flux-general-multi") +DEEPGRAM_LANGUAGES = ( + "multi", + "ar", + "ar-AE", + "ar-SA", + "ar-QA", + "ar-KW", + "ar-SY", + "ar-LB", + "ar-PS", + "ar-JO", + "ar-EG", + "ar-SD", + "ar-TD", + "ar-MA", + "ar-DZ", + "ar-TN", + "ar-IQ", + "ar-IR", + "be", + "bn", + "bs", + "bg", + "ca", + "cs", + "da", + "da-DK", + "de", + "de-CH", + "el", + "en", + "en-US", + "en-AU", + "en-GB", + "en-IN", + "en-NZ", + "es", + "es-419", + "et", + "fa", + "fi", + "fr", + "fr-CA", + "he", + "hi", + "hr", + "hu", + "id", + "it", + "ja", + "kn", + "ko", + "ko-KR", + "lt", + "lv", + "mk", + "mr", + "ms", + "nl", + "nl-BE", + "no", + "pl", + "pt", + "pt-BR", + "pt-PT", + "ro", + "ru", + "sk", + "sl", + "sr", + "sv", + "sv-SE", + "ta", + "te", + "th", + "tl", + "tr", + "uk", + "ur", + "vi", + "zh-CN", + "zh-TW", +) diff --git a/api/services/configuration/options/gladia.py b/api/services/configuration/options/gladia.py new file mode 100644 index 0000000..4f951dc --- /dev/null +++ b/api/services/configuration/options/gladia.py @@ -0,0 +1,103 @@ +GLADIA_STT_MODELS = ("solaria-1",) +GLADIA_STT_LANGUAGES = ( + "af", + "am", + "ar", + "as", + "az", + "ba", + "be", + "bg", + "bn", + "bo", + "br", + "bs", + "ca", + "cs", + "cy", + "da", + "de", + "el", + "en", + "es", + "et", + "eu", + "fa", + "fi", + "fo", + "fr", + "gl", + "gu", + "ha", + "haw", + "he", + "hi", + "hr", + "ht", + "hu", + "hy", + "id", + "is", + "it", + "ja", + "jw", + "ka", + "kk", + "km", + "kn", + "ko", + "la", + "lb", + "ln", + "lo", + "lt", + "lv", + "mg", + "mi", + "mk", + "ml", + "mn", + "mr", + "ms", + "mt", + "my", + "ne", + "nl", + "nn", + "no", + "oc", + "pa", + "pl", + "ps", + "pt", + "ro", + "ru", + "sa", + "sd", + "si", + "sk", + "sl", + "sn", + "so", + "sq", + "sr", + "su", + "sv", + "sw", + "ta", + "te", + "tg", + "th", + "tk", + "tl", + "tr", + "tt", + "uk", + "ur", + "uz", + "vi", + "wo", + "yi", + "yo", + "zh", +) diff --git a/api/services/configuration/options/google.py b/api/services/configuration/options/google.py new file mode 100644 index 0000000..8852f11 --- /dev/null +++ b/api/services/configuration/options/google.py @@ -0,0 +1,281 @@ +GOOGLE_MODELS = ( + "gemini-2.0-flash", + "gemini-2.0-flash-lite", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "gemini-3.5-flash", + "gemini-3.5-flash-lite", +) +GOOGLE_VERTEX_MODELS = ( + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "gemini-3.1-flash-lite", + "gemini-3.5-flash", +) + +GOOGLE_REALTIME_MODELS = ("gemini-3.1-flash-live-preview",) +GOOGLE_REALTIME_VOICES = ("Puck", "Charon", "Kore", "Fenrir", "Aoede") +GOOGLE_REALTIME_LANGUAGES = ( + "ar", + "bn", + "de", + "en", + "es", + "fr", + "gu", + "hi", + "id", + "it", + "ja", + "kn", + "ko", + "ml", + "mr", + "nl", + "pl", + "pt", + "ru", + "ta", + "te", + "th", + "tr", + "vi", + "zh", +) + +GOOGLE_VERTEX_REALTIME_MODELS = ("google/gemini-live-2.5-flash-native-audio",) +GOOGLE_VERTEX_REALTIME_VOICES = GOOGLE_REALTIME_VOICES +GOOGLE_VERTEX_REALTIME_LANGUAGES = GOOGLE_REALTIME_LANGUAGES + +GOOGLE_STT_MODELS = ("latest_long", "latest_short", "chirp_3") +# Docs-derived from Google Cloud Speech-to-Text V2 supported languages. +GOOGLE_STT_LANGUAGES = ( + "af-ZA", + "am-ET", + "ar-AE", + "ar-BH", + "ar-DZ", + "ar-EG", + "ar-IL", + "ar-IQ", + "ar-JO", + "ar-KW", + "ar-LB", + "ar-MA", + "ar-MR", + "ar-OM", + "ar-PS", + "ar-QA", + "ar-SA", + "ar-SY", + "ar-TN", + "ar-XA", + "ar-YE", + "as-IN", + "ast-ES", + "az-AZ", + "be-BY", + "bg-BG", + "bn-BD", + "bn-IN", + "bs-BA", + "ca-ES", + "ceb-PH", + "ckb-IQ", + "cmn-Hans-CN", + "cmn-Hant-TW", + "cs-CZ", + "cy-GB", + "da-DK", + "de-AT", + "de-CH", + "de-DE", + "el-GR", + "en-AU", + "en-GB", + "en-HK", + "en-IE", + "en-IN", + "en-NZ", + "en-PH", + "en-PK", + "en-SG", + "en-US", + "es-419", + "es-AR", + "es-BO", + "es-CL", + "es-CO", + "es-CR", + "es-DO", + "es-EC", + "es-ES", + "es-GT", + "es-HN", + "es-MX", + "es-NI", + "es-PA", + "es-PE", + "es-PR", + "es-SV", + "es-US", + "es-UY", + "es-VE", + "et-EE", + "eu-ES", + "fa-IR", + "ff-SN", + "fi-FI", + "fil-PH", + "fr-BE", + "fr-CA", + "fr-CH", + "fr-FR", + "ga-IE", + "gl-ES", + "gu-IN", + "ha-NG", + "hi-IN", + "hr-HR", + "hu-HU", + "hy-AM", + "id-ID", + "ig-NG", + "is-IS", + "it-CH", + "it-IT", + "iw-IL", + "ja-JP", + "jv-ID", + "ka-GE", + "kam-KE", + "kea-CV", + "kk-KZ", + "km-KH", + "kn-IN", + "ko-KR", + "ky-KG", + "lb-LU", + "lg-UG", + "ln-CD", + "lo-LA", + "lt-LT", + "luo-KE", + "lv-LV", + "mi-NZ", + "mk-MK", + "ml-IN", + "mn-MN", + "mr-IN", + "ms-MY", + "mt-MT", + "my-MM", + "ne-NP", + "nl-BE", + "nl-NL", + "no-NO", + "nso-ZA", + "ny-MW", + "oc-FR", + "om-ET", + "or-IN", + "pa-Guru-IN", + "pl-PL", + "ps-AF", + "pt-BR", + "pt-PT", + "ro-RO", + "ru-RU", + "rup-BG", + "rw-RW", + "sd-IN", + "si-LK", + "sk-SK", + "sl-SI", + "sn-ZW", + "so-SO", + "sq-AL", + "sr-RS", + "ss-Latn-ZA", + "st-ZA", + "su-ID", + "sv-SE", + "sw", + "sw-KE", + "ta-IN", + "te-IN", + "tg-TJ", + "th-TH", + "tn-Latn-ZA", + "tr-TR", + "ts-ZA", + "uk-UA", + "umb-AO", + "ur-PK", + "uz-UZ", + "ve-ZA", + "vi-VN", + "wo-SN", + "xh-ZA", + "yo-NG", + "yue-Hant-HK", + "zu-ZA", +) + +GOOGLE_TTS_MODELS = ("chirp_3_hd",) +GOOGLE_TTS_VOICES = ("en-US-Chirp3-HD-Charon",) +GOOGLE_TTS_LANGUAGES = ( + "ar-XA", + "bn-IN", + "bg-BG", + "yue-HK", + "hr-HR", + "cs-CZ", + "da-DK", + "nl-BE", + "nl-NL", + "en-AU", + "en-IN", + "en-GB", + "en-US", + "et-EE", + "fi-FI", + "fr-CA", + "fr-FR", + "de-DE", + "el-GR", + "gu-IN", + "he-IL", + "hi-IN", + "hu-HU", + "id-ID", + "it-IT", + "ja-JP", + "kn-IN", + "ko-KR", + "lv-LV", + "lt-LT", + "ml-IN", + "cmn-CN", + "mr-IN", + "nb-NO", + "pl-PL", + "pt-BR", + "pa-IN", + "ro-RO", + "ru-RU", + "sr-RS", + "sk-SK", + "sl-SI", + "es-ES", + "es-US", + "sw-KE", + "sv-SE", + "ta-IN", + "te-IN", + "th-TH", + "tr-TR", + "uk-UA", + "ur-IN", + "vi-VN", +) diff --git a/api/services/configuration/options/sarvam.py b/api/services/configuration/options/sarvam.py new file mode 100644 index 0000000..00a7e5b --- /dev/null +++ b/api/services/configuration/options/sarvam.py @@ -0,0 +1,66 @@ +SARVAM_TTS_MODELS = ("bulbul:v2", "bulbul:v3") +SARVAM_V2_VOICES = ( + "anushka", + "manisha", + "vidya", + "arya", + "abhilash", + "karun", + "hitesh", +) +SARVAM_V3_VOICES = ( + "shubh", + "aditya", + "ritu", + "priya", + "neha", + "rahul", + "pooja", + "rohan", + "simran", + "kavya", + "amit", + "dev", + "ishita", + "shreya", + "ratan", + "varun", + "manan", + "sumit", + "roopa", + "kabir", + "aayan", + "ashutosh", + "advait", + "amelia", + "sophia", + "anand", + "tanya", + "tarun", + "sunny", + "mani", + "gokul", + "vijay", + "shruti", + "suhani", + "mohit", + "kavitha", + "rehan", + "soham", + "rupali", +) +SARVAM_LANGUAGES = ( + "bn-IN", + "en-IN", + "gu-IN", + "hi-IN", + "kn-IN", + "ml-IN", + "mr-IN", + "od-IN", + "pa-IN", + "ta-IN", + "te-IN", + "as-IN", +) +SARVAM_STT_MODELS = ("saarika:v2.5", "saaras:v2") diff --git a/api/services/configuration/options/speechmatics.py b/api/services/configuration/options/speechmatics.py new file mode 100644 index 0000000..d0ac992 --- /dev/null +++ b/api/services/configuration/options/speechmatics.py @@ -0,0 +1,63 @@ +SPEECHMATICS_STT_LANGUAGES = ( + "ar", + "ar_en", + "ba", + "eu", + "be", + "bn", + "bg", + "yue", + "ca", + "hr", + "cs", + "da", + "nl", + "en", + "eo", + "et", + "fi", + "fr", + "gl", + "de", + "el", + "he", + "hi", + "hu", + "id", + "ia", + "ga", + "it", + "ja", + "ko", + "lv", + "lt", + "ms", + "en_ms", + "mt", + "cmn", + "cmn_en", + "cmn_en_ms_ta", + "mr", + "mn", + "no", + "fa", + "pl", + "pt", + "ro", + "ru", + "sk", + "sl", + "es", + "sw", + "sv", + "tl", + "ta", + "en_ta", + "th", + "tr", + "uk", + "ur", + "ug", + "vi", + "cy", +) diff --git a/api/services/configuration/registry.py b/api/services/configuration/registry.py index 758a24d..e60db18 100644 --- a/api/services/configuration/registry.py +++ b/api/services/configuration/registry.py @@ -2,7 +2,33 @@ import random from enum import Enum, auto from typing import Annotated, Dict, Literal, Type, TypeVar, Union -from pydantic import BaseModel, Field, computed_field, field_validator +from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator + +from api.services.configuration.options import ( + DEEPGRAM_LANGUAGES, + DEEPGRAM_STT_MODELS, + GLADIA_STT_LANGUAGES, + GLADIA_STT_MODELS, + GOOGLE_MODELS, + GOOGLE_REALTIME_LANGUAGES, + GOOGLE_REALTIME_MODELS, + GOOGLE_REALTIME_VOICES, + GOOGLE_STT_LANGUAGES, + GOOGLE_STT_MODELS, + GOOGLE_TTS_LANGUAGES, + GOOGLE_TTS_MODELS, + GOOGLE_TTS_VOICES, + GOOGLE_VERTEX_REALTIME_LANGUAGES, + GOOGLE_VERTEX_REALTIME_MODELS, + GOOGLE_VERTEX_REALTIME_VOICES, + SARVAM_LANGUAGES, + SARVAM_STT_MODELS, + SARVAM_TTS_MODELS, + SARVAM_V2_VOICES, + SARVAM_V3_VOICES, + SPEECHMATICS_STT_LANGUAGES, +) +from api.services.configuration.options.google import GOOGLE_VERTEX_MODELS class ServiceType(Enum): @@ -32,8 +58,13 @@ class ServiceProviders(str, Enum): ASSEMBLYAI = "assemblyai" GLADIA = "gladia" RIME = "rime" + MINIMAX = "minimax" + GOOGLE_VERTEX = "google_vertex" OPENAI_REALTIME = "openai_realtime" + GROK_REALTIME = "grok_realtime" + ULTRAVOX_REALTIME = "ultravox_realtime" GOOGLE_REALTIME = "google_realtime" + GOOGLE_VERTEX_REALTIME = "google_vertex_realtime" class BaseServiceConfiguration(BaseModel): @@ -51,8 +82,13 @@ class BaseServiceConfiguration(BaseModel): ServiceProviders.ASSEMBLYAI, ServiceProviders.GLADIA, ServiceProviders.RIME, + ServiceProviders.MINIMAX, + ServiceProviders.GOOGLE_VERTEX, ServiceProviders.OPENAI_REALTIME, + ServiceProviders.GROK_REALTIME, + ServiceProviders.ULTRAVOX_REALTIME, ServiceProviders.GOOGLE_REALTIME, + ServiceProviders.GOOGLE_VERTEX_REALTIME, # ServiceProviders.SARVAM, ] api_key: str | list[str] @@ -151,9 +187,59 @@ def register_embeddings(cls: Type[BaseEmbeddingsConfiguration]): return register_service(ServiceType.EMBEDDINGS)(cls) +def provider_model_config( + title: str, + *, + description: str | None = None, + provider_docs_url: str | None = None, +) -> ConfigDict: + json_schema_extra: dict[str, str] = {} + if description is not None: + json_schema_extra["description"] = description + if provider_docs_url is not None: + json_schema_extra["provider_docs_url"] = provider_docs_url + if json_schema_extra: + return ConfigDict(title=title, json_schema_extra=json_schema_extra) + return ConfigDict(title=title) + + ###################################################### LLM ######################################################################## # Suggested models for each provider (used for UI dropdown) +OPENAI_PROVIDER_MODEL_CONFIG = provider_model_config("OpenAI") +GOOGLE_PROVIDER_MODEL_CONFIG = provider_model_config("Google") +GROQ_PROVIDER_MODEL_CONFIG = provider_model_config("Groq") +OPENROUTER_PROVIDER_MODEL_CONFIG = provider_model_config("Open Router") +AZURE_OPENAI_PROVIDER_MODEL_CONFIG = provider_model_config("Azure OpenAI") +DOGRAH_PROVIDER_MODEL_CONFIG = provider_model_config("Dograh") +AWS_BEDROCK_PROVIDER_MODEL_CONFIG = provider_model_config("AWS Bedrock") +GOOGLE_VERTEX_PROVIDER_MODEL_CONFIG = provider_model_config("Google Vertex") +OPENAI_REALTIME_PROVIDER_MODEL_CONFIG = provider_model_config("OpenAI Realtime") +GROK_REALTIME_PROVIDER_MODEL_CONFIG = provider_model_config("Grok Realtime") +ULTRAVOX_REALTIME_PROVIDER_MODEL_CONFIG = provider_model_config("Ultravox Realtime") +GOOGLE_REALTIME_PROVIDER_MODEL_CONFIG = provider_model_config("Google Realtime") +GOOGLE_VERTEX_REALTIME_PROVIDER_MODEL_CONFIG = provider_model_config( + "Google Vertex Realtime" +) +DEEPGRAM_PROVIDER_MODEL_CONFIG = provider_model_config("Deepgram") +ELEVENLABS_PROVIDER_MODEL_CONFIG = provider_model_config("ElevenLabs") +CARTESIA_PROVIDER_MODEL_CONFIG = provider_model_config("Cartesia") +SARVAM_PROVIDER_MODEL_CONFIG = provider_model_config("Sarvam") +CAMB_PROVIDER_MODEL_CONFIG = provider_model_config("Camb.ai") +RIME_PROVIDER_MODEL_CONFIG = provider_model_config("Rime") +GOOGLE_CLOUD_PROVIDER_MODEL_CONFIG = provider_model_config("Google Cloud") +SPEECHMATICS_PROVIDER_MODEL_CONFIG = provider_model_config("Speechmatics") +ASSEMBLYAI_PROVIDER_MODEL_CONFIG = provider_model_config("AssemblyAI") +GLADIA_PROVIDER_MODEL_CONFIG = provider_model_config("Gladia") +SPEACHES_PROVIDER_MODEL_CONFIG = provider_model_config( + "Local Models (Speaches)", + description=( + "Self-hosted OpenAI-compatible local models. See the Speaches project " + "for setup and supported backends." + ), + provider_docs_url="https://github.com/speaches-ai/speaches", +) + OPENAI_MODELS = [ "gpt-4.1", "gpt-4.1-mini", @@ -163,12 +249,7 @@ OPENAI_MODELS = [ "gpt-5-nano", "gpt-3.5-turbo", ] -GOOGLE_MODELS = [ - "gemini-2.0-flash", - "gemini-2.0-flash-lite", - "gemini-2.5-flash", - "gemini-2.5-flash-lite", -] + GROQ_MODELS = [ "llama-3.3-70b-versatile", "deepseek-r1-distill-llama-70b", @@ -202,73 +283,138 @@ AWS_BEDROCK_MODELS = [ @register_llm class OpenAILLMService(BaseLLMConfiguration): + model_config = OPENAI_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.OPENAI] = ServiceProviders.OPENAI model: str = Field( default="gpt-4.1", + description="OpenAI chat model to use.", json_schema_extra={"examples": OPENAI_MODELS, "allow_custom_input": True}, ) @register_llm class GoogleLLMService(BaseLLMConfiguration): + model_config = GOOGLE_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.GOOGLE] = ServiceProviders.GOOGLE model: str = Field( default="gemini-2.0-flash", + description="Gemini model on Google AI Studio (not Vertex).", json_schema_extra={"examples": GOOGLE_MODELS, "allow_custom_input": True}, ) +@register_llm +class GoogleVertexLLMConfiguration(BaseLLMConfiguration): + model_config = GOOGLE_VERTEX_PROVIDER_MODEL_CONFIG + provider: Literal[ServiceProviders.GOOGLE_VERTEX] = ServiceProviders.GOOGLE_VERTEX + model: str = Field( + default="gemini-2.5-flash", + description="Gemini model on Vertex AI.", + json_schema_extra={ + "examples": GOOGLE_VERTEX_MODELS, + "allow_custom_input": True, + }, + ) + project_id: str = Field(description="Google Cloud project ID for Vertex AI.") + location: str = Field( + default="global", + description="GCP region for the Vertex AI endpoint (e.g. 'global').", + ) + credentials: str | None = Field( + default=None, + description=( + "Paste the entire service-account JSON file contents. If omitted, " + "falls back to Application Default Credentials (ADC)." + ), + json_schema_extra={"multiline": True}, + ) + api_key: str | list[str] | None = Field( + default=None, + description=( + "Not used for Vertex AI — authentication is via the service account " + "in `credentials` (or ADC). Leave blank." + ), + ) + + @register_llm class GroqLLMService(BaseLLMConfiguration): + model_config = GROQ_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.GROQ] = ServiceProviders.GROQ model: str = Field( default="llama-3.3-70b-versatile", + description="Groq-hosted model identifier.", json_schema_extra={"examples": GROQ_MODELS, "allow_custom_input": True}, ) @register_llm class OpenRouterLLMConfiguration(BaseLLMConfiguration): + model_config = OPENROUTER_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.OPENROUTER] = ServiceProviders.OPENROUTER model: str = Field( default="openai/gpt-4.1", + description="OpenRouter model slug in 'vendor/model' form.", json_schema_extra={"examples": OPENROUTER_MODELS, "allow_custom_input": True}, ) - base_url: str = Field(default="https://openrouter.ai/api/v1") + base_url: str = Field( + default="https://openrouter.ai/api/v1", + description="Override only if proxying OpenRouter through your own gateway.", + ) @register_llm class AzureLLMService(BaseLLMConfiguration): + model_config = AZURE_OPENAI_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.AZURE] = ServiceProviders.AZURE model: str = Field( default="gpt-4.1-mini", + description="Azure deployment name (not the upstream OpenAI model id).", json_schema_extra={"examples": AZURE_MODELS, "allow_custom_input": True}, ) - endpoint: str + endpoint: str = Field( + description="Azure OpenAI resource endpoint (e.g. https://.openai.azure.com).", + ) @register_llm class DograhLLMService(BaseLLMConfiguration): + model_config = DOGRAH_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.DOGRAH] = ServiceProviders.DOGRAH model: str = Field( default="default", + description="Dograh-hosted model tier.", json_schema_extra={"examples": DOGRAH_LLM_MODELS, "allow_custom_input": True}, ) @register_llm class AWSBedrockLLMConfiguration(BaseLLMConfiguration): + model_config = AWS_BEDROCK_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.AWS_BEDROCK] = ServiceProviders.AWS_BEDROCK model: str = Field( default="us.amazon.nova-pro-v1:0", + description="Bedrock model ID — include the region inference-profile prefix (e.g. 'us.').", json_schema_extra={"examples": AWS_BEDROCK_MODELS, "allow_custom_input": True}, ) - aws_access_key: str = Field(default="") - aws_secret_key: str = Field(default="") - aws_region: str = Field(default="us-east-1") - api_key: str | list[str] | None = Field(default=None) + aws_access_key: str = Field( + default="", + description="AWS access key ID with bedrock:InvokeModel permission.", + ) + aws_secret_key: str = Field( + default="", + description="AWS secret access key paired with the access key ID.", + ) + aws_region: str = Field( + default="us-east-1", + description="AWS region where the Bedrock model is available.", + ) + api_key: str | list[str] | None = Field( + default=None, + description="Not used for Bedrock — authentication is via the AWS credentials above. Leave blank.", + ) SPEACHES_LLM_MODELS = ["llama3", "mistral", "phi3", "qwen2", "gemma2", "deepseek-r1"] @@ -276,9 +422,11 @@ SPEACHES_LLM_MODELS = ["llama3", "mistral", "phi3", "qwen2", "gemma2", "deepseek @register_llm class SpeachesLLMConfiguration(BaseLLMConfiguration): + model_config = SPEACHES_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.SPEACHES] = ServiceProviders.SPEACHES model: str = Field( default="llama3", + description="Model name as exposed by your OpenAI-compatible server.", json_schema_extra={ "examples": SPEACHES_LLM_MODELS, "allow_custom_input": True, @@ -286,12 +434,41 @@ class SpeachesLLMConfiguration(BaseLLMConfiguration): ) base_url: str = Field( default="http://localhost:11434/v1", - description="OpenAI-compatible endpoint (Ollama, vLLM, etc.)", + description="OpenAI-compatible endpoint (Ollama, vLLM, etc.).", + ) + api_key: str | list[str] | None = Field( + default=None, + description="Usually not required for self-hosted endpoints. Leave blank unless your server enforces one.", ) - api_key: str | list[str] | None = Field(default=None) -OPENAI_REALTIME_MODELS = ["gpt-4o-realtime-preview", "gpt-4o-mini-realtime-preview"] +MINIMAX_MODELS = [ + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", +] + + +@register_llm +class MiniMaxLLMConfiguration(BaseLLMConfiguration): + provider: Literal[ServiceProviders.MINIMAX] = ServiceProviders.MINIMAX + model: str = Field( + default="MiniMax-M2.7", + description="MiniMax chat model.", + json_schema_extra={"examples": MINIMAX_MODELS, "allow_custom_input": True}, + ) + base_url: str = Field( + default="https://api.minimax.io/v1", + description="MiniMax OpenAI-compatible API endpoint.", + ) + temperature: float = Field( + default=1.0, + gt=0.0, + le=2.0, + description="Sampling temperature. MiniMax requires > 0.", + ) + + +OPENAI_REALTIME_MODELS = ["gpt-realtime-2"] OPENAI_REALTIME_VOICES = [ "alloy", "ash", @@ -304,62 +481,86 @@ OPENAI_REALTIME_VOICES = [ ] -# @register_service(ServiceType.REALTIME) -# class OpenAIRealtimeLLMConfiguration(BaseLLMConfiguration): -# provider: Literal[ServiceProviders.OPENAI_REALTIME] = ( -# ServiceProviders.OPENAI_REALTIME -# ) -# model: str = Field( -# default="gpt-4o-realtime-preview", -# json_schema_extra={ -# "examples": OPENAI_REALTIME_MODELS, -# "allow_custom_input": True, -# }, -# ) -# voice: str = Field( -# default="alloy", -# json_schema_extra={"examples": OPENAI_REALTIME_VOICES}, -# ) +@register_service(ServiceType.REALTIME) +class OpenAIRealtimeLLMConfiguration(BaseLLMConfiguration): + model_config = OPENAI_REALTIME_PROVIDER_MODEL_CONFIG + provider: Literal[ServiceProviders.OPENAI_REALTIME] = ( + ServiceProviders.OPENAI_REALTIME + ) + model: str = Field( + default="gpt-realtime-2", + description="OpenAI realtime (speech-to-speech) model.", + json_schema_extra={ + "examples": OPENAI_REALTIME_MODELS, + "allow_custom_input": True, + }, + ) + voice: str = Field( + default="alloy", + description="Voice the model speaks in.", + json_schema_extra={ + "examples": OPENAI_REALTIME_VOICES, + "allow_custom_input": True, + }, + ) -GOOGLE_REALTIME_MODELS = ["gemini-3.1-flash-live-preview"] -GOOGLE_REALTIME_VOICES = ["Puck", "Charon", "Kore", "Fenrir", "Aoede"] -GOOGLE_REALTIME_LANGUAGES = [ - "ar", - "bn", - "de", - "en", - "es", - "fr", - "gu", - "hi", - "id", - "it", - "ja", - "kn", - "ko", - "ml", - "mr", - "nl", - "pl", - "pt", - "ru", - "ta", - "te", - "th", - "tr", - "vi", - "zh", -] +GROK_REALTIME_MODELS = ["grok-voice-think-fast-1.0"] +GROK_REALTIME_VOICES = ["Ara", "Rex", "Sal", "Eve", "Leo"] +ULTRAVOX_REALTIME_MODELS = ["ultravox-v0.7", "fixie-ai/ultravox"] + + +@register_service(ServiceType.REALTIME) +class GrokRealtimeLLMConfiguration(BaseLLMConfiguration): + model_config = GROK_REALTIME_PROVIDER_MODEL_CONFIG + provider: Literal[ServiceProviders.GROK_REALTIME] = ServiceProviders.GROK_REALTIME + model: str = Field( + default="grok-voice-think-fast-1.0", + description="Grok realtime voice-agent model.", + json_schema_extra={ + "examples": GROK_REALTIME_MODELS, + "allow_custom_input": True, + }, + ) + voice: str = Field( + default="Ara", + description="Voice the model speaks in.", + json_schema_extra={ + "examples": GROK_REALTIME_VOICES, + "allow_custom_input": True, + }, + ) + + +@register_service(ServiceType.REALTIME) +class UltravoxRealtimeLLMConfiguration(BaseLLMConfiguration): + model_config = ULTRAVOX_REALTIME_PROVIDER_MODEL_CONFIG + provider: Literal[ServiceProviders.ULTRAVOX_REALTIME] = ( + ServiceProviders.ULTRAVOX_REALTIME + ) + model: str = Field( + default="ultravox-v0.7", + description="Ultravox realtime voice-agent model.", + json_schema_extra={ + "examples": ULTRAVOX_REALTIME_MODELS, + "allow_custom_input": True, + }, + ) + voice: str = Field( + default="Mark", + description="Ultravox voice name or voice ID.", + ) @register_service(ServiceType.REALTIME) class GoogleRealtimeLLMConfiguration(BaseLLMConfiguration): + model_config = GOOGLE_REALTIME_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.GOOGLE_REALTIME] = ( ServiceProviders.GOOGLE_REALTIME ) model: str = Field( default="gemini-3.1-flash-live-preview", + description="Gemini Live model on Google AI Studio (not Vertex).", json_schema_extra={ "examples": GOOGLE_REALTIME_MODELS, "allow_custom_input": True, @@ -367,6 +568,7 @@ class GoogleRealtimeLLMConfiguration(BaseLLMConfiguration): ) voice: str = Field( default="Puck", + description="Voice the model speaks in.", json_schema_extra={ "examples": GOOGLE_REALTIME_VOICES, "allow_custom_input": True, @@ -374,6 +576,7 @@ class GoogleRealtimeLLMConfiguration(BaseLLMConfiguration): ) language: str = Field( default="en", + description="ISO 639-1 language code.", json_schema_extra={ "examples": GOOGLE_REALTIME_LANGUAGES, "allow_custom_input": True, @@ -381,15 +584,71 @@ class GoogleRealtimeLLMConfiguration(BaseLLMConfiguration): ) +@register_service(ServiceType.REALTIME) +class GoogleVertexRealtimeLLMConfiguration(BaseLLMConfiguration): + model_config = GOOGLE_VERTEX_REALTIME_PROVIDER_MODEL_CONFIG + provider: Literal[ServiceProviders.GOOGLE_VERTEX_REALTIME] = ( + ServiceProviders.GOOGLE_VERTEX_REALTIME + ) + model: str = Field( + default="google/gemini-live-2.5-flash-native-audio", + description="Vertex AI publisher/model identifier.", + json_schema_extra={ + "examples": GOOGLE_VERTEX_REALTIME_MODELS, + "allow_custom_input": True, + }, + ) + voice: str = Field( + default="Charon", + description="Voice the model speaks in.", + json_schema_extra={ + "examples": GOOGLE_VERTEX_REALTIME_VOICES, + "allow_custom_input": True, + }, + ) + language: str = Field( + default="en", + description="BCP-47 language code (e.g. 'en-US').", + json_schema_extra={ + "examples": GOOGLE_VERTEX_REALTIME_LANGUAGES, + "allow_custom_input": True, + }, + ) + project_id: str = Field(description="Google Cloud project ID for Vertex AI.") + location: str = Field( + default="global", + description="GCP region for the Vertex AI endpoint (e.g. 'global').", + ) + credentials: str | None = Field( + default=None, + description=( + "Paste the entire service-account JSON file contents. If omitted, " + "falls back to Application Default Credentials (ADC)." + ), + json_schema_extra={"multiline": True}, + ) + api_key: str | list[str] | None = Field( + default=None, + description=( + "Not used for Vertex AI — authentication is via the service account " + "in `credentials` (or ADC). Leave blank." + ), + ) + + REALTIME_PROVIDERS = { ServiceProviders.OPENAI_REALTIME.value, + ServiceProviders.GROK_REALTIME.value, + ServiceProviders.ULTRAVOX_REALTIME.value, ServiceProviders.GOOGLE_REALTIME.value, + ServiceProviders.GOOGLE_VERTEX_REALTIME.value, } LLMConfig = Annotated[ Union[ OpenAILLMService, + GoogleVertexLLMConfiguration, GroqLLMService, OpenRouterLLMConfiguration, GoogleLLMService, @@ -397,14 +656,18 @@ LLMConfig = Annotated[ DograhLLMService, AWSBedrockLLMConfiguration, SpeachesLLMConfiguration, + MiniMaxLLMConfiguration, ], Field(discriminator="provider"), ] RealtimeConfig = Annotated[ Union[ - # OpenAIRealtimeLLMConfiguration, + OpenAIRealtimeLLMConfiguration, + GrokRealtimeLLMConfiguration, + UltravoxRealtimeLLMConfiguration, GoogleRealtimeLLMConfiguration, + GoogleVertexRealtimeLLMConfiguration, ], Field(discriminator="provider"), ] @@ -414,8 +677,12 @@ RealtimeConfig = Annotated[ @register_tts class DeepgramTTSConfiguration(BaseServiceConfiguration): + model_config = DEEPGRAM_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.DEEPGRAM] = ServiceProviders.DEEPGRAM - voice: str = "aura-2-helena-en" + voice: str = Field( + default="aura-2-helena-en", + description="Deepgram voice ID (model is inferred from the 'aura-N' prefix).", + ) @computed_field @property @@ -436,11 +703,16 @@ ELEVENLABS_TTS_MODELS = ["eleven_flash_v2_5"] @register_tts class ElevenlabsTTSConfiguration(BaseServiceConfiguration): + model_config = ELEVENLABS_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.ELEVENLABS] = ServiceProviders.ELEVENLABS - voice: str = "21m00Tcm4TlvDq8ikWAM" # Rachel voice ID - speed: float = Field(default=1.0, ge=0.1, le=2.0, description="Speed of the voice") + voice: str = Field( + default="21m00Tcm4TlvDq8ikWAM", + description="ElevenLabs voice ID from your Voice Library.", + ) + speed: float = Field(default=1.0, ge=0.1, le=2.0, description="Speed of the voice.") model: str = Field( default="eleven_flash_v2_5", + description="ElevenLabs TTS model.", json_schema_extra={"examples": ELEVENLABS_TTS_MODELS}, ) base_url: str = Field( @@ -453,16 +725,80 @@ class ElevenlabsTTSConfiguration(BaseServiceConfiguration): ) +@register_tts +class GoogleTTSConfiguration(BaseTTSConfiguration): + model_config = GOOGLE_CLOUD_PROVIDER_MODEL_CONFIG + provider: Literal[ServiceProviders.GOOGLE] = ServiceProviders.GOOGLE + model: str = Field( + default="chirp_3_hd", + description=( + "Google Cloud low-latency TTS engine. Dograh maps this to Pipecat's " + "streaming Google TTS service for Chirp 3 HD and Journey voices." + ), + json_schema_extra={ + "examples": GOOGLE_TTS_MODELS, + "allow_custom_input": True, + }, + ) + voice: str = Field( + default="en-US-Chirp3-HD-Charon", + description="Google Cloud voice name. Use a Chirp 3 HD or Journey voice for streaming TTS.", + json_schema_extra={ + "examples": GOOGLE_TTS_VOICES, + "allow_custom_input": True, + }, + ) + language: str = Field( + default="en-US", + description="BCP-47 language code for synthesis.", + json_schema_extra={ + "examples": GOOGLE_TTS_LANGUAGES, + "allow_custom_input": True, + }, + ) + speed: float = Field( + default=1.0, + ge=0.25, + le=2.0, + description="Speech speed multiplier for Google streaming TTS.", + ) + location: str | None = Field( + default=None, + description=( + "Optional Google Cloud regional Text-to-Speech endpoint (for example " + "'us-central1'). Leave blank to use the default endpoint." + ), + ) + credentials: str | None = Field( + default=None, + description=( + "Paste the entire Google Cloud service-account JSON. If omitted, " + "the server falls back to Application Default Credentials (ADC)." + ), + json_schema_extra={"multiline": True}, + ) + api_key: str | list[str] | None = Field( + default=None, + description="Not used for Google Cloud TTS. Leave blank.", + ) + + OPENAI_TTS_MODELS = ["gpt-4o-mini-tts"] @register_tts class OpenAITTSService(BaseTTSConfiguration): + model_config = OPENAI_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.OPENAI] = ServiceProviders.OPENAI model: str = Field( - default="gpt-4o-mini-tts", json_schema_extra={"examples": OPENAI_TTS_MODELS} + default="gpt-4o-mini-tts", + description="OpenAI TTS model.", + json_schema_extra={"examples": OPENAI_TTS_MODELS}, + ) + voice: str = Field( + default="alloy", + description="OpenAI TTS voice name.", ) - voice: str = "alloy" DOGRAH_TTS_MODELS = ["default"] @@ -470,12 +806,18 @@ DOGRAH_TTS_MODELS = ["default"] @register_tts class DograhTTSService(BaseTTSConfiguration): + model_config = DOGRAH_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.DOGRAH] = ServiceProviders.DOGRAH model: str = Field( - default="default", json_schema_extra={"examples": DOGRAH_TTS_MODELS} + default="default", + description="Dograh TTS tier.", + json_schema_extra={"examples": DOGRAH_TTS_MODELS}, ) - voice: str = "default" - speed: float = Field(default=1.0, ge=0.5, le=2.0, description="Speed of the voice") + voice: str = Field( + default="default", + description="Voice preset.", + ) + speed: float = Field(default=1.0, ge=0.5, le=2.0, description="Speed of the voice.") CARTESIA_TTS_MODELS = ["sonic-3"] @@ -483,95 +825,38 @@ CARTESIA_TTS_MODELS = ["sonic-3"] @register_tts class CartesiaTTSConfiguration(BaseTTSConfiguration): + model_config = CARTESIA_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.CARTESIA] = ServiceProviders.CARTESIA model: str = Field( - default="sonic-3", json_schema_extra={"examples": CARTESIA_TTS_MODELS} + default="sonic-3", + description="Cartesia TTS model.", + json_schema_extra={"examples": CARTESIA_TTS_MODELS}, ) - voice: str = Field(default="3faa81ae-d3d8-4ab1-9e44-e50e46d33c30") - speed: float = Field(default=1.0, ge=0.6, le=1.5, description="Speed of the voice") + voice: str = Field( + default="3faa81ae-d3d8-4ab1-9e44-e50e46d33c30", + description="Cartesia voice UUID from your Cartesia dashboard.", + ) + speed: float = Field(default=1.0, ge=0.6, le=1.5, description="Speed of the voice.") volume: float = Field( default=1.0, ge=0.5, le=2.0, - description="Volume multiplier for generated speech", + description="Volume multiplier for generated speech.", ) -SARVAM_TTS_MODELS = ["bulbul:v2", "bulbul:v3"] -SARVAM_V2_VOICES = [ - "anushka", - "manisha", - "vidya", - "arya", - "abhilash", - "karun", - "hitesh", -] -SARVAM_V3_VOICES = [ - "shubh", - "aditya", - "ritu", - "priya", - "neha", - "rahul", - "pooja", - "rohan", - "simran", - "kavya", - "amit", - "dev", - "ishita", - "shreya", - "ratan", - "varun", - "manan", - "sumit", - "roopa", - "kabir", - "aayan", - "ashutosh", - "advait", - "amelia", - "sophia", - "anand", - "tanya", - "tarun", - "sunny", - "mani", - "gokul", - "vijay", - "shruti", - "suhani", - "mohit", - "kavitha", - "rehan", - "soham", - "rupali", -] -SARVAM_LANGUAGES = [ - "bn-IN", - "en-IN", - "gu-IN", - "hi-IN", - "kn-IN", - "ml-IN", - "mr-IN", - "od-IN", - "pa-IN", - "ta-IN", - "te-IN", - "as-IN", -] - - @register_tts class SarvamTTSConfiguration(BaseTTSConfiguration): + model_config = SARVAM_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.SARVAM] = ServiceProviders.SARVAM model: str = Field( - default="bulbul:v2", json_schema_extra={"examples": SARVAM_TTS_MODELS} + default="bulbul:v2", + description="Sarvam TTS model (voice list depends on this).", + json_schema_extra={"examples": SARVAM_TTS_MODELS}, ) voice: str = Field( default="anushka", + description="Sarvam voice name; must match the selected model's voice list.", json_schema_extra={ "examples": SARVAM_V2_VOICES, "model_options": { @@ -581,7 +866,9 @@ class SarvamTTSConfiguration(BaseTTSConfiguration): }, ) language: str = Field( - default="hi-IN", json_schema_extra={"examples": SARVAM_LANGUAGES} + default="hi-IN", + description="BCP-47 Indian-language code (e.g. hi-IN, en-IN).", + json_schema_extra={"examples": SARVAM_LANGUAGES}, ) @@ -590,12 +877,15 @@ CAMB_TTS_MODELS = ["mars-flash", "mars-pro", "mars-instruct"] @register_tts class CambTTSConfiguration(BaseTTSConfiguration): + model_config = CAMB_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.CAMB] = ServiceProviders.CAMB model: str = Field( - default="mars-flash", json_schema_extra={"examples": CAMB_TTS_MODELS} + default="mars-flash", + description="Camb.ai TTS model.", + json_schema_extra={"examples": CAMB_TTS_MODELS}, ) - voice: str = Field(default="147320", description="Camb.ai voice ID") - language: str = Field(default="en-us", description="BCP-47 language code") + voice: str = Field(default="147320", description="Camb.ai voice ID.") + language: str = Field(default="en-us", description="BCP-47 language code.") RIME_TTS_MODELS = ["arcana", "mistv3", "mistv2", "mist"] @@ -604,20 +894,23 @@ RIME_TTS_LANGUAGES = ["en", "de", "fr", "es", "hi"] @register_tts class RimeTTSConfiguration(BaseTTSConfiguration): + model_config = RIME_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.RIME] = ServiceProviders.RIME model: str = Field( default="arcana", + description="Rime TTS model.", json_schema_extra={"examples": RIME_TTS_MODELS, "allow_custom_input": True}, ) voice: str = Field( default="celeste", - description="Rime voice ID", + description="Rime voice ID.", ) speed: float = Field( - default=1.0, ge=0.5, le=2.0, description="Speech speed multiplier" + default=1.0, ge=0.5, le=2.0, description="Speech speed multiplier." ) language: str = Field( default="en", + description="ISO 639-1 language code.", json_schema_extra={"examples": RIME_TTS_LANGUAGES, "allow_custom_input": True}, ) @@ -627,9 +920,11 @@ SPEACHES_TTS_MODELS = ["hexgrad/Kokoro-82M"] @register_tts class SpeachesTTSConfiguration(BaseTTSConfiguration): + model_config = SPEACHES_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.SPEACHES] = ServiceProviders.SPEACHES model: str = Field( default="kokoro", + description="Model name as served by your TTS endpoint (e.g. Kokoro-FastAPI).", json_schema_extra={ "examples": SPEACHES_TTS_MODELS, "allow_custom_input": True, @@ -638,21 +933,66 @@ class SpeachesTTSConfiguration(BaseTTSConfiguration): voice: str = Field( default="af_heart", json_schema_extra={"allow_custom_input": True}, - description="Voice ID for the TTS engine", + description="Voice ID for the TTS engine.", ) base_url: str = Field( default="http://localhost:8000/v1", - description="OpenAI-compatible TTS endpoint (Kokoro-FastAPI, etc.)", + description="OpenAI-compatible TTS endpoint (Kokoro-FastAPI, etc.).", ) speed: float = Field( - default=1.0, ge=0.25, le=4.0, description="Speech speed (0.25 to 4.0)" + default=1.0, ge=0.25, le=4.0, description="Speech speed (0.25 to 4.0)." + ) + api_key: str | list[str] | None = Field( + default=None, + description="Usually not required for self-hosted TTS. Leave blank unless enforced.", + ) + + +MINIMAX_TTS_MODELS = ["speech-2.8-hd", "speech-2.8-turbo"] +MINIMAX_TTS_VOICES = [ + "English_Graceful_Lady", + "English_Insightful_Speaker", + "English_radiant_girl", + "English_Persuasive_Man", + "English_Lucky_Robot", + "English_expressive_narrator", +] + + +@register_tts +class MiniMaxTTSConfiguration(BaseTTSConfiguration): + provider: Literal[ServiceProviders.MINIMAX] = ServiceProviders.MINIMAX + model: str = Field( + default="speech-2.8-hd", + description="MiniMax TTS model.", + json_schema_extra={"examples": MINIMAX_TTS_MODELS}, + ) + voice: str = Field( + default="English_Graceful_Lady", + description="MiniMax voice ID.", + json_schema_extra={"examples": MINIMAX_TTS_VOICES, "allow_custom_input": True}, + ) + base_url: str = Field( + default="https://api.minimax.io/v1/t2a_v2", + description=( + "MiniMax TTS API endpoint (must include the /v1/t2a_v2 path). " + "Defaults to the global endpoint; override with " + "https://api.minimaxi.chat/v1/t2a_v2 (mainland China) or " + "https://api-uw.minimax.io/v1/t2a_v2 (US-West)." + ), + ) + speed: float = Field( + default=1.0, ge=0.5, le=2.0, description="Speech speed (0.5 to 2.0)." + ) + group_id: str = Field( + description="MiniMax Group ID (found in your MiniMax dashboard under Account → Group).", ) - api_key: str | list[str] | None = Field(default=None) TTSConfig = Annotated[ Union[ DeepgramTTSConfiguration, + GoogleTTSConfiguration, OpenAITTSService, ElevenlabsTTSConfiguration, CartesiaTTSConfiguration, @@ -661,6 +1001,7 @@ TTSConfig = Annotated[ CambTTSConfiguration, RimeTTSConfiguration, SpeachesTTSConfiguration, + MiniMaxTTSConfiguration, ], Field(discriminator="provider"), ] @@ -668,105 +1009,23 @@ TTSConfig = Annotated[ ###################################################### STT ######################################################################## -DEEPGRAM_STT_MODELS = ["nova-3-general", "flux-general-en", "flux-general-multi"] -DEEPGRAM_LANGUAGES = [ - "multi", - "ar", - "ar-AE", - "ar-SA", - "ar-QA", - "ar-KW", - "ar-SY", - "ar-LB", - "ar-PS", - "ar-JO", - "ar-EG", - "ar-SD", - "ar-TD", - "ar-MA", - "ar-DZ", - "ar-TN", - "ar-IQ", - "ar-IR", - "be", - "bn", - "bs", - "bg", - "ca", - "cs", - "da", - "da-DK", - "de", - "de-CH", - "el", - "en", - "en-US", - "en-AU", - "en-GB", - "en-IN", - "en-NZ", - "es", - "es-419", - "et", - "fa", - "fi", - "fr", - "fr-CA", - "he", - "hi", - "hr", - "hu", - "id", - "it", - "ja", - "kn", - "ko", - "ko-KR", - "lt", - "lv", - "mk", - "mr", - "ms", - "nl", - "nl-BE", - "no", - "pl", - "pt", - "pt-BR", - "pt-PT", - "ro", - "ru", - "sk", - "sl", - "sr", - "sv", - "sv-SE", - "ta", - "te", - "th", - "tl", - "tr", - "uk", - "ur", - "vi", - "zh-CN", - "zh-TW", -] - - @register_stt class DeepgramSTTConfiguration(BaseSTTConfiguration): + model_config = DEEPGRAM_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.DEEPGRAM] = ServiceProviders.DEEPGRAM model: str = Field( - default="nova-3-general", json_schema_extra={"examples": DEEPGRAM_STT_MODELS} + default="nova-3-general", + description="Deepgram STT model.", + json_schema_extra={"examples": DEEPGRAM_STT_MODELS}, ) language: str = Field( default="multi", + description="Language code; 'multi' enables auto-detect (Nova-3 only).", json_schema_extra={ "examples": DEEPGRAM_LANGUAGES, "model_options": { "nova-3-general": DEEPGRAM_LANGUAGES, - "flux-general-en": ["en"], + "flux-general-en": ("en",), }, }, ) @@ -777,9 +1036,12 @@ CARTESIA_STT_MODELS = ["ink-whisper"] @register_stt class CartesiaSTTConfiguration(BaseSTTConfiguration): + model_config = CARTESIA_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.CARTESIA] = ServiceProviders.CARTESIA model: str = Field( - default="ink-whisper", json_schema_extra={"examples": CARTESIA_STT_MODELS} + default="ink-whisper", + description="Cartesia STT model.", + json_schema_extra={"examples": CARTESIA_STT_MODELS}, ) @@ -788,9 +1050,51 @@ OPENAI_STT_MODELS = ["gpt-4o-transcribe"] @register_stt class OpenAISTTConfiguration(BaseSTTConfiguration): + model_config = OPENAI_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.OPENAI] = ServiceProviders.OPENAI model: str = Field( - default="gpt-4o-transcribe", json_schema_extra={"examples": OPENAI_STT_MODELS} + default="gpt-4o-transcribe", + description="OpenAI transcription model.", + json_schema_extra={"examples": OPENAI_STT_MODELS}, + ) + + +@register_stt +class GoogleSTTConfiguration(BaseSTTConfiguration): + model_config = GOOGLE_CLOUD_PROVIDER_MODEL_CONFIG + provider: Literal[ServiceProviders.GOOGLE] = ServiceProviders.GOOGLE + model: str = Field( + default="latest_long", + description="Google Cloud Speech-to-Text V2 recognition model.", + json_schema_extra={ + "examples": GOOGLE_STT_MODELS, + "allow_custom_input": True, + }, + ) + language: str = Field( + default="en-US", + description="Primary BCP-47 language code for recognition.", + json_schema_extra={ + "examples": GOOGLE_STT_LANGUAGES, + "allow_custom_input": True, + "docs_url": "https://docs.cloud.google.com/speech-to-text/docs/speech-to-text-supported-languages", + }, + ) + location: str = Field( + default="global", + description="Google Cloud Speech-to-Text region (for example 'global' or 'us-central1').", + ) + credentials: str | None = Field( + default=None, + description=( + "Paste the entire Google Cloud service-account JSON. If omitted, " + "the server falls back to Application Default Credentials (ADC)." + ), + json_schema_extra={"multiline": True}, + ) + api_key: str | list[str] | None = Field( + default=None, + description="Not used for Google Cloud STT. Leave blank.", ) @@ -801,66 +1105,48 @@ DOGRAH_STT_LANGUAGES = DEEPGRAM_LANGUAGES @register_stt class DograhSTTService(BaseSTTConfiguration): + model_config = DOGRAH_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.DOGRAH] = ServiceProviders.DOGRAH model: str = Field( - default="default", json_schema_extra={"examples": DOGRAH_STT_MODELS} + default="default", + description="Dograh STT tier.", + json_schema_extra={"examples": DOGRAH_STT_MODELS}, ) language: str = Field( - default="multi", json_schema_extra={"examples": DOGRAH_STT_LANGUAGES} + default="multi", + description="Language code; use 'multi' for auto-detect.", + json_schema_extra={"examples": DOGRAH_STT_LANGUAGES}, ) -# Sarvam STT Service -SARVAM_STT_MODELS = ["saarika:v2.5", "saaras:v2"] - - @register_stt class SarvamSTTConfiguration(BaseSTTConfiguration): + model_config = SARVAM_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.SARVAM] = ServiceProviders.SARVAM model: str = Field( - default="saarika:v2.5", json_schema_extra={"examples": SARVAM_STT_MODELS} + default="saarika:v2.5", + description="Sarvam STT model.", + json_schema_extra={"examples": SARVAM_STT_MODELS}, ) language: str = Field( - default="hi-IN", json_schema_extra={"examples": SARVAM_LANGUAGES} + default="hi-IN", + description="BCP-47 Indian-language code.", + json_schema_extra={"examples": SARVAM_LANGUAGES}, ) -# Speechmatics STT Service -SPEECHMATICS_STT_LANGUAGES = [ - "en", - "es", - "fr", - "de", - "it", - "pt", - "nl", - "ja", - "ko", - "zh", - "ru", - "ar", - "hi", - "pl", - "tr", - "vi", - "th", - "id", - "ms", - "sv", - "da", - "no", - "fi", -] - - @register_stt class SpeechmaticsSTTConfiguration(BaseSTTConfiguration): + model_config = SPEECHMATICS_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.SPEECHMATICS] = ServiceProviders.SPEECHMATICS model: str = Field( - default="enhanced", description="Operating point: standard or enhanced" + default="enhanced", + description="Speechmatics operating point: 'standard' or 'enhanced'.", ) language: str = Field( - default="en", json_schema_extra={"examples": SPEECHMATICS_STT_LANGUAGES} + default="en", + description="ISO 639-1 language code.", + json_schema_extra={"examples": SPEECHMATICS_STT_LANGUAGES}, ) @@ -873,9 +1159,11 @@ SPEACHES_STT_LANGUAGES = ["en", "ar", "nl", "fr", "de", "hi", "it", "pt", "es"] @register_stt class SpeachesSTTConfiguration(BaseSTTConfiguration): + model_config = SPEACHES_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.SPEACHES] = ServiceProviders.SPEACHES model: str = Field( default="Systran/faster-distil-whisper-small.en", + description="Whisper model identifier as served by your STT endpoint.", json_schema_extra={ "examples": SPEACHES_STT_MODELS, "allow_custom_input": True, @@ -883,6 +1171,7 @@ class SpeachesSTTConfiguration(BaseSTTConfiguration): ) language: str = Field( default="en", + description="ISO 639-1 language code.", json_schema_extra={ "examples": SPEACHES_STT_LANGUAGES, "allow_custom_input": True, @@ -890,9 +1179,12 @@ class SpeachesSTTConfiguration(BaseSTTConfiguration): ) base_url: str = Field( default="http://localhost:8000/v1", - description="OpenAI-compatible STT endpoint (Speaches, etc.)", + description="OpenAI-compatible STT endpoint (Speaches, etc.).", + ) + api_key: str | list[str] | None = Field( + default=None, + description="Usually not required for self-hosted STT. Leave blank unless enforced.", ) - api_key: str | list[str] | None = Field(default=None) ASSEMBLYAI_STT_MODELS = ["u3-rt-pro"] @@ -901,131 +1193,32 @@ ASSEMBLYAI_STT_LANGUAGES = ["en", "es", "de", "fr", "pt", "it"] @register_stt class AssemblyAISTTConfiguration(BaseSTTConfiguration): + model_config = ASSEMBLYAI_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.ASSEMBLYAI] = ServiceProviders.ASSEMBLYAI model: str = Field( default="u3-rt-pro", + description="AssemblyAI realtime STT model.", json_schema_extra={"examples": ASSEMBLYAI_STT_MODELS}, ) language: str = Field( default="en", + description="ISO 639-1 language code.", json_schema_extra={"examples": ASSEMBLYAI_STT_LANGUAGES}, ) -GLADIA_STT_MODELS = ["solaria-1"] -GLADIA_STT_LANGUAGES = [ - "af", - "am", - "ar", - "as", - "az", - "ba", - "be", - "bg", - "bn", - "bo", - "br", - "bs", - "ca", - "cs", - "cy", - "da", - "de", - "el", - "en", - "es", - "et", - "eu", - "fa", - "fi", - "fo", - "fr", - "gl", - "gu", - "ha", - "haw", - "he", - "hi", - "hr", - "ht", - "hu", - "hy", - "id", - "is", - "it", - "ja", - "jw", - "ka", - "kk", - "km", - "kn", - "ko", - "la", - "lb", - "ln", - "lo", - "lt", - "lv", - "mg", - "mi", - "mk", - "ml", - "mn", - "mr", - "ms", - "mt", - "my", - "ne", - "nl", - "nn", - "no", - "oc", - "pa", - "pl", - "ps", - "pt", - "ro", - "ru", - "sa", - "sd", - "si", - "sk", - "sl", - "sn", - "so", - "sq", - "sr", - "su", - "sv", - "sw", - "ta", - "te", - "tg", - "th", - "tk", - "tl", - "tr", - "tt", - "uk", - "ur", - "uz", - "vi", - "wo", - "yi", - "yo", - "zh", -] - - @register_stt class GladiaSTTConfiguration(BaseSTTConfiguration): + model_config = GLADIA_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.GLADIA] = ServiceProviders.GLADIA model: str = Field( default="solaria-1", + description="Gladia STT model.", json_schema_extra={"examples": GLADIA_STT_MODELS}, ) language: str = Field( default="en", + description="ISO 639-1 language code.", json_schema_extra={"examples": GLADIA_STT_LANGUAGES}, ) @@ -1035,6 +1228,7 @@ STTConfig = Annotated[ DeepgramSTTConfiguration, CartesiaSTTConfiguration, OpenAISTTConfiguration, + GoogleSTTConfiguration, DograhSTTService, SpeechmaticsSTTConfiguration, SarvamSTTConfiguration, @@ -1052,9 +1246,11 @@ OPENAI_EMBEDDING_MODELS = ["text-embedding-3-small"] @register_embeddings class OpenAIEmbeddingsConfiguration(BaseEmbeddingsConfiguration): + model_config = OPENAI_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.OPENAI] = ServiceProviders.OPENAI model: str = Field( default="text-embedding-3-small", + description="OpenAI embedding model.", json_schema_extra={"examples": OPENAI_EMBEDDING_MODELS}, ) @@ -1064,13 +1260,18 @@ OPENROUTER_EMBEDDING_MODELS = ["openai/text-embedding-3-small"] @register_embeddings class OpenRouterEmbeddingsConfiguration(BaseEmbeddingsConfiguration): + model_config = OPENROUTER_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.OPENROUTER] = ServiceProviders.OPENROUTER model: str = Field( default="openai/text-embedding-3-small", + description="OpenRouter-hosted embedding model slug.", json_schema_extra={"examples": OPENROUTER_EMBEDDING_MODELS}, ) - base_url: str = Field(default="https://openrouter.ai/api/v1") + base_url: str = Field( + default="https://openrouter.ai/api/v1", + description="Override only if proxying OpenRouter through your own gateway.", + ) EmbeddingsConfig = Annotated[ diff --git a/api/services/integrations/AGENTS.md b/api/services/integrations/AGENTS.md new file mode 100644 index 0000000..44ba9b9 --- /dev/null +++ b/api/services/integrations/AGENTS.md @@ -0,0 +1,239 @@ +# Integrations - Plugin Contract + +`api/services/integrations/` is the extension seam for third-party integrations. +New integrations should be self-contained here. Do not bleed integration-specific +logic into `workflow/dto.py`, `workflow/node_specs/`, `run_pipeline.py`, +`event_handlers.py`, or `run_integrations.py` unless you are changing the generic +framework itself. + +## Golden Path + +Create a package: + +```text +api/services/integrations// +├── __init__.py +├── node.py +├── runtime.py # optional +├── completion.py # optional +├── routes.py # optional +└── client.py # optional +``` + +The package self-registers on import via `register_package(...)`. Discovery is +automatic: `api/services/integrations/loader.py` imports every submodule under +`api.services.integrations` except the reserved internal names `base`, `loader`, +and `registry`. + +## Registration Pattern + +`__init__.py` should register one `IntegrationPackageSpec`, following the +existing integration packages in this directory. + +Use: + +```python +PACKAGE = register_package( + IntegrationPackageSpec( + name="", + nodes=(NODE,), + create_runtime_sessions=create_runtime_sessions, # optional + run_completion=run_completion, # optional + routers=(router,), # optional + ) +) +``` + +The package name is the registry key. The node `type_name` is the workflow node +type string and must stay stable once exposed. + +## Node Model + Spec + +For integration nodes, the Pydantic model is the source of truth. The serialized +`NodeSpec` is derived from it. + +Refer to an existing integration node for the overall structure: + +- Define one Pydantic model per node, inheriting + `api/services/workflow/node_data.py:BaseNodeData`. +- Annotate it with `@node_spec(...)`. +- Define fields with `spec_field(...)`. +- Generate the external spec with `SPEC = build_spec(ModelClass)`. +- Register the node with `IntegrationNodeRegistration(...)`. + +Important rules: + +- Put runtime validation in the model, not in the generated spec. + Example: conditional requiredness belongs in `@model_validator(mode="after")`. +- Keep `@node_spec(name=...)` and `IntegrationNodeRegistration.type_name` + identical. They are the same workflow node type string. +- Put wire constraints in the field itself where possible. + Example: `gt=0`, `min_length=1`, `pattern=...`. +- Put UI/export-only differences in `field_overrides`. + Use this for `display_name`, `description`, `required`, `spec_default`, + `display_options`, or property ordering. +- Use `spec_exclude=True` for internal fields that must exist in persisted data + but must not show up in `/api/v1/node-types`. +- Set `property_order=(...)` in `@node_spec(...)` when the editor field order + must remain stable. + +Typical workflow graph constraints for configuration-only integration nodes: + +```python +GraphConstraints(min_incoming=0, max_incoming=0, min_outgoing=0, max_outgoing=0) +``` + +These constraints control how the node can be connected in the workflow graph. +Use them for configuration nodes that are not conversational graph steps. + +## Secret Fields + +If the node stores secrets, register them in +`IntegrationNodeRegistration.sensitive_fields`. + +That is enough for generic masking / masked round-trip preservation via +`api/services/configuration/masking.py`. Do not add new integration-specific +masking branches unless you are changing the shared masking framework. + +## No Central DTO Edits + +Do not add integration node classes to `api/services/workflow/dto.py`. + +Integration nodes are resolved dynamically through: + +- `get_node_data_model()` in `workflow/dto.py` +- `get_node_spec()` / `all_node_specs()` in `services/integrations/registry.py` + +`RFNodeDTO` validates integration nodes by `type` through the registry. That is +the intended extension path. + +## Live Call Path + +If the integration needs live call data, implement `create_runtime_sessions(...)` +in `runtime.py` and return `IntegrationRuntimeSession` objects. + +The generic wiring is already in `api/services/pipecat/run_pipeline.py`: + +- `create_runtime_sessions(IntegrationRuntimeContext(...))` is called before the + pipeline task starts. +- Each returned session gets `session.attach(task)` called. + +Use this only for lightweight live collection: + +- attach task observers +- read context messages +- capture timing / turn / tool events +- build an in-memory snapshot + +Do not do outbound network I/O in the live path unless there is a very strong +reason. Prefer the standard pattern: collect live, deliver after the call. + +`IntegrationRuntimeContext` gives you: + +- `workflow_run_id` +- `workflow_run` +- `workflow_graph` +- `run_definition` +- `user_config` +- `is_realtime` +- `context_messages_provider` + +Typical runtime pattern: + +- scan `context.workflow_graph.nodes.values()` for enabled nodes of your type +- if none are enabled, return `[]` +- build one collector/session per workflow run, not per node, unless the + integration truly needs multiple independent collectors + +## Call-Finish Snapshot Path + +`api/services/pipecat/event_handlers.py` finalizes runtime sessions before the +engine is cleaned up. + +The generic flow: + +1. `on_pipeline_finished` builds `gathered_context` +2. each runtime session gets `await session.on_call_finished(...)` +3. returned dicts are merged into `integration_logs` +4. those logs are persisted into `workflow_run.logs` + +Use `on_call_finished(...)` to emit a compact, serializable snapshot that the +post-call completion handler can consume later. Return `None` if there is nothing +to persist. + +This is the handoff between the live call path and the post-call task path. + +## Post-Call Completion Path + +If the integration needs durable artifacts, public URLs, retries, or external +delivery, implement `run_completion(nodes, context)` in `completion.py`. + +The generic orchestration is already in `api/tasks/run_integrations.py`: + +1. load the pinned workflow definition from the workflow run +2. create a public token if post-call work exists +3. run QA nodes first +4. run registered integration completion handlers +5. run webhook nodes last + +Your handler receives: + +- `nodes`: raw workflow node dicts for your node types only +- `IntegrationCompletionContext`: + - `workflow_run_id` + - `workflow_run` + - `workflow_definition` + - `definition_id` + - `organization_id` + - `public_token` + +Expected completion handler pattern: + +- validate each node with `YourNodeData.model_validate(node.get("data", {}))` +- skip disabled nodes +- read any runtime snapshot from `context.workflow_run.logs` +- build durable URLs using `public_token` when appropriate +- perform external delivery +- return a result dict keyed per node, usually with `node_id` embedded + +Returned data is merged into `workflow_run.annotations`. + +Do not assume completion runs inside the live pipeline process. Treat it as a +separate post-call worker step. + +## Optional Routes + +If an integration exposes HTTP routes, put them in `routes.py` and include the +router in `IntegrationPackageSpec.routers`. + +Routers are mounted automatically by `api/routes/main.py` through `all_routers()`. +Do not edit `routes/main.py` for per-integration route wiring. + +## Import Discipline + +Keep package import side effects light. + +The integration loader runs during: + +- node-type/spec enumeration +- tests +- route startup +- registry access + +So avoid top-level imports that require environment variables, network access, +or heavyweight initialization when possible. Prefer lazy imports inside +`run_completion()` / `create_runtime_sessions()` if the dependency is optional or +environment-sensitive. + +## Testing Expectations + +At minimum, new integrations should add coverage for: + +- node model validation +- generated spec/example validity +- secret masking + masked round-trip preservation if secrets exist +- runtime snapshot creation if live collectors exist +- completion handler happy path and disabled-node skip path + +If you change shared integration machinery, test the framework in the generic +code path, not only the concrete integration. diff --git a/api/services/integrations/__init__.py b/api/services/integrations/__init__.py index e69de29..f1483d6 100644 --- a/api/services/integrations/__init__.py +++ b/api/services/integrations/__init__.py @@ -0,0 +1,39 @@ +from api.services.integrations.base import ( + IntegrationCompletionContext, + IntegrationNodeRegistration, + IntegrationPackageSpec, + IntegrationRuntimeContext, + IntegrationRuntimeSession, +) +from api.services.integrations.registry import ( + all_node_specs, + all_packages, + all_routers, + create_runtime_sessions, + get_node_data_model, + get_node_registration, + get_node_secret_fields, + get_node_spec, + has_completion_handlers, + register_package, + run_completion_handlers, +) + +__all__ = [ + "IntegrationCompletionContext", + "IntegrationNodeRegistration", + "IntegrationPackageSpec", + "IntegrationRuntimeContext", + "IntegrationRuntimeSession", + "all_node_specs", + "all_packages", + "all_routers", + "create_runtime_sessions", + "get_node_data_model", + "get_node_registration", + "get_node_secret_fields", + "get_node_spec", + "has_completion_handlers", + "register_package", + "run_completion_handlers", +] diff --git a/api/services/integrations/base.py b/api/services/integrations/base.py new file mode 100644 index 0000000..b591474 --- /dev/null +++ b/api/services/integrations/base.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Protocol + +from fastapi import APIRouter + +from api.services.workflow.node_data import BaseNodeData +from api.services.workflow.node_specs._base import NodeSpec + + +class IntegrationRuntimeSession(Protocol): + name: str + + def attach(self, task: Any) -> None: ... + + async def on_call_finished( + self, + *, + gathered_context: dict[str, Any], + ) -> dict[str, Any] | None: ... + + +@dataclass(frozen=True) +class IntegrationRuntimeContext: + workflow_run_id: int + workflow_run: Any + workflow_graph: Any + run_definition: Any + user_config: Any + is_realtime: bool + context_messages_provider: Callable[[], list[dict[str, Any]]] + + +@dataclass(frozen=True) +class IntegrationCompletionContext: + workflow_run_id: int + workflow_run: Any + workflow_definition: dict[str, Any] + definition_id: int | None + organization_id: int + public_token: str | None + + +RuntimeFactory = Callable[ + [IntegrationRuntimeContext], + list[IntegrationRuntimeSession], +] +CompletionHandler = Callable[ + [list[dict[str, Any]], IntegrationCompletionContext], + Awaitable[dict[str, Any]], +] + + +@dataclass(frozen=True) +class IntegrationNodeRegistration: + type_name: str + data_model: type[BaseNodeData] + node_spec: NodeSpec + sensitive_fields: tuple[str, ...] = () + + +@dataclass(frozen=True) +class IntegrationPackageSpec: + name: str + nodes: tuple[IntegrationNodeRegistration, ...] = () + routers: tuple[APIRouter, ...] = () + create_runtime_sessions: RuntimeFactory | None = None + run_completion: CompletionHandler | None = None diff --git a/api/services/integrations/loader.py b/api/services/integrations/loader.py new file mode 100644 index 0000000..c5dca70 --- /dev/null +++ b/api/services/integrations/loader.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import importlib +import pkgutil + +_INTERNAL_MODULES = {"base", "loader", "registry"} +_loaded = False + + +def ensure_integrations_loaded() -> None: + global _loaded + if _loaded: + return + + package = importlib.import_module("api.services.integrations") + for module_info in pkgutil.iter_modules(package.__path__): + if module_info.name in _INTERNAL_MODULES: + continue + importlib.import_module(f"{package.__name__}.{module_info.name}") + + _loaded = True diff --git a/api/services/integrations/nango.py b/api/services/integrations/nango.py deleted file mode 100644 index ce96861..0000000 --- a/api/services/integrations/nango.py +++ /dev/null @@ -1,253 +0,0 @@ -import hashlib -import json -import os -from typing import Any, Dict - -import httpx -from fastapi import HTTPException -from loguru import logger -from pydantic import BaseModel - -from api.db import db_client - -NANGO_ALLOWED_INTEGRATIONS = [ - i.strip() for i in os.environ.get("NANGO_ALLOWED_INTEGRATIONS", "slack").split(",") -] - - -class NangoWebhookRequest(BaseModel): - type: str - connectionId: str - providerConfigKey: str - authMode: str - provider: str - environment: str - operation: str - endUser: dict # Contains endUserId and organizationId - success: bool - - -class NangoService: - def __init__(self): - self.base_url = "https://api.nango.dev" - self.secret_key = os.getenv("NANGO_API_KEY") - - def _verify_webhook_signature( - self, request_body: str, signature: str = None - ) -> bool: - """ - Verify the webhook signature using SHA256 hash. - - Args: - request_body: The raw request body as string - signature: The signature from request headers (optional for now) - - Returns: - True if signature is valid - """ - expected_signature = self.secret_key + request_body - expected_hash = hashlib.sha256(expected_signature.encode("utf-8")).hexdigest() - return expected_hash == signature - - async def create_session( - self, user_id: str, organization_id: int - ) -> Dict[str, Any]: - """ - Create a Nango session for the given user and organization. - - Args: - user_id: The end user ID - organization_id: The organization ID - - Returns: - Response from Nango API - """ - if not self.secret_key: - raise ValueError("NANGO_SECRET_KEY environment variable is not set") - - headers = { - "Authorization": f"Bearer {self.secret_key}", - "Content-Type": "application/json", - } - - payload = { - "end_user": {"id": user_id}, - "organization": {"id": str(organization_id)}, - "allowed_integrations": NANGO_ALLOWED_INTEGRATIONS, - } - - async with httpx.AsyncClient() as client: - response = await client.post( - f"{self.base_url}/connect/sessions", headers=headers, json=payload - ) - - if response.status_code != 201: - raise httpx.HTTPStatusError( - f"Nango API error: {response.status_code}", - request=response.request, - response=response, - ) - - return response.json() - - async def process_webhook( - self, raw_body: bytes, signature: str = None - ) -> Dict[str, str]: - """ - Process incoming Nango webhook request. - - Args: - raw_body: The raw request body as bytes - signature: Optional signature from request headers - - Returns: - Dict with status and message - """ - # Decode and parse the request body - try: - body_text = raw_body.decode("utf-8") - webhook_json = json.loads(body_text) if body_text else {} - logger.debug(f"received webhook from nango: {webhook_json}") - except json.JSONDecodeError as e: - logger.error(f"JSON decode error: {e} body_text: {body_text}") - raise HTTPException(status_code=400, detail=f"Invalid JSON: {str(e)}") - - # Verify webhook signature - if not self._verify_webhook_signature(body_text, signature): - raise HTTPException(status_code=401, detail="Invalid webhook signature") - - # Parse webhook data - try: - webhook_data = NangoWebhookRequest(**webhook_json) - except Exception as e: - logger.error(f"Failed to parse webhook data: {e}") - raise HTTPException( - status_code=400, detail=f"Invalid webhook format: {str(e)}" - ) - - # Extract user and organization IDs from the webhook payload - end_user = webhook_data.endUser - if ( - not end_user - or "endUserId" not in end_user - or "organizationId" not in end_user - ): - raise HTTPException( - status_code=400, detail="Missing endUser information in webhook payload" - ) - - user_id = int(end_user["endUserId"]) - organization_id = int(end_user["organizationId"]) - - # Use the connectionId as the integration_id since it's unique per integration - integration_id = webhook_data.connectionId - - # Initialize connection_details - connection_details = {} - - # Fetch connection details if type is auth and provider is slack - if webhook_data.type == "auth": - connection_details = await self._fetch_connection_details( - integration_id, webhook_data.provider - ) - - # Create the integration in the database - integration = await db_client.create_integration( - integration_id=integration_id, - organisation_id=organization_id, - provider=webhook_data.provider, - created_by=user_id, - is_active=True, - connection_details=connection_details, - ) - - return { - "status": "success", - "message": f"Integration created successfully with ID: {integration.id}", - } - - async def _fetch_connection_details( - self, connection_id: str, provider_key: str - ) -> Dict[str, Any]: - """ - Fetch connection details from Nango API for a given connection ID. - - Args: - connection_id: The connection ID from the webhook - - Returns: - Connection details as a dictionary - """ - - headers = { - "Authorization": f"Bearer {self.secret_key}", - "Content-Type": "application/json", - } - - url = f"{self.base_url}/connection/{connection_id}/?provider_config_key={provider_key}" - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - - if response.status_code != 200: - logger.error( - f"Failed to fetch connection details: {response.status_code} - {response.text}" - ) - raise httpx.HTTPStatusError( - f"Nango API error while fetching connection: {response.status_code}", - request=response.request, - response=response, - ) - - connection_details = response.json() - return connection_details - - except httpx.HTTPError as e: - logger.error(f"HTTP error while fetching connection details: {e}") - # Return empty dict if API call fails, but log the error - return {} - - async def get_access_token( - self, connection_id: str, provider_config_key: str - ) -> Dict[str, Any]: - """ - Get the latest access token for a connection from Nango. - - Args: - connection_id: The connection ID - provider_config_key: The provider config key (e.g., 'google-sheet') - - Returns: - Dict containing access token and other connection details - """ - headers = { - "Authorization": f"Bearer {self.secret_key}", - "Content-Type": "application/json", - } - - url = f"{self.base_url}/connection/{connection_id}?provider_config_key={provider_config_key}" - - async with httpx.AsyncClient() as client: - try: - response = await client.get(url, headers=headers) - - if response.status_code != 200: - logger.error( - f"Failed to get access token: {response.status_code} - {response.text}" - ) - raise httpx.HTTPStatusError( - f"Nango API error: {response.status_code}", - request=response.request, - response=response, - ) - - return response.json() - - except httpx.HTTPError as e: - logger.error(f"HTTP error while getting access token: {e}") - raise - - -# Create a singleton instance -nango_service = NangoService() diff --git a/api/services/integrations/registry.py b/api/services/integrations/registry.py new file mode 100644 index 0000000..e85b6c9 --- /dev/null +++ b/api/services/integrations/registry.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from typing import Any + +from api.services.integrations.base import ( + IntegrationCompletionContext, + IntegrationNodeRegistration, + IntegrationPackageSpec, + IntegrationRuntimeContext, +) +from api.services.workflow.node_data import BaseNodeData + +_PACKAGE_REGISTRY: dict[str, IntegrationPackageSpec] = {} + + +def register_package(spec: IntegrationPackageSpec) -> IntegrationPackageSpec: + existing = _PACKAGE_REGISTRY.get(spec.name) + if existing is not None and existing is not spec: + raise ValueError( + f"Duplicate integration package registration for {spec.name!r}" + ) + _PACKAGE_REGISTRY[spec.name] = spec + return spec + + +def _ensure_loaded() -> None: + from api.services.integrations.loader import ensure_integrations_loaded + + ensure_integrations_loaded() + + +def all_packages() -> list[IntegrationPackageSpec]: + _ensure_loaded() + return [_PACKAGE_REGISTRY[name] for name in sorted(_PACKAGE_REGISTRY)] + + +def get_package(name: str) -> IntegrationPackageSpec | None: + _ensure_loaded() + return _PACKAGE_REGISTRY.get(name) + + +def get_node_registration(type_name: str) -> IntegrationNodeRegistration | None: + _ensure_loaded() + for package in _PACKAGE_REGISTRY.values(): + for node in package.nodes: + if node.type_name == type_name: + return node + return None + + +def get_node_data_model(type_name: str) -> type[BaseNodeData] | None: + registration = get_node_registration(type_name) + return registration.data_model if registration else None + + +def get_node_spec(type_name: str): + registration = get_node_registration(type_name) + return registration.node_spec if registration else None + + +def get_node_secret_fields(type_name: str) -> tuple[str, ...]: + registration = get_node_registration(type_name) + return registration.sensitive_fields if registration else () + + +def all_node_specs(): + _ensure_loaded() + specs = [] + for package in all_packages(): + specs.extend(node.node_spec for node in package.nodes) + return specs + + +def all_routers(): + _ensure_loaded() + routers = [] + for package in all_packages(): + routers.extend(package.routers) + return routers + + +def create_runtime_sessions( + context: IntegrationRuntimeContext, +): + _ensure_loaded() + sessions = [] + for package in all_packages(): + if package.create_runtime_sessions is None: + continue + sessions.extend(package.create_runtime_sessions(context)) + return sessions + + +def iter_completion_packages( + workflow_definition: dict[str, Any], +): + _ensure_loaded() + nodes = workflow_definition.get("nodes", []) if workflow_definition else [] + for package in all_packages(): + node_types = {node.type_name for node in package.nodes} + package_nodes = [ + node + for node in nodes + if isinstance(node, dict) and node.get("type") in node_types + ] + if package_nodes: + yield package, package_nodes + + +def has_completion_handlers(workflow_definition: dict[str, Any]) -> bool: + return any( + package.run_completion is not None + for package, _nodes in iter_completion_packages(workflow_definition) + ) + + +async def run_completion_handlers( + *, + context: IntegrationCompletionContext, +) -> dict[str, Any]: + results: dict[str, Any] = {} + 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) + if package_result: + results.update(package_result) + return results diff --git a/api/services/integrations/tuner/__init__.py b/api/services/integrations/tuner/__init__.py new file mode 100644 index 0000000..a37288c --- /dev/null +++ b/api/services/integrations/tuner/__init__.py @@ -0,0 +1,19 @@ +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="tuner", + nodes=(NODE,), + create_runtime_sessions=create_runtime_sessions, + run_completion=run_completion, + ) +) + +__all__ = ["PACKAGE"] diff --git a/api/services/integrations/tuner/client.py b/api/services/integrations/tuner/client.py new file mode 100644 index 0000000..18e16cb --- /dev/null +++ b/api/services/integrations/tuner/client.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import Any + +import httpx +from loguru import logger +from pydantic import BaseModel, field_validator + + +class TunerDeliveryConfig(BaseModel): + base_url: str + api_key: str + workspace_id: int + agent_id: str + + @field_validator("api_key", "agent_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 + + @field_validator("workspace_id") + @classmethod + def _workspace_must_be_positive(cls, value: int) -> int: + if value <= 0: + raise ValueError("must be a positive integer") + return value + + +async def post_call( + config: TunerDeliveryConfig, + payload: dict[str, Any], +) -> dict[str, Any]: + url = ( + f"{config.base_url}/api/v1/public/call" + f"?workspace_id={config.workspace_id}" + f"&agent_remote_identifier={config.agent_id}" + ) + headers = {"Authorization": f"Bearer {config.api_key}"} + + logger.info( + "[tuner] posting completed call {} to workspace {} / agent {}", + payload.get("call_id"), + config.workspace_id, + config.agent_id, + ) + + async with httpx.AsyncClient(timeout=10) as client: + response = await client.post(url, json=payload, headers=headers) + + if response.status_code == 409: + logger.info("[tuner] call {} already exists in tuner", payload.get("call_id")) + return {"status": "duplicate", "status_code": response.status_code} + + if response.status_code >= 400: + logger.error( + "[tuner] POST failed for call {} with status {}: {}", + payload.get("call_id"), + response.status_code, + response.text[:200], + ) + + response.raise_for_status() + + logger.info( + "[tuner] POST succeeded for call {} with status {}", + payload.get("call_id"), + response.status_code, + ) + return {"status": "delivered", "status_code": response.status_code} diff --git a/api/services/integrations/tuner/collector.py b/api/services/integrations/tuner/collector.py new file mode 100644 index 0000000..73dd410 --- /dev/null +++ b/api/services/integrations/tuner/collector.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import time +from collections import deque +from dataclasses import dataclass +from typing import Any, Callable + +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 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 + + +def mode_to_tuner_call_type(mode: str | None) -> str: + if mode in { + WorkflowRunMode.WEBRTC.value, + WorkflowRunMode.SMALLWEBRTC.value, + }: + return "web_call" + return "phone_call" + + +class TunerCollector(BaseObserver): + """Collect runtime call metadata and build a deferred Tuner payload.""" + + def __init__( + self, + *, + workflow_run_id: int, + call_type: str, + asr_model: str = "", + 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) + + def attach_context(self, provider: Callable[[], list[dict[str, Any]]]) -> None: + self._context_provider = provider + + 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, + ) + return payload.to_dict() diff --git a/api/services/integrations/tuner/completion.py b/api/services/integrations/tuner/completion.py new file mode 100644 index 0000000..f32c738 --- /dev/null +++ b/api/services/integrations/tuner/completion.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import copy +from datetime import UTC, datetime +from typing import Any + +from loguru import logger + +from api.constants import BACKEND_API_ENDPOINT, TUNER_BASE_URL +from api.services.integrations.base import IntegrationCompletionContext + +from .client import TunerDeliveryConfig, post_call +from .collector import TUNER_RECORDING_PLACEHOLDER +from .node import TunerNodeData + + +def _build_recording_url( + context: IntegrationCompletionContext, +) -> str | None: + workflow_run = context.workflow_run + if context.public_token: + base_url = f"{BACKEND_API_ENDPOINT}/api/v1/public/download/workflow/{context.public_token}" + return f"{base_url}/recording" if workflow_run.recording_url else None + return workflow_run.recording_url + + +async def run_completion( + nodes: list[dict[str, Any]], + context: IntegrationCompletionContext, +) -> dict[str, Any]: + results: dict[str, Any] = {} + payload_snapshot = (context.workflow_run.logs or {}).get("tuner_payload") + recording_url = _build_recording_url(context) or TUNER_RECORDING_PLACEHOLDER + + for node in nodes: + node_id = node.get("id", "unknown") + try: + tuner_data = TunerNodeData.model_validate(node.get("data", {})) + except Exception as exc: + logger.warning(f"Tuner node #{node_id} failed validation, skipping: {exc}") + results[f"tuner_{node_id}"] = {"error": "validation_failed"} + continue + + if not tuner_data.tuner_enabled: + logger.debug(f"Tuner node '{tuner_data.name}' is disabled, skipping") + continue + + if not payload_snapshot: + logger.warning( + f"Tuner payload snapshot missing for node '{tuner_data.name}' (#{node_id})" + ) + results[f"tuner_{node_id}"] = {"error": "missing_payload_snapshot"} + continue + + payload = copy.deepcopy(payload_snapshot) + payload["recording_url"] = recording_url + + try: + config = TunerDeliveryConfig( + base_url=TUNER_BASE_URL, + api_key=tuner_data.tuner_api_key or "", + workspace_id=tuner_data.tuner_workspace_id or 0, + agent_id=tuner_data.tuner_agent_id or "", + ) + delivery = await post_call(config, payload) + results[f"tuner_{node_id}"] = { + **delivery, + "workspace_id": tuner_data.tuner_workspace_id, + "agent_id": tuner_data.tuner_agent_id, + "exported_at": datetime.now(UTC).isoformat(), + } + except Exception as exc: + logger.error(f"Tuner export failed for node '{tuner_data.name}': {exc}") + results[f"tuner_{node_id}"] = {"error": str(exc)} + + return results diff --git a/api/services/integrations/tuner/node.py b/api/services/integrations/tuner/node.py new file mode 100644 index 0000000..d603730 --- /dev/null +++ b/api/services/integrations/tuner/node.py @@ -0,0 +1,139 @@ +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="tuner", + display_name="Tuner", + description="Export the completed call to Tuner for Agent Observability", + llm_hint=( + "Tuner is a post-call observability export. It does not participate in the " + "conversation graph and should not be connected to other nodes." + ), + category=NodeCategory.integration, + icon="Activity", + examples=[ + NodeExample( + name="tuner_export", + data={ + "name": "Primary Tuner Export", + "tuner_enabled": True, + "tuner_agent_id": "sales-bot-prod", + "tuner_workspace_id": 42, + "tuner_api_key": "tuner_live_xxxxxxxx", + }, + ) + ], + graph_constraints=GraphConstraints( + min_incoming=0, + max_incoming=0, + min_outgoing=0, + max_outgoing=0, + ), + property_order=( + "name", + "tuner_enabled", + "tuner_agent_id", + "tuner_workspace_id", + "tuner_api_key", + ), + field_overrides={ + "name": { + "spec_default": "Tuner", + "description": "Short identifier for this Tuner export configuration.", + }, + "tuner_enabled": { + "display_name": "Enabled", + "description": "When false, Dograh skips exporting this call to Tuner.", + }, + "tuner_agent_id": { + "display_name": "Tuner Agent ID", + "description": "The agent identifier registered in your Tuner workspace.", + "required": True, + }, + "tuner_workspace_id": { + "display_name": "Tuner Workspace ID", + "description": "Your numeric Tuner workspace ID.", + "required": True, + "min_value": 1, + }, + "tuner_api_key": { + "display_name": "Tuner API Key", + "description": "Bearer token used when posting completed calls to Tuner.", + "required": True, + }, + }, +) +class TunerNodeData(BaseNodeData): + tuner_enabled: bool = spec_field( + default=True, + ui_type=PropertyType.boolean, + display_name="Enabled", + description="When false, Dograh skips exporting this call to Tuner.", + ) + tuner_agent_id: str | None = spec_field( + default=None, + ui_type=PropertyType.string, + display_name="Tuner Agent ID", + description="The agent identifier registered in your Tuner workspace.", + ) + tuner_workspace_id: int | None = spec_field( + default=None, + gt=0, + ui_type=PropertyType.number, + display_name="Tuner Workspace ID", + description="Your numeric Tuner workspace ID.", + ) + tuner_api_key: str | None = spec_field( + default=None, + ui_type=PropertyType.string, + display_name="Tuner API Key", + description="Bearer token used when posting completed calls to Tuner.", + ) + + @model_validator(mode="after") + def _validate_enabled_config(self): + if not self.tuner_enabled: + return self + + missing: list[str] = [] + if not self.tuner_agent_id or not self.tuner_agent_id.strip(): + missing.append("tuner_agent_id") + if self.tuner_workspace_id is None: + missing.append("tuner_workspace_id") + if not self.tuner_api_key or not self.tuner_api_key.strip(): + missing.append("tuner_api_key") + + if missing: + fields = ", ".join(missing) + raise ValueError( + f"Tuner node is enabled but missing required fields: {fields}" + ) + + return self + + +SPEC = build_spec(TunerNodeData) + + +NODE = IntegrationNodeRegistration( + type_name="tuner", + data_model=TunerNodeData, + node_spec=SPEC, + sensitive_fields=("tuner_api_key",), +) diff --git a/api/services/integrations/tuner/runtime.py b/api/services/integrations/tuner/runtime.py new file mode 100644 index 0000000..9c8ae08 --- /dev/null +++ b/api/services/integrations/tuner/runtime.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from typing import Any + +from api.services.configuration.registry import ServiceProviders +from api.services.integrations.base import ( + IntegrationRuntimeContext, + IntegrationRuntimeSession, +) + +from .collector import TunerCollector, mode_to_tuner_call_type + + +def _format_model_label(provider: str | None, model: str | None) -> str: + if provider and model: + return f"{provider}/{model}" + if model: + return model + return provider or "" + + +def _resolve_model_labels(context: IntegrationRuntimeContext) -> tuple[str, str, str]: + user_config = context.user_config + + if context.is_realtime and user_config.realtime: + realtime_provider = user_config.realtime.provider + realtime_model = user_config.realtime.model + llm_model = _format_model_label(realtime_provider, realtime_model) + if realtime_provider in { + ServiceProviders.GOOGLE_REALTIME.value, + ServiceProviders.GOOGLE_VERTEX_REALTIME.value, + ServiceProviders.OPENAI_REALTIME.value, + }: + return "", llm_model, "" + return "", llm_model, "" + + return ( + _format_model_label( + getattr(user_config.stt, "provider", None), + getattr(user_config.stt, "model", None), + ), + _format_model_label( + getattr(user_config.llm, "provider", None), + getattr(user_config.llm, "model", None), + ), + _format_model_label( + getattr(user_config.tts, "provider", None), + getattr(user_config.tts, "model", None), + ), + ) + + +class TunerRuntimeSession(IntegrationRuntimeSession): + name = "tuner" + + def __init__(self, collector: TunerCollector) -> None: + self._collector = collector + + 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) + + async def on_call_finished( + self, + *, + gathered_context: dict[str, Any], + ) -> dict[str, Any] | None: + self._collector.set_disconnection_reason( + gathered_context.get("call_disposition") + ) + payload = self._collector.build_payload_snapshot() + if payload is None: + return None + return {"tuner_payload": payload} + + +def create_runtime_sessions( + context: IntegrationRuntimeContext, +) -> list[IntegrationRuntimeSession]: + tuner_nodes = [ + node + for node in context.workflow_graph.nodes.values() + if node.node_type == "tuner" and getattr(node.data, "tuner_enabled", True) + ] + if not tuner_nodes: + return [] + + asr_model, llm_model, tts_model = _resolve_model_labels(context) + + collector = TunerCollector( + workflow_run_id=context.workflow_run_id, + call_type=mode_to_tuner_call_type(context.workflow_run.mode), + asr_model=asr_model, + llm_model=llm_model, + tts_model=tts_model, + agent_version=getattr(context.run_definition, "version_number", None), + ) + collector.attach_context(context.context_messages_provider) + + return [TunerRuntimeSession(collector)] diff --git a/api/services/looptalk/__init__.py b/api/services/looptalk/__init__.py deleted file mode 100644 index f959ec5..0000000 --- a/api/services/looptalk/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .orchestrator import LoopTalkTestOrchestrator - -__all__ = ["LoopTalkTestOrchestrator"] diff --git a/api/services/looptalk/audio_streamer.py b/api/services/looptalk/audio_streamer.py deleted file mode 100644 index 0acdb22..0000000 --- a/api/services/looptalk/audio_streamer.py +++ /dev/null @@ -1,220 +0,0 @@ -""" -Audio streaming processor for LoopTalk real-time audio monitoring. - -This processor captures audio from both actor and adversary agents and streams -it to connected WebRTC clients for real-time monitoring. -""" - -import asyncio -from typing import Dict, Set - -from loguru import logger -from pipecat.audio.utils import mix_audio -from pipecat.frames.frames import ( - Frame, - InputAudioRawFrame, - OutputAudioRawFrame, -) -from pipecat.processors.frame_processor import FrameDirection, FrameProcessor - - -class LoopTalkAudioStreamer(FrameProcessor): - """ - Processes audio frames from LoopTalk conversations and streams to WebRTC clients. - - This processor sits in the pipeline and captures all audio frames, then - forwards them to connected WebRTC clients for real-time monitoring. - """ - - def __init__( - self, - test_session_id: str, - role: str, # "actor" or "adversary" - **kwargs, - ): - super().__init__(**kwargs) - self._test_session_id = test_session_id - self._role = role - self._listeners: Set[asyncio.Queue] = set() - self._sample_rate = 16000 # Default sample rate - self._num_channels = 1 - - def add_listener(self, queue: asyncio.Queue): - """Add a listener queue for streaming audio.""" - self._listeners.add(queue) - - def remove_listener(self, queue: asyncio.Queue): - """Remove a listener queue.""" - self._listeners.discard(queue) - - async def process_frame(self, frame: Frame, direction: FrameDirection): - """Process audio frames and stream to listeners.""" - await super().process_frame(frame, direction) - - # Capture both input and output audio - if isinstance(frame, (InputAudioRawFrame, OutputAudioRawFrame)): - # Extract audio data - audio_data = frame.audio - sample_rate = frame.sample_rate - num_channels = frame.num_channels - - # Store sample rate for reference - if sample_rate: - self._sample_rate = sample_rate - if num_channels: - self._num_channels = num_channels - - # Stream to all listeners - if self._listeners and audio_data: - # Create a packet with metadata - packet = { - "test_session_id": self._test_session_id, - "role": self._role, - "audio": audio_data, - "sample_rate": sample_rate, - "num_channels": num_channels, - "is_input": isinstance(frame, InputAudioRawFrame), - } - - # Send to all listeners without blocking - for queue in list(self._listeners): - try: - queue.put_nowait(packet) - except asyncio.QueueFull: - logger.warning( - f"Audio queue full for session {self._test_session_id}" - ) - except Exception as e: - logger.error(f"Error streaming audio: {e}") - self._listeners.discard(queue) - elif self._listeners and not audio_data: - logger.warning( - f"Audio streamer {self._role} received frame with no audio data" - ) - elif audio_data and not self._listeners: - # This is expected early in the session before WebSocket connects - pass - - # Always forward the frame - await self.push_frame(frame, direction) - - -class LoopTalkAudioMixer: - """ - Mixes audio from actor and adversary streams for combined playback. - - This class manages the mixing of two audio streams (actor and adversary) - to create a combined audio stream for monitoring. - """ - - def __init__(self, test_session_id: str): - self._test_session_id = test_session_id - self._actor_buffer = bytearray() - self._adversary_buffer = bytearray() - self._listeners: Set[asyncio.Queue] = set() - self._sample_rate = 16000 - self._num_channels = 1 - self._buffer_size = 8000 # 0.5 seconds at 16kHz - - def add_listener(self, queue: asyncio.Queue): - """Add a listener for mixed audio.""" - self._listeners.add(queue) - - def remove_listener(self, queue: asyncio.Queue): - """Remove a listener.""" - self._listeners.discard(queue) - - async def add_audio( - self, role: str, audio_data: bytes, sample_rate: int, num_channels: int - ): - """Add audio data from actor or adversary.""" - if role == "actor": - self._actor_buffer.extend(audio_data) - elif role == "adversary": - self._adversary_buffer.extend(audio_data) - - # Update audio parameters - self._sample_rate = sample_rate - self._num_channels = num_channels - - # Check if we have enough data to mix - await self._check_and_mix() - - async def _check_and_mix(self): - """Check buffers and mix audio when enough data is available.""" - # Mix when we have at least buffer_size in both buffers - while ( - len(self._actor_buffer) >= self._buffer_size - and len(self._adversary_buffer) >= self._buffer_size - ): - # Extract chunks - actor_chunk = bytes(self._actor_buffer[: self._buffer_size]) - adversary_chunk = bytes(self._adversary_buffer[: self._buffer_size]) - - # Remove from buffers - del self._actor_buffer[: self._buffer_size] - del self._adversary_buffer[: self._buffer_size] - - # Mix audio - mixed_audio = mix_audio(actor_chunk, adversary_chunk) - - # Stream to listeners - if self._listeners and mixed_audio: - packet = { - "test_session_id": self._test_session_id, - "role": "mixed", - "audio": mixed_audio, - "sample_rate": self._sample_rate, - "num_channels": self._num_channels, - "is_input": False, - } - - for queue in list(self._listeners): - try: - queue.put_nowait(packet) - except asyncio.QueueFull: - logger.warning( - f"Mixed audio queue full for session {self._test_session_id}" - ) - except Exception as e: - logger.error(f"Error streaming mixed audio: {e}") - self._listeners.discard(queue) - - -# Global registry for audio streamers and mixers -_audio_streamers: Dict[str, Dict[str, LoopTalkAudioStreamer]] = {} -_audio_mixers: Dict[str, LoopTalkAudioMixer] = {} - - -def get_or_create_audio_streamer( - test_session_id: str, role: str -) -> LoopTalkAudioStreamer: - """Get or create an audio streamer for a test session and role.""" - if test_session_id not in _audio_streamers: - _audio_streamers[test_session_id] = {} - - if role not in _audio_streamers[test_session_id]: - _audio_streamers[test_session_id][role] = LoopTalkAudioStreamer( - test_session_id=test_session_id, role=role - ) - - return _audio_streamers[test_session_id][role] - - -def get_or_create_audio_mixer(test_session_id: str) -> LoopTalkAudioMixer: - """Get or create an audio mixer for a test session.""" - if test_session_id not in _audio_mixers: - _audio_mixers[test_session_id] = LoopTalkAudioMixer(test_session_id) - - return _audio_mixers[test_session_id] - - -def cleanup_audio_streamers(test_session_id: str): - """Clean up audio streamers and mixers for a test session.""" - if test_session_id in _audio_streamers: - del _audio_streamers[test_session_id] - - if test_session_id in _audio_mixers: - del _audio_mixers[test_session_id] - - logger.info(f"Cleaned up audio streamers for test session {test_session_id}") diff --git a/api/services/looptalk/core/__init__.py b/api/services/looptalk/core/__init__.py deleted file mode 100644 index 6e4ca1c..0000000 --- a/api/services/looptalk/core/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Core modules for LoopTalk orchestration.""" diff --git a/api/services/looptalk/core/pipeline_builder.py b/api/services/looptalk/core/pipeline_builder.py deleted file mode 100644 index 49adc72..0000000 --- a/api/services/looptalk/core/pipeline_builder.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Pipeline building logic for LoopTalk agents.""" - -from typing import Any, Dict - -from loguru import logger -from pipecat.pipeline.pipeline import Pipeline -from pipecat.processors.aggregators.llm_response_universal import ( - LLMContextAggregatorPair, -) - -from api.db.db_client import DBClient -from api.services.looptalk.audio_streamer import get_or_create_audio_streamer -from api.services.looptalk.internal_transport import InternalTransport -from api.services.pipecat.audio_config import AudioConfig -from api.services.pipecat.pipeline_builder import ( - create_pipeline_components, - create_pipeline_task, -) -from api.services.pipecat.pipeline_engine_callbacks_processor import ( - PipelineEngineCallbacksProcessor, -) -from api.services.pipecat.service_factory import ( - create_llm_service, - create_stt_service, - create_tts_service, -) -from api.services.workflow.dto import ReactFlowDTO -from api.services.workflow.pipecat_engine import PipecatEngine -from api.services.workflow.workflow_graph import WorkflowGraph - - -class LoopTalkPipelineBuilder: - """Builds pipelines for LoopTalk agents.""" - - def __init__(self, db_client: DBClient): - """Initialize the pipeline builder. - - Args: - db_client: Database client for fetching user configurations - """ - self.db_client = db_client - - async def create_agent_pipeline( - self, - transport: InternalTransport, - workflow: Any, - test_session_id: int, - agent_id: str, - role: str, - ) -> Dict[str, Any]: - """Create a pipeline for an agent (actor or adversary). - - Args: - transport: Internal transport for the agent - workflow: Workflow model from database - test_session_id: ID of the test session - agent_id: Unique identifier for the agent - role: Either "actor" or "adversary" - - Returns: - Dictionary containing pipeline task, engine, and components - """ - # Get user configuration from database - user_config = await self.db_client.get_user_configurations(workflow.user_id) - - # Create pipeline components - audio_config = AudioConfig( - transport_in_sample_rate=16000, - transport_out_sample_rate=16000, - vad_sample_rate=16000, - pipeline_sample_rate=16000, - ) - - # Use published definition for graph + configs - released_def = workflow.released_definition - wf_json = released_def.workflow_json - wf_configs = released_def.workflow_configurations or {} - - # Extract keyterms from workflow configurations - keyterms = None - if wf_configs and "dictionary" in wf_configs: - dictionary = wf_configs["dictionary"] - if dictionary and isinstance(dictionary, str): - keyterms = [ - term.strip() for term in dictionary.split(",") if term.strip() - ] - if keyterms: - logger.info(f"Using {len(keyterms)} keyterms for STT: {keyterms}") - - # Resolve model overrides from the version onto global user config - from api.services.configuration.resolve import resolve_effective_config - - model_overrides = wf_configs.get("model_overrides") - user_config = resolve_effective_config(user_config, model_overrides) - - # Create services - stt = create_stt_service(user_config, audio_config, keyterms=keyterms) - llm = create_llm_service(user_config) - tts = create_tts_service(user_config, audio_config) - - logger.debug(f"Created services for {role}: STT={stt}, LLM={llm}, TTS={tts}") - - # Get workflow graph - workflow_graph = WorkflowGraph(ReactFlowDTO.model_validate(wf_json)) - - # Create engine first (needed for create_pipeline_components) - engine = PipecatEngine( - llm=llm, - workflow=workflow_graph, - call_context_vars={}, - workflow_run_id=None, # LoopTalk doesn't have workflow runs - ) - - # Create pipeline components with audio configuration and engine - audio_buffer, transcript, context = create_pipeline_components( - audio_config, engine - ) - - # Set the context and audio_buffer after creation - engine.set_context(context) - - context_aggregator = LLMContextAggregatorPair(context) - - # Create pipeline engine callback processor - pipeline_engine_callback_processor = PipelineEngineCallbacksProcessor( - max_call_duration_seconds=300, - max_duration_end_task_callback=engine.create_max_duration_callback(), - generation_started_callback=engine.create_generation_started_callback(), - ) - - # Get aggregators - user_context_aggregator = context_aggregator.user() - assistant_context_aggregator = context_aggregator.assistant() - - # Get audio streamer for real-time streaming - audio_streamer = get_or_create_audio_streamer(str(test_session_id), role) - - # Create pipeline with AudioBufferProcessor after transport.output() - pipeline = Pipeline( - [ - transport.input(), - audio_streamer, # Stream audio to connected clients - stt, - transcript.user(), - user_context_aggregator, - llm, - pipeline_engine_callback_processor, - tts, - transport.output(), - audio_buffer, # AudioBufferProcessor - records both input and output audio - transcript.assistant(), - assistant_context_aggregator, - ] - ) - - # Create pipeline task with unique conversation ID for tracing - conversation_id = f"{test_session_id}-{role}-{agent_id}" - task = create_pipeline_task(pipeline, conversation_id, audio_config) - - # Set the task on the engine - engine.set_task(task) - - return { - "task": task, - "engine": engine, - "audio_buffer": audio_buffer, - "transcript": transcript, - "assistant_context_aggregator": assistant_context_aggregator, - "audio_streamer": audio_streamer, - } diff --git a/api/services/looptalk/core/recording_manager.py b/api/services/looptalk/core/recording_manager.py deleted file mode 100644 index 1e58e34..0000000 --- a/api/services/looptalk/core/recording_manager.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Recording management for LoopTalk sessions.""" - -import wave -from pathlib import Path -from typing import Dict, Optional, Tuple - -from loguru import logger - -from api.enums import StorageBackend -from api.services.storage import storage_fs - - -class RecordingManager: - """Manages audio recording and transcript files for LoopTalk sessions.""" - - def __init__(self, base_dir: Path): - """Initialize the recording manager. - - Args: - base_dir: Base directory for temporary recordings - """ - self.base_dir = base_dir - self.base_dir.mkdir(parents=True, exist_ok=True) - - def get_recording_paths(self, test_session_id: int, role: str) -> Dict[str, Path]: - """Get file paths for recordings. - - Args: - test_session_id: ID of the test session - role: Either "actor" or "adversary" - - Returns: - Dictionary with paths for audio, transcript, and temp audio files - """ - session_dir = self.base_dir / f"session_{test_session_id}" - session_dir.mkdir(parents=True, exist_ok=True) - - return { - "audio": session_dir / f"{role}_audio.wav", - "transcript": session_dir / f"{role}_transcript.txt", - "temp_audio": session_dir / f"{role}_audio_temp.pcm", - } - - def convert_pcm_to_wav( - self, - test_session_id: int, - role: str, - sample_rate: int = 16000, - num_channels: int = 1, - ) -> Optional[Path]: - """Convert PCM audio file to WAV format. - - Args: - test_session_id: ID of the test session - role: Either "actor" or "adversary" - sample_rate: Sample rate of the audio - num_channels: Number of audio channels - - Returns: - Path to the WAV file if successful, None otherwise - """ - paths = self.get_recording_paths(test_session_id, role) - - # Check if PCM file exists - if not paths["temp_audio"].exists(): - logger.warning(f"No audio recorded for {role} in session {test_session_id}") - return None - - try: - # Read PCM data - with open(paths["temp_audio"], "rb") as f: - pcm_data = f.read() - - # Write WAV file - with wave.open(str(paths["audio"]), "wb") as wav_file: - wav_file.setnchannels(num_channels) - wav_file.setsampwidth(2) # 16-bit audio - wav_file.setframerate(sample_rate) - wav_file.writeframes(pcm_data) - - # Remove temporary PCM file - paths["temp_audio"].unlink() - - logger.info( - f"Converted audio to WAV for {role} in session {test_session_id}: {paths['audio']}" - ) - return paths["audio"] - - except Exception as e: - logger.error( - f"Failed to convert audio to WAV for {role} in session {test_session_id}: {e}" - ) - return None - - async def upload_recording_to_s3( - self, test_session_id: int, role: str - ) -> Tuple[Optional[str], Optional[str]]: - """Upload recording and transcript to S3. - - Args: - test_session_id: ID of the test session - role: Either "actor" or "adversary" - - Returns: - Tuple of (audio_url, transcript_url) or (None, None) if failed - """ - paths = self.get_recording_paths(test_session_id, role) - audio_url = None - transcript_url = None - - # Import here to avoid circular imports - - current_backend = StorageBackend.get_current_backend() - logger.info( - f"LOOPTALK UPLOAD: Using {current_backend.label} (code: {current_backend.code}) for session {test_session_id}, role: {role}" - ) - - # Upload audio if exists - if paths["audio"].exists(): - audio_key = f"looptalk/recordings/{test_session_id}/{role}_audio.wav" - try: - success = await storage_fs.aupload_file(str(paths["audio"]), audio_key) - if success: - audio_url = audio_key - logger.info( - f"Uploaded {role} audio to {current_backend.label}: {audio_key}" - ) - else: - logger.error( - f"Failed to upload {role} audio to {current_backend.label}" - ) - except Exception as e: - logger.error( - f"Error uploading {role} audio to {current_backend.label}: {e}" - ) - - # Upload transcript if exists - if paths["transcript"].exists(): - transcript_key = ( - f"looptalk/transcripts/{test_session_id}/{role}_transcript.txt" - ) - try: - success = await storage_fs.aupload_file( - str(paths["transcript"]), transcript_key - ) - if success: - transcript_url = transcript_key - logger.info( - f"Uploaded {role} transcript to {current_backend.label}: {transcript_key}" - ) - else: - logger.error( - f"Failed to upload {role} transcript to {current_backend.label}" - ) - except Exception as e: - logger.error( - f"Error uploading {role} transcript to {current_backend.label}: {e}" - ) - - return audio_url, transcript_url - - def cleanup_session_files(self, test_session_id: int): - """Clean up local files for a session. - - Args: - test_session_id: ID of the test session - """ - session_dir = self.base_dir / f"session_{test_session_id}" - if session_dir.exists(): - try: - import shutil - - shutil.rmtree(session_dir) - logger.debug(f"Cleaned up local files for session {test_session_id}") - except Exception as e: - logger.error(f"Failed to clean up session files: {e}") - - def get_recording_info(self, test_session_id: int) -> Dict[str, any]: - """Get information about recordings for a test session. - - Args: - test_session_id: ID of the test session - - Returns: - Dictionary with recording information - """ - session_dir = self.base_dir / f"session_{test_session_id}" - - info = { - "test_session_id": test_session_id, - "recording_dir": str(session_dir), - "files": {}, - } - - for role in ["actor", "adversary"]: - paths = self.get_recording_paths(test_session_id, role) - role_info = {} - - # Check audio file - if paths["audio"].exists(): - role_info["audio"] = { - "path": str(paths["audio"]), - "size_bytes": paths["audio"].stat().st_size, - } - - # Check transcript file - if paths["transcript"].exists(): - role_info["transcript"] = { - "path": str(paths["transcript"]), - "size_bytes": paths["transcript"].stat().st_size, - } - - if role_info: - info["files"][role] = role_info - - return info diff --git a/api/services/looptalk/core/session_manager.py b/api/services/looptalk/core/session_manager.py deleted file mode 100644 index 555e480..0000000 --- a/api/services/looptalk/core/session_manager.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Session management for LoopTalk test sessions.""" - -import asyncio -from datetime import UTC, datetime -from typing import Any, Dict, Optional - -from loguru import logger - - -class SessionManager: - """Manages running LoopTalk test sessions.""" - - def __init__(self): - """Initialize the session manager.""" - self._running_sessions: Dict[int, Dict[str, Any]] = {} - self._disconnect_handlers: Dict[int, asyncio.Task] = {} - - def add_session(self, test_session_id: int, session_info: Dict[str, Any]): - """Add a new session to the manager. - - Args: - test_session_id: ID of the test session - session_info: Dictionary containing session information - """ - self._running_sessions[test_session_id] = session_info - - def get_session(self, test_session_id: int) -> Optional[Dict[str, Any]]: - """Get session information. - - Args: - test_session_id: ID of the test session - - Returns: - Session information dictionary or None if not found - """ - return self._running_sessions.get(test_session_id) - - def remove_session(self, test_session_id: int): - """Remove a session from the manager. - - Args: - test_session_id: ID of the test session - """ - if test_session_id in self._running_sessions: - del self._running_sessions[test_session_id] - - # Cancel any disconnect handler for this session - if test_session_id in self._disconnect_handlers: - handler = self._disconnect_handlers.pop(test_session_id) - if not handler.done(): - handler.cancel() - - def get_active_count(self) -> int: - """Get the number of currently active sessions.""" - return len(self._running_sessions) - - def get_active_info(self) -> Dict[str, Any]: - """Get information about all active sessions.""" - return { - "count": len(self._running_sessions), - "sessions": [ - { - "test_session_id": session_id, - "conversation_id": info["conversation"].id, - "start_time": info["start_time"], - "duration_seconds": int( - (datetime.now(UTC) - info["start_time"]).total_seconds() - ), - } - for session_id, info in self._running_sessions.items() - ], - } - - async def handle_agent_disconnect( - self, test_session_id: int, disconnected_role: str, stop_callback: callable - ): - """Handle when one agent disconnects. - - This will cancel the other agent as well to ensure clean shutdown. - - Args: - test_session_id: ID of the test session - disconnected_role: Role that disconnected ("actor" or "adversary") - stop_callback: Callback to stop the session - """ - logger.info( - f"Handling {disconnected_role} disconnect for session {test_session_id}" - ) - - # Check if we already have a disconnect handler running - if test_session_id in self._disconnect_handlers: - logger.debug( - f"Disconnect handler already running for session {test_session_id}" - ) - return - - # Create a task to handle the disconnect - async def _handle_disconnect(): - try: - # Wait a short time to avoid race conditions - await asyncio.sleep(0.5) - - # Check if session still exists - session_info = self.get_session(test_session_id) - if not session_info: - logger.debug(f"Session {test_session_id} already stopped") - return - - # Stop the session (which will cancel both agents) - logger.info( - f"Stopping session {test_session_id} due to {disconnected_role} disconnect" - ) - await stop_callback(test_session_id) - - except asyncio.CancelledError: - logger.debug( - f"Disconnect handler cancelled for session {test_session_id}" - ) - raise - except Exception as e: - logger.error( - f"Error handling disconnect for session {test_session_id}: {e}" - ) - - # Store the task so we can cancel it if needed - self._disconnect_handlers[test_session_id] = asyncio.create_task( - _handle_disconnect() - ) - - def update_audio_metadata( - self, - test_session_id: int, - role: str, - sample_rate: Optional[int] = None, - num_channels: Optional[int] = None, - ): - """Update audio metadata for a role in a session. - - Args: - test_session_id: ID of the test session - role: Either "actor" or "adversary" - sample_rate: Sample rate of the audio - num_channels: Number of audio channels - """ - if test_session_id not in self._running_sessions: - return - - if "audio_metadata" not in self._running_sessions[test_session_id]: - self._running_sessions[test_session_id]["audio_metadata"] = {} - - if role not in self._running_sessions[test_session_id]["audio_metadata"]: - self._running_sessions[test_session_id]["audio_metadata"][role] = {} - - metadata = self._running_sessions[test_session_id]["audio_metadata"][role] - if sample_rate is not None: - metadata["sample_rate"] = sample_rate - if num_channels is not None: - metadata["num_channels"] = num_channels - - def get_audio_metadata(self, test_session_id: int, role: str) -> Dict[str, Any]: - """Get audio metadata for a role in a session. - - Args: - test_session_id: ID of the test session - role: Either "actor" or "adversary" - - Returns: - Dictionary with sample_rate and num_channels - """ - default = {"sample_rate": 16000, "num_channels": 1} - - if test_session_id not in self._running_sessions: - return default - - metadata = ( - self._running_sessions.get(test_session_id, {}) - .get("audio_metadata", {}) - .get(role, {}) - ) - - return { - "sample_rate": metadata.get("sample_rate", 16000), - "num_channels": metadata.get("num_channels", 1), - } diff --git a/api/services/looptalk/internal_serializer.py b/api/services/looptalk/internal_serializer.py deleted file mode 100644 index 6a94a40..0000000 --- a/api/services/looptalk/internal_serializer.py +++ /dev/null @@ -1,75 +0,0 @@ -# -# Copyright (c) 2024–2025, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -"""Internal frame serializer for agent-to-agent communication.""" - -from loguru import logger -from pipecat.frames.frames import ( - Frame, - InputAudioRawFrame, - OutputAudioRawFrame, -) -from pipecat.serializers.base_serializer import FrameSerializer - - -class InternalFrameSerializer(FrameSerializer): - """Serializer for InternalTransport that filters frames between agents. - - This serializer ensures only audio frames are passed between agents, - preventing control frames from creating infinite loops. - """ - - async def serialize(self, frame: Frame) -> bytes | None: - """Only serialize audio frames for transmission between agents.""" - # Only pass audio frames between agents - if isinstance(frame, OutputAudioRawFrame): - # Use a fixed-size header to avoid parsing issues with binary data - # Format: "AUDIO" (5 bytes) + sample_rate (4 bytes) + num_channels (2 bytes) + audio data - header = b"AUDIO" - sample_rate_bytes = frame.sample_rate.to_bytes(4, byteorder="big") - num_channels_bytes = frame.num_channels.to_bytes(2, byteorder="big") - - serialized = header + sample_rate_bytes + num_channels_bytes + frame.audio - return serialized - - # Don't pass control frames between agents - return None - - async def deserialize(self, data: bytes) -> Frame | None: - """Deserialize audio frames from partner agent.""" - if data.startswith(b"AUDIO"): - try: - # Fixed-size header parsing - # Header: "AUDIO" (5 bytes) + sample_rate (4 bytes) + num_channels (2 bytes) - if len(data) < 11: # Minimum size for header - logger.error( - f"InternalSerializer: Data too short for header: {len(data)} bytes" - ) - return None - - # Extract fixed-size fields - # Skip header validation - we already checked startswith(b"AUDIO") - sample_rate = int.from_bytes(data[5:9], byteorder="big") - num_channels = int.from_bytes(data[9:11], byteorder="big") - - # Extract audio data - everything after the header - audio_data = data[11:] - - # Check if audio data length is valid - if len(audio_data) % 2 != 0: - logger.warning( - f"InternalSerializer: Audio data has odd length: {len(audio_data)}" - ) - - # Convert to InputAudioRawFrame for the receiving agent - return InputAudioRawFrame( - audio=audio_data, num_channels=num_channels, sample_rate=sample_rate - ) - except Exception as e: - logger.error(f"Failed to deserialize audio frame: {e}") - return None - - return None diff --git a/api/services/looptalk/internal_transport.py b/api/services/looptalk/internal_transport.py deleted file mode 100644 index 00bfc93..0000000 --- a/api/services/looptalk/internal_transport.py +++ /dev/null @@ -1,405 +0,0 @@ -# -# Copyright (c) 2024–2025, Daily -# -# SPDX-License-Identifier: BSD 2-Clause License -# - -"""Internal transport for in-memory agent-to-agent communication.""" - -import asyncio -import time -from typing import Dict, Optional, Tuple - -from loguru import logger -from pipecat.frames.frames import ( - CancelFrame, - EndFrame, - InputAudioRawFrame, - OutputAudioRawFrame, - OutputDTMFFrame, - OutputDTMFUrgentFrame, - OutputImageRawFrame, - StartFrame, - StopFrame, -) -from pipecat.processors.frame_processor import FrameDirection -from pipecat.transports.base_input import BaseInputTransport -from pipecat.transports.base_output import BaseOutputTransport -from pipecat.transports.base_transport import BaseTransport, TransportParams - -from api.services.looptalk.internal_serializer import InternalFrameSerializer - - -class InternalInputTransport(BaseInputTransport): - """Input side of internal transport for agent-to-agent communication.""" - - def __init__( - self, - transport: Optional["InternalTransport"], - params: TransportParams, - **kwargs, - ): - """Initialize internal input transport. - - Args: - transport: The parent InternalTransport instance. - params: Transport parameters for configuration. - **kwargs: Additional keyword arguments including latency_seconds. - """ - # Extract latency configuration before passing to parent - self._latency_seconds = kwargs.pop("latency_seconds", 0.0) - - super().__init__(params, **kwargs) - self._transport = transport - self._queue: asyncio.Queue[bytes] = asyncio.Queue() - self._partner: Optional["InternalOutputTransport"] = None - self._running = False - self._connected = False - self._serializer = InternalFrameSerializer() - # Queue for delayed packets (timestamp, data) - self._delayed_queue: asyncio.Queue[Tuple[float, bytes]] = asyncio.Queue() - self._latency_task: Optional[asyncio.Task] = None - - def set_partner(self, partner: "InternalOutputTransport"): - """Connect this input transport to an output transport.""" - self._partner = partner - - async def receive_data(self, data: bytes): - """Receive serialized data from the partner output transport.""" - # logger.debug("received data in input transport") - if self._latency_seconds > 0: - # Add to delayed queue with delivery timestamp - delivery_time = time.monotonic() + self._latency_seconds - await self._delayed_queue.put((delivery_time, data)) - else: - # No latency, put directly in the main queue - await self._queue.put(data) - - async def start(self, frame: StartFrame): - """Start the input transport.""" - self._running = True - await super().start(frame) - await self._serializer.setup(frame) - - # Set transport ready to initialize audio task for VAD processing - await self.set_transport_ready(frame) - - # Trigger on_client_connected event for InternalTransport (only once) - if hasattr(self, "_transport") and self._transport and not self._connected: - self._connected = True - await self._transport._call_event_handler( - "on_client_connected", self._transport - ) - - # Start latency processor if latency is configured - if self._latency_seconds > 0: - self._latency_task = asyncio.create_task(self._latency_processor()) - - asyncio.create_task(self._run()) - - async def stop(self, frame: EndFrame | StopFrame | None = None): - """Stop the input transport.""" - self._running = False - - # Stop latency processor - if self._latency_task: - self._latency_task.cancel() - try: - await self._latency_task - except asyncio.CancelledError: - pass - self._latency_task = None - - await super().stop(frame) - - # Trigger on_client_disconnected event for InternalTransport - if hasattr(self, "_transport") and self._transport: - await self._transport._call_event_handler( - "on_client_disconnected", self._transport - ) - - async def _run(self): - """Main loop to process incoming data.""" - while self._running: - try: - data = await asyncio.wait_for(self._queue.get(), timeout=0.1) - - # Deserialize the data - frame = await self._serializer.deserialize(data) - if frame: - if isinstance(frame, InputAudioRawFrame): - # Debug received audio - try: - import numpy as np - - # Check if audio length is valid for int16 - if len(frame.audio) % 2 != 0: - logger.error( - f"InternalInput: Audio buffer has odd length: {len(frame.audio)}" - ) - else: - audio_array = np.frombuffer(frame.audio, dtype=np.int16) - # logger.debug(f"InternalInput: Received audio - size: {len(frame.audio)} bytes, " - # f"samples: {len(audio_array)}, min: {audio_array.min()}, max: {audio_array.max()}, " - # f"sample_rate: {frame.sample_rate}") - except Exception as e: - logger.error(f"InternalInput: Error analyzing audio: {e}") - - # Use the base class's audio processing which includes VAD - await self.push_audio_frame(frame) - else: - # For non-audio frames, push directly - await self.push_frame(frame, FrameDirection.DOWNSTREAM) - - except asyncio.TimeoutError: - continue - except Exception as e: - logger.error(f"Error in internal input transport: {e}") - - async def _latency_processor(self): - """Process delayed packets and deliver them after the configured latency.""" - logger.info( - f"InternalInput: Started latency processor with {self._latency_seconds}s delay" - ) - - # Use a list to maintain order (we'll process in FIFO order) - pending_packets = [] - - while self._running: - try: - # Get all new packets from the delayed queue (non-blocking) - while True: - try: - packet = self._delayed_queue.get_nowait() - pending_packets.append(packet) - except asyncio.QueueEmpty: - break - - # Process packets that are ready - current_time = time.monotonic() - delivered = [] - - for i, (delivery_time, data) in enumerate(pending_packets): - if current_time >= delivery_time: - # Time to deliver this packet - await self._queue.put(data) - delivered.append(i) - - # Remove delivered packets (in reverse order to maintain indices) - for i in reversed(delivered): - pending_packets.pop(i) - - # Sleep briefly before next check - await asyncio.sleep(0.005) # 5ms for more responsive delivery - - except asyncio.CancelledError: - # Deliver any remaining packets immediately on shutdown - for _, data in pending_packets: - await self._queue.put(data) - break - except Exception as e: - logger.error(f"Error in latency processor: {e}") - await asyncio.sleep(0.01) - - logger.info("InternalInput: Stopped latency processor") - - -class InternalOutputTransport(BaseOutputTransport): - """Output side of internal transport for agent-to-agent communication.""" - - def __init__(self, params: TransportParams, **kwargs): - """Initialize internal output transport. - - Args: - params: Transport parameters for configuration. - **kwargs: Additional keyword arguments. - """ - super().__init__(params, **kwargs) - self._partner: Optional[InternalInputTransport] = None - self._serializer = InternalFrameSerializer() - - # Audio timing synchronization (similar to WebsocketServerOutputTransport) - # _send_interval is the time interval between audio chunks in seconds - self._send_interval = 0 - self._next_send_time = 0 - - def set_partner(self, partner: InternalInputTransport): - """Connect this output transport to an input transport.""" - self._partner = partner - - async def start(self, frame: StartFrame): - """Start the output transport.""" - await super().start(frame) - await self._serializer.setup(frame) - # Calculate the send interval based on audio chunk size (like WebsocketServerOutputTransport) - self._send_interval = ( - self._params.audio_out_10ms_chunks * 10 / 1000 - ) # Convert ms to seconds - await self.set_transport_ready(frame) - - async def write_audio_frame(self, frame: OutputAudioRawFrame): - """Write audio frame to partner through serializer with proper timing.""" - # Debug audio characteristics - # import numpy as np - # audio_array = np.frombuffer(frame.audio, dtype=np.int16) - # logger.debug(f"InternalOutput: Sending audio - type: {type(frame).__name__}, size: {len(frame.audio)} bytes, " - # f"samples: {len(audio_array)}, min: {audio_array.min()}, max: {audio_array.max()}, " - # f"sample_rate: {frame.sample_rate}") - - # Serialize and send the audio first - data = await self._serializer.serialize(frame) - if data and self._partner: - await self._partner.receive_data(data) - - # logger.debug(f"InternalOutput: Sent audio frame to partner") - - # Then simulate audio playback timing (following WebsocketServerOutputTransport pattern) - await self._write_audio_sleep() - - async def write_video_frame(self, _frame: OutputImageRawFrame): - """Internal transport doesn't support video.""" - pass - - async def write_dtmf(self, _frame: OutputDTMFFrame | OutputDTMFUrgentFrame): - """Internal transport doesn't support DTMF.""" - pass - - async def stop(self, frame: EndFrame): - """Stop the output transport and reset timing.""" - await super().stop(frame) - self._next_send_time = 0 - - async def cancel(self, frame: CancelFrame): - """Cancel the output transport and reset timing.""" - await super().cancel(frame) - self._next_send_time = 0 - - async def _write_audio_sleep(self): - """Simulate audio playback timing (following WebsocketServerOutputTransport pattern).""" - # Simulate a clock to ensure audio is sent at real-time pace - current_time = time.monotonic() - sleep_duration = max(0, self._next_send_time - current_time) - await asyncio.sleep(sleep_duration) - if sleep_duration == 0: - self._next_send_time = time.monotonic() + self._send_interval - else: - self._next_send_time += self._send_interval - - -class InternalTransport(BaseTransport): - """Internal transport for in-memory agent-to-agent communication.""" - - def __init__(self, params: TransportParams, **kwargs): - """Initialize internal transport. - - Args: - params: Transport parameters for configuration. - **kwargs: Additional keyword arguments including latency_seconds. - """ - # Extract latency configuration before passing to parent - self._latency_seconds = kwargs.pop("latency_seconds", 0.0) - - super().__init__(**kwargs) - self._params = params - - # Create input and output transports - self._input = InternalInputTransport( - self, - params, - name=self._input_name or f"{self.name}#input", - latency_seconds=self._latency_seconds, - ) - self._output = InternalOutputTransport( - params, name=self._output_name or f"{self.name}#output" - ) - - # Register supported event handlers - self._register_event_handler("on_client_connected") - self._register_event_handler("on_client_disconnected") - - def input(self) -> InternalInputTransport: - """Get the input transport.""" - return self._input - - def output(self) -> InternalOutputTransport: - """Get the output transport.""" - return self._output - - def connect_partner(self, partner: "InternalTransport"): - """Connect this transport to another internal transport.""" - # Connect output of this transport to input of partner - self._output.set_partner(partner._input) - # Connect output of partner to input of this transport - partner._output.set_partner(self._input) - - -class InternalTransportManager: - """Manages multiple internal transport pairs for load testing.""" - - def __init__(self): - """Initialize internal transport manager.""" - self._transport_pairs: Dict[ - str, Tuple[InternalTransport, InternalTransport] - ] = {} - - def create_transport_pair( - self, - test_session_id: str, - actor_params: TransportParams, - adversary_params: TransportParams, - latency_seconds: float = 0.0, - ) -> Tuple[InternalTransport, InternalTransport]: - """Create a connected pair of internal transports. - - Args: - test_session_id: Unique identifier for the test session. - actor_params: Transport parameters for the actor. - adversary_params: Transport parameters for the adversary. - latency_seconds: Simulated network latency in seconds (default: 0.0). - - Returns: - Tuple of (actor_transport, adversary_transport). - """ - # Create actor transport with latency - actor_transport = InternalTransport( - params=actor_params, - name=f"actor-{test_session_id}", - latency_seconds=latency_seconds, - ) - - # Create adversary transport with latency - adversary_transport = InternalTransport( - params=adversary_params, - name=f"adversary-{test_session_id}", - latency_seconds=latency_seconds, - ) - - # Connect them - actor_transport.connect_partner(adversary_transport) - - # Store the pair - self._transport_pairs[test_session_id] = (actor_transport, adversary_transport) - - logger.info( - f"Created internal transport pair for test session: {test_session_id} with {latency_seconds}s latency" - ) - - return actor_transport, adversary_transport - - def get_transport_pair( - self, test_session_id: str - ) -> Optional[Tuple[InternalTransport, InternalTransport]]: - """Get an existing transport pair.""" - return self._transport_pairs.get(test_session_id) - - def remove_transport_pair(self, test_session_id: str): - """Remove a transport pair.""" - if test_session_id in self._transport_pairs: - del self._transport_pairs[test_session_id] - logger.info( - f"Removed internal transport pair for test session: {test_session_id}" - ) - - def get_active_test_count(self) -> int: - """Get the number of active test sessions.""" - return len(self._transport_pairs) diff --git a/api/services/looptalk/orchestrator.py b/api/services/looptalk/orchestrator.py deleted file mode 100644 index d51e9da..0000000 --- a/api/services/looptalk/orchestrator.py +++ /dev/null @@ -1,542 +0,0 @@ -import asyncio -import os -import uuid -from datetime import UTC, datetime -from pathlib import Path -from typing import Any, Dict, Optional - -from loguru import logger -from pipecat.pipeline.task import PipelineTask -from pipecat.utils.run_context import set_current_run_id - -from api.db.db_client import DBClient -from api.services.looptalk.internal_transport import ( - InternalTransport, - InternalTransportManager, -) -from api.services.pipecat.transport_setup import create_internal_transport - -from .core.pipeline_builder import LoopTalkPipelineBuilder -from .core.recording_manager import RecordingManager -from .core.session_manager import SessionManager - - -class LoopTalkTestOrchestrator: - """Orchestrates LoopTalk testing sessions with agent-to-agent conversations.""" - - def __init__( - self, db_client: DBClient, network_latency_seconds: Optional[float] = None - ): - self.db_client = db_client - self.transport_manager = InternalTransportManager() - self.session_manager = SessionManager() - self.pipeline_builder = LoopTalkPipelineBuilder(db_client) - self.recording_manager = RecordingManager(Path("/tmp/looptalk_recordings")) - - # Default network latency (can be overridden per session) - # Priority: constructor param > env var > default (100ms) - if network_latency_seconds is not None: - self._default_network_latency = network_latency_seconds - else: - env_latency = os.environ.get("LOOPTALK_NETWORK_LATENCY_MS") - if env_latency: - try: - self._default_network_latency = ( - float(env_latency) / 1000.0 - ) # Convert ms to seconds - except ValueError: - logger.warning( - f"Invalid LOOPTALK_NETWORK_LATENCY_MS value: {env_latency}, using default 100ms" - ) - self._default_network_latency = 0.1 - else: - self._default_network_latency = 0.1 # 100ms default - - async def start_test_session( - self, - test_session_id: int, - organization_id: int, - network_latency_seconds: Optional[float] = None, - ) -> Dict[str, Any]: - """Start a LoopTalk test session.""" - - # Get test session details - test_session = await self.db_client.get_test_session( - test_session_id=test_session_id, organization_id=organization_id - ) - - if not test_session: - raise ValueError(f"Test session {test_session_id} not found") - - if test_session.status != "pending": - raise ValueError(f"Test session {test_session_id} is not in pending state") - - try: - # Update status to running - await self.db_client.update_test_session_status( - test_session_id=test_session_id, status="running" - ) - - # Create conversation record - conversation = await self.db_client.create_conversation( - test_session_id=test_session_id - ) - - # Create audio configuration for LoopTalk - from api.services.pipecat.audio_config import AudioConfig - - audio_config = AudioConfig( - transport_in_sample_rate=16000, - transport_out_sample_rate=16000, - pipeline_sample_rate=16000, - ) - - # Use provided latency or fall back to default - latency = ( - network_latency_seconds - if network_latency_seconds is not None - else self._default_network_latency - ) - logger.info( - f"Using network latency of {latency}s for test session {test_session_id}" - ) - - # Generate unique workflow run IDs for each agent - actor_workflow_run_id = int(str(test_session_id) + "1") - adversary_workflow_run_id = int(str(test_session_id) + "2") - - # Create transports using the new method with turn analyzer - actor_transport = create_internal_transport( - workflow_run_id=actor_workflow_run_id, - audio_config=audio_config, - latency_seconds=latency, - ) - adversary_transport = create_internal_transport( - workflow_run_id=adversary_workflow_run_id, - audio_config=audio_config, - latency_seconds=latency, - ) - - # Connect the transports - actor_transport.connect_partner(adversary_transport) - - # Store the transport pair in the manager - self.transport_manager._transport_pairs[str(test_session_id)] = ( - actor_transport, - adversary_transport, - ) - - # Generate unique identifiers for actor and adversary - actor_id = f"actor_{test_session_id}_{str(uuid.uuid4())[:8]}" - adversary_id = f"adversary_{test_session_id}_{str(uuid.uuid4())[:8]}" - - # Create pipelines for both agents - actor_pipeline_info = await self.pipeline_builder.create_agent_pipeline( - transport=actor_transport, - workflow=test_session.actor_workflow, - test_session_id=test_session_id, - agent_id=actor_id, - role="actor", - ) - actor_pipeline_task = actor_pipeline_info["task"] - - adversary_pipeline_info = await self.pipeline_builder.create_agent_pipeline( - transport=adversary_transport, - workflow=test_session.adversary_workflow, - test_session_id=test_session_id, - agent_id=adversary_id, - role="adversary", - ) - - adversary_pipeline_task = adversary_pipeline_info["task"] - - # Register event handlers for both pipelines - await self._register_transport_handlers( - actor_transport, actor_pipeline_info, test_session_id, "actor" - ) - await self._register_transport_handlers( - adversary_transport, - adversary_pipeline_info, - test_session_id, - "adversary", - ) - - # Store session info - session_info = { - "test_session": test_session, - "conversation": conversation, - "actor_task": actor_pipeline_task, - "adversary_task": adversary_pipeline_task, - "actor_transport": actor_transport, - "adversary_transport": adversary_transport, - "start_time": datetime.now(UTC), - } - self.session_manager.add_session(test_session_id, session_info) - - # Start both pipelines in background tasks - from pipecat.pipeline.base_task import PipelineTaskParams - - params = PipelineTaskParams(loop=asyncio.get_event_loop()) - - # Start the pipelines - this will trigger initialization through the normal pipeline start process - # The workflow engines will be initialized when the pipeline starts - - # Create conversation IDs for tracing - actor_conversation_id = f"{test_session_id}-actor-{actor_id}" - adversary_conversation_id = f"{test_session_id}-adversary-{adversary_id}" - - # Create tasks but don't await them - they'll run in the background - logger.debug(f"Running actor task with ID: {actor_id}") - actor_task_future = asyncio.create_task( - self._run_pipeline_with_context( - actor_pipeline_task, - params, - actor_id, - actor_conversation_id, - "actor", - ) - ) - - logger.debug(f"Running adversary task with ID: {adversary_id}") - adversary_task_future = asyncio.create_task( - self._run_pipeline_with_context( - adversary_pipeline_task, - params, - adversary_id, - adversary_conversation_id, - "adversary", - ) - ) - - # Store the futures so we can monitor them - session_info["actor_task_future"] = actor_task_future - session_info["adversary_task_future"] = adversary_task_future - - logger.info(f"Started LoopTalk test session {test_session_id}") - - return { - "test_session_id": test_session_id, - "conversation_id": conversation.id, - "status": "running", - } - - except Exception as e: - logger.error(f"Failed to start test session {test_session_id}: {e}") - await self.db_client.update_test_session_status( - test_session_id=test_session_id, status="failed", error=str(e) - ) - raise - - async def _register_transport_handlers( - self, - transport: InternalTransport, - pipeline_info: Dict[str, Any], - test_session_id: int, - role: str, - ): - """Register transport event handlers for a pipeline. - - Args: - transport: The transport to register handlers on - pipeline_info: Dictionary containing pipeline components - test_session_id: ID of the test session - role: Either "actor" or "adversary" - """ - engine = pipeline_info["engine"] - task = pipeline_info["task"] - audio_buffer = pipeline_info["audio_buffer"] - transcript = pipeline_info["transcript"] - assistant_context_aggregator = pipeline_info["assistant_context_aggregator"] - - # Register transport event handlers - @transport.event_handler("on_client_connected") - async def on_client_connected(transport, participant): - logger.debug(f"LoopTalk {role} client connected - initializing workflow") - # Start audio recording - await audio_buffer.start_recording() - await engine.initialize() - - @transport.event_handler("on_client_disconnected") - async def on_client_disconnected(transport, participant): - logger.debug(f"LoopTalk {role} client disconnected") - # Stop audio recording - await audio_buffer.stop_recording() - - # Handle disconnect propagation - stop the other agent too - await self.session_manager.handle_agent_disconnect( - test_session_id, role, self.stop_test_session - ) - - await task.cancel() - - # Register custom audio and transcript handlers for LoopTalk - await self._register_looptalk_handlers( - audio_buffer, transcript, test_session_id, role - ) - - async def _register_looptalk_handlers( - self, audio_buffer, transcript, test_session_id: int, role: str - ): - """Register LoopTalk-specific handlers for audio and transcript recording""" - - paths = self.recording_manager.get_recording_paths(test_session_id, role) - - # Store audio metadata for later WAV conversion - audio_metadata = {"sample_rate": None, "num_channels": None} - - # Audio handler - writes directly to PCM file - @audio_buffer.event_handler("on_audio_data") - async def on_audio_data(buffer, audio, sample_rate, num_channels): - if not audio: - return - - # Store metadata on first write - if audio_metadata["sample_rate"] is None: - audio_metadata["sample_rate"] = sample_rate - audio_metadata["num_channels"] = num_channels - - # Append PCM data to temporary file - try: - with open(paths["temp_audio"], "ab") as f: - f.write(audio) - except Exception as e: - logger.error( - f"Failed to write audio for {role} in session {test_session_id}: {e}" - ) - - # Transcript handler - writes directly to text file - @transcript.event_handler("on_transcript_update") - async def on_transcript_update(processor, frame): - transcript_text = "" - for msg in frame.messages: - timestamp = f"[{msg.timestamp}] " if msg.timestamp else "" - line = f"{timestamp}{msg.role}: {msg.content}\n" - transcript_text += line - - # Append transcript to file - try: - with open(paths["transcript"], "a") as f: - f.write(transcript_text) - except Exception as e: - logger.error( - f"Failed to write transcript for {role} in session {test_session_id}: {e}" - ) - - # Store metadata in session info for later WAV conversion - # Set default values if not yet captured - if audio_metadata["sample_rate"] is None: - audio_metadata["sample_rate"] = 16000 # Default sample rate - audio_metadata["num_channels"] = 1 # Default channels - - self.session_manager.update_audio_metadata( - test_session_id, - role, - sample_rate=audio_metadata["sample_rate"], - num_channels=audio_metadata["num_channels"], - ) - - async def _run_pipeline_with_context( - self, - pipeline_task: PipelineTask, - params, - agent_id: str, - conversation_id: str, - role: str, - ): - """Run a pipeline task with the agent_id set in context""" - set_current_run_id(agent_id) - return await pipeline_task.run(params) - - async def stop_test_session(self, test_session_id: int) -> Dict[str, Any]: - """Stop a running test session.""" - - session_info = self.session_manager.get_session(test_session_id) - if not session_info: - raise ValueError(f"Test session {test_session_id} is not running") - - try: - # Cancel both pipeline tasks - await session_info["actor_task"].cancel() - await session_info["adversary_task"].cancel() - - # Also cancel the task futures if they exist - if "actor_task_future" in session_info: - session_info["actor_task_future"].cancel() - if "adversary_task_future" in session_info: - session_info["adversary_task_future"].cancel() - - # Calculate duration - duration_seconds = int( - (datetime.now(UTC) - session_info["start_time"]).total_seconds() - ) - - # Update conversation - await self.db_client.update_conversation( - conversation_id=session_info["conversation"].id, - duration_seconds=duration_seconds, - ended_at=datetime.now(UTC), - ) - - # Update test session status - await self.db_client.update_test_session_status( - test_session_id=test_session_id, - status="completed", - results={ - "duration_seconds": duration_seconds, - "conversation_id": session_info["conversation"].id, - }, - ) - - # Finalize recordings for both actor and adversary - # Convert PCM files to WAV - actor_metadata = self.session_manager.get_audio_metadata( - test_session_id, "actor" - ) - adversary_metadata = self.session_manager.get_audio_metadata( - test_session_id, "adversary" - ) - - self.recording_manager.convert_pcm_to_wav( - test_session_id, - "actor", - sample_rate=actor_metadata["sample_rate"], - num_channels=actor_metadata["num_channels"], - ) - self.recording_manager.convert_pcm_to_wav( - test_session_id, - "adversary", - sample_rate=adversary_metadata["sample_rate"], - num_channels=adversary_metadata["num_channels"], - ) - - # Upload recordings to S3 (synchronously for load testing) - ( - actor_audio_url, - actor_transcript_url, - ) = await self.recording_manager.upload_recording_to_s3( - test_session_id, "actor" - ) - ( - adversary_audio_url, - adversary_transcript_url, - ) = await self.recording_manager.upload_recording_to_s3( - test_session_id, "adversary" - ) - - # Update conversation with recording URLs - await self.db_client.update_conversation( - conversation_id=session_info["conversation"].id, - actor_recording_url=actor_audio_url, - adversary_recording_url=adversary_audio_url, - transcript={ - "actor_transcript_url": actor_transcript_url, - "adversary_transcript_url": adversary_transcript_url, - }, - ) - - # Log recording locations - logger.info(f"LoopTalk recordings uploaded to S3:") - if actor_audio_url: - logger.info(f" - Actor audio: {actor_audio_url}") - if actor_transcript_url: - logger.info(f" - Actor transcript: {actor_transcript_url}") - if adversary_audio_url: - logger.info(f" - Adversary audio: {adversary_audio_url}") - if adversary_transcript_url: - logger.info(f" - Adversary transcript: {adversary_transcript_url}") - - # Clean up local files after successful upload - self.recording_manager.cleanup_session_files(test_session_id) - - # Clean up - self.transport_manager.remove_transport_pair(str(test_session_id)) - self.session_manager.remove_session(test_session_id) - - # Clean up audio streamers - from api.services.looptalk.audio_streamer import cleanup_audio_streamers - - cleanup_audio_streamers(str(test_session_id)) - - logger.info(f"Stopped LoopTalk test session {test_session_id}") - - return { - "test_session_id": test_session_id, - "status": "completed", - "duration_seconds": duration_seconds, - } - - except Exception as e: - logger.error(f"Failed to stop test session {test_session_id}: {e}") - await self.db_client.update_test_session_status( - test_session_id=test_session_id, status="failed", error=str(e) - ) - raise - - async def start_load_test( - self, - organization_id: int, - name_prefix: str, - actor_workflow_id: int, - adversary_workflow_id: int, - config: Dict[str, Any], - test_count: int, - ) -> Dict[str, Any]: - """Start a load test with multiple concurrent test sessions.""" - - # Validate test count - if test_count < 1 or test_count > 10: - raise ValueError("Test count must be between 1 and 10") - - # Create test sessions - test_sessions = await self.db_client.create_load_test_group( - organization_id=organization_id, - name_prefix=name_prefix, - actor_workflow_id=actor_workflow_id, - adversary_workflow_id=adversary_workflow_id, - config=config, - test_count=test_count, - ) - - # Start all test sessions concurrently - tasks = [] - for test_session in test_sessions: - task = asyncio.create_task( - self.start_test_session( - test_session_id=test_session.id, organization_id=organization_id - ) - ) - tasks.append(task) - - # Wait for all to start - results = await asyncio.gather(*tasks, return_exceptions=True) - - # Count successes and failures - started = sum(1 for r in results if not isinstance(r, Exception)) - failed = sum(1 for r in results if isinstance(r, Exception)) - - load_test_group_id = test_sessions[0].load_test_group_id - - logger.info( - f"Started load test {load_test_group_id}: " - f"{started} started, {failed} failed out of {test_count}" - ) - - return { - "load_test_group_id": load_test_group_id, - "total": test_count, - "started": started, - "failed": failed, - "test_session_ids": [ts.id for ts in test_sessions], - } - - def get_active_test_count(self) -> int: - """Get the number of currently active test sessions.""" - return self.session_manager.get_active_count() - - def get_active_test_info(self) -> Dict[str, Any]: - """Get information about all active test sessions.""" - return self.session_manager.get_active_info() - - def get_recording_info(self, test_session_id: int) -> Dict[str, Any]: - """Get information about recordings for a test session""" - return self.recording_manager.get_recording_info(test_session_id) diff --git a/api/services/pipecat/event_handlers.py b/api/services/pipecat/event_handlers.py index 53e9b49..390f6cc 100644 --- a/api/services/pipecat/event_handlers.py +++ b/api/services/pipecat/event_handlers.py @@ -5,8 +5,9 @@ from loguru import logger from api.db import db_client from api.enums import PostHogEvent, WorkflowRunState from api.services.campaign.circuit_breaker import circuit_breaker +from api.services.integrations import IntegrationRuntimeSession from api.services.pipecat.audio_config import AudioConfig -from api.services.pipecat.audio_playback import play_audio, play_audio_loop +from api.services.pipecat.audio_playback import play_audio_loop from api.services.pipecat.in_memory_buffers import ( InMemoryAudioBuffer, InMemoryLogsBuffer, @@ -19,8 +20,6 @@ from api.tasks.arq import enqueue_job from api.tasks.function_names import FunctionNames from pipecat.frames.frames import ( Frame, - LLMContextFrame, - TTSSpeakFrame, ) from pipecat.pipeline.task import PipelineTask from pipecat.processors.audio.audio_buffer_processor import AudioBufferProcessor @@ -68,8 +67,8 @@ def register_event_handlers( pipeline_metrics_aggregator: PipelineMetricsAggregator, audio_config=AudioConfig, pre_call_fetch_task: asyncio.Task | None = None, - fetch_recording_audio=None, user_provider_id: str | None = None, + integration_runtime_sessions: list[IntegrationRuntimeSession] | None = None, ): """Register all event handlers for transport and task events. @@ -97,20 +96,11 @@ def register_event_handlers( "initial_response_triggered": False, } - async def queue_initial_llm_context(): - # Queue LLMContextFrame after the VoicemailDetector since the detector - # gates LLMContextFrames until voicemail detection completes. We also - # don't want to trigger the Voicemail LLM with this initial frame. - await engine.llm.queue_frame(LLMContextFrame(engine.context)) - async def maybe_trigger_initial_response(): """Start the conversation after both pipeline_started and client_connected events. If a pre-call fetch is in progress, plays a ringer while waiting for the response, then merges the result into the call context before proceeding. - - If the start node has a greeting configured, play it directly via TTS. - Otherwise, trigger an LLM generation for the opening message. """ if ( ready_state["pipeline_started"] @@ -165,46 +155,11 @@ def register_event_handlers( # Set the start node now (after pre-call fetch data is merged) # so that render_template() has the complete _call_context_vars. await engine.set_node(engine.workflow.start_node_id) - - greeting_info = engine.get_start_greeting() - if greeting_info: - greeting_type, greeting_value = greeting_info - if ( - greeting_type == "audio" - and greeting_value - and fetch_recording_audio - ): - logger.debug(f"Playing audio greeting recording: {greeting_value}") - result = await fetch_recording_audio( - recording_pk=int(greeting_value) - ) - if result: - await play_audio( - result.audio, - sample_rate=audio_config.pipeline_sample_rate or 16000, - queue_frame=transport.output().queue_frame, - transcript=result.transcript, - append_to_context=True, - ) - else: - logger.warning( - f"Failed to fetch audio greeting {greeting_value}, " - "falling back to LLM generation" - ) - await queue_initial_llm_context() - else: - logger.debug("Playing text greeting via TTS") - # append_to_context=True so the assistant aggregator commits - # the greeting to the LLM context once TTS finishes; without - # it the LLM would re-greet on its first generation. - await task.queue_frame( - TTSSpeakFrame(greeting_value, append_to_context=True) - ) - else: - logger.debug( - "Both pipeline_started and client_connected received - triggering initial LLM generation" - ) - await queue_initial_llm_context() + await engine.queue_node_opening( + node_id=engine.workflow.start_node_id, + previous_node_id=None, + generate_if_no_greeting=True, + ) @transport.event_handler("on_client_connected") async def on_client_connected(_transport, _participant): @@ -319,6 +274,20 @@ def register_event_handlers( ) # Clean up engine resources (including voicemail detector) + integration_logs: dict[str, object] = {} + for runtime_session in integration_runtime_sessions or []: + try: + session_logs = await runtime_session.on_call_finished( + gathered_context=gathered_context + ) + if session_logs: + integration_logs.update(session_logs) + except Exception as e: + logger.error( + f"Error finalizing integration runtime session '{runtime_session.name}': {e}", + exc_info=True, + ) + await engine.cleanup() # ------------------------------------------------------------------ @@ -368,14 +337,11 @@ def register_event_handlers( ) ) - # Save real-time feedback logs to workflow run + logs_update: dict[str, object] = {} if not in_memory_logs_buffer.is_empty: try: feedback_events = in_memory_logs_buffer.get_events() - await db_client.update_workflow_run( - run_id=workflow_run_id, - logs={"realtime_feedback_events": feedback_events}, - ) + logs_update["realtime_feedback_events"] = feedback_events logger.debug( f"Saved {len(feedback_events)} feedback events to workflow run logs" ) @@ -384,6 +350,17 @@ def register_event_handlers( else: logger.debug("Logs buffer is empty, skipping save") + logs_update.update(integration_logs) + + if logs_update: + try: + await db_client.update_workflow_run( + run_id=workflow_run_id, + logs=logs_update, + ) + 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 transcript_temp_path = None diff --git a/api/services/pipecat/in_memory_buffers.py b/api/services/pipecat/in_memory_buffers.py index 5c55aa0..3cf22d5 100644 --- a/api/services/pipecat/in_memory_buffers.py +++ b/api/services/pipecat/in_memory_buffers.py @@ -6,6 +6,10 @@ from typing import List, Optional from loguru import logger +from api.services.pipecat.realtime_feedback_events import ( + realtime_feedback_event_sort_key, + stamp_realtime_feedback_event, +) from api.utils.transcript import generate_transcript_text as _generate_transcript_text from pipecat.utils.enums import RealtimeFeedbackType @@ -98,16 +102,13 @@ class InMemoryLogsBuffer: async def append(self, event: dict): """Append a feedback event to the buffer with timestamp and current node.""" - # Add timestamp, turn tracking, and current node - timestamped_event = { - **event, - "timestamp": datetime.now(UTC).isoformat(), - "turn": self._turn_counter, - } - if self._current_node_id: - timestamped_event["node_id"] = self._current_node_id - if self._current_node_name: - timestamped_event["node_name"] = self._current_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, + ) self._events.append(timestamped_event) logger.trace( f"Appended event {event.get('type')} to logs buffer for workflow {self._workflow_run_id}" @@ -120,17 +121,12 @@ class InMemoryLogsBuffer: f"Incremented turn counter to {self._turn_counter} for workflow {self._workflow_run_id}" ) - @staticmethod - def _event_sort_key(event: dict) -> str: - payload_ts = event.get("payload", {}).get("timestamp") - return payload_ts or event.get("timestamp", "") - 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. - return sorted(self._events, key=self._event_sort_key) + 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.""" diff --git a/api/services/pipecat/minimax_tts.py b/api/services/pipecat/minimax_tts.py new file mode 100644 index 0000000..ef53a81 --- /dev/null +++ b/api/services/pipecat/minimax_tts.py @@ -0,0 +1,23 @@ +"""MiniMax TTS wrapper that closes its aiohttp session in cleanup(). + +Pipecat's MiniMaxHttpTTSService leaves session disposal to the caller. Our +factory creates a fresh session per service instance, so we own its close +here to avoid leaking sockets/FDs on shutdown. +""" + +import aiohttp + +from pipecat.services.minimax.tts import MiniMaxHttpTTSService + + +class MiniMaxOwnedSessionTTSService(MiniMaxHttpTTSService): + """MiniMaxHttpTTSService variant that owns its aiohttp session lifecycle.""" + + def __init__(self, *args, aiohttp_session: aiohttp.ClientSession, **kwargs): + super().__init__(*args, aiohttp_session=aiohttp_session, **kwargs) + self._owned_session = aiohttp_session + + async def cleanup(self): + await super().cleanup() + if not self._owned_session.closed: + await self._owned_session.close() diff --git a/api/services/pipecat/pipeline_builder.py b/api/services/pipecat/pipeline_builder.py index b2fa929..de9d48c 100644 --- a/api/services/pipecat/pipeline_builder.py +++ b/api/services/pipecat/pipeline_builder.py @@ -152,8 +152,30 @@ def build_realtime_pipeline( return Pipeline(processors) -def create_pipeline_task(pipeline, workflow_run_id, audio_config: AudioConfig = None): - """Create a pipeline task with appropriate parameters""" +def create_pipeline_task( + pipeline, + workflow_run_id, + audio_config: AudioConfig = None, + *, + conversation_parent_context=None, + conversation_type: str = "voice", + additional_span_attributes: dict | None = None, +): + """Create a pipeline task with appropriate parameters. + + Args: + pipeline: The pipeline to run. + workflow_run_id: Run id, used as the conversation id. + audio_config: Optional audio configuration. + conversation_parent_context: Optional OTEL context carrying a fixed + trace id. When provided, the conversation span attaches to that + trace instead of starting a new root trace (used by text chat to + stitch every per-turn pipeline into one trace). + conversation_type: ``conversation.type`` span attribute value. + additional_span_attributes: Extra attributes set on the conversation + span (e.g. ``langfuse.trace.name`` to name a stitched trace that + has no real root span). + """ # Set up pipeline params with audio configuration if provided pipeline_params = PipelineParams( enable_metrics=True, @@ -178,6 +200,9 @@ def create_pipeline_task(pipeline, workflow_run_id, audio_config: AudioConfig = enable_tracing=True, enable_rtvi=False, conversation_id=f"{workflow_run_id}", + conversation_parent_context=conversation_parent_context, + conversation_type=conversation_type, + additional_span_attributes=additional_span_attributes, ) # Check if turn logging is enabled diff --git a/api/services/pipecat/realtime/__init__.py b/api/services/pipecat/realtime/__init__.py new file mode 100644 index 0000000..cd346c1 --- /dev/null +++ b/api/services/pipecat/realtime/__init__.py @@ -0,0 +1,9 @@ +"""Dograh-specific subclasses of pipecat realtime LLM services. + +Each subclass wires Dograh engine integration quirks (user-mute gating, +TTSSpeakFrame greeting trigger, node-transition handling, function-call +deferral, etc.) onto the corresponding pipecat realtime service. + +The pipecat fork's services stay close to upstream — Dograh behavior lives +here. +""" diff --git a/api/services/pipecat/realtime/gemini_live.py b/api/services/pipecat/realtime/gemini_live.py new file mode 100644 index 0000000..abcc3ea --- /dev/null +++ b/api/services/pipecat/realtime/gemini_live.py @@ -0,0 +1,237 @@ +"""Dograh subclass of pipecat's Gemini Live LLM service. + +Layers Dograh engine integration quirks onto upstream-pristine +:class:`GeminiLiveLLMService`: + +- **Deferred connect.** Connection is held back until ``system_instruction`` + is set via :meth:`_update_settings`, so pre-call-fetch template variables + land before the live session opens. +- **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. +- **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 + to kick off the first response after node setup; the service intercepts + it and runs the initial-context path. +- **Finalize-pending on transcriptions.** Marks the transcription emitted + immediately after VAD-stop as finalized, distinguishing it from + mid-turn partials. +""" + +from typing import Any + +from loguru import logger + +from pipecat.frames.frames import ( + BotStoppedSpeakingFrame, + Frame, + TranscriptionFrame, + TTSSpeakFrame, + UserMuteStartedFrame, + UserMuteStoppedFrame, +) +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.google.gemini_live.llm import GeminiLiveLLMService +from pipecat.services.llm_service import FunctionCallFromLLM +from pipecat.utils.time import time_now_iso8601 +from pipecat.utils.tracing.service_decorators import traced_gemini_live + + +class DograhGeminiLiveLLMService(GeminiLiveLLMService): + """Gemini Live with Dograh engine integration quirks. See module docstring.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + # User-mute state, driven by broadcast UserMute{Started,Stopped}Frames. + # Audio is not forwarded to Gemini while muted. + self._user_is_muted: bool = False + # 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] = [] + # Tracks whether the next transcription to arrive should be marked as + # the finalized transcription for the current user turn. + self._finalize_pending: bool = False + + # ------------------------------------------------------------------ + # Hooks from upstream GeminiLiveLLMService + # ------------------------------------------------------------------ + + def _should_connect_on_start(self) -> bool: + # Hold the connection until the engine sets a system_instruction. This + # lets pre-call fetch populate template variables first. + return bool(self._settings.system_instruction) + + async def _handle_changed_settings(self, changed: dict[str, Any]) -> set[str]: + if "system_instruction" not in changed: + return set() + 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() + return {"system_instruction"} + + async def _run_or_defer_function_calls( + self, function_calls_llm: list[FunctionCallFromLLM] + ): + 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 + logger.debug( + f"{self}: deferring {len(function_calls_llm)} function call(s) " + "until bot turn ends" + ) + return + await super()._run_or_defer_function_calls(function_calls_llm) + + # ------------------------------------------------------------------ + # State-transition side effects + # ------------------------------------------------------------------ + + async def _set_bot_is_responding(self, responding: bool): + 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() + + async def _run_pending_function_calls(self): + """Run any function calls deferred during the bot's last turn.""" + if not self._pending_function_calls: + return + fcs = self._pending_function_calls + self._pending_function_calls = [] + logger.debug( + f"{self}: executing {len(fcs)} deferred function call(s) " + "after bot turn ended" + ) + await self.run_function_calls(fcs) + + # ------------------------------------------------------------------ + # Frame handling: mute, TTSSpeakFrame, BotStoppedSpeakingFrame flush + # ------------------------------------------------------------------ + + async def process_frame(self, frame: Frame, direction: FrameDirection): + if isinstance(frame, UserMuteStartedFrame): + self._user_is_muted = True + await self.push_frame(frame, direction) + return + if isinstance(frame, UserMuteStoppedFrame): + self._user_is_muted = False + await self.push_frame(frame, direction) + return + 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. + if not self._handled_initial_context: + await self._handle_context(self._context) + else: + logger.warning( + f"{self}: TTSSpeakFrame after initial context already " + "handled — Gemini Live owns audio generation, ignoring" + ) + return + 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() + # Fall through to super for the actual push. + await super().process_frame(frame, direction) + + async def _send_user_audio(self, frame): + if self._user_is_muted: + return + await super()._send_user_audio(frame) + + # ------------------------------------------------------------------ + # Context lifecycle: Dograh pre-populates self._context via the engine, + # so upstream's "first arrival === self._context is None" check doesn't + # work. We gate on _handled_initial_context instead and skip the + # init-instruction reconciliation (Dograh updates system_instruction at + # runtime via _update_settings, not via init). + # ------------------------------------------------------------------ + + async def _handle_context(self, context: LLMContext): + if not self._handled_initial_context: + self._handled_initial_context = True + self._context = context + await self._create_initial_response() + else: + self._context = context + await self._process_completed_function_calls(send_new_results=True) + + # ------------------------------------------------------------------ + # Session lifecycle: drop upstream's automatic reconnect-seed and + # initial-context-seed paths. The TTSSpeakFrame trigger and the + # function-call-result LLMContextFrame are the only paths that should + # kick off bot turns in the Dograh flow. + # ------------------------------------------------------------------ + + @traced_gemini_live(operation="llm_setup") + async def _handle_session_ready(self, session): + logger.debug( + f"In _handle_session_ready self._run_llm_when_session_ready: {self._run_llm_when_session_ready}" + ) + self._session = session + 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() + 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. + + # ------------------------------------------------------------------ + # Transcription: broadcast (so downstream voicemail detector and + # logs buffer both see it) and set finalized= for turn-boundary + # semantics. + # ------------------------------------------------------------------ + + async def _handle_user_started_speaking(self, frame): + await super()._handle_user_started_speaking(frame) + # A new VAD start invalidates any pending finalize from a prior stop + # that hasn't been paired with a transcription yet. + self._finalize_pending = False + + async def _handle_user_stopped_speaking(self, frame): + await super()._handle_user_stopped_speaking(frame) + self._finalize_pending = True + + async def _push_user_transcription(self, text: str, result=None): + await self._handle_user_transcription(text, True, self._settings.language) + finalized = self._finalize_pending + self._finalize_pending = False + await self.broadcast_frame( + TranscriptionFrame, + text=text, + user_id="", + timestamp=time_now_iso8601(), + result=result, + finalized=finalized, + ) diff --git a/api/services/pipecat/realtime/gemini_live_vertex.py b/api/services/pipecat/realtime/gemini_live_vertex.py new file mode 100644 index 0000000..06dfa44 --- /dev/null +++ b/api/services/pipecat/realtime/gemini_live_vertex.py @@ -0,0 +1,42 @@ +"""Dograh subclass of pipecat's Gemini Live Vertex AI LLM service. + +Diamond inheritance: combines the Dograh engine-integration overrides from +:class:`DograhGeminiLiveLLMService` with the Vertex-specific tweaks from +upstream's :class:`GeminiLiveVertexLLMService` (no history config, +``NON_BLOCKING`` tools disabled, service-account credentials). + +MRO:: + + DograhGeminiLiveVertexLLMService + -> DograhGeminiLiveLLMService + -> GeminiLiveVertexLLMService + -> GeminiLiveLLMService + -> LLMService + -> ... +""" + +from api.services.pipecat.realtime.gemini_live import DograhGeminiLiveLLMService +from pipecat.services.google.gemini_live.vertex.llm import ( + GeminiLiveVertexLLMService, +) + + +class DograhGeminiLiveVertexLLMService( + DograhGeminiLiveLLMService, + GeminiLiveVertexLLMService, +): + """Vertex AI variant of Gemini Live with Dograh integration quirks.""" + + pass + + +# Guard against MRO regressions: a future refactor that flips inheritance +# order or breaks the diamond would silently bypass the Dograh overrides. +_mro = DograhGeminiLiveVertexLLMService.__mro__ +assert _mro[1] is DograhGeminiLiveLLMService, ( + f"Expected DograhGeminiLiveLLMService at MRO[1], got {_mro[1]}" +) +assert _mro[2] is GeminiLiveVertexLLMService, ( + f"Expected GeminiLiveVertexLLMService at MRO[2], got {_mro[2]}" +) +del _mro diff --git a/api/services/pipecat/realtime/grok_realtime.py b/api/services/pipecat/realtime/grok_realtime.py new file mode 100644 index 0000000..84037c4 --- /dev/null +++ b/api/services/pipecat/realtime/grok_realtime.py @@ -0,0 +1,253 @@ +"""Dograh subclass of pipecat's Grok Realtime LLM service. + +Layers Dograh engine integration quirks onto upstream-pristine +:class:`GrokRealtimeLLMService`. Grok already supports runtime session updates, +so this wrapper stays close to the OpenAI realtime shim. + +Adds: + +- **User-mute audio gating** via ``UserMuteStarted/StoppedFrame``. +- **TTSSpeakFrame as initial-response trigger** so the engine's greeting + 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. +- **finalized=True on TranscriptionFrame** for parity with Dograh's other + realtime providers. +""" + +import json +from typing import Any + +from loguru import logger + +from pipecat.frames.frames import ( + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + Frame, + LLMFullResponseStartFrame, + LLMMessagesAppendFrame, + TranscriptionFrame, + TTSSpeakFrame, + UserMuteStartedFrame, + UserMuteStoppedFrame, +) +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.llm_service import FunctionCallFromLLM +from pipecat.services.xai.realtime import events +from pipecat.services.xai.realtime.llm import GrokRealtimeLLMService +from pipecat.utils.time import time_now_iso8601 + + +class DograhGrokRealtimeLLMService(GrokRealtimeLLMService): + """Grok Realtime with Dograh engine integration quirks.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._user_is_muted: bool = False + self._handled_initial_context: bool = False + self._bot_is_speaking: bool = False + self._deferred_function_calls: list[FunctionCallFromLLM] = [] + + async def process_frame(self, frame: Frame, direction: FrameDirection): + if isinstance(frame, UserMuteStartedFrame): + self._user_is_muted = True + await self.push_frame(frame, direction) + return + if isinstance(frame, UserMuteStoppedFrame): + self._user_is_muted = False + await self.push_frame(frame, direction) + return + if isinstance(frame, TTSSpeakFrame): + if not self._handled_initial_context: + await self._handle_context(self._context) + else: + logger.warning( + f"{self}: TTSSpeakFrame after initial context already " + "handled — Grok Realtime owns audio generation, ignoring" + ) + return + if isinstance(frame, LLMMessagesAppendFrame): + await self._handle_messages_append(frame) + return + if isinstance(frame, BotStartedSpeakingFrame): + self._bot_is_speaking = True + elif isinstance(frame, BotStoppedSpeakingFrame): + self._bot_is_speaking = False + await self._run_pending_function_calls() + await super().process_frame(frame, direction) + + async def _handle_messages_append(self, frame: LLMMessagesAppendFrame): + """Consume a one-off append frame without mutating the local LLMContext.""" + if self._disconnecting: + return + + if not self._api_session_ready: + if frame.run_llm: + logger.debug( + f"{self}: LLMMessagesAppendFrame received before session ready; " + "deferring response until the session is initialized" + ) + self._run_llm_when_api_session_ready = True + return + + appended_any = False + for message in frame.messages: + item = self._message_to_conversation_item(message) + if item is None: + continue + evt = events.ConversationItemCreateEvent(item=item) + self._messages_added_manually[evt.item.id] = True + await self.send_client_event(evt) + appended_any = True + + if frame.run_llm and appended_any: + await self._send_manual_response_create() + + async def _handle_context(self, context: LLMContext): + if not self._handled_initial_context: + if context is None: + logger.warning( + f"{self}: received initial context trigger before context was set" + ) + return + self._handled_initial_context = True + self._context = context + await self._create_response() + else: + self._context = context + await self._process_completed_function_calls(send_new_results=True) + + async def _send_user_audio(self, frame): + if self._user_is_muted: + return + await super()._send_user_audio(frame) + + def _message_to_conversation_item( + self, message: dict[str, Any] + ) -> events.ConversationItem | None: + if not isinstance(message, dict): + logger.warning( + f"{self}: skipping unsupported appended message payload {message!r}" + ) + return None + + role = message.get("role") + if role not in {"user", "system", "developer"}: + logger.warning( + f"{self}: skipping unsupported appended message role {role!r}" + ) + return None + + text = self._extract_text_content(message.get("content")) + if not text: + logger.warning( + f"{self}: skipping appended message with unsupported content {message!r}" + ) + return None + + item_role = "system" if role in {"system", "developer"} else "user" + return events.ConversationItem( + type="message", + role=item_role, + content=[events.ItemContent(type="input_text", text=text)], + ) + + @staticmethod + def _extract_text_content(content: Any) -> str | None: + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for part in content: + if not isinstance(part, dict): + return None + if part.get("type") != "text": + return None + text = part.get("text") + if not isinstance(text, str): + return None + parts.append(text) + return "\n".join(parts) if parts else None + return None + + async def _send_manual_response_create(self): + """Trigger inference after manually appending conversation items.""" + 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(modalities=["text", "audio"]) + ) + ) + + async def _run_pending_function_calls(self): + if not self._deferred_function_calls: + return + function_calls = self._deferred_function_calls + self._deferred_function_calls = [] + logger.debug( + f"{self}: executing {len(function_calls)} deferred function 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.""" + try: + args = json.loads(evt.arguments) + + function_call_item = self._pending_function_calls.get(evt.call_id) + if function_call_item: + del self._pending_function_calls[evt.call_id] + + function_name = getattr(evt, "name", None) or function_call_item.name + function_calls = [ + FunctionCallFromLLM( + context=self._context, + tool_call_id=evt.call_id, + function_name=function_name, + arguments=args, + ) + ] + + if self._bot_is_speaking: + self._deferred_function_calls.extend(function_calls) + logger.debug( + f"{self}: deferring function call {function_name} " + "until bot stops speaking" + ) + else: + await self.run_function_calls(function_calls) + logger.debug(f"Processed function call: {function_name}") + else: + logger.warning( + f"No tracked function call found for call_id: {evt.call_id}" + ) + logger.warning( + f"Available pending calls: {list(self._pending_function_calls.keys())}" + ) + + except Exception as e: + logger.error(f"Failed to process function call arguments: {e}") + + async def _handle_evt_input_audio_transcription_completed(self, evt): + await self._call_event_handler( + "on_conversation_item_updated", evt.item_id, None + ) + + transcript = evt.transcript.strip() if evt.transcript else "" + if not transcript: + return + + await self.broadcast_frame( + TranscriptionFrame, + text=transcript, + user_id="", + timestamp=time_now_iso8601(), + result=evt, + finalized=True, + ) diff --git a/api/services/pipecat/realtime/openai_realtime.py b/api/services/pipecat/realtime/openai_realtime.py new file mode 100644 index 0000000..8822c06 --- /dev/null +++ b/api/services/pipecat/realtime/openai_realtime.py @@ -0,0 +1,274 @@ +"""Dograh subclass of pipecat's OpenAI Realtime LLM service. + +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. + +Adds: + +- **User-mute audio gating** via ``UserMuteStarted/StoppedFrame``. +- **TTSSpeakFrame as initial-response trigger** so the engine's greeting + 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``. +- **finalized=True on TranscriptionFrame** for parity with the Gemini + service (every OpenAI transcription via the ``completed`` event is + final by construction). +""" + +import json +from typing import Any + +from loguru import logger + +from pipecat.frames.frames import ( + BotStartedSpeakingFrame, + BotStoppedSpeakingFrame, + Frame, + LLMFullResponseStartFrame, + LLMMessagesAppendFrame, + TranscriptionFrame, + TTSSpeakFrame, + UserMuteStartedFrame, + UserMuteStoppedFrame, +) +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.llm_service import FunctionCallFromLLM +from pipecat.services.openai.realtime import events +from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMService +from pipecat.transcriptions.language import Language +from pipecat.utils.time import time_now_iso8601 + + +class DograhOpenAIRealtimeLLMService(OpenAIRealtimeLLMService): + """OpenAI Realtime with Dograh engine integration quirks. See module docstring.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._user_is_muted: bool = False + # Dograh pre-populates self._context via the engine before the first + # 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. + self._bot_is_speaking: bool = False + self._deferred_function_calls: list[FunctionCallFromLLM] = [] + + # ------------------------------------------------------------------ + # Frame handling: mute, TTSSpeakFrame as greeting trigger + # ------------------------------------------------------------------ + + async def process_frame(self, frame: Frame, direction: FrameDirection): + if isinstance(frame, UserMuteStartedFrame): + self._user_is_muted = True + await self.push_frame(frame, direction) + return + if isinstance(frame, UserMuteStoppedFrame): + self._user_is_muted = False + await self.push_frame(frame, direction) + return + 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. + if not self._handled_initial_context: + await self._handle_context(self._context) + else: + logger.warning( + f"{self}: TTSSpeakFrame after initial context already " + "handled — OpenAI Realtime owns audio generation, ignoring" + ) + # Don't forward the frame; the audio path is owned by the realtime + # service itself. + return + if isinstance(frame, LLMMessagesAppendFrame): + await self._handle_messages_append(frame) + return + if isinstance(frame, BotStartedSpeakingFrame): + self._bot_is_speaking = True + elif isinstance(frame, BotStoppedSpeakingFrame): + self._bot_is_speaking = False + await self._run_pending_function_calls() + await super().process_frame(frame, direction) + + async def _handle_messages_append(self, frame: LLMMessagesAppendFrame): + """Consume a one-off append frame without mutating the local LLMContext.""" + if self._disconnecting: + return + + if not self._api_session_ready: + if frame.run_llm: + logger.debug( + f"{self}: LLMMessagesAppendFrame received before session ready; " + "deferring response until the session is initialized" + ) + self._run_llm_when_api_session_ready = True + return + + appended_any = False + for message in frame.messages: + item = self._message_to_conversation_item(message) + if item is None: + continue + evt = events.ConversationItemCreateEvent(item=item) + self._messages_added_manually[evt.item.id] = True + await self.send_client_event(evt) + appended_any = True + + if frame.run_llm and appended_any: + await self._send_manual_response_create() + + async def _handle_context(self, context: LLMContext): + if not self._handled_initial_context: + if context is None: + logger.warning( + f"{self}: received initial context trigger before context was set" + ) + return + self._handled_initial_context = True + self._context = context + await self._create_response() + else: + self._context = context + await self._process_completed_function_calls(send_new_results=True) + + async def _send_user_audio(self, frame): + if self._user_is_muted: + return + await super()._send_user_audio(frame) + + def _message_to_conversation_item( + self, message: dict[str, Any] + ) -> events.ConversationItem | None: + if not isinstance(message, dict): + logger.warning( + f"{self}: skipping unsupported appended message payload {message!r}" + ) + return None + + role = message.get("role") + if role not in {"user", "system", "developer"}: + logger.warning( + f"{self}: skipping unsupported appended message role {role!r}" + ) + return None + + text = self._extract_text_content(message.get("content")) + if not text: + logger.warning( + f"{self}: skipping appended message with unsupported content {message!r}" + ) + return None + + item_role = "system" if role in {"system", "developer"} else "user" + return events.ConversationItem( + type="message", + role=item_role, + content=[events.ItemContent(type="input_text", text=text)], + ) + + @staticmethod + def _extract_text_content(content: Any) -> str | None: + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for part in content: + if not isinstance(part, dict): + return None + if part.get("type") != "text": + return None + text = part.get("text") + if not isinstance(text, str): + return None + parts.append(text) + return "\n".join(parts) if parts else None + return None + + async def _send_manual_response_create(self): + """Trigger inference after manually appending conversation items.""" + 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() + ) + ) + ) + + async def _run_pending_function_calls(self): + if not self._deferred_function_calls: + return + function_calls = self._deferred_function_calls + self._deferred_function_calls = [] + logger.debug( + f"{self}: executing {len(function_calls)} deferred function 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.""" + try: + args = json.loads(evt.arguments) + + function_call_item = self._pending_function_calls.get(evt.call_id) + if function_call_item: + del self._pending_function_calls[evt.call_id] + + function_calls = [ + FunctionCallFromLLM( + context=self._context, + tool_call_id=evt.call_id, + function_name=function_call_item.name, + arguments=args, + ) + ] + + if self._bot_is_speaking: + self._deferred_function_calls.extend(function_calls) + logger.debug( + f"{self}: deferring function call {function_call_item.name} " + "until bot stops speaking" + ) + else: + await self.run_function_calls(function_calls) + logger.debug(f"Processed function call: {function_call_item.name}") + else: + logger.warning( + f"No tracked function call found for call_id: {evt.call_id}" + ) + logger.warning( + f"Available pending calls: {list(self._pending_function_calls.keys())}" + ) + + except Exception as e: + logger.error(f"Failed to process function call arguments: {e}") + + # ------------------------------------------------------------------ + # Transcription: broadcast with finalized=True for parity with the + # Gemini service (consumers that check `finalized` should see True + # for every completed-transcription event from OpenAI). + # ------------------------------------------------------------------ + + async def handle_evt_input_audio_transcription_completed(self, evt): + await self._call_event_handler( + "on_conversation_item_updated", evt.item_id, None + ) + await self.broadcast_frame( + TranscriptionFrame, + text=evt.transcript, + user_id="", + timestamp=time_now_iso8601(), + result=evt, + finalized=True, + ) + await self._handle_user_transcription(evt.transcript, True, Language.EN) diff --git a/api/services/pipecat/realtime/ultravox_realtime.py b/api/services/pipecat/realtime/ultravox_realtime.py new file mode 100644 index 0000000..a666bc9 --- /dev/null +++ b/api/services/pipecat/realtime/ultravox_realtime.py @@ -0,0 +1,653 @@ +"""Dograh subclass of pipecat's Ultravox realtime LLM service. + +Ultravox is audio-native and realtime, but prompt and tool configuration is +bound to call creation. Dograh therefore cannot lean on in-session updates or +Gemini-style session resumption handles. This wrapper adapts Ultravox to the +Dograh engine contract by: + +- deferring the first call creation until the engine queues the initial node + opening via ``TTSSpeakFrame`` or ``LLMContextFrame`` +- marking the call for recreation when ``system_instruction`` changes across + node transitions, then rebuilding it on the follow-up ``LLMContextFrame`` + so the transition tool result is present in ``initialMessages`` +- reconstructing Ultravox ``initialMessages`` from Dograh context when the + call must be recreated after a node transition +- appending a transient resumptive user nudge to recreated ``initialMessages`` + after tool-result transitions, without mutating Dograh's stored context +- handling Dograh-only frames such as user mute and idle append prompts +- tagging user transcripts with ``finalized=True`` for downstream parity +""" + +import hashlib +import json +from typing import Any + +from loguru import logger +from pydantic import Field +from websockets.exceptions import ConnectionClosed + +from pipecat.frames.frames import ( + Frame, + LLMMessagesAppendFrame, + TranscriptionFrame, + TTSSpeakFrame, + UserMuteStartedFrame, + UserMuteStoppedFrame, +) +from pipecat.processors.aggregators import async_tool_messages +from pipecat.processors.aggregators.llm_context import ( + LLMContext, + LLMSpecificMessage, + is_given, +) +from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.llm_service import LLMService +from pipecat.services.settings import _NotGiven, assert_given +from pipecat.services.ultravox.llm import ( + OneShotInputParams, + UltravoxRealtimeLLMService, + websocket_client, +) +from pipecat.utils.time import time_now_iso8601 + + +class DograhUltravoxOneShotInputParams(OneShotInputParams): + """Dograh-friendly OneShot params with string voice support.""" + + voice: str | None = Field(default=None) + + +_ULTRAVOX_MAX_TOOL_TIMEOUT_SECS = 40.0 +_RESUMPTION_USER_MESSAGE = ( + "IMPORTANT: We are resuming an existing conversation. You are given previous turns ONLY for your reference. " + "Do not use that to frame your response. Follow your ORIGINAL INSTRUCTIONS ONLY." +) + + +class DograhUltravoxRealtimeLLMService(UltravoxRealtimeLLMService): + """Ultravox realtime with Dograh engine integration quirks.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._context: LLMContext | None = None + self._selected_tools = None + self._user_is_muted: bool = False + self._call_system_instruction: str | None = None + self._reconnect_required: bool = False + self._call_started: bool = False + self._has_connected_once: bool = False + self._pending_reconnect_system_instruction: str | None = None + self._pending_initial_messages: list[dict[str, Any]] | None = None + self._pending_user_text_messages: list[str] = [] + + async def start(self, frame): + # Dograh defers call creation until the engine queues the node opening. + await LLMService.start(self, frame) + + async def process_frame(self, frame: Frame, direction: FrameDirection): + if isinstance(frame, UserMuteStartedFrame): + self._user_is_muted = True + await self.push_frame(frame, direction) + return + if isinstance(frame, UserMuteStoppedFrame): + self._user_is_muted = False + await self.push_frame(frame, direction) + return + if isinstance(frame, TTSSpeakFrame): + if not self._socket: + await self._connect_call( + system_instruction=self._current_system_instruction(), + greeting_text=frame.text, + initial_messages=None, + agent_speaks_first=True, + ) + else: + logger.warning( + f"{self}: TTSSpeakFrame received after the Ultravox call was " + "already created; ignoring because Ultravox owns speech output" + ) + return + if isinstance(frame, LLMMessagesAppendFrame): + await self._handle_messages_append(frame) + return + await super().process_frame(frame, direction) + + async def _update_settings(self, delta: UltravoxRealtimeLLMService.Settings): + changed = await super(UltravoxRealtimeLLMService, self)._update_settings(delta) + if "output_medium" in changed: + await self._update_output_medium(assert_given(self._settings.output_medium)) + if "system_instruction" in changed and self._has_connected_once: + # Mirror Gemini's "settings change means reconnect" intent, but + # defer the actual new-call creation until the subsequent + # LLMContextFrame arrives with the transition tool result. Ultravox + # cannot accept that historical tool result over a formal + # post-connect tool-response channel the way Gemini can. + self._reconnect_required = True + handled = {"output_medium", "system_instruction"} + self._warn_unhandled_updated_settings(changed.keys() - handled) + return changed + + async def _disconnect(self, preserve_completed_tool_calls: bool = True): + self._disconnecting = True + await self.stop_all_metrics() + if self._socket: + await self._socket.close() + self._socket = None + if self._receive_task: + await self.cancel_task(self._receive_task, timeout=1.0) + self._receive_task = None + if not preserve_completed_tool_calls: + self._completed_tool_calls = set() + self._call_started = False + self._started_placeholder_sent = set() + self._disconnecting = False + + async def _send_user_audio(self, frame): + if self._user_is_muted: + return + await super()._send_user_audio(frame) + + async def _handle_context(self, context: LLMContext): + self._context = context + system_instruction = self._current_system_instruction() + + if self._socket and not self._reconnect_required: + await super()._handle_context(context) + return + + initial_messages, history_tool_call_ids = self._build_initial_messages(context) + if history_tool_call_ids: + self._completed_tool_calls.update(history_tool_call_ids) + + if self._bot_responding: + self._pending_reconnect_system_instruction = system_instruction + self._pending_initial_messages = initial_messages + return + + await self._reconnect_with_context( + system_instruction=system_instruction, + initial_messages=initial_messages, + ) + + async def _handle_response_end(self): + await super()._handle_response_end() + if self._pending_reconnect_system_instruction is None: + return + + system_instruction = self._pending_reconnect_system_instruction + initial_messages = self._pending_initial_messages + self._pending_reconnect_system_instruction = None + self._pending_initial_messages = None + await self._reconnect_with_context( + system_instruction=system_instruction, + initial_messages=initial_messages, + ) + + async def _handle_messages_append(self, frame: LLMMessagesAppendFrame): + texts = [ + text + for text in ( + self._extract_text_content(message.get("content")) + for message in frame.messages + if isinstance(message, dict) + ) + if text + ] + if not texts: + return + + if not self._socket: + self._pending_user_text_messages.extend(texts) + await self._connect_call( + system_instruction=self._current_system_instruction(), + greeting_text=None, + initial_messages=None, + agent_speaks_first=False, + ) + return + + if not self._call_started: + self._pending_user_text_messages.extend(texts) + logger.debug( + f"{self}: queueing {len(texts)} user text message(s) until call_started" + ) + return + + for text in texts: + await self._send_user_text(text) + + async def _handle_user_transcript(self, text: str): + transcript = text.strip() if text else "" + if not transcript: + return + await self.broadcast_frame( + TranscriptionFrame, + user_id=self._last_user_id or "", + timestamp=time_now_iso8601(), + result=text, + text=transcript, + finalized=True, + ) + + async def _connect_call( + self, + *, + system_instruction: str | None, + greeting_text: str | None, + initial_messages: list[dict[str, Any]] | None, + agent_speaks_first: bool, + ): + params = self._build_one_shot_params( + greeting_text=greeting_text, + initial_messages=initial_messages, + agent_speaks_first=agent_speaks_first, + ) + self._params = params + self._selected_tools = self._current_tools_schema(self._context) + tool_names = ( + [tool.name for tool in self._selected_tools.standard_tools] + if self._selected_tools + else [] + ) + prompt = params.system_prompt or "" + prompt_hash = hashlib.sha256(prompt.encode("utf-8")).hexdigest()[:12] + + try: + logger.info( + f"{self}: creating Ultravox call " + f"(agent_speaks_first={agent_speaks_first}, " + f"voice={params.voice!r}, " + f"tools={tool_names}, " + f"system_prompt_len={len(prompt)}, " + f"system_prompt_sha256={prompt_hash})" + ) + join_url = await self._start_one_shot_call(params) + logger.info(f"Joining Ultravox Realtime call via URL: {join_url}") + self._socket = await websocket_client.connect(join_url) + self._receive_task = self.create_task(self._receive_messages()) + self._call_system_instruction = system_instruction + self._call_started = False + self._has_connected_once = True + except Exception as e: + logger.error( + f"{self}: Ultravox call creation/join failed " + f"for tools={tool_names}: {e}" + ) + await self.push_error(f"Failed to connect to Ultravox: {e}", e, fatal=True) + + async def _receive_messages(self): + """Receive messages from the Ultravox Realtime WebSocket. + + Upstream handles exceptions raised while processing individual messages, + but websocket close exceptions are raised by the async iterator itself. + During user hangup / pipeline teardown that close is expected, so treat + normal websocket shutdown as a debug condition rather than a pipeline + error. + """ + if not self._socket: + return + + try: + async for message in self._socket: + try: + if isinstance(message, bytes): + await self._handle_audio(message) + continue + + data = json.loads(message) + match data.get("type"): + case "call_started": + self._call_started = True + logger.debug( + f"{self}: Ultravox call_started received for callId=" + f"{data.get('callId')}" + ) + await self._flush_pending_user_text_messages() + case "state": + if self._bot_responding and data.get("state") != "speaking": + await self._handle_response_end() + case "client_tool_invocation": + await self._handle_tool_invocation( + data.get("toolName"), + data.get("invocationId"), + data.get("parameters"), + ) + case "transcript": + match data.get("role"): + case "user": + if not data.get("final"): + logger.warning( + "Unexpected non-final user transcript from Ultravox Realtime; ignoring." + ) + else: + await self._handle_user_transcript( + data.get("text") + ) + case "agent": + await self._handle_agent_transcript( + data.get("medium"), + data.get("text"), + data.get("delta"), + data.get("final", False), + ) + case _: + logger.debug( + f"Received transcript with unknown role from Ultravox Realtime: {data}" + ) + case _: + logger.debug(f"Received unhandled Ultravox message: {data}") + except Exception as e: + if self._disconnecting or not self._socket: + return + await self.push_error( + "Ultravox websocket receive error", e, fatal=True + ) + except ConnectionClosed as e: + if ( + self._disconnecting + or not self._socket + or self._is_benign_websocket_close(e) + ): + logger.debug(f"{self}: Ultravox websocket closed: {e}") + return + await self.push_error("Ultravox websocket receive error", e, fatal=True) + + async def _flush_pending_user_text_messages(self): + if ( + not self._socket + or not self._call_started + or not self._pending_user_text_messages + ): + return + + pending_texts = self._pending_user_text_messages + self._pending_user_text_messages = [] + for pending_text in pending_texts: + await self._send_user_text(pending_text) + + async def _reconnect_with_context( + self, + *, + system_instruction: str | None, + initial_messages: list[dict[str, Any]] | None, + ): + call_initial_messages = self._initial_messages_for_call(initial_messages) + logger.debug( + f"{self}: reconnecting Ultravox call with initialMessages=" + f"{json.dumps(call_initial_messages, ensure_ascii=True, default=str)}" + ) + if self._socket: + await self._disconnect(preserve_completed_tool_calls=True) + + await self._connect_call( + system_instruction=system_instruction, + greeting_text=None, + initial_messages=initial_messages, + agent_speaks_first=self._should_agent_speak_first(initial_messages), + ) + self._reconnect_required = False + + def _build_one_shot_params( + self, + *, + greeting_text: str | None, + initial_messages: list[dict[str, Any]] | None, + agent_speaks_first: bool, + ) -> DograhUltravoxOneShotInputParams: + current_params = self._params + extra = { + key: value + for key, value in current_params.extra.items() + if key not in {"firstSpeakerSettings", "initialMessages"} + } + + if greeting_text is not None: + extra["firstSpeakerSettings"] = {"agent": {"text": greeting_text}} + elif agent_speaks_first: + extra["firstSpeakerSettings"] = {"agent": {}} + else: + extra["firstSpeakerSettings"] = {"user": {}} + call_initial_messages = self._initial_messages_for_call(initial_messages) + if call_initial_messages: + extra["initialMessages"] = call_initial_messages + + output_medium = self._settings.output_medium + if isinstance(output_medium, _NotGiven): + output_medium = current_params.output_medium + + return DograhUltravoxOneShotInputParams( + api_key=current_params.api_key, + system_prompt=self._current_system_instruction(), + temperature=current_params.temperature, + model=assert_given(self._settings.model), + voice=current_params.voice, + metadata=current_params.metadata, + output_medium=output_medium, + max_duration=current_params.max_duration, + extra=extra, + ) + + def _current_tools_schema(self, context: LLMContext | None): + if context is None or not is_given(context.tools): + return None + return context.tools + + def _to_selected_tools(self, tool: Any) -> list[dict[str, Any]]: + selected_tools = super()._to_selected_tools(tool) + for selected_tool in selected_tools: + temporary_tool = selected_tool.get("temporaryTool") + if not isinstance(temporary_tool, dict): + continue + + tool_name = temporary_tool.get("modelToolName") + if not isinstance(tool_name, str): + continue + + timeout = self._ultravox_timeout_for_tool(tool_name) + if timeout is not None: + temporary_tool["timeout"] = timeout + return selected_tools + + def _current_system_instruction(self) -> str | None: + system_instruction = self._settings.system_instruction + if isinstance(system_instruction, _NotGiven): + return None + return system_instruction + + def _ultravox_timeout_for_tool(self, function_name: str) -> str | None: + item = self._functions.get(function_name) or self._functions.get(None) + if item is None or item.timeout_secs is None or item.timeout_secs <= 0: + return None + + timeout_secs = min(float(item.timeout_secs), _ULTRAVOX_MAX_TOOL_TIMEOUT_SECS) + return f"{timeout_secs:g}s" + + def _initial_messages_for_call( + self, initial_messages: list[dict[str, Any]] | None + ) -> list[dict[str, Any]] | None: + if not initial_messages: + return None + if not self._should_add_resumption_user_message(initial_messages): + return initial_messages + + return [ + *initial_messages, + { + "role": "MESSAGE_ROLE_USER", + "text": _RESUMPTION_USER_MESSAGE, + }, + ] + + def _build_initial_messages( + self, context: LLMContext + ) -> tuple[list[dict[str, Any]] | None, set[str]]: + initial_messages: list[dict[str, Any]] = [] + tool_call_id_to_name: dict[str, str] = {} + completed_tool_call_ids: set[str] = set() + + for message in context.get_messages(): + if isinstance(message, LLMSpecificMessage): + continue + + async_payload = async_tool_messages.parse_message(message) + if async_payload is not None: + if async_payload.kind == "intermediate": + logger.error( + f"{self}: Ultravox does not support streamed async tool results; " + f"dropping intermediate result from initialMessages for " + f"tool_call_id={async_payload.tool_call_id}." + ) + continue + if async_payload.kind == "final": + initial_message = self._build_ultravox_message( + role="MESSAGE_ROLE_TOOL_RESULT", + text=async_payload.result or "", + invocation_id=async_payload.tool_call_id, + tool_name=tool_call_id_to_name.get(async_payload.tool_call_id), + ) + if initial_message is not None: + initial_messages.append(initial_message) + completed_tool_call_ids.add(async_payload.tool_call_id) + continue + + role = message.get("role") + if role == "user": + initial_message = self._build_ultravox_message( + role="MESSAGE_ROLE_USER", + text=self._extract_text_content(message.get("content")), + ) + if initial_message is not None: + initial_messages.append(initial_message) + elif role == "assistant": + text = self._extract_text_content(message.get("content")) + initial_message = self._build_ultravox_message( + role="MESSAGE_ROLE_AGENT", + text=text, + ) + if initial_message is not None: + initial_messages.append(initial_message) + + tool_calls = message.get("tool_calls") + if isinstance(tool_calls, list): + for tool_call in tool_calls: + if not isinstance(tool_call, dict): + continue + tool_id = tool_call.get("id") + function = tool_call.get("function") + tool_name = ( + function.get("name") if isinstance(function, dict) else None + ) + if isinstance(tool_id, str) and isinstance(tool_name, str): + tool_call_id_to_name[tool_id] = tool_name + initial_message = self._build_ultravox_message( + role="MESSAGE_ROLE_TOOL_CALL", + text="", + invocation_id=tool_id, + tool_name=tool_name, + ) + if initial_message is not None: + initial_messages.append(initial_message) + elif ( + role == "tool" + and message.get("content") != "IN_PROGRESS" + and message.get("content") != "CANCELLED" + ): + tool_call_id = message.get("tool_call_id") + initial_message = self._build_ultravox_message( + role="MESSAGE_ROLE_TOOL_RESULT", + text=self._stringify_tool_result(message.get("content")), + invocation_id=tool_call_id + if isinstance(tool_call_id, str) + else None, + tool_name=( + tool_call_id_to_name.get(tool_call_id) + if isinstance(tool_call_id, str) + else None + ), + ) + if initial_message is not None: + initial_messages.append(initial_message) + if isinstance(tool_call_id, str): + completed_tool_call_ids.add(tool_call_id) + + return (initial_messages or None), completed_tool_call_ids + + @staticmethod + def _build_ultravox_message( + *, + role: str, + text: str | None, + invocation_id: str | None = None, + tool_name: str | None = None, + ) -> dict[str, Any] | None: + if text is None: + return None + + message: dict[str, Any] = { + "role": role, + "text": text, + } + if invocation_id is not None: + message["invocationId"] = invocation_id + if tool_name is not None: + message["toolName"] = tool_name + return message + + @staticmethod + def _should_agent_speak_first( + initial_messages: list[dict[str, Any]] | None, + ) -> bool: + if not initial_messages: + return True + return initial_messages[-1].get("role") in { + "MESSAGE_ROLE_USER", + "MESSAGE_ROLE_TOOL_RESULT", + } + + @staticmethod + def _should_add_resumption_user_message( + initial_messages: list[dict[str, Any]] | None, + ) -> bool: + if not initial_messages: + return False + return initial_messages[-1].get("role") == "MESSAGE_ROLE_TOOL_RESULT" + + @staticmethod + def _is_benign_websocket_close(exc: ConnectionClosed) -> bool: + return any( + close is not None and close.code in {1000, 1001} + for close in (exc.sent, exc.rcvd) + ) + + @staticmethod + def _extract_text_content(content: Any) -> str | None: + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for part in content: + if not isinstance(part, dict): + return None + if part.get("type") != "text": + return None + text = part.get("text") + if not isinstance(text, str): + return None + parts.append(text) + return "\n".join(parts) if parts else None + return None + + @staticmethod + def _stringify_tool_result(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for part in content: + if isinstance(part, dict): + text = part.get("text") + if isinstance(text, str): + parts.append(text) + if parts: + return "".join(parts) + return json.dumps(content, ensure_ascii=True, default=str) diff --git a/api/services/pipecat/realtime_feedback_events.py b/api/services/pipecat/realtime_feedback_events.py new file mode 100644 index 0000000..e140fc6 --- /dev/null +++ b/api/services/pipecat/realtime_feedback_events.py @@ -0,0 +1,163 @@ +"""Shared helpers for building and ordering realtime feedback events.""" + +from typing import Any + +from pipecat.utils.enums import RealtimeFeedbackType + + +def build_node_transition_event( + *, + node_id: str | None, + node_name: str | None, + previous_node_id: str | None, + previous_node_name: str | None, + allow_interrupt: bool = False, +) -> dict[str, Any]: + return { + "type": RealtimeFeedbackType.NODE_TRANSITION.value, + "payload": { + "node_id": node_id, + "node_name": node_name, + "previous_node_id": previous_node_id, + "previous_node_name": previous_node_name, + "allow_interrupt": allow_interrupt, + }, + } + + +def build_user_transcription_event( + *, + text: str, + final: bool, + timestamp: str | None = None, + user_id: str | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "text": text, + "final": final, + } + if timestamp is not None: + payload["timestamp"] = timestamp + if user_id is not None: + payload["user_id"] = user_id + return { + "type": RealtimeFeedbackType.USER_TRANSCRIPTION.value, + "payload": payload, + } + + +def build_bot_text_event( + *, + text: str, + timestamp: str | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = {"text": text} + if timestamp is not None: + payload["timestamp"] = timestamp + return { + "type": RealtimeFeedbackType.BOT_TEXT.value, + "payload": payload, + } + + +def build_function_call_start_event( + *, + function_name: str | None, + tool_call_id: str | None, + arguments: dict[str, Any] | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "function_name": function_name, + "tool_call_id": tool_call_id, + } + if arguments is not None: + payload["arguments"] = arguments + return { + "type": RealtimeFeedbackType.FUNCTION_CALL_START.value, + "payload": payload, + } + + +def serialize_realtime_feedback_tool_result(result: Any) -> str | None: + """Normalize function-call results to the string shape stored in logs.""" + if result is None: + return None + return str(result) + + +def build_function_call_end_event( + *, + function_name: str | None, + tool_call_id: str | None, + result: Any, +) -> dict[str, Any]: + return { + "type": RealtimeFeedbackType.FUNCTION_CALL_END.value, + "payload": { + "function_name": function_name, + "tool_call_id": tool_call_id, + "result": serialize_realtime_feedback_tool_result(result), + }, + } + + +def build_ttfb_metric_event( + *, + ttfb_seconds: float, + processor: str | None, + model: str | None, +) -> dict[str, Any]: + return { + "type": RealtimeFeedbackType.TTFB_METRIC.value, + "payload": { + "ttfb_seconds": ttfb_seconds, + "processor": processor, + "model": model, + }, + } + + +def build_pipeline_error_event( + *, + error: str, + fatal: bool, + processor: str | None = None, + extra_payload: dict[str, Any] | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "error": error, + "fatal": fatal, + } + if processor is not None: + payload["processor"] = processor + if extra_payload: + payload.update(extra_payload) + return { + "type": RealtimeFeedbackType.PIPELINE_ERROR.value, + "payload": payload, + } + + +def stamp_realtime_feedback_event( + event: dict[str, Any], + *, + timestamp: str | None = None, + turn: int | None = None, + node_id: str | None = None, + node_name: str | None = None, +) -> dict[str, Any]: + stamped = dict(event) + if timestamp is not None: + stamped["timestamp"] = timestamp + if turn is not None: + stamped["turn"] = turn + if node_id is not None: + stamped["node_id"] = node_id + if node_name is not None: + stamped["node_name"] = node_name + return stamped + + +def realtime_feedback_event_sort_key(event: dict[str, Any]) -> str: + payload_timestamp = (event.get("payload") or {}).get("timestamp") + return payload_timestamp or event.get("timestamp") or "" diff --git a/api/services/pipecat/realtime_feedback_observer.py b/api/services/pipecat/realtime_feedback_observer.py index f2b0c07..3cc85c6 100644 --- a/api/services/pipecat/realtime_feedback_observer.py +++ b/api/services/pipecat/realtime_feedback_observer.py @@ -27,6 +27,15 @@ from typing import TYPE_CHECKING, Awaitable, Callable, Optional, Set from loguru import logger +from api.services.pipecat.realtime_feedback_events import ( + build_bot_text_event, + build_function_call_end_event, + build_function_call_start_event, + build_pipeline_error_event, + build_ttfb_metric_event, + build_user_transcription_event, +) + if TYPE_CHECKING: from api.services.pipecat.in_memory_buffers import InMemoryLogsBuffer @@ -211,29 +220,23 @@ class RealtimeFeedbackObserver(BaseObserver): # Handle user transcriptions (interim) - WebSocket only elif isinstance(frame, InterimTranscriptionFrame): await self._send_ws( - { - "type": RealtimeFeedbackType.USER_TRANSCRIPTION.value, - "payload": { - "text": frame.text, - "final": False, - "user_id": frame.user_id, - "timestamp": frame.timestamp, - }, - } + build_user_transcription_event( + text=frame.text, + final=False, + user_id=frame.user_id, + timestamp=frame.timestamp, + ) ) # Handle user transcriptions (final) - WebSocket only # Complete turn text is persisted via register_turn_handlers elif isinstance(frame, TranscriptionFrame): await self._send_ws( - { - "type": RealtimeFeedbackType.USER_TRANSCRIPTION.value, - "payload": { - "text": frame.text, - "final": True, - "user_id": frame.user_id, - "timestamp": frame.timestamp, - }, - } + build_user_transcription_event( + text=frame.text, + final=True, + user_id=frame.user_id, + timestamp=frame.timestamp, + ) ) # Handle engine-queued speech (transition/tool messages) marked for # log persistence. The downstream TTSTextFrame(s) from the TTS service @@ -241,23 +244,13 @@ class RealtimeFeedbackObserver(BaseObserver): # to avoid word-level log entries from word-timestamp providers. elif isinstance(frame, TTSSpeakFrame): if getattr(frame, "persist_to_logs", False): - await self._append_to_buffer( - { - "type": RealtimeFeedbackType.BOT_TEXT.value, - "payload": {"text": frame.text}, - } - ) + await self._append_to_buffer(build_bot_text_event(text=frame.text)) # Handle bot TTS text - respect pts timing, WebSocket only # Complete turn text is persisted via register_turn_handlers, # except for frames explicitly flagged persist_to_logs (e.g. recording # transcripts from play_audio) which bypass the aggregator path. elif isinstance(frame, TTSTextFrame): - message = { - "type": RealtimeFeedbackType.BOT_TEXT.value, - "payload": { - "text": frame.text, - }, - } + message = build_bot_text_event(text=frame.text) # If frame has pts, queue it for timed delivery if frame.pts: @@ -280,13 +273,11 @@ class RealtimeFeedbackObserver(BaseObserver): and frame_direction == FrameDirection.DOWNSTREAM ): await self._send_message( - { - "type": RealtimeFeedbackType.FUNCTION_CALL_START.value, - "payload": { - "function_name": frame.function_name, - "tool_call_id": frame.tool_call_id, - }, - } + build_function_call_start_event( + function_name=frame.function_name, + tool_call_id=frame.tool_call_id, + arguments=dict(frame.arguments or {}), + ) ) # Handle function call result elif ( @@ -294,14 +285,11 @@ class RealtimeFeedbackObserver(BaseObserver): and frame_direction == FrameDirection.DOWNSTREAM ): await self._send_message( - { - "type": RealtimeFeedbackType.FUNCTION_CALL_END.value, - "payload": { - "function_name": frame.function_name, - "tool_call_id": frame.tool_call_id, - "result": str(frame.result) if frame.result else None, - }, - } + build_function_call_end_event( + function_name=frame.function_name, + tool_call_id=frame.tool_call_id, + result=frame.result, + ) ) # Handle TTFB metrics - capture LLM generation time only elif isinstance(frame, MetricsFrame): @@ -311,47 +299,42 @@ class RealtimeFeedbackObserver(BaseObserver): # Only send TTFB if it's from an LLM processor if metric_data.processor and "LLM" in metric_data.processor: await self._send_message( - { - "type": RealtimeFeedbackType.TTFB_METRIC.value, - "payload": { - "ttfb_seconds": metric_data.value, - "processor": metric_data.processor, - "model": metric_data.model, - }, - } + build_ttfb_metric_event( + ttfb_seconds=metric_data.value, + processor=metric_data.processor, + model=metric_data.model, + ) ) # Handle pipeline errors elif isinstance(frame, ErrorFrame): processor_name = str(frame.processor) if frame.processor else None - payload = { - "error": frame.error, - "fatal": frame.fatal, - "processor": processor_name, - } + extra_payload: dict[str, object] = {} # Surface structured fields when the underlying exception carries # them (e.g. google.genai APIError: code=1008, status=None, # message="Your project has been denied access..."). exc = frame.exception if exc is not None: exc_type = type(exc).__name__ - payload["exception_type"] = exc_type - payload["exception_message"] = str(exc) + extra_payload["exception_type"] = exc_type + extra_payload["exception_message"] = str(exc) for attr in ("code", "status", "message", "details"): value = getattr(exc, attr, None) - if value is None or attr in payload: + if value is None or attr in extra_payload: continue try: # Ensure the value is JSON-serializable; fall back # to str() for opaque objects (e.g. raw response). json.dumps(value) - payload[attr] = value + extra_payload[attr] = value except (TypeError, ValueError): - payload[attr] = str(value) + extra_payload[attr] = str(value) await self._send_message( - { - "type": RealtimeFeedbackType.PIPELINE_ERROR.value, - "payload": payload, - } + build_pipeline_error_event( + error=frame.error, + fatal=frame.fatal, + processor=processor_name, + extra_payload=extra_payload or None, + ) ) async def _send_ws(self, message: dict): @@ -401,14 +384,11 @@ def register_turn_log_handlers( logs_buffer.increment_turn() try: await logs_buffer.append( - { - "type": RealtimeFeedbackType.USER_TRANSCRIPTION.value, - "payload": { - "text": message.content, - "final": True, - "timestamp": message.timestamp, - }, - } + build_user_transcription_event( + text=message.content, + final=True, + timestamp=message.timestamp, + ) ) except Exception as e: logger.error(f"Failed to append user turn to logs buffer: {e}") @@ -418,13 +398,10 @@ def register_turn_log_handlers( if message.content: try: await logs_buffer.append( - { - "type": RealtimeFeedbackType.BOT_TEXT.value, - "payload": { - "text": message.content, - "timestamp": message.timestamp, - }, - } + build_bot_text_event( + text=message.content, + timestamp=message.timestamp, + ) ) except Exception as e: logger.error(f"Failed to append assistant turn to logs buffer: {e}") diff --git a/api/services/pipecat/run_pipeline.py b/api/services/pipecat/run_pipeline.py index b29f225..6cae498 100644 --- a/api/services/pipecat/run_pipeline.py +++ b/api/services/pipecat/run_pipeline.py @@ -7,6 +7,10 @@ from loguru import logger from api.db import db_client from api.enums import WorkflowRunMode from api.services.configuration.registry import ServiceProviders +from api.services.integrations import ( + IntegrationRuntimeContext, + create_runtime_sessions, +) from api.services.pipecat.audio_config import AudioConfig, create_audio_config from api.services.pipecat.event_handlers import ( register_audio_data_handler, @@ -24,6 +28,9 @@ from api.services.pipecat.pipeline_engine_callbacks_processor import ( ) from api.services.pipecat.pipeline_metrics_aggregator import PipelineMetricsAggregator from api.services.pipecat.pre_call_fetch import execute_pre_call_fetch +from api.services.pipecat.realtime_feedback_events import ( + build_node_transition_event, +) from api.services.pipecat.realtime_feedback_observer import ( RealtimeFeedbackObserver, register_turn_log_handlers, @@ -86,6 +93,53 @@ from pipecat.utils.run_context import set_current_org_id, set_current_run_id ensure_tracing() +def _create_realtime_user_turn_config(provider: str): + """Return user turn strategies and optional local VAD for realtime providers.""" + if provider in { + ServiceProviders.GOOGLE_REALTIME.value, + ServiceProviders.GOOGLE_VERTEX_REALTIME.value, + }: + # Let Gemini Live own barge-in via its server-side VAD, but keep local + # Silero VAD for early user-turn start and speaking-state tracking. + return ( + UserTurnStrategies( + start=[VADUserTurnStartStrategy(enable_interruptions=False)], + stop=[SpeechTimeoutUserTurnStopStrategy()], + ), + SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), + ) + + if provider == ServiceProviders.OPENAI_REALTIME.value: + # OpenAI Realtime already emits speaking-state frames and interruption + # events from the provider, so the aggregator should follow those + # external signals rather than run its own local VAD. + return ( + UserTurnStrategies( + start=[ExternalUserTurnStartStrategy()], + stop=[ExternalUserTurnStopStrategy()], + ), + None, + ) + if provider == ServiceProviders.GROK_REALTIME.value: + # Grok Voice Agent emits server-side speech-start/stop and + # interruption signals, so local VAD should stay out of the way. + return ( + UserTurnStrategies( + start=[ExternalUserTurnStartStrategy()], + stop=[ExternalUserTurnStopStrategy()], + ), + None, + ) + + return ( + UserTurnStrategies( + start=[VADUserTurnStartStrategy()], + stop=[SpeechTimeoutUserTurnStopStrategy()], + ), + SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), + ) + + async def run_pipeline_telephony( websocket, *, @@ -138,6 +192,20 @@ async def run_pipeline_telephony( "telephony_configuration_id" ) + # Resolve effective user config here so the transport can tune its + # bot-stopped-speaking fallback based on is_realtime; pass the resolved + # values into _run_pipeline so it doesn't fetch them again. + from api.services.configuration.resolve import resolve_effective_config + + user_config = await db_client.get_user_configurations(user_id) + run_configs = ( + (workflow_run.definition.workflow_configurations or {}) if workflow_run else {} + ) + user_config = resolve_effective_config( + user_config, run_configs.get("model_overrides") + ) + is_realtime = bool(user_config.is_realtime and user_config.realtime is not None) + spec = telephony_registry.get(provider_name) audio_config = create_audio_config(provider_name) @@ -148,6 +216,7 @@ async def run_pipeline_telephony( workflow.organization_id, ambient_noise_config=ambient_noise_config, telephony_configuration_id=telephony_configuration_id, + is_realtime=is_realtime, **transport_kwargs, ) @@ -158,6 +227,8 @@ async def run_pipeline_telephony( workflow_run_id, user_id, audio_config=audio_config, + workflow_run=workflow_run, + resolved_user_config=user_config, ) except Exception as e: logger.error( @@ -198,11 +269,27 @@ async def run_pipeline_smallwebrtc( # Create audio configuration for WebRTC audio_config = create_audio_config(WorkflowRunMode.SMALLWEBRTC.value) + # Resolve workflow_run + effective user_config here so the transport can + # tune its bot-stopped-speaking fallback based on is_realtime. _run_pipeline + # reuses these via kwargs so we don't fetch twice. + from api.services.configuration.resolve import resolve_effective_config + + workflow_run = await db_client.get_workflow_run(workflow_run_id, user_id) + user_config = await db_client.get_user_configurations(user_id) + run_configs = ( + (workflow_run.definition.workflow_configurations or {}) if workflow_run else {} + ) + user_config = resolve_effective_config( + user_config, run_configs.get("model_overrides") + ) + is_realtime = bool(user_config.is_realtime and user_config.realtime is not None) + transport = await create_webrtc_transport( webrtc_connection, workflow_run_id, audio_config, ambient_noise_config, + is_realtime=is_realtime, ) await _run_pipeline( transport, @@ -212,6 +299,8 @@ async def run_pipeline_smallwebrtc( call_context_vars=call_context_vars, audio_config=audio_config, user_provider_id=user_provider_id, + workflow_run=workflow_run, + resolved_user_config=user_config, ) @@ -223,6 +312,8 @@ async def _run_pipeline( call_context_vars: dict = {}, audio_config: AudioConfig = None, user_provider_id: str | None = None, + workflow_run=None, + resolved_user_config=None, ) -> None: """ Run the pipeline with the given transport and configuration @@ -232,9 +323,12 @@ async def _run_pipeline( workflow_id: The ID of the workflow workflow_run_id: The ID of the workflow run user_id: The ID of the user - mode: The mode of the pipeline (twilio or smallwebrtc) + workflow_run: Pre-fetched workflow run row. Fetched here if None. + resolved_user_config: User configuration with model_overrides already + applied. Fetched and resolved here if None. """ - workflow_run = await db_client.get_workflow_run(workflow_run_id, user_id) + if workflow_run is None: + workflow_run = await db_client.get_workflow_run(workflow_run_id, user_id) # If the workflow run is already completed, we don't need to run it again if workflow_run.is_completed: @@ -246,9 +340,6 @@ async def _run_pipeline( if call_context_vars: merged_call_context_vars = {**merged_call_context_vars, **call_context_vars} - # Get user configuration - user_config = await db_client.get_user_configurations(user_id) - # Get workflow for metadata (name, organization_id, call_disposition_codes) workflow = await db_client.get_workflow(workflow_id, user_id) if not workflow: @@ -286,11 +377,17 @@ async def _run_pipeline( term.strip() for term in dictionary.split(",") if term.strip() ] - # Resolve model overrides from the version onto global user config - from api.services.configuration.resolve import resolve_effective_config + # Resolve model overrides from the version onto global user config (skip + # when the caller already resolved it). + if resolved_user_config is None: + from api.services.configuration.resolve import resolve_effective_config - model_overrides = run_configs.get("model_overrides") - user_config = resolve_effective_config(user_config, model_overrides) + user_config = await db_client.get_user_configurations(user_id) + user_config = resolve_effective_config( + user_config, run_configs.get("model_overrides") + ) + else: + user_config = resolved_user_config # Detect realtime mode (speech-to-speech services like OpenAI Realtime, Gemini Live) is_realtime = user_config.is_realtime and user_config.realtime is not None @@ -381,16 +478,13 @@ async def _run_pipeline( # Update current node on the buffer so subsequent events are tagged in_memory_logs_buffer.set_current_node(node_id, node_name) - message = { - "type": RealtimeFeedbackType.NODE_TRANSITION.value, - "payload": { - "node_id": node_id, - "node_name": node_name, - "previous_node_id": previous_node_id, - "previous_node_name": previous_node_name, - "allow_interrupt": allow_interrupt, - }, - } + message = build_node_transition_event( + node_id=node_id, + node_name=node_name, + previous_node_id=previous_node_id, + previous_node_name=previous_node_name, + allow_interrupt=allow_interrupt, + ) # Send via WebSocket if available if ws_sender: try: @@ -445,6 +539,18 @@ async def _run_pipeline( # Create pipeline components audio_buffer, context = create_pipeline_components(audio_config) + integration_runtime_sessions = create_runtime_sessions( + IntegrationRuntimeContext( + workflow_run_id=workflow_run_id, + workflow_run=workflow_run, + workflow_graph=workflow_graph, + run_definition=run_definition, + user_config=user_config, + is_realtime=is_realtime, + context_messages_provider=lambda: context.messages, + ) + ) + # Set the context, audio_config, and audio_buffer after creation engine.set_context(context) engine.set_audio_config(audio_config) @@ -453,23 +559,20 @@ async def _run_pipeline( correct_aggregation_callback=engine.create_aggregation_correction_callback(), ) + user_mute_strategies = [ + MuteUntilFirstBotCompleteUserMuteStrategy(), + FunctionCallUserMuteStrategy(), + CallbackUserMuteStrategy(should_mute_callback=engine.should_mute_user), + ] + user_vad_analyzer = SileroVADAnalyzer(params=VADParams(stop_secs=0.2)) + # Configure turn strategies based on STT provider, model, and workflow configuration if is_realtime: - # Realtime services do server-side turn detection for response generation, - # but we still need a client-side stop strategy so the user aggregator emits - # UserStoppedSpeakingFrame. Without it, downstream consumers (e.g. voicemail - # detector) and Gemini Live's _finalize_pending flag never see a turn end. - user_turn_strategies = UserTurnStrategies( - start=[VADUserTurnStartStrategy()], - stop=[SpeechTimeoutUserTurnStopStrategy()], + # Realtime services still need user-turn tracking even when the model + # itself owns speech generation and interruption behavior. + user_turn_strategies, user_vad_analyzer = _create_realtime_user_turn_config( + user_config.realtime.provider ) - - # Lets not start the pipeline as muted for Realtime - # - CallbackUserMuteStrategy: mutes based on engine's _mute_pipeline state - user_mute_strategies = [ - FunctionCallUserMuteStrategy(), - CallbackUserMuteStrategy(should_mute_callback=engine.should_mute_user), - ] else: # Deepgram Flux uses external turn detection (VAD + External start/stop) # Other models use configurable turn detection strategy @@ -510,18 +613,11 @@ async def _run_pipeline( stop=[SpeechTimeoutUserTurnStopStrategy()], ) - # - CallbackUserMuteStrategy: mutes based on engine's _mute_pipeline state - user_mute_strategies = [ - MuteUntilFirstBotCompleteUserMuteStrategy(), - FunctionCallUserMuteStrategy(), - CallbackUserMuteStrategy(should_mute_callback=engine.should_mute_user), - ] - user_params = LLMUserAggregatorParams( user_turn_strategies=user_turn_strategies, user_mute_strategies=user_mute_strategies, user_idle_timeout=max_user_idle_timeout, - vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.2)), + vad_analyzer=user_vad_analyzer, ) context_aggregator = LLMContextAggregatorPair( context, assistant_params=assistant_params, user_params=user_params @@ -562,15 +658,14 @@ async def _run_pipeline( ) engine.set_fetch_recording_audio(fetch_audio) - # Voicemail detection works in both modes. In realtime mode the detector sits - # after the realtime LLM and consumes the TranscriptionFrames it broadcasts; - # the LLM gate / TTS gate are not used (the realtime LLM responds to audio - # directly, not LLMContextFrames), so on detection we rely on - # end_call_with_reason to drop the call. voicemail_config = (workflow.workflow_configurations or {}).get( "voicemail_detection", {} ) - if voicemail_config.get("enabled", False): + if is_realtime and voicemail_config.get("enabled", False): + logger.info( + f"Disabling voicemail detection for realtime workflow run {workflow_run_id}" + ) + if voicemail_config.get("enabled", False) and not is_realtime: logger.info(f"Voicemail detection enabled for workflow run {workflow_run_id}") # Create a separate LLM instance for the voicemail sub-pipeline # (can't share with main pipeline as it would mess up frame linking) @@ -648,6 +743,14 @@ async def _run_pipeline( # Create pipeline task with audio configuration task = create_pipeline_task(pipeline, workflow_run_id, audio_config) + for runtime_session in integration_runtime_sessions: + runtime_session.attach(task) + logger.info( + "[integrations] attached runtime session '{}' for workflow run {}", + runtime_session.name, + workflow_run_id, + ) + # Now set the task and transport output on the engine engine.set_task(task) engine.set_transport_output(transport.output()) @@ -710,8 +813,8 @@ async def _run_pipeline( pipeline_metrics_aggregator=pipeline_metrics_aggregator, audio_config=audio_config, pre_call_fetch_task=pre_call_fetch_task, - fetch_recording_audio=fetch_audio, user_provider_id=user_provider_id, + integration_runtime_sessions=integration_runtime_sessions, ) register_audio_data_handler(audio_buffer, workflow_run_id, in_memory_audio_buffer) diff --git a/api/services/pipecat/service_factory.py b/api/services/pipecat/service_factory.py index dcd5841..ad5c357 100644 --- a/api/services/pipecat/service_factory.py +++ b/api/services/pipecat/service_factory.py @@ -1,10 +1,12 @@ from typing import TYPE_CHECKING +import aiohttp from fastapi import HTTPException from loguru import logger from api.constants import MPS_API_URL from api.services.configuration.registry import ServiceProviders +from api.services.pipecat.minimax_tts import MiniMaxOwnedSessionTTSService from pipecat.services.assemblyai.stt import AssemblyAISTTService, AssemblyAISTTSettings from pipecat.services.aws.llm import AWSBedrockLLMService, AWSBedrockLLMSettings from pipecat.services.azure.llm import AzureLLMService, AzureLLMSettings @@ -26,7 +28,15 @@ from pipecat.services.dograh.tts import DograhTTSService, DograhTTSSettings from pipecat.services.elevenlabs.tts import ElevenLabsTTSService, ElevenLabsTTSSettings from pipecat.services.gladia.stt import GladiaSTTService, GladiaSTTSettings from pipecat.services.google.llm import GoogleLLMService, GoogleLLMSettings +from pipecat.services.google.stt import GoogleSTTService, GoogleSTTSettings +from pipecat.services.google.tts import GoogleTTSService, GoogleTTSSettings +from pipecat.services.google.vertex.llm import ( + GoogleVertexLLMService, + GoogleVertexLLMSettings, +) from pipecat.services.groq.llm import GroqLLMService, GroqLLMSettings +from pipecat.services.minimax.llm import MiniMaxLLMService +from pipecat.services.minimax.tts import MiniMaxTTSSettings from pipecat.services.openai.base_llm import OpenAILLMSettings from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.openai.stt import ( @@ -101,6 +111,23 @@ def create_stt_service( api_key=user_config.stt.api_key, settings=OpenAISTTSettings(model=user_config.stt.model), ) + elif user_config.stt.provider == ServiceProviders.GOOGLE.value: + language = getattr(user_config.stt, "language", None) or "en-US" + location = getattr(user_config.stt, "location", None) or "global" + credentials = getattr(user_config.stt, "credentials", None) + + settings_kwargs = {"model": user_config.stt.model} + try: + settings_kwargs["languages"] = [Language(language)] + except ValueError: + settings_kwargs["language_codes"] = [language] + + return GoogleSTTService( + credentials=credentials, + location=location, + settings=GoogleSTTSettings(**settings_kwargs), + sample_rate=audio_config.transport_in_sample_rate, + ) elif user_config.stt.provider == ServiceProviders.CARTESIA.value: return CartesiaSTTService( api_key=user_config.stt.api_key, @@ -241,6 +268,30 @@ def create_tts_service(user_config, audio_config: "AudioConfig"): skip_aggregator_types=["recording_router", "recording"], silence_time_s=1.0, ) + elif user_config.tts.provider == ServiceProviders.GOOGLE.value: + model = getattr(user_config.tts, "model", None) or "chirp_3_hd" + language = getattr(user_config.tts, "language", None) or "en-US" + voice = getattr(user_config.tts, "voice", None) or "en-US-Chirp3-HD-Charon" + speed = getattr(user_config.tts, "speed", None) + location = getattr(user_config.tts, "location", None) or None + credentials = getattr(user_config.tts, "credentials", None) + + settings_kwargs = { + "model": model, + "voice": voice, + "language": language, + } + if speed is not None and speed != 1.0: + settings_kwargs["speaking_rate"] = speed + + return GoogleTTSService( + credentials=credentials, + location=location, + settings=GoogleTTSSettings(**settings_kwargs), + text_filters=[xml_function_tag_filter], + skip_aggregator_types=["recording_router", "recording"], + silence_time_s=1.0, + ) elif user_config.tts.provider == ServiceProviders.ELEVENLABS.value: # Backward compatible with older configuration "Name - voice_id" try: @@ -392,6 +443,40 @@ def create_tts_service(user_config, audio_config: "AudioConfig"): skip_aggregator_types=["recording_router", "recording"], silence_time_s=1.0, ) + elif user_config.tts.provider == ServiceProviders.MINIMAX.value: + group_id = getattr(user_config.tts, "group_id", None) + if not group_id: + raise HTTPException( + status_code=400, + detail="MiniMax TTS requires a group_id. Configure it in your TTS settings.", + ) + voice = getattr(user_config.tts, "voice", None) or "English_Graceful_Lady" + speed = getattr(user_config.tts, "speed", None) or 1.0 + + # Pipecat appends "?GroupId=..." to base_url as-is, so /t2a_v2 must + # already be in the path. + base_url = ( + getattr(user_config.tts, "base_url", None) + or "https://api.minimax.io/v1/t2a_v2" + ).rstrip("/") + if not base_url.endswith("/t2a_v2"): + base_url = f"{base_url}/t2a_v2" + + session = aiohttp.ClientSession() + return MiniMaxOwnedSessionTTSService( + api_key=user_config.tts.api_key, + group_id=group_id, + base_url=base_url, + aiohttp_session=session, + settings=MiniMaxTTSSettings( + model=user_config.tts.model, + voice=voice, + speed=speed, + ), + text_filters=[xml_function_tag_filter], + skip_aggregator_types=["recording_router", "recording"], + silence_time_s=1.0, + ) else: raise HTTPException( status_code=400, detail=f"Invalid TTS provider {user_config.tts.provider}" @@ -401,13 +486,17 @@ def create_tts_service(user_config, audio_config: "AudioConfig"): def create_llm_service_from_provider( provider: str, model: str, - api_key: str, + api_key: str | None, *, base_url: str | None = None, endpoint: str | None = None, aws_access_key: str | None = None, aws_secret_key: str | None = None, aws_region: str | None = None, + project_id: str | None = None, + location: str | None = None, + credentials: str | None = None, + temperature: float | None = None, ): """Create an LLM service from explicit provider/model/api_key. @@ -446,6 +535,13 @@ def create_llm_service_from_provider( api_key=api_key, settings=GoogleLLMSettings(model=model, temperature=0.1), ) + elif provider == ServiceProviders.GOOGLE_VERTEX.value: + return GoogleVertexLLMService( + credentials=credentials, + project_id=project_id, + location=location or "us-east4", + settings=GoogleVertexLLMSettings(model=model, temperature=0.1), + ) elif provider == ServiceProviders.AZURE.value: return AzureLLMService( api_key=api_key, @@ -471,6 +567,15 @@ def create_llm_service_from_provider( api_key=api_key or "none", settings=SpeachesLLMSettings(model=model), ) + elif provider == ServiceProviders.MINIMAX.value: + return MiniMaxLLMService( + api_key=api_key, + base_url=base_url or "https://api.minimax.io/v1", + settings=MiniMaxLLMService.Settings( + model=model, + temperature=temperature if temperature is not None else 1.0, + ), + ) else: raise HTTPException(status_code=400, detail=f"Invalid LLM provider {provider}") @@ -493,6 +598,9 @@ def create_realtime_llm_service(user_config, audio_config: "AudioConfig"): ) if provider == ServiceProviders.OPENAI_REALTIME.value: + from api.services.pipecat.realtime.openai_realtime import ( + DograhOpenAIRealtimeLLMService, + ) from pipecat.services.openai.realtime.events import ( AudioConfiguration, AudioInput, @@ -500,11 +608,10 @@ def create_realtime_llm_service(user_config, audio_config: "AudioConfig"): InputAudioTranscription, SessionProperties, ) - from pipecat.services.openai.realtime.llm import OpenAIRealtimeLLMService - return OpenAIRealtimeLLMService( + return DograhOpenAIRealtimeLLMService( api_key=api_key, - settings=OpenAIRealtimeLLMService.Settings( + settings=DograhOpenAIRealtimeLLMService.Settings( model=model, session_properties=SessionProperties( audio=AudioConfiguration( @@ -518,8 +625,43 @@ def create_realtime_llm_service(user_config, audio_config: "AudioConfig"): ), ), ) + elif provider == ServiceProviders.GROK_REALTIME.value: + from api.services.pipecat.realtime.grok_realtime import ( + DograhGrokRealtimeLLMService, + ) + from pipecat.services.xai.realtime.events import SessionProperties + + return DograhGrokRealtimeLLMService( + api_key=api_key, + settings=DograhGrokRealtimeLLMService.Settings( + model=model, + session_properties=SessionProperties( + voice=voice or "Ara", + ), + ), + ) + elif provider == ServiceProviders.ULTRAVOX_REALTIME.value: + from api.services.pipecat.realtime.ultravox_realtime import ( + DograhUltravoxOneShotInputParams, + DograhUltravoxRealtimeLLMService, + ) + + return DograhUltravoxRealtimeLLMService( + params=DograhUltravoxOneShotInputParams( + api_key=api_key, + model=model, + voice=voice, + output_medium="voice", + ), + settings=DograhUltravoxRealtimeLLMService.Settings( + model=model, + output_medium="voice", + ), + ) elif provider == ServiceProviders.GOOGLE_REALTIME.value: - from pipecat.services.google.gemini_live.llm import GeminiLiveLLMService + from api.services.pipecat.realtime.gemini_live import ( + DograhGeminiLiveLLMService, + ) # Gemini Live enables input/output audio transcription by default # in its _connect() method — no need to configure it explicitly. @@ -529,9 +671,30 @@ def create_realtime_llm_service(user_config, audio_config: "AudioConfig"): } if language: settings_kwargs["language"] = language - return GeminiLiveLLMService( + return DograhGeminiLiveLLMService( api_key=api_key, - settings=GeminiLiveLLMService.Settings(**settings_kwargs), + settings=DograhGeminiLiveLLMService.Settings(**settings_kwargs), + ) + elif provider == ServiceProviders.GOOGLE_VERTEX_REALTIME.value: + from api.services.pipecat.realtime.gemini_live_vertex import ( + DograhGeminiLiveVertexLLMService, + ) + + project_id = getattr(realtime_config, "project_id", None) + location = getattr(realtime_config, "location", None) or "us-east4" + credentials = getattr(realtime_config, "credentials", None) + + settings_kwargs = { + "model": model, + "voice": voice or "Charon", + } + if language: + settings_kwargs["language"] = language + return DograhGeminiLiveVertexLLMService( + credentials=credentials, + project_id=project_id, + location=location, + settings=DograhGeminiLiveVertexLLMService.Settings(**settings_kwargs), ) else: raise HTTPException( @@ -556,5 +719,12 @@ def create_llm_service(user_config): kwargs["aws_access_key"] = user_config.llm.aws_access_key kwargs["aws_secret_key"] = user_config.llm.aws_secret_key kwargs["aws_region"] = user_config.llm.aws_region + elif provider == ServiceProviders.GOOGLE_VERTEX.value: + kwargs["project_id"] = user_config.llm.project_id + kwargs["location"] = user_config.llm.location + kwargs["credentials"] = user_config.llm.credentials + elif provider == ServiceProviders.MINIMAX.value: + kwargs["base_url"] = user_config.llm.base_url + kwargs["temperature"] = user_config.llm.temperature return create_llm_service_from_provider(provider, model, api_key, **kwargs) diff --git a/api/services/pipecat/tracing_config.py b/api/services/pipecat/tracing_config.py index cb5bdbc..6a88af0 100644 --- a/api/services/pipecat/tracing_config.py +++ b/api/services/pipecat/tracing_config.py @@ -254,6 +254,44 @@ async def handle_langfuse_sync(event): unregister_org_langfuse_credentials(org_id) +def build_remote_parent_context(trace_id: str | None): + """Build an OTEL context whose active span carries ``trace_id``. + + Spans started under the returned context join the Langfuse trace identified + by ``trace_id`` (Langfuse groups observations by trace id). The parent span + id is a non-existent placeholder, so spans created under it attach at the + trace root rather than nesting under a real parent span. + + This is the shared primitive behind both post-call QA tracing and text-chat + trace stitching. Returns the context, or ``None`` when tracing is + unavailable or ``trace_id`` is missing/invalid. + """ + if not trace_id: + return None + if not ensure_tracing(): + return None + try: + from opentelemetry.trace import ( + NonRecordingSpan, + SpanContext, + TraceFlags, + set_span_in_context, + ) + + parent_span_context = SpanContext( + trace_id=int(trace_id, 16), + span_id=0x1, + is_remote=True, + trace_flags=TraceFlags(0x01), + ) + return set_span_in_context(NonRecordingSpan(parent_span_context)) + except Exception as e: + logger.warning( + f"Failed to build remote parent context for trace {trace_id}: {e}" + ) + return None + + def get_trace_url(trace_id: str, org_id=None) -> str | None: """Build a Langfuse trace URL, using org-specific host when available.""" if org_id is None: diff --git a/api/services/pipecat/transport_params.py b/api/services/pipecat/transport_params.py new file mode 100644 index 0000000..d206b20 --- /dev/null +++ b/api/services/pipecat/transport_params.py @@ -0,0 +1,25 @@ +"""Shared helpers for tuning pipecat ``TransportParams`` per run mode. + +These live outside ``transport_setup.py`` (which is non-telephony only) so +that both the WebRTC factory there and the telephony provider factories +under ``api.services.telephony.providers//transport.py`` can call +into the same place. +""" + +# Realtime (speech-to-speech) LLMs don't emit ``TTSStoppedFrame``, so the +# bot-stopped-speaking signal relies on the output-queue-drained fallback. +# The default 3s tail leaves a long gap before the assistant aggregator +# closes its turn; 0.5s keeps the conversation snappy without cutting into +# the bot's own audio (audio chunks arrive far more frequently than this). +REALTIME_BOT_VAD_STOP_SECS = 0.5 + + +def realtime_param_overrides(is_realtime: bool) -> dict: + """Return kwargs to splat into ``TransportParams`` for the given run mode. + + Currently this only tunes ``bot_vad_stop_secs``; new realtime-specific + knobs should be added here so each transport stays a thin shim. + """ + if not is_realtime: + return {} + return {"bot_vad_stop_secs": REALTIME_BOT_VAD_STOP_SECS} diff --git a/api/services/pipecat/transport_setup.py b/api/services/pipecat/transport_setup.py index 43de97b..c5a4fca 100644 --- a/api/services/pipecat/transport_setup.py +++ b/api/services/pipecat/transport_setup.py @@ -1,11 +1,12 @@ """Transport factories for non-telephony pipelines. Telephony transports live in their respective ``api.services.telephony.providers//transport.py``. -This module hosts only the shared, non-telephony transports (WebRTC, internal/LoopTalk). +This module hosts only the shared, non-telephony transports (WebRTC). """ from api.services.pipecat.audio_config import AudioConfig from api.services.pipecat.audio_mixer import build_audio_out_mixer +from api.services.pipecat.transport_params import realtime_param_overrides from pipecat.transports.base_transport import TransportParams from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection from pipecat.transports.smallwebrtc.transport import SmallWebRTCTransport @@ -16,6 +17,7 @@ async def create_webrtc_transport( workflow_run_id: int, audio_config: AudioConfig, ambient_noise_config: dict | None = None, + is_realtime: bool = False, ): """Create a transport for WebRTC connections.""" mixer = await build_audio_out_mixer( @@ -30,26 +32,6 @@ async def create_webrtc_transport( audio_in_sample_rate=audio_config.transport_in_sample_rate, audio_out_sample_rate=audio_config.transport_out_sample_rate, audio_out_mixer=mixer, + **realtime_param_overrides(is_realtime), ), ) - - -def create_internal_transport( - workflow_run_id: int, - audio_config: AudioConfig, - latency_seconds: float = 0.0, - ambient_noise_config: dict | None = None, -): - """Create an internal transport for agent-to-agent connections (LoopTalk). - - Args: - workflow_run_id: ID of the workflow run for turn analyzer context - audio_config: Audio configuration for the transport - latency_seconds: Network latency to simulate - - Returns: - InternalTransport instance configured with turn analyzer - """ - pass - # Commented out because looptalk coming in the regular import flow - # was causing issue. May be move this to looptalk/orchestrator.py diff --git a/api/services/pricing/workflow_run_cost.py b/api/services/pricing/workflow_run_cost.py index 3f33d17..6d6010c 100644 --- a/api/services/pricing/workflow_run_cost.py +++ b/api/services/pricing/workflow_run_cost.py @@ -1,9 +1,11 @@ +from decimal import Decimal + from loguru import logger from api.db import db_client from api.enums import WorkflowRunMode from api.services.pricing.cost_calculator import cost_calculator -from api.services.telephony.factory import get_telephony_provider +from api.services.telephony.factory import get_telephony_provider_for_run async def _fetch_telephony_cost(workflow_run) -> dict | None: @@ -27,7 +29,9 @@ async def _fetch_telephony_cost(workflow_run) -> dict | None: logger.warning("Workflow not found for workflow run") raise Exception("Workflow not found") - provider = await get_telephony_provider(workflow.organization_id) + provider = await get_telephony_provider_for_run( + workflow_run, workflow.organization_id + ) call_cost_info = await provider.get_call_cost(call_id) if call_cost_info.get("status") == "error": @@ -61,24 +65,31 @@ async def _update_organization_usage( ) -async def calculate_workflow_run_cost(workflow_run_id: int): - logger.debug("Calculating cost for workflow run") +async def _get_pricing_organization(workflow_run): + workflow = getattr(workflow_run, "workflow", None) + organization_id = getattr(workflow, "organization_id", None) + if organization_id is None and workflow and workflow.user: + organization_id = workflow.user.selected_organization_id + if organization_id is None: + return None + return await db_client.get_organization_by_id(organization_id) - workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) - if not workflow_run: - logger.warning("Workflow run not found") - return - workflow_usage_info = workflow_run.usage_info - if not workflow_usage_info: +async def _build_usage_cost_snapshot( + usage_info: dict | None, + *, + workflow_run=None, + include_telephony_cost: bool = False, + organization=None, + calculated_at: str | None = None, +) -> dict | None: + if not usage_info: logger.warning("No usage info available for workflow run") - return + return None - try: - # Calculate cost breakdown - cost_breakdown = cost_calculator.calculate_total_cost(workflow_usage_info) + cost_breakdown = cost_calculator.calculate_total_cost(usage_info) - # Fetch telephony call cost + if include_telephony_cost and workflow_run is not None: try: telephony_cost = await _fetch_telephony_cost(workflow_run) if telephony_cost: @@ -93,61 +104,127 @@ async def calculate_workflow_run_cost(workflow_run_id: int): logger.error(f"Failed to fetch telephony call cost: {e}") # Don't fail the whole cost calculation if telephony API fails - # Store cost information back to the workflow run - # Convert USD to Dograh Tokens (1 cent = 1 token) - dograh_tokens = round(float(cost_breakdown["total"]) * 100, 2) + total_cost_usd = Decimal(str(cost_breakdown["total"])) + dograh_tokens = float(total_cost_usd * Decimal("100")) - # Get organization to check if it has USD pricing - org = None - charge_usd = None - if ( - workflow_run.workflow - and workflow_run.workflow.user - and workflow_run.workflow.user.selected_organization_id - ): - org = await db_client.get_organization_by_id( - workflow_run.workflow.user.selected_organization_id - ) + if organization is None and workflow_run is not None: + organization = await _get_pricing_organization(workflow_run) - # Calculate USD cost if organization has pricing configured - if org and org.price_per_second_usd: - duration_seconds = workflow_usage_info.get("call_duration_seconds", 0) - charge_usd = duration_seconds * org.price_per_second_usd + charge_usd = None + if organization and organization.price_per_second_usd: + duration_seconds = usage_info.get("call_duration_seconds", 0) + charge_usd = float( + Decimal(str(duration_seconds)) + * Decimal(str(organization.price_per_second_usd)) + ) - cost_info = { - **workflow_run.cost_info, - "cost_breakdown": cost_breakdown, - "total_cost_usd": float(cost_breakdown["total"]), - "dograh_token_usage": dograh_tokens, - "calculated_at": workflow_run.created_at.isoformat(), - "call_duration_seconds": workflow_usage_info["call_duration_seconds"], - } + cost_info = { + "cost_breakdown": cost_breakdown, + "total_cost_usd": float(total_cost_usd), + "dograh_token_usage": dograh_tokens, + "calculated_at": calculated_at + or (workflow_run.created_at.isoformat() if workflow_run is not None else None), + "call_duration_seconds": usage_info.get("call_duration_seconds", 0), + } - # Add USD cost if available - if charge_usd is not None: - cost_info["charge_usd"] = charge_usd - cost_info["price_per_second_usd"] = org.price_per_second_usd + if charge_usd is not None: + cost_info["charge_usd"] = charge_usd + cost_info["price_per_second_usd"] = organization.price_per_second_usd - # Update workflow run with cost information - await db_client.update_workflow_run(run_id=workflow_run_id, cost_info=cost_info) + return cost_info - # Update organization usage if applicable - if org: - try: - duration_seconds = workflow_usage_info.get("call_duration_seconds", 0) - await _update_organization_usage( - org, dograh_tokens, duration_seconds, charge_usd - ) - except Exception as e: + +async def build_workflow_run_cost_info(workflow_run) -> dict | None: + cost_info = await _build_usage_cost_snapshot( + workflow_run.usage_info, + workflow_run=workflow_run, + include_telephony_cost=True, + calculated_at=workflow_run.created_at.isoformat(), + ) + if cost_info is None: + return None + return { + **(workflow_run.cost_info or {}), + **cost_info, + } + + +async def save_workflow_run_cost_info( + workflow_run_id: int, cost_info: dict | None +) -> None: + if cost_info is None: + return + await db_client.update_workflow_run(run_id=workflow_run_id, cost_info=cost_info) + + +async def apply_workflow_run_usage_to_organization( + workflow_run, cost_info: dict | None +) -> None: + if cost_info is None: + return + + org = await _get_pricing_organization(workflow_run) + if not org: + return + + await _update_organization_usage( + org, + float(cost_info.get("dograh_token_usage") or 0), + float(cost_info.get("call_duration_seconds") or 0), + cost_info.get("charge_usd"), + ) + + +async def apply_usage_delta_to_organization( + workflow_run, usage_info: dict | None +) -> dict | None: + org = await _get_pricing_organization(workflow_run) + if not org: + return None + + cost_info = await _build_usage_cost_snapshot(usage_info, organization=org) + if cost_info is None: + return None + + await _update_organization_usage( + org, + float(cost_info.get("dograh_token_usage") or 0), + float(cost_info.get("call_duration_seconds") or 0), + cost_info.get("charge_usd"), + ) + return cost_info + + +async def calculate_workflow_run_cost(workflow_run_id: int): + logger.debug("Calculating cost for workflow run") + + workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) + if not workflow_run: + logger.warning("Workflow run not found") + return + + try: + cost_info = await build_workflow_run_cost_info(workflow_run) + if cost_info is None: + return + + await save_workflow_run_cost_info(workflow_run_id, cost_info) + + try: + await apply_workflow_run_usage_to_organization(workflow_run, cost_info) + except Exception as e: + org = await _get_pricing_organization(workflow_run) + if org: logger.error( f"Failed to update organization usage for org {org.id}: {e}" ) - # Don't fail the whole task if usage update fails + else: + logger.error(f"Failed to update organization usage: {e}") + # Don't fail the whole cost calculation if usage update fails logger.info( - f"Calculated cost for workflow run: ${cost_breakdown['total']:.6f} USD ({dograh_tokens} Dograh Tokens)" + f"Calculated cost for workflow run: ${cost_info['total_cost_usd']:.6f} USD ({cost_info['dograh_token_usage']} Dograh Tokens)" ) - except Exception as e: logger.error(f"Error calculating cost for workflow run: {e}") raise diff --git a/api/services/telephony/AGENTS.md b/api/services/telephony/AGENTS.md new file mode 100644 index 0000000..873ac8f --- /dev/null +++ b/api/services/telephony/AGENTS.md @@ -0,0 +1,10 @@ +# Telephony + +Shared telephony code lives here. Provider-specific code lives in `providers/`; +read `providers/AGENTS.md` before changing a provider package. + +- Keep cross-provider contracts, registry/factory wiring, shared status/transfer handling, and org-scoped config resolution in this folder. +- Keep provider-specific transports, serializers, config models, and webhook handlers in `providers/`. +- Resolve providers through the shared telephony helpers in this folder; do not instantiate provider classes directly from routes, tasks, or unrelated services. +- Keep telephony config lookups tenant-safe and respect any run-scoped telephony configuration carried on a workflow run. +- Keep provider-specific HTTP routes in provider packages; shared route glue belongs in `api/routes/`. diff --git a/api/services/telephony/README.md b/api/services/telephony/README.md index 812b864..37c113b 100644 --- a/api/services/telephony/README.md +++ b/api/services/telephony/README.md @@ -13,10 +13,16 @@ Business Logic → TelephonyProvider (Interface) → Concrete Provider (Twilio, ### Using the Provider in Code ```python -from api.services.telephony.factory import get_telephony_provider +from api.services.telephony.factory import ( + get_default_telephony_provider, + get_telephony_provider_by_id, +) -# Get provider based on organization config -provider = await get_telephony_provider(organization_id) +# Get the org's default outbound provider +provider = await get_default_telephony_provider(organization_id) + +# Or resolve a specific telephony configuration row +provider = await get_telephony_provider_by_id(config_id, organization_id) # Initiate a call result = await provider.initiate_call( @@ -47,7 +53,7 @@ See the [Custom Provider Guide](https://docs.dograh.com/integrations/telephony/c Quick checklist: 1. Create `providers/your_provider.py` implementing `TelephonyProvider` -2. Update `factory.py` to include your provider +2. Register the package in `providers/__init__.py` and add its schemas to `api/schemas/telephony_config.py` 3. Write unit tests 4. Update documentation @@ -107,7 +113,7 @@ class MockProvider(TelephonyProvider): # Implement other required methods... # In tests -@patch('api.services.telephony.factory.get_telephony_provider') +@patch('api.services.telephony.factory.get_default_telephony_provider') async def test_call_initiation(mock_get_provider): mock_get_provider.return_value = MockProvider() # Test your business logic @@ -141,8 +147,8 @@ await service.initiate_call(...) New code: ```python -from api.services.telephony.factory import get_telephony_provider -provider = await get_telephony_provider(org_id) +from api.services.telephony.factory import get_default_telephony_provider +provider = await get_default_telephony_provider(org_id) await provider.initiate_call(...) ``` @@ -164,4 +170,4 @@ await provider.initiate_call(...) - [User Documentation](https://docs.dograh.com/integrations/telephony/overview) - [Twilio Integration](https://docs.dograh.com/integrations/telephony/twilio) - [Custom Providers](https://docs.dograh.com/integrations/telephony/custom) -- [Webhooks Guide](https://docs.dograh.com/integrations/telephony/webhooks) \ No newline at end of file +- [Webhooks Guide](https://docs.dograh.com/integrations/telephony/webhooks) diff --git a/api/services/telephony/factory.py b/api/services/telephony/factory.py index cb2a702..b5ca671 100644 --- a/api/services/telephony/factory.py +++ b/api/services/telephony/factory.py @@ -6,9 +6,7 @@ resolution paths exist: * by config id — the canonical path used by outbound (test calls, campaigns, API triggers) and by the websocket transport once a workflow run has ``initial_context.telephony_configuration_id`` stamped on it. -* by org default — used as a fallback when no specific config is requested - (e.g. the legacy ``/telephony-config`` endpoint, the back-compat - ``get_telephony_provider(organization_id)`` shim). +* by org default — used as a fallback when no specific config is requested. * for inbound — given a detected provider and an account-id from the webhook, iterate the org's configs of that provider and return the one whose stored account-id credential matches. @@ -30,7 +28,7 @@ from api.services.telephony.base import TelephonyProvider async def load_telephony_config_by_id( - telephony_configuration_id: int, + telephony_configuration_id: int | str | None, organization_id: int, ) -> Dict[str, Any]: """Load and normalize the config row by primary key, scoped to the org. @@ -41,17 +39,19 @@ async def load_telephony_config_by_id( or doesn't belong to ``organization_id`` — the org scope is what makes this safe to expose to user-driven request flows. """ - if not telephony_configuration_id: - raise ValueError("telephony_configuration_id is required") + try: + resolved_cfg_id = int(telephony_configuration_id) + except (TypeError, ValueError) as e: + raise ValueError("telephony_configuration_id must be an integer") from e if not organization_id: raise ValueError("organization_id is required") row = await db_client.get_telephony_configuration_for_org( - telephony_configuration_id, organization_id + resolved_cfg_id, organization_id ) if not row: raise ValueError( - f"Telephony configuration {telephony_configuration_id} not found " + f"Telephony configuration {resolved_cfg_id} not found " f"for organization {organization_id}" ) return await _normalize_with_phone_numbers(row) @@ -122,7 +122,7 @@ async def find_telephony_config_for_inbound( async def get_telephony_provider_by_id( - telephony_configuration_id: int, + telephony_configuration_id: int | str | None, organization_id: int, ) -> TelephonyProvider: config = await load_telephony_config_by_id( @@ -144,7 +144,7 @@ async def get_telephony_provider_for_run( still resolve. """ cfg_id = (workflow_run.initial_context or {}).get("telephony_configuration_id") - if cfg_id: + if cfg_id is not None: return await get_telephony_provider_by_id(cfg_id, organization_id) return await get_default_telephony_provider(organization_id) @@ -169,7 +169,7 @@ async def get_telephony_provider_for_inbound( async def load_credentials_for_transport( organization_id: int, - telephony_configuration_id: Optional[int], + telephony_configuration_id: Optional[int | str], expected_provider: str, ) -> Dict[str, Any]: """Helper for per-provider transport modules. @@ -180,10 +180,9 @@ async def load_credentials_for_transport( so legacy runs created before the multi-config migration still work. Raises ValueError when the resolved config is for a different provider. """ - if telephony_configuration_id: - config = await load_telephony_config_by_id( - telephony_configuration_id, organization_id - ) + resolved_cfg_id = telephony_configuration_id + if resolved_cfg_id is not None: + config = await load_telephony_config_by_id(resolved_cfg_id, organization_id) else: config = await load_default_telephony_config(organization_id) @@ -191,43 +190,16 @@ async def load_credentials_for_transport( if actual != expected_provider: raise ValueError( f"Expected {expected_provider} provider, got {actual} " - f"(config_id={telephony_configuration_id}, org={organization_id})" + f"(config_id={resolved_cfg_id}, org={organization_id})" ) return config -# --------------------------------------------------------------------------- -# Back-compat shims -# --------------------------------------------------------------------------- - - -async def load_telephony_config(organization_id: int) -> Dict[str, Any]: - """Deprecated: returns the org's default config. - - Existing callers that don't carry a config id continue to work via this - shim. New code should pass an explicit telephony_configuration_id.""" - return await load_default_telephony_config(organization_id) - - -async def get_telephony_provider(organization_id: int) -> TelephonyProvider: - """Deprecated: returns a provider for the org's default config. - - See ``load_telephony_config`` above. New code should call - ``get_telephony_provider_by_id`` with the resolved config id. - """ - return await get_default_telephony_provider(organization_id) - - async def get_all_telephony_providers() -> List[Type[TelephonyProvider]]: """All registered provider classes — used by inbound webhook detection.""" return [spec.provider_cls for spec in registry.all_specs()] -# --------------------------------------------------------------------------- -# Internals -# --------------------------------------------------------------------------- - - async def _normalize_with_phone_numbers( row: TelephonyConfigurationModel, ) -> Dict[str, Any]: diff --git a/api/services/telephony/providers/AGENTS.md b/api/services/telephony/providers/AGENTS.md new file mode 100644 index 0000000..546b4af --- /dev/null +++ b/api/services/telephony/providers/AGENTS.md @@ -0,0 +1,123 @@ +# Telephony Providers + +Each subdirectory here is a self-registering telephony provider. Adding a new one should touch this folder plus **exactly two lines** outside it. If a change you're making requires editing `factory.py`, `audio_config.py`, `run_pipeline.py`, `routes/telephony.py`, or any frontend file, stop — that's a smell. Push the variation through the registry instead. + +## Anatomy of a provider package + +``` +providers// +├── __init__.py # Required. Builds + register()s ProviderSpec +├── config.py # Required. Pydantic Request + Response, both with `provider: Literal[""]` +├── provider.py # Required. TelephonyProvider subclass +├── transport.py # Required. async create_transport(...) -> FastAPIWebsocketTransport +├── serializers.py # Optional but conventional. Re-export from pipecat +├── routes.py # Optional. APIRouter mounted lazily under /api/v1/telephony +└── strategies.py # Optional. Transfer/Hangup strategies for the frame serializer +``` + +Every file is provider-local. Nothing here imports another provider package. + +## The two edits outside this folder + +After creating `providers//`: + +1. `providers/__init__.py` — add `` to the import-for-side-effects list. Registration runs at import time. +2. `api/schemas/telephony_config.py` — import `ConfigurationRequest`/`Response` and add the request to the `TelephonyConfigRequest` `Union[...]` and the response as an optional field on `TelephonyConfigurationResponse`. + +If you find yourself editing anything else, re-read the registry plumbing first: + +| Want to change... | Source of truth | +| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| Outbound provider lookup | `factory.get_default_telephony_provider`, `get_telephony_provider_by_id`, and `get_telephony_provider_for_run` read `registry.get(name).provider_cls` | +| Stored credentials → constructor dict | `ProviderSpec.config_loader` | +| Audio sample rate / VAD rate | `ProviderSpec.transport_sample_rate` (full `AudioConfig` is built in `pipecat/audio_config.py::create_audio_config`) | +| Which transport runs in `run_pipeline_telephony` | `ProviderSpec.transport_factory` | +| Save-request validation + masked response shape | `ProviderSpec.config_request_cls` / `config_response_cls` | +| Form rendered by the telephony-config UI | `ProviderSpec.ui_metadata` (`ProviderUIField` list) | +| Which credential masks on read | `ui_metadata.fields[*].sensitive=True` (no separate list) | +| Inbound webhook → config row matching | `ProviderSpec.account_id_credential_field` | +| HTTP routes (answer URL, status callbacks) | `providers//routes.py` (auto-mounted via `importlib`) | + +## ProviderSpec — minimum viable shape + +```python +SPEC = ProviderSpec( + name="", # registry key, WorkflowRunMode value, stored discriminator + provider_cls=YourProvider, + config_loader=_config_loader, # raw dict from DB → constructor dict + transport_factory=create_transport, + transport_sample_rate=8000, # wire-format rate; pipecat derives the full AudioConfig + config_request_cls=YourProviderConfigurationRequest, + config_response_cls=YourProviderConfigurationResponse, + ui_metadata=ProviderUIMetadata(...), # drives the form UI + account_id_credential_field="api_key", # "" if provider has no account-id concept +) +register(SPEC) +``` + +`ProviderSpec` is frozen — immutable post-registration. Re-registration with the same instance is a no-op; re-registration with a different instance raises. + +## Registration is import-driven, not config-driven + +`api/services/telephony/__init__.py` imports `providers/` for side effects. Don't add a registration call elsewhere — by the time `factory`, `audio_config`, or `run_pipeline_telephony` look the spec up, the package init has already executed. + +The package init **does not import `routes.py`** — `api/routes/telephony.py::_mount_provider_routers()` walks `registry.all_specs()` and uses `importlib.import_module(f"...providers.{spec.name}.routes")`, treating `ModuleNotFoundError` as "no routes for this provider." This is what keeps `from api.services.telephony.base import TelephonyProvider` from fanning out to every route handler in the app. Don't undo it by importing `.routes` from `__init__.py`. + +## Conventions + +### `provider: Literal[""]` on both Request and Response + +Pydantic's discriminated union dispatches on this field. Forgetting `Literal` makes the union accept any provider's payload as yours. Default it to the literal so save calls don't have to send it explicitly. + +### Transports load credentials lazily + +Always: + +```python +from api.services.telephony.factory import load_credentials_for_transport + +config = await load_credentials_for_transport( + organization_id, telephony_configuration_id, expected_provider="", +) +``` + +Never read the org's default config from `transport.py`. The workflow run carries `telephony_configuration_id` in `initial_context` for multi-config orgs; `load_credentials_for_transport` resolves the right row and validates the provider matches. + +### `_config_loader` is a pure dict reshape + +It runs over `TelephonyConfigurationModel.credentials` (the JSONB column). Don't do I/O in it. Don't pull `from_numbers` from credentials — the factory attaches active phone numbers from `telephony_phone_numbers` after the loader runs, by joining and normalizing addresses. + +### Sensitive fields + +Mark every credential field `sensitive=True` in `ProviderUIMetadata`. The org routes derive masking from `ui_metadata`, not from a separate hardcoded list. If you re-submit a masked value, `preserve_masked_fields` restores the original — relying on this means you should never write `sensitive=False` on a real secret to "make the form simpler." + +### Inbound webhook routing + +When multiple configs of the same provider live in one org (e.g. two Twilio sub-accounts), the inbound dispatcher matches the webhook to a config by `credentials[]`. Set this to whatever your provider stamps on inbound payloads (`account_sid` for Twilio, `auth_id` for Plivo, etc.). Set `""` only when the provider truly has no account-id concept (e.g. ARI — there's at most one config per org). + +### `configure_inbound` defaults to no-op + +Override only when the provider supports programmatic webhook binding (Plivo `application_id`, Telnyx app config). Markup-response providers that learn the webhook URL from console-side configuration leave the default. Returning `ProviderSyncResult(ok=False, message="...")` surfaces a non-fatal warning to the user without aborting the DB write. + +## Reference implementations + +Pick the closest shape and copy from it. + +| Provider | Pick when... | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------ | +| `twilio/` | Markup-response (TwiML), HMAC-signed webhooks, conference-style transfers, status callbacks. The most full-featured reference. | +| `plivo/` | Markup-response with multi-callback signature schemes, programmatic answer-URL sync via Application API. | +| `vonage/` | JWT auth, 16 kHz Linear PCM wire format, NCCO JSON responses. | +| `cloudonix/` | SIP-trunk-style with custom transfer/hangup strategies. | +| `telnyx/` | Call-control style — REST calls to answer/stream rather than markup response. | +| `vobiz/` | Body-signed webhooks (signature covers raw bytes). | +| `ari/` | Smallest viable: no `routes.py`, no `verify_inbound_signature`, WebSocket-only, no account-id. | + +## What NOT to do + +- **Don't import another provider's `provider.py` or `transport.py`.** Cross-provider behavior belongs in `services/telephony/` (e.g. `status_processor`, `ari_manager`, `call_transfer_manager`), not in another provider's package. +- **Don't add a hardcoded provider list anywhere.** If you need to iterate, use `registry.all_specs()` / `registry.names()`. +- **Don't add a route under `routes/telephony.py` for a single provider.** Provider-specific handlers go in `providers//routes.py`. Cross-provider handlers (`/inbound/run`, `/twiml`) stay in `routes/telephony.py`. +- **Don't import `.routes` from a provider's `__init__.py`.** That's the cycle we deliberately broke — see "Registration is import-driven." +- **Don't write a frontend form for a new provider.** The UI consumes `GET /api/v1/organizations/telephony-providers/metadata` and renders generically from `ProviderUIField`. If a `field.type` you need doesn't exist (`text`/`password`/`textarea`/`string-array`/`number`), extend the shared telephony UI under `ui/src/components/telephony/` once — not per provider. +- **Don't run a database migration to add a provider.** The discriminator lives in JSONB credentials and a `VARCHAR(64)` `mode` column; nothing in the DB schema knows the set of provider names. diff --git a/api/services/telephony/providers/CLAUDE.md b/api/services/telephony/providers/CLAUDE.md index 4db70d8..43c994c 100644 --- a/api/services/telephony/providers/CLAUDE.md +++ b/api/services/telephony/providers/CLAUDE.md @@ -1,123 +1 @@ -# Telephony Providers - -Each subdirectory here is a self-registering telephony provider. Adding a new one should touch this folder plus **exactly two lines** outside it. If a change you're making requires editing `factory.py`, `audio_config.py`, `run_pipeline.py`, `routes/telephony.py`, or any frontend file, stop — that's a smell. Push the variation through the registry instead. - -## Anatomy of a provider package - -``` -providers// -├── __init__.py # Required. Builds + register()s ProviderSpec -├── config.py # Required. Pydantic Request + Response, both with `provider: Literal[""]` -├── provider.py # Required. TelephonyProvider subclass -├── transport.py # Required. async create_transport(...) -> FastAPIWebsocketTransport -├── serializers.py # Optional but conventional. Re-export from pipecat -├── routes.py # Optional. APIRouter mounted lazily under /api/v1/telephony -└── strategies.py # Optional. Transfer/Hangup strategies for the frame serializer -``` - -Every file is provider-local. Nothing here imports another provider package. - -## The two edits outside this folder - -After creating `providers//`: - -1. `providers/__init__.py` — add `` to the import-for-side-effects list. Registration runs at import time. -2. `api/schemas/telephony_config.py` — import `ConfigurationRequest`/`Response` and add the request to the `TelephonyConfigRequest` `Union[...]` and the response as an optional field on `TelephonyConfigurationResponse`. - -If you find yourself editing anything else, re-read the registry plumbing first: - -| Want to change... | Source of truth | -| --- | --- | -| Outbound provider lookup | `factory.get_telephony_provider*` reads `registry.get(name).provider_cls` | -| Stored credentials → constructor dict | `ProviderSpec.config_loader` | -| Audio sample rate / VAD rate | `ProviderSpec.transport_sample_rate` (full `AudioConfig` is built in `pipecat/audio_config.py::create_audio_config`) | -| Which transport runs in `run_pipeline_telephony` | `ProviderSpec.transport_factory` | -| Save-request validation + masked response shape | `ProviderSpec.config_request_cls` / `config_response_cls` | -| Form rendered by the telephony-config UI | `ProviderSpec.ui_metadata` (`ProviderUIField` list) | -| Which credential masks on read | `ui_metadata.fields[*].sensitive=True` (no separate list) | -| Inbound webhook → config row matching | `ProviderSpec.account_id_credential_field` | -| HTTP routes (answer URL, status callbacks) | `providers//routes.py` (auto-mounted via `importlib`) | - -## ProviderSpec — minimum viable shape - -```python -SPEC = ProviderSpec( - name="", # registry key, WorkflowRunMode value, stored discriminator - provider_cls=YourProvider, - config_loader=_config_loader, # raw dict from DB → constructor dict - transport_factory=create_transport, - transport_sample_rate=8000, # wire-format rate; pipecat derives the full AudioConfig - config_request_cls=YourProviderConfigurationRequest, - config_response_cls=YourProviderConfigurationResponse, - ui_metadata=ProviderUIMetadata(...), # drives the form UI - account_id_credential_field="api_key", # "" if provider has no account-id concept -) -register(SPEC) -``` - -`ProviderSpec` is frozen — immutable post-registration. Re-registration with the same instance is a no-op; re-registration with a different instance raises. - -## Registration is import-driven, not config-driven - -`api/services/telephony/__init__.py` imports `providers/` for side effects. Don't add a registration call elsewhere — by the time `factory`, `audio_config`, or `run_pipeline_telephony` look the spec up, the package init has already executed. - -The package init **does not import `routes.py`** — `api/routes/telephony.py::_mount_provider_routers()` walks `registry.all_specs()` and uses `importlib.import_module(f"...providers.{spec.name}.routes")`, treating `ModuleNotFoundError` as "no routes for this provider." This is what keeps `from api.services.telephony.base import TelephonyProvider` from fanning out to every route handler in the app. Don't undo it by importing `.routes` from `__init__.py`. - -## Conventions - -### `provider: Literal[""]` on both Request and Response - -Pydantic's discriminated union dispatches on this field. Forgetting `Literal` makes the union accept any provider's payload as yours. Default it to the literal so save calls don't have to send it explicitly. - -### Transports load credentials lazily - -Always: - -```python -from api.services.telephony.factory import load_credentials_for_transport - -config = await load_credentials_for_transport( - organization_id, telephony_configuration_id, expected_provider="", -) -``` - -Never read the org's default config from `transport.py`. The workflow run carries `telephony_configuration_id` in `initial_context` for multi-config orgs; `load_credentials_for_transport` resolves the right row and validates the provider matches. - -### `_config_loader` is a pure dict reshape - -It runs over `TelephonyConfigurationModel.credentials` (the JSONB column). Don't do I/O in it. Don't pull `from_numbers` from credentials — the factory attaches active phone numbers from `telephony_phone_numbers` after the loader runs, by joining and normalizing addresses. - -### Sensitive fields - -Mark every credential field `sensitive=True` in `ProviderUIMetadata`. The org routes derive masking from `ui_metadata`, not from a separate hardcoded list. If you re-submit a masked value, `preserve_masked_fields` restores the original — relying on this means you should never write `sensitive=False` on a real secret to "make the form simpler." - -### Inbound webhook routing - -When multiple configs of the same provider live in one org (e.g. two Twilio sub-accounts), the inbound dispatcher matches the webhook to a config by `credentials[]`. Set this to whatever your provider stamps on inbound payloads (`account_sid` for Twilio, `auth_id` for Plivo, etc.). Set `""` only when the provider truly has no account-id concept (e.g. ARI — there's at most one config per org). - -### `configure_inbound` defaults to no-op - -Override only when the provider supports programmatic webhook binding (Plivo `application_id`, Telnyx app config). Markup-response providers that learn the webhook URL from console-side configuration leave the default. Returning `ProviderSyncResult(ok=False, message="...")` surfaces a non-fatal warning to the user without aborting the DB write. - -## Reference implementations - -Pick the closest shape and copy from it. - -| Provider | Pick when... | -| --- | --- | -| `twilio/` | Markup-response (TwiML), HMAC-signed webhooks, conference-style transfers, status callbacks. The most full-featured reference. | -| `plivo/` | Markup-response with multi-callback signature schemes, programmatic answer-URL sync via Application API. | -| `vonage/` | JWT auth, 16 kHz Linear PCM wire format, NCCO JSON responses. | -| `cloudonix/` | SIP-trunk-style with custom transfer/hangup strategies. | -| `telnyx/` | Call-control style — REST calls to answer/stream rather than markup response. | -| `vobiz/` | Body-signed webhooks (signature covers raw bytes). | -| `ari/` | Smallest viable: no `routes.py`, no `verify_inbound_signature`, WebSocket-only, no account-id. | - -## What NOT to do - -- **Don't import another provider's `provider.py` or `transport.py`.** Cross-provider behavior belongs in `services/telephony/` (e.g. `status_processor`, `ari_manager`, `call_transfer_manager`), not in another provider's package. -- **Don't add a hardcoded provider list anywhere.** If you need to iterate, use `registry.all_specs()` / `registry.names()`. -- **Don't add a route under `routes/telephony.py` for a single provider.** Provider-specific handlers go in `providers//routes.py`. Cross-provider handlers (`/inbound/run`, `/twiml`) stay in `routes/telephony.py`. -- **Don't import `.routes` from a provider's `__init__.py`.** That's the cycle we deliberately broke — see "Registration is import-driven." -- **Don't write a frontend form for a new provider.** The UI consumes `GET /api/v1/organizations/telephony-providers/metadata` and renders generically from `ProviderUIField`. If a `field.type` you need doesn't exist (`text`/`password`/`textarea`/`string-array`/`number`), extend the renderer in `ui/src/app/(authenticated)/telephony-configurations/` once — not per provider. -- **Don't run a database migration to add a provider.** The discriminator lives in JSONB credentials and a `VARCHAR(64)` `mode` column; nothing in the DB schema knows the set of provider names. +@AGENTS.md diff --git a/api/services/telephony/providers/ari/transport.py b/api/services/telephony/providers/ari/transport.py index 58efea5..53cfd92 100644 --- a/api/services/telephony/providers/ari/transport.py +++ b/api/services/telephony/providers/ari/transport.py @@ -8,6 +8,7 @@ from pipecat.transports.websocket.fastapi import ( from api.services.pipecat.audio_config import AudioConfig from api.services.pipecat.audio_mixer import build_audio_out_mixer +from api.services.pipecat.transport_params import realtime_param_overrides from api.services.telephony.factory import load_credentials_for_transport from .serializers import AsteriskFrameSerializer @@ -22,6 +23,7 @@ async def create_transport( *, ambient_noise_config: dict | None = None, telephony_configuration_id: int | None = None, + is_realtime: bool = False, channel_id: str, ): """Create a transport for Asterisk ARI connections.""" @@ -65,5 +67,6 @@ async def create_transport( audio_out_sample_rate=audio_config.transport_out_sample_rate, audio_out_mixer=mixer, serializer=serializer, + **realtime_param_overrides(is_realtime), ), ) diff --git a/api/services/telephony/providers/cloudonix/provider.py b/api/services/telephony/providers/cloudonix/provider.py index 349335a..28ebcea 100644 --- a/api/services/telephony/providers/cloudonix/provider.py +++ b/api/services/telephony/providers/cloudonix/provider.py @@ -544,12 +544,13 @@ class CloudonixProvider(TelephonyProvider): try: stream_sid = start_msg["start"]["streamSid"] call_sid = start_msg["start"]["callSid"] + call_session = start_msg["start"]["session"] except KeyError: - logger.error("Missing streamSid or callSid in start message") + logger.error("Missing streamSid or callSid or session in start message") await websocket.close(code=4400, reason="Missing stream identifiers") return - if not await self._validate_session(domain_id, call_sid, bearer_token): + if not await self._validate_session(domain_id, call_session, bearer_token): await websocket.close( code=4400, reason="Cloudonix session validation failed" ) @@ -557,7 +558,7 @@ class CloudonixProvider(TelephonyProvider): logger.info( f"Cloudonix agent-stream connected for workflow_run " - f"{workflow_run_id} stream_sid={stream_sid} call_sid={call_sid} " + f"{workflow_run_id} stream_sid={stream_sid} call_sid={call_sid} session={call_session}" f"telephony_configuration_id={config.id}" ) @@ -567,9 +568,9 @@ class CloudonixProvider(TelephonyProvider): workflow_id=workflow_id, workflow_run_id=workflow_run_id, user_id=user_id, - call_id=call_sid, + call_id=call_session, transport_kwargs={ - "call_id": call_sid, + "call_id": call_session, "stream_sid": stream_sid, "bearer_token": bearer_token, "domain_id": domain_id, @@ -581,17 +582,15 @@ class CloudonixProvider(TelephonyProvider): raise async def _validate_session( - self, domain_id: str, call_id: str, bearer_token: str + self, domain_id: str, call_session: str, bearer_token: str ) -> bool: """Confirm the session is live with Cloudonix. - Hits ``GET /customers/self/domains/{domain_id}/sessions/{call_id}`` + Hits ``GET /customers/self/domains/{domain_id}/sessions/{call_session}`` with the supplied bearer token. A 200 response means both the token is valid and the session exists. """ - endpoint = ( - f"{self.base_url}/customers/self/domains/{domain_id}/sessions/{call_id}" - ) + endpoint = f"{self.base_url}/customers/self/domains/{domain_id}/sessions/{call_session}" headers = { "Authorization": f"Bearer {bearer_token}", "Content-Type": "application/json", @@ -605,13 +604,13 @@ class CloudonixProvider(TelephonyProvider): logger.error( f"Cloudonix session validation failed: " f"HTTP {response.status} domain_id={domain_id} " - f"call_id={call_id} body={body}" + f"call_id={call_session} body={body}" ) return False except Exception as e: logger.error( f"Cloudonix session validation error for domain_id={domain_id} " - f"call_id={call_id}: {e}" + f"call_id={call_session}: {e}" ) return False @@ -952,10 +951,24 @@ class CloudonixProvider(TelephonyProvider): **kwargs: Any, ) -> Dict[str, Any]: """ - Cloudonix provider does not support call transfers. + Initiate a call transfer via Cloudonix. + + Uses inline CXML to put the destination into a conference when they answer, + and a status callback to track the transfer outcome. + + Args: + destination: The destination phone number (E.164 format) + transfer_id: Unique identifier for tracking this transfer + conference_name: Name of the conference to join the destination into + timeout: Transfer timeout in seconds + **kwargs: Additional Twilio-specific parameters + + Returns: + Dict containing transfer result information Raises: - NotImplementedError: Cloudonix call transfers are yet to be implemented + ValueError: If provider configuration is invalid + Exception: If Twilio API call fails """ raise NotImplementedError("Cloudonix provider does not support call transfers") diff --git a/api/services/telephony/providers/cloudonix/transport.py b/api/services/telephony/providers/cloudonix/transport.py index cd91518..c38c975 100644 --- a/api/services/telephony/providers/cloudonix/transport.py +++ b/api/services/telephony/providers/cloudonix/transport.py @@ -8,6 +8,7 @@ from pipecat.transports.websocket.fastapi import ( from api.services.pipecat.audio_config import AudioConfig from api.services.pipecat.audio_mixer import build_audio_out_mixer +from api.services.pipecat.transport_params import realtime_param_overrides from api.services.telephony.factory import load_credentials_for_transport from .serializers import CloudonixFrameSerializer @@ -22,6 +23,7 @@ async def create_transport( *, ambient_noise_config: dict | None = None, telephony_configuration_id: int | None = None, + is_realtime: bool = False, call_id: str, stream_sid: str, bearer_token: str | None = None, @@ -69,5 +71,6 @@ async def create_transport( audio_out_mixer=mixer, serializer=serializer, audio_out_10ms_chunks=2, + **realtime_param_overrides(is_realtime), ), ) diff --git a/api/services/telephony/providers/plivo/routes.py b/api/services/telephony/providers/plivo/routes.py index be1ecd7..ff64b37 100644 --- a/api/services/telephony/providers/plivo/routes.py +++ b/api/services/telephony/providers/plivo/routes.py @@ -5,9 +5,8 @@ provider registry — see ProviderSpec.router. """ import json -from typing import Optional -from fastapi import APIRouter, Header, Request +from fastapi import APIRouter, Request from loguru import logger from pipecat.utils.run_context import set_current_run_id from starlette.responses import HTMLResponse @@ -18,7 +17,6 @@ from api.services.telephony.status_processor import ( StatusCallbackRequest, _process_status_update, ) -from api.utils.common import get_backend_endpoints router = APIRouter() @@ -26,9 +24,6 @@ router = APIRouter() async def _handle_plivo_status_callback( workflow_run_id: int, request: Request, - x_plivo_signature_v3: Optional[str], - x_plivo_signature_ma_v3: Optional[str], - x_plivo_signature_v3_nonce: Optional[str], ): set_current_run_id(workflow_run_id) @@ -52,19 +47,14 @@ async def _handle_plivo_status_callback( workflow_run, workflow.organization_id ) - signature = x_plivo_signature_v3 or x_plivo_signature_ma_v3 - if signature: - backend_endpoint, _ = await get_backend_endpoints() - callback_kind = request.url.path.split("/")[-2] - full_url = f"{backend_endpoint}/api/v1/telephony/plivo/{callback_kind}/{workflow_run_id}" - is_valid = await provider.verify_inbound_signature( - full_url, - callback_data, - dict(request.headers), - ) - if not is_valid: - logger.warning(f"[run {workflow_run_id}] Invalid Plivo webhook signature") - return {"status": "error", "reason": "invalid_signature"} + is_valid = await provider.verify_inbound_signature( + str(request.url), + callback_data, + dict(request.headers), + ) + if not is_valid: + logger.warning(f"[run {workflow_run_id}] Invalid Plivo webhook signature") + return {"status": "error", "reason": "invalid_signature"} parsed_data = provider.parse_status_callback(callback_data) status_update = StatusCallbackRequest( @@ -88,9 +78,6 @@ async def handle_plivo_xml_webhook( workflow_run_id: int, organization_id: int, request: Request, - x_plivo_signature_v3: Optional[str] = Header(None), - x_plivo_signature_ma_v3: Optional[str] = Header(None), - x_plivo_signature_v3_nonce: Optional[str] = Header(None), ): """ Handle initial webhook from Plivo when an outbound call is answered. @@ -103,26 +90,16 @@ async def handle_plivo_xml_webhook( form_data = await request.form() callback_data = dict(form_data) - signature = x_plivo_signature_v3 or x_plivo_signature_ma_v3 - if signature: - backend_endpoint, _ = await get_backend_endpoints() - full_url = ( - f"{backend_endpoint}/api/v1/telephony/plivo-xml" - f"?workflow_id={workflow_id}" - f"&user_id={user_id}" - f"&workflow_run_id={workflow_run_id}" - f"&organization_id={organization_id}" + is_valid = await provider.verify_inbound_signature( + str(request.url), callback_data, dict(request.headers) + ) + if not is_valid: + logger.warning( + f"[run {workflow_run_id}] Invalid Plivo signature on answer webhook" ) - is_valid = await provider.verify_inbound_signature( - full_url, callback_data, dict(request.headers) + return provider.generate_error_response( + "invalid_signature", "Invalid webhook signature." ) - if not is_valid: - logger.warning( - f"[run {workflow_run_id}] Invalid Plivo signature on answer webhook" - ) - return provider.generate_error_response( - "invalid_signature", "Invalid webhook signature." - ) call_id = callback_data.get("CallUUID") or callback_data.get("RequestUUID") if call_id: @@ -142,33 +119,15 @@ async def handle_plivo_xml_webhook( async def handle_plivo_hangup_callback( workflow_run_id: int, request: Request, - x_plivo_signature_v3: Optional[str] = Header(None), - x_plivo_signature_ma_v3: Optional[str] = Header(None), - x_plivo_signature_v3_nonce: Optional[str] = Header(None), ): """Handle Plivo hangup callbacks.""" - return await _handle_plivo_status_callback( - workflow_run_id, - request, - x_plivo_signature_v3, - x_plivo_signature_ma_v3, - x_plivo_signature_v3_nonce, - ) + return await _handle_plivo_status_callback(workflow_run_id, request) @router.post("/plivo/ring-callback/{workflow_run_id}") async def handle_plivo_ring_callback( workflow_run_id: int, request: Request, - x_plivo_signature_v3: Optional[str] = Header(None), - x_plivo_signature_ma_v3: Optional[str] = Header(None), - x_plivo_signature_v3_nonce: Optional[str] = Header(None), ): """Handle Plivo ring callbacks.""" - return await _handle_plivo_status_callback( - workflow_run_id, - request, - x_plivo_signature_v3, - x_plivo_signature_ma_v3, - x_plivo_signature_v3_nonce, - ) + return await _handle_plivo_status_callback(workflow_run_id, request) diff --git a/api/services/telephony/providers/plivo/transport.py b/api/services/telephony/providers/plivo/transport.py index 039c562..d9cd04f 100644 --- a/api/services/telephony/providers/plivo/transport.py +++ b/api/services/telephony/providers/plivo/transport.py @@ -8,6 +8,7 @@ from pipecat.transports.websocket.fastapi import ( from api.services.pipecat.audio_config import AudioConfig from api.services.pipecat.audio_mixer import build_audio_out_mixer +from api.services.pipecat.transport_params import realtime_param_overrides from api.services.telephony.factory import load_credentials_for_transport from .serializers import PlivoFrameSerializer @@ -21,6 +22,7 @@ async def create_transport( *, ambient_noise_config: dict | None = None, telephony_configuration_id: int | None = None, + is_realtime: bool = False, stream_id: str, call_id: str, ): @@ -61,5 +63,6 @@ async def create_transport( audio_out_sample_rate=audio_config.transport_out_sample_rate, audio_out_mixer=mixer, serializer=serializer, + **realtime_param_overrides(is_realtime), ), ) diff --git a/api/services/telephony/providers/telnyx/provider.py b/api/services/telephony/providers/telnyx/provider.py index e78914b..f14e0f1 100644 --- a/api/services/telephony/providers/telnyx/provider.py +++ b/api/services/telephony/providers/telnyx/provider.py @@ -25,7 +25,6 @@ TELNYX_TIMESTAMP_TOLERANCE_SECONDS = 300 TELNYX_PUBLIC_KEY_BYTES = 32 TELNYX_SIGNATURE_BYTES = 64 -from api.constants import TELNYX_WEBHOOK_VERIFICATION_OPTIONAL from api.enums import WorkflowRunMode from api.services.telephony.base import ( CallInitiationResult, @@ -211,12 +210,6 @@ class TelnyxProvider(TelephonyProvider): return False if not self.webhook_public_key: - # REMOVE-AFTER 2026-05-15: transition window. Allow webhooks - # through for configs that haven't added the key yet. Remove this - # branch along with TELNYX_WEBHOOK_VERIFICATION_OPTIONAL after - # the cutoff. - if TELNYX_WEBHOOK_VERIFICATION_OPTIONAL: - return True logger.error("Missing Telnyx webhook_public_key configuration") return False diff --git a/api/services/telephony/providers/telnyx/transport.py b/api/services/telephony/providers/telnyx/transport.py index 393e688..189a741 100644 --- a/api/services/telephony/providers/telnyx/transport.py +++ b/api/services/telephony/providers/telnyx/transport.py @@ -8,6 +8,7 @@ from pipecat.transports.websocket.fastapi import ( from api.services.pipecat.audio_config import AudioConfig from api.services.pipecat.audio_mixer import build_audio_out_mixer +from api.services.pipecat.transport_params import realtime_param_overrides from api.services.telephony.factory import load_credentials_for_transport from .serializers import TelnyxFrameSerializer @@ -22,6 +23,7 @@ async def create_transport( *, ambient_noise_config: dict | None = None, telephony_configuration_id: int | None = None, + is_realtime: bool = False, stream_id: str, call_control_id: str, encoding: str = "PCMU", @@ -64,5 +66,6 @@ async def create_transport( audio_out_sample_rate=audio_config.transport_out_sample_rate, audio_out_mixer=mixer, serializer=serializer, + **realtime_param_overrides(is_realtime), ), ) diff --git a/api/services/telephony/providers/twilio/routes.py b/api/services/telephony/providers/twilio/routes.py index e8ac939..c779617 100644 --- a/api/services/telephony/providers/twilio/routes.py +++ b/api/services/telephony/providers/twilio/routes.py @@ -5,9 +5,8 @@ provider registry — see ProviderSpec.router. """ import json -from typing import Optional -from fastapi import APIRouter, Header, Request +from fastapi import APIRouter, HTTPException, Request from loguru import logger from pipecat.utils.run_context import set_current_run_id from starlette.responses import HTMLResponse @@ -18,14 +17,17 @@ from api.services.telephony.status_processor import ( StatusCallbackRequest, _process_status_update, ) -from api.utils.common import get_backend_endpoints router = APIRouter() @router.post("/twiml", include_in_schema=False) async def handle_twiml_webhook( - workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int + workflow_id: int, + user_id: int, + workflow_run_id: int, + organization_id: int, + request: Request, ): """ Handle initial webhook from telephony provider. @@ -34,6 +36,18 @@ async def handle_twiml_webhook( workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) provider = await get_telephony_provider_for_run(workflow_run, organization_id) + callback_data = dict(await request.form()) + + is_valid = await provider.verify_inbound_signature( + str(request.url), + callback_data, + dict(request.headers), + ) + if not is_valid: + logger.warning( + f"[run {workflow_run_id}] Invalid Twilio signature on answer webhook" + ) + raise HTTPException(status_code=401, detail="Invalid webhook signature") response_content = await provider.get_webhook_response( workflow_id, user_id, workflow_run_id @@ -46,7 +60,6 @@ async def handle_twiml_webhook( async def handle_twilio_status_callback( workflow_run_id: int, request: Request, - x_webhook_signature: Optional[str] = Header(None), ): """Handle Twilio-specific status callbacks.""" set_current_run_id(workflow_run_id) @@ -75,19 +88,14 @@ async def handle_twilio_status_callback( workflow_run, workflow.organization_id ) - if x_webhook_signature: - backend_endpoint, _ = await get_backend_endpoints() - full_url = f"{backend_endpoint}/api/v1/telephony/twilio/status-callback/{workflow_run_id}" - - is_valid = await provider.verify_webhook_signature( - full_url, callback_data, x_webhook_signature - ) - - if not is_valid: - logger.warning( - f"Invalid webhook signature for workflow run {workflow_run_id}" - ) - return {"status": "error", "reason": "invalid_signature"} + is_valid = await provider.verify_inbound_signature( + str(request.url), + callback_data, + dict(request.headers), + ) + if not is_valid: + logger.warning(f"Invalid webhook signature for workflow run {workflow_run_id}") + raise HTTPException(status_code=401, detail="Invalid webhook signature") # Parse the callback data into generic format parsed_data = provider.parse_status_callback(callback_data) diff --git a/api/services/telephony/providers/twilio/transport.py b/api/services/telephony/providers/twilio/transport.py index d3a4937..6c3f744 100644 --- a/api/services/telephony/providers/twilio/transport.py +++ b/api/services/telephony/providers/twilio/transport.py @@ -8,6 +8,7 @@ from pipecat.transports.websocket.fastapi import ( from api.services.pipecat.audio_config import AudioConfig from api.services.pipecat.audio_mixer import build_audio_out_mixer +from api.services.pipecat.transport_params import realtime_param_overrides from api.services.telephony.factory import load_credentials_for_transport from .serializers import TwilioFrameSerializer @@ -22,6 +23,7 @@ async def create_transport( *, ambient_noise_config: dict | None = None, telephony_configuration_id: int | None = None, + is_realtime: bool = False, stream_sid: str, call_sid: str, ): @@ -60,5 +62,6 @@ async def create_transport( audio_out_sample_rate=audio_config.transport_out_sample_rate, audio_out_mixer=mixer, serializer=serializer, + **realtime_param_overrides(is_realtime), ), ) diff --git a/api/services/telephony/providers/vobiz/routes.py b/api/services/telephony/providers/vobiz/routes.py index 4fffe5b..3c13e4b 100644 --- a/api/services/telephony/providers/vobiz/routes.py +++ b/api/services/telephony/providers/vobiz/routes.py @@ -81,9 +81,9 @@ async def handle_vobiz_hangup_callback( f"[run {workflow_run_id}] Vobiz hangup callback - Headers: {json.dumps(all_headers)}" ) - # Parse the callback data (Vobiz sends form data or JSON) - form_data = await request.form() - callback_data = dict(form_data) + # Parse the callback data from the raw body so signed webhooks can verify + # the exact bytes Vobiz sent without draining the request stream first. + callback_data, raw_body = await parse_webhook_request(request) # TODO: Remove this debug logging after Vobiz team clarifies webhook authentication logger.info( @@ -114,10 +114,6 @@ async def handle_vobiz_hangup_callback( workflow_run, workflow.organization_id ) - # Get raw body for signature verification - raw_body = await request.body() - webhook_body = raw_body.decode("utf-8") - # Verify signature backend_endpoint, _ = await get_backend_endpoints() webhook_url = f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/{workflow_run_id}" @@ -127,7 +123,7 @@ async def handle_vobiz_hangup_callback( callback_data, x_vobiz_signature, x_vobiz_timestamp, - webhook_body, + raw_body, ) if not is_valid: @@ -206,9 +202,9 @@ async def handle_vobiz_ring_callback( f"[run {workflow_run_id}] Vobiz ring callback - Headers: {json.dumps(all_headers)}" ) - # Parse the callback data - form_data = await request.form() - callback_data = dict(form_data) + # Parse the callback data from the raw body so signed webhooks can verify + # the exact bytes Vobiz sent without draining the request stream first. + callback_data, raw_body = await parse_webhook_request(request) # TODO: Remove this debug logging after Vobiz team clarifies webhook authentication logger.info( @@ -240,10 +236,6 @@ async def handle_vobiz_ring_callback( workflow_run, workflow.organization_id ) - # Get raw body for signature verification - raw_body = await request.body() - webhook_body = raw_body.decode("utf-8") - # Verify signature backend_endpoint, _ = await get_backend_endpoints() webhook_url = ( @@ -255,7 +247,7 @@ async def handle_vobiz_ring_callback( callback_data, x_vobiz_signature, x_vobiz_timestamp, - webhook_body, + raw_body, ) if not is_valid: @@ -311,9 +303,10 @@ async def handle_vobiz_hangup_callback_by_workflow( ) try: - callback_data, _ = await parse_webhook_request(request) + callback_data, raw_body = await parse_webhook_request(request) except ValueError: callback_data = {} + raw_body = "" call_uuid = callback_data.get("CallUUID") or callback_data.get("call_uuid") logger.info( @@ -356,8 +349,6 @@ async def handle_vobiz_hangup_callback_by_workflow( ) if x_vobiz_signature: - raw_body = await request.body() - webhook_body = raw_body.decode("utf-8") backend_endpoint, _ = await get_backend_endpoints() webhook_url = f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}" @@ -366,7 +357,7 @@ async def handle_vobiz_hangup_callback_by_workflow( callback_data, x_vobiz_signature, x_vobiz_timestamp, - webhook_body, + raw_body, ) if not is_valid: diff --git a/api/services/telephony/providers/vobiz/transport.py b/api/services/telephony/providers/vobiz/transport.py index 46ac392..96ee333 100644 --- a/api/services/telephony/providers/vobiz/transport.py +++ b/api/services/telephony/providers/vobiz/transport.py @@ -14,6 +14,7 @@ from pipecat.transports.websocket.fastapi import ( from api.services.pipecat.audio_config import AudioConfig from api.services.pipecat.audio_mixer import build_audio_out_mixer +from api.services.pipecat.transport_params import realtime_param_overrides from api.services.telephony.factory import load_credentials_for_transport from .serializers import VobizFrameSerializer @@ -27,6 +28,7 @@ async def create_transport( *, ambient_noise_config: dict | None = None, telephony_configuration_id: int | None = None, + is_realtime: bool = False, stream_id: str, call_id: str, ): @@ -72,6 +74,7 @@ async def create_transport( audio_out_sample_rate=audio_config.transport_out_sample_rate, audio_out_mixer=mixer, serializer=serializer, + **realtime_param_overrides(is_realtime), ), ) diff --git a/api/services/telephony/providers/vonage/transport.py b/api/services/telephony/providers/vonage/transport.py index 0fae27f..f50398d 100644 --- a/api/services/telephony/providers/vonage/transport.py +++ b/api/services/telephony/providers/vonage/transport.py @@ -7,6 +7,7 @@ from pipecat.transports.websocket.fastapi import ( from api.services.pipecat.audio_config import AudioConfig from api.services.pipecat.audio_mixer import build_audio_out_mixer +from api.services.pipecat.transport_params import realtime_param_overrides from api.services.telephony.factory import load_credentials_for_transport from .serializers import VonageFrameSerializer @@ -20,6 +21,7 @@ async def create_transport( *, ambient_noise_config: dict | None = None, telephony_configuration_id: int | None = None, + is_realtime: bool = False, call_uuid: str, ): """Create a transport for Vonage connections.""" @@ -59,5 +61,6 @@ async def create_transport( audio_out_sample_rate=audio_config.transport_out_sample_rate, audio_out_mixer=mixer, serializer=serializer, + **realtime_param_overrides(is_realtime), ), ) diff --git a/api/services/telephony/registry.py b/api/services/telephony/registry.py index 4ecf6d7..48546b1 100644 --- a/api/services/telephony/registry.py +++ b/api/services/telephony/registry.py @@ -81,8 +81,8 @@ class ProviderSpec: stored config JSON and as the WorkflowRunMode value. provider_cls: The TelephonyProvider subclass. config_loader: Normalizes raw stored config into the dict shape the - provider constructor expects. Replaces the if/elif chain in the - old factory.load_telephony_config(). + provider constructor expects. Replaces the old factory if/elif + chain. transport_factory: Async callable that creates the pipecat transport for an accepted WebSocket. Provider-specific kwargs (stream_sid, call_sid, etc.) are forwarded as ``**kwargs``. diff --git a/api/services/workflow/audit.py b/api/services/workflow/audit.py index 6a7dade..8e70384 100644 --- a/api/services/workflow/audit.py +++ b/api/services/workflow/audit.py @@ -7,7 +7,7 @@ script in `api/services/admin_utils/local_exec.py` is the production consumer. """ -from api.services.workflow.node_specs import REGISTRY +from api.services.workflow.node_specs import all_specs def _build_type_rules() -> tuple[set[str], set[str]]: @@ -16,14 +16,14 @@ def _build_type_rules() -> tuple[set[str], set[str]]: (max_incoming == 0).""" src_forbidden: set[str] = set() tgt_forbidden: set[str] = set() - for name, spec in REGISTRY.items(): + for spec in all_specs(): gc = spec.graph_constraints if gc is None: continue if gc.max_outgoing == 0: - src_forbidden.add(name) + src_forbidden.add(spec.name) if gc.max_incoming == 0: - tgt_forbidden.add(name) + tgt_forbidden.add(spec.name) return src_forbidden, tgt_forbidden diff --git a/api/services/workflow/dto.py b/api/services/workflow/dto.py index dc26843..c22f804 100644 --- a/api/services/workflow/dto.py +++ b/api/services/workflow/dto.py @@ -1,7 +1,25 @@ from enum import Enum -from typing import Annotated, List, Literal, Optional, Union +from typing import Any, Dict, List, Literal, Optional, Union -from pydantic import BaseModel, Field, ValidationError, model_validator +from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator + +from api.services.integrations import ( + all_packages, +) +from api.services.integrations import ( + get_node_data_model as get_integration_node_data_model, +) +from api.services.workflow.node_data import BaseNodeData +from api.services.workflow.node_specs._base import ( + DisplayOptions, + GraphConstraints, + NodeCategory, + NodeExample, + PropertyOption, + PropertyType, +) +from api.services.workflow.node_specs.constants import DEFAULT_QA_SYSTEM_PROMPT +from api.services.workflow.node_specs.model_spec import node_spec, spec_field class NodeType(str, Enum): @@ -26,73 +44,381 @@ class VariableType(str, Enum): class ExtractionVariableDTO(BaseModel): - name: str = Field(..., min_length=1) - type: VariableType - prompt: Optional[str] = None + name: str = spec_field( + ..., + min_length=1, + ui_type=PropertyType.string, + display_name="Variable Name", + description="snake_case identifier used downstream.", + required=True, + ) + type: VariableType = spec_field( + ..., + display_name="Type", + description="Data type of the extracted value.", + required=True, + options=[ + PropertyOption(value="string", label="String"), + PropertyOption(value="number", label="Number"), + PropertyOption(value="boolean", label="Boolean"), + ], + spec_default="string", + ) + prompt: Optional[str] = spec_field( + default=None, + ui_type=PropertyType.string, + display_name="Extraction Hint", + description="Per-variable hint describing what to look for.", + editor="textarea", + ) class CustomHeaderDTO(BaseModel): - key: str - value: str + key: str = spec_field( + ..., + ui_type=PropertyType.string, + display_name="Header Name", + description="HTTP header name (e.g., 'X-Source').", + required=True, + ) + value: str = spec_field( + ..., + ui_type=PropertyType.string, + display_name="Header Value", + description="Header value (supports {{template_variables}}).", + required=True, + ) # ───────────────────────────────────────────────────────────────────────── # Per-type node data classes. # -# Shared fields are factored out as Pydantic mixins; per-type classes -# inherit only the mixins they need so mistyped fields raise at validation -# time and downstream consumers get accurate types. `is_start` / `is_end` -# live on every variant so the WorkflowGraph can identify boundary nodes -# without dispatching on type. +# Shared fields live on `BaseNodeData` in a neutral module so both core and +# integration nodes can inherit the same workflow contract. Per-type classes +# then add only the mixins they need so mistyped fields raise at validation +# time and downstream consumers get accurate types. # ───────────────────────────────────────────────────────────────────────── -class _NodeDataBase(BaseModel): - name: str = Field(..., min_length=1) - is_start: bool = False - is_end: bool = False - - class _PromptedNodeDataMixin(BaseModel): - prompt: Optional[str] = Field(default=None) - is_static: bool = False - allow_interrupt: bool = False - add_global_prompt: bool = True + prompt: Optional[str] = spec_field( + default=None, + ui_type=PropertyType.mention_textarea, + display_name="Prompt", + description="System prompt for this node. Supports {{template_variables}}.", + required=True, + min_length=1, + ) + allow_interrupt: bool = spec_field( + default=False, + ui_type=PropertyType.boolean, + display_name="Allow Interruption", + description="When true, the user can interrupt the agent mid-utterance.", + ) + add_global_prompt: bool = spec_field( + default=True, + ui_type=PropertyType.boolean, + display_name="Add Global Prompt", + description=( + "When true and a Global node exists, prepends the global prompt to this " + "node's prompt at runtime." + ), + ) class _ExtractionNodeDataMixin(BaseModel): - extraction_enabled: bool = False - extraction_prompt: Optional[str] = None - extraction_variables: Optional[list[ExtractionVariableDTO]] = None + extraction_enabled: bool = spec_field( + default=False, + ui_type=PropertyType.boolean, + display_name="Enable Variable Extraction", + description="When true, runs an LLM extraction pass for this node.", + ) + extraction_prompt: Optional[str] = spec_field( + default=None, + ui_type=PropertyType.string, + display_name="Extraction Prompt", + description="Overall instructions guiding variable extraction.", + display_options=DisplayOptions(show={"extraction_enabled": [True]}), + editor="textarea", + ) + extraction_variables: Optional[list[ExtractionVariableDTO]] = spec_field( + default=None, + display_name="Variables to Extract", + description=( + "Each entry declares one variable to capture, with its name, data " + "type, and extraction hint." + ), + display_options=DisplayOptions(show={"extraction_enabled": [True]}), + ) class _ToolDocumentRefsMixin(BaseModel): - tool_uuids: Optional[List[str]] = None - document_uuids: Optional[List[str]] = None + tool_uuids: Optional[List[str]] = spec_field( + default=None, + ui_type=PropertyType.tool_refs, + display_name="Tools", + description="Tools this node can invoke.", + llm_hint="List of tool UUIDs from `list_tools`.", + ) + document_uuids: Optional[List[str]] = spec_field( + default=None, + ui_type=PropertyType.document_refs, + display_name="Knowledge Base Documents", + description="Documents this node can reference.", + llm_hint="List of document UUIDs from `list_documents`.", + ) + mcp_tool_filters: Optional[Dict[str, List[str]]] = spec_field( + default=None, + spec_exclude=True, + ) +@node_spec( + name="startCall", + display_name="Start Call", + description="Entry point of the workflow — plays a greeting and opens the conversation.", + llm_hint=( + "The entry point of every workflow (exactly one required). Plays an " + "optional greeting, can fetch context from an external API before the " + "call begins, and executes the first conversational turn." + ), + category=NodeCategory.call_node, + icon="Play", + examples=[ + NodeExample( + name="warm_greeting", + data={ + "name": "Greeting", + "prompt": "Greet warmly and ask the caller's reason for calling.", + "greeting_type": "text", + "greeting": "Hi {{first_name}}, this is Sarah from Acme.", + "allow_interrupt": True, + }, + ) + ], + graph_constraints=GraphConstraints(min_incoming=0, max_incoming=0), + property_order=( + "name", + "greeting_type", + "greeting", + "greeting_recording_id", + "prompt", + "allow_interrupt", + "add_global_prompt", + "delayed_start", + "delayed_start_duration", + "extraction_enabled", + "extraction_prompt", + "extraction_variables", + "tool_uuids", + "document_uuids", + "pre_call_fetch_enabled", + "pre_call_fetch_url", + "pre_call_fetch_credential_uuid", + ), + field_overrides={ + "name": { + "spec_default": "Start Call", + "description": "Short identifier shown in the canvas and call logs.", + }, + "prompt": { + "description": ( + "Agent system prompt for the opening turn. Supports " + "{{template_variables}} from pre-call fetch and the initial context." + ), + "placeholder": "Greet the caller warmly and ask how you can help today.", + }, + "greeting_type": { + "display_name": "Greeting Type", + "description": ( + "Whether the optional greeting is spoken via TTS from text or " + "played from a pre-recorded audio file." + ), + "options": [ + PropertyOption(value="text", label="Text (TTS)"), + PropertyOption(value="audio", label="Pre-recorded Audio"), + ], + "spec_default": "text", + }, + "greeting": { + "display_name": "Greeting Text", + "description": ( + "Text spoken via TTS at the start of the call. Supports " + "{{template_variables}}. Leave empty to skip the greeting." + ), + "display_options": DisplayOptions(show={"greeting_type": ["text"]}), + "placeholder": "Hi {{first_name}}, this is Sarah from Acme.", + "editor": "textarea", + }, + "greeting_recording_id": { + "display_name": "Greeting Recording", + "description": "Pre-recorded audio file played at the start of the call.", + "ui_type": PropertyType.recording_ref, + "llm_hint": ( + "Value is the `recording_id` string. Use the `list_recordings` " + "MCP tool to discover available recordings." + ), + "display_options": DisplayOptions(show={"greeting_type": ["audio"]}), + }, + "allow_interrupt": { + "description": "When true, the user can interrupt the agent mid-utterance.", + }, + "tool_uuids": { + "description": "Tools the agent can invoke during the opening turn.", + }, + "document_uuids": { + "description": "Documents the agent can reference.", + }, + "delayed_start": { + "display_name": "Delayed Start", + "description": ( + "When true, the agent waits before speaking after pickup. Useful " + "for outbound calls where the called party needs a moment to settle." + ), + }, + "delayed_start_duration": { + "display_name": "Delay Duration (seconds)", + "description": "Seconds to wait before the agent speaks. 0.1–10.", + "spec_default": 2.0, + "min_value": 0.1, + "max_value": 10.0, + "display_options": DisplayOptions(show={"delayed_start": [True]}), + }, + "pre_call_fetch_enabled": { + "display_name": "Pre-Call Data Fetch", + "description": ( + "When true, makes a POST request to an external API before the " + "call starts and merges the JSON response into the call context " + "as template variables." + ), + }, + "pre_call_fetch_url": { + "display_name": "Endpoint URL", + "description": ( + "URL the pre-call POST request is sent to. The request body " + "includes caller and called numbers." + ), + "ui_type": PropertyType.url, + "display_options": DisplayOptions(show={"pre_call_fetch_enabled": [True]}), + "placeholder": "https://api.example.com/customer-lookup", + }, + "pre_call_fetch_credential_uuid": { + "display_name": "Authentication", + "description": "Optional credential attached to the pre-call request.", + "ui_type": PropertyType.credential_ref, + "llm_hint": "Credential UUID from `list_credentials`.", + "display_options": DisplayOptions(show={"pre_call_fetch_enabled": [True]}), + }, + }, +) class StartCallNodeData( - _NodeDataBase, + BaseNodeData, _PromptedNodeDataMixin, _ExtractionNodeDataMixin, _ToolDocumentRefsMixin, ): - is_start: bool = True - greeting: Optional[str] = None - greeting_type: Optional[str] = None # 'text' or 'audio' - greeting_recording_id: Optional[str] = None - wait_for_user_response: bool = False - wait_for_user_response_timeout: Optional[float] = None - detect_voicemail: bool = False - delayed_start: bool = False - delayed_start_duration: Optional[float] = None - pre_call_fetch_enabled: bool = False - pre_call_fetch_url: Optional[str] = None - pre_call_fetch_credential_uuid: Optional[str] = None + is_start: bool = spec_field(default=True, spec_exclude=True) + greeting: Optional[str] = spec_field(default=None, ui_type=PropertyType.string) + greeting_type: Optional[str] = spec_field( + default=None, ui_type=PropertyType.options + ) + greeting_recording_id: Optional[str] = spec_field( + default=None, ui_type=PropertyType.recording_ref + ) + delayed_start: bool = spec_field(default=False, ui_type=PropertyType.boolean) + delayed_start_duration: Optional[float] = spec_field( + default=None, ui_type=PropertyType.number + ) + pre_call_fetch_enabled: bool = spec_field( + default=False, ui_type=PropertyType.boolean + ) + pre_call_fetch_url: Optional[str] = spec_field( + default=None, ui_type=PropertyType.url + ) + pre_call_fetch_credential_uuid: Optional[str] = spec_field( + default=None, ui_type=PropertyType.credential_ref + ) +@node_spec( + name="agentNode", + display_name="Agent Node", + description="Conversational step — the LLM runs one focused exchange.", + llm_hint=( + "Mid-call step executed by the LLM. Most workflows are a chain of agent " + "nodes connected by edges that describe transition conditions. Each agent " + "node can invoke tools and reference documents." + ), + category=NodeCategory.call_node, + icon="Headset", + examples=[ + NodeExample( + name="qualify_lead", + data={ + "name": "Qualify Budget", + "prompt": "Ask about budget and timeline. Capture both before transitioning.", + "allow_interrupt": True, + "extraction_enabled": True, + "extraction_prompt": "Extract budget amount and rough timeline.", + "extraction_variables": [ + { + "name": "budget_usd", + "type": "number", + "prompt": "Stated budget in USD", + }, + { + "name": "timeline", + "type": "string", + "prompt": "When they want to start", + }, + ], + }, + ) + ], + graph_constraints=GraphConstraints(min_incoming=1), + property_order=( + "name", + "prompt", + "allow_interrupt", + "add_global_prompt", + "extraction_enabled", + "extraction_prompt", + "extraction_variables", + "tool_uuids", + "document_uuids", + ), + field_overrides={ + "name": { + "spec_default": "Agent", + "description": ( + "Short identifier for this step (e.g., 'Qualify Budget'). Appears " + "in call logs and edge transition tools." + ), + }, + "prompt": { + "description": ( + "Agent system prompt for this step. Supports {{template_variables}} " + "from extraction or pre-call fetch." + ), + "placeholder": "Ask the caller about their budget and timeline.", + }, + "allow_interrupt": { + "description": ( + "When true, the user can interrupt the agent mid-utterance. Set " + "false for non-interruptible disclosures." + ), + "spec_default": True, + }, + "tool_uuids": { + "description": "Tools the agent can invoke during this step.", + }, + "document_uuids": { + "description": "Documents the agent can reference during this step.", + }, + }, +) class AgentNodeData( - _NodeDataBase, + BaseNodeData, _PromptedNodeDataMixin, _ExtractionNodeDataMixin, _ToolDocumentRefsMixin, @@ -100,43 +426,451 @@ class AgentNodeData( pass +@node_spec( + name="endCall", + display_name="End Call", + description="Closes the conversation and hangs up.", + llm_hint=( + "Terminal node that politely closes the conversation. Variable extraction " + "can run before hangup. A workflow can have multiple endCall nodes reached " + "via different edge conditions." + ), + category=NodeCategory.call_node, + icon="OctagonX", + examples=[ + NodeExample( + name="successful_close", + data={ + "name": "Successful Close", + "prompt": "Confirm the appointment time, thank the caller, and end the call.", + "add_global_prompt": False, + }, + ) + ], + graph_constraints=GraphConstraints(min_incoming=1, min_outgoing=0, max_outgoing=0), + property_order=( + "name", + "prompt", + "add_global_prompt", + "extraction_enabled", + "extraction_prompt", + "extraction_variables", + ), + field_overrides={ + "name": { + "spec_default": "End Call", + "description": ( + "Short identifier shown in call logs. Should describe the ending " + "context (e.g., 'Successful close', 'Polite decline')." + ), + }, + "prompt": { + "description": ( + "Agent system prompt for the closing exchange. Supports " + "{{template_variables}} from extraction or pre-call fetch." + ), + "placeholder": "Thank the caller and confirm next steps before ending the call.", + }, + "allow_interrupt": {"spec_exclude": True}, + "add_global_prompt": { + "description": ( + "When true and a Global node exists, prepends the global prompt " + "to this node's prompt at runtime." + ), + "spec_default": False, + }, + "extraction_enabled": { + "description": ( + "When true, runs an LLM extraction pass before hangup to capture " + "variables from the conversation." + ) + }, + "extraction_prompt": { + "description": ( + "Overall instructions guiding how variables should be extracted " + "from the conversation." + ) + }, + "extraction_variables": { + "description": ( + "Each entry declares one variable to capture from the conversation, " + "with its name, data type, and a per-variable extraction hint." + ) + }, + }, +) class EndCallNodeData( - _NodeDataBase, + BaseNodeData, _PromptedNodeDataMixin, _ExtractionNodeDataMixin, ): - is_end: bool = True + is_end: bool = spec_field(default=True, spec_exclude=True) -class GlobalNodeData(_NodeDataBase, _PromptedNodeDataMixin): +@node_spec( + name="globalNode", + display_name="Global Node", + description="Persona/tone appended to every agent node's prompt.", + llm_hint=( + "System-level prompt appended to every prompted node whose " + "`add_global_prompt` is true. Use it for persona, tone, and shared " + "rules that apply across the entire conversation. At most one global " + "node per workflow." + ), + category=NodeCategory.global_node, + icon="Globe", + examples=[ + NodeExample( + name="basic_persona", + description="Establishes a consistent persona across the call.", + data={ + "name": "Persona", + "prompt": ( + "You are Sarah, a polite and warm representative from Acme Corp. " + "Always thank the caller for their time and speak in short " + "conversational sentences." + ), + }, + ) + ], + graph_constraints=GraphConstraints( + min_incoming=0, + max_incoming=0, + min_outgoing=0, + max_outgoing=0, + ), + property_order=("name", "prompt"), + field_overrides={ + "name": { + "spec_default": "Global Node", + "description": ( + "Short identifier shown in the canvas and call logs. Has no " + "runtime effect." + ), + }, + "prompt": { + "display_name": "Global Prompt", + "description": ( + "Text appended to every prompted node's system prompt when that " + "node has `add_global_prompt=true`. Supports {{template_variables}}." + ), + "placeholder": ( + "You are a friendly assistant calling on behalf of {{company_name}}." + ), + "spec_default": ( + "You are a helpful assistant whose mode of interaction with the " + "user is voice. So don't use any special characters which can not " + "be pronounced. Use short sentences and simple language." + ), + }, + "allow_interrupt": {"spec_exclude": True}, + "add_global_prompt": {"spec_exclude": True}, + }, +) +class GlobalNodeData(BaseNodeData, _PromptedNodeDataMixin): pass -class TriggerNodeData(_NodeDataBase): - trigger_path: Optional[str] = None - enabled: bool = True +@node_spec( + name="trigger", + display_name="API Trigger", + description="Public HTTP endpoints that launch the workflow.", + llm_hint=( + "Exposes two public HTTP POST endpoints derived from the auto-generated " + "`trigger_path`:\n" + " • Production: `/api/v1/public/agent/` — runs " + "the published agent. Use this from production systems.\n" + " • Test: `/api/v1/public/agent/test/` — runs " + "the latest draft, useful for verifying changes before publishing. Falls " + "back to the published agent when no draft exists.\n" + "Both require an API key in the `X-API-Key` header.\n" + "Request body fields:\n" + " • `phone_number` (string, required) — destination to dial.\n" + " • `initial_context` (object, optional) — merged into the run's initial context.\n" + " • `telephony_configuration_id` (int, optional) — pick a specific telephony " + "configuration for the call. Must belong to the same organization as the " + "trigger. When omitted, the org's default outbound configuration is used." + ), + category=NodeCategory.trigger, + icon="Webhook", + examples=[ + NodeExample(name="default", data={"name": "Inbound Trigger", "enabled": True}) + ], + graph_constraints=GraphConstraints(min_incoming=0, max_incoming=0), + property_order=("name", "enabled", "trigger_path"), + field_overrides={ + "name": { + "spec_default": "API Trigger", + "description": "Short identifier shown in the canvas. No runtime effect.", + }, + "enabled": { + "display_name": "Enabled", + "description": "When false, the trigger URL returns 404.", + }, + "trigger_path": { + "display_name": "Trigger Path", + "description": ( + "Path segment that uniquely identifies " + "this trigger. Used in both URLs:\n" + " • Production: `/api/v1/public/agent/` — executes " + "the published agent.\n" + " • Test: `/api/v1/public/agent/test/` — executes " + "the latest draft.\n" + "Can be customized to a descriptive value up to 36 characters " + "using letters, numbers, hyphens, or underscores." + ), + }, + }, +) +class TriggerNodeData(BaseNodeData): + trigger_path: Optional[str] = spec_field(default=None, ui_type=PropertyType.string) + enabled: bool = spec_field(default=True, ui_type=PropertyType.boolean) -class WebhookNodeData(_NodeDataBase): - enabled: bool = True - http_method: Optional[str] = None - endpoint_url: Optional[str] = None - credential_uuid: Optional[str] = None - custom_headers: Optional[list[CustomHeaderDTO]] = None - payload_template: Optional[dict] = None +@node_spec( + name="webhook", + display_name="Webhook", + description="Send HTTP request after the workflow completes.", + llm_hint=( + "Sends an HTTP request to an external system after the workflow completes. " + "The payload is a Jinja-templated JSON body with access to " + "`workflow_run_id`, `initial_context`, `gathered_context`, `annotations`, " + "and call metadata." + ), + category=NodeCategory.integration, + icon="Link2", + examples=[ + NodeExample( + name="post_to_crm", + data={ + "name": "Notify CRM", + "enabled": True, + "http_method": "POST", + "endpoint_url": "https://crm.example.com/calls", + "payload_template": { + "run_id": "{{workflow_run_id}}", + "outcome": "{{gathered_context.call_disposition}}", + }, + }, + ) + ], + graph_constraints=GraphConstraints( + min_incoming=0, max_incoming=0, min_outgoing=0, max_outgoing=0 + ), + property_order=( + "name", + "enabled", + "http_method", + "endpoint_url", + "credential_uuid", + "custom_headers", + "payload_template", + ), + field_overrides={ + "name": { + "spec_default": "Webhook", + "description": "Short identifier shown in the canvas and run logs.", + }, + "enabled": { + "display_name": "Enabled", + "description": "When false, the webhook is skipped at run time.", + }, + "http_method": { + "display_name": "HTTP Method", + "description": "HTTP verb used for the outbound request.", + "options": [ + PropertyOption(value="GET", label="GET"), + PropertyOption(value="POST", label="POST"), + PropertyOption(value="PUT", label="PUT"), + PropertyOption(value="PATCH", label="PATCH"), + PropertyOption(value="DELETE", label="DELETE"), + ], + "spec_default": "POST", + }, + "endpoint_url": { + "display_name": "Endpoint URL", + "description": "URL the request is sent to.", + "ui_type": PropertyType.url, + "placeholder": "https://api.example.com/webhook", + }, + "credential_uuid": { + "display_name": "Authentication", + "description": "Optional credential applied as the Authorization header.", + "ui_type": PropertyType.credential_ref, + "llm_hint": "Credential UUID from `list_credentials`.", + }, + "custom_headers": { + "display_name": "Custom Headers", + "description": "Additional HTTP headers to include with the request.", + }, + "payload_template": { + "display_name": "Payload Template", + "description": ( + "JSON body of the request. Values are Jinja-rendered against the " + "run context — `{{workflow_run_id}}`, `{{gathered_context.foo}}`, " + "`{{annotations.qa_xxx}}`, etc." + ), + "ui_type": PropertyType.json, + "spec_default": { + "call_id": "{{workflow_run_id}}", + "first_name": "{{initial_context.first_name}}", + "rsvp": "{{gathered_context.rsvp}}", + "duration": "{{cost_info.call_duration_seconds}}", + "recording_url": "{{recording_url}}", + "transcript_url": "{{transcript_url}}", + }, + }, + }, +) +class WebhookNodeData(BaseNodeData): + enabled: bool = spec_field(default=True, ui_type=PropertyType.boolean) + http_method: Optional[str] = spec_field(default=None, ui_type=PropertyType.options) + endpoint_url: Optional[str] = spec_field(default=None, ui_type=PropertyType.url) + credential_uuid: Optional[str] = spec_field( + default=None, ui_type=PropertyType.credential_ref + ) + custom_headers: Optional[list[CustomHeaderDTO]] = spec_field(default=None) + payload_template: Optional[dict] = spec_field( + default=None, ui_type=PropertyType.json + ) -class QANodeData(_NodeDataBase): - qa_enabled: bool = True - qa_use_workflow_llm: bool = True - qa_provider: Optional[str] = None - qa_model: Optional[str] = None - qa_api_key: Optional[str] = None - qa_endpoint: Optional[str] = None - qa_system_prompt: Optional[str] = None - qa_min_call_duration: int = 15 - qa_voicemail_calls: bool = False - qa_sample_rate: int = 100 +@node_spec( + name="qa", + display_name="QA Analysis", + description="Run LLM quality analysis on the call transcript.", + llm_hint=( + "Runs an LLM quality review on the call transcript after completion. " + "Per-node analysis splits the conversation by node and evaluates each " + "segment against the configured system prompt. Sampling, minimum " + "duration, and voicemail filters are supported." + ), + category=NodeCategory.integration, + icon="ClipboardCheck", + examples=[ + NodeExample( + name="basic_qa", + data={ + "name": "Compliance Check", + "qa_enabled": True, + "qa_system_prompt": ( + "You are a compliance reviewer. Review the transcript and " + "produce a JSON object with `tags`, `summary`, " + "`call_quality_score`, and `overall_sentiment`." + ), + "qa_min_call_duration": 30, + "qa_sample_rate": 100, + }, + ) + ], + graph_constraints=GraphConstraints( + min_incoming=0, max_incoming=0, min_outgoing=0, max_outgoing=0 + ), + property_order=( + "name", + "qa_enabled", + "qa_system_prompt", + "qa_min_call_duration", + "qa_voicemail_calls", + "qa_sample_rate", + "qa_use_workflow_llm", + "qa_provider", + "qa_model", + "qa_api_key", + "qa_endpoint", + ), + field_overrides={ + "name": { + "spec_default": "QA Analysis", + "description": "Short identifier for this QA configuration.", + }, + "qa_enabled": { + "display_name": "Enabled", + "description": "When false, the QA run is skipped.", + }, + "qa_system_prompt": { + "display_name": "System Prompt", + "description": ( + "Instructions to the QA reviewer LLM. Supports placeholders: " + "`{node_summary}`, `{previous_conversation_summary}`, " + "`{transcript}`, `{metrics}`." + ), + "spec_default": DEFAULT_QA_SYSTEM_PROMPT, + "editor": "textarea", + }, + "qa_min_call_duration": { + "display_name": "Minimum Call Duration (seconds)", + "description": "Calls shorter than this are skipped.", + "min_value": 0, + }, + "qa_voicemail_calls": { + "display_name": "Include Voicemail Calls", + "description": "When false, calls flagged as voicemail are skipped.", + }, + "qa_sample_rate": { + "display_name": "Sample Rate (%)", + "description": ( + "Percent of eligible calls QA'd. 100 means every call; lower " + "values use random sampling." + ), + "min_value": 1, + "max_value": 100, + }, + "qa_use_workflow_llm": { + "display_name": "Use Workflow's LLM", + "description": ( + "When true, the QA pass uses the same LLM the workflow runs with. " + "Set false to specify a separate provider/model." + ), + }, + "qa_provider": { + "display_name": "QA LLM Provider", + "description": "LLM provider used for the QA pass.", + "options": [ + PropertyOption(value="openai", label="OpenAI"), + PropertyOption(value="azure", label="Azure OpenAI"), + PropertyOption(value="openrouter", label="OpenRouter"), + PropertyOption(value="anthropic", label="Anthropic"), + ], + "display_options": DisplayOptions(show={"qa_use_workflow_llm": [False]}), + }, + "qa_model": { + "display_name": "QA Model", + "description": ( + "Model identifier (e.g., 'gpt-4o', 'claude-sonnet-4-6'). " + "Provider-specific." + ), + "spec_default": "default", + "display_options": DisplayOptions(show={"qa_use_workflow_llm": [False]}), + }, + "qa_api_key": { + "display_name": "API Key", + "description": "API key for the chosen provider.", + "display_options": DisplayOptions(show={"qa_use_workflow_llm": [False]}), + }, + "qa_endpoint": { + "display_name": "Azure Endpoint", + "description": "Required for the Azure provider.", + "ui_type": PropertyType.url, + "display_options": DisplayOptions( + show={"qa_use_workflow_llm": [False], "qa_provider": ["azure"]} + ), + }, + }, +) +class QANodeData(BaseNodeData): + qa_enabled: bool = spec_field(default=True, ui_type=PropertyType.boolean) + qa_use_workflow_llm: bool = spec_field(default=True, ui_type=PropertyType.boolean) + qa_provider: Optional[str] = spec_field(default=None, ui_type=PropertyType.options) + qa_model: Optional[str] = spec_field(default=None, ui_type=PropertyType.string) + qa_api_key: Optional[str] = spec_field(default=None, ui_type=PropertyType.string) + qa_endpoint: Optional[str] = spec_field(default=None, ui_type=PropertyType.url) + qa_system_prompt: Optional[str] = spec_field( + default=None, ui_type=PropertyType.string + ) + qa_min_call_duration: int = spec_field(default=15, ui_type=PropertyType.number) + qa_voicemail_calls: bool = spec_field(default=False, ui_type=PropertyType.boolean) + qa_sample_rate: int = spec_field(default=100, ui_type=PropertyType.number) # Union of every per-type data class — useful as a type annotation on @@ -156,9 +890,9 @@ NodeDataDTO = Union[ # ───────────────────────────────────────────────────────────────────────── # Per-type RF nodes. # -# RFNodeDTO is a discriminated Union over `type`. Pydantic dispatches to -# the right variant when validating wire JSON. Direct instantiation must -# use the concrete per-type class (StartCallRFNode, AgentRFNode, ...). +# Core node variants keep concrete helper classes for tests and type-aware +# consumers. The persisted workflow DTO itself validates `type` dynamically +# against the core registry plus any integration packages. # ───────────────────────────────────────────────────────────────────────── @@ -228,18 +962,38 @@ class QARFNode(_RFNodeBase): data: QANodeData -RFNodeDTO = Annotated[ - Union[ - StartCallRFNode, - AgentRFNode, - EndCallRFNode, - GlobalRFNode, - TriggerRFNode, - WebhookRFNode, - QARFNode, - ], - Field(discriminator="type"), -] +_PROMPT_REQUIRED_NODE_TYPES: dict[str, str] = { + NodeType.startNode.value: "start", + NodeType.agentNode.value: "agent", + NodeType.endNode.value: "end", + NodeType.globalNode.value: "global", +} + + +class RFNodeDTO(_RFNodeBase): + type: str = Field(..., min_length=1) + data: Any + + @field_validator("type") + @classmethod + def _validate_type(cls, value: str) -> str: + if get_node_data_model(value) is None: + raise ValueError(f"Unknown node type: {value!r}") + return value + + @model_validator(mode="after") + def _validate(self): + data_model = get_node_data_model(self.type) + if data_model is None: + raise ValueError(f"Unknown node type: {self.type!r}") + + self.data = data_model.model_validate(self.data) + + prompt_label = _PROMPT_REQUIRED_NODE_TYPES.get(self.type) + if prompt_label: + _require_prompt(self.data, prompt_label) + + return self # ───────────────────────────────────────────────────────────────────────── @@ -293,9 +1047,7 @@ class ReactFlowDTO(BaseModel): return self -# Node type → per-type data class. Keeps sanitize_workflow_definition in -# step with RFNodeDTO's discriminated union. -_NODE_DATA_CLASSES: dict[str, type[BaseModel]] = { +_CORE_NODE_DATA_CLASSES: dict[str, type[BaseNodeData]] = { NodeType.startNode.value: StartCallNodeData, NodeType.agentNode.value: AgentNodeData, NodeType.endNode.value: EndCallNodeData, @@ -306,6 +1058,18 @@ _NODE_DATA_CLASSES: dict[str, type[BaseModel]] = { } +def get_node_data_model(type_name: str) -> type[BaseNodeData] | None: + return _CORE_NODE_DATA_CLASSES.get(type_name) or get_integration_node_data_model( + type_name + ) + + +def all_node_type_names() -> set[str]: + return set(_CORE_NODE_DATA_CLASSES) | { + node.type_name for package in all_packages() for node in package.nodes + } + + def sanitize_workflow_definition(definition: dict | None) -> dict | None: """Strip unknown fields from each node.data and edge.data so UI-only runtime state (`invalid`, `validationMessage`, etc.) doesn't leak into @@ -332,7 +1096,7 @@ def sanitize_workflow_definition(definition: dict | None) -> dict | None: def _sanitize_node(node): if not isinstance(node, dict): return node - data_cls = _NODE_DATA_CLASSES.get(node.get("type")) + data_cls = get_node_data_model(node.get("type")) raw_data = node.get("data") if not data_cls or not isinstance(raw_data, dict): return node diff --git a/api/services/workflow/mcp_tool_session.py b/api/services/workflow/mcp_tool_session.py new file mode 100644 index 0000000..0caa1b7 --- /dev/null +++ b/api/services/workflow/mcp_tool_session.py @@ -0,0 +1,254 @@ +"""Single unit that knows the MCP protocol + credentials. + +Wraps the vendored Pipecat ``MCPClient`` for connection/session, builds +streamable-HTTP params from a Dograh credential, exposes namespaced +``FunctionSchema``s, and proxies tool calls. Connection failures degrade +(``available = False``) instead of raising — the call must survive a +dead MCP server. +""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set + +from loguru import logger +from mcp.client.session_group import StreamableHttpParameters +from pipecat.adapters.schemas.function_schema import FunctionSchema +from pipecat.services.mcp_service import MCPClient + +from api.services.workflow.tools.mcp_tool import namespace_function_name +from api.utils.credential_auth import build_auth_header + +if TYPE_CHECKING: + from api.db.models import ExternalCredentialModel + + +def build_streamable_http_params( + *, + url: str, + credential: Optional["ExternalCredentialModel"], + timeout_secs: int, + sse_read_timeout_secs: int, +) -> StreamableHttpParameters: + """Build Pipecat/MCP streamable-HTTP params, injecting the auth header + from an ExternalCredentialModel (reuses the http_api credential path).""" + headers: Optional[Dict[str, str]] = None + if credential is not None: + auth = build_auth_header(credential) + headers = auth or None + return StreamableHttpParameters( + url=url, + headers=headers, + timeout=timedelta(seconds=timeout_secs), + sse_read_timeout=timedelta(seconds=sse_read_timeout_secs), + ) + + +class McpToolSession: + """One live MCP server connection for the duration of a call.""" + + def __init__( + self, + *, + tool_uuid: str, + tool_name: str, + url: str, + credential: Optional["ExternalCredentialModel"], + tools_filter: List[str], + timeout_secs: int, + sse_read_timeout_secs: int, + ) -> None: + self._tool_uuid = tool_uuid + self._tool_name = tool_name + self._url = url + self._credential = credential + # An empty list is intentionally treated as "no filter (expose all + # tools)" — Pipecat's MCPClient applies a filter only when this is a + # non-empty list, so [] and None are equivalent ("all tools"). + self._tools_filter = tools_filter or None + self._timeout_secs = timeout_secs + self._sse_read_timeout_secs = sse_read_timeout_secs + + self._client: Optional[MCPClient] = None + self._session: Any = None # mcp.ClientSession (read once after start) + self._schemas: List[FunctionSchema] = [] + # namespaced LLM name -> original MCP tool name + self._name_map: Dict[str, str] = {} + self.available: bool = False + + async def start(self) -> None: + """Connect, initialize, and cache the tool list. Never raises — + on any failure the session is marked unavailable.""" + try: + params = build_streamable_http_params( + url=self._url, + credential=self._credential, + timeout_secs=self._timeout_secs, + sse_read_timeout_secs=self._sse_read_timeout_secs, + ) + self._client = MCPClient(params, tools_filter=self._tools_filter) + await self._client.start() + # Single, isolated touch of Pipecat internals (vendored submodule). + self._session = self._client._active_session + tools_schema = await self._client.get_tools_schema() + + fallback = self._tool_uuid[:8] if self._tool_uuid else "server" + for fs in tools_schema.standard_tools: + ns_name = namespace_function_name( + self._tool_name, fs.name, fallback=fallback + ) + self._name_map[ns_name] = fs.name + self._schemas.append( + FunctionSchema( + name=ns_name, + description=fs.description, + properties=fs.properties, + required=fs.required, + ) + ) + self.available = True + logger.info( + f"MCP session ready for tool '{self._tool_name}' " + f"({self._tool_uuid}): {sorted(self._name_map)}" + ) + except (KeyboardInterrupt, SystemExit): + raise + except asyncio.CancelledError as e: + # Empirically, a dead/unreachable MCP server does NOT surface as a + # plain Exception here. The real failure is httpx.ConnectError, but + # anyio's streamablehttp_client task group, while tearing down that + # ConnectError, re-surfaces it to our frame as an *internal* + # cancel-scope CancelledError carrying the signature message + # "Cancelled via cancel scope ". A genuine *external* + # cancellation (call teardown / shutdown) is a bare CancelledError + # (empty args) or one with an application-chosen message. Type, MRO, + # context chain, and asyncio task.cancelling() are all identical + # between the two, so the anyio scope-signature message is the only + # reliable discriminator. Re-raise genuine external cancellation to + # preserve structured concurrency; degrade only on the anyio + # connect-teardown artifact. + msg = "" if not e.args else str(e.args[0] or "") + if not msg.startswith("Cancelled via cancel scope"): + raise + await self._degrade(e) + except Exception as e: # noqa: BLE001 — see _degrade docstring + # Defensive: if a future Pipecat/httpx version surfaces the connect + # failure directly (e.g. httpx.ConnectError) instead of via the + # anyio cancel-scope artifact above, still degrade gracefully. + await self._degrade(e) + + async def _degrade(self, e: BaseException) -> None: + """Mark this session unavailable and tear down any dangling client so + start() leaves self._client either fully usable or None. The contract + requires graceful degradation on any *connect* failure (never raising + for a dead MCP server) while genuine external cancellation / + KeyboardInterrupt / SystemExit are re-raised by the caller.""" + self.available = False + self._schemas = [] + self._name_map = {} + # Self-contained cleanup: _client.start() may have succeeded before a + # later step (e.g. get_tools_schema()) failed, leaving an open client. + if self._client is not None: + try: + await self._client.close() + except Exception: + pass + finally: + self._client = None + self._session = None + logger.warning( + f"MCP session unavailable for tool '{self._tool_name}' " + f"({self._tool_uuid}) at {self._url}: {e!r}. " + f"Call proceeds without these tools." + ) + + @property + def call_timeout_secs(self) -> float: + """Pipecat function-call timeout for this server's tools. Slightly + longer than the transport read timeout so a slow MCP call surfaces + as a structured tool error (handled in the handler) rather than a + hard pipeline timeout.""" + return float(self._sse_read_timeout_secs) + 5.0 + + def function_schemas( + self, allowed_raw_names: Optional[Set[str]] = None + ) -> List[FunctionSchema]: + """Return cached FunctionSchemas, optionally filtered by raw MCP tool name. + + ``allowed_raw_names=None`` returns all schemas. An empty set returns none. + Raw names are the pre-namespace MCP tool names (e.g. ``echo``, not + ``mcp__slug__echo``). + """ + if allowed_raw_names is None: + return list(self._schemas) + return [ + s for s in self._schemas if self._name_map.get(s.name) in allowed_raw_names + ] + + def discovered_tools(self) -> List[Dict[str, str]]: + """Raw MCP tool catalog for UI/cache: ``[{name, description}]`` + using the *raw* server names (not the namespaced LLM names). + Empty if the session is unavailable.""" + out: List[Dict[str, str]] = [] + for s in self._schemas: + raw = self._name_map.get(s.name) + if raw is None: + continue + out.append({"name": raw, "description": s.description or ""}) + return out + + async def call(self, namespaced_name: str, arguments: Dict[str, Any]) -> str: + """Invoke an MCP tool by its namespaced LLM name. Returns a string + (flattened text content). Raises if the session is unavailable so + the caller can map it to a structured error for the LLM.""" + if not self.available or self._session is None: + raise RuntimeError(f"MCP session unavailable for {namespaced_name}") + original = self._name_map.get(namespaced_name) + if original is None: + raise RuntimeError(f"Unknown MCP function {namespaced_name}") + result = await self._session.call_tool(original, arguments=arguments) + text = "" + for content in getattr(result, "content", []) or []: + if getattr(content, "text", None): + text += content.text + return text or "Sorry, the MCP tool returned no content." + + async def close(self) -> None: + if self._client is not None: + try: + await self._client.close() + except Exception as e: + logger.warning(f"Error closing MCP session {self._tool_uuid}: {e}") + finally: + self._client = None + self._session = None + + +async def discover_mcp_tools( + *, + url: str, + credential: Optional["ExternalCredentialModel"], + timeout_secs: int, + sse_read_timeout_secs: int, +) -> List[Dict[str, str]]: + """Open an ephemeral MCP session, list its tools, close it. Returns + ``[{name, description}]`` (raw names). Never raises — on any connect + failure returns ``[]``.""" + session = McpToolSession( + tool_uuid="discover", + tool_name="discover", + url=url, + credential=credential, + tools_filter=[], + timeout_secs=timeout_secs, + sse_read_timeout_secs=sse_read_timeout_secs, + ) + await session.start() + try: + if not session.available: + return [] + return session.discovered_tools() + finally: + await session.close() diff --git a/api/services/workflow/node_data.py b/api/services/workflow/node_data.py new file mode 100644 index 0000000..deb9638 --- /dev/null +++ b/api/services/workflow/node_data.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from pydantic import BaseModel + +from api.services.workflow.node_specs._base import PropertyType +from api.services.workflow.node_specs.model_spec import spec_field + + +class BaseNodeData(BaseModel): + name: str = spec_field( + ..., + min_length=1, + ui_type=PropertyType.string, + display_name="Name", + description="Short identifier shown in the canvas and call logs.", + required=True, + ) + is_start: bool = spec_field(default=False, spec_exclude=True) + is_end: bool = spec_field(default=False, spec_exclude=True) diff --git a/api/services/workflow/node_specs/__init__.py b/api/services/workflow/node_specs/__init__.py index 93f6ab0..620fb49 100644 --- a/api/services/workflow/node_specs/__init__.py +++ b/api/services/workflow/node_specs/__init__.py @@ -1,10 +1,8 @@ """Node specification registry. -Adding a new node type: -1. Create a new module under this package, define a `SPEC: NodeSpec`. -2. Add it to the imports + REGISTRY below. -3. The Pydantic discriminated-union variant in dto.py must use the same - `name` value as `SPEC.name`. +Core node specs are generated from the workflow DTO models. Third-party +integration node specs live under `api.services.integrations//` and +register through the integration registry so they don't need edits here. """ from __future__ import annotations @@ -21,8 +19,10 @@ from api.services.workflow.node_specs._base import ( PropertyType, evaluate_display_options, ) +from api.services.workflow.node_specs.model_spec import build_spec REGISTRY: dict[str, NodeSpec] = {} +_CORE_SPECS_LOADED = False def register(spec: NodeSpec) -> NodeSpec: @@ -38,12 +38,23 @@ def register(spec: NodeSpec) -> NodeSpec: def get_spec(name: str) -> NodeSpec | None: - return REGISTRY.get(name) + _ensure_core_registered() + if name in REGISTRY: + return REGISTRY[name] + + from api.services.integrations import get_node_spec + + return get_node_spec(name) def all_specs() -> list[NodeSpec]: """All registered specs, sorted by name for stable output.""" - return [REGISTRY[name] for name in sorted(REGISTRY)] + _ensure_core_registered() + from api.services.integrations import all_node_specs + + specs = {spec.name: spec for spec in REGISTRY.values()} + specs.update({spec.name: spec for spec in all_node_specs()}) + return [specs[name] for name in sorted(specs)] __all__ = [ @@ -64,19 +75,15 @@ __all__ = [ ] -# Side-effect imports — each module's `register(SPEC)` call populates REGISTRY. -# Keep at module bottom so the registry helpers are defined first. -from api.services.workflow.node_specs import ( # noqa: E402, F401 - agent, - end_call, - global_node, - qa, - start_call, - trigger, - webhook, -) +def _ensure_core_registered() -> None: + global _CORE_SPECS_LOADED + if _CORE_SPECS_LOADED: + return -# Wire up registrations from the SPEC constants in each module. -for _module in (start_call, agent, end_call, global_node, trigger, webhook, qa): - register(_module.SPEC) -del _module + from api.services.workflow.dto import _CORE_NODE_DATA_CLASSES + + for model_cls in _CORE_NODE_DATA_CLASSES.values(): + if model_cls.__node_spec_metadata__.name in REGISTRY: + continue + register(build_spec(model_cls)) + _CORE_SPECS_LOADED = True diff --git a/api/services/workflow/node_specs/_base.py b/api/services/workflow/node_specs/_base.py index cbf044f..b8324c1 100644 --- a/api/services/workflow/node_specs/_base.py +++ b/api/services/workflow/node_specs/_base.py @@ -1,9 +1,9 @@ """Spec schema for node definitions. -A `NodeSpec` is the single source of truth for a node type. It drives: -- Pydantic validation (the per-type DTOs in dto.py mirror these property types) -- The generic UI renderer (frontend reads specs via /api/v1/node-types) -- The LLM SDK (constructors and JSON-Schema derived from these specs) +`NodeSpec` is the serialized contract exposed to the frontend, MCP tools, and +SDKs. Core workflow node specs are generated from the DTO models plus +model-attached metadata; integration packages may generate them the same way or +register a prebuilt spec object. Every property's `description` is LLM-readable copy — treat it as production documentation, not internal notes. Spec lint enforces non-empty descriptions @@ -122,6 +122,16 @@ class PropertyOption(BaseModel): model_config = ConfigDict(extra="forbid") + def to_mcp_dict(self) -> dict[str, Any]: + """Lean projection for `get_node_type`: the `value` an LLM writes in + code, plus a `description` when one carries real meaning. The UI + `label` is dropped — it's the option's display string, never used + when authoring.""" + out: dict[str, Any] = {"value": self.value} + if self.description: + out["description"] = self.description + return out + class PropertySpec(BaseModel): """Single field on a node. @@ -175,6 +185,43 @@ class PropertySpec(BaseModel): model_config = ConfigDict(extra="forbid") + def to_mcp_dict(self) -> dict[str, Any]: + """Lean projection of this property for the `get_node_type` MCP tool. + + Keeps only what an LLM needs to author a valid value: name, type, + description, llm_hint, requiredness, default, enum options, nested + row properties, and validation bounds. UI-rendering concerns + (`display_name`, `placeholder`, `display_options`, `editor`, + `extra`) and null/empty fields are omitted — they're noise in the + model's context and never appear in authored SDK code. + """ + out: dict[str, Any] = { + "name": self.name, + "type": self.type.value, + "description": self.description, + } + if self.llm_hint: + out["llm_hint"] = self.llm_hint + if self.required: + out["required"] = True + if self.default is not None: + out["default"] = self.default + if self.options: + out["options"] = [opt.to_mcp_dict() for opt in self.options] + if self.properties: + out["properties"] = [prop.to_mcp_dict() for prop in self.properties] + for constraint in ( + "min_value", + "max_value", + "min_length", + "max_length", + "pattern", + ): + value = getattr(self, constraint) + if value is not None: + out[constraint] = value + return out + PropertySpec.model_rebuild() @@ -222,3 +269,33 @@ class NodeSpec(BaseModel): graph_constraints: Optional[GraphConstraints] = None model_config = ConfigDict(extra="forbid") + + def to_mcp_dict(self) -> dict[str, Any]: + """Lean projection of this spec for the `get_node_type` MCP tool. + + Drops node-level UI metadata (`display_name`, `category`, `icon`, + `version`) and the per-property rendering concerns trimmed by + `PropertySpec.to_mcp_dict`, leaving just the authoring-relevant + schema the LLM consumes when composing a workflow. The full spec is + still served verbatim to the frontend renderer (REST `node-types` + route) and the SDK codegen / TS validator (`ts_bridge`), which need + the dropped fields. + """ + out: dict[str, Any] = { + "name": self.name, + "description": self.description, + } + if self.llm_hint: + out["llm_hint"] = self.llm_hint + out["properties"] = [prop.to_mcp_dict() for prop in self.properties] + if self.examples: + out["examples"] = [ + ex.model_dump(mode="json", exclude_none=True) for ex in self.examples + ] + if self.graph_constraints: + constraints = self.graph_constraints.model_dump( + mode="json", exclude_none=True + ) + if constraints: + out["graph_constraints"] = constraints + return out diff --git a/api/services/workflow/node_specs/agent.py b/api/services/workflow/node_specs/agent.py deleted file mode 100644 index 218fd9f..0000000 --- a/api/services/workflow/node_specs/agent.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Spec for the Agent node — the workhorse mid-call node where the LLM -executes a focused conversational step with optional tools and documents.""" - -from api.services.workflow.node_specs._base import ( - DisplayOptions, - GraphConstraints, - NodeCategory, - NodeExample, - NodeSpec, - PropertyOption, - PropertySpec, - PropertyType, -) - -SPEC = NodeSpec( - name="agentNode", - display_name="Agent Node", - description="Conversational step — the LLM runs one focused exchange.", - llm_hint=( - "Mid-call step executed by the LLM. Most workflows are a chain of " - "agent nodes connected by edges that describe transition conditions. " - "Each agent node can invoke tools and reference documents." - ), - category=NodeCategory.call_node, - icon="Headset", - properties=[ - PropertySpec( - name="name", - type=PropertyType.string, - display_name="Name", - description=( - "Short identifier for this step (e.g., 'Qualify Budget'). " - "Appears in call logs and edge transition tools." - ), - required=True, - min_length=1, - default="Agent", - ), - PropertySpec( - name="prompt", - type=PropertyType.mention_textarea, - display_name="Prompt", - description=( - "Agent system prompt for this step. Supports " - "{{template_variables}} from extraction or pre-call fetch." - ), - required=True, - min_length=1, - placeholder="Ask the caller about their budget and timeline.", - ), - PropertySpec( - name="allow_interrupt", - type=PropertyType.boolean, - display_name="Allow Interruption", - description=( - "When true, the user can interrupt the agent mid-utterance. " - "Set false for non-interruptible disclosures." - ), - default=True, - ), - PropertySpec( - name="add_global_prompt", - type=PropertyType.boolean, - display_name="Add Global Prompt", - description=( - "When true and a Global node exists, prepends the global " - "prompt to this node's prompt at runtime." - ), - default=True, - ), - PropertySpec( - name="extraction_enabled", - type=PropertyType.boolean, - display_name="Enable Variable Extraction", - description=( - "When true, runs an LLM extraction pass on transition out of " - "this node to capture variables from the conversation." - ), - default=False, - ), - PropertySpec( - name="extraction_prompt", - type=PropertyType.string, - display_name="Extraction Prompt", - description="Overall instructions guiding variable extraction.", - display_options=DisplayOptions(show={"extraction_enabled": [True]}), - editor="textarea", - ), - PropertySpec( - name="extraction_variables", - type=PropertyType.fixed_collection, - display_name="Variables to Extract", - description=( - "Each entry declares one variable to capture from the " - "conversation, with its name, type, and per-variable hint." - ), - display_options=DisplayOptions(show={"extraction_enabled": [True]}), - properties=[ - PropertySpec( - name="name", - type=PropertyType.string, - display_name="Variable Name", - description="snake_case identifier used downstream.", - required=True, - ), - PropertySpec( - name="type", - type=PropertyType.options, - display_name="Type", - description="Data type of the extracted value.", - required=True, - default="string", - options=[ - PropertyOption(value="string", label="String"), - PropertyOption(value="number", label="Number"), - PropertyOption(value="boolean", label="Boolean"), - ], - ), - PropertySpec( - name="prompt", - type=PropertyType.string, - display_name="Extraction Hint", - description="Per-variable hint describing what to look for.", - editor="textarea", - ), - ], - ), - PropertySpec( - name="tool_uuids", - type=PropertyType.tool_refs, - display_name="Tools", - description="Tools the agent can invoke during this step.", - llm_hint="List of tool UUIDs from `list_tools`.", - ), - PropertySpec( - name="document_uuids", - type=PropertyType.document_refs, - display_name="Knowledge Base Documents", - description="Documents the agent can reference during this step.", - llm_hint="List of document UUIDs from `list_documents`.", - ), - ], - examples=[ - NodeExample( - name="qualify_lead", - data={ - "name": "Qualify Budget", - "prompt": "Ask about budget and timeline. Capture both before transitioning.", - "allow_interrupt": True, - "extraction_enabled": True, - "extraction_prompt": "Extract budget amount and rough timeline.", - "extraction_variables": [ - { - "name": "budget_usd", - "type": "number", - "prompt": "Stated budget in USD", - }, - { - "name": "timeline", - "type": "string", - "prompt": "When they want to start", - }, - ], - }, - ), - ], - graph_constraints=GraphConstraints(min_incoming=1), -) diff --git a/api/services/workflow/node_specs/constants.py b/api/services/workflow/node_specs/constants.py new file mode 100644 index 0000000..c5b7544 --- /dev/null +++ b/api/services/workflow/node_specs/constants.py @@ -0,0 +1,44 @@ +DEFAULT_QA_SYSTEM_PROMPT = """You are a QA analyst evaluating a specific segment of a voice AI conversation. + +## Node Purpose +{{node_summary}} + +## Previous Conversation Context (For start of conversation, previous conversation summary can be empty.) +{{previous_conversation_summary}} + +## Tags to evaluate + +Examine the conversation carefully and identify which of the following tags apply: + +- UNCLEAR_CONVERSATION - The conversation is not coherent or clear, messages don't connect logically +- ASSISTANT_IN_LOOP - The assistant asks the same question multiple times or gets stuck repeating itself +- ASSISTANT_REPLY_IMPROPER - The assistant did not reply properly to the user's question/query or seems confused by what the user said +- USER_FRUSTRATED - The user seems angry, frustrated, or is complaining about something in the call +- USER_NOT_UNDERSTANDING - The user explicitly says they don't understand or repeatedly asks for clarification +- HEARING_ISSUES - Either party can't hear the other ("hello?", "are you there?", "can you hear me?") +- DEAD_AIR - Unusually long silences in the conversation (use the timestamps to judge) +- USER_REQUESTING_FEATURE - The user asks for something the assistant can't fulfill +- ASSISTANT_LACKS_EMPATHY - The assistant ignores the user's personal situation or emotional state and continues pitching or pushing the agenda. +- USER_DETECTS_AI - The user suspects or identifies that they are talking to an AI/robot/bot rather than a real human. + +## Call metrics (pre-computed) + +Use these alongside the transcript for your analysis: +{{metrics}} + +## Output format + +Return ONLY a valid JSON object (no markdown): +{ + "tags": [ + { + "tag": "TAG_NAME", + "reason": "Short reason with evidence from the transcript" + } + ], + "overall_sentiment": "positive|neutral|negative", + "call_quality_score": <1-10>, + "summary": "1-2 sentence summary of this segment" +} + +If no tags apply, return an empty tags list. Always provide sentiment, score, and summary.""" diff --git a/api/services/workflow/node_specs/end_call.py b/api/services/workflow/node_specs/end_call.py deleted file mode 100644 index 33129ed..0000000 --- a/api/services/workflow/node_specs/end_call.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Spec for the End Call node — terminal node that wraps up a conversation -and optionally extracts variables before hangup.""" - -from api.services.workflow.node_specs._base import ( - DisplayOptions, - GraphConstraints, - NodeCategory, - NodeExample, - NodeSpec, - PropertyOption, - PropertySpec, - PropertyType, -) - -SPEC = NodeSpec( - name="endCall", - display_name="End Call", - description="Closes the conversation and hangs up.", - llm_hint=( - "Terminal node that politely closes the conversation. Variable " - "extraction can run before hangup. A workflow can have multiple " - "endCall nodes reached via different edge conditions." - ), - category=NodeCategory.call_node, - icon="OctagonX", - properties=[ - PropertySpec( - name="name", - type=PropertyType.string, - display_name="Name", - description=( - "Short identifier shown in call logs. Should describe the " - "ending context (e.g., 'Successful close', 'Polite decline')." - ), - required=True, - min_length=1, - default="End Call", - ), - PropertySpec( - name="prompt", - type=PropertyType.mention_textarea, - display_name="Prompt", - description=( - "Agent system prompt for the closing exchange. Supports " - "{{template_variables}} from extraction or pre-call fetch." - ), - required=True, - min_length=1, - placeholder="Thank the caller and confirm next steps before ending the call.", - ), - PropertySpec( - name="add_global_prompt", - type=PropertyType.boolean, - display_name="Add Global Prompt", - description=( - "When true and a Global node exists, prepends the global " - "prompt to this node's prompt at runtime." - ), - default=False, - ), - PropertySpec( - name="extraction_enabled", - type=PropertyType.boolean, - display_name="Enable Variable Extraction", - description=( - "When true, runs an LLM extraction pass before hangup to " - "capture variables from the conversation." - ), - default=False, - ), - PropertySpec( - name="extraction_prompt", - type=PropertyType.string, - display_name="Extraction Prompt", - description=( - "Overall instructions guiding how variables should be " - "extracted from the conversation." - ), - display_options=DisplayOptions(show={"extraction_enabled": [True]}), - editor="textarea", - ), - PropertySpec( - name="extraction_variables", - type=PropertyType.fixed_collection, - display_name="Variables to Extract", - description=( - "Each entry declares one variable to capture from the " - "conversation, with its name, data type, and a per-variable " - "extraction hint." - ), - display_options=DisplayOptions(show={"extraction_enabled": [True]}), - properties=[ - PropertySpec( - name="name", - type=PropertyType.string, - display_name="Variable Name", - description="snake_case identifier used downstream.", - required=True, - ), - PropertySpec( - name="type", - type=PropertyType.options, - display_name="Type", - description="The data type of the extracted value.", - required=True, - default="string", - options=[ - PropertyOption(value="string", label="String"), - PropertyOption(value="number", label="Number"), - PropertyOption(value="boolean", label="Boolean"), - ], - ), - PropertySpec( - name="prompt", - type=PropertyType.string, - display_name="Extraction Hint", - description=( - "Per-variable hint describing what to look for in " - "the conversation." - ), - editor="textarea", - ), - ], - ), - ], - examples=[ - NodeExample( - name="successful_close", - data={ - "name": "Successful Close", - "prompt": "Confirm the appointment time, thank the caller, and end the call.", - "add_global_prompt": False, - }, - ), - ], - graph_constraints=GraphConstraints( - min_incoming=1, - min_outgoing=0, - max_outgoing=0, - ), -) diff --git a/api/services/workflow/node_specs/global_node.py b/api/services/workflow/node_specs/global_node.py deleted file mode 100644 index bd983d7..0000000 --- a/api/services/workflow/node_specs/global_node.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Spec for the Global node — system-level instructions appended to every -agent node that opts in via `add_global_prompt`.""" - -from api.services.workflow.node_specs._base import ( - GraphConstraints, - NodeCategory, - NodeExample, - NodeSpec, - PropertySpec, - PropertyType, -) - -SPEC = NodeSpec( - name="globalNode", - display_name="Global Node", - description="Persona/tone appended to every agent node's prompt.", - llm_hint=( - "System-level prompt appended to every prompted node whose " - "`add_global_prompt` is true. Use it for persona, tone, and shared " - "rules that apply across the entire conversation. At most one " - "global node per workflow." - ), - category=NodeCategory.global_node, - icon="Globe", - properties=[ - PropertySpec( - name="name", - type=PropertyType.string, - display_name="Name", - description=( - "Short identifier shown in the canvas and call logs. Has no " - "runtime effect." - ), - required=True, - min_length=1, - default="Global Node", - ), - PropertySpec( - name="prompt", - type=PropertyType.mention_textarea, - display_name="Global Prompt", - description=( - "Text appended to every prompted node's system prompt when " - "that node has `add_global_prompt=true`. Supports " - "{{template_variables}}." - ), - required=True, - min_length=1, - placeholder="You are a friendly assistant calling on behalf of {{company_name}}.", - default=( - "You are a helpful assistant whose mode of interaction with " - "the user is voice. So don't use any special characters which " - "can not be pronounced. Use short sentences and simple language." - ), - ), - ], - examples=[ - NodeExample( - name="basic_persona", - description="Establishes a consistent persona across the call.", - data={ - "name": "Persona", - "prompt": ( - "You are Sarah, a polite and warm representative from " - "Acme Corp. Always thank the caller for their time and " - "speak in short conversational sentences." - ), - }, - ), - ], - graph_constraints=GraphConstraints( - min_incoming=0, - max_incoming=0, - min_outgoing=0, - max_outgoing=0, - ), -) diff --git a/api/services/workflow/node_specs/model_spec.py b/api/services/workflow/node_specs/model_spec.py new file mode 100644 index 0000000..1379ee1 --- /dev/null +++ b/api/services/workflow/node_specs/model_spec.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +from dataclasses import dataclass +from dataclasses import field as dataclass_field +from enum import Enum +from types import NoneType +from typing import Any, Callable, Literal, get_args, get_origin + +from pydantic import BaseModel, Field +from pydantic.fields import FieldInfo, PydanticUndefined + +from api.services.workflow.node_specs._base import ( + DisplayOptions, + GraphConstraints, + NodeCategory, + NodeExample, + NodeSpec, + PropertyOption, + PropertySpec, + PropertyType, +) + +_SPEC_FIELD_META_KEY = "__dograh_spec_field__" +_UNSET = object() + + +@dataclass(frozen=True) +class NodeSpecMetadata: + name: str + display_name: str + description: str + category: NodeCategory + icon: str + llm_hint: str | None = None + version: str = "1.0.0" + examples: tuple[NodeExample, ...] = () + graph_constraints: GraphConstraints | None = None + property_order: tuple[str, ...] = () + field_overrides: dict[str, dict[str, Any]] = dataclass_field(default_factory=dict) + + +def spec_field( + *field_args: Any, + ui_type: PropertyType | str | None = None, + display_name: str | None = None, + llm_hint: str | None = None, + required: bool | None = None, + spec_default: Any = _UNSET, + placeholder: str | None = None, + display_options: DisplayOptions | None = None, + options: list[PropertyOption] | None = None, + editor: str | None = None, + extra: dict[str, Any] | None = None, + spec_exclude: bool = False, + min_value: float | None = None, + max_value: float | None = None, + min_length: int | None = None, + max_length: int | None = None, + pattern: str | None = None, + **field_kwargs: Any, +): + json_schema_extra = dict(field_kwargs.pop("json_schema_extra", {}) or {}) + json_schema_extra[_SPEC_FIELD_META_KEY] = { + "ui_type": ui_type.value if isinstance(ui_type, PropertyType) else ui_type, + "display_name": display_name, + "llm_hint": llm_hint, + "required": required, + "placeholder": placeholder, + "display_options": display_options, + "options": options, + "editor": editor, + "extra": extra or {}, + "spec_exclude": spec_exclude, + "min_value": min_value, + "max_value": max_value, + "min_length": min_length, + "max_length": max_length, + "pattern": pattern, + } + if spec_default is not _UNSET: + json_schema_extra[_SPEC_FIELD_META_KEY]["spec_default"] = spec_default + return Field(*field_args, json_schema_extra=json_schema_extra, **field_kwargs) + + +def node_spec( + *, + name: str, + display_name: str, + description: str, + category: NodeCategory, + icon: str, + llm_hint: str | None = None, + version: str = "1.0.0", + examples: list[NodeExample] | tuple[NodeExample, ...] = (), + graph_constraints: GraphConstraints | None = None, + property_order: list[str] | tuple[str, ...] = (), + field_overrides: dict[str, dict[str, Any]] | None = None, +) -> Callable[[type[BaseModel]], type[BaseModel]]: + metadata = NodeSpecMetadata( + name=name, + display_name=display_name, + description=description, + category=category, + icon=icon, + llm_hint=llm_hint, + version=version, + examples=tuple(examples), + graph_constraints=graph_constraints, + property_order=tuple(property_order), + field_overrides=field_overrides or {}, + ) + + def decorator(model_cls: type[BaseModel]) -> type[BaseModel]: + setattr(model_cls, "__node_spec_metadata__", metadata) + return model_cls + + return decorator + + +def build_spec(model_cls: type[BaseModel]) -> NodeSpec: + metadata: NodeSpecMetadata | None = getattr( + model_cls, "__node_spec_metadata__", None + ) + if metadata is None: + raise ValueError(f"{model_cls.__name__} is missing __node_spec_metadata__") + + properties: list[PropertySpec] = [] + for name, field in model_cls.model_fields.items(): + prop = _build_property_spec(model_cls, name, field) + if prop is not None: + properties.append(prop) + properties = _sort_properties(metadata.name, properties, metadata.property_order) + + return NodeSpec( + name=metadata.name, + display_name=metadata.display_name, + description=metadata.description, + llm_hint=metadata.llm_hint, + category=metadata.category, + icon=metadata.icon, + version=metadata.version, + properties=properties, + examples=list(metadata.examples), + graph_constraints=metadata.graph_constraints, + ) + + +def _sort_properties( + spec_name: str, + properties: list[PropertySpec], + property_order: tuple[str, ...], +) -> list[PropertySpec]: + if not property_order: + return properties + + property_names = {prop.name for prop in properties} + missing = [name for name in property_order if name not in property_names] + if missing: + raise ValueError( + f"{spec_name}: property_order references unknown properties: {missing}" + ) + + order_map = {name: idx for idx, name in enumerate(property_order)} + ordered = sorted( + enumerate(properties), + key=lambda item: (order_map.get(item[1].name, len(order_map)), item[0]), + ) + return [prop for _, prop in ordered] + + +def _build_property_spec( + owner_cls: type[BaseModel], + field_name: str, + field: FieldInfo, +) -> PropertySpec | None: + meta = _merged_field_meta(owner_cls, field_name, field) + if meta.get("spec_exclude"): + return None + + prop_type = _resolve_property_type(field.annotation, meta) + nested_properties = _resolve_nested_properties(field.annotation, prop_type) + options = _resolve_options(field.annotation, meta, prop_type) + min_value, max_value, min_length, max_length, pattern = _resolve_constraints( + field, meta + ) + + description = meta.get("description") or field.description + if not description: + raise ValueError(f"{owner_cls.__name__}.{field_name} is missing a description") + + return PropertySpec( + name=field_name, + type=prop_type, + display_name=meta.get("display_name") or _humanize_identifier(field_name), + description=description, + llm_hint=meta.get("llm_hint"), + default=_resolve_default(field, meta), + required=_resolve_required(field, meta), + placeholder=meta.get("placeholder"), + display_options=meta.get("display_options"), + options=options, + properties=nested_properties, + min_value=min_value, + max_value=max_value, + min_length=min_length, + max_length=max_length, + pattern=pattern, + editor=meta.get("editor"), + extra=meta.get("extra") or {}, + ) + + +def _merged_field_meta( + owner_cls: type[BaseModel], + field_name: str, + field: FieldInfo, +) -> dict[str, Any]: + field_meta = {} + if isinstance(field.json_schema_extra, dict): + field_meta = dict(field.json_schema_extra.get(_SPEC_FIELD_META_KEY, {}) or {}) + metadata: NodeSpecMetadata | None = getattr( + owner_cls, "__node_spec_metadata__", None + ) + override = ( + dict(metadata.field_overrides.get(field_name, {}) or {}) + if metadata is not None + else {} + ) + merged = dict(field_meta) + merged.update(override) + return merged + + +def _resolve_property_type(annotation: Any, meta: dict[str, Any]) -> PropertyType: + ui_type = meta.get("ui_type") + if ui_type: + return PropertyType(ui_type) + + inner = _strip_optional(annotation) + origin = get_origin(inner) + args = get_args(inner) + + if origin is list: + item_type = _strip_optional(args[0]) if args else Any + if isinstance(item_type, type) and issubclass(item_type, BaseModel): + return PropertyType.fixed_collection + raise ValueError( + "List-valued fields must declare an explicit ui_type unless they wrap a " + f"BaseModel row type (field annotation: {annotation!r})." + ) + + if _is_enum(inner) or _is_literal(inner): + return PropertyType.options + + if inner in (str,): + return PropertyType.string + if inner in (int, float): + return PropertyType.number + if inner is bool: + return PropertyType.boolean + if inner in (dict, Any) or origin is dict: + return PropertyType.json + + raise ValueError(f"Unable to derive PropertyType for annotation {annotation!r}") + + +def _resolve_nested_properties( + annotation: Any, + prop_type: PropertyType, +) -> list[PropertySpec] | None: + if prop_type != PropertyType.fixed_collection: + return None + + inner = _strip_optional(annotation) + args = get_args(inner) + if not args: + raise ValueError( + f"fixed_collection field annotation is missing row type: {annotation!r}" + ) + row_type = _strip_optional(args[0]) + if not isinstance(row_type, type) or not issubclass(row_type, BaseModel): + raise ValueError( + f"fixed_collection rows must be BaseModel subclasses: {annotation!r}" + ) + + properties: list[PropertySpec] = [] + for field_name, field in row_type.model_fields.items(): + prop = _build_property_spec(row_type, field_name, field) + if prop is not None: + properties.append(prop) + return properties + + +def _resolve_options( + annotation: Any, + meta: dict[str, Any], + prop_type: PropertyType, +) -> list[PropertyOption] | None: + if prop_type not in (PropertyType.options, PropertyType.multi_options): + return meta.get("options") + + if meta.get("options"): + return meta["options"] + + inner = _strip_optional(annotation) + if prop_type == PropertyType.multi_options: + inner = _strip_optional(get_args(inner)[0]) + + if _is_enum(inner): + return [ + PropertyOption( + value=member.value, label=_humanize_option_label(member.value) + ) + for member in inner + ] + if _is_literal(inner): + return [ + PropertyOption(value=value, label=_humanize_option_label(value)) + for value in get_args(inner) + if value is not None + ] + return None + + +def _resolve_constraints( + field: FieldInfo, + meta: dict[str, Any], +) -> tuple[float | None, float | None, int | None, int | None, str | None]: + min_value = meta.get("min_value") + max_value = meta.get("max_value") + min_length = meta.get("min_length") + max_length = meta.get("max_length") + pattern = meta.get("pattern") + + for item in field.metadata: + if min_value is None: + if hasattr(item, "ge") and item.ge is not None: + min_value = item.ge + elif hasattr(item, "gt") and item.gt is not None: + min_value = item.gt + if max_value is None: + if hasattr(item, "le") and item.le is not None: + max_value = item.le + elif hasattr(item, "lt") and item.lt is not None: + max_value = item.lt + if ( + min_length is None + and hasattr(item, "min_length") + and item.min_length is not None + ): + min_length = item.min_length + if ( + max_length is None + and hasattr(item, "max_length") + and item.max_length is not None + ): + max_length = item.max_length + if pattern is None and hasattr(item, "pattern") and item.pattern is not None: + pattern = item.pattern + + return min_value, max_value, min_length, max_length, pattern + + +def _resolve_default(field: FieldInfo, meta: dict[str, Any]) -> Any: + if "spec_default" in meta: + return meta["spec_default"] + if field.default is not PydanticUndefined: + return field.default + return None + + +def _resolve_required(field: FieldInfo, meta: dict[str, Any]) -> bool: + if meta.get("required") is not None: + return bool(meta["required"]) + return bool(field.is_required()) + + +def _strip_optional(annotation: Any) -> Any: + origin = get_origin(annotation) + if origin is None: + return annotation + + args = [arg for arg in get_args(annotation) if arg is not NoneType] + if len(args) == 1 and len(args) != len(get_args(annotation)): + return args[0] + return annotation + + +def _is_enum(annotation: Any) -> bool: + return isinstance(annotation, type) and issubclass(annotation, Enum) + + +def _is_literal(annotation: Any) -> bool: + return get_origin(annotation) is Literal + + +def _humanize_identifier(name: str) -> str: + return name.replace("_", " ").strip().title() + + +def _humanize_option_label(value: Any) -> str: + if isinstance(value, str): + return value.replace("_", " ").replace("-", " ").strip().title() + return str(value) diff --git a/api/services/workflow/node_specs/qa.py b/api/services/workflow/node_specs/qa.py deleted file mode 100644 index f140297..0000000 --- a/api/services/workflow/node_specs/qa.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Spec for the QA Analysis node — runs an LLM quality review on the call -transcript after completion.""" - -from api.services.workflow.node_specs._base import ( - DisplayOptions, - GraphConstraints, - NodeCategory, - NodeExample, - NodeSpec, - PropertyOption, - PropertySpec, - PropertyType, -) - -DEFAULT_QA_SYSTEM_PROMPT = """You are a QA analyst evaluating a specific segment of a voice AI conversation. - -## Node Purpose -{{node_summary}} - -## Previous Conversation Context (For start of conversation, previous conversation summary can be empty.) -{{previous_conversation_summary}} - -## Tags to evaluate - -Examine the conversation carefully and identify which of the following tags apply: - -- UNCLEAR_CONVERSATION - The conversation is not coherent or clear, messages don't connect logically -- ASSISTANT_IN_LOOP - The assistant asks the same question multiple times or gets stuck repeating itself -- ASSISTANT_REPLY_IMPROPER - The assistant did not reply properly to the user's question/query or seems confused by what the user said -- USER_FRUSTRATED - The user seems angry, frustrated, or is complaining about something in the call -- USER_NOT_UNDERSTANDING - The user explicitly says they don't understand or repeatedly asks for clarification -- HEARING_ISSUES - Either party can't hear the other ("hello?", "are you there?", "can you hear me?") -- DEAD_AIR - Unusually long silences in the conversation (use the timestamps to judge) -- USER_REQUESTING_FEATURE - The user asks for something the assistant can't fulfill -- ASSISTANT_LACKS_EMPATHY - The assistant ignores the user's personal situation or emotional state and continues pitching or pushing the agenda. -- USER_DETECTS_AI - The user suspects or identifies that they are talking to an AI/robot/bot rather than a real human. - -## Call metrics (pre-computed) - -Use these alongside the transcript for your analysis: -{{metrics}} - -## Output format - -Return ONLY a valid JSON object (no markdown): -{ - "tags": [ - { - "tag": "TAG_NAME", - "reason": "Short reason with evidence from the transcript" - } - ], - "overall_sentiment": "positive|neutral|negative", - "call_quality_score": <1-10>, - "summary": "1-2 sentence summary of this segment" -} - -If no tags apply, return an empty tags list. Always provide sentiment, score, and summary.""" - - -SPEC = NodeSpec( - name="qa", - display_name="QA Analysis", - description="Run LLM quality analysis on the call transcript.", - llm_hint=( - "Runs an LLM quality review on the call transcript after completion. " - "Per-node analysis splits the conversation by node and evaluates each " - "segment against the configured system prompt. Sampling, minimum " - "duration, and voicemail filters are supported." - ), - category=NodeCategory.integration, - icon="ClipboardCheck", - properties=[ - PropertySpec( - name="name", - type=PropertyType.string, - display_name="Name", - description="Short identifier for this QA configuration.", - required=True, - min_length=1, - default="QA Analysis", - ), - PropertySpec( - name="qa_enabled", - type=PropertyType.boolean, - display_name="Enabled", - description="When false, the QA run is skipped.", - default=True, - ), - PropertySpec( - name="qa_system_prompt", - type=PropertyType.string, - display_name="System Prompt", - description=( - "Instructions to the QA reviewer LLM. Supports placeholders: " - "`{node_summary}`, `{previous_conversation_summary}`, " - "`{transcript}`, `{metrics}`." - ), - editor="textarea", - default=DEFAULT_QA_SYSTEM_PROMPT, - ), - PropertySpec( - name="qa_min_call_duration", - type=PropertyType.number, - display_name="Minimum Call Duration (seconds)", - description="Calls shorter than this are skipped.", - default=15, - min_value=0, - ), - PropertySpec( - name="qa_voicemail_calls", - type=PropertyType.boolean, - display_name="Include Voicemail Calls", - description="When false, calls flagged as voicemail are skipped.", - default=False, - ), - PropertySpec( - name="qa_sample_rate", - type=PropertyType.number, - display_name="Sample Rate (%)", - description=( - "Percent of eligible calls QA'd. 100 means every call; lower " - "values use random sampling." - ), - default=100, - min_value=1, - max_value=100, - ), - # ---- LLM configuration ---- - PropertySpec( - name="qa_use_workflow_llm", - type=PropertyType.boolean, - display_name="Use Workflow's LLM", - description=( - "When true, the QA pass uses the same LLM the workflow runs " - "with. Set false to specify a separate provider/model." - ), - default=True, - ), - PropertySpec( - name="qa_provider", - type=PropertyType.options, - display_name="QA LLM Provider", - description="LLM provider used for the QA pass.", - display_options=DisplayOptions(show={"qa_use_workflow_llm": [False]}), - options=[ - PropertyOption(value="openai", label="OpenAI"), - PropertyOption(value="azure", label="Azure OpenAI"), - PropertyOption(value="openrouter", label="OpenRouter"), - PropertyOption(value="anthropic", label="Anthropic"), - ], - ), - PropertySpec( - name="qa_model", - type=PropertyType.string, - display_name="QA Model", - description=( - "Model identifier (e.g., 'gpt-4o', 'claude-sonnet-4-6'). " - "Provider-specific." - ), - display_options=DisplayOptions(show={"qa_use_workflow_llm": [False]}), - default="default", - ), - PropertySpec( - name="qa_api_key", - type=PropertyType.string, - display_name="API Key", - description="API key for the chosen provider.", - display_options=DisplayOptions(show={"qa_use_workflow_llm": [False]}), - ), - PropertySpec( - name="qa_endpoint", - type=PropertyType.url, - display_name="Azure Endpoint", - description="Required for the Azure provider.", - display_options=DisplayOptions( - show={"qa_use_workflow_llm": [False], "qa_provider": ["azure"]} - ), - ), - ], - examples=[ - NodeExample( - name="basic_qa", - data={ - "name": "Compliance Check", - "qa_enabled": True, - "qa_system_prompt": ( - "You are a compliance reviewer. Review the transcript and " - "produce a JSON object with `tags`, `summary`, " - "`call_quality_score`, and `overall_sentiment`." - ), - "qa_min_call_duration": 30, - "qa_sample_rate": 100, - }, - ), - ], - # QA runs post-call against the saved transcript (run_integrations - # scans by type), never as a graph step. Reject any edge into or out - # of a QA node. - graph_constraints=GraphConstraints( - min_incoming=0, max_incoming=0, min_outgoing=0, max_outgoing=0 - ), -) diff --git a/api/services/workflow/node_specs/start_call.py b/api/services/workflow/node_specs/start_call.py deleted file mode 100644 index 07cd870..0000000 --- a/api/services/workflow/node_specs/start_call.py +++ /dev/null @@ -1,250 +0,0 @@ -"""Spec for the Start Call node — the single entry point of every workflow. -Carries greeting, pre-call data fetch, and the same prompt/extraction/tools -fields as agent nodes.""" - -from api.services.workflow.node_specs._base import ( - DisplayOptions, - GraphConstraints, - NodeCategory, - NodeExample, - NodeSpec, - PropertyOption, - PropertySpec, - PropertyType, -) - -SPEC = NodeSpec( - name="startCall", - display_name="Start Call", - description="Entry point of the workflow — plays a greeting and opens the conversation.", - llm_hint=( - "The entry point of every workflow (exactly one required). Plays an " - "optional greeting, can fetch context from an external API before " - "the call begins, and executes the first conversational turn." - ), - category=NodeCategory.call_node, - icon="Play", - properties=[ - PropertySpec( - name="name", - type=PropertyType.string, - display_name="Name", - description="Short identifier shown in the canvas and call logs.", - required=True, - min_length=1, - default="Start Call", - ), - # ---- Greeting (variant via greeting_type) ---- - PropertySpec( - name="greeting_type", - type=PropertyType.options, - display_name="Greeting Type", - description=( - "Whether the optional greeting is spoken via TTS from text " - "or played from a pre-recorded audio file." - ), - default="text", - options=[ - PropertyOption(value="text", label="Text (TTS)"), - PropertyOption(value="audio", label="Pre-recorded Audio"), - ], - ), - PropertySpec( - name="greeting", - type=PropertyType.string, - display_name="Greeting Text", - description=( - "Text spoken via TTS at the start of the call. Supports " - "{{template_variables}}. Leave empty to skip the greeting." - ), - display_options=DisplayOptions(show={"greeting_type": ["text"]}), - editor="textarea", - placeholder="Hi {{first_name}}, this is Sarah from Acme.", - ), - PropertySpec( - name="greeting_recording_id", - type=PropertyType.recording_ref, - display_name="Greeting Recording", - description="Pre-recorded audio file played at the start of the call.", - llm_hint=( - "Value is the `recording_id` string. Use the `list_recordings` " - "MCP tool to discover available recordings." - ), - display_options=DisplayOptions(show={"greeting_type": ["audio"]}), - ), - PropertySpec( - name="prompt", - type=PropertyType.mention_textarea, - display_name="Prompt", - description=( - "Agent system prompt for the opening turn. Supports " - "{{template_variables}} from pre-call fetch and the initial context." - ), - required=True, - min_length=1, - placeholder="Greet the caller warmly and ask how you can help today.", - ), - # ---- Behavior toggles ---- - PropertySpec( - name="allow_interrupt", - type=PropertyType.boolean, - display_name="Allow Interruption", - description=("When true, the user can interrupt the agent mid-utterance."), - default=False, - ), - PropertySpec( - name="add_global_prompt", - type=PropertyType.boolean, - display_name="Add Global Prompt", - description=( - "When true and a Global node exists, prepends the global " - "prompt to this node's prompt at runtime." - ), - default=True, - ), - PropertySpec( - name="delayed_start", - type=PropertyType.boolean, - display_name="Delayed Start", - description=( - "When true, the agent waits before speaking after pickup. " - "Useful for outbound calls where the called party needs a " - "moment to settle." - ), - default=False, - ), - PropertySpec( - name="delayed_start_duration", - type=PropertyType.number, - display_name="Delay Duration (seconds)", - description="Seconds to wait before the agent speaks. 0.1–10.", - default=2.0, - min_value=0.1, - max_value=10.0, - display_options=DisplayOptions(show={"delayed_start": [True]}), - ), - # ---- Variable extraction ---- - PropertySpec( - name="extraction_enabled", - type=PropertyType.boolean, - display_name="Enable Variable Extraction", - description=( - "When true, runs an LLM extraction pass on transition out of " - "this node to capture variables from the opening turn." - ), - default=False, - ), - PropertySpec( - name="extraction_prompt", - type=PropertyType.string, - display_name="Extraction Prompt", - description="Overall instructions guiding variable extraction.", - display_options=DisplayOptions(show={"extraction_enabled": [True]}), - editor="textarea", - ), - PropertySpec( - name="extraction_variables", - type=PropertyType.fixed_collection, - display_name="Variables to Extract", - description=( - "Each entry declares one variable to capture, with its name, " - "data type, and per-variable extraction hint." - ), - display_options=DisplayOptions(show={"extraction_enabled": [True]}), - properties=[ - PropertySpec( - name="name", - type=PropertyType.string, - display_name="Variable Name", - description="snake_case identifier used downstream.", - required=True, - ), - PropertySpec( - name="type", - type=PropertyType.options, - display_name="Type", - description="Data type of the extracted value.", - required=True, - default="string", - options=[ - PropertyOption(value="string", label="String"), - PropertyOption(value="number", label="Number"), - PropertyOption(value="boolean", label="Boolean"), - ], - ), - PropertySpec( - name="prompt", - type=PropertyType.string, - display_name="Extraction Hint", - description="Per-variable hint describing what to look for.", - editor="textarea", - ), - ], - ), - # ---- Tools / documents ---- - PropertySpec( - name="tool_uuids", - type=PropertyType.tool_refs, - display_name="Tools", - description="Tools the agent can invoke during the opening turn.", - llm_hint="List of tool UUIDs from `list_tools`.", - ), - PropertySpec( - name="document_uuids", - type=PropertyType.document_refs, - display_name="Knowledge Base Documents", - description="Documents the agent can reference.", - llm_hint="List of document UUIDs from `list_documents`.", - ), - # ---- Pre-call data fetch (advanced) ---- - PropertySpec( - name="pre_call_fetch_enabled", - type=PropertyType.boolean, - display_name="Pre-Call Data Fetch", - description=( - "When true, makes a POST request to an external API before " - "the call starts and merges the JSON response into the call " - "context as template variables." - ), - default=False, - ), - PropertySpec( - name="pre_call_fetch_url", - type=PropertyType.url, - display_name="Endpoint URL", - description=( - "URL the pre-call POST request is sent to. The request body " - "includes caller and called numbers." - ), - display_options=DisplayOptions(show={"pre_call_fetch_enabled": [True]}), - placeholder="https://api.example.com/customer-lookup", - ), - PropertySpec( - name="pre_call_fetch_credential_uuid", - type=PropertyType.credential_ref, - display_name="Authentication", - description="Optional credential attached to the pre-call request.", - llm_hint="Credential UUID from `list_credentials`.", - display_options=DisplayOptions(show={"pre_call_fetch_enabled": [True]}), - ), - ], - examples=[ - NodeExample( - name="warm_greeting", - data={ - "name": "Greeting", - "prompt": "Greet warmly and ask the caller's reason for calling.", - "greeting_type": "text", - "greeting": "Hi {{first_name}}, this is Sarah from Acme.", - "allow_interrupt": True, - }, - ), - ], - # `min_outgoing` is intentionally unset: a startCall is allowed to - # sit on the canvas without an outgoing edge (e.g. a workflow with - # just a greeting). Only constraint: nothing flows INTO the start. - graph_constraints=GraphConstraints( - min_incoming=0, - max_incoming=0, - ), -) diff --git a/api/services/workflow/node_specs/trigger.py b/api/services/workflow/node_specs/trigger.py deleted file mode 100644 index 5d9883d..0000000 --- a/api/services/workflow/node_specs/trigger.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Spec for the API Trigger node — exposes a public webhook URL that -external systems can hit to launch the workflow.""" - -from api.services.workflow.node_specs._base import ( - GraphConstraints, - NodeCategory, - NodeExample, - NodeSpec, - PropertySpec, - PropertyType, -) - -SPEC = NodeSpec( - name="trigger", - display_name="API Trigger", - description=("Public HTTP endpoints that launch the workflow."), - llm_hint=( - "Exposes two public HTTP POST endpoints derived from the auto-generated " - "`trigger_path`:\n" - " • Production: `/api/v1/public/agent/` — runs " - "the published agent. Use this from production systems.\n" - " • Test: `/api/v1/public/agent/test/` — runs " - "the latest draft, useful for verifying changes before publishing. " - "Falls back to the published agent when no draft exists.\n" - "Both require an API key in the `X-API-Key` header.\n" - "Request body fields:\n" - " • `phone_number` (string, required) — destination to dial.\n" - " • `initial_context` (object, optional) — merged into the run's " - "initial context.\n" - " • `telephony_configuration_id` (int, optional) — pick a specific " - "telephony configuration for the call. Must belong to the same " - "organization as the trigger. When omitted, the org's default " - "outbound configuration is used." - ), - category=NodeCategory.trigger, - icon="Webhook", - properties=[ - PropertySpec( - name="name", - type=PropertyType.string, - display_name="Name", - description="Short identifier shown in the canvas. No runtime effect.", - required=True, - min_length=1, - default="API Trigger", - ), - PropertySpec( - name="enabled", - type=PropertyType.boolean, - display_name="Enabled", - description="When false, the trigger URL returns 404.", - default=True, - ), - PropertySpec( - name="trigger_path", - type=PropertyType.string, - display_name="Trigger Path", - description=( - "Auto-generated UUID-style path segment that uniquely " - "identifies this trigger. Used in both URLs:\n" - " • Production: `/api/v1/public/agent/` — " - "executes the published agent.\n" - " • Test: `/api/v1/public/agent/test/` — " - "executes the latest draft.\n" - "Do not edit manually." - ), - ), - ], - examples=[ - NodeExample( - name="default", - data={"name": "Inbound Trigger", "enabled": True}, - ), - ], - graph_constraints=GraphConstraints( - min_incoming=0, - max_incoming=0, - ), -) diff --git a/api/services/workflow/node_specs/webhook.py b/api/services/workflow/node_specs/webhook.py deleted file mode 100644 index 37d17f6..0000000 --- a/api/services/workflow/node_specs/webhook.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Spec for the Webhook node — sends an HTTP request to an external system -after the workflow completes.""" - -from api.services.workflow.node_specs._base import ( - GraphConstraints, - NodeCategory, - NodeExample, - NodeSpec, - PropertyOption, - PropertySpec, - PropertyType, -) - -SPEC = NodeSpec( - name="webhook", - display_name="Webhook", - description="Send HTTP request after the workflow completes.", - llm_hint=( - "Sends an HTTP request to an external system after the workflow " - "completes. The payload is a Jinja-templated JSON body with access " - "to `workflow_run_id`, `initial_context`, `gathered_context`, " - "`annotations`, and call metadata." - ), - category=NodeCategory.integration, - icon="Link2", - properties=[ - PropertySpec( - name="name", - type=PropertyType.string, - display_name="Name", - description="Short identifier shown in the canvas and run logs.", - required=True, - min_length=1, - default="Webhook", - ), - PropertySpec( - name="enabled", - type=PropertyType.boolean, - display_name="Enabled", - description="When false, the webhook is skipped at run time.", - default=True, - ), - PropertySpec( - name="http_method", - type=PropertyType.options, - display_name="HTTP Method", - description="HTTP verb used for the outbound request.", - default="POST", - options=[ - PropertyOption(value="GET", label="GET"), - PropertyOption(value="POST", label="POST"), - PropertyOption(value="PUT", label="PUT"), - PropertyOption(value="PATCH", label="PATCH"), - PropertyOption(value="DELETE", label="DELETE"), - ], - ), - PropertySpec( - name="endpoint_url", - type=PropertyType.url, - display_name="Endpoint URL", - description="URL the request is sent to.", - placeholder="https://api.example.com/webhook", - ), - PropertySpec( - name="credential_uuid", - type=PropertyType.credential_ref, - display_name="Authentication", - description="Optional credential applied as the Authorization header.", - llm_hint="Credential UUID from `list_credentials`.", - ), - PropertySpec( - name="custom_headers", - type=PropertyType.fixed_collection, - display_name="Custom Headers", - description="Additional HTTP headers to include with the request.", - properties=[ - PropertySpec( - name="key", - type=PropertyType.string, - display_name="Header Name", - description="HTTP header name (e.g., 'X-Source').", - required=True, - ), - PropertySpec( - name="value", - type=PropertyType.string, - display_name="Header Value", - description="Header value (supports {{template_variables}}).", - required=True, - ), - ], - ), - PropertySpec( - name="payload_template", - type=PropertyType.json, - display_name="Payload Template", - description=( - "JSON body of the request. Values are Jinja-rendered against " - "the run context — `{{workflow_run_id}}`, " - "`{{gathered_context.foo}}`, `{{annotations.qa_xxx}}`, etc." - ), - default={ - "call_id": "{{workflow_run_id}}", - "first_name": "{{initial_context.first_name}}", - "rsvp": "{{gathered_context.rsvp}}", - "duration": "{{cost_info.call_duration_seconds}}", - "recording_url": "{{recording_url}}", - "transcript_url": "{{transcript_url}}", - }, - ), - ], - examples=[ - NodeExample( - name="post_to_crm", - data={ - "name": "Notify CRM", - "enabled": True, - "http_method": "POST", - "endpoint_url": "https://crm.example.com/calls", - "payload_template": { - "run_id": "{{workflow_run_id}}", - "outcome": "{{gathered_context.call_disposition}}", - }, - }, - ), - ], - # Webhooks fire post-call (run_integrations scans nodes by type), - # never as a graph step. Reject any edge into or out of a webhook so - # the editor can't wire one into the conversation flow. - graph_constraints=GraphConstraints( - min_incoming=0, max_incoming=0, min_outgoing=0, max_outgoing=0 - ), -) diff --git a/api/services/workflow/pipecat_engine.py b/api/services/workflow/pipecat_engine.py index d73d9be..f056725 100644 --- a/api/services/workflow/pipecat_engine.py +++ b/api/services/workflow/pipecat_engine.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Awaitable, Callable, Optional, Union +from typing import TYPE_CHECKING, Awaitable, Callable, Dict, Literal, Optional, Union from pipecat.adapters.schemas.tools_schema import ToolsSchema from pipecat.frames.frames import ( @@ -7,6 +7,7 @@ from pipecat.frames.frames import ( CancelFrame, EndFrame, FunctionCallResultProperties, + LLMContextFrame, TTSSpeakFrame, ) from pipecat.pipeline.task import PipelineTask @@ -16,6 +17,7 @@ from pipecat.services.settings import LLMSettings from pipecat.utils.enums import EndTaskReason from api.db import db_client +from api.enums import ToolCategory from api.services.pipecat.audio_playback import play_audio from api.services.workflow.disposition_mapper import apply_disposition_mapping from api.services.workflow.workflow_graph import Node, WorkflowGraph @@ -34,6 +36,7 @@ import asyncio from loguru import logger from api.services.workflow import pipecat_engine_callbacks as engine_callbacks +from api.services.workflow.mcp_tool_session import McpToolSession from api.services.workflow.pipecat_engine_context_composer import ( compose_functions_for_node, compose_system_prompt_for_node, @@ -116,6 +119,9 @@ class PipecatEngine: # Cached organization ID (resolved lazily from workflow run) self._organization_id: Optional[int] = None + # Open MCP tool sessions for this call, keyed by tool_uuid + self._mcp_sessions: Dict[str, McpToolSession] = {} + # Embeddings configuration (passed from run_pipeline.py) self._embeddings_api_key: Optional[str] = embeddings_api_key self._embeddings_model: Optional[str] = embeddings_model @@ -178,6 +184,9 @@ class PipecatEngine: # Helper that encapsulates custom tool management self._custom_tool_manager = CustomToolManager(self) + # Open persistent MCP server sessions for this call (degrades on failure) + await self._open_mcp_sessions() + # Helper that encapsulates context summarization if self._context_compaction_enabled: self._context_summarization_manager = ContextSummarizationManager(self) @@ -503,7 +512,10 @@ class PipecatEngine: # Register custom tool handlers for this node if node.tool_uuids and self._custom_tool_manager: - await self._custom_tool_manager.register_handlers(node.tool_uuids) + await self._custom_tool_manager.register_handlers( + node.tool_uuids, + mcp_tool_filters=getattr(node, "mcp_tool_filters", None), + ) # Register knowledge base retrieval handler if node has documents if node.document_uuids: @@ -522,14 +534,14 @@ class PipecatEngine: ) await self._update_llm_context(system_prompt, functions) - async def set_node(self, node_id: str): + async def set_node(self, node_id: str, emit_transition_event: bool = True): """ Simplified set_node implementation according to v2 PRD. """ node = self.workflow.nodes[node_id] logger.debug( - f"Executing node: name: {node.name} is_static: {node.is_static} allow_interrupt: {node.allow_interrupt} is_end: {node.is_end}" + f"Executing node: name: {node.name} allow_interrupt: {node.allow_interrupt} is_end: {node.is_end}" ) # Track previous node for transition event @@ -545,7 +557,7 @@ class PipecatEngine: nodes_visited.append(node.name) # Send node transition event if callback is provided - if self._node_transition_callback: + if emit_transition_event and self._node_transition_callback: try: await self._node_transition_callback( node_id, @@ -584,14 +596,11 @@ class PipecatEngine: ) await asyncio.sleep(delay_duration) - if node.is_static: - raise ValueError("Static nodes are not supported!") - else: - # Setup LLM Context with Prompts and Functions - await self._setup_llm_context(node) + # Setup LLM context with prompts and functions. + await self._setup_llm_context(node) - def get_start_greeting(self) -> Optional[tuple[str, Optional[str]]]: - """Return the greeting info for the start node, or None if not configured. + def get_node_greeting(self, node_id: str) -> Optional[tuple[str, Optional[str]]]: + """Return the greeting info for a node, or None if not configured. Returns: A tuple of (greeting_type, value) where: @@ -599,35 +608,102 @@ class PipecatEngine: - ("audio", recording_id) for pre-recorded audio greetings Or None if no greeting is configured. """ - start_node = self.workflow.nodes.get(self.workflow.start_node_id) - if not start_node: + node = self.workflow.nodes.get(node_id) + if not node: return None - greeting_type = start_node.greeting_type or "text" + greeting_type = node.greeting_type or "text" - if greeting_type == "audio" and start_node.greeting_recording_id: - return ("audio", start_node.greeting_recording_id) + if greeting_type == "audio" and node.greeting_recording_id: + return ("audio", node.greeting_recording_id) - if start_node.greeting: - return ("text", self._format_prompt(start_node.greeting)) + if node.greeting: + return ("text", self._format_prompt(node.greeting)) return None + def get_start_greeting(self) -> Optional[tuple[str, Optional[str]]]: + """Return the greeting info for the start node, or None if not configured.""" + return self.get_node_greeting(self.workflow.start_node_id) + + async def queue_node_opening( + self, + *, + node_id: str, + previous_node_id: Optional[str] = None, + generate_if_no_greeting: bool = False, + ) -> Literal["none", "greeting", "llm"]: + """Queue the opening behavior for a node. + + This is the shared source of truth for how a node begins once the + engine is ready and the node has already been set on the context. + + Returns: + "greeting" when a text/audio greeting was queued, + "llm" when an initial LLM generation was queued, + "none" when nothing was queued. + """ + if previous_node_id != node_id: + greeting_info = self.get_node_greeting(node_id) + if greeting_info: + greeting_type, greeting_value = greeting_info + if ( + greeting_type == "audio" + and greeting_value + and self._fetch_recording_audio + and self._transport_output is not None + ): + logger.debug(f"Playing audio greeting recording: {greeting_value}") + result = await self._fetch_recording_audio( + recording_pk=int(greeting_value) + ) + if result: + await play_audio( + result.audio, + sample_rate=self._audio_config.pipeline_sample_rate + if self._audio_config + else 16000, + queue_frame=self._transport_output.queue_frame, + transcript=result.transcript, + append_to_context=True, + ) + return "greeting" + logger.warning( + f"Failed to fetch audio greeting {greeting_value}, " + "falling back to LLM generation" + ) + elif greeting_value and self.task is not None: + logger.debug("Playing text greeting via TTS") + # append_to_context=True so the assistant aggregator commits + # the greeting to the LLM context once TTS finishes; without + # it the LLM would re-greet on its first generation. + await self.task.queue_frame( + TTSSpeakFrame(greeting_value, append_to_context=True) + ) + return "greeting" + + if ( + generate_if_no_greeting + and self.llm is not None + and self.context is not None + ): + logger.debug("Queueing initial LLM generation for node opening") + # Queue after the voicemail detector in the live pipeline so the + # detector can gate initial generations when needed. + await self.llm.queue_frame(LLMContextFrame(self.context)) + return "llm" + + return "none" + async def _handle_end_node(self, node: Node) -> None: """Handle end node execution.""" - if node.is_static: - raise ValueError("Static nodes are not supported!") - else: - # Setup LLM Context with Prompts and Functions - await self._setup_llm_context(node) + # Setup LLM context with prompts and functions. + await self._setup_llm_context(node) async def _handle_agent_node(self, node: Node) -> None: """Handle agent node execution.""" - if node.is_static: - raise ValueError("Static nodes are not supported!") - else: - # Setup LLM Context with Prompts and Functions - await self._setup_llm_context(node) + # Setup LLM context with prompts and functions. + await self._setup_llm_context(node) async def end_call_with_reason( self, @@ -814,6 +890,79 @@ class PipecatEngine: """Get the gathered context including extracted variables.""" return self._gathered_context.copy() + async def _open_mcp_sessions(self) -> None: + """Connect every MCP-category tool referenced by any workflow node. + Failures degrade (session marked unavailable); never raises.""" + from api.services.workflow.tools.mcp_tool import ( + McpDefinitionError, + validate_mcp_definition, + ) + + try: + tool_uuids: set[str] = set() + for node in self.workflow.nodes.values(): + for tu in getattr(node, "tool_uuids", None) or []: + tool_uuids.add(tu) + if not tool_uuids: + return + + organization_id = await self._get_organization_id() + if not organization_id: + logger.warning("Cannot open MCP sessions: organization_id missing") + return + + tools = await db_client.get_tools_by_uuids( + list(tool_uuids), organization_id + ) + for tool in tools: + if tool.category != ToolCategory.MCP.value: + continue + try: + cfg = validate_mcp_definition(tool.definition) + except McpDefinitionError as e: + logger.warning( + f"Skipping MCP tool '{tool.name}' ({tool.tool_uuid}): " + f"invalid definition: {e}" + ) + continue + + credential = None + if cfg["credential_uuid"]: + try: + credential = await db_client.get_credential_by_uuid( + cfg["credential_uuid"], organization_id + ) + except Exception as e: + logger.warning( + f"MCP tool '{tool.name}': credential fetch failed: {e}" + ) + continue + + session = McpToolSession( + tool_uuid=tool.tool_uuid, + tool_name=tool.name, + url=cfg["url"], + credential=credential, + tools_filter=cfg["tools_filter"], + timeout_secs=cfg["timeout_secs"], + sse_read_timeout_secs=cfg["sse_read_timeout_secs"], + ) + await session.start() + self._mcp_sessions[tool.tool_uuid] = session + except Exception as e: + logger.warning( + f"Failed to open MCP sessions; call proceeds without MCP tools: {e}", + exc_info=True, + ) + + async def _close_mcp_sessions(self) -> None: + for tool_uuid, session in list(self._mcp_sessions.items()): + try: + await session.close() + except Exception as e: + logger.warning(f"Error closing MCP session {tool_uuid}: {e}") + self._mcp_sessions = {} + async def cleanup(self): """Clean up engine resources on disconnect.""" # Cancel any pending timeout tasks @@ -823,6 +972,12 @@ class PipecatEngine: ): self._user_response_timeout_task.cancel() - # Cancel any in-flight background summarization - if self._context_summarization_manager: - await self._context_summarization_manager.cleanup() + # Cancel any in-flight background summarization. + # MCP sessions are closed in a finally block so they are guaranteed to + # run even if the summarization cleanup raises an exception. + try: + if self._context_summarization_manager: + await self._context_summarization_manager.cleanup() + finally: + # Close any open MCP tool sessions + await self._close_mcp_sessions() diff --git a/api/services/workflow/pipecat_engine_context_composer.py b/api/services/workflow/pipecat_engine_context_composer.py index 03c253a..41b62c0 100644 --- a/api/services/workflow/pipecat_engine_context_composer.py +++ b/api/services/workflow/pipecat_engine_context_composer.py @@ -117,7 +117,8 @@ async def compose_functions_for_node( # Custom tools if node.tool_uuids and custom_tool_manager: custom_tool_schemas = await custom_tool_manager.get_tool_schemas( - node.tool_uuids + node.tool_uuids, + mcp_tool_filters=getattr(node, "mcp_tool_filters", None), ) functions.extend(custom_tool_schemas) diff --git a/api/services/workflow/pipecat_engine_custom_tools.py b/api/services/workflow/pipecat_engine_custom_tools.py index 844cc10..25298d7 100644 --- a/api/services/workflow/pipecat_engine_custom_tools.py +++ b/api/services/workflow/pipecat_engine_custom_tools.py @@ -34,6 +34,7 @@ from api.services.workflow.tools.custom_tool import ( ) if TYPE_CHECKING: + from api.services.workflow.mcp_tool_session import McpToolSession from api.services.workflow.pipecat_engine import PipecatEngine @@ -121,11 +122,18 @@ class CustomToolManager: """Get the organization ID from the engine (shared cache).""" return await self._engine._get_organization_id() - async def get_tool_schemas(self, tool_uuids: list[str]) -> list[FunctionSchema]: + async def get_tool_schemas( + self, + tool_uuids: list[str], + mcp_tool_filters: Optional[dict[str, list[str]]] = None, + ) -> list[FunctionSchema]: """Fetch custom tools and convert them to function schemas. Args: tool_uuids: List of tool UUIDs to fetch + mcp_tool_filters: Optional per-node filter mapping tool_uuid → list of + raw MCP tool names to expose. None (default) exposes all tools. + Empty dict or entry with [] suppresses all tools for that uuid. Returns: List of FunctionSchema objects for LLM @@ -154,6 +162,22 @@ class CustomToolManager: ) continue + if tool.category == ToolCategory.MCP.value: + session = self._engine._mcp_sessions.get(tool.tool_uuid) + if session is None or not session.available: + logger.warning( + f"MCP tool '{tool.name}' ({tool.tool_uuid}) " + f"unavailable; skipping" + ) + continue + allowed = ( + None + if mcp_tool_filters is None + else set(mcp_tool_filters.get(tool.tool_uuid, [])) + ) + schemas.extend(session.function_schemas(allowed)) + continue + raw_schema = tool_to_function_schema(tool) function_name = raw_schema["function"]["name"] @@ -178,11 +202,18 @@ class CustomToolManager: logger.error(f"Failed to fetch custom tools: {e}") return [] - async def register_handlers(self, tool_uuids: list[str]) -> None: + async def register_handlers( + self, + tool_uuids: list[str], + mcp_tool_filters: Optional[dict[str, list[str]]] = None, + ) -> None: """Register custom tool execution handlers with the LLM. Args: tool_uuids: List of tool UUIDs to register handlers for + mcp_tool_filters: Optional per-node filter mapping tool_uuid → list of + raw MCP tool names to expose. None (default) exposes all tools. + Empty dict or entry with [] suppresses all tools for that uuid. """ organization_id = await self.get_organization_id() if not organization_id: @@ -203,6 +234,32 @@ class CustomToolManager: ) continue + if tool.category == ToolCategory.MCP.value: + session = self._engine._mcp_sessions.get(tool.tool_uuid) + if session is None or not session.available: + logger.warning( + f"MCP tool '{tool.name}' ({tool.tool_uuid}) " + f"unavailable; skipping handler registration" + ) + continue + allowed = ( + None + if mcp_tool_filters is None + else set(mcp_tool_filters.get(tool.tool_uuid, [])) + ) + mcp_schemas = session.function_schemas(allowed) + for fs in mcp_schemas: + self._engine.llm.register_function( + fs.name, + self._create_mcp_handler(session, fs.name), + timeout_secs=session.call_timeout_secs, + ) + logger.debug( + f"Registered {len(mcp_schemas)} MCP " + f"handlers for tool '{tool.name}' ({tool.tool_uuid})" + ) + continue + schema = tool_to_function_schema(tool) function_name = schema["function"]["name"] @@ -240,6 +297,10 @@ class CustomToolManager: timeout_secs = 120.0 handler = self._create_transfer_call_handler(tool, function_name) else: + timeout_ms = ((tool.definition or {}).get("config", {}) or {}).get( + "timeout_ms", 5000 + ) + timeout_secs = float(timeout_ms) / 1000 handler = self._create_http_tool_handler(tool, function_name) return handler, timeout_secs @@ -321,6 +382,7 @@ class CustomToolManager: tool=tool, arguments=function_call_params.arguments, call_context_vars=self._engine._call_context_vars, + gathered_context_vars=self._engine._gathered_context, organization_id=await self.get_organization_id(), ) @@ -334,6 +396,29 @@ class CustomToolManager: return http_tool_handler + def _create_mcp_handler(self, session: "McpToolSession", function_name: str): + """Create a handler that proxies an LLM function call to a live MCP + session. Errors are returned to the LLM as structured text so the + agent can recover verbally; the call is never crashed.""" + + async def mcp_tool_handler( + function_call_params: FunctionCallParams, + ) -> None: + logger.info(f"MCP Tool EXECUTED: {function_name}") + logger.info(f"Arguments: {function_call_params.arguments}") + try: + result = await session.call( + function_name, function_call_params.arguments or {} + ) + await function_call_params.result_callback(result) + except Exception as e: + logger.error(f"MCP tool '{function_name}' failed: {e}") + await function_call_params.result_callback( + {"status": "error", "error": str(e)} + ) + + return mcp_tool_handler + def _create_end_call_handler(self, tool: Any, function_name: str): """Create a handler function for an end call tool. @@ -430,6 +515,17 @@ class CustomToolManager: workflow_run = await db_client.get_workflow_run_by_id( self._engine._workflow_run_id ) + if workflow_run.mode == WorkflowRunMode.TEXTCHAT.value: + textchat_error_result = { + "status": "failed", + "message": "I'm sorry, but call transfers are not available in text chat tests.", + "action": "transfer_failed", + "reason": "textchat_not_supported", + } + await self._handle_transfer_result( + textchat_error_result, function_call_params, properties + ) + return if workflow_run.mode in [ WorkflowRunMode.WEBRTC.value, WorkflowRunMode.SMALLWEBRTC.value, diff --git a/api/services/workflow/qa/tracing.py b/api/services/workflow/qa/tracing.py index 58a0843..919ef9e 100644 --- a/api/services/workflow/qa/tracing.py +++ b/api/services/workflow/qa/tracing.py @@ -6,7 +6,10 @@ import re from loguru import logger from api.db.models import WorkflowRunModel -from api.services.pipecat.tracing_config import get_trace_url +from api.services.pipecat.tracing_config import ( + build_remote_parent_context, + get_trace_url, +) def extract_trace_id(gathered_context: dict) -> str | None: @@ -33,36 +36,12 @@ def setup_langfuse_parent_context(workflow_run: WorkflowRunModel): Returns the parent context object, or None if tracing is unavailable. """ - try: - from opentelemetry.trace import ( - NonRecordingSpan, - SpanContext, - TraceFlags, - set_span_in_context, - ) - - from api.services.pipecat.tracing_config import ensure_tracing - - if not ensure_tracing(): - return None - - gathered_context = workflow_run.gathered_context or {} - trace_id = extract_trace_id(gathered_context) - if not trace_id: - logger.debug("No trace_id found, skipping Langfuse tracing") - return None - - parent_span_ctx = SpanContext( - trace_id=int(trace_id, 16), - span_id=0x1, - is_remote=True, - trace_flags=TraceFlags(0x01), - ) - return set_span_in_context(NonRecordingSpan(parent_span_ctx)) - - except Exception as e: - logger.warning(f"Failed to set up Langfuse parent context: {e}") + gathered_context = workflow_run.gathered_context or {} + trace_id = extract_trace_id(gathered_context) + if not trace_id: + logger.debug("No trace_id found, skipping Langfuse tracing") return None + return build_remote_parent_context(trace_id) def add_qa_span_to_trace( diff --git a/api/services/workflow/text_chat_logs.py b/api/services/workflow/text_chat_logs.py new file mode 100644 index 0000000..42a9274 --- /dev/null +++ b/api/services/workflow/text_chat_logs.py @@ -0,0 +1,144 @@ +"""Helpers for projecting text-chat session state into run-log snapshots.""" + +from typing import Any + +from api.services.pipecat.realtime_feedback_events import ( + build_bot_text_event, + build_function_call_end_event, + build_function_call_start_event, + build_node_transition_event, + build_pipeline_error_event, + build_user_transcription_event, + realtime_feedback_event_sort_key, + stamp_realtime_feedback_event, +) + + +def visible_text_chat_turns(session_data: dict[str, Any]) -> list[dict[str, Any]]: + """Return the active branch of turns for the current text-chat session. + + After a rewind, `session_data["turns"]` may still contain future turns until + the next message is sent. Those turns are no longer part of the visible + branch, so callers that synthesize transcript/log views should trim at + `cursor_turn_id`. + """ + turns = list(session_data.get("turns") or []) + cursor_turn_id = session_data.get("cursor_turn_id") + if cursor_turn_id is None: + return turns + + for index, turn in enumerate(turns): + if turn.get("id") == cursor_turn_id: + return turns[: index + 1] + + return turns + + +def build_text_chat_realtime_feedback_events( + session_data: dict[str, Any], +) -> list[dict[str, Any]]: + """Project text-chat session state into `workflow_runs.logs` event format. + + `workflow_run_text_sessions` holds the authoritative rewindable conversation + state. Historical run pages and QA helpers read the normalized + `workflow_runs.logs.realtime_feedback_events` schema instead, so this helper + rebuilds that snapshot from the currently visible branch. + """ + events: list[dict[str, Any]] = [] + last_emitted_node_id: str | None = None + + for turn_index, turn in enumerate(visible_text_chat_turns(session_data)): + turn_events = list(turn.get("events") or []) + for event in turn_events: + payload = dict(event.get("payload") or {}) + event_type = event.get("type") + timestamp = event.get("created_at") or turn.get("created_at") + + if event_type == "node_transition": + node_id = payload.get("node_id") + if node_id is not None and node_id == last_emitted_node_id: + continue + snapshot_event = stamp_realtime_feedback_event( + build_node_transition_event( + node_id=node_id, + node_name=payload.get("node_name"), + previous_node_id=payload.get("previous_node_id"), + previous_node_name=payload.get("previous_node_name"), + allow_interrupt=bool(payload.get("allow_interrupt", False)), + ), + timestamp=timestamp, + turn=turn_index, + node_id=node_id, + node_name=payload.get("node_name"), + ) + if node_id is not None: + last_emitted_node_id = node_id + events.append(snapshot_event) + elif event_type == "tool_call_started": + events.append( + stamp_realtime_feedback_event( + build_function_call_start_event( + function_name=payload.get("function_name"), + tool_call_id=payload.get("tool_call_id"), + arguments=payload.get("arguments"), + ), + timestamp=timestamp, + turn=turn_index, + ) + ) + elif event_type == "tool_call_result": + events.append( + stamp_realtime_feedback_event( + build_function_call_end_event( + function_name=payload.get("function_name"), + tool_call_id=payload.get("tool_call_id"), + result=payload.get("result"), + ), + timestamp=timestamp, + turn=turn_index, + ) + ) + elif event_type == "execution_error": + events.append( + stamp_realtime_feedback_event( + build_pipeline_error_event( + error=payload.get("message", "Execution error"), + fatal=True, + ), + timestamp=timestamp, + turn=turn_index, + ) + ) + + user_message = turn.get("user_message") or {} + if user_message.get("text"): + message_timestamp = user_message.get("created_at") or turn.get("created_at") + events.append( + stamp_realtime_feedback_event( + build_user_transcription_event( + text=user_message["text"], + final=True, + timestamp=message_timestamp, + ), + timestamp=message_timestamp, + turn=turn_index, + ) + ) + + assistant_message = turn.get("assistant_message") or {} + if assistant_message.get("text"): + message_timestamp = assistant_message.get("created_at") or turn.get( + "created_at" + ) + events.append( + stamp_realtime_feedback_event( + build_bot_text_event( + text=assistant_message["text"], + timestamp=message_timestamp, + ), + timestamp=message_timestamp, + turn=turn_index, + ) + ) + + return sorted(events, key=realtime_feedback_event_sort_key) diff --git a/api/services/workflow/text_chat_runner.py b/api/services/workflow/text_chat_runner.py new file mode 100644 index 0000000..577aac1 --- /dev/null +++ b/api/services/workflow/text_chat_runner.py @@ -0,0 +1,649 @@ +import asyncio +import hashlib +import time +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any + +from fastapi.encoders import jsonable_encoder +from loguru import logger +from pipecat.frames.frames import ( + BotStoppedSpeakingFrame, + CancelFrame, + EndFrame, + FunctionCallInProgressFrame, + FunctionCallResultFrame, + LLMAssistantPushAggregationFrame, + LLMContextFrame, + LLMFullResponseEndFrame, + LLMFullResponseStartFrame, + TextFrame, + TTSSpeakFrame, + TTSStoppedFrame, +) +from pipecat.pipeline.pipeline import Pipeline +from pipecat.pipeline.runner import PipelineRunner +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.aggregators.llm_response_universal import ( + LLMAssistantAggregatorParams, + LLMContextAggregatorPair, +) +from pipecat.processors.frame_processor import FrameDirection, FrameProcessor +from pipecat.utils.run_context import set_current_org_id + +from api.db import db_client +from api.enums import WorkflowRunMode, WorkflowRunState +from api.services.configuration.resolve import resolve_effective_config +from api.services.pipecat.audio_config import create_audio_config +from api.services.pipecat.pipeline_builder import create_pipeline_task +from api.services.pipecat.pipeline_metrics_aggregator import ( + PipelineMetricsAggregator, +) +from api.services.pipecat.recording_audio_cache import create_recording_audio_fetcher +from api.services.pipecat.service_factory import create_llm_service +from api.services.pipecat.tracing_config import ( + build_remote_parent_context, + get_trace_url, +) +from api.services.workflow.dto import ReactFlowDTO +from api.services.workflow.pipecat_engine import PipecatEngine +from api.services.workflow.workflow_graph import WorkflowGraph + +TEXT_CHAT_CHECKPOINT_VERSION = 1 +TEXT_CHAT_TURN_TIMEOUT_SECONDS = 60.0 +TEXT_CHAT_IDLE_SETTLE_SECONDS = 0.2 +TEXT_CHAT_INTERNAL_CANCEL_REASON = "text_chat_turn_complete" + + +def text_chat_trace_id(workflow_run_id: int) -> str: + """Deterministic Langfuse trace id for a text-chat session. + + Each turn runs in its own short-lived pipeline, so there is no single + long-running task to own the trace the way a voice call does. Deriving the + id from the run id means every turn re-creates the *same* trace id and all + per-turn spans land in one shared trace — without persisting extra state + across the otherwise stateless turn requests. + """ + digest = hashlib.sha256(f"dograh-text-chat:{workflow_run_id}".encode()).hexdigest() + return digest[:32] + + +def default_text_chat_checkpoint() -> dict[str, Any]: + return { + "version": TEXT_CHAT_CHECKPOINT_VERSION, + "anchor_turn_id": None, + "current_node_id": None, + "messages": [], + "gathered_context": {}, + "tool_state": {}, + } + + +def normalize_text_chat_checkpoint( + checkpoint: dict[str, Any] | None, +) -> dict[str, Any]: + normalized = { + **default_text_chat_checkpoint(), + **(checkpoint or {}), + } + normalized["messages"] = list(normalized.get("messages") or []) + normalized["gathered_context"] = dict(normalized.get("gathered_context") or {}) + normalized["tool_state"] = dict(normalized.get("tool_state") or {}) + return normalized + + +@dataclass +class TextChatTurnExecutionResult: + assistant_text: str | None + assistant_created_at: str + events: list[dict[str, Any]] + usage: dict[str, Any] + checkpoint: dict[str, Any] + gathered_context: dict[str, Any] + initial_context: dict[str, Any] + state: str + is_completed: bool + + +@dataclass +class _ResponseWindowState: + active_assistant_segments: int = 0 + active_llm_completions: int = 0 + pending_context_requests: int = 0 + blocking_tool_call_ids: set[str] = field(default_factory=set) + outputs: list[str] = field(default_factory=list) + + def note_direct_context_request(self) -> None: + self.pending_context_requests += 1 + + def note_upstream_context_request(self) -> None: + self.pending_context_requests += 1 + + def note_llm_start(self) -> None: + if self.pending_context_requests > 0: + self.pending_context_requests -= 1 + self.active_llm_completions += 1 + + def note_llm_end(self) -> None: + if self.active_llm_completions > 0: + self.active_llm_completions -= 1 + + def note_assistant_turn_started(self) -> None: + self.active_assistant_segments += 1 + + def note_assistant_turn_stopped(self, content: str) -> None: + if self.active_assistant_segments > 0: + self.active_assistant_segments -= 1 + normalized_content = content.strip() + if normalized_content: + self.outputs.append(normalized_content) + + def note_function_call_in_progress(self, tool_call_id: str, blocking: bool) -> None: + if blocking: + self.blocking_tool_call_ids.add(tool_call_id) + + def note_function_call_result(self, tool_call_id: str) -> None: + self.blocking_tool_call_ids.discard(tool_call_id) + + @property + def has_blocking_tool_calls(self) -> bool: + return bool(self.blocking_tool_call_ids) + + @property + def frontier_is_idle(self) -> bool: + return ( + self.pending_context_requests == 0 + and self.active_llm_completions == 0 + and self.active_assistant_segments == 0 + and not self.has_blocking_tool_calls + ) + + +class _TaskQueueProxy: + def __init__(self, queue_frame): + self.queue_frame = queue_frame + + +class _TextChatCaptureProcessor(FrameProcessor): + def __init__(self, response_window: _ResponseWindowState) -> None: + super().__init__() + self.last_activity_at = time.monotonic() + self.activity_count = 0 + self.events: list[dict[str, Any]] = [] + self._response_window = response_window + + def _touch(self) -> None: + self.last_activity_at = time.monotonic() + self.activity_count += 1 + + def _append_event(self, event_type: str, payload: dict[str, Any]) -> None: + self.events.append( + { + "type": event_type, + "created_at": datetime.now(UTC).isoformat(), + "payload": jsonable_encoder(payload), + } + ) + + async def process_frame(self, frame, direction: FrameDirection): + await super().process_frame(frame, direction) + self._touch() + + if isinstance(frame, TTSSpeakFrame): + text_frame = TextFrame(frame.text) + text_frame.append_to_context = ( + frame.append_to_context if frame.append_to_context is not None else True + ) + await self.push_frame(text_frame, direction) + await self.push_frame(LLMAssistantPushAggregationFrame(), direction) + return + + if isinstance(frame, LLMContextFrame) and direction == FrameDirection.UPSTREAM: + self._response_window.note_upstream_context_request() + + if isinstance(frame, TTSStoppedFrame): + await self.push_frame(frame, direction) + await self.push_frame(LLMAssistantPushAggregationFrame(), direction) + return + + if ( + isinstance(frame, LLMFullResponseStartFrame) + and direction == FrameDirection.DOWNSTREAM + ): + self._response_window.note_llm_start() + + if ( + isinstance(frame, LLMFullResponseEndFrame) + and direction is FrameDirection.DOWNSTREAM + ): + self._response_window.note_llm_end() + await self.push_frame(frame, direction) + # Text chat has no TTS/output transport, so mixed text+tool responses + # would otherwise leave function calls waiting forever on a + # BotStoppedSpeakingFrame that never arrives. + await self.push_frame(BotStoppedSpeakingFrame(), FrameDirection.UPSTREAM) + return + + if isinstance(frame, FunctionCallInProgressFrame): + self._response_window.note_function_call_in_progress( + tool_call_id=frame.tool_call_id, + blocking=frame.cancel_on_interruption, + ) + self._append_event( + "tool_call_started", + { + "function_name": frame.function_name, + "tool_call_id": frame.tool_call_id, + "arguments": dict(frame.arguments or {}), + }, + ) + elif isinstance(frame, FunctionCallResultFrame): + self._response_window.note_function_call_result(frame.tool_call_id) + self._append_event( + "tool_call_result", + { + "function_name": frame.function_name, + "tool_call_id": frame.tool_call_id, + "result": frame.result, + }, + ) + elif isinstance(frame, EndFrame): + self._append_event("session_end", {"reason": frame.reason}) + elif isinstance(frame, CancelFrame): + if frame.reason != TEXT_CHAT_INTERNAL_CANCEL_REASON: + self._append_event("session_cancelled", {"reason": frame.reason}) + + await self.push_frame(frame, direction) + + +def _merge_usage_info( + existing: dict[str, Any] | None, + delta: dict[str, Any] | None, +) -> dict[str, Any]: + merged = dict(existing or {}) + delta = dict(delta or {}) + + merged_llm = dict(merged.get("llm") or {}) + for key, value in (delta.get("llm") or {}).items(): + current = dict(merged_llm.get(key) or {}) + merged_llm[key] = { + "prompt_tokens": int(current.get("prompt_tokens") or 0) + + int(value.get("prompt_tokens") or 0), + "completion_tokens": int(current.get("completion_tokens") or 0) + + int(value.get("completion_tokens") or 0), + "total_tokens": int(current.get("total_tokens") or 0) + + int(value.get("total_tokens") or 0), + "cache_read_input_tokens": int(current.get("cache_read_input_tokens") or 0) + + int(value.get("cache_read_input_tokens") or 0), + "cache_creation_input_tokens": int( + current.get("cache_creation_input_tokens") or 0 + ) + + int(value.get("cache_creation_input_tokens") or 0), + } + merged["llm"] = merged_llm + + for section in ("tts", "stt"): + merged_section = dict(merged.get(section) or {}) + for key, value in (delta.get(section) or {}).items(): + merged_section[key] = float(merged_section.get(key) or 0) + float(value) + merged[section] = merged_section + + merged["call_duration_seconds"] = int( + merged.get("call_duration_seconds") or 0 + ) + int(delta.get("call_duration_seconds") or 0) + + return merged + + +def merge_text_chat_usage_info( + existing: dict[str, Any] | None, + delta: dict[str, Any] | None, +) -> dict[str, Any]: + return _merge_usage_info(existing, delta) + + +def _resolve_checkpoint_for_pending_turn( + session_data: dict[str, Any], + checkpoint: dict[str, Any] | None, +) -> dict[str, Any]: + turns = list(session_data.get("turns") or []) + if not turns: + return normalize_text_chat_checkpoint(checkpoint) + + pending_turn = turns[-1] + if pending_turn.get("status") != "pending": + return normalize_text_chat_checkpoint(checkpoint) + + for turn in reversed(turns[:-1]): + if turn.get("status") != "completed": + continue + stored_checkpoint = turn.get("checkpoint_after_turn") + if stored_checkpoint: + return normalize_text_chat_checkpoint(stored_checkpoint) + break + + return normalize_text_chat_checkpoint(checkpoint) + + +async def _wait_for_quiescence( + *, + capture_processor: _TextChatCaptureProcessor, + response_window: _ResponseWindowState, + runner_task: asyncio.Task, + activity_marker: int, + timeout_seconds: float = TEXT_CHAT_TURN_TIMEOUT_SECONDS, +) -> None: + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout_seconds + + while loop.time() < deadline: + if runner_task.done(): + await runner_task + return + + if ( + capture_processor.activity_count <= activity_marker + and response_window.frontier_is_idle + ): + await asyncio.sleep(0.05) + continue + + if ( + response_window.frontier_is_idle + and (time.monotonic() - capture_processor.last_activity_at) + >= TEXT_CHAT_IDLE_SETTLE_SECONDS + ): + return + + await asyncio.sleep(0.05) + + raise TimeoutError( + "Timed out waiting for text chat response window to settle " + f"(pending_context_requests={response_window.pending_context_requests}, " + f"active_llm_completions={response_window.active_llm_completions}, " + f"active_assistant_segments={response_window.active_assistant_segments}, " + f"blocking_tool_calls={sorted(response_window.blocking_tool_call_ids)})" + ) + + +async def execute_text_chat_pending_turn( + *, + workflow_run_id: int, + workflow_id: int, + session_data: dict[str, Any], + checkpoint: dict[str, Any] | None, +) -> TextChatTurnExecutionResult: + turns = list(session_data.get("turns") or []) + if not turns or turns[-1].get("status") != "pending": + raise ValueError("Text chat session has no pending turn to execute") + + pending_turn = turns[-1] + pending_user_message = ( + ((pending_turn.get("user_message") or {}).get("text") or "").strip() + if pending_turn.get("user_message") is not None + else None + ) + + workflow_run, _ = await db_client.get_workflow_run_with_context(workflow_run_id) + if not workflow_run or workflow_run.workflow_id != workflow_id: + raise ValueError("Workflow run not found for text chat execution") + if workflow_run.definition is None: + raise ValueError("Workflow run is missing a pinned definition") + if workflow_run.workflow is None or workflow_run.workflow.user is None: + raise ValueError("Workflow run is missing workflow context") + + workflow = await db_client.get_workflow( + workflow_id, organization_id=workflow_run.workflow.organization_id + ) + if workflow is None: + raise ValueError("Workflow not found for text chat execution") + + # Stamp the async context so OTEL spans are tagged with this org and routed + # to its Langfuse project (the voice paths do this in run_pipeline / + # webrtc_signaling; the text path previously skipped it, so its spans never + # reached org-specific exporters). + set_current_org_id(workflow.organization_id) + + run_definition = workflow_run.definition + run_configs = run_definition.workflow_configurations or {} + + user_config = await db_client.get_user_configurations(workflow_run.workflow.user.id) + user_config = resolve_effective_config( + user_config, run_configs.get("model_overrides") + ) + if user_config.llm is None: + raise ValueError("Text chat requires an LLM configuration") + + llm = create_llm_service(user_config) + inference_llm = llm + + runtime_configuration = { + "llm_provider": user_config.llm.provider, + "llm_model": user_config.llm.model, + } + initial_context = { + **(workflow_run.initial_context or {}), + "runtime_configuration": runtime_configuration, + } + + workflow_graph = WorkflowGraph( + ReactFlowDTO.model_validate(run_definition.workflow_json) + ) + base_checkpoint = _resolve_checkpoint_for_pending_turn(session_data, checkpoint) + + response_window = _ResponseWindowState() + capture_processor = _TextChatCaptureProcessor(response_window) + context = LLMContext() + context.set_messages(base_checkpoint["messages"]) + + node_transition_events = capture_processor.events + + async def send_node_transition( + node_id: str, + node_name: str, + previous_node_id: str | None, + previous_node_name: str | None, + allow_interrupt: bool = False, + ) -> None: + node_transition_events.append( + { + "type": "node_transition", + "created_at": datetime.now(UTC).isoformat(), + "payload": { + "node_id": node_id, + "node_name": node_name, + "previous_node_id": previous_node_id, + "previous_node_name": previous_node_name, + "allow_interrupt": allow_interrupt, + }, + } + ) + + embeddings_api_key = None + embeddings_model = None + embeddings_base_url = None + if user_config.embeddings: + embeddings_api_key = user_config.embeddings.api_key + embeddings_model = user_config.embeddings.model + embeddings_base_url = getattr(user_config.embeddings, "base_url", None) + + has_recordings = await db_client.has_active_recordings(workflow.organization_id) + context_compaction_enabled = (workflow.workflow_configurations or {}).get( + "context_compaction_enabled", False + ) + + engine = PipecatEngine( + llm=llm, + inference_llm=inference_llm, + context=context, + workflow=workflow_graph, + call_context_vars=initial_context, + workflow_run_id=workflow_run_id, + node_transition_callback=send_node_transition, + embeddings_api_key=embeddings_api_key, + embeddings_model=embeddings_model, + embeddings_base_url=embeddings_base_url, + has_recordings=has_recordings, + context_compaction_enabled=context_compaction_enabled, + ) + engine._gathered_context = dict(base_checkpoint["gathered_context"]) + + assistant_params = LLMAssistantAggregatorParams() + context_aggregator = LLMContextAggregatorPair( + context, assistant_params=assistant_params + ) + assistant_context_aggregator = context_aggregator.assistant() + + @assistant_context_aggregator.event_handler("on_assistant_turn_started") + async def on_assistant_turn_started(_aggregator): + response_window.note_assistant_turn_started() + + @assistant_context_aggregator.event_handler("on_assistant_turn_stopped") + async def on_assistant_turn_stopped(_aggregator, message): + response_window.note_assistant_turn_stopped(message.content or "") + + # Text chat has no wire transport; reuse the neutral 16 kHz config shape + # from the browser pipeline so TTS/recording helpers still have sane defaults. + audio_config = create_audio_config(WorkflowRunMode.SMALLWEBRTC.value) + pipeline_metrics_aggregator = PipelineMetricsAggregator() + + # Stitch every per-turn pipeline of this session into one Langfuse trace by + # handing each task the same remote parent context (derived from the run id). + trace_id = text_chat_trace_id(workflow_run_id) + conversation_parent_context = build_remote_parent_context(trace_id) + # The stitched trace has no real root span (each per-turn conversation span + # hangs off a synthetic remote parent), so Langfuse can't infer a name and + # shows "Unnamed trace". Name it explicitly via the conversation span. + trace_span_attributes = { + "langfuse.trace.name": workflow_run.name or f"text-chat-{workflow_run_id}" + } + + pipeline = Pipeline( + [ + llm, + capture_processor, + assistant_context_aggregator, + pipeline_metrics_aggregator, + ] + ) + task = create_pipeline_task( + pipeline, + workflow_run_id, + audio_config, + conversation_parent_context=conversation_parent_context, + conversation_type="text", + additional_span_attributes=trace_span_attributes, + ) + runner = PipelineRunner(handle_sigint=False, handle_sigterm=False) + runner_task = asyncio.create_task(runner.run(task)) + + engine.set_task(task) + engine.set_audio_config(audio_config) + engine.set_transport_output(_TaskQueueProxy(task.queue_frame)) + engine.set_fetch_recording_audio( + create_recording_audio_fetcher( + organization_id=workflow.organization_id, + pipeline_sample_rate=audio_config.pipeline_sample_rate, + ) + ) + + try: + await asyncio.wait_for(task._pipeline_start_event.wait(), timeout=5.0) + + await engine.initialize() + + current_node_id = base_checkpoint.get("current_node_id") + target_node_id = current_node_id or workflow_graph.start_node_id + await engine.set_node( + target_node_id, + emit_transition_event=current_node_id is None, + ) + + opening_marker = capture_processor.activity_count + opening_expects_llm = pending_user_message is None and ( + current_node_id == target_node_id + or engine.get_node_greeting(target_node_id) is None + ) + if opening_expects_llm: + response_window.note_direct_context_request() + opening_action = await engine.queue_node_opening( + node_id=target_node_id, + previous_node_id=current_node_id, + generate_if_no_greeting=pending_user_message is None, + ) + if opening_action != "llm" and opening_expects_llm: + response_window.pending_context_requests = max( + 0, response_window.pending_context_requests - 1 + ) + if opening_action != "none": + await _wait_for_quiescence( + capture_processor=capture_processor, + response_window=response_window, + runner_task=runner_task, + activity_marker=opening_marker, + ) + + if pending_user_message is not None: + context.add_message({"role": "user", "content": pending_user_message}) + generation_marker = capture_processor.activity_count + response_window.note_direct_context_request() + await llm.queue_frame(LLMContextFrame(context)) + await _wait_for_quiescence( + capture_processor=capture_processor, + response_window=response_window, + runner_task=runner_task, + activity_marker=generation_marker, + ) + finally: + if not task.has_finished(): + await task.cancel(reason=TEXT_CHAT_INTERNAL_CANCEL_REASON) + try: + await runner_task + except Exception: + logger.exception( + "Transportless text chat pipeline failed while closing run {}", + workflow_run_id, + ) + await engine.cleanup() + raise + await engine.cleanup() + + gathered_context = await engine.get_gathered_context() + assistant_text = ( + "\n\n".join(part for part in response_window.outputs if part).strip() + if response_window.outputs + else None + ) + assistant_created_at = datetime.now(UTC).isoformat() + usage = pipeline_metrics_aggregator.get_all_usage_metrics_serialized() + current_node = getattr(engine, "_current_node", None) + + updated_checkpoint = { + "version": TEXT_CHAT_CHECKPOINT_VERSION, + "anchor_turn_id": pending_turn.get("id"), + "current_node_id": current_node.id if current_node else None, + "messages": jsonable_encoder(context.get_messages()), + "gathered_context": jsonable_encoder(gathered_context), + "tool_state": jsonable_encoder(base_checkpoint.get("tool_state") or {}), + } + + encoded_gathered_context = jsonable_encoder(gathered_context) + trace_url = get_trace_url(trace_id, org_id=workflow.organization_id) + if trace_url: + encoded_gathered_context = {**encoded_gathered_context, "trace_url": trace_url} + + return TextChatTurnExecutionResult( + assistant_text=assistant_text, + assistant_created_at=assistant_created_at, + events=jsonable_encoder(capture_processor.events), + usage=jsonable_encoder(usage), + checkpoint=updated_checkpoint, + gathered_context=encoded_gathered_context, + initial_context=jsonable_encoder(initial_context), + state=( + WorkflowRunState.COMPLETED.value + if engine.is_call_disposed() + else WorkflowRunState.RUNNING.value + ), + is_completed=engine.is_call_disposed(), + ) diff --git a/api/services/workflow/text_chat_session_service.py b/api/services/workflow/text_chat_session_service.py new file mode 100644 index 0000000..53354d5 --- /dev/null +++ b/api/services/workflow/text_chat_session_service.py @@ -0,0 +1,411 @@ +"""Service helpers for text-chat session lifecycle orchestration.""" + +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +from loguru import logger + +from api.db import db_client +from api.db.models import WorkflowRunTextSessionModel +from api.db.workflow_run_text_session_client import ( + WorkflowRunTextSessionRevisionConflictError, +) +from api.services.pricing.workflow_run_cost import ( + apply_usage_delta_to_organization, + build_workflow_run_cost_info, +) +from api.services.workflow.text_chat_logs import ( + build_text_chat_realtime_feedback_events, +) +from api.services.workflow.text_chat_runner import ( + default_text_chat_checkpoint, + execute_text_chat_pending_turn, + merge_text_chat_usage_info, + normalize_text_chat_checkpoint, +) + +TEXT_CHAT_SESSION_VERSION = 1 + + +class TextChatSessionRevisionConflictError(Exception): + def __init__(self, expected_revision: int, actual_revision: int): + self.expected_revision = expected_revision + self.actual_revision = actual_revision + super().__init__( + "Text chat session revision conflict: " + f"expected {expected_revision}, found {actual_revision}" + ) + + +class TextChatSessionExecutionError(Exception): + """Raised when the assistant turn fails to execute.""" + + +class TextChatPendingTurnLostError(Exception): + """Raised when the pending turn disappears before persistence completes.""" + + +class TextChatTurnNotFoundError(Exception): + """Raised when a requested rewind cursor does not exist in the session.""" + + +def default_text_chat_session_data() -> dict[str, Any]: + return { + "version": TEXT_CHAT_SESSION_VERSION, + "status": "idle", + "cursor_turn_id": None, + "turns": [], + "discarded_future": [], + "simulator": { + "enabled": False, + "config": {}, + }, + } + + +def normalize_text_chat_session_data( + session_data: dict[str, Any] | None, +) -> dict[str, Any]: + normalized = { + **default_text_chat_session_data(), + **(session_data or {}), + } + normalized["turns"] = list(normalized.get("turns") or []) + normalized["discarded_future"] = list(normalized.get("discarded_future") or []) + simulator = normalized.get("simulator") or {} + normalized["simulator"] = { + "enabled": bool(simulator.get("enabled", False)), + "config": dict(simulator.get("config") or {}), + } + return normalized + + +async def initialize_text_chat_session( + *, + run_id: int, + text_session: WorkflowRunTextSessionModel, +) -> WorkflowRunTextSessionModel: + session_data = normalize_text_chat_session_data(text_session.session_data) + checkpoint = normalize_text_chat_checkpoint(text_session.checkpoint) + + session_data["turns"] = [build_pending_text_chat_turn(user_text=None)] + session_data["status"] = "pending_assistant_turn" + checkpoint["anchor_turn_id"] = latest_completed_text_chat_turn_id( + session_data["turns"] + ) + + try: + await db_client.update_workflow_run_text_session( + run_id, + session_data=session_data, + checkpoint=checkpoint, + expected_revision=text_session.revision, + ) + except WorkflowRunTextSessionRevisionConflictError as e: + raise TextChatSessionRevisionConflictError( + expected_revision=e.expected_revision, + actual_revision=e.actual_revision, + ) from e + + return await _reload_text_chat_session(run_id) + + +async def append_text_chat_user_message( + *, + run_id: int, + text_session: WorkflowRunTextSessionModel, + user_text: str, + expected_revision: int | None, +) -> WorkflowRunTextSessionModel: + session_data = normalize_text_chat_session_data(text_session.session_data) + checkpoint = normalize_text_chat_checkpoint(text_session.checkpoint) + + active_turns, discarded_future = truncate_text_chat_future_turns(session_data) + active_turns.append(build_pending_text_chat_turn(user_text=user_text)) + + session_data["turns"] = active_turns + session_data["discarded_future"] = discarded_future + session_data["cursor_turn_id"] = None + session_data["status"] = "pending_assistant_turn" + checkpoint["anchor_turn_id"] = latest_completed_text_chat_turn_id(active_turns) + + try: + await db_client.update_workflow_run_text_session( + run_id, + session_data=session_data, + checkpoint=checkpoint, + expected_revision=expected_revision, + ) + except WorkflowRunTextSessionRevisionConflictError as e: + raise TextChatSessionRevisionConflictError( + expected_revision=e.expected_revision, + actual_revision=e.actual_revision, + ) from e + + return await _reload_text_chat_session(run_id) + + +async def rewind_text_chat_session_state( + *, + run_id: int, + text_session: WorkflowRunTextSessionModel, + cursor_turn_id: str | None, + expected_revision: int | None, +) -> WorkflowRunTextSessionModel: + session_data = normalize_text_chat_session_data(text_session.session_data) + validate_text_chat_turn_cursor(session_data, cursor_turn_id) + + session_data["cursor_turn_id"] = cursor_turn_id + session_data["status"] = "rewound" if cursor_turn_id else "idle" + + try: + await db_client.update_workflow_run_text_session( + run_id, + session_data=session_data, + expected_revision=expected_revision, + ) + except WorkflowRunTextSessionRevisionConflictError as e: + raise TextChatSessionRevisionConflictError( + expected_revision=e.expected_revision, + actual_revision=e.actual_revision, + ) from e + + await db_client.update_workflow_run( + run_id, + logs={ + "realtime_feedback_events": build_text_chat_realtime_feedback_events( + session_data + ) + }, + ) + + return await _reload_text_chat_session(run_id) + + +async def execute_pending_text_chat_turn( + *, + workflow_id: int, + run_id: int, + text_session: WorkflowRunTextSessionModel, +) -> WorkflowRunTextSessionModel: + """Execute the current pending assistant turn and persist its side effects.""" + session_data = normalize_text_chat_session_data(text_session.session_data) + checkpoint = normalize_text_chat_checkpoint(text_session.checkpoint) + + try: + execution = await execute_text_chat_pending_turn( + workflow_run_id=run_id, + workflow_id=workflow_id, + session_data=session_data, + checkpoint=checkpoint, + ) + except Exception as e: + await _mark_pending_turn_failed( + run_id=run_id, + text_session=text_session, + error_message=str(e), + ) + raise TextChatSessionExecutionError( + "Failed to execute text chat assistant turn" + ) from e + + completed_session_data = normalize_text_chat_session_data(text_session.session_data) + completed_turns = list(completed_session_data.get("turns") or []) + if not completed_turns or completed_turns[-1].get("status") != "pending": + raise TextChatPendingTurnLostError( + "Text chat session lost its pending turn before completion" + ) + + completed_turns[-1]["status"] = "completed" + completed_turns[-1]["assistant_message"] = ( + { + "text": execution.assistant_text, + "created_at": execution.assistant_created_at, + } + if execution.assistant_text + else None + ) + completed_turns[-1]["events"] = execution.events + completed_turns[-1]["usage"] = execution.usage + completed_turns[-1]["checkpoint_after_turn"] = execution.checkpoint + completed_session_data["turns"] = completed_turns + completed_session_data["status"] = "idle" + + try: + await db_client.update_workflow_run_text_session( + run_id, + session_data=completed_session_data, + checkpoint=execution.checkpoint, + expected_revision=text_session.revision, + ) + except WorkflowRunTextSessionRevisionConflictError as e: + raise TextChatSessionRevisionConflictError( + expected_revision=e.expected_revision, + actual_revision=e.actual_revision, + ) from e + + existing_usage_info = text_session.workflow_run.usage_info or {} + merged_usage_info = merge_text_chat_usage_info(existing_usage_info, execution.usage) + text_chat_logs = { + "realtime_feedback_events": build_text_chat_realtime_feedback_events( + completed_session_data + ) + } + await db_client.update_workflow_run( + run_id, + initial_context=execution.initial_context, + usage_info=merged_usage_info, + gathered_context=execution.gathered_context, + logs=text_chat_logs, + state=execution.state, + is_completed=execution.is_completed, + ) + workflow_run = await db_client.get_workflow_run_by_id(run_id) + if workflow_run: + try: + # Apply the per-turn delta so org usage tracks cumulative run cost + # without replaying the full session totals on every turn. + await apply_usage_delta_to_organization(workflow_run, execution.usage) + except Exception as e: + logger.error( + f"Failed to update organization usage for text chat run {run_id}: {e}" + ) + + cost_info = await build_workflow_run_cost_info(workflow_run) + if cost_info is not None: + await db_client.update_workflow_run(run_id, cost_info=cost_info) + + return await _reload_text_chat_session(run_id) + + +def validate_text_chat_turn_cursor( + session_data: dict[str, Any], + cursor_turn_id: str | None, +) -> None: + if cursor_turn_id is None: + return + if not any(turn.get("id") == cursor_turn_id for turn in session_data["turns"]): + raise TextChatTurnNotFoundError("Turn not found in text chat session") + + +def truncate_text_chat_future_turns( + session_data: dict[str, Any], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + cursor_turn_id = session_data.get("cursor_turn_id") + turns = list(session_data.get("turns") or []) + discarded_future = list(session_data.get("discarded_future") or []) + + if cursor_turn_id is None: + return turns, discarded_future + + for index, turn in enumerate(turns): + if turn.get("id") == cursor_turn_id: + active_turns = turns[: index + 1] + future_turns = turns[index + 1 :] + if future_turns: + discarded_future.append( + { + "rewound_from_turn_id": cursor_turn_id, + "discarded_at": datetime.now(UTC).isoformat(), + "turns": future_turns, + } + ) + return active_turns, discarded_future + + raise TextChatTurnNotFoundError("Turn not found in text chat session") + + +def latest_completed_text_chat_turn_id(turns: list[dict[str, Any]]) -> str | None: + for turn in reversed(turns): + if turn.get("status") == "completed": + return turn.get("id") + return None + + +def build_pending_text_chat_turn(*, user_text: str | None) -> dict[str, Any]: + now = datetime.now(UTC).isoformat() + return { + "id": f"turn_{uuid4().hex[:12]}", + "status": "pending", + "created_at": now, + "user_message": ( + { + "text": user_text, + "created_at": now, + } + if user_text is not None + else None + ), + "assistant_message": None, + "events": [], + "usage": {}, + } + + +async def _mark_pending_turn_failed( + *, + run_id: int, + text_session: WorkflowRunTextSessionModel, + error_message: str, +) -> None: + failed_session_data = normalize_text_chat_session_data(text_session.session_data) + failed_turns = list(failed_session_data.get("turns") or []) + if not failed_turns or failed_turns[-1].get("status") != "pending": + return + + failed_turns[-1]["status"] = "failed" + failed_turns[-1]["events"] = [ + *(failed_turns[-1].get("events") or []), + { + "type": "execution_error", + "created_at": datetime.now(UTC).isoformat(), + "payload": {"message": error_message}, + }, + ] + failed_session_data["turns"] = failed_turns + failed_session_data["status"] = "error" + try: + await db_client.update_workflow_run_text_session( + run_id, + session_data=failed_session_data, + expected_revision=text_session.revision, + ) + except WorkflowRunTextSessionRevisionConflictError: + return + + +async def _reload_text_chat_session(run_id: int) -> WorkflowRunTextSessionModel: + organization_id = await db_client.get_organization_id_by_workflow_run_id(run_id) + if organization_id is None: + raise TextChatSessionExecutionError( + "Workflow run organization not found after update" + ) + updated_text_session = await db_client.get_workflow_run_text_session( + run_id, + organization_id=organization_id, + ) + if updated_text_session is None: + raise TextChatSessionExecutionError("Text chat session not found after update") + return updated_text_session + + +__all__ = [ + "TEXT_CHAT_SESSION_VERSION", + "TextChatTurnNotFoundError", + "append_text_chat_user_message", + "build_pending_text_chat_turn", + "TextChatPendingTurnLostError", + "TextChatSessionExecutionError", + "TextChatSessionRevisionConflictError", + "default_text_chat_checkpoint", + "default_text_chat_session_data", + "execute_pending_text_chat_turn", + "initialize_text_chat_session", + "latest_completed_text_chat_turn_id", + "normalize_text_chat_checkpoint", + "normalize_text_chat_session_data", + "rewind_text_chat_session_state", + "truncate_text_chat_future_turns", + "validate_text_chat_turn_cursor", +] diff --git a/api/services/workflow/tools/custom_tool.py b/api/services/workflow/tools/custom_tool.py index da8775b..626610f 100644 --- a/api/services/workflow/tools/custom_tool.py +++ b/api/services/workflow/tools/custom_tool.py @@ -1,5 +1,6 @@ """Custom tool execution for user-defined HTTP API tools.""" +import json import re from typing import Any, Dict, Optional @@ -8,6 +9,7 @@ from loguru import logger from api.db import db_client from api.utils.credential_auth import build_auth_header +from api.utils.template_renderer import render_template # Map tool parameter types to JSON schema types TYPE_MAP = { @@ -84,10 +86,94 @@ def tool_to_function_schema(tool: Any) -> Dict[str, Any]: } +def _coerce_parameter_value(value: Any, param_type: str) -> Any: + """Coerce a rendered preset parameter into the configured JSON type.""" + + if value is None: + return None + + if param_type == "string": + if isinstance(value, str): + return value + if isinstance(value, (dict, list)): + return json.dumps(value) + return str(value) + + if param_type == "number": + if isinstance(value, (int, float)) and not isinstance(value, bool): + return value + + rendered = str(value).strip() + if rendered == "": + return None + + if re.fullmatch(r"[-+]?\d+", rendered): + return int(rendered) + + return float(rendered) + + if param_type == "boolean": + if isinstance(value, bool): + return value + + if isinstance(value, (int, float)): + return bool(value) + + rendered = str(value).strip().lower() + if rendered in {"true", "1", "yes", "y", "on"}: + return True + if rendered in {"false", "0", "no", "n", "off"}: + return False + + raise ValueError(f"Cannot convert '{value}' to boolean") + + return value + + +def _resolve_preset_parameters( + config: Dict[str, Any], + call_context_vars: Optional[Dict[str, Any]], + gathered_context_vars: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + """Resolve fixed/template-backed parameters before executing the HTTP request.""" + + preset_parameters = config.get("preset_parameters", []) or [] + if not preset_parameters: + return {} + + initial_context = dict(call_context_vars or {}) + render_context: Dict[str, Any] = { + **initial_context, + "initial_context": initial_context, + "gathered_context": dict(gathered_context_vars or {}), + } + + resolved: Dict[str, Any] = {} + for param in preset_parameters: + param_name = (param.get("name") or "").strip() + if not param_name: + continue + + rendered = render_template(param.get("value_template", ""), render_context) + if rendered in (None, ""): + if param.get("required", True): + raise ValueError( + f"Preset parameter '{param_name}' resolved to an empty value" + ) + continue + + resolved[param_name] = _coerce_parameter_value( + rendered, param.get("type", "string") + ) + + return resolved + + async def execute_http_tool( tool: Any, arguments: Dict[str, Any], call_context_vars: Optional[Dict[str, Any]] = None, + gathered_context_vars: Optional[Dict[str, Any]] = None, organization_id: Optional[int] = None, ) -> Dict[str, Any]: """Execute an HTTP API tool. @@ -95,7 +181,8 @@ async def execute_http_tool( Args: tool: ToolModel instance arguments: Arguments passed by the LLM (parameter name -> value) - call_context_vars: Additional context variables from the call (unused for now) + call_context_vars: Initial context variables available at runtime + gathered_context_vars: Variables extracted during the conversation organization_id: Organization ID for credential lookup Returns: @@ -133,17 +220,31 @@ async def execute_http_tool( timeout_ms = config.get("timeout_ms", 5000) timeout_seconds = timeout_ms / 1000 + try: + preset_arguments = _resolve_preset_parameters( + config, call_context_vars, gathered_context_vars + ) + except ValueError as e: + logger.error(f"Custom tool '{tool.name}' preset parameter error: {e}") + return {"status": "error", "error": str(e)} + + resolved_arguments = {**(arguments or {}), **preset_arguments} + # Build request: JSON body for POST/PUT/PATCH, query params for GET/DELETE body = None params = None if method in ("POST", "PUT", "PATCH"): - body = arguments - elif method in ("GET", "DELETE") and arguments: - params = arguments + body = resolved_arguments + elif method in ("GET", "DELETE") and resolved_arguments: + params = resolved_arguments logger.info( f"Executing custom tool '{tool.name}' ({tool.tool_uuid}): {method} {url}" ) + if preset_arguments: + logger.debug( + f"Resolved preset parameters for '{tool.name}': {list(preset_arguments.keys())}" + ) logger.debug(f"Request body: {body}, params: {params}") try: diff --git a/api/services/workflow/tools/mcp_tool.py b/api/services/workflow/tools/mcp_tool.py new file mode 100644 index 0000000..26dac2a --- /dev/null +++ b/api/services/workflow/tools/mcp_tool.py @@ -0,0 +1,116 @@ +"""Pure helpers for MCP-category tools: definition validation and +LLM-function-name namespacing. No I/O, no MCP protocol here.""" + +from __future__ import annotations + +import re +from typing import Any, Dict, Literal, Optional + +from pydantic import BaseModel, Field, ValidationError, field_validator + +DEFAULT_TIMEOUT_SECS = 30 +DEFAULT_SSE_READ_TIMEOUT_SECS = 300 + + +class McpDefinitionError(ValueError): + """Raised when an MCP tool definition is structurally invalid.""" + + +class McpToolConfig(BaseModel): + """Configuration for an MCP tool definition.""" + + transport: Literal["streamable_http"] = Field( + default="streamable_http", description="MCP transport protocol" + ) + url: str = Field(description="MCP server URL (must be http:// or https://)") + credential_uuid: Optional[str] = Field( + default=None, description="Reference to ExternalCredentialModel for auth" + ) + tools_filter: list[str] = Field( + default_factory=list, + description="Allowlist of MCP tool names to expose (empty = all tools)", + ) + timeout_secs: int = Field( + default=DEFAULT_TIMEOUT_SECS, description="Connection timeout in seconds" + ) + sse_read_timeout_secs: int = Field( + default=DEFAULT_SSE_READ_TIMEOUT_SECS, + description="SSE read timeout in seconds", + ) + discovered_tools: list[dict[str, Any]] = Field( + default_factory=list, + description=( + "Server-managed cache of the MCP server's tool catalog " + "[{name, description}]. Populated best-effort by the backend." + ), + ) + + @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.url must be an http(s) URL") + return v + + @field_validator("tools_filter") + @classmethod + def validate_tools_filter(cls, v: list[str]) -> list[str]: + if not all(isinstance(tool_name, str) for tool_name in v): + raise ValueError("config.tools_filter must be a list of strings") + return v + + +class McpToolDefinition(BaseModel): + """Persisted MCP tool definition.""" + + schema_version: int = Field(default=1, description="Schema version") + type: Literal["mcp"] = Field(description="Tool type") + config: McpToolConfig = Field(description="MCP server configuration") + + +def _format_validation_error(error: ValidationError) -> str: + parts: list[str] = [] + for item in error.errors(): + location = ".".join(str(part) for part in item["loc"]) + parts.append(f"{location}: {item['msg']}") + return "; ".join(parts) + + +def validate_mcp_definition(definition: Dict[str, Any]) -> Dict[str, Any]: + """Validate a ``type: "mcp"`` ToolModel definition and return a + normalized config dict with defaults applied. + + Raises: + McpDefinitionError: if the definition is missing required fields + or uses an unsupported transport. + """ + if not isinstance(definition, dict) or definition.get("type") != "mcp": + raise McpDefinitionError("definition.type must be 'mcp'") + + config = definition.get("config") + if not isinstance(config, dict): + raise McpDefinitionError("definition.config is required and must be an object") + + try: + parsed = McpToolDefinition.model_validate(definition) + except ValidationError as e: + raise McpDefinitionError(_format_validation_error(e)) from e + + return parsed.config.model_dump(exclude={"discovered_tools"}) + + +def _slugify(value: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "_", value.strip().lower()).strip("_") + return slug + + +def namespace_function_name( + tool_name: str, mcp_tool_name: str, *, fallback: str = "server" +) -> str: + """Build a collision-safe LLM function name: ``mcp____``. + + ``slug`` is derived from the Dograh ToolModel name; if it slugifies to + empty, ``fallback`` (e.g. first 8 chars of tool_uuid) is used instead. + """ + slug = _slugify(tool_name) or _slugify(fallback) or "server" + return f"mcp__{slug}__{mcp_tool_name}" diff --git a/api/services/workflow/trigger_paths.py b/api/services/workflow/trigger_paths.py new file mode 100644 index 0000000..ed34345 --- /dev/null +++ b/api/services/workflow/trigger_paths.py @@ -0,0 +1,142 @@ +import copy +import re +import uuid +from dataclasses import dataclass +from typing import Optional + +TRIGGER_PATH_MAX_LENGTH = 36 +TRIGGER_PATH_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$") + + +@dataclass(frozen=True) +class TriggerPathIssue: + node_id: str | None + trigger_path: str + message: str + + +def extract_trigger_paths(workflow_definition: Optional[dict]) -> list[str]: + """Extract trigger paths from a workflow definition.""" + if not workflow_definition: + return [] + + trigger_paths: list[str] = [] + for node in workflow_definition.get("nodes") or []: + if node.get("type") != "trigger": + continue + trigger_path = (node.get("data") or {}).get("trigger_path") + if isinstance(trigger_path, str) and trigger_path: + trigger_paths.append(trigger_path) + return trigger_paths + + +def trigger_path_to_node_id(workflow_definition: Optional[dict]) -> dict[str, str]: + """Map each trigger node's trigger_path to its node id.""" + if not workflow_definition: + return {} + + out: dict[str, str] = {} + for node in workflow_definition.get("nodes") or []: + if node.get("type") != "trigger": + continue + trigger_path = (node.get("data") or {}).get("trigger_path") + if isinstance(trigger_path, str) and trigger_path: + out[trigger_path] = node.get("id") + return out + + +def regenerate_trigger_uuids(workflow_definition: Optional[dict]) -> Optional[dict]: + """Regenerate UUIDs for all trigger nodes in a workflow definition.""" + if not workflow_definition: + return workflow_definition + + updated_definition = copy.deepcopy(workflow_definition) + for node in updated_definition.get("nodes") or []: + if node.get("type") != "trigger": + continue + data = node.setdefault("data", {}) + data["trigger_path"] = str(uuid.uuid4()) + return updated_definition + + +def ensure_trigger_paths(workflow_definition: Optional[dict]) -> Optional[dict]: + """Mint UUIDs for trigger nodes that do not already have a path.""" + if not workflow_definition: + return workflow_definition + + out = copy.deepcopy(workflow_definition) + for node in out.get("nodes") or []: + if node.get("type") != "trigger": + continue + data = node.setdefault("data", {}) + if not data.get("trigger_path"): + data["trigger_path"] = str(uuid.uuid4()) + return out + + +def validate_trigger_paths( + workflow_definition: Optional[dict], +) -> list[TriggerPathIssue]: + """Validate custom trigger paths before they reach persistence/runtime.""" + if not workflow_definition: + return [] + + issues: list[TriggerPathIssue] = [] + seen_paths: dict[str, str | None] = {} + + for node in workflow_definition.get("nodes") or []: + if node.get("type") != "trigger": + continue + + node_id = node.get("id") + trigger_path = (node.get("data") or {}).get("trigger_path") + if not trigger_path: + continue + + if not isinstance(trigger_path, str): + issues.append( + TriggerPathIssue( + node_id=node_id, + trigger_path=repr(trigger_path), + message="Trigger path must be a string.", + ) + ) + continue + + if len(trigger_path) > TRIGGER_PATH_MAX_LENGTH: + issues.append( + TriggerPathIssue( + node_id=node_id, + trigger_path=trigger_path, + message=( + f"Trigger path must be {TRIGGER_PATH_MAX_LENGTH} " + "characters or fewer." + ), + ) + ) + + if not TRIGGER_PATH_PATTERN.fullmatch(trigger_path): + issues.append( + TriggerPathIssue( + node_id=node_id, + trigger_path=trigger_path, + message=( + "Trigger path must be a single URL path segment using " + "only letters, numbers, hyphens, and underscores." + ), + ) + ) + + first_node_id = seen_paths.get(trigger_path) + if first_node_id is None: + seen_paths[trigger_path] = node_id + else: + issues.append( + TriggerPathIssue( + node_id=node_id, + trigger_path=trigger_path, + message="Trigger path is duplicated in this workflow.", + ) + ) + + return issues diff --git a/api/services/workflow/workflow_graph.py b/api/services/workflow/workflow_graph.py index fc2be79..a626815 100644 --- a/api/services/workflow/workflow_graph.py +++ b/api/services/workflow/workflow_graph.py @@ -4,7 +4,8 @@ from typing import Dict, List, Set from api.services.workflow.dto import EdgeDataDTO, NodeType, ReactFlowDTO from api.services.workflow.errors import ItemKind, WorkflowError -from api.services.workflow.node_specs import REGISTRY +from api.services.workflow.node_data import BaseNodeData +from api.services.workflow.node_specs import get_spec # Regex for matching {{ variable }} template placeholders. # Captures: group(1) = variable path, group(2) = filter name, group(3) = filter value. @@ -62,7 +63,7 @@ class Edge: class Node: - def __init__(self, id: str, node_type: NodeType, data): + def __init__(self, id: str, node_type: str, data: BaseNodeData): self.id, self.node_type, self.data = id, node_type, data self.out: Dict[str, "Node"] = {} # forward nodes self.out_edges: List[Edge] = [] # forward edges with properties @@ -75,7 +76,6 @@ class Node: # Type-specific fields — read with getattr so this works for every # node variant in the discriminated union. self.prompt = getattr(data, "prompt", None) - self.is_static = getattr(data, "is_static", False) self.allow_interrupt = getattr(data, "allow_interrupt", False) self.extraction_enabled = getattr(data, "extraction_enabled", False) self.extraction_prompt = getattr(data, "extraction_prompt", None) @@ -84,11 +84,11 @@ class Node: self.greeting = getattr(data, "greeting", None) self.greeting_type = getattr(data, "greeting_type", None) self.greeting_recording_id = getattr(data, "greeting_recording_id", None) - self.detect_voicemail = getattr(data, "detect_voicemail", False) self.delayed_start = getattr(data, "delayed_start", False) self.delayed_start_duration = getattr(data, "delayed_start_duration", None) self.tool_uuids = getattr(data, "tool_uuids", None) self.document_uuids = getattr(data, "document_uuids", None) + self.mcp_tool_filters = getattr(data, "mcp_tool_filters", None) self.pre_call_fetch_enabled = getattr(data, "pre_call_fetch_enabled", False) self.pre_call_fetch_url = getattr(data, "pre_call_fetch_url", None) self.pre_call_fetch_credential_uuid = getattr( @@ -105,11 +105,11 @@ class WorkflowGraph: """ def __init__(self, dto: ReactFlowDTO): - # build adjacency list. n.type comes off the discriminated-union - # variant as a literal string; coerce to NodeType for downstream - # comparisons. + # Build adjacency list from validated DTO nodes. Core node comparisons + # still use NodeType string enums; integration nodes remain plain + # strings and resolve constraints through node specs. self.nodes: Dict[str, Node] = { - n.id: Node(n.id, NodeType(n.type), n.data) for n in dto.nodes + n.id: Node(n.id, n.type, n.data) for n in dto.nodes } # Store all edges @@ -139,7 +139,7 @@ class WorkflowGraph: # Get a reference to the global node try: self.global_node_id = [ - n.id for n in dto.nodes if n.type == NodeType.globalNode + n.id for n in dto.nodes if n.type == NodeType.globalNode.value ][0] except IndexError: self.global_node_id = None @@ -249,7 +249,7 @@ class WorkflowGraph: def _assert_global_node(self): errors: list[WorkflowError] = [] global_node = [ - n for n in self.nodes.values() if n.node_type == NodeType.globalNode + n for n in self.nodes.values() if n.node_type == NodeType.globalNode.value ] if not len(global_node) <= 1: errors.append( @@ -281,7 +281,7 @@ class WorkflowGraph: in_deg[m.id] += 1 for n in self.nodes.values(): - spec = REGISTRY.get(n.node_type.value) + spec = get_spec(n.node_type) if spec is None or spec.graph_constraints is None: continue gc = spec.graph_constraints diff --git a/api/tasks/campaign_tasks.py b/api/tasks/campaign_tasks.py index 6dc9ee9..286b9de 100644 --- a/api/tasks/campaign_tasks.py +++ b/api/tasks/campaign_tasks.py @@ -20,7 +20,7 @@ async def sync_campaign_source(ctx: Dict, campaign_id: int) -> None: Phase 1: Syncs data from configured source to queued_runs table - Campaign state should already be 'syncing' - Determines source type from campaign configuration - - Fetches data via appropriate sync service (Google Sheets, HubSpot, etc.) + - Fetches data via the appropriate sync service - Creates queued_run entries with unique source_uuid - Updates campaign total_rows - Transitions campaign state to 'running' on success diff --git a/api/tasks/run_integrations.py b/api/tasks/run_integrations.py index f60829c..be11a3c 100644 --- a/api/tasks/run_integrations.py +++ b/api/tasks/run_integrations.py @@ -1,7 +1,6 @@ """Execute integrations (QA analysis, webhooks) after workflow run completion.""" import random -from datetime import UTC, datetime from typing import Any, Dict, Optional import httpx @@ -14,6 +13,11 @@ from api.constants import BACKEND_API_ENDPOINT from api.db import db_client from api.db.models import WorkflowRunModel from api.enums import OrganizationConfigurationKey +from api.services.integrations import ( + IntegrationCompletionContext, + has_completion_handlers, + run_completion_handlers, +) from api.services.pipecat.tracing_config import register_org_langfuse_credentials from api.services.workflow.dto import ( QANodeData, @@ -214,16 +218,20 @@ async def run_integrations_post_workflow_run(_ctx, workflow_run_id: int): nodes = workflow_definition.get("nodes", []) qa_nodes = [n for n in nodes if n.get("type") == "qa"] webhook_nodes = [n for n in nodes if n.get("type") == "webhook"] + has_registered_integrations = has_completion_handlers(workflow_definition) - # Step 4: Generate public access token if webhooks exist or campaign_id is set + # Step 4: Generate a public access token for any run that needs post-call work. has_campaign = workflow_run.campaign_id is not None - if not webhook_nodes and not qa_nodes and not has_campaign: + if ( + not webhook_nodes + and not qa_nodes + and not has_registered_integrations + and not has_campaign + ): logger.debug("No integration nodes and no campaign, skipping") return - public_token = None - if webhook_nodes or has_campaign: - public_token = await db_client.ensure_public_access_token(workflow_run_id) + public_token = await db_client.ensure_public_access_token(workflow_run_id) # Step 5: Run QA analysis before webhooks if qa_nodes: @@ -263,17 +271,37 @@ async def run_integrations_post_workflow_run(_ctx, workflow_run_id: int): workflow_run_id ) - # Step 6: Execute webhooks + # Step 6: Run registered third-party integrations after uploads are complete + integration_results = await run_completion_handlers( + context=IntegrationCompletionContext( + workflow_run_id=workflow_run_id, + workflow_run=workflow_run, + workflow_definition=workflow_definition, + definition_id=definition_id, + organization_id=organization_id, + public_token=public_token, + ) + ) + + if integration_results: + await db_client.update_workflow_run( + workflow_run_id, annotations=integration_results + ) + workflow_run, _ = await db_client.get_workflow_run_with_context( + workflow_run_id + ) + + # Step 7: Execute webhooks if not webhook_nodes: logger.debug("No webhook nodes in workflow") return logger.info(f"Found {len(webhook_nodes)} webhook nodes to execute") - # Step 7: Build render context (includes annotations from QA) + # Step 8: Build render context (includes annotations from QA and integrations) render_context = _build_render_context(workflow_run, public_token) - # Step 8: Execute each webhook node + # Step 9: Execute each webhook node for node in webhook_nodes: node_id = node.get("id", "unknown") try: diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 2c03283..a8beed7 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -15,16 +15,14 @@ import pytest from api.services.workflow.dto import ( AgentNodeData, - AgentRFNode, EdgeDataDTO, EndCallNodeData, - EndCallRFNode, ExtractionVariableDTO, Position, ReactFlowDTO, RFEdgeDTO, + RFNodeDTO, StartCallNodeData, - StartCallRFNode, VariableType, ) from api.services.workflow.workflow_graph import WorkflowGraph @@ -270,8 +268,9 @@ def simple_workflow() -> WorkflowGraph: """ dto = ReactFlowDTO( nodes=[ - StartCallRFNode( + RFNodeDTO( id="start", + type="startCall", position=Position(x=0, y=0), data=StartCallNodeData( name="Start Call", @@ -290,8 +289,9 @@ def simple_workflow() -> WorkflowGraph: ], ), ), - EndCallRFNode( + RFNodeDTO( id="end", + type="endCall", position=Position(x=0, y=200), data=EndCallNodeData( name="End Call", @@ -333,8 +333,9 @@ def three_node_workflow() -> WorkflowGraph: """ dto = ReactFlowDTO( nodes=[ - StartCallRFNode( + RFNodeDTO( id="start", + type="startCall", position=Position(x=0, y=0), data=StartCallNodeData( name="Start Call", @@ -353,8 +354,9 @@ def three_node_workflow() -> WorkflowGraph: ], ), ), - AgentRFNode( + RFNodeDTO( id="agent", + type="agentNode", position=Position(x=0, y=200), data=AgentNodeData( name="Collect Info", @@ -372,8 +374,9 @@ def three_node_workflow() -> WorkflowGraph: ], ), ), - EndCallRFNode( + RFNodeDTO( id="end", + type="endCall", position=Position(x=0, y=400), data=EndCallNodeData( name="End Call", @@ -424,8 +427,9 @@ def three_node_workflow_extraction_start_only() -> WorkflowGraph: """ dto = ReactFlowDTO( nodes=[ - StartCallRFNode( + RFNodeDTO( id="start", + type="startCall", position=Position(x=0, y=0), data=StartCallNodeData( name="Start Call", @@ -444,8 +448,9 @@ def three_node_workflow_extraction_start_only() -> WorkflowGraph: ], ), ), - AgentRFNode( + RFNodeDTO( id="agent", + type="agentNode", position=Position(x=0, y=200), data=AgentNodeData( name="Collect Info", @@ -455,8 +460,9 @@ def three_node_workflow_extraction_start_only() -> WorkflowGraph: extraction_enabled=False, # Explicitly disabled for testing ), ), - EndCallRFNode( + RFNodeDTO( id="end", + type="endCall", position=Position(x=0, y=400), data=EndCallNodeData( name="End Call", @@ -503,8 +509,9 @@ def three_node_workflow_no_variable_extraction() -> WorkflowGraph: """ dto = ReactFlowDTO( nodes=[ - StartCallRFNode( + RFNodeDTO( id="start", + type="startCall", position=Position(x=0, y=0), data=StartCallNodeData( name="Start Call", @@ -515,8 +522,9 @@ def three_node_workflow_no_variable_extraction() -> WorkflowGraph: extraction_enabled=False, ), ), - AgentRFNode( + RFNodeDTO( id="agent", + type="agentNode", position=Position(x=0, y=200), data=AgentNodeData( name="Collect Info", @@ -526,8 +534,9 @@ def three_node_workflow_no_variable_extraction() -> WorkflowGraph: extraction_enabled=False, # Explicitly disabled for testing ), ), - EndCallRFNode( + RFNodeDTO( id="end", + type="endCall", position=Position(x=0, y=400), data=EndCallNodeData( name="End Call", diff --git a/api/tests/dto_fixtures/sample_branching_workflow.json b/api/tests/dto_fixtures/sample_branching_workflow.json index b470f71..888ae2a 100644 --- a/api/tests/dto_fixtures/sample_branching_workflow.json +++ b/api/tests/dto_fixtures/sample_branching_workflow.json @@ -63,7 +63,6 @@ }, "data": { "prompt": "Hello, I am Abhishek from Dograh. ", - "is_static": true, "name": "Start Call", "is_start": true }, @@ -83,7 +82,6 @@ }, "data": { "prompt": "Thank you for calling Dograh. Have a great day!", - "is_static": true, "name": "End Call" }, "measured": { @@ -161,4 +159,4 @@ "y": 0, "zoom": 1 } -} \ No newline at end of file +} diff --git a/api/tests/support/__init__.py b/api/tests/support/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/support/mcp_mock_server.py b/api/tests/support/mcp_mock_server.py new file mode 100644 index 0000000..09a3c8b --- /dev/null +++ b/api/tests/support/mcp_mock_server.py @@ -0,0 +1,103 @@ +"""A real FastMCP server exposing 2 tools over streamable-HTTP, run in a +background uvicorn thread on an ephemeral port. Used to exercise the real +MCP protocol path in tests. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import socket +import threading +from typing import AsyncIterator + +import httpx +import uvicorn +from fastmcp import FastMCP +from starlette.responses import JSONResponse + + +def _build_app(required_headers: dict[str, str] | None = None): + mcp = FastMCP("mock-mcp") + + @mcp.tool() + def echo(text: str) -> str: + """Echo the provided text back.""" + return f"echo:{text}" + + @mcp.tool() + def add(a: int, b: int) -> int: + """Add two integers.""" + return a + b + + # FastMCP 3.x: ASGI app for streamable-HTTP transport at "/mcp". + app = mcp.http_app() + if not required_headers: + return app + + normalized = {k.lower(): v for k, v in required_headers.items()} + + async def guarded_app(scope, receive, send): + if scope["type"] == "http": + headers = { + key.decode("latin-1").lower(): value.decode("latin-1") + for key, value in scope.get("headers", []) + } + for header_name, expected_value in normalized.items(): + if headers.get(header_name) != expected_value: + response = JSONResponse( + {"detail": f"Missing or invalid header: {header_name}"}, + status_code=401, + ) + await response(scope, receive, send) + return + await app(scope, receive, send) + + return guarded_app + + +def _free_port() -> int: + with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@contextlib.asynccontextmanager +async def running_mcp_server( + *, required_headers: dict[str, str] | None = None +) -> AsyncIterator[str]: + """Yield the base streamable-HTTP URL of a live mock MCP server.""" + port = _free_port() + config = uvicorn.Config( + _build_app(required_headers), host="127.0.0.1", port=port, log_level="warning" + ) + server = uvicorn.Server(config) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + + base_url = f"http://127.0.0.1:{port}/mcp" + server_ready = False + for _ in range(50): + try: + async with httpx.AsyncClient() as client: + await client.get(base_url, timeout=0.5) + server_ready = True + break + except Exception: + await asyncio.sleep(0.1) + if not server_ready: + server.should_exit = True + thread.join(timeout=5) + raise RuntimeError(f"Mock MCP server at {base_url} failed to start within 5s") + try: + yield base_url + finally: + server.should_exit = True + thread.join(timeout=5) + if thread.is_alive(): + import warnings + + warnings.warn( + "Mock MCP server thread did not terminate within 5s", + ResourceWarning, + ) diff --git a/api/tests/telephony/plivo/test_routes.py b/api/tests/telephony/plivo/test_routes.py new file mode 100644 index 0000000..e3a2b06 --- /dev/null +++ b/api/tests/telephony/plivo/test_routes.py @@ -0,0 +1,185 @@ +import base64 +import hashlib +import hmac +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch +from urllib.parse import urlencode + +import pytest +from starlette.requests import Request + +from api.services.telephony.providers.plivo.provider import PlivoProvider +from api.services.telephony.providers.plivo.routes import ( + handle_plivo_hangup_callback, + handle_plivo_xml_webhook, +) + + +def _provider() -> PlivoProvider: + return PlivoProvider( + { + "auth_id": "MA123", + "auth_token": "plivo-auth-token", + "from_numbers": ["+15551230002"], + } + ) + + +def _request( + *, + path: str, + query: dict[str, str | int], + form_data: dict[str, str], + headers: dict[str, str] | None = None, +) -> Request: + body = urlencode(form_data).encode("utf-8") + query_string = urlencode(query).encode("utf-8") + request_headers = [ + (b"content-type", b"application/x-www-form-urlencoded"), + *[ + (name.lower().encode("ascii"), value.encode("ascii")) + for name, value in (headers or {}).items() + ], + ] + + async def receive(): + return { + "type": "http.request", + "body": body, + "more_body": False, + } + + return Request( + { + "type": "http", + "method": "POST", + "scheme": "https", + "server": ("example.test", 443), + "path": path, + "query_string": query_string, + "headers": request_headers, + }, + receive, + ) + + +def _signature( + provider: PlivoProvider, + *, + path: str, + query: dict[str, str | int], + form_data: dict[str, str], + nonce: str, +) -> str: + url = f"https://example.test{path}" + if query: + url = f"{url}?{urlencode(query)}" + payload = f"{provider._construct_post_url(url, form_data)}.{nonce}" + return base64.b64encode( + hmac.new( + provider.auth_token.encode("utf-8"), + payload.encode("utf-8"), + hashlib.sha256, + ).digest() + ).decode("utf-8") + + +@pytest.mark.asyncio +async def test_plivo_xml_route_accepts_valid_signature_with_extra_query_param(): + provider = _provider() + query = { + "workflow_id": 7, + "user_id": 8, + "workflow_run_id": 123, + "campaign_id": 42, + "organization_id": 11, + } + form_data = { + "CallUUID": "call-123", + "Direction": "outbound", + "From": "15551230001", + "To": "15551230002", + } + nonce = "nonce-123" + request = _request( + path="/api/v1/telephony/plivo-xml", + query=query, + form_data=form_data, + headers={ + "x-plivo-signature-v3": _signature( + provider, + path="/api/v1/telephony/plivo-xml", + query=query, + form_data=form_data, + nonce=nonce, + ), + "x-plivo-signature-v3-nonce": nonce, + }, + ) + + with ( + patch("api.services.telephony.providers.plivo.routes.db_client") as db_client, + patch( + "api.services.telephony.providers.plivo.routes.get_telephony_provider_for_run", + new_callable=AsyncMock, + return_value=provider, + ), + patch.object( + provider, + "get_webhook_response", + new_callable=AsyncMock, + return_value="", + ) as get_webhook_response, + ): + db_client.get_workflow_run_by_id = AsyncMock( + return_value=SimpleNamespace(gathered_context={}, workflow_id=7) + ) + db_client.update_workflow_run = AsyncMock() + + response = await handle_plivo_xml_webhook( + workflow_id=7, + user_id=8, + workflow_run_id=123, + organization_id=11, + request=request, + ) + + assert response.body == b"" + get_webhook_response.assert_awaited_once_with(7, 8, 123) + db_client.update_workflow_run.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_plivo_status_callback_rejects_missing_signature(): + provider = _provider() + request = _request( + path="/api/v1/telephony/plivo/hangup-callback/123", + query={}, + form_data={"CallUUID": "call-123", "Event": "hangup"}, + ) + + with ( + patch("api.services.telephony.providers.plivo.routes.db_client") as db_client, + patch( + "api.services.telephony.providers.plivo.routes.get_telephony_provider_for_run", + new_callable=AsyncMock, + return_value=provider, + ), + patch( + "api.services.telephony.providers.plivo.routes._process_status_update", + new_callable=AsyncMock, + ) as process_status, + ): + db_client.get_workflow_run_by_id = AsyncMock( + return_value=SimpleNamespace(workflow_id=7) + ) + db_client.get_workflow_by_id = AsyncMock( + return_value=SimpleNamespace(organization_id=11) + ) + + result = await handle_plivo_hangup_callback( + workflow_run_id=123, request=request + ) + + assert result == {"status": "error", "reason": "invalid_signature"} + process_status.assert_not_awaited() diff --git a/api/tests/telephony/telnyx/test_provider.py b/api/tests/telephony/telnyx/test_provider.py index 3f0f6f3..6462119 100644 --- a/api/tests/telephony/telnyx/test_provider.py +++ b/api/tests/telephony/telnyx/test_provider.py @@ -153,45 +153,16 @@ async def test_verify_inbound_signature_rejects_missing_config_public_key(): _, headers = _signed_headers(body) provider = _provider() - # REMOVE-AFTER 2026-05-15: drop the patch wrapper once - # TELNYX_WEBHOOK_VERIFICATION_OPTIONAL is removed; the bare call below - # will then assert the only path. - with patch( - "api.services.telephony.providers.telnyx.provider.TELNYX_WEBHOOK_VERIFICATION_OPTIONAL", - False, - ): - result = await provider.verify_inbound_signature( - "https://example.test/api/v1/telephony/inbound/run", - json.loads(body), - headers, - body, - ) + result = await provider.verify_inbound_signature( + "https://example.test/api/v1/telephony/inbound/run", + json.loads(body), + headers, + body, + ) assert result is False -# REMOVE-AFTER 2026-05-15: delete this whole test along with the -# TELNYX_WEBHOOK_VERIFICATION_OPTIONAL flag. -@pytest.mark.asyncio -async def test_verify_inbound_signature_allows_missing_key_when_optional_flag_set(): - body = _body() - _, headers = _signed_headers(body) - provider = _provider() - - with patch( - "api.services.telephony.providers.telnyx.provider.TELNYX_WEBHOOK_VERIFICATION_OPTIONAL", - True, - ): - result = await provider.verify_inbound_signature( - "https://example.test/api/v1/telephony/inbound/run", - json.loads(body), - headers, - body, - ) - - assert result is True - - @pytest.mark.asyncio async def test_verify_inbound_signature_reads_headers_case_insensitively(): body = _body() diff --git a/api/tests/telephony/twilio/test_routes.py b/api/tests/telephony/twilio/test_routes.py new file mode 100644 index 0000000..6748d94 --- /dev/null +++ b/api/tests/telephony/twilio/test_routes.py @@ -0,0 +1,253 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch +from urllib.parse import urlencode + +import pytest +from fastapi import HTTPException +from starlette.requests import Request +from twilio.request_validator import RequestValidator + +from api.services.telephony.providers.twilio.provider import TwilioProvider +from api.services.telephony.providers.twilio.routes import ( + handle_twilio_status_callback, + handle_twiml_webhook, +) + + +def _provider() -> TwilioProvider: + return TwilioProvider( + { + "account_sid": "AC123", + "auth_token": "twilio-auth-token", + "from_numbers": ["+15551230002"], + } + ) + + +def _request( + *, + path: str, + query: dict[str, str | int], + form_data: dict[str, str], + headers: dict[str, str] | None = None, +) -> Request: + body = urlencode(form_data).encode("utf-8") + query_string = urlencode(query).encode("utf-8") + request_headers = [ + (b"content-type", b"application/x-www-form-urlencoded"), + *[ + (name.lower().encode("ascii"), value.encode("ascii")) + for name, value in (headers or {}).items() + ], + ] + + async def receive(): + return { + "type": "http.request", + "body": body, + "more_body": False, + } + + return Request( + { + "type": "http", + "method": "POST", + "scheme": "https", + "server": ("example.test", 443), + "path": path, + "query_string": query_string, + "headers": request_headers, + }, + receive, + ) + + +def _signature( + provider: TwilioProvider, + *, + path: str, + query: dict[str, str | int], + form_data: dict[str, str], +) -> str: + url = f"https://example.test{path}" + if query: + url = f"{url}?{urlencode(query)}" + validator = RequestValidator(provider.auth_token) + return validator.compute_signature(url, form_data) + + +@pytest.mark.asyncio +async def test_twiml_route_accepts_valid_signature_with_extra_query_param(): + provider = _provider() + query = { + "workflow_id": 7, + "user_id": 8, + "workflow_run_id": 123, + "campaign_id": 42, + "organization_id": 11, + } + form_data = {"CallSid": "CA123", "CallStatus": "in-progress"} + request = _request( + path="/api/v1/telephony/twiml", + query=query, + form_data=form_data, + headers={ + "x-twilio-signature": _signature( + provider, + path="/api/v1/telephony/twiml", + query=query, + form_data=form_data, + ) + }, + ) + + with ( + patch("api.services.telephony.providers.twilio.routes.db_client") as db_client, + patch( + "api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run", + new_callable=AsyncMock, + return_value=provider, + ), + patch.object( + provider, + "get_webhook_response", + new_callable=AsyncMock, + return_value="", + ) as get_webhook_response, + ): + db_client.get_workflow_run_by_id = AsyncMock( + return_value=SimpleNamespace(id=123) + ) + + response = await handle_twiml_webhook( + workflow_id=7, + user_id=8, + workflow_run_id=123, + organization_id=11, + request=request, + ) + + assert response.body == b"" + get_webhook_response.assert_awaited_once_with(7, 8, 123) + + +@pytest.mark.asyncio +async def test_twiml_route_rejects_missing_signature(): + provider = _provider() + request = _request( + path="/api/v1/telephony/twiml", + query={ + "workflow_id": 7, + "user_id": 8, + "workflow_run_id": 123, + "organization_id": 11, + }, + form_data={"CallSid": "CA123"}, + ) + + with ( + patch("api.services.telephony.providers.twilio.routes.db_client") as db_client, + patch( + "api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run", + new_callable=AsyncMock, + return_value=provider, + ), + ): + db_client.get_workflow_run_by_id = AsyncMock( + return_value=SimpleNamespace(id=123) + ) + + with pytest.raises(HTTPException) as exc_info: + await handle_twiml_webhook( + workflow_id=7, + user_id=8, + workflow_run_id=123, + organization_id=11, + request=request, + ) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Invalid webhook signature" + + +@pytest.mark.asyncio +async def test_twilio_status_callback_rejects_legacy_header_name(): + provider = _provider() + form_data = {"CallSid": "CA123", "CallStatus": "completed"} + request = _request( + path="/api/v1/telephony/twilio/status-callback/123", + query={}, + form_data=form_data, + headers={"x-webhook-signature": "not-a-twilio-signature"}, + ) + + with ( + patch("api.services.telephony.providers.twilio.routes.db_client") as db_client, + patch( + "api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run", + new_callable=AsyncMock, + return_value=provider, + ), + patch( + "api.services.telephony.providers.twilio.routes._process_status_update", + new_callable=AsyncMock, + ) as process_status, + ): + db_client.get_workflow_run_by_id = AsyncMock( + return_value=SimpleNamespace(workflow_id=7) + ) + db_client.get_workflow_by_id = AsyncMock( + return_value=SimpleNamespace(organization_id=11) + ) + + with pytest.raises(HTTPException) as exc_info: + await handle_twilio_status_callback(workflow_run_id=123, request=request) + + assert exc_info.value.status_code == 401 + assert exc_info.value.detail == "Invalid webhook signature" + process_status.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_twilio_status_callback_accepts_valid_signature(): + provider = _provider() + form_data = {"CallSid": "CA123", "CallStatus": "completed"} + request = _request( + path="/api/v1/telephony/twilio/status-callback/123", + query={}, + form_data=form_data, + headers={ + "x-twilio-signature": _signature( + provider, + path="/api/v1/telephony/twilio/status-callback/123", + query={}, + form_data=form_data, + ) + }, + ) + + with ( + patch("api.services.telephony.providers.twilio.routes.db_client") as db_client, + patch( + "api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run", + new_callable=AsyncMock, + return_value=provider, + ), + patch( + "api.services.telephony.providers.twilio.routes._process_status_update", + new_callable=AsyncMock, + ) as process_status, + ): + db_client.get_workflow_run_by_id = AsyncMock( + return_value=SimpleNamespace(workflow_id=7) + ) + db_client.get_workflow_by_id = AsyncMock( + return_value=SimpleNamespace(organization_id=11) + ) + + result = await handle_twilio_status_callback( + workflow_run_id=123, request=request + ) + + assert result == {"status": "success"} + process_status.assert_awaited_once() diff --git a/api/tests/telephony/vobiz/test_routes.py b/api/tests/telephony/vobiz/test_routes.py new file mode 100644 index 0000000..f726eee --- /dev/null +++ b/api/tests/telephony/vobiz/test_routes.py @@ -0,0 +1,178 @@ +import hashlib +import hmac +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch +from urllib.parse import urlencode + +import pytest +from starlette.requests import Request + +from api.services.telephony.providers.vobiz.provider import VobizProvider +from api.services.telephony.providers.vobiz.routes import ( + handle_vobiz_hangup_callback, + handle_vobiz_ring_callback, +) + + +def _provider() -> VobizProvider: + return VobizProvider( + { + "auth_id": "MA123", + "auth_token": "vobiz-auth-token", + "from_numbers": ["+15551230002"], + } + ) + + +def _request( + *, + path: str, + form_data: dict[str, str], + headers: dict[str, str] | None = None, +) -> Request: + body = urlencode(form_data).encode("utf-8") + request_headers = [ + (b"content-type", b"application/x-www-form-urlencoded"), + *[ + (name.lower().encode("ascii"), value.encode("ascii")) + for name, value in (headers or {}).items() + ], + ] + + async def receive(): + return { + "type": "http.request", + "body": body, + "more_body": False, + } + + return Request( + { + "type": "http", + "method": "POST", + "scheme": "https", + "server": ("example.test", 443), + "path": path, + "query_string": b"", + "headers": request_headers, + }, + receive, + ) + + +def _signed_headers( + provider: VobizProvider, *, form_data: dict[str, str] +) -> dict[str, str]: + timestamp = str(int(datetime.now(UTC).timestamp())) + body = urlencode(form_data) + signature = hmac.new( + provider.auth_token.encode("utf-8"), + f"{timestamp}.{body}".encode("utf-8"), + hashlib.sha256, + ).hexdigest() + return { + "x-vobiz-signature": signature, + "x-vobiz-timestamp": timestamp, + } + + +@pytest.mark.asyncio +async def test_vobiz_hangup_callback_accepts_signed_form_body(): + provider = _provider() + form_data = { + "CallUUID": "call-123", + "CallStatus": "completed", + "From": "15551230001", + "To": "15551230002", + "Direction": "outbound", + "Duration": "12", + } + headers = _signed_headers(provider, form_data=form_data) + request = _request( + path="/api/v1/telephony/vobiz/hangup-callback/123", + form_data=form_data, + headers=headers, + ) + + with ( + patch("api.services.telephony.providers.vobiz.routes.db_client") as db_client, + patch( + "api.services.telephony.providers.vobiz.routes.get_telephony_provider_for_run", + new_callable=AsyncMock, + return_value=provider, + ), + patch( + "api.services.telephony.providers.vobiz.routes.get_backend_endpoints", + new_callable=AsyncMock, + return_value=("https://example.test", "wss://example.test"), + ), + patch( + "api.services.telephony.providers.vobiz.routes._process_status_update", + new_callable=AsyncMock, + ) as process_status, + ): + db_client.get_workflow_run_by_id = AsyncMock( + return_value=SimpleNamespace(workflow_id=7) + ) + db_client.get_workflow_by_id = AsyncMock( + return_value=SimpleNamespace(organization_id=11) + ) + + result = await handle_vobiz_hangup_callback( + workflow_run_id=123, + request=request, + x_vobiz_signature=headers["x-vobiz-signature"], + x_vobiz_timestamp=headers["x-vobiz-timestamp"], + ) + + assert result == {"status": "success"} + process_status.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_vobiz_ring_callback_accepts_signed_form_body(): + provider = _provider() + form_data = { + "CallUUID": "call-123", + "CallStatus": "ringing", + "From": "15551230001", + "To": "15551230002", + } + headers = _signed_headers(provider, form_data=form_data) + request = _request( + path="/api/v1/telephony/vobiz/ring-callback/123", + form_data=form_data, + headers=headers, + ) + + workflow_run = SimpleNamespace(workflow_id=7, logs={}) + + with ( + patch("api.services.telephony.providers.vobiz.routes.db_client") as db_client, + patch( + "api.services.telephony.providers.vobiz.routes.get_telephony_provider_for_run", + new_callable=AsyncMock, + return_value=provider, + ), + patch( + "api.services.telephony.providers.vobiz.routes.get_backend_endpoints", + new_callable=AsyncMock, + return_value=("https://example.test", "wss://example.test"), + ), + ): + db_client.get_workflow_run_by_id = AsyncMock(return_value=workflow_run) + db_client.get_workflow_by_id = AsyncMock( + return_value=SimpleNamespace(organization_id=11) + ) + db_client.update_workflow_run = AsyncMock() + + result = await handle_vobiz_ring_callback( + workflow_run_id=123, + request=request, + x_vobiz_signature=headers["x-vobiz-signature"], + x_vobiz_timestamp=headers["x-vobiz-timestamp"], + ) + + assert result == {"status": "success"} + db_client.update_workflow_run.assert_awaited_once() diff --git a/api/tests/test_custom_tools.py b/api/tests/test_custom_tools.py index 693afc7..703ae76 100644 --- a/api/tests/test_custom_tools.py +++ b/api/tests/test_custom_tools.py @@ -140,6 +140,45 @@ class TestToolToFunctionSchema: assert "duration_minutes" in required assert "is_priority" not in required + def test_preset_parameters_are_not_exposed_to_llm_schema(self): + """Test that preset parameters are injected at runtime, not shown to the LLM.""" + tool = MockToolModel( + tool_uuid="test-uuid-preset", + name="Lookup Customer", + description="Lookup a customer using contextual identifiers", + category="http_api", + definition={ + "schema_version": 1, + "type": "http_api", + "config": { + "method": "POST", + "url": "https://api.example.com/customers/lookup", + "parameters": [ + { + "name": "customer_name", + "type": "string", + "description": "Customer name spoken by the caller", + "required": True, + } + ], + "preset_parameters": [ + { + "name": "phone_number", + "type": "string", + "value_template": "{{initial_context.phone_number}}", + "required": True, + } + ], + }, + }, + ) + + schema = tool_to_function_schema(tool) + props = schema["function"]["parameters"]["properties"] + + assert "customer_name" in props + assert "phone_number" not in props + def test_tool_name_sanitization(self): """Test that tool names with special characters are sanitized.""" tool = MockToolModel( @@ -255,6 +294,108 @@ class TestExecuteHttpTool: assert result["status_code"] == 201 assert result["data"]["id"] == 123 + @pytest.mark.asyncio + async def test_post_request_injects_preset_parameters(self): + """Test that preset parameters are resolved from runtime context.""" + tool = MockToolModel( + tool_uuid="test-uuid-preset", + name="Create Lead", + description="Create a lead with caller context", + category="http_api", + definition={ + "schema_version": 1, + "type": "http_api", + "config": { + "method": "POST", + "url": "https://api.example.com/leads", + "timeout_ms": 5000, + "preset_parameters": [ + { + "name": "phone_number", + "type": "string", + "value_template": "{{initial_context.phone_number}}", + "required": True, + }, + { + "name": "customer_id", + "type": "number", + "value_template": "{{gathered_context.customer_id}}", + "required": True, + }, + { + "name": "is_vip", + "type": "boolean", + "value_template": "{{initial_context.is_vip}}", + "required": False, + }, + ], + }, + }, + ) + + arguments = {"name": "John"} + + with patch( + "api.services.workflow.tools.custom_tool.httpx.AsyncClient" + ) as mock_client_class: + mock_client = AsyncMock() + mock_response = Mock() + mock_response.status_code = 201 + mock_response.json.return_value = {"id": 123} + mock_client.request.return_value = mock_response + mock_client_class.return_value.__aenter__.return_value = mock_client + + result = await execute_http_tool( + tool, + arguments, + call_context_vars={ + "phone_number": "+14155550123", + "is_vip": "true", + }, + gathered_context_vars={"customer_id": "42"}, + ) + + call_kwargs = mock_client.request.call_args.kwargs + assert call_kwargs["json"] == { + "name": "John", + "phone_number": "+14155550123", + "customer_id": 42, + "is_vip": True, + } + assert result["status"] == "success" + + @pytest.mark.asyncio + async def test_missing_required_preset_parameter_returns_error(self): + """Test that required preset parameters fail before the HTTP request.""" + tool = MockToolModel( + tool_uuid="test-uuid-preset-error", + name="Create Lead", + description="Create a lead with caller context", + category="http_api", + definition={ + "schema_version": 1, + "type": "http_api", + "config": { + "method": "POST", + "url": "https://api.example.com/leads", + "timeout_ms": 5000, + "preset_parameters": [ + { + "name": "phone_number", + "type": "string", + "value_template": "{{initial_context.phone_number}}", + "required": True, + } + ], + }, + }, + ) + + result = await execute_http_tool(tool, {"name": "John"}, call_context_vars={}) + + assert result["status"] == "error" + assert "phone_number" in result["error"] + @pytest.mark.asyncio async def test_get_request_sends_query_params(self): """Test that GET requests send arguments as query parameters.""" @@ -794,9 +935,11 @@ class TestCustomToolManagerUnit: # Create a mock engine with a mock LLM mock_llm = Mock() registered_handlers = {} + registered_kwargs = {} def capture_register(name, handler, **kwargs): registered_handlers[name] = handler + registered_kwargs[name] = kwargs mock_llm.register_function = capture_register @@ -845,6 +988,7 @@ class TestCustomToolManagerUnit: # Verify handler was registered assert "api_call" in registered_handlers + assert registered_kwargs["api_call"]["timeout_secs"] == pytest.approx(5) # Now test that the handler works handler = registered_handlers["api_call"] diff --git a/api/tests/test_dograh_sdk_typed.py b/api/tests/test_dograh_sdk_typed.py index 4b23076..042f336 100644 --- a/api/tests/test_dograh_sdk_typed.py +++ b/api/tests/test_dograh_sdk_typed.py @@ -19,6 +19,7 @@ from dograh_sdk.typed import ( Qa, StartCall, Trigger, + Tuner, TypedNode, Webhook, ) @@ -50,6 +51,7 @@ def client() -> _StubClient: (Trigger, "trigger"), (Webhook, "webhook"), (Qa, "qa"), + (Tuner, "tuner"), ], ids=lambda v: v.__name__ if isinstance(v, type) else v, ) @@ -68,8 +70,15 @@ def test_typed_class_declares_spec_name(cls: type[TypedNode], expected_type: str inst = cls(name="t") elif cls is Webhook: inst = cls(name="wh") - else: # Qa + elif cls is Qa: inst = cls(name="qa") + else: # Tuner + inst = cls( + name="tuner", + tuner_agent_id="agent", + tuner_workspace_id=1, + tuner_api_key="secret", + ) assert inst.type == expected_type diff --git a/api/tests/test_dto.py b/api/tests/test_dto.py index 512252b..1f99d02 100644 --- a/api/tests/test_dto.py +++ b/api/tests/test_dto.py @@ -16,6 +16,37 @@ async def test_dto(): assert dto is not None +def test_dto_ignores_legacy_unknown_node_data_fields(): + dto = ReactFlowDTO.model_validate( + { + "nodes": [ + { + "id": "n1", + "type": "startCall", + "position": {"x": 0, "y": 0}, + "data": { + "name": "Start", + "prompt": "Hello", + "is_static": True, + "detect_voicemail": True, + "wait_for_user_response": False, + "wait_for_user_response_timeout": 2.5, + "legacy_field": "ignored", + }, + } + ], + "edges": [], + } + ) + + data = dto.nodes[0].data.model_dump() + assert "is_static" not in data + assert "detect_voicemail" not in data + assert "wait_for_user_response" not in data + assert "wait_for_user_response_timeout" not in data + assert "legacy_field" not in data + + def test_sanitize_strips_ui_runtime_fields(): definition = { "viewport": {"x": 0, "y": 0, "zoom": 1}, diff --git a/api/tests/test_from_number_pool_isolation.py b/api/tests/test_from_number_pool_isolation.py index c22241f..3c65d10 100644 --- a/api/tests/test_from_number_pool_isolation.py +++ b/api/tests/test_from_number_pool_isolation.py @@ -313,6 +313,13 @@ class TestDispatcherThreadsTelephonyConfig: f"kwargs={store_kwargs}" ) + assert provider.initiate_call.await_count == 1 + webhook_url = provider.initiate_call.await_args.kwargs["webhook_url"] + assert "campaign_id=" not in webhook_url, ( + "campaign outbound answer_url should not include campaign_id; " + f"got {webhook_url}" + ) + @pytest.mark.asyncio async def test_release_call_slot_uses_stored_telephony_config(self): """When a call completes, release_call_slot must release the from_number diff --git a/api/tests/test_gemini_live_reconnect_tool_results.py b/api/tests/test_gemini_live_reconnect_tool_results.py new file mode 100644 index 0000000..1ad0670 --- /dev/null +++ b/api/tests/test_gemini_live_reconnect_tool_results.py @@ -0,0 +1,86 @@ +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from pipecat.processors.aggregators.llm_context import LLMContext + +from api.services.pipecat.realtime.gemini_live import DograhGeminiLiveLLMService + + +class _TestDograhGeminiLiveLLMService(DograhGeminiLiveLLMService): + """Dograh Gemini service with client creation stubbed for unit tests.""" + + def create_client(self): + self._client = SimpleNamespace( + aio=SimpleNamespace(live=SimpleNamespace(connect=None)) + ) + + +class _FakeSession: + def __init__(self): + self.send_tool_response = AsyncMock() + self.send_realtime_input = AsyncMock() + self.close = AsyncMock() + + +def _make_service() -> _TestDograhGeminiLiveLLMService: + service = _TestDograhGeminiLiveLLMService(api_key="test-key") + service.stop_all_metrics = AsyncMock() + service.start_ttfb_metrics = AsyncMock() + service.cancel_task = AsyncMock() + service.push_error = AsyncMock() + return service + + +def _make_tool_result_context(tool_call_id: str) -> LLMContext: + return LLMContext( + messages=[ + { + "role": "tool", + "content": json.dumps({"status": "done"}), + "tool_call_id": tool_call_id, + } + ] + ) + + +@pytest.mark.asyncio +async def test_updated_context_during_reconnect_keeps_result_pending_until_session_ready(): + service = _make_service() + service._handled_initial_context = True + service._tool_call_id_to_name = {"call-transition": "transition_to_next_node"} + service._session = _FakeSession() + + context = _make_tool_result_context("call-transition") + + await service._disconnect() + await service._handle_context(context) + + # A reconnect gap should not count as successful delivery to Gemini. + assert "call-transition" not in service._completed_tool_calls + + session = _FakeSession() + await service._handle_session_ready(session) + + session.send_tool_response.assert_awaited_once() + sent_response = session.send_tool_response.await_args.kwargs["function_responses"] + assert sent_response.id == "call-transition" + assert sent_response.name == "transition_to_next_node" + assert "call-transition" in service._completed_tool_calls + + +@pytest.mark.asyncio +async def test_disconnect_does_not_forget_previously_delivered_tool_results(): + service = _make_service() + service._context = _make_tool_result_context("call-transition") + service._completed_tool_calls = {"call-transition"} + service._tool_call_id_to_name = {"call-transition": "transition_to_next_node"} + service._session = _FakeSession() + service._tool_result = AsyncMock() + + await service._disconnect() + await service._process_completed_function_calls(send_new_results=True) + + service._tool_result.assert_not_awaited() + assert service._completed_tool_calls == {"call-transition"} diff --git a/api/tests/test_google_stt_service_factory.py b/api/tests/test_google_stt_service_factory.py new file mode 100644 index 0000000..fb99188 --- /dev/null +++ b/api/tests/test_google_stt_service_factory.py @@ -0,0 +1,55 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from pipecat.transcriptions.language import Language + +from api.services.configuration.registry import ServiceProviders +from api.services.pipecat.service_factory import create_stt_service + + +def test_create_google_stt_service_uses_credentials_location_and_language(): + user_config = SimpleNamespace( + stt=SimpleNamespace( + provider=ServiceProviders.GOOGLE.value, + credentials='{"project_id":"demo-project"}', + api_key=None, + model="latest_long", + language="en-US", + location="us-central1", + ) + ) + audio_config = SimpleNamespace(transport_in_sample_rate=16000) + + with patch("api.services.pipecat.service_factory.GoogleSTTService") as mock_service: + create_stt_service(user_config, audio_config) + + assert mock_service.call_count == 1 + kwargs = mock_service.call_args.kwargs + assert kwargs["credentials"] == '{"project_id":"demo-project"}' + assert kwargs["location"] == "us-central1" + assert kwargs["sample_rate"] == 16000 + assert kwargs["settings"].model == "latest_long" + assert kwargs["settings"].languages == [Language.EN_US] + + +def test_create_google_stt_service_falls_back_to_raw_language_codes(): + user_config = SimpleNamespace( + stt=SimpleNamespace( + provider=ServiceProviders.GOOGLE.value, + credentials=None, + api_key=None, + model="chirp_3", + language="cmn-Hans-CN", + location="global", + ) + ) + audio_config = SimpleNamespace(transport_in_sample_rate=24000) + + with patch("api.services.pipecat.service_factory.GoogleSTTService") as mock_service: + create_stt_service(user_config, audio_config) + + assert mock_service.call_count == 1 + kwargs = mock_service.call_args.kwargs + assert kwargs["sample_rate"] == 24000 + assert kwargs["settings"].model == "chirp_3" + assert kwargs["settings"].language_codes == ["cmn-Hans-CN"] diff --git a/api/tests/test_google_tts_service_factory.py b/api/tests/test_google_tts_service_factory.py new file mode 100644 index 0000000..a84f4d5 --- /dev/null +++ b/api/tests/test_google_tts_service_factory.py @@ -0,0 +1,67 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from pipecat.services.settings import NOT_GIVEN + +from api.services.configuration.registry import ServiceProviders +from api.services.pipecat.service_factory import create_tts_service + + +def test_create_google_tts_service_uses_credentials_location_and_settings(): + user_config = SimpleNamespace( + tts=SimpleNamespace( + provider=ServiceProviders.GOOGLE.value, + credentials='{"project_id":"demo-project"}', + api_key=None, + model="chirp_3_hd", + voice="en-US-Chirp3-HD-Charon", + language="en-US", + speed=1.15, + location="us-central1", + ) + ) + audio_config = SimpleNamespace( + transport_out_sample_rate=24000, + transport_in_sample_rate=16000, + ) + + with patch("api.services.pipecat.service_factory.GoogleTTSService") as mock_service: + create_tts_service(user_config, audio_config) + + assert mock_service.call_count == 1 + kwargs = mock_service.call_args.kwargs + assert kwargs["credentials"] == '{"project_id":"demo-project"}' + assert kwargs["location"] == "us-central1" + assert kwargs["settings"].model == "chirp_3_hd" + assert kwargs["settings"].voice == "en-US-Chirp3-HD-Charon" + assert kwargs["settings"].language == "en-US" + assert kwargs["settings"].speaking_rate == 1.15 + + +def test_create_google_tts_service_omits_default_speed(): + user_config = SimpleNamespace( + tts=SimpleNamespace( + provider=ServiceProviders.GOOGLE.value, + credentials=None, + api_key=None, + model="chirp_3_hd", + voice="en-US-Chirp3-HD-Charon", + language="sw-KE", + speed=1.0, + location=None, + ) + ) + audio_config = SimpleNamespace( + transport_out_sample_rate=24000, + transport_in_sample_rate=16000, + ) + + with patch("api.services.pipecat.service_factory.GoogleTTSService") as mock_service: + create_tts_service(user_config, audio_config) + + assert mock_service.call_count == 1 + kwargs = mock_service.call_args.kwargs + assert kwargs["location"] is None + assert kwargs["settings"].model == "chirp_3_hd" + assert kwargs["settings"].language == "sw-KE" + assert kwargs["settings"].speaking_rate is NOT_GIVEN diff --git a/api/tests/test_google_vertex_llm_service_factory.py b/api/tests/test_google_vertex_llm_service_factory.py new file mode 100644 index 0000000..966d657 --- /dev/null +++ b/api/tests/test_google_vertex_llm_service_factory.py @@ -0,0 +1,103 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from api.services.configuration.check_validity import UserConfigurationValidator +from api.services.configuration.registry import ( + REGISTRY, + GoogleVertexLLMConfiguration, + ServiceProviders, + ServiceType, +) +from api.services.pipecat.service_factory import ( + create_llm_service, + create_llm_service_from_provider, +) + + +class TestGoogleVertexLLMConfiguration: + def test_defaults(self): + config = GoogleVertexLLMConfiguration(project_id="demo-project") + assert config.provider == ServiceProviders.GOOGLE_VERTEX + assert config.model == "gemini-2.5-flash" + assert config.location == "global" + assert config.credentials is None + assert config.api_key is None + + def test_registered_in_llm_registry(self): + assert ServiceProviders.GOOGLE_VERTEX in REGISTRY[ServiceType.LLM] + assert ( + REGISTRY[ServiceType.LLM][ServiceProviders.GOOGLE_VERTEX] + is GoogleVertexLLMConfiguration + ) + + +class TestGoogleVertexLLMServiceFactory: + def test_create_llm_service_from_provider_uses_vertex_service(self): + with patch( + "api.services.pipecat.service_factory.GoogleVertexLLMService" + ) as mock_service: + create_llm_service_from_provider( + provider=ServiceProviders.GOOGLE_VERTEX.value, + model="gemini-2.5-pro", + api_key=None, + project_id="demo-project", + location="us-central1", + credentials='{"type":"service_account"}', + ) + + kwargs = mock_service.call_args.kwargs + assert kwargs["project_id"] == "demo-project" + assert kwargs["location"] == "us-central1" + assert kwargs["credentials"] == '{"type":"service_account"}' + assert kwargs["settings"].model == "gemini-2.5-pro" + assert kwargs["settings"].temperature == 0.1 + + def test_create_llm_service_extracts_vertex_credentials(self): + user_config = SimpleNamespace( + llm=SimpleNamespace( + provider=ServiceProviders.GOOGLE_VERTEX.value, + api_key=None, + model="gemini-2.5-flash", + project_id="demo-project", + location="us-east4", + credentials='{"type":"service_account"}', + ) + ) + + with patch( + "api.services.pipecat.service_factory.GoogleVertexLLMService" + ) as mock_service: + create_llm_service(user_config) + + kwargs = mock_service.call_args.kwargs + assert kwargs["project_id"] == "demo-project" + assert kwargs["location"] == "us-east4" + assert kwargs["credentials"] == '{"type":"service_account"}' + + +class TestGoogleVertexLLMValidation: + def test_validator_accepts_vertex_llm_without_api_key(self): + validator = UserConfigurationValidator() + config = GoogleVertexLLMConfiguration( + project_id="demo-project", + location="us-east4", + credentials='{"type":"service_account"}', + ) + + assert validator._validate_service(config, "llm") == [] + + def test_validator_requires_project_id(self): + validator = UserConfigurationValidator() + config = SimpleNamespace( + provider=ServiceProviders.GOOGLE_VERTEX.value, + project_id=None, + location="us-east4", + credentials='{"type":"service_account"}', + api_key=None, + ) + + result = validator._validate_service(config, "llm") + + assert result == [ + {"model": "llm", "message": "project_id is required for Google Vertex"} + ] diff --git a/api/tests/test_grok_realtime_wrapper.py b/api/tests/test_grok_realtime_wrapper.py new file mode 100644 index 0000000..f3cfa1a --- /dev/null +++ b/api/tests/test_grok_realtime_wrapper.py @@ -0,0 +1,138 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from pipecat.frames.frames import LLMMessagesAppendFrame, TTSSpeakFrame +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.frame_processor import FrameDirection +from pipecat.services.xai.realtime import events + +from api.schemas.user_configuration import UserConfiguration +from api.services.configuration.registry import GrokRealtimeLLMConfiguration +from api.services.pipecat.realtime.grok_realtime import ( + DograhGrokRealtimeLLMService, +) +from api.services.pipecat.service_factory import create_realtime_llm_service + + +def _make_service() -> DograhGrokRealtimeLLMService: + service = DograhGrokRealtimeLLMService(api_key="test-key") + service._create_response = AsyncMock() + service._process_completed_function_calls = AsyncMock() + return service + + +@pytest.mark.asyncio +async def test_initial_context_triggers_response_when_context_was_prepopulated(): + service = _make_service() + context = LLMContext() + service._context = context + + await service._handle_context(context) + + assert service._handled_initial_context is True + assert service._context is context + service._create_response.assert_awaited_once() + service._process_completed_function_calls.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_tts_greeting_uses_initial_context_handler(): + service = _make_service() + service._context = LLMContext() + service._handle_context = AsyncMock() + + await service.process_frame( + TTSSpeakFrame("hello", append_to_context=True), + FrameDirection.DOWNSTREAM, + ) + + service._handle_context.assert_awaited_once_with(service._context) + + +@pytest.mark.asyncio +async def test_messages_append_frame_sends_conversation_item(): + service = _make_service() + service._api_session_ready = True + service.send_client_event = AsyncMock() + service._send_manual_response_create = AsyncMock() + + await service._handle_messages_append( + LLMMessagesAppendFrame( + [{"role": "user", "content": "Are you still there?"}], + run_llm=True, + ) + ) + + service.send_client_event.assert_awaited_once() + event = service.send_client_event.await_args.args[0] + assert isinstance(event, events.ConversationItemCreateEvent) + assert event.item.role == "user" + assert event.item.type == "message" + assert event.item.content == [ + events.ItemContent(type="input_text", text="Are you still there?") + ] + service._send_manual_response_create.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_function_call_is_deferred_until_bot_stops_speaking(): + service = _make_service() + service._context = LLMContext() + service.run_function_calls = AsyncMock() + service._bot_is_speaking = True + service._pending_function_calls["call-1"] = SimpleNamespace(name="customer_support") + + await service._handle_evt_function_call_arguments_done( + SimpleNamespace( + call_id="call-1", + name="customer_support", + arguments='{"department":"sales"}', + ) + ) + + service.run_function_calls.assert_not_awaited() + assert len(service._deferred_function_calls) == 1 + + await service._run_pending_function_calls() + + service.run_function_calls.assert_awaited_once() + assert service._deferred_function_calls == [] + + +@pytest.mark.asyncio +async def test_completed_input_transcription_is_broadcast_as_finalized(): + service = _make_service() + service._call_event_handler = AsyncMock() + service.broadcast_frame = AsyncMock() + + evt = SimpleNamespace(item_id="item-1", transcript="Hello there") + + await service._handle_evt_input_audio_transcription_completed(evt) + + service._call_event_handler.assert_awaited_once_with( + "on_conversation_item_updated", "item-1", None + ) + service.broadcast_frame.assert_awaited_once() + assert service.broadcast_frame.await_args.args[0].__name__ == "TranscriptionFrame" + assert service.broadcast_frame.await_args.kwargs["text"] == "Hello there" + assert service.broadcast_frame.await_args.kwargs["finalized"] is True + + +def test_factory_creates_dograh_grok_realtime_service(): + user_config = UserConfiguration( + is_realtime=True, + realtime=GrokRealtimeLLMConfiguration( + provider="grok_realtime", + api_key="xai-key", + model="grok-voice-think-fast-1.0", + voice="Sal", + ), + ) + + service = create_realtime_llm_service( + user_config, + audio_config=SimpleNamespace(), + ) + + assert isinstance(service, DograhGrokRealtimeLLMService) diff --git a/api/tests/test_is_private_ip_candidate.py b/api/tests/test_is_private_ip_candidate.py index 25feb5a..991fd51 100644 --- a/api/tests/test_is_private_ip_candidate.py +++ b/api/tests/test_is_private_ip_candidate.py @@ -1,4 +1,11 @@ -from api.routes.webrtc_signaling import is_private_ip_candidate +from api.enums import Environment +from api.routes.webrtc_signaling import ( + NonRelayFilterPolicy, + _keep_candidate, + is_local_or_cgnat_ip, + is_private_ip_candidate, + resolve_ice_filter_policies, +) class TestIsPrivateIpCandidate: @@ -142,3 +149,76 @@ class TestIsPrivateIpCandidate: "candidate:999 1 tcp 1518280447 192.168.1.100 9 typ host tcptype active" ) assert is_private_ip_candidate(candidate) is True + + +class TestIsLocalOrCgnatIp: + def test_loopback_is_local(self): + assert is_local_or_cgnat_ip("127.0.0.1") is True + + def test_link_local_is_local(self): + assert is_local_or_cgnat_ip("169.254.1.1") is True + + def test_cgnat_is_local(self): + assert is_local_or_cgnat_ip("100.64.0.1") is True + + def test_public_ipv4_is_not_local(self): + assert is_local_or_cgnat_ip("8.8.8.8") is False + + +class TestKeepCandidate: + def test_private_relay_candidate_survives_private_policy(self): + candidate = "candidate:111 1 udp 41885439 192.168.1.50 50000 typ relay raddr 0.0.0.0 rport 0" + assert _keep_candidate(candidate, NonRelayFilterPolicy.PRIVATE) is True + + def test_private_host_candidate_drops_under_private_policy(self): + candidate = ( + "candidate:123 1 udp 2122260223 192.168.50.24 63603 typ host generation 0" + ) + assert _keep_candidate(candidate, NonRelayFilterPolicy.PRIVATE) is False + + +class TestResolveIceFilterPolicies: + def test_local_deployment_keeps_all_candidates(self): + outbound, inbound = resolve_ice_filter_policies( + Environment.LOCAL.value, + False, + "", + ) + assert outbound == NonRelayFilterPolicy.NONE + assert inbound == NonRelayFilterPolicy.NONE + + def test_private_lan_remote_keeps_all_candidates(self): + outbound, inbound = resolve_ice_filter_policies( + Environment.PRODUCTION.value, + False, + "192.168.50.24", + ) + assert outbound == NonRelayFilterPolicy.NONE + assert inbound == NonRelayFilterPolicy.NONE + + def test_public_remote_filters_private_candidates(self): + outbound, inbound = resolve_ice_filter_policies( + Environment.PRODUCTION.value, + False, + "8.8.8.8", + ) + assert outbound == NonRelayFilterPolicy.PRIVATE + assert inbound == NonRelayFilterPolicy.PRIVATE + + def test_force_turn_relay_stays_relay_only_on_private_lan(self): + outbound, inbound = resolve_ice_filter_policies( + Environment.PRODUCTION.value, + True, + "192.168.50.24", + ) + assert outbound == NonRelayFilterPolicy.ALL + assert inbound == NonRelayFilterPolicy.NONE + + def test_force_turn_relay_keeps_public_remote_private_filter(self): + outbound, inbound = resolve_ice_filter_policies( + Environment.PRODUCTION.value, + True, + "8.8.8.8", + ) + assert outbound == NonRelayFilterPolicy.ALL + assert inbound == NonRelayFilterPolicy.PRIVATE diff --git a/api/tests/test_masked_key_rejection.py b/api/tests/test_masked_key_rejection.py index bdb6c6a..c6fdb51 100644 --- a/api/tests/test_masked_key_rejection.py +++ b/api/tests/test_masked_key_rejection.py @@ -9,6 +9,7 @@ from api.services.auth.depends import get_user from api.services.configuration.masking import mask_key from api.services.configuration.registry import ( GoogleLLMService, + GoogleVertexLLMConfiguration, OpenAILLMService, ) @@ -168,3 +169,44 @@ class TestMaskedKeyRejection: # Merge resolves the masked key back to the real one, # so check_for_masked_keys should NOT raise. assert response.status_code == 200 + + def test_allows_same_provider_with_masked_vertex_credentials(self): + """Same provider with masked credentials should succeed.""" + app = _make_test_app() + client = TestClient(app) + + real_credentials = '{"type":"service_account","project_id":"demo-project"}' + masked_credentials = mask_key(real_credentials) + existing = UserConfiguration( + llm=GoogleVertexLLMConfiguration( + provider="google_vertex", + api_key=None, + model="gemini-2.5-flash", + project_id="demo-project", + location="us-east4", + credentials=real_credentials, + ) + ) + + with ( + patch("api.routes.user.db_client") as mock_db, + patch("api.routes.user.UserConfigurationValidator") as mock_validator, + ): + mock_db.get_user_configurations = AsyncMock(return_value=existing) + mock_db.update_user_configuration = AsyncMock(return_value=existing) + mock_validator.return_value.validate = AsyncMock() + + response = client.put( + "/user/configurations/user", + json={ + "llm": { + "provider": "google_vertex", + "model": "gemini-2.5-flash", + "project_id": "demo-project", + "location": "us-east4", + "credentials": masked_credentials, + } + }, + ) + + assert response.status_code == 200 diff --git a/api/tests/test_mcp_auth.py b/api/tests/test_mcp_auth.py new file mode 100644 index 0000000..c6c10c1 --- /dev/null +++ b/api/tests/test_mcp_auth.py @@ -0,0 +1,63 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException + +from api.mcp_server.auth import authenticate_mcp_request + + +@pytest.mark.asyncio +async def test_authenticate_mcp_request_accepts_bearer_authorization(): + user = MagicMock() + user.id = 1 + user.selected_organization_id = 90 + + with ( + patch( + "api.mcp_server.auth.get_http_headers", + return_value={"authorization": "Bearer secret-api-key"}, + ) as get_headers, + patch( + "api.mcp_server.auth._handle_api_key_auth", + AsyncMock(return_value=user), + ) as handle_auth, + ): + authed = await authenticate_mcp_request() + + assert authed is user + get_headers.assert_called_once_with(include={"authorization"}) + handle_auth.assert_awaited_once_with("secret-api-key") + + +@pytest.mark.asyncio +async def test_authenticate_mcp_request_accepts_x_api_key(): + user = MagicMock() + user.id = 2 + user.selected_organization_id = 91 + + with ( + patch( + "api.mcp_server.auth.get_http_headers", + return_value={"x-api-key": "secret-api-key"}, + ) as get_headers, + patch( + "api.mcp_server.auth._handle_api_key_auth", + AsyncMock(return_value=user), + ) as handle_auth, + ): + authed = await authenticate_mcp_request() + + assert authed is user + get_headers.assert_called_once_with(include={"authorization"}) + handle_auth.assert_awaited_once_with("secret-api-key") + + +@pytest.mark.asyncio +async def test_authenticate_mcp_request_rejects_missing_api_key(): + with patch("api.mcp_server.auth.get_http_headers", return_value={}) as get_headers: + with pytest.raises(HTTPException) as exc_info: + await authenticate_mcp_request() + + assert exc_info.value.status_code == 401 + assert "Missing API key" in str(exc_info.value.detail) + get_headers.assert_called_once_with(include={"authorization"}) diff --git a/api/tests/test_mcp_custom_tool_manager.py b/api/tests/test_mcp_custom_tool_manager.py new file mode 100644 index 0000000..b40776a --- /dev/null +++ b/api/tests/test_mcp_custom_tool_manager.py @@ -0,0 +1,181 @@ +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from api.enums import ToolCategory +from api.services.workflow.mcp_tool_session import McpToolSession +from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager +from api.tests.support.mcp_mock_server import running_mcp_server + + +def _mcp_tool(): + t = MagicMock() + t.tool_uuid = "uuid-" + uuid.uuid4().hex[:8] + t.name = "Acme MCP" + t.category = ToolCategory.MCP.value + t.definition = {"type": "mcp", "config": {"url": "https://x/mcp"}} + return t + + +@pytest.mark.asyncio +async def test_get_tool_schemas_and_handler_for_mcp(monkeypatch): + async with running_mcp_server() as base_url: + tool = _mcp_tool() + session = McpToolSession( + tool_uuid=tool.tool_uuid, + tool_name=tool.name, + url=base_url, + credential=None, + tools_filter=[], + timeout_secs=10, + sse_read_timeout_secs=10, + ) + await session.start() + + engine = MagicMock() + engine._mcp_sessions = {tool.tool_uuid: session} + registered = {} + reg_kwargs = {} + + def _reg(name, fn, **kw): + registered[name] = fn + reg_kwargs[name] = kw + + engine.llm.register_function = _reg + + mgr = CustomToolManager(engine) + mgr.get_organization_id = AsyncMock(return_value=42) + + from api.db import db_client + + monkeypatch.setattr( + db_client, "get_tools_by_uuids", AsyncMock(return_value=[tool]) + ) + + try: + schemas = await mgr.get_tool_schemas([tool.tool_uuid]) + names = sorted(s.name for s in schemas) + assert names == ["mcp__acme_mcp__add", "mcp__acme_mcp__echo"] + + await mgr.register_handlers([tool.tool_uuid]) + assert "mcp__acme_mcp__echo" in registered + assert reg_kwargs["mcp__acme_mcp__echo"]["timeout_secs"] == pytest.approx( + 15.0 + ) + + captured = {} + + class P: + function_name = "mcp__acme_mcp__echo" + arguments = {"text": "yo"} + + async def result_callback(self, r, *, properties=None): + captured["r"] = r + + await registered["mcp__acme_mcp__echo"](P()) + assert "echo:yo" in str(captured["r"]) + finally: + await session.close() + + +@pytest.mark.asyncio +async def test_unavailable_mcp_session_contributes_nothing(monkeypatch): + tool = _mcp_tool() + session = McpToolSession( + tool_uuid=tool.tool_uuid, + tool_name=tool.name, + url="http://127.0.0.1:1/mcp", + credential=None, + tools_filter=[], + timeout_secs=1, + sse_read_timeout_secs=1, + ) + await session.start() # degrades + + engine = MagicMock() + engine._mcp_sessions = {tool.tool_uuid: session} + mgr = CustomToolManager(engine) + mgr.get_organization_id = AsyncMock(return_value=42) + + from api.db import db_client + + monkeypatch.setattr(db_client, "get_tools_by_uuids", AsyncMock(return_value=[tool])) + + schemas = await mgr.get_tool_schemas([tool.tool_uuid]) + assert schemas == [] + await mgr.register_handlers([tool.tool_uuid]) # must not raise + + +def test_call_timeout_secs_is_read_timeout_plus_buffer(): + session = McpToolSession( + tool_uuid="uuid-abc123", + tool_name="Acme MCP", + url="https://x/mcp", + credential=None, + tools_filter=[], + timeout_secs=10, + sse_read_timeout_secs=20, + ) + assert session.call_timeout_secs == 25.0 + + +@pytest.mark.asyncio +async def test_per_node_mcp_filter_intersection(monkeypatch): + async with running_mcp_server() as base_url: + tool = _mcp_tool() + session = McpToolSession( + tool_uuid=tool.tool_uuid, + tool_name=tool.name, + url=base_url, + credential=None, + tools_filter=[], + timeout_secs=10, + sse_read_timeout_secs=10, + ) + await session.start() + + engine = MagicMock() + engine._mcp_sessions = {tool.tool_uuid: session} + registered = {} + engine.llm.register_function = lambda name, fn, **kw: registered.__setitem__( + name, fn + ) + + mgr = CustomToolManager(engine) + mgr.get_organization_id = AsyncMock(return_value=42) + + from api.db import db_client + + monkeypatch.setattr( + db_client, "get_tools_by_uuids", AsyncMock(return_value=[tool]) + ) + try: + # Allow only raw "echo" for this node + filters = {tool.tool_uuid: ["echo"]} + schemas = await mgr.get_tool_schemas( + [tool.tool_uuid], mcp_tool_filters=filters + ) + # Check only "echo" schema returned (namespaced name depends on tool.name) + assert len(schemas) == 1 + assert all("echo" in s.name for s in schemas) + + await mgr.register_handlers([tool.tool_uuid], mcp_tool_filters=filters) + assert len(registered) == 1 + assert all("echo" in k for k in registered) + + # No filter entry for this uuid = none (default-none) + registered.clear() + result = await mgr.get_tool_schemas([tool.tool_uuid], mcp_tool_filters={}) + assert result == [] + await mgr.register_handlers([tool.tool_uuid], mcp_tool_filters={}) + assert registered == {} + + # mcp_tool_filters=None = backward-compatible (all tools) + registered.clear() + all_schemas = await mgr.get_tool_schemas([tool.tool_uuid]) + assert len(all_schemas) == 2 # both echo and add + await mgr.register_handlers([tool.tool_uuid]) + assert len(registered) == 2 # both handlers registered + finally: + await session.close() diff --git a/api/tests/test_mcp_docs_search.py b/api/tests/test_mcp_docs_search.py new file mode 100644 index 0000000..5019aeb --- /dev/null +++ b/api/tests/test_mcp_docs_search.py @@ -0,0 +1,359 @@ +"""Unit tests for the MCP docs discovery tools.""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException + +from api.mcp_server.tools import docs_search as docs_search_module +from api.mcp_server.tools.docs_search import ( + _docs_index, + _extract_page_title, + _resolve_docs_root, + _score_page, + _strip_frontmatter, + _tokenize_query, + list_docs, + read_doc, + search_docs, +) + + +def _clear_docs_caches() -> None: + docs_search_module._docs_index.cache_clear() + + +@pytest.fixture +def fake_docs_root(tmp_path: Path) -> Path: + docs_root = tmp_path / "docs" + docs_root.mkdir() + + (docs_root / "getting-started").mkdir() + (docs_root / "getting-started" / "index.mdx").write_text( + "---\n" + 'title: "Getting started"\n' + 'description: "Start using Dograh."\n' + "---\n\n" + "# Getting started\n\n" + "Welcome to Dograh.\n", + encoding="utf-8", + ) + + (docs_root / "voice-agent").mkdir() + (docs_root / "voice-agent" / "introduction.mdx").write_text( + "---\n" + 'title: "Voice Agent Builder"\n' + 'description: "Build conversational workflows."\n' + "---\n\n" + "# Voice Agent Builder\n\n" + "Build workflows with nodes and tools.\n", + encoding="utf-8", + ) + + (docs_root / "voice-agent" / "tools").mkdir() + (docs_root / "voice-agent" / "tools" / "mcp-tool.mdx").write_text( + "---\n" + 'title: "MCP Tool"\n' + 'description: "Connect external MCP servers."\n' + 'llm_hint: "Use for MCP server setup, remote tools, or model context protocol questions."\n' + "aliases:\n" + ' - "model context protocol"\n' + "---\n\n" + "# MCP Tool\n\n" + "Connect an external MCP server to your voice agent.\n\n" + "## Authentication\n\n" + "Provide the MCP endpoint URL and headers.\n", + encoding="utf-8", + ) + + (docs_root / "deployment").mkdir() + (docs_root / "deployment" / "docker.mdx").write_text( + "---\n" + 'title: "Docker"\n' + 'description: "Deploy Dograh with Docker."\n' + 'llm_hint: "Use for Docker deployment, local setup, remote setup, TURN server, coturn, or WebRTC connectivity questions."\n' + "aliases:\n" + ' - "coturn"\n' + ' - "turn server"\n' + "---\n\n" + "# Docker\n\n" + "Run Dograh with Docker.\n\n" + "## Troubleshooting WebRTC Connectivity\n\n" + "If audio fails or ICE fails, configure a TURN server. Coturn is the recommended choice.\n", + encoding="utf-8", + ) + + # Hidden/orphaned docs page: present on disk but not in docs.json, so it + # must not be indexed by the MCP tools. + (docs_root / "internal-only.mdx").write_text( + "---\n" + 'title: "Internal TURN Notes"\n' + "---\n\n" + "# Internal TURN Notes\n\n" + "This page mentions zyxinternalturntoken but is not user-facing.\n", + encoding="utf-8", + ) + + (docs_root / "AGENTS.md").write_text("# Internal instructions\n", encoding="utf-8") + + (docs_root / "docs.json").write_text( + """{ + "navigation": { + "tabs": [ + { + "tab": "Guides", + "groups": [ + { + "group": "Getting started", + "pages": [ + "getting-started/index" + ] + }, + { + "group": "Voice Agent Builder", + "pages": [ + "voice-agent/introduction", + { + "group": "Tools", + "pages": [ + "voice-agent/tools/mcp-tool" + ] + } + ] + } + ] + }, + { + "tab": "Developer", + "groups": [ + { + "group": "Deployment", + "pages": [ + "deployment/docker" + ] + } + ] + } + ] + } +} +""", + encoding="utf-8", + ) + + _clear_docs_caches() + with patch.dict(os.environ, {"DOGRAH_DOCS_PATH": str(docs_root)}): + yield docs_root + _clear_docs_caches() + + +@pytest.fixture +def authed_user(): + class _FakeUser: + selected_organization_id = 1 + id = 42 + + with patch( + "api.mcp_server.tools.docs_search.authenticate_mcp_request", + new=AsyncMock(return_value=_FakeUser()), + ): + yield _FakeUser() + + +def test_tokenize_query_dedupes_and_drops_stopwords(): + assert _tokenize_query("How do I configure a TURN server TURN?") == [ + "configure", + "turn", + "server", + ] + + +def test_tokenize_query_empty_input_returns_empty(): + assert _tokenize_query("") == [] + assert _tokenize_query("?? // !!") == [] + + +def test_strip_frontmatter_removes_yaml_block(): + body = '---\ntitle: "X"\n---\n\n# Heading\n' + assert _strip_frontmatter(body).startswith("# Heading") + + +def test_extract_page_title_prefers_frontmatter(): + body = '---\ntitle: "Front Title"\n---\n\n# Heading Title\n' + assert _extract_page_title(body, fallback="x.mdx") == "Front Title" + + +def test_extract_page_title_falls_back_to_first_heading(): + body = "# Heading Title\nbody\n" + assert _extract_page_title(body, fallback="x.mdx") == "Heading Title" + + +def test_score_page_uses_llm_hint_and_aliases(): + page = docs_search_module.DocPage( + path="deployment/docker", + file_path="deployment/docker.mdx", + title="Docker", + description="Deploy Dograh with Docker.", + llm_hint="Use for TURN server and coturn setup.", + aliases=("coturn",), + breadcrumb=("Developer", "Deployment"), + content="Docker deployment.", + sections=( + docs_search_module.DocSection( + title="Troubleshooting WebRTC Connectivity", + slug="troubleshooting-webrtc-connectivity", + level=2, + content="Configure a TURN server with coturn.", + ), + ), + order=0, + ) + score, section = _score_page(page, ["coturn"]) + assert score > 0 + assert section is not None + assert section.slug == "troubleshooting-webrtc-connectivity" + + +def test_resolve_docs_root_honors_env_override(tmp_path: Path): + docs = tmp_path / "custom_docs" + docs.mkdir() + (docs / "docs.json").write_text("{}", encoding="utf-8") + with patch.dict(os.environ, {"DOGRAH_DOCS_PATH": str(docs)}): + assert _resolve_docs_root() == docs.resolve() + + +@pytest.mark.asyncio +async def test_search_docs_ranks_turn_doc_and_uses_route_path( + fake_docs_root, authed_user +): + results = await search_docs("How do I configure coturn for WebRTC?") + assert results + assert results[0]["path"] == "deployment/docker" + assert results[0]["section_slug"] == "troubleshooting-webrtc-connectivity" + assert "TURN server" in results[0]["llm_hint"] + assert "snippet" not in results[0] + assert "score" not in results[0] + assert "url" not in results[0] + + +@pytest.mark.asyncio +async def test_search_docs_indexes_only_docs_json_pages(fake_docs_root, authed_user): + results = await search_docs("zyxinternalturntoken") + assert results == [] + + +@pytest.mark.asyncio +async def test_search_docs_respects_limit(fake_docs_root, authed_user): + results = await search_docs("dograh", limit=1) + assert len(results) == 1 + + +@pytest.mark.asyncio +async def test_search_docs_returns_empty_when_no_match(fake_docs_root, authed_user): + assert await search_docs("xyzzy unrelated zzz") == [] + + +@pytest.mark.asyncio +async def test_search_docs_returns_empty_when_no_corpus( + tmp_path, authed_user, monkeypatch +): + nonexistent = tmp_path / "no-docs-here" + monkeypatch.setenv("DOGRAH_DOCS_PATH", str(nonexistent)) + _clear_docs_caches() + with patch( + "api.mcp_server.tools.docs_search._resolve_docs_root", return_value=None + ): + assert await search_docs("anything") == [] + + +@pytest.mark.asyncio +async def test_search_docs_rejects_empty_query(fake_docs_root, authed_user): + with pytest.raises(ValueError, match="non-empty string"): + await search_docs("") + + +@pytest.mark.asyncio +async def test_search_docs_rejects_query_with_only_stopwords( + fake_docs_root, authed_user +): + with pytest.raises(ValueError, match="non-stopword"): + await search_docs("how do I") + + +@pytest.mark.asyncio +async def test_search_docs_rejects_zero_limit(fake_docs_root, authed_user): + with pytest.raises(ValueError, match="at least 1"): + await search_docs("Dograh", limit=0) + + +@pytest.mark.asyncio +async def test_list_docs_returns_top_level_sections(fake_docs_root, authed_user): + results = await list_docs() + assert results[0]["kind"] == "section" + assert results[0]["path"] == "guides/getting-started" + assert results[1]["path"] == "guides/voice-agent-builder" + + +@pytest.mark.asyncio +async def test_list_docs_depth_expands_children(fake_docs_root, authed_user): + results = await list_docs("guides/voice-agent-builder", depth=2) + paths = [item["path"] for item in results] + assert "voice-agent/introduction" in paths + assert "guides/voice-agent-builder/tools" in paths + assert "voice-agent/tools/mcp-tool" in paths + + +@pytest.mark.asyncio +async def test_list_docs_rejects_unknown_section(fake_docs_root, authed_user): + with pytest.raises(HTTPException, match="Unknown docs section"): + await list_docs("nope") + + +@pytest.mark.asyncio +async def test_read_doc_returns_full_page_and_sections(fake_docs_root, authed_user): + result = await read_doc("deployment/docker") + assert result["path"] == "deployment/docker" + assert result["title"] == "Docker" + assert "url" not in result + section_slugs = [section["slug"] for section in result["sections"]] + assert "docker" in section_slugs + assert "troubleshooting-webrtc-connectivity" in section_slugs + assert "Coturn" in result["content"] or "coturn" in result["content"].lower() + + +@pytest.mark.asyncio +async def test_read_doc_can_target_section(fake_docs_root, authed_user): + result = await read_doc( + "deployment/docker", + section="troubleshooting-webrtc-connectivity", + ) + assert result["section_slug"] == "troubleshooting-webrtc-connectivity" + assert "ICE fails" in result["content"] or "TURN server" in result["content"] + assert "Run Dograh with Docker." not in result["content"] + + +@pytest.mark.asyncio +async def test_read_doc_rejects_unknown_page(fake_docs_root, authed_user): + with pytest.raises(HTTPException, match="Unknown docs page"): + await read_doc("missing/page") + + +@pytest.mark.asyncio +async def test_read_doc_rejects_unknown_section(fake_docs_root, authed_user): + with pytest.raises(HTTPException, match="Unknown section"): + await read_doc("deployment/docker", section="missing-section") + + +def test_docs_index_uses_docs_json_navigation(fake_docs_root): + index = _docs_index() + assert "internal-only" not in index.pages_by_path + assert "guides/voice-agent-builder/tools" in index.sections_by_path + assert index.pages_by_path["voice-agent/tools/mcp-tool"].breadcrumb == ( + "Guides", + "Voice Agent Builder", + "Tools", + ) diff --git a/api/tests/test_mcp_get_workflow.py b/api/tests/test_mcp_get_workflow.py new file mode 100644 index 0000000..743da3f --- /dev/null +++ b/api/tests/test_mcp_get_workflow.py @@ -0,0 +1,99 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from api.mcp_server.tools.workflows import get_workflow + + +@pytest.fixture +def authed_user() -> MagicMock: + user = MagicMock() + user.selected_organization_id = 1 + return user + + +def _workflow() -> SimpleNamespace: + return SimpleNamespace( + id=7, + name="Support Agent", + status="active", + released_definition=SimpleNamespace( + workflow_json={"nodes": [{"id": "published"}], "edges": []}, + version_number=3, + ), + workflow_definition={"nodes": [{"id": "legacy"}], "edges": []}, + ) + + +@pytest.mark.asyncio +async def test_get_workflow_returns_draft_sdk_view(authed_user: MagicMock): + workflow = _workflow() + draft = SimpleNamespace( + workflow_json={"nodes": [{"id": "draft"}], "edges": []}, + version_number=4, + ) + + with ( + patch( + "api.mcp_server.tools.workflows.authenticate_mcp_request", + AsyncMock(return_value=authed_user), + ), + patch( + "api.mcp_server.tools.workflows.db_client.get_workflow", + AsyncMock(return_value=workflow), + ), + patch( + "api.mcp_server.tools._workflow_projection.db_client.get_draft_version", + AsyncMock(return_value=draft), + ), + patch( + "api.mcp_server.tools._workflow_projection.generate_code", + AsyncMock( + return_value='const wf = new Workflow({ name: "Support Agent" });' + ), + ) as generate_code_mock, + ): + result = await get_workflow(workflow_id=workflow.id) + + assert result == { + "id": 7, + "name": "Support Agent", + "status": "active", + "version": "draft", + "version_number": 4, + "code": 'const wf = new Workflow({ name: "Support Agent" });', + } + generate_code_mock.assert_awaited_once_with( + draft.workflow_json, workflow_name="Support Agent" + ) + + +@pytest.mark.asyncio +async def test_get_workflow_falls_back_to_published_sdk_view(authed_user: MagicMock): + workflow = _workflow() + + with ( + patch( + "api.mcp_server.tools.workflows.authenticate_mcp_request", + AsyncMock(return_value=authed_user), + ), + patch( + "api.mcp_server.tools.workflows.db_client.get_workflow", + AsyncMock(return_value=workflow), + ), + patch( + "api.mcp_server.tools._workflow_projection.db_client.get_draft_version", + AsyncMock(return_value=None), + ), + patch( + "api.mcp_server.tools._workflow_projection.generate_code", + AsyncMock( + return_value='const wf = new Workflow({ name: "Support Agent" });' + ), + ), + ): + result = await get_workflow(workflow_id=workflow.id) + + assert result["version"] == "published" + assert result["version_number"] == 3 diff --git a/api/tests/test_mcp_instructions_drift.py b/api/tests/test_mcp_instructions_drift.py new file mode 100644 index 0000000..275b824 --- /dev/null +++ b/api/tests/test_mcp_instructions_drift.py @@ -0,0 +1,115 @@ +"""Drift guards between the static MCP guide and the live tool surface. + +`api/mcp_server/instructions.py` is free text baked into the client +system prompt. It is *not* the authoritative description of the tools — +names, signatures, and per-tool error codes reach the model dynamically +via `tools/list`, derived from each tool's own function signature and +docstring. These tests fail on the two classic drift modes: + +1. The guide references a tool that is no longer registered (renamed or + removed) — the model would be told to call something that 404s. +2. A tool returns an `error_code` that is absent from the description it + ships via `tools/list` — the model can't learn to recover from it. + +Keep the guide about orchestration (call order, hard constraints) and let +the tools describe themselves. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from api.mcp_server import instructions as instructions_module +from api.mcp_server.server import mcp +from api.mcp_server.tools import create_workflow as create_workflow_module +from api.mcp_server.tools import save_workflow as save_workflow_module + +# Every registered MCP tool name starts with one of these verbs. A +# backticked snake_case token in the guide whose leading word is a verb is +# treated as a tool reference; field/reference names like `tool_refs`, +# `credential_ref`, or `pre_call_fetch` don't start with a verb and are +# correctly ignored. Extend this only when a new tool introduces a new +# leading verb (a missing verb under-checks, it never false-fails). +_TOOL_VERB_PREFIXES = frozenset( + { + "search", + "read", + "list", + "get", + "save", + "create", + "update", + "delete", + "add", + "remove", + "set", + } +) + +# A backtick immediately followed by a snake_case identifier (>= 1 +# underscore). Anchoring on the opening backtick captures the leading +# identifier of a code span whether it is bare (`read_doc`) or a call +# (`read_doc(path)`), while skipping DSL constructs like `wf.edge` or +# `new Workflow` whose first char after the backtick isn't `[a-z_]`. +_BACKTICKED_SNAKE_RE = re.compile(r"`([a-z][a-z0-9]*(?:_[a-z0-9]+)+)") + +# Error codes are emitted as the first string arg to `_error_result(...)`. +_ERROR_RESULT_LITERAL_RE = re.compile(r'_error_result\(\s*"([a-z_]+)"') +# `parse_error` / `validation_error` are picked by a `code_key` ternary +# rather than passed as a literal to `_error_result`, so match them too. +_CODE_KEY_LITERAL_RE = re.compile(r'"(parse_error|validation_error)"') + + +def _referenced_tool_names(text: str) -> set[str]: + return { + token + for token in _BACKTICKED_SNAKE_RE.findall(text) + if token.split("_", 1)[0] in _TOOL_VERB_PREFIXES + } + + +def _returned_error_codes(module) -> set[str]: + source = Path(module.__file__).read_text(encoding="utf-8") + return set(_ERROR_RESULT_LITERAL_RE.findall(source)) | set( + _CODE_KEY_LITERAL_RE.findall(source) + ) + + +@pytest.mark.asyncio +async def test_guide_only_references_registered_tools(): + registered = {tool.name for tool in await mcp.list_tools()} + referenced = _referenced_tool_names(instructions_module.DOGRAH_MCP_INSTRUCTIONS) + + assert referenced, "no tool references extracted — the regex likely broke" + unknown = sorted(referenced - registered) + assert not unknown, ( + f"instructions.py references tools that are not registered: {unknown}. " + f"Rename/remove the reference or register the tool. " + f"Registered tools: {sorted(registered)}." + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "tool_name, module", + [ + ("save_workflow", save_workflow_module), + ("create_workflow", create_workflow_module), + ], +) +async def test_tool_documents_every_error_code_it_returns(tool_name, module): + descriptions = { + tool.name: tool.description or "" for tool in await mcp.list_tools() + } + description = descriptions[tool_name] + returned = _returned_error_codes(module) + + assert returned, f"no error codes detected in {tool_name} source — regex broke" + undocumented = sorted(code for code in returned if code not in description) + assert not undocumented, ( + f"{tool_name} returns error_code(s) {undocumented} absent from the description " + f"shipped via tools/list. Document them in the {tool_name} docstring." + ) diff --git a/api/tests/test_mcp_integration.py b/api/tests/test_mcp_integration.py new file mode 100644 index 0000000..4cf01f0 --- /dev/null +++ b/api/tests/test_mcp_integration.py @@ -0,0 +1,107 @@ +import uuid +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from api.enums import ToolCategory +from api.services.workflow.pipecat_engine import PipecatEngine +from api.tests.support.mcp_mock_server import running_mcp_server + + +def _mcp_tool(url: str): + t = MagicMock() + t.tool_uuid = "uuid-" + uuid.uuid4().hex[:8] + t.name = "Acme MCP" + t.category = ToolCategory.MCP.value + t.definition = { + "schema_version": 1, + "type": "mcp", + "config": {"transport": "streamable_http", "url": url}, + } + return t + + +@pytest.mark.asyncio +async def test_engine_opens_and_closes_mcp_sessions(monkeypatch): + async with running_mcp_server() as base_url: + tool = _mcp_tool(base_url) + + engine = PipecatEngine.__new__(PipecatEngine) + node = MagicMock() + node.tool_uuids = [tool.tool_uuid] + workflow = MagicMock() + workflow.nodes = {"n1": node} + engine.workflow = workflow + engine._mcp_sessions = {} + + from api.db import db_client + + monkeypatch.setattr( + db_client, "get_tools_by_uuids", AsyncMock(return_value=[tool]) + ) + monkeypatch.setattr( + db_client, "get_credential_by_uuid", AsyncMock(return_value=None) + ) + engine._get_organization_id = AsyncMock(return_value=42) + + await engine._open_mcp_sessions() + try: + assert tool.tool_uuid in engine._mcp_sessions + sess = engine._mcp_sessions[tool.tool_uuid] + assert sess.available is True + assert len(sess.function_schemas()) == 2 + finally: + await engine._close_mcp_sessions() + assert engine._mcp_sessions == {} + + +@pytest.mark.asyncio +async def test_open_mcp_sessions_swallows_db_error(monkeypatch): + engine = PipecatEngine.__new__(PipecatEngine) + node = MagicMock() + node.tool_uuids = ["uuid-deadbeef"] + workflow = MagicMock() + workflow.nodes = {"n1": node} + engine.workflow = workflow + engine._mcp_sessions = {} + + from api.db import db_client + + monkeypatch.setattr( + db_client, + "get_tools_by_uuids", + AsyncMock(side_effect=RuntimeError("db down")), + ) + engine._get_organization_id = AsyncMock(return_value=42) + + # Must NOT raise + await engine._open_mcp_sessions() + assert engine._mcp_sessions == {} + + +@pytest.mark.asyncio +async def test_open_mcp_sessions_skips_tool_when_credential_fetch_fails(monkeypatch): + tool = _mcp_tool("http://127.0.0.1:1/mcp") + tool.definition["config"]["credential_uuid"] = "cred-1234" + + engine = PipecatEngine.__new__(PipecatEngine) + node = MagicMock() + node.tool_uuids = [tool.tool_uuid] + workflow = MagicMock() + workflow.nodes = {"n1": node} + engine.workflow = workflow + engine._mcp_sessions = {} + + from api.db import db_client + + monkeypatch.setattr(db_client, "get_tools_by_uuids", AsyncMock(return_value=[tool])) + monkeypatch.setattr( + db_client, + "get_credential_by_uuid", + AsyncMock(side_effect=RuntimeError("cred store down")), + ) + engine._get_organization_id = AsyncMock(return_value=42) + + # Must NOT raise, and must skip the tool (no futile unauthenticated start) + await engine._open_mcp_sessions() + assert engine._mcp_sessions == {} diff --git a/api/tests/test_mcp_save_workflow.py b/api/tests/test_mcp_save_workflow.py index 3dbd703..e877b5e 100644 --- a/api/tests/test_mcp_save_workflow.py +++ b/api/tests/test_mcp_save_workflow.py @@ -186,6 +186,45 @@ const n = wf.addTyped(startCall({ name: "g", prompt: "hi", promt: "typo" })); update_mock.assert_not_awaited() +@pytest.mark.asyncio +async def test_invalid_trigger_path_surfaces_validation_error(mock_backends): + save_mock, update_mock = mock_backends + payload = { + "nodes": [ + { + "id": "trigger-1", + "type": "trigger", + "data": {"trigger_path": "support/west"}, + } + ], + "edges": [], + } + + with ( + patch( + "api.mcp_server.tools.save_workflow.parse_code", + AsyncMock( + return_value={ + "ok": True, + "workflowName": _FakeWorkflowModel.name, + "workflow": payload, + } + ), + ), + patch( + "api.mcp_server.tools.save_workflow.reconcile_positions", + return_value=payload, + ), + ): + result = await save_workflow(workflow_id=1, code="ignored") + + assert result["saved"] is False + assert result["error_code"] == "validation_error" + assert "single URL path segment" in result["error"] + save_mock.assert_not_awaited() + update_mock.assert_not_awaited() + + # ─── Graph-stage rejections ────────────────────────────────────────────── diff --git a/api/tests/test_mcp_tool_definition.py b/api/tests/test_mcp_tool_definition.py new file mode 100644 index 0000000..5158c3a --- /dev/null +++ b/api/tests/test_mcp_tool_definition.py @@ -0,0 +1,112 @@ +import importlib + +import pytest + +from api.enums import ToolCategory +from api.routes.tool import McpToolConfig as RouteMcpToolConfig +from api.routes.tool import McpToolDefinition as RouteMcpToolDefinition +from api.services.workflow.tools.mcp_tool import ( + McpDefinitionError, + McpToolConfig, + McpToolDefinition, + namespace_function_name, + validate_mcp_definition, +) + + +def test_mcp_category_exists(): + assert ToolCategory.MCP.value == "mcp" + assert ToolCategory("mcp") is ToolCategory.MCP + + +def test_mcp_migration_present_and_chained(monkeypatch): + mod = importlib.import_module( + "api.alembic.versions.0a1b2c3d4e5f_add_mcp_in_toolcategory" + ) + assert mod.revision == "0a1b2c3d4e5f" + assert mod.down_revision == "4c1f1e3e8ef2" + + calls = [] + + def fake_sync_enum_values(**kwargs): + calls.append(kwargs) + + monkeypatch.setattr(mod.op, "sync_enum_values", fake_sync_enum_values) + + mod.upgrade() + mod.downgrade() + + assert len(calls) == 2 + assert calls[0]["enum_name"] == "tool_category" + assert "mcp" in calls[0]["new_values"] + assert "mcp" not in calls[1]["new_values"] + + +def test_route_reuses_shared_mcp_models(): + assert RouteMcpToolConfig is McpToolConfig + assert RouteMcpToolDefinition is McpToolDefinition + + +def test_validate_mcp_definition_ok(): + cfg = validate_mcp_definition( + { + "schema_version": 1, + "type": "mcp", + "config": { + "transport": "streamable_http", + "url": "https://acme.example.com/mcp", + "credential_uuid": "cred-123", + "tools_filter": ["lookup_patient"], + "timeout_secs": 30, + "sse_read_timeout_secs": 300, + }, + } + ) + assert cfg["url"] == "https://acme.example.com/mcp" + assert cfg["transport"] == "streamable_http" + assert cfg["tools_filter"] == ["lookup_patient"] + assert cfg["timeout_secs"] == 30 + assert cfg["sse_read_timeout_secs"] == 300 + assert cfg["credential_uuid"] == "cred-123" + + +def test_validate_mcp_definition_defaults(): + cfg = validate_mcp_definition({"type": "mcp", "config": {"url": "https://x/mcp"}}) + assert cfg["transport"] == "streamable_http" + assert cfg["tools_filter"] == [] + assert cfg["timeout_secs"] == 30 + assert cfg["sse_read_timeout_secs"] == 300 + assert cfg["credential_uuid"] is None + + +@pytest.mark.parametrize( + "definition", + [ + {"type": "mcp", "config": {}}, + {"type": "mcp", "config": {"url": ""}}, + {"type": "mcp", "config": {"url": "ftp://x"}}, + {"type": "mcp"}, + {"type": "mcp", "config": {"url": "https://x", "transport": "stdio"}}, + ], +) +def test_validate_mcp_definition_rejects(definition): + with pytest.raises(McpDefinitionError): + validate_mcp_definition(definition) + + +def test_validate_mcp_definition_zero_timeout_preserved(): + cfg = validate_mcp_definition( + {"type": "mcp", "config": {"url": "https://x/mcp", "timeout_secs": 0}} + ) + assert cfg["timeout_secs"] == 0 + + +def test_namespace_function_name(): + assert ( + namespace_function_name("Acme MCP", "lookup_patient") + == "mcp__acme_mcp__lookup_patient" + ) + assert ( + namespace_function_name("", "ping", fallback="abcd1234") + == "mcp__abcd1234__ping" + ) diff --git a/api/tests/test_mcp_tool_route.py b/api/tests/test_mcp_tool_route.py new file mode 100644 index 0000000..a16f75c --- /dev/null +++ b/api/tests/test_mcp_tool_route.py @@ -0,0 +1,437 @@ +"""Route-level tests for the MCP tool definition schema. + +These tests exercise the Pydantic request models (CreateToolRequest / +UpdateToolRequest) to catch schema gaps at the route/request-model layer — +the layer where the pre-fix defect lived (HTTP 422 on every MCP tool +creation attempt). + +Test coverage: +- CreateToolRequest validates a valid MCP definition (was 422 before Part A). +- UpdateToolRequest validates a valid MCP definition. +- Invalid MCP bodies are rejected (ftp:// url, missing url). +- Round-trip: validated definition dict passes through validate_mcp_definition + unchanged, proving the request schema and call-time validator agree. +- Full HTTP round-trip via the ASGI test client (POST /api/v1/tools/). +""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from api.routes.tool import CreateToolRequest, McpToolDefinition, UpdateToolRequest +from api.services.workflow.tools.mcp_tool import ( + validate_mcp_definition, +) + +# ── Canonical valid MCP request body ───────────────────────────────────────── + +VALID_MCP_DEFINITION = { + "schema_version": 1, + "type": "mcp", + "config": { + "transport": "streamable_http", + "url": "https://x/mcp", + "credential_uuid": None, + "tools_filter": [], + }, +} + + +# ── Part A regression: CreateToolRequest / UpdateToolRequest validation ─────── + + +def test_create_tool_request_accepts_mcp_definition(): + """CreateToolRequest must accept an MCP definition (was HTTP 422 before fix).""" + req = CreateToolRequest( + name="My MCP Tool", + description="Integration via MCP", + category="mcp", + definition=VALID_MCP_DEFINITION, + ) + assert isinstance(req.definition, McpToolDefinition) + assert req.definition.type == "mcp" + assert req.definition.config.url == "https://x/mcp" + assert req.definition.config.transport == "streamable_http" + assert req.definition.config.credential_uuid is None + assert req.definition.config.tools_filter == [] + assert req.definition.config.timeout_secs == 30 + assert req.definition.config.sse_read_timeout_secs == 300 + + +def test_update_tool_request_accepts_mcp_definition(): + """UpdateToolRequest must also accept an MCP definition.""" + req = UpdateToolRequest( + name="Updated MCP Tool", + definition=VALID_MCP_DEFINITION, + ) + assert isinstance(req.definition, McpToolDefinition) + assert req.definition.type == "mcp" + assert req.definition.config.url == "https://x/mcp" + + +def test_create_tool_request_accepts_mcp_with_all_fields(): + """All optional MCP config fields are accepted and preserved.""" + req = CreateToolRequest( + name="Full MCP Tool", + category="mcp", + definition={ + "schema_version": 1, + "type": "mcp", + "config": { + "transport": "streamable_http", + "url": "https://acme.example.com/mcp", + "credential_uuid": "cred-abc-123", + "tools_filter": ["lookup_patient", "schedule_appointment"], + "timeout_secs": 60, + "sse_read_timeout_secs": 600, + }, + }, + ) + cfg = req.definition.config # type: ignore[union-attr] + assert cfg.url == "https://acme.example.com/mcp" + assert cfg.credential_uuid == "cred-abc-123" + assert cfg.tools_filter == ["lookup_patient", "schedule_appointment"] + assert cfg.timeout_secs == 60 + assert cfg.sse_read_timeout_secs == 600 + + +# ── Invalid bodies are rejected ─────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "definition", + [ + # ftp:// URL — rejected by McpToolConfig.validate_url + { + "schema_version": 1, + "type": "mcp", + "config": {"transport": "streamable_http", "url": "ftp://x/mcp"}, + }, + # Empty url — rejected by McpToolConfig.validate_url + { + "schema_version": 1, + "type": "mcp", + "config": {"transport": "streamable_http", "url": ""}, + }, + # Missing url — rejected by McpToolConfig (required field) + { + "schema_version": 1, + "type": "mcp", + "config": {"transport": "streamable_http"}, + }, + # Unsupported transport — rejected because Literal["streamable_http"] constraint + { + "schema_version": 1, + "type": "mcp", + "config": {"url": "https://x/mcp", "transport": "stdio"}, + }, + ], +) +def test_create_tool_request_rejects_invalid_mcp_definition(definition): + """Invalid MCP definitions must raise ValidationError.""" + with pytest.raises(ValidationError): + CreateToolRequest( + name="Bad MCP Tool", + category="mcp", + definition=definition, + ) + + +# ── Round-trip compatibility: request schema ↔ validate_mcp_definition ─────── + + +def test_mcp_definition_round_trips_through_validate_mcp_definition(): + """The dict produced by CreateToolRequest.definition.model_dump() must be + accepted by validate_mcp_definition without raising, and the result must + contain the expected fields. This proves the request-layer schema and the + call-time validator agree on the stored config shape.""" + req = CreateToolRequest( + name="Round-Trip MCP Tool", + category="mcp", + definition={ + "schema_version": 1, + "type": "mcp", + "config": { + "transport": "streamable_http", + "url": "https://roundtrip.example.com/mcp", + "credential_uuid": "cred-rt-456", + "tools_filter": ["ping"], + "timeout_secs": 45, + "sse_read_timeout_secs": 400, + }, + }, + ) + + # Simulate what the route does: persist definition as a plain dict + persisted = req.definition.model_dump() # type: ignore[union-attr] + + # validate_mcp_definition must accept the persisted shape without raising + normalized = validate_mcp_definition(persisted) + + assert normalized["url"] == "https://roundtrip.example.com/mcp" + assert normalized["transport"] == "streamable_http" + assert normalized["credential_uuid"] == "cred-rt-456" + assert normalized["tools_filter"] == ["ping"] + assert normalized["timeout_secs"] == 45 + assert normalized["sse_read_timeout_secs"] == 400 + + +def test_mcp_definition_round_trip_defaults(): + """Round-trip with minimal body: defaults fill in correctly and + validate_mcp_definition agrees on them.""" + req = CreateToolRequest( + name="Minimal MCP Tool", + category="mcp", + definition=VALID_MCP_DEFINITION, + ) + + persisted = req.definition.model_dump() # type: ignore[union-attr] + normalized = validate_mcp_definition(persisted) + + assert normalized["transport"] == "streamable_http" + assert normalized["tools_filter"] == [] + assert normalized["timeout_secs"] == 30 + assert normalized["sse_read_timeout_secs"] == 300 + assert normalized["credential_uuid"] is None + # Part B: auth_header / auth_scheme must NOT be present in the normalized + # config dict (they were dead config removed in the fix) + assert "auth_header" not in normalized + assert "auth_scheme" not in normalized + + +# ── Full HTTP round-trip via ASGI test client ───────────────────────────────── + + +async def test_post_tool_mcp_returns_200(test_client_factory, db_session): + """POST /api/v1/tools/ with an MCP definition must return HTTP 200 and + persist the definition with type='mcp'. Before Part A this always + returned 422.""" + # Create a user and an organization, then link them so the route's + # selected_organization_id check passes. + user, _ = await db_session.get_or_create_user_by_provider_id("mcp_route_test_user") + org, _ = await db_session.get_or_create_organization_by_provider_id( + "mcp_route_test_org", user.id + ) + await db_session.update_user_selected_organization(user.id, org.id) + # Reload the user so selected_organization_id is populated on the object. + user = await db_session.get_user_by_id(user.id) + + async with test_client_factory(user) as client: + response = await client.post( + "/api/v1/tools/", + json={ + "name": "HTTP Round-Trip MCP Tool", + "description": "Testing the full route", + "category": "mcp", + "definition": { + "schema_version": 1, + "type": "mcp", + "config": { + "transport": "streamable_http", + "url": "https://roundtrip.example.com/mcp", + "credential_uuid": None, + "tools_filter": [], + }, + }, + }, + ) + + assert response.status_code == 200, ( + f"Expected 200, got {response.status_code}: {response.text}" + ) + body = response.json() + assert body["definition"]["type"] == "mcp" + assert body["definition"]["config"]["url"] == "https://roundtrip.example.com/mcp" + assert body["category"] == "mcp" + + +async def test_post_tool_mcp_invalid_url_returns_422(test_client_factory, db_session): + """POST /api/v1/tools/ with an ftp:// URL must return HTTP 422.""" + user, _ = await db_session.get_or_create_user_by_provider_id( + "mcp_route_test_user_422" + ) + org, _ = await db_session.get_or_create_organization_by_provider_id( + "mcp_route_test_org_422", user.id + ) + await db_session.update_user_selected_organization(user.id, org.id) + user = await db_session.get_user_by_id(user.id) + + async with test_client_factory(user) as client: + response = await client.post( + "/api/v1/tools/", + json={ + "name": "Bad MCP Tool", + "category": "mcp", + "definition": { + "schema_version": 1, + "type": "mcp", + "config": { + "transport": "streamable_http", + "url": "ftp://invalid.example.com/mcp", + }, + }, + }, + ) + + assert response.status_code == 422 + + +# ── Task 6: discovered_tools field and _populate_discovered_tools helper ────── + +from unittest.mock import AsyncMock, MagicMock + +from api.routes.tool import McpToolConfig, _populate_discovered_tools + + +def test_mcp_config_accepts_discovered_tools(): + cfg = McpToolConfig( + url="https://x/mcp", + discovered_tools=[{"name": "echo", "description": "Echo"}], + ) + assert cfg.discovered_tools == [{"name": "echo", "description": "Echo"}] + # Defaults to [] when omitted + assert McpToolConfig(url="https://x/mcp").discovered_tools == [] + + +@pytest.mark.asyncio +async def test_populate_discovered_tools_overwrites_cache(monkeypatch): + import api.routes.tool as tool_mod + + monkeypatch.setattr( + tool_mod, + "discover_mcp_tools", + AsyncMock(return_value=[{"name": "echo", "description": "Echo"}]), + ) + definition = { + "schema_version": 1, + "type": "mcp", + "config": { + "url": "https://x/mcp", + "tools_filter": [], + "discovered_tools": [{"name": "stale", "description": "old"}], + }, + } + out = await _populate_discovered_tools(definition, organization_id=1) + assert out["config"]["discovered_tools"] == [ + {"name": "echo", "description": "Echo"} + ] + + +@pytest.mark.asyncio +async def test_populate_discovered_tools_non_mcp_is_noop(): + definition = {"schema_version": 1, "type": "http_api", "config": {}} + out = await _populate_discovered_tools(definition, organization_id=1) + assert out == definition # untouched + + +@pytest.mark.asyncio +async def test_populate_discovered_tools_server_down_sets_empty(monkeypatch): + import api.routes.tool as tool_mod + + monkeypatch.setattr( + tool_mod, + "discover_mcp_tools", + AsyncMock(side_effect=RuntimeError("connection refused")), + ) + definition = { + "schema_version": 1, + "type": "mcp", + "config": {"url": "https://x/mcp", "tools_filter": []}, + } + out = await _populate_discovered_tools(definition, organization_id=1) + assert out["config"]["discovered_tools"] == [] + + +# ── Task 7: POST /{tool_uuid}/mcp/refresh ───────────────────────────────────── + +from fastapi import HTTPException + +from api.routes.tool import refresh_mcp_tools + + +def _fake_user(org_id=1): + u = MagicMock() + u.selected_organization_id = org_id + u.id = 1 + u.provider_id = "p1" + return u + + +def _mcp_tool_model(org_id=1): + t = MagicMock() + t.tool_uuid = "tu-mcp" + t.name = "Mock MCP" + t.category = "mcp" + t.definition = { + "schema_version": 1, + "type": "mcp", + "config": {"url": "https://x/mcp", "tools_filter": []}, + } + return t + + +@pytest.mark.asyncio +async def test_refresh_success(monkeypatch): + import api.routes.tool as tool_mod + + tool = _mcp_tool_model() + monkeypatch.setattr( + tool_mod.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool) + ) + monkeypatch.setattr( + tool_mod.db_client, + "update_tool", + AsyncMock(return_value=tool), + ) + monkeypatch.setattr( + tool_mod, + "discover_mcp_tools", + AsyncMock(return_value=[{"name": "echo", "description": "Echo"}]), + ) + resp = await refresh_mcp_tools("tu-mcp", user=_fake_user()) + assert resp.discovered_tools == [{"name": "echo", "description": "Echo"}] + assert resp.error is None + + +@pytest.mark.asyncio +async def test_refresh_server_down_returns_200_with_error(monkeypatch): + import api.routes.tool as tool_mod + + tool = _mcp_tool_model() + monkeypatch.setattr( + tool_mod.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool) + ) + monkeypatch.setattr(tool_mod.db_client, "update_tool", AsyncMock(return_value=tool)) + monkeypatch.setattr(tool_mod, "discover_mcp_tools", AsyncMock(return_value=[])) + resp = await refresh_mcp_tools("tu-mcp", user=_fake_user()) + assert resp.discovered_tools == [] + assert resp.error # non-empty human-readable message + # update_tool should NOT be called when discovery returns empty + tool_mod.db_client.update_tool.assert_not_called() + + +@pytest.mark.asyncio +async def test_refresh_non_mcp_is_400(monkeypatch): + import api.routes.tool as tool_mod + + tool = _mcp_tool_model() + tool.category = "http_api" + monkeypatch.setattr( + tool_mod.db_client, "get_tool_by_uuid", AsyncMock(return_value=tool) + ) + with pytest.raises(HTTPException) as ei: + await refresh_mcp_tools("tu-mcp", user=_fake_user()) + assert ei.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_refresh_not_found_is_404(monkeypatch): + import api.routes.tool as tool_mod + + monkeypatch.setattr( + tool_mod.db_client, "get_tool_by_uuid", AsyncMock(return_value=None) + ) + with pytest.raises(HTTPException) as ei: + await refresh_mcp_tools("nope", user=_fake_user()) + assert ei.value.status_code == 404 diff --git a/api/tests/test_mcp_tool_session.py b/api/tests/test_mcp_tool_session.py new file mode 100644 index 0000000..eab3d5c --- /dev/null +++ b/api/tests/test_mcp_tool_session.py @@ -0,0 +1,274 @@ +from datetime import timedelta +from unittest.mock import MagicMock + +import httpx +import pytest + +from api.services.workflow.mcp_tool_session import ( + McpToolSession, + build_streamable_http_params, + discover_mcp_tools, +) +from api.tests.support.mcp_mock_server import running_mcp_server + + +@pytest.mark.asyncio +async def test_mock_server_starts_and_serves(): + async with running_mcp_server() as base_url: + async with httpx.AsyncClient() as client: + resp = await client.get(base_url, timeout=5.0) + assert resp.status_code in (400, 404, 405, 406) + + +def test_build_streamable_http_params_with_credential(): + cred = MagicMock() + cred.credential_type = "bearer_token" + cred.credential_data = {"token": "abc"} + params = build_streamable_http_params( + url="https://acme.example.com/mcp", + credential=cred, + timeout_secs=30, + sse_read_timeout_secs=300, + ) + assert params.url == "https://acme.example.com/mcp" + assert params.headers == {"Authorization": "Bearer abc"} + assert params.timeout == timedelta(seconds=30) + assert params.sse_read_timeout == timedelta(seconds=300) + + +def test_build_streamable_http_params_no_credential(): + params = build_streamable_http_params( + url="https://acme.example.com/mcp", + credential=None, + timeout_secs=10, + sse_read_timeout_secs=20, + ) + assert params.headers is None or params.headers == {} + + +@pytest.mark.asyncio +async def test_session_start_passes_auth_header_to_real_server(): + cred = MagicMock() + cred.credential_type = "bearer_token" + cred.credential_data = {"token": "abc"} + + async with running_mcp_server( + required_headers={"Authorization": "Bearer abc"} + ) as base_url: + session = McpToolSession( + tool_uuid="uuid-auth-ok", + tool_name="Secure MCP", + url=base_url, + credential=cred, + tools_filter=[], + timeout_secs=10, + sse_read_timeout_secs=20, + ) + await session.start() + try: + assert session.available is True + names = sorted(s.name for s in session.function_schemas()) + assert names == ["mcp__secure_mcp__add", "mcp__secure_mcp__echo"] + result = await session.call("mcp__secure_mcp__echo", {"text": "hi"}) + assert "echo:hi" in result + finally: + await session.close() + + +@pytest.mark.asyncio +async def test_session_auth_failure_degrades_not_raises(): + async with running_mcp_server( + required_headers={"Authorization": "Bearer abc"} + ) as base_url: + session = McpToolSession( + tool_uuid="uuid-auth-fail", + tool_name="Secure MCP", + url=base_url, + credential=None, + tools_filter=[], + timeout_secs=2, + sse_read_timeout_secs=2, + ) + await session.start() # must degrade instead of raising on 401 + try: + assert session.available is False + assert session.function_schemas() == [] + finally: + await session.close() + + +@pytest.mark.asyncio +async def test_session_start_lists_and_calls_real_server(): + async with running_mcp_server() as base_url: + session = McpToolSession( + tool_uuid="uuid-1234abcd", + tool_name="Acme MCP", + url=base_url, + credential=None, + tools_filter=[], + timeout_secs=10, + sse_read_timeout_secs=20, + ) + await session.start() + try: + assert session.available is True + schemas = session.function_schemas() + names = sorted(s.name for s in schemas) + assert names == ["mcp__acme_mcp__add", "mcp__acme_mcp__echo"] + result = await session.call("mcp__acme_mcp__echo", {"text": "hi"}) + assert "echo:hi" in result + finally: + await session.close() + + +@pytest.mark.asyncio +async def test_session_tools_filter_applied(): + async with running_mcp_server() as base_url: + session = McpToolSession( + tool_uuid="uuid-1234abcd", + tool_name="Acme MCP", + url=base_url, + credential=None, + tools_filter=["echo"], + timeout_secs=10, + sse_read_timeout_secs=20, + ) + await session.start() + try: + names = sorted(s.name for s in session.function_schemas()) + assert names == ["mcp__acme_mcp__echo"] + finally: + await session.close() + + +@pytest.mark.asyncio +async def test_session_unreachable_degrades_not_raises(): + session = McpToolSession( + tool_uuid="uuid-1234abcd", + tool_name="Acme MCP", + url="http://127.0.0.1:1/mcp", + credential=None, + tools_filter=[], + timeout_secs=2, + sse_read_timeout_secs=2, + ) + await session.start() # must NOT raise + assert session.available is False + assert session.function_schemas() == [] + await session.close() + + +@pytest.mark.asyncio +async def test_call_on_unavailable_session_raises(): + session = McpToolSession( + tool_uuid="uuid-1234abcd", + tool_name="Acme MCP", + url="http://127.0.0.1:1/mcp", + credential=None, + tools_filter=[], + timeout_secs=2, + sse_read_timeout_secs=2, + ) + await session.start() + with pytest.raises(RuntimeError): + await session.call("mcp__acme_mcp__echo", {"text": "x"}) + await session.close() + + +@pytest.mark.asyncio +async def test_call_unknown_function_raises(): + async with running_mcp_server() as base_url: + session = McpToolSession( + tool_uuid="uuid-1234abcd", + tool_name="Acme MCP", + url=base_url, + credential=None, + tools_filter=[], + timeout_secs=10, + sse_read_timeout_secs=10, + ) + await session.start() + try: + with pytest.raises(RuntimeError): + await session.call("mcp__acme_mcp__does_not_exist", {}) + finally: + await session.close() + + +@pytest.mark.asyncio +async def test_function_schemas_filter_by_raw_name(): + async with running_mcp_server() as base_url: + session = McpToolSession( + tool_uuid="t-filter", + tool_name="Mock MCP", + url=base_url, + credential=None, + tools_filter=[], + timeout_secs=10, + sse_read_timeout_secs=10, + ) + await session.start() + try: + # No arg = all (backward compatible) + all_names = sorted(s.name for s in session.function_schemas()) + assert all_names == ["mcp__mock_mcp__add", "mcp__mock_mcp__echo"] + + # Allow only raw "echo" + only_echo = session.function_schemas(allowed_raw_names={"echo"}) + assert [s.name for s in only_echo] == ["mcp__mock_mcp__echo"] + + # Empty set = none (default-none semantics) + assert session.function_schemas(allowed_raw_names=set()) == [] + + # Unknown raw name = skipped (pure intersection) + assert session.function_schemas(allowed_raw_names={"nope"}) == [] + finally: + await session.close() + + +@pytest.mark.asyncio +async def test_discover_mcp_tools_success(): + async with running_mcp_server() as base_url: + tools = await discover_mcp_tools( + url=base_url, + credential=None, + timeout_secs=10, + sse_read_timeout_secs=10, + ) + names = sorted(t["name"] for t in tools) + assert names == ["add", "echo"] + by_name = {t["name"]: t for t in tools} + assert by_name["echo"]["description"] # non-empty description + assert set(by_name["echo"]) == {"name", "description"} + + +@pytest.mark.asyncio +async def test_discover_mcp_tools_server_down_returns_empty(): + # Unroutable port, short timeouts: must degrade to [] (never raise). + tools = await discover_mcp_tools( + url="http://127.0.0.1:1/mcp", + credential=None, + timeout_secs=1, + sse_read_timeout_secs=1, + ) + assert tools == [] + + +def test_agent_node_data_carries_mcp_tool_filters(): + from api.services.workflow.dto import AgentNodeData, NodeType + from api.services.workflow.workflow_graph import Node + + data = AgentNodeData( + name="N1", + tool_uuids=["tu-1"], + mcp_tool_filters={"tu-1": ["echo"]}, + ) + assert data.mcp_tool_filters == {"tu-1": ["echo"]} + + node = Node("n1", NodeType.agentNode, data) + assert node.mcp_tool_filters == {"tu-1": ["echo"]} + + # Absent field defaults to None (backward compatible) + data2 = AgentNodeData(name="N2") + assert data2.mcp_tool_filters is None + assert Node("n2", NodeType.agentNode, data2).mcp_tool_filters is None diff --git a/api/tests/test_message_sanitization.py b/api/tests/test_message_sanitization.py new file mode 100644 index 0000000..2d2ef66 --- /dev/null +++ b/api/tests/test_message_sanitization.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from copy import deepcopy + +from pipecat.utils.context.message_sanitization import ( + strip_thought_from_id, + strip_thought_ids_from_messages, +) + + +def test_strip_thought_from_id(): + assert strip_thought_from_id("call_123__thought__abc") == "call_123" + assert strip_thought_from_id("call_123") == "call_123" + assert strip_thought_from_id(None) is None + + +def test_strip_thought_ids_from_messages_does_not_mutate_input(): + messages = [ + { + "role": "assistant", + "tool_calls": [ + { + "id": "call_1__thought__hidden", + "type": "function", + "function": {"name": "lookup", "arguments": "{}"}, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_1__thought__hidden", + "content": '{"status":"ok"}', + }, + ] + original = deepcopy(messages) + + cleaned = strip_thought_ids_from_messages(messages) + + assert messages == original + assert cleaned is not messages + assert cleaned[0]["tool_calls"][0]["id"] == "call_1" + assert cleaned[1]["tool_call_id"] == "call_1" diff --git a/api/tests/test_minimax_service_factory.py b/api/tests/test_minimax_service_factory.py new file mode 100644 index 0000000..207e39f --- /dev/null +++ b/api/tests/test_minimax_service_factory.py @@ -0,0 +1,127 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from pipecat.services.minimax.llm import MiniMaxLLMService as RealMiniMaxLLMService + +from api.services.configuration.registry import ( + MiniMaxLLMConfiguration, + MiniMaxTTSConfiguration, + ServiceProviders, +) +from api.services.pipecat.service_factory import ( + create_llm_service_from_provider, + create_tts_service, +) + + +class TestMiniMaxLLMConfiguration: + def test_default_values(self): + config = MiniMaxLLMConfiguration(api_key="test-key") + assert config.provider == ServiceProviders.MINIMAX + assert config.model == "MiniMax-M2.7" + assert config.base_url == "https://api.minimax.io/v1" + + def test_custom_model(self): + config = MiniMaxLLMConfiguration( + api_key="test-key", model="MiniMax-M2.7-highspeed" + ) + assert config.model == "MiniMax-M2.7-highspeed" + + def test_custom_base_url(self): + config = MiniMaxLLMConfiguration( + api_key="test-key", base_url="https://api.minimaxi.com/v1" + ) + assert config.base_url == "https://api.minimaxi.com/v1" + + +class TestMiniMaxTTSConfiguration: + def test_default_values(self): + config = MiniMaxTTSConfiguration(api_key="test-key", group_id="test-group") + assert config.provider == ServiceProviders.MINIMAX + assert config.model == "speech-2.8-hd" + assert config.voice == "English_Graceful_Lady" + assert config.speed == 1.0 + assert config.group_id == "test-group" + + +class TestMiniMaxLLMServiceFactory: + def test_create_minimax_llm_service_uses_openai_compatible(self): + with patch( + "api.services.pipecat.service_factory.MiniMaxLLMService" + ) as mock_service: + mock_service.Settings = RealMiniMaxLLMService.Settings + create_llm_service_from_provider( + provider=ServiceProviders.MINIMAX.value, + model="MiniMax-M2.7", + api_key="test-key", + ) + + assert mock_service.call_count == 1 + kwargs = mock_service.call_args.kwargs + assert kwargs["api_key"] == "test-key" + assert kwargs["base_url"] == "https://api.minimax.io/v1" + assert kwargs["settings"].model == "MiniMax-M2.7" + assert kwargs["settings"].temperature == 1.0 + + def test_create_minimax_llm_service_custom_base_url(self): + with patch( + "api.services.pipecat.service_factory.MiniMaxLLMService" + ) as mock_service: + mock_service.Settings = RealMiniMaxLLMService.Settings + create_llm_service_from_provider( + provider=ServiceProviders.MINIMAX.value, + model="MiniMax-M2.7-highspeed", + api_key="test-key", + base_url="https://api.minimaxi.com/v1", + ) + + kwargs = mock_service.call_args.kwargs + assert kwargs["base_url"] == "https://api.minimaxi.com/v1" + assert kwargs["settings"].model == "MiniMax-M2.7-highspeed" + + def test_create_minimax_llm_service_passes_user_temperature(self): + with patch( + "api.services.pipecat.service_factory.MiniMaxLLMService" + ) as mock_service: + mock_service.Settings = RealMiniMaxLLMService.Settings + create_llm_service_from_provider( + provider=ServiceProviders.MINIMAX.value, + model="MiniMax-M2.7", + api_key="test-key", + temperature=0.3, + ) + kwargs = mock_service.call_args.kwargs + assert kwargs["settings"].temperature == 0.3 + + +class TestMiniMaxTTSServiceFactory: + def test_create_minimax_tts_service(self): + user_config = SimpleNamespace( + tts=SimpleNamespace( + provider=ServiceProviders.MINIMAX.value, + api_key="test-key", + model="speech-2.8-hd", + voice="English_Graceful_Lady", + speed=1.0, + base_url="https://api.minimax.io/v1", + group_id="test-group", + ) + ) + audio_config = SimpleNamespace(transport_in_sample_rate=16000) + + with ( + patch("api.services.pipecat.service_factory.aiohttp.ClientSession"), + patch( + "api.services.pipecat.service_factory.MiniMaxOwnedSessionTTSService" + ) as mock_service, + ): + create_tts_service(user_config, audio_config) + + assert mock_service.call_count == 1 + kwargs = mock_service.call_args.kwargs + assert kwargs["api_key"] == "test-key" + assert kwargs["group_id"] == "test-group" + assert kwargs["settings"].model == "speech-2.8-hd" + assert kwargs["settings"].voice == "English_Graceful_Lady" + assert kwargs["settings"].speed == 1.0 + assert kwargs["aiohttp_session"] is not None diff --git a/api/tests/test_node_specs.py b/api/tests/test_node_specs.py index 110927b..b4797df 100644 --- a/api/tests/test_node_specs.py +++ b/api/tests/test_node_specs.py @@ -14,7 +14,12 @@ import re import pytest -from api.services.workflow.dto import NodeType, ReactFlowDTO +from api.services.workflow.dto import ( + ReactFlowDTO, + all_node_type_names, + get_node_data_model, +) +from api.services.workflow.node_data import BaseNodeData from api.services.workflow.node_specs import ( NodeSpec, PropertySpec, @@ -118,9 +123,9 @@ def test_fixed_collection_has_sub_properties(spec: NodeSpec): @pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name) def test_spec_name_matches_dto_discriminator(spec: NodeSpec): - valid_names = {t.value for t in NodeType} + valid_names = all_node_type_names() assert spec.name in valid_names, ( - f"NodeSpec {spec.name!r} doesn't match any NodeType discriminator. " + f"NodeSpec {spec.name!r} doesn't match any registered node type. " f"Valid: {sorted(valid_names)}" ) @@ -187,10 +192,226 @@ def test_examples_validate_against_dto(spec: NodeSpec): def test_all_dto_types_have_specs(): - """Every NodeType discriminator value must have a registered NodeSpec — - catches the case where someone adds a new node type to dto.py but - forgets to author a spec.""" + """Every registered node type must have a registered NodeSpec.""" spec_names = {s.name for s in all_specs()} - type_values = {t.value for t in NodeType} + type_values = all_node_type_names() missing = type_values - spec_names - assert not missing, f"NodeType discriminators without specs: {sorted(missing)}" + assert not missing, f"Registered node types without specs: {sorted(missing)}" + + +def test_all_registered_node_models_inherit_base_node_data(): + for type_name in sorted(all_node_type_names()): + data_model = get_node_data_model(type_name) + assert data_model is not None, f"{type_name}: missing node data model" + assert issubclass(data_model, BaseNodeData), ( + f"{type_name}: node data model must inherit BaseNodeData" + ) + + +@pytest.mark.parametrize( + ("spec_name", "expected_order"), + [ + ( + "startCall", + [ + "name", + "greeting_type", + "greeting", + "greeting_recording_id", + "prompt", + "allow_interrupt", + "add_global_prompt", + "delayed_start", + "delayed_start_duration", + "extraction_enabled", + "extraction_prompt", + "extraction_variables", + "tool_uuids", + "document_uuids", + "pre_call_fetch_enabled", + "pre_call_fetch_url", + "pre_call_fetch_credential_uuid", + ], + ), + ( + "agentNode", + [ + "name", + "prompt", + "allow_interrupt", + "add_global_prompt", + "extraction_enabled", + "extraction_prompt", + "extraction_variables", + "tool_uuids", + "document_uuids", + ], + ), + ( + "endCall", + [ + "name", + "prompt", + "add_global_prompt", + "extraction_enabled", + "extraction_prompt", + "extraction_variables", + ], + ), + ("globalNode", ["name", "prompt"]), + ("trigger", ["name", "enabled", "trigger_path"]), + ( + "webhook", + [ + "name", + "enabled", + "http_method", + "endpoint_url", + "credential_uuid", + "custom_headers", + "payload_template", + ], + ), + ( + "qa", + [ + "name", + "qa_enabled", + "qa_system_prompt", + "qa_min_call_duration", + "qa_voicemail_calls", + "qa_sample_rate", + "qa_use_workflow_llm", + "qa_provider", + "qa_model", + "qa_api_key", + "qa_endpoint", + ], + ), + ( + "tuner", + [ + "name", + "tuner_enabled", + "tuner_agent_id", + "tuner_workspace_id", + "tuner_api_key", + ], + ), + ], +) +def test_node_spec_property_order_stable(spec_name: str, expected_order: list[str]): + spec = next(spec for spec in all_specs() if spec.name == spec_name) + assert [prop.name for prop in spec.properties] == expected_order + + +# ───────────────────────────────────────────────────────────────────────── +# `to_mcp_dict` projection — the lean view served by the `get_node_type` +# MCP tool. UI-only metadata is dropped so it doesn't poison LLM context; +# the full spec stays available to the frontend and SDK via other paths. +# ───────────────────────────────────────────────────────────────────────── + +# Keys that are UI-rendering concerns and must never reach the LLM view, at +# either the node or property level. +_UI_ONLY_KEYS = frozenset( + { + "display_name", + "icon", + "category", + "version", + "placeholder", + "display_options", + "editor", + "extra", + "label", # PropertyOption display string + } +) + + +def _walk_dicts(node): + """Yield every dict nested anywhere inside a projected structure.""" + if isinstance(node, dict): + yield node + for value in node.values(): + yield from _walk_dicts(value) + elif isinstance(node, list): + for item in node: + yield from _walk_dicts(item) + + +@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name) +def test_to_mcp_dict_drops_ui_only_keys(spec: NodeSpec): + projected = spec.to_mcp_dict() + for d in _walk_dicts(projected): + leaked = _UI_ONLY_KEYS & d.keys() + assert not leaked, f"{spec.name}: UI-only keys leaked into LLM view: {leaked}" + + +@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name) +def test_to_mcp_dict_omits_null_and_empty(spec: NodeSpec): + """The lean view never emits null values — absent means unset/optional, + which is what halves the noise versus the full model dump.""" + for d in _walk_dicts(spec.to_mcp_dict()): + for key, value in d.items(): + assert value is not None, f"{spec.name}: {key!r} emitted as null" + + +@pytest.mark.parametrize("spec", all_specs(), ids=lambda s: s.name) +def test_to_mcp_dict_keeps_property_essentials(spec: NodeSpec): + """Every property in the LLM view carries the minimum an LLM needs to + author a value: machine name, type, and a description.""" + + def _check(props: list[dict]): + for prop in props: + assert prop.get("name"), f"{spec.name}: property missing name" + assert prop.get("type"), f"{spec.name}.{prop.get('name')}: missing type" + assert prop.get("description"), ( + f"{spec.name}.{prop.get('name')}: missing description" + ) + if prop.get("properties"): + _check(prop["properties"]) + + _check(spec.to_mcp_dict()["properties"]) + + +def test_to_mcp_dict_retains_authoring_signal_startcall(): + """startCall is the richest core node — lock in that the projection + keeps the fields an LLM actually authors against while shedding the rest.""" + spec = next(s for s in all_specs() if s.name == "startCall") + projected = spec.to_mcp_dict() + + assert set(projected) == { + "name", + "description", + "llm_hint", + "properties", + "examples", + "graph_constraints", + } + + props = {p["name"]: p for p in projected["properties"]} + + # Required field keeps `required`; optional fields omit it. + assert props["prompt"]["required"] is True + assert "required" not in props["greeting"] + + # Enum options project to bare values, dropping the UI label. + assert props["greeting_type"]["options"] == [{"value": "text"}, {"value": "audio"}] + + # Validation bounds survive (they constrain valid authored values). + assert props["delayed_start_duration"]["min_value"] == 0.1 + assert props["delayed_start_duration"]["max_value"] == 10.0 + + # llm_hint survives where present (catalog-tool references). + assert "list_recordings" in props["greeting_recording_id"]["llm_hint"] + + # fixed_collection rows recurse through the same projection. + var_rows = {p["name"]: p for p in props["extraction_variables"]["properties"]} + assert var_rows["type"]["options"] == [ + {"value": "string"}, + {"value": "number"}, + {"value": "boolean"}, + ] + + # graph_constraints drops its null sub-fields. + assert projected["graph_constraints"] == {"min_incoming": 0, "max_incoming": 0} diff --git a/api/tests/test_openai_realtime_initial_context.py b/api/tests/test_openai_realtime_initial_context.py new file mode 100644 index 0000000..214d0f6 --- /dev/null +++ b/api/tests/test_openai_realtime_initial_context.py @@ -0,0 +1,98 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from pipecat.frames.frames import TTSSpeakFrame +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.frame_processor import FrameDirection + +from api.services.pipecat.realtime.openai_realtime import ( + DograhOpenAIRealtimeLLMService, +) + + +def _make_service() -> DograhOpenAIRealtimeLLMService: + service = DograhOpenAIRealtimeLLMService(api_key="test-key") + service._create_response = AsyncMock() + service._process_completed_function_calls = AsyncMock() + return service + + +@pytest.mark.asyncio +async def test_initial_context_triggers_response_when_context_was_prepopulated(): + service = _make_service() + context = LLMContext() + service._context = context + + await service._handle_context(context) + + assert service._handled_initial_context is True + assert service._context is context + service._create_response.assert_awaited_once() + service._process_completed_function_calls.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_updated_context_uses_tool_result_path_after_initial_context(): + service = _make_service() + context = LLMContext() + service._handled_initial_context = True + + await service._handle_context(context) + + assert service._context is context + service._create_response.assert_not_awaited() + service._process_completed_function_calls.assert_awaited_once_with( + send_new_results=True + ) + + +@pytest.mark.asyncio +async def test_tts_greeting_uses_initial_context_handler(): + service = _make_service() + service._context = LLMContext() + service._handle_context = AsyncMock() + + await service.process_frame( + TTSSpeakFrame("hello", append_to_context=True), + FrameDirection.DOWNSTREAM, + ) + + service._handle_context.assert_awaited_once_with(service._context) + service._create_response.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_function_call_executes_immediately_when_bot_is_not_speaking(): + service = _make_service() + service._context = LLMContext() + service.run_function_calls = AsyncMock() + service._pending_function_calls["call-1"] = SimpleNamespace(name="customer_support") + + await service._handle_evt_function_call_arguments_done( + SimpleNamespace(call_id="call-1", arguments='{"department":"sales"}') + ) + + service.run_function_calls.assert_awaited_once() + assert service._deferred_function_calls == [] + + +@pytest.mark.asyncio +async def test_function_call_is_deferred_until_bot_stops_speaking(): + service = _make_service() + service._context = LLMContext() + service.run_function_calls = AsyncMock() + service._bot_is_speaking = True + service._pending_function_calls["call-1"] = SimpleNamespace(name="customer_support") + + await service._handle_evt_function_call_arguments_done( + SimpleNamespace(call_id="call-1", arguments='{"department":"sales"}') + ) + + service.run_function_calls.assert_not_awaited() + assert len(service._deferred_function_calls) == 1 + + await service._run_pending_function_calls() + + service.run_function_calls.assert_awaited_once() + assert service._deferred_function_calls == [] diff --git a/api/tests/test_pipecat_engine_end_call.py b/api/tests/test_pipecat_engine_end_call.py index 2ba4570..523ad54 100644 --- a/api/tests/test_pipecat_engine_end_call.py +++ b/api/tests/test_pipecat_engine_end_call.py @@ -45,12 +45,11 @@ from api.enums import ToolCategory from api.services.workflow.dto import ( EdgeDataDTO, EndCallNodeData, - EndCallRFNode, Position, ReactFlowDTO, RFEdgeDTO, + RFNodeDTO, StartCallNodeData, - StartCallRFNode, ) from api.services.workflow.pipecat_engine import PipecatEngine from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager @@ -1014,8 +1013,9 @@ class TestEndCallExtractionBehavior: # Create a workflow where start node has NO extraction dto = ReactFlowDTO( nodes=[ - StartCallRFNode( + RFNodeDTO( id="start", + type="startCall", position=Position(x=0, y=0), data=StartCallNodeData( name="Start Call", @@ -1026,8 +1026,9 @@ class TestEndCallExtractionBehavior: extraction_enabled=False, # No extraction ), ), - EndCallRFNode( + RFNodeDTO( id="end", + type="endCall", position=Position(x=0, y=200), data=EndCallNodeData( name="End Call", diff --git a/api/tests/test_public_agent_routes.py b/api/tests/test_public_agent_routes.py new file mode 100644 index 0000000..a7849fb --- /dev/null +++ b/api/tests/test_public_agent_routes.py @@ -0,0 +1,191 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.routes.public_agent import router + + +def _make_test_app() -> FastAPI: + app = FastAPI() + app.include_router(router) + return app + + +def _active_workflow(*, trigger_path: str | None = None): + nodes = [] + if trigger_path is not None: + nodes.append( + { + "type": "trigger", + "data": {"trigger_path": trigger_path}, + } + ) + + return SimpleNamespace( + id=33, + user_id=99, + organization_id=11, + status="active", + workflow_uuid="workflow-uuid-123", + released_definition=SimpleNamespace( + workflow_json={"nodes": nodes, "edges": []} + ), + ) + + +def _provider(): + return SimpleNamespace( + PROVIDER_NAME="twilio", + WEBHOOK_ENDPOINT="outbound", + validate_config=Mock(return_value=True), + initiate_call=AsyncMock(), + ) + + +def test_trigger_route_executes_as_workflow_owner(): + app = _make_test_app() + client = TestClient(app) + + workflow = _active_workflow(trigger_path="trigger-uuid-123") + provider = _provider() + quota_mock = AsyncMock( + return_value=SimpleNamespace(has_quota=True, error_message="") + ) + + with ( + patch("api.routes.public_agent.db_client") as mock_db, + patch( + "api.routes.public_agent.check_dograh_quota_by_user_id", + new=quota_mock, + ), + patch( + "api.routes.public_agent.get_default_telephony_provider", + new=AsyncMock(return_value=provider), + ), + patch( + "api.routes.public_agent.get_backend_endpoints", + new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")), + ), + ): + mock_db.validate_api_key = AsyncMock( + return_value=SimpleNamespace(id=7, organization_id=11, created_by=22) + ) + mock_db.get_agent_trigger_by_path = AsyncMock( + return_value=SimpleNamespace( + workflow_id=workflow.id, + organization_id=11, + state="active", + ) + ) + mock_db.get_workflow = AsyncMock(return_value=workflow) + mock_db.get_default_telephony_configuration = AsyncMock( + return_value=SimpleNamespace(id=55) + ) + mock_db.create_workflow_run = AsyncMock(return_value=SimpleNamespace(id=501)) + + response = client.post( + "/public/agent/trigger-uuid-123", + headers={"X-API-Key": "test-api-key"}, + json={"phone_number": "+15551234567"}, + ) + + assert response.status_code == 200 + quota_mock.assert_awaited_once_with(workflow.user_id, workflow_id=workflow.id) + mock_db.get_workflow.assert_awaited_once_with(workflow.id, organization_id=11) + + create_kwargs = mock_db.create_workflow_run.await_args.kwargs + assert create_kwargs["workflow_id"] == workflow.id + assert create_kwargs["user_id"] == workflow.user_id + assert create_kwargs["organization_id"] == workflow.organization_id + assert create_kwargs["initial_context"]["agent_uuid"] == "trigger-uuid-123" + assert create_kwargs["initial_context"]["agent_identifier"] == "trigger-uuid-123" + assert create_kwargs["initial_context"]["agent_identifier_type"] == "trigger_path" + assert create_kwargs["initial_context"]["workflow_uuid"] == workflow.workflow_uuid + assert create_kwargs["initial_context"]["api_key_id"] == 7 + assert create_kwargs["initial_context"]["api_key_created_by"] == 22 + + initiate_kwargs = provider.initiate_call.await_args.kwargs + assert initiate_kwargs["workflow_id"] == workflow.id + assert initiate_kwargs["user_id"] == workflow.user_id + + +def test_workflow_uuid_route_uses_scoped_lookup_and_shared_execution(): + app = _make_test_app() + client = TestClient(app) + + workflow = _active_workflow() + provider = _provider() + quota_mock = AsyncMock( + return_value=SimpleNamespace(has_quota=True, error_message="") + ) + + with ( + patch("api.routes.public_agent.db_client") as mock_db, + patch( + "api.routes.public_agent.check_dograh_quota_by_user_id", + new=quota_mock, + ), + patch( + "api.routes.public_agent.get_default_telephony_provider", + new=AsyncMock(return_value=provider), + ), + patch( + "api.routes.public_agent.get_backend_endpoints", + new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")), + ), + ): + mock_db.validate_api_key = AsyncMock( + return_value=SimpleNamespace(id=8, organization_id=11, created_by=22) + ) + mock_db.get_workflow_by_uuid = AsyncMock(return_value=workflow) + mock_db.get_default_telephony_configuration = AsyncMock( + return_value=SimpleNamespace(id=55) + ) + mock_db.create_workflow_run = AsyncMock(return_value=SimpleNamespace(id=601)) + + response = client.post( + f"/public/agent/workflow/{workflow.workflow_uuid}", + headers={"X-API-Key": "test-api-key"}, + json={"phone_number": "+15551234567"}, + ) + + assert response.status_code == 200 + mock_db.get_workflow_by_uuid.assert_awaited_once_with( + workflow.workflow_uuid, + 11, + ) + assert not mock_db.get_agent_trigger_by_path.called + + create_kwargs = mock_db.create_workflow_run.await_args.kwargs + assert create_kwargs["user_id"] == workflow.user_id + assert ( + create_kwargs["initial_context"]["agent_identifier"] == workflow.workflow_uuid + ) + assert create_kwargs["initial_context"]["agent_identifier_type"] == "workflow_uuid" + assert "agent_uuid" not in create_kwargs["initial_context"] + + +def test_workflow_uuid_route_rejects_archived_workflows(): + app = _make_test_app() + client = TestClient(app) + + workflow = _active_workflow() + workflow.status = "archived" + + with patch("api.routes.public_agent.db_client") as mock_db: + mock_db.validate_api_key = AsyncMock( + return_value=SimpleNamespace(id=9, organization_id=11, created_by=22) + ) + mock_db.get_workflow_by_uuid = AsyncMock(return_value=workflow) + + response = client.post( + f"/public/agent/workflow/{workflow.workflow_uuid}", + headers={"X-API-Key": "test-api-key"}, + json={"phone_number": "+15551234567"}, + ) + + assert response.status_code == 404 + assert response.json()["detail"] == "Workflow is not active" + assert not mock_db.create_workflow_run.called diff --git a/api/tests/test_realtime_feedback_events.py b/api/tests/test_realtime_feedback_events.py new file mode 100644 index 0000000..95c0859 --- /dev/null +++ b/api/tests/test_realtime_feedback_events.py @@ -0,0 +1,53 @@ +from api.services.pipecat.realtime_feedback_events import ( + build_bot_text_event, + build_function_call_end_event, + build_node_transition_event, + realtime_feedback_event_sort_key, + stamp_realtime_feedback_event, +) + + +def test_build_function_call_end_event_serializes_results(): + event = build_function_call_end_event( + function_name="lookup_contact", + tool_call_id="tool-1", + result={"contact_id": 42}, + ) + + assert event == { + "type": "rtf-function-call-end", + "payload": { + "function_name": "lookup_contact", + "tool_call_id": "tool-1", + "result": "{'contact_id': 42}", + }, + } + + +def test_stamp_and_sort_realtime_feedback_events(): + node_transition = stamp_realtime_feedback_event( + build_node_transition_event( + node_id="node-1", + node_name="Greeting", + previous_node_id=None, + previous_node_name=None, + ), + timestamp="2026-01-01T00:00:03+00:00", + turn=0, + node_id="node-1", + node_name="Greeting", + ) + bot_text = stamp_realtime_feedback_event( + build_bot_text_event( + text="Hello there", + timestamp="2026-01-01T00:00:01+00:00", + ), + timestamp="2026-01-01T00:00:02+00:00", + turn=0, + ) + + events = sorted([node_transition, bot_text], key=realtime_feedback_event_sort_key) + + assert events == [bot_text, node_transition] + assert node_transition["node_id"] == "node-1" + assert node_transition["node_name"] == "Greeting" diff --git a/api/tests/test_realtime_message_append.py b/api/tests/test_realtime_message_append.py new file mode 100644 index 0000000..56ec892 --- /dev/null +++ b/api/tests/test_realtime_message_append.py @@ -0,0 +1,59 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from pipecat.frames.frames import LLMMessagesAppendFrame +from pipecat.services.openai.realtime import events + +from api.services.pipecat.realtime.openai_realtime import ( + DograhOpenAIRealtimeLLMService, +) +from api.services.workflow.pipecat_engine_callbacks import UserIdleHandler + + +@pytest.mark.asyncio +async def test_openai_realtime_messages_append_frame_sends_conversation_item(): + service = DograhOpenAIRealtimeLLMService(api_key="test") + service._api_session_ready = True + service.send_client_event = AsyncMock() + service._send_manual_response_create = AsyncMock() + + await service._handle_messages_append( + LLMMessagesAppendFrame( + [{"role": "user", "content": "Are you still there?"}], + run_llm=True, + ) + ) + + service.send_client_event.assert_awaited_once() + event = service.send_client_event.await_args.args[0] + assert isinstance(event, events.ConversationItemCreateEvent) + assert event.item.role == "user" + assert event.item.type == "message" + assert event.item.content == [ + events.ItemContent(type="input_text", text="Are you still there?") + ] + service._send_manual_response_create.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_user_idle_handler_uses_realtime_append_path(): + engine = SimpleNamespace( + llm=SimpleNamespace(), + end_call_with_reason=AsyncMock(), + ) + aggregator = SimpleNamespace(push_frame=AsyncMock()) + handler = UserIdleHandler(engine) + + await handler.handle_idle(aggregator) + + aggregator.push_frame.assert_awaited_once() + frame = aggregator.push_frame.await_args.args[0] + assert isinstance(frame, LLMMessagesAppendFrame) + assert frame.run_llm is True + assert frame.messages == [ + { + "role": "user", + "content": "The user has been quiet. Politely and briefly ask if they're still there in the language that the user has been speaking so far.", + } + ] diff --git a/api/tests/test_resolve_effective_config.py b/api/tests/test_resolve_effective_config.py index 5d37058..c747387 100644 --- a/api/tests/test_resolve_effective_config.py +++ b/api/tests/test_resolve_effective_config.py @@ -14,7 +14,10 @@ from api.services.configuration.registry import ( DeepgramSTTConfiguration, ElevenlabsTTSConfiguration, GoogleRealtimeLLMConfiguration, + GoogleVertexLLMConfiguration, + GrokRealtimeLLMConfiguration, OpenAILLMService, + UltravoxRealtimeLLMConfiguration, ) from api.services.configuration.resolve import resolve_effective_config @@ -164,6 +167,23 @@ class TestProviderChange: assert result.tts.provider == "elevenlabs" assert result.stt.provider == "deepgram" + def test_override_llm_to_google_vertex(self, global_config): + result = resolve_effective_config( + global_config, + { + "llm": { + "provider": "google_vertex", + "model": "gemini-2.5-flash", + "project_id": "demo-project", + "location": "us-east4", + "credentials": '{"type":"service_account"}', + } + }, + ) + assert isinstance(result.llm, GoogleVertexLLMConfiguration) + assert result.llm.provider == "google_vertex" + assert result.llm.project_id == "demo-project" + # --------------------------------------------------------------------------- # API key inheritance @@ -226,6 +246,38 @@ class TestRealtimeOverride: assert result.realtime.provider == "google_realtime" # inherited assert result.realtime.api_key == "goog-global-rt" # inherited + def test_switch_realtime_provider_to_grok(self, global_config_realtime): + result = resolve_effective_config( + global_config_realtime, + { + "realtime": { + "provider": "grok_realtime", + "api_key": "xai-key", + "model": "grok-voice-think-fast-1.0", + "voice": "Sal", + } + }, + ) + assert isinstance(result.realtime, GrokRealtimeLLMConfiguration) + assert result.realtime.provider == "grok_realtime" + assert result.realtime.voice == "Sal" + + def test_switch_realtime_provider_to_ultravox(self, global_config_realtime): + result = resolve_effective_config( + global_config_realtime, + { + "realtime": { + "provider": "ultravox_realtime", + "api_key": "ultra-key", + "model": "ultravox-v0.7", + "voice": "Mark", + } + }, + ) + assert isinstance(result.realtime, UltravoxRealtimeLLMConfiguration) + assert result.realtime.provider == "ultravox_realtime" + assert result.realtime.voice == "Mark" + def test_override_is_realtime_only_without_realtime_section(self, global_config): """Override is_realtime=True but provide no realtime config. Should set the flag; realtime section stays None from global.""" diff --git a/api/tests/test_run_pipeline_realtime_turn_config.py b/api/tests/test_run_pipeline_realtime_turn_config.py new file mode 100644 index 0000000..0ec07bd --- /dev/null +++ b/api/tests/test_run_pipeline_realtime_turn_config.py @@ -0,0 +1,74 @@ +from pipecat.audio.vad.silero import SileroVADAnalyzer +from pipecat.turns.user_start import ( + ExternalUserTurnStartStrategy, +) +from pipecat.turns.user_start.vad_user_turn_start_strategy import ( + VADUserTurnStartStrategy, +) +from pipecat.turns.user_stop import ( + ExternalUserTurnStopStrategy, + SpeechTimeoutUserTurnStopStrategy, +) + +from api.services.configuration.registry import ServiceProviders +from api.services.pipecat.run_pipeline import _create_realtime_user_turn_config + + +def test_gemini_realtime_uses_local_vad_without_local_interruptions(): + strategies, vad_analyzer = _create_realtime_user_turn_config( + ServiceProviders.GOOGLE_REALTIME.value + ) + + assert isinstance(vad_analyzer, SileroVADAnalyzer) + assert len(strategies.start) == 1 + assert isinstance(strategies.start[0], VADUserTurnStartStrategy) + assert strategies.start[0]._enable_interruptions is False + assert len(strategies.stop) == 1 + assert isinstance(strategies.stop[0], SpeechTimeoutUserTurnStopStrategy) + + +def test_gemini_vertex_realtime_uses_same_turn_config_as_gemini_live(): + strategies, vad_analyzer = _create_realtime_user_turn_config( + ServiceProviders.GOOGLE_VERTEX_REALTIME.value + ) + + assert isinstance(vad_analyzer, SileroVADAnalyzer) + assert len(strategies.start) == 1 + assert isinstance(strategies.start[0], VADUserTurnStartStrategy) + assert strategies.start[0]._enable_interruptions is False + + +def test_openai_realtime_uses_provider_turn_frames_without_local_vad(): + strategies, vad_analyzer = _create_realtime_user_turn_config( + ServiceProviders.OPENAI_REALTIME.value + ) + + assert vad_analyzer is None + assert len(strategies.start) == 1 + assert isinstance(strategies.start[0], ExternalUserTurnStartStrategy) + assert strategies.start[0]._enable_interruptions is False + assert len(strategies.stop) == 1 + assert isinstance(strategies.stop[0], ExternalUserTurnStopStrategy) + + +def test_grok_realtime_uses_provider_turn_frames_without_local_vad(): + strategies, vad_analyzer = _create_realtime_user_turn_config( + ServiceProviders.GROK_REALTIME.value + ) + + assert vad_analyzer is None + assert len(strategies.start) == 1 + assert isinstance(strategies.start[0], ExternalUserTurnStartStrategy) + assert strategies.start[0]._enable_interruptions is False + assert len(strategies.stop) == 1 + assert isinstance(strategies.stop[0], ExternalUserTurnStopStrategy) + + +def test_unknown_realtime_providers_keep_local_vad(): + strategies, vad_analyzer = _create_realtime_user_turn_config("other_realtime") + + assert isinstance(vad_analyzer, SileroVADAnalyzer) + assert len(strategies.start) == 1 + assert isinstance(strategies.start[0], VADUserTurnStartStrategy) + assert len(strategies.stop) == 1 + assert isinstance(strategies.stop[0], SpeechTimeoutUserTurnStopStrategy) diff --git a/api/tests/test_telephony_factory.py b/api/tests/test_telephony_factory.py new file mode 100644 index 0000000..cca9da4 --- /dev/null +++ b/api/tests/test_telephony_factory.py @@ -0,0 +1,96 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from api.services.telephony.factory import ( + get_telephony_provider_for_run, + load_credentials_for_transport, + load_telephony_config_by_id, +) + + +@pytest.mark.asyncio +async def test_get_telephony_provider_for_run_casts_numeric_string_config_id(): + workflow_run = SimpleNamespace( + initial_context={"telephony_configuration_id": "213"} + ) + + with ( + patch( + "api.services.telephony.factory.get_telephony_provider_by_id", + new_callable=AsyncMock, + return_value="provider", + ) as get_provider, + patch( + "api.services.telephony.factory.get_default_telephony_provider", + new_callable=AsyncMock, + ) as get_default, + ): + result = await get_telephony_provider_for_run(workflow_run, 2617) + + assert result == "provider" + get_provider.assert_awaited_once_with("213", 2617) + get_default.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_get_telephony_provider_for_run_rejects_non_numeric_string_config_id(): + workflow_run = SimpleNamespace( + initial_context={"telephony_configuration_id": "twilio-main"} + ) + + with patch( + "api.services.telephony.factory.get_default_telephony_provider", + new_callable=AsyncMock, + ) as get_default: + with pytest.raises( + ValueError, + match="telephony_configuration_id must be an integer", + ): + await get_telephony_provider_for_run(workflow_run, 2617) + + get_default.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_load_credentials_for_transport_casts_numeric_string_config_id(): + with ( + patch( + "api.services.telephony.factory.load_telephony_config_by_id", + new_callable=AsyncMock, + return_value={"provider": "twilio"}, + ) as load_by_id, + patch( + "api.services.telephony.factory.load_default_telephony_config", + new_callable=AsyncMock, + ) as load_default, + ): + result = await load_credentials_for_transport(2617, "213", "twilio") + + assert result == {"provider": "twilio"} + load_by_id.assert_awaited_once_with("213", 2617) + load_default.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_load_telephony_config_by_id_casts_numeric_string_before_db_lookup(): + row = SimpleNamespace(id=213) + + with ( + patch( + "api.services.telephony.factory.db_client.get_telephony_configuration_for_org", + new_callable=AsyncMock, + return_value=row, + ) as get_config, + patch( + "api.services.telephony.factory._normalize_with_phone_numbers", + new_callable=AsyncMock, + return_value={"provider": "twilio"}, + ) as normalize, + ): + result = await load_telephony_config_by_id("213", 2617) + + assert result == {"provider": "twilio"} + get_config.assert_awaited_once_with(213, 2617) + normalize.assert_awaited_once_with(row) diff --git a/api/tests/test_telephony_routes.py b/api/tests/test_telephony_routes.py new file mode 100644 index 0000000..49c2f8d --- /dev/null +++ b/api/tests/test_telephony_routes.py @@ -0,0 +1,158 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock, patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.routes.telephony import router +from api.services.auth.depends import get_user + + +def _make_test_app() -> FastAPI: + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_user] = lambda: SimpleNamespace( + id=7, + selected_organization_id=11, + ) + return app + + +def _workflow(*, workflow_id: int = 33, user_id: int = 99): + return SimpleNamespace( + id=workflow_id, + user_id=user_id, + organization_id=11, + template_context_variables={"template_key": "template-value"}, + ) + + +def _provider(): + return SimpleNamespace( + PROVIDER_NAME="twilio", + WEBHOOK_ENDPOINT="twilio/voice", + validate_config=Mock(return_value=True), + initiate_call=AsyncMock( + return_value=SimpleNamespace( + caller_number="+15550001111", + provider_metadata={"call_id": "call-123"}, + ) + ), + ) + + +def test_initiate_call_executes_as_workflow_owner_for_shared_org_workflow(): + app = _make_test_app() + client = TestClient(app) + + workflow = _workflow() + provider = _provider() + quota_mock = AsyncMock( + return_value=SimpleNamespace(has_quota=True, error_message="") + ) + + with ( + patch("api.routes.telephony.db_client") as mock_db, + patch( + "api.routes.telephony.check_dograh_quota_by_user_id", + new=quota_mock, + ), + patch( + "api.routes.telephony.get_default_telephony_provider", + new=AsyncMock(return_value=provider), + ), + patch( + "api.routes.telephony.get_backend_endpoints", + new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")), + ), + ): + mock_db.get_user_configurations = AsyncMock( + return_value=SimpleNamespace(test_phone_number=None) + ) + mock_db.get_default_telephony_configuration = AsyncMock( + return_value=SimpleNamespace(id=55) + ) + mock_db.get_workflow = AsyncMock(return_value=workflow) + mock_db.create_workflow_run = AsyncMock( + return_value=SimpleNamespace( + id=501, + name="WR-TEL-OUT-00000001", + initial_context={"template_key": "template-value"}, + ) + ) + mock_db.update_workflow_run = AsyncMock() + + response = client.post( + "/telephony/initiate-call", + json={"workflow_id": workflow.id, "phone_number": "+15551234567"}, + ) + + assert response.status_code == 200 + quota_mock.assert_awaited_once_with(workflow.user_id, workflow_id=workflow.id) + mock_db.get_workflow.assert_awaited_once_with(workflow.id, organization_id=11) + + create_call = mock_db.create_workflow_run.await_args + create_args = create_call.args + create_kwargs = create_call.kwargs + assert create_args[1] == workflow.id + assert create_kwargs["user_id"] == workflow.user_id + assert create_kwargs["organization_id"] == workflow.organization_id + assert create_kwargs["initial_context"]["template_key"] == "template-value" + + initiate_kwargs = provider.initiate_call.await_args.kwargs + assert initiate_kwargs["workflow_id"] == workflow.id + assert initiate_kwargs["user_id"] == workflow.user_id + assert "user_id=99" in initiate_kwargs["webhook_url"] + + +def test_initiate_call_rejects_existing_run_for_different_workflow(): + app = _make_test_app() + client = TestClient(app) + + workflow = _workflow() + provider = _provider() + quota_mock = AsyncMock( + return_value=SimpleNamespace(has_quota=True, error_message="") + ) + + with ( + patch("api.routes.telephony.db_client") as mock_db, + patch( + "api.routes.telephony.check_dograh_quota_by_user_id", + new=quota_mock, + ), + patch( + "api.routes.telephony.get_default_telephony_provider", + new=AsyncMock(return_value=provider), + ), + ): + mock_db.get_user_configurations = AsyncMock( + return_value=SimpleNamespace(test_phone_number=None) + ) + mock_db.get_default_telephony_configuration = AsyncMock( + return_value=SimpleNamespace(id=55) + ) + mock_db.get_workflow = AsyncMock(return_value=workflow) + mock_db.get_workflow_run = AsyncMock( + return_value=SimpleNamespace( + id=501, + workflow_id=44, + name="WR-TEL-OUT-00000044", + initial_context={}, + ) + ) + + response = client.post( + "/telephony/initiate-call", + json={ + "workflow_id": workflow.id, + "workflow_run_id": 501, + "phone_number": "+15551234567", + }, + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "workflow_run_workflow_mismatch" + mock_db.get_workflow_run.assert_awaited_once_with(501, organization_id=11) + assert not mock_db.create_workflow_run.called + assert provider.initiate_call.await_count == 0 diff --git a/api/tests/test_text_and_audio_playback.py b/api/tests/test_text_and_audio_playback.py index 39b77aa..3c35af2 100644 --- a/api/tests/test_text_and_audio_playback.py +++ b/api/tests/test_text_and_audio_playback.py @@ -34,12 +34,11 @@ from api.services.pipecat.recording_audio_cache import RecordingAudio from api.services.workflow.dto import ( EdgeDataDTO, EndCallNodeData, - EndCallRFNode, Position, ReactFlowDTO, RFEdgeDTO, + RFNodeDTO, StartCallNodeData, - StartCallRFNode, ) from api.services.workflow.pipecat_engine import PipecatEngine from api.services.workflow.pipecat_engine_custom_tools import CustomToolManager @@ -65,8 +64,9 @@ def text_workflow() -> WorkflowGraph: """Start->End workflow with text greeting and text transition speech.""" dto = ReactFlowDTO( nodes=[ - StartCallRFNode( + RFNodeDTO( id="start", + type="startCall", position=Position(x=0, y=0), data=StartCallNodeData( name="Start Call", @@ -79,8 +79,9 @@ def text_workflow() -> WorkflowGraph: extraction_enabled=False, ), ), - EndCallRFNode( + RFNodeDTO( id="end", + type="endCall", position=Position(x=0, y=200), data=EndCallNodeData( name="End Call", @@ -114,8 +115,9 @@ def audio_workflow() -> WorkflowGraph: """Start->End workflow with audio greeting and audio transition speech.""" dto = ReactFlowDTO( nodes=[ - StartCallRFNode( + RFNodeDTO( id="start", + type="startCall", position=Position(x=0, y=0), data=StartCallNodeData( name="Start Call", @@ -128,8 +130,9 @@ def audio_workflow() -> WorkflowGraph: extraction_enabled=False, ), ), - EndCallRFNode( + RFNodeDTO( id="end", + type="endCall", position=Position(x=0, y=200), data=EndCallNodeData( name="End Call", @@ -290,8 +293,9 @@ class TestStartGreeting: """No greeting configured should return None.""" dto = ReactFlowDTO( nodes=[ - StartCallRFNode( + RFNodeDTO( id="start", + type="startCall", position=Position(x=0, y=0), data=StartCallNodeData( name="Start", @@ -301,8 +305,9 @@ class TestStartGreeting: extraction_enabled=False, ), ), - EndCallRFNode( + RFNodeDTO( id="end", + type="endCall", position=Position(x=0, y=200), data=EndCallNodeData( name="End", @@ -333,8 +338,9 @@ class TestStartGreeting: """Text greeting with {{variable}} placeholders should be rendered.""" dto = ReactFlowDTO( nodes=[ - StartCallRFNode( + RFNodeDTO( id="start", + type="startCall", position=Position(x=0, y=0), data=StartCallNodeData( name="Start", @@ -346,8 +352,9 @@ class TestStartGreeting: extraction_enabled=False, ), ), - EndCallRFNode( + RFNodeDTO( id="end", + type="endCall", position=Position(x=0, y=200), data=EndCallNodeData( name="End", @@ -375,6 +382,105 @@ class TestStartGreeting: result = engine.get_start_greeting() assert result == ("text", "Hello Alice!") + @pytest.mark.asyncio + async def test_queue_node_opening_queues_text_greeting( + self, text_workflow: WorkflowGraph + ): + """Fresh node entry with a greeting should queue TTS and skip LLM bootstrap.""" + llm = Mock() + llm.queue_frame = AsyncMock() + task = Mock() + task.queue_frame = AsyncMock() + + engine = PipecatEngine( + llm=llm, + context=LLMContext(), + workflow=text_workflow, + call_context_vars={}, + workflow_run_id=1, + ) + engine.set_task(task) + + result = await engine.queue_node_opening( + node_id=text_workflow.start_node_id, + previous_node_id=None, + generate_if_no_greeting=True, + ) + + assert result == "greeting" + llm.queue_frame.assert_not_awaited() + queued_frame = task.queue_frame.await_args.args[0] + assert isinstance(queued_frame, TTSSpeakFrame) + assert queued_frame.text == TEXT_GREETING + assert queued_frame.append_to_context is True + + @pytest.mark.asyncio + async def test_queue_node_opening_falls_back_to_llm_without_greeting(self): + """When a node has no greeting, the engine should queue initial LLM generation.""" + dto = ReactFlowDTO( + nodes=[ + RFNodeDTO( + id="start", + type="startCall", + position=Position(x=0, y=0), + data=StartCallNodeData( + name="Start", + prompt="Prompt", + is_start=True, + add_global_prompt=False, + extraction_enabled=False, + ), + ), + RFNodeDTO( + id="end", + type="endCall", + position=Position(x=0, y=200), + data=EndCallNodeData( + name="End", + prompt="End", + is_end=True, + add_global_prompt=False, + extraction_enabled=False, + ), + ), + ], + edges=[ + RFEdgeDTO( + id="e", + source="start", + target="end", + data=EdgeDataDTO(label="End", condition="End"), + ), + ], + ) + workflow = WorkflowGraph(dto) + context = LLMContext() + llm = Mock() + llm.queue_frame = AsyncMock() + task = Mock() + task.queue_frame = AsyncMock() + + engine = PipecatEngine( + llm=llm, + context=context, + workflow=workflow, + call_context_vars={}, + workflow_run_id=1, + ) + engine.set_task(task) + + result = await engine.queue_node_opening( + node_id=workflow.start_node_id, + previous_node_id=None, + generate_if_no_greeting=True, + ) + + assert result == "llm" + task.queue_frame.assert_not_awaited() + queued_frame = llm.queue_frame.await_args.args[0] + assert isinstance(queued_frame, LLMContextFrame) + assert queued_frame.context is context + # ─── Tests: Transition Speech (Pipeline) ──────────────────────── diff --git a/api/tests/test_text_chat_logs.py b/api/tests/test_text_chat_logs.py new file mode 100644 index 0000000..5faf5b2 --- /dev/null +++ b/api/tests/test_text_chat_logs.py @@ -0,0 +1,126 @@ +from api.services.workflow.text_chat_logs import ( + build_text_chat_realtime_feedback_events, + visible_text_chat_turns, +) + + +def test_visible_text_chat_turns_trims_to_cursor_branch(): + session_data = { + "cursor_turn_id": "turn-2", + "turns": [ + {"id": "turn-1"}, + {"id": "turn-2"}, + {"id": "turn-3"}, + ], + } + + assert visible_text_chat_turns(session_data) == [ + {"id": "turn-1"}, + {"id": "turn-2"}, + ] + + +def test_build_text_chat_realtime_feedback_events_uses_visible_branch_and_dedupes_node_transitions(): + session_data = { + "cursor_turn_id": "turn-2", + "turns": [ + { + "id": "turn-1", + "created_at": "2026-01-01T00:00:00+00:00", + "events": [ + { + "type": "node_transition", + "created_at": "2026-01-01T00:00:00+00:00", + "payload": { + "node_id": "node-start", + "node_name": "Start", + "previous_node_id": None, + "previous_node_name": None, + "allow_interrupt": False, + }, + } + ], + "user_message": None, + "assistant_message": { + "text": "Hello", + "created_at": "2026-01-01T00:00:01+00:00", + }, + }, + { + "id": "turn-2", + "created_at": "2026-01-01T00:00:02+00:00", + "events": [ + { + "type": "node_transition", + "created_at": "2026-01-01T00:00:02+00:00", + "payload": { + "node_id": "node-start", + "node_name": "Start", + "previous_node_id": None, + "previous_node_name": None, + "allow_interrupt": False, + }, + }, + { + "type": "tool_call_started", + "created_at": "2026-01-01T00:00:03+00:00", + "payload": { + "function_name": "lookup_contact", + "tool_call_id": "tool-1", + }, + }, + { + "type": "tool_call_result", + "created_at": "2026-01-01T00:00:04+00:00", + "payload": { + "function_name": "lookup_contact", + "tool_call_id": "tool-1", + "result": {"contact_id": 42}, + }, + }, + ], + "user_message": { + "text": "Find Abhishek", + "created_at": "2026-01-01T00:00:02+00:00", + }, + "assistant_message": { + "text": "I found one match.", + "created_at": "2026-01-01T00:00:05+00:00", + }, + }, + { + "id": "turn-3", + "created_at": "2026-01-01T00:00:06+00:00", + "events": [ + { + "type": "execution_error", + "created_at": "2026-01-01T00:00:06+00:00", + "payload": {"message": "Should be hidden after rewind"}, + } + ], + "user_message": { + "text": "This turn is rewound away", + "created_at": "2026-01-01T00:00:06+00:00", + }, + "assistant_message": None, + }, + ], + } + + events = build_text_chat_realtime_feedback_events(session_data) + + assert [event["type"] for event in events] == [ + "rtf-node-transition", + "rtf-bot-text", + "rtf-user-transcription", + "rtf-function-call-start", + "rtf-function-call-end", + "rtf-bot-text", + ] + assert events[0]["payload"]["node_name"] == "Start" + assert events[2]["payload"]["text"] == "Find Abhishek" + assert events[4]["payload"]["result"] == "{'contact_id': 42}" + assert all( + event.get("payload", {}).get("error") != "Should be hidden after rewind" + for event in events + ) diff --git a/api/tests/test_text_chat_session_service.py b/api/tests/test_text_chat_session_service.py new file mode 100644 index 0000000..abbba0e --- /dev/null +++ b/api/tests/test_text_chat_session_service.py @@ -0,0 +1,91 @@ +from unittest.mock import AsyncMock + +import pytest + +import api.services.workflow.text_chat_session_service as text_chat_session_service +from api.db.models import WorkflowRunTextSessionModel +from api.services.workflow.text_chat_session_service import ( + TextChatSessionExecutionError, + TextChatTurnNotFoundError, + _reload_text_chat_session, + build_pending_text_chat_turn, + truncate_text_chat_future_turns, + validate_text_chat_turn_cursor, +) + + +def test_build_pending_text_chat_turn_sets_pending_shape(): + turn = build_pending_text_chat_turn(user_text="Hello") + + assert turn["id"].startswith("turn_") + assert turn["status"] == "pending" + assert turn["user_message"]["text"] == "Hello" + assert turn["assistant_message"] is None + assert turn["events"] == [] + assert turn["usage"] == {} + + +def test_truncate_text_chat_future_turns_moves_rewound_branch_to_discarded_future(): + session_data = { + "cursor_turn_id": "turn-2", + "turns": [ + {"id": "turn-1"}, + {"id": "turn-2"}, + {"id": "turn-3"}, + ], + "discarded_future": [], + } + + active_turns, discarded_future = truncate_text_chat_future_turns(session_data) + + assert active_turns == [{"id": "turn-1"}, {"id": "turn-2"}] + assert discarded_future[0]["rewound_from_turn_id"] == "turn-2" + assert discarded_future[0]["turns"] == [{"id": "turn-3"}] + + +def test_validate_text_chat_turn_cursor_raises_for_missing_turn(): + with pytest.raises(TextChatTurnNotFoundError): + validate_text_chat_turn_cursor( + {"turns": [{"id": "turn-1"}]}, + "turn-404", + ) + + +@pytest.mark.asyncio +async def test_reload_text_chat_session_uses_run_id_to_resolve_organization( + monkeypatch, +): + reloaded_session = WorkflowRunTextSessionModel(workflow_run_id=123) + get_org_id = AsyncMock(return_value=77) + get_text_session = AsyncMock(return_value=reloaded_session) + + monkeypatch.setattr( + text_chat_session_service.db_client, + "get_organization_id_by_workflow_run_id", + get_org_id, + ) + monkeypatch.setattr( + text_chat_session_service.db_client, + "get_workflow_run_text_session", + get_text_session, + ) + + result = await _reload_text_chat_session(123) + + assert result is reloaded_session + get_org_id.assert_awaited_once_with(123) + get_text_session.assert_awaited_once_with(123, organization_id=77) + + +@pytest.mark.asyncio +async def test_reload_text_chat_session_raises_when_run_organization_is_missing( + monkeypatch, +): + monkeypatch.setattr( + text_chat_session_service.db_client, + "get_organization_id_by_workflow_run_id", + AsyncMock(return_value=None), + ) + + with pytest.raises(TextChatSessionExecutionError, match="organization not found"): + await _reload_text_chat_session(123) diff --git a/api/tests/test_trigger_path_validation.py b/api/tests/test_trigger_path_validation.py new file mode 100644 index 0000000..758eceb --- /dev/null +++ b/api/tests/test_trigger_path_validation.py @@ -0,0 +1,56 @@ +from api.services.workflow.trigger_paths import ( + TRIGGER_PATH_MAX_LENGTH, + validate_trigger_paths, +) + + +def test_validate_trigger_paths_rejects_invalid_path_segments(): + workflow_definition = { + "nodes": [ + { + "id": "trigger-1", + "type": "trigger", + "data": {"trigger_path": "support/west"}, + } + ], + "edges": [], + } + + issues = validate_trigger_paths(workflow_definition) + + assert len(issues) == 1 + assert issues[0].node_id == "trigger-1" + assert "single URL path segment" in issues[0].message + + +def test_validate_trigger_paths_rejects_long_and_duplicate_paths(): + long_path = "a" * (TRIGGER_PATH_MAX_LENGTH + 1) + workflow_definition = { + "nodes": [ + { + "id": "trigger-1", + "type": "trigger", + "data": {"trigger_path": long_path}, + }, + { + "id": "trigger-2", + "type": "trigger", + "data": {"trigger_path": "sales_agent"}, + }, + { + "id": "trigger-3", + "type": "trigger", + "data": {"trigger_path": "sales_agent"}, + }, + ], + "edges": [], + } + + issues = validate_trigger_paths(workflow_definition) + messages = [issue.message for issue in issues] + + assert ( + f"Trigger path must be {TRIGGER_PATH_MAX_LENGTH} characters or fewer." + in messages + ) + assert "Trigger path is duplicated in this workflow." in messages diff --git a/api/tests/test_ts_bridge.py b/api/tests/test_ts_bridge.py index 723fe4d..448b7e8 100644 --- a/api/tests/test_ts_bridge.py +++ b/api/tests/test_ts_bridge.py @@ -7,10 +7,19 @@ that code → JSON and JSON → code round-trip losslessly. from __future__ import annotations import shutil +from types import NoneType +from typing import Any, get_args import pytest from api.mcp_server.ts_bridge import TsBridgeError, generate_code, parse_code +from api.services.workflow.dto import EdgeDataDTO +from api.services.workflow.node_specs import ( + NodeSpec, + PropertySpec, + PropertyType, + all_specs, +) pytestmark = pytest.mark.skipif( shutil.which("node") is None, reason="node binary not available" @@ -81,6 +90,102 @@ def _normalize(wf: dict) -> dict: } +def _strip_optional(annotation: Any) -> Any: + args = tuple(arg for arg in get_args(annotation) if arg is not NoneType) + if len(args) == 1: + return args[0] + return annotation + + +def _pick_option_value(prop: PropertySpec) -> Any: + assert prop.options, f"{prop.name} has no options" + default = prop.default + for option in prop.options: + if option.value != default: + return option.value + return prop.options[0].value + + +def _sample_number(prop: PropertySpec) -> int | float: + candidates: list[int | float] = [1, 2, 3, 0.5, 4.5, 10] + for candidate in candidates: + if prop.min_value is not None and candidate < prop.min_value: + continue + if prop.max_value is not None and candidate > prop.max_value: + continue + if prop.default is not None and candidate == prop.default: + continue + return candidate + raise AssertionError(f"No valid sample number found for {prop.name}") + + +def _sample_property_value(prop: PropertySpec, *, path: str) -> Any: + slug = path.replace(".", "_") + + if prop.type == PropertyType.string: + return f"{slug}_value" + if prop.type == PropertyType.mention_textarea: + return f"{slug} prompt with {{name}}" + if prop.type == PropertyType.url: + return f"https://example.com/{slug}" + if prop.type == PropertyType.recording_ref: + return f"recording_{slug}" + if prop.type == PropertyType.credential_ref: + return f"credential_{slug}" + if prop.type == PropertyType.number: + return _sample_number(prop) + if prop.type == PropertyType.boolean: + return not prop.default if isinstance(prop.default, bool) else True + if prop.type == PropertyType.options: + return _pick_option_value(prop) + if prop.type == PropertyType.multi_options: + return [_pick_option_value(prop)] + if prop.type == PropertyType.tool_refs: + return [f"tool_{slug}"] + if prop.type == PropertyType.document_refs: + return [f"document_{slug}"] + if prop.type == PropertyType.json: + return {"kind": slug, "enabled": True} + if prop.type == PropertyType.fixed_collection: + assert prop.properties, f"{prop.name} fixed_collection has no sub-properties" + return [ + { + sub_prop.name: _sample_property_value( + sub_prop, path=f"{path}.{sub_prop.name}" + ) + for sub_prop in prop.properties + } + ] + raise AssertionError(f"Unhandled PropertyType in TS bridge test: {prop.type}") + + +def _sample_node_data(spec: NodeSpec) -> dict[str, Any]: + return { + prop.name: _sample_property_value(prop, path=f"{spec.name}.{prop.name}") + for prop in spec.properties + } + + +def _sample_edge_value(field_name: str, annotation: Any) -> Any: + inner = _strip_optional(annotation) + if inner is str: + return f"{field_name}_value" + if inner is bool: + return True + if inner in (int, float): + return 1 + raise AssertionError( + f"Unhandled edge field annotation in TS bridge test: {field_name} -> {annotation!r}" + ) + + +def _sample_edge_data() -> dict[str, Any]: + return { + field_name: _sample_edge_value(field_name, field.annotation) + for field_name, field in EdgeDataDTO.model_fields.items() + } + + # ─── generate_code ─────────────────────────────────────────────────────── @@ -154,6 +259,19 @@ async def test_generate_strips_unknown_edge_fields(): assert "validationMessage" not in code +@pytest.mark.asyncio +async def test_generate_preserves_all_edge_dto_fields(): + wf = _minimal_workflow() + edge_data = _sample_edge_data() + wf["edges"][0]["data"] = edge_data + + code = await generate_code(wf) + result = await parse_code(code) + + assert result["ok"] is True, result + assert result["workflow"]["edges"][0]["data"] == edge_data + + # ─── parse_code ────────────────────────────────────────────────────────── @@ -229,6 +347,21 @@ wf.edge(a, b, { label: "", condition: "c" }); assert result["stage"] == "parse" +@pytest.mark.asyncio +async def test_parse_rejects_unknown_edge_field(): + code = """import { Workflow } from "@dograh/sdk"; +import { startCall, endCall } from "@dograh/sdk/typed"; +const wf = new Workflow({ name: "x" }); +const a = wf.addTyped(startCall({ name: "g", prompt: "hi" })); +const b = wf.addTyped(endCall({ name: "d", prompt: "bye" })); +wf.edge(a, b, { label: "done", condition: "wrapped", bogus: "x" }); +""" + result = await parse_code(code) + assert result["ok"] is False + assert result["stage"] == "parse" + assert any("Unknown edge field" in e["message"] for e in result["errors"]) + + # ─── Round-trip ────────────────────────────────────────────────────────── @@ -257,6 +390,30 @@ async def test_round_trip_minimal(): ] +@pytest.mark.asyncio +@pytest.mark.parametrize("spec", all_specs(), ids=lambda spec: spec.name) +async def test_round_trip_preserves_all_node_spec_fields(spec: NodeSpec): + data = _sample_node_data(spec) + wf = { + "nodes": [ + { + "id": "1", + "type": spec.name, + "position": {"x": 0, "y": 0}, + "data": data, + } + ], + "edges": [], + "viewport": {"x": 0, "y": 0, "zoom": 1}, + } + + code = await generate_code(wf, workflow_name=f"{spec.name}_rt") + result = await parse_code(code) + + assert result["ok"] is True, result + assert result["workflow"]["nodes"][0]["data"] == data + + @pytest.mark.asyncio async def test_generate_fails_on_unknown_type(): bad = { diff --git a/api/tests/test_ultravox_realtime_wrapper.py b/api/tests/test_ultravox_realtime_wrapper.py new file mode 100644 index 0000000..1034b8d --- /dev/null +++ b/api/tests/test_ultravox_realtime_wrapper.py @@ -0,0 +1,459 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, call + +import pytest +from pipecat.adapters.schemas.function_schema import FunctionSchema +from pipecat.adapters.schemas.tools_schema import ToolsSchema +from pipecat.frames.frames import LLMMessagesAppendFrame, TTSSpeakFrame +from pipecat.processors.aggregators.llm_context import LLMContext +from pipecat.processors.frame_processor import FrameDirection +from websockets.exceptions import ConnectionClosedError +from websockets.frames import Close + +from api.schemas.user_configuration import UserConfiguration +from api.services.configuration.registry import UltravoxRealtimeLLMConfiguration +from api.services.pipecat.realtime.ultravox_realtime import ( + _RESUMPTION_USER_MESSAGE, + DograhUltravoxOneShotInputParams, + DograhUltravoxRealtimeLLMService, +) +from api.services.pipecat.service_factory import create_realtime_llm_service + + +class _ClosingSocket: + def __init__(self, exc): + self._exc = exc + + def __aiter__(self): + return self + + async def __anext__(self): + raise self._exc + + +class _MessageSocket: + def __init__(self, messages): + self._messages = iter(messages) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self._messages) + except StopIteration: + raise StopAsyncIteration + + +def _make_service() -> DograhUltravoxRealtimeLLMService: + service = DograhUltravoxRealtimeLLMService( + params=DograhUltravoxOneShotInputParams( + api_key="test-key", + model="ultravox-v0.7", + output_medium="voice", + ), + settings=DograhUltravoxRealtimeLLMService.Settings( + model="ultravox-v0.7", + output_medium="voice", + ), + ) + service.stop_all_metrics = AsyncMock() + service.cancel_task = AsyncMock() + service.push_error = AsyncMock() + return service + + +def _tool_schema() -> ToolsSchema: + return ToolsSchema( + standard_tools=[ + FunctionSchema( + name="transition_to_next_node", + description="Move to the next workflow node", + properties={"reason": {"type": "string"}}, + required=[], + ) + ] + ) + + +@pytest.mark.asyncio +async def test_tts_greeting_triggers_initial_connect(): + service = _make_service() + service._connect_call = AsyncMock() + + await service.process_frame( + TTSSpeakFrame("Hello there", append_to_context=True), + FrameDirection.DOWNSTREAM, + ) + + service._connect_call.assert_awaited_once() + assert service._connect_call.await_args.kwargs["greeting_text"] == "Hello there" + assert service._connect_call.await_args.kwargs["agent_speaks_first"] is True + + +@pytest.mark.asyncio +async def test_initial_context_connects_without_replay(): + service = _make_service() + service._connect_call = AsyncMock() + context = LLMContext() + + await service._handle_context(context) + + service._connect_call.assert_awaited_once() + assert service._connect_call.await_args.kwargs["initial_messages"] is None + assert service._connect_call.await_args.kwargs["agent_speaks_first"] is True + + +@pytest.mark.asyncio +async def test_system_instruction_update_marks_reconnect_required(): + service = _make_service() + service._has_connected_once = True + + changed = await service._update_settings( + DograhUltravoxRealtimeLLMService.Settings(system_instruction="new instruction") + ) + + assert "system_instruction" in changed + assert service._reconnect_required is True + + +@pytest.mark.asyncio +async def test_system_instruction_change_reconnects_with_full_initial_messages(): + service = _make_service() + service._socket = object() + service._has_connected_once = True + service._call_system_instruction = "old instruction" + service._reconnect_required = True + service._settings.system_instruction = "new instruction" + service._reconnect_with_context = AsyncMock() + + context = LLMContext( + messages=[ + {"role": "user", "content": "I want to hear the pricing."}, + { + "role": "assistant", + "content": "Let me check that for you.", + "tool_calls": [ + { + "id": "call-transition", + "type": "function", + "function": { + "name": "transition_to_next_node", + "arguments": '{"reason":"pricing requested"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call-transition", + "content": '{"status":"done"}', + }, + ], + tools=_tool_schema(), + ) + + await service._handle_context(context) + + service._reconnect_with_context.assert_awaited_once() + initial_messages = service._reconnect_with_context.await_args.kwargs[ + "initial_messages" + ] + assert initial_messages == [ + { + "role": "MESSAGE_ROLE_USER", + "text": "I want to hear the pricing.", + }, + { + "role": "MESSAGE_ROLE_AGENT", + "text": "Let me check that for you.", + }, + { + "role": "MESSAGE_ROLE_TOOL_CALL", + "text": "", + "invocationId": "call-transition", + "toolName": "transition_to_next_node", + }, + { + "role": "MESSAGE_ROLE_TOOL_RESULT", + "text": '{"status":"done"}', + "invocationId": "call-transition", + "toolName": "transition_to_next_node", + }, + ] + assert "call-transition" in service._completed_tool_calls + + +@pytest.mark.asyncio +async def test_tool_context_update_does_not_reconnect_when_system_instruction_is_unchanged(): + service = _make_service() + service._socket = object() + service._call_system_instruction = "same instruction" + service._settings.system_instruction = "same instruction" + service._reconnect_with_context = AsyncMock() + service._send_tool_result = AsyncMock() + + context = LLMContext( + messages=[ + { + "role": "tool", + "tool_call_id": "call-transition", + "content": '{"status":"done"}', + }, + ], + tools=_tool_schema(), + ) + + await service._handle_context(context) + + service._reconnect_with_context.assert_not_awaited() + service._send_tool_result.assert_awaited_once_with( + "call-transition", + '{"status":"done"}', + ) + + +@pytest.mark.asyncio +async def test_messages_append_frame_sends_user_text(): + service = _make_service() + service._socket = object() + service._call_started = True + service._send_user_text = AsyncMock() + + await service._handle_messages_append( + LLMMessagesAppendFrame( + [{"role": "user", "content": "Are you still there?"}], + run_llm=True, + ) + ) + + service._send_user_text.assert_awaited_once_with("Are you still there?") + + +@pytest.mark.asyncio +async def test_messages_append_frame_queues_user_text_until_call_started(): + service = _make_service() + service._socket = object() + service._call_started = False + service._send_user_text = AsyncMock() + + await service._handle_messages_append( + LLMMessagesAppendFrame( + [{"role": "user", "content": "Are you still there?"}], + run_llm=True, + ) + ) + + assert service._pending_user_text_messages == ["Are you still there?"] + service._send_user_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_call_started_flushes_pending_user_text_messages(): + service = _make_service() + service._pending_user_text_messages = [ + "First queued message", + "Second queued message", + ] + service._send_user_text = AsyncMock() + service._socket = _MessageSocket(['{"type":"call_started","callId":"call-123"}']) + + await service._receive_messages() + + assert service._call_started is True + assert service._pending_user_text_messages == [] + assert service._send_user_text.await_args_list == [ + call("First queued message"), + call("Second queued message"), + ] + + +@pytest.mark.asyncio +async def test_completed_input_transcription_is_broadcast_as_finalized(): + service = _make_service() + service.broadcast_frame = AsyncMock() + service._last_user_id = "caller-1" + + await service._handle_user_transcript("Hello there") + + service.broadcast_frame.assert_awaited_once() + assert service.broadcast_frame.await_args.args[0].__name__ == "TranscriptionFrame" + assert service.broadcast_frame.await_args.kwargs["text"] == "Hello there" + assert service.broadcast_frame.await_args.kwargs["finalized"] is True + + +def test_build_one_shot_params_uses_explicit_greeting_text(): + service = _make_service() + + params = service._build_one_shot_params( + greeting_text="Welcome to Dograh", + initial_messages=None, + agent_speaks_first=True, + ) + + assert params.extra["firstSpeakerSettings"] == { + "agent": {"text": "Welcome to Dograh"} + } + + +def test_build_one_shot_params_includes_initial_messages(): + service = _make_service() + service._settings.system_instruction = "Base instruction" + + params = service._build_one_shot_params( + greeting_text=None, + initial_messages=[ + {"role": "MESSAGE_ROLE_USER", "text": "User asked a question."}, + {"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'}, + ], + agent_speaks_first=True, + ) + + assert params.extra["initialMessages"] == [ + {"role": "MESSAGE_ROLE_USER", "text": "User asked a question."}, + {"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'}, + {"role": "MESSAGE_ROLE_USER", "text": _RESUMPTION_USER_MESSAGE}, + ] + assert params.system_prompt == "Base instruction" + + +def test_build_one_shot_params_without_tool_result_does_not_add_resumption_user_message(): + service = _make_service() + service._settings.system_instruction = "Base instruction" + + params = service._build_one_shot_params( + greeting_text=None, + initial_messages=[ + {"role": "MESSAGE_ROLE_USER", "text": "User asked a question."}, + {"role": "MESSAGE_ROLE_AGENT", "text": "Assistant replied."}, + ], + agent_speaks_first=False, + ) + + assert params.system_prompt == "Base instruction" + + +def test_should_agent_speak_first_when_history_ends_with_tool_result(): + service = _make_service() + + assert ( + service._should_agent_speak_first( + [ + {"role": "MESSAGE_ROLE_USER", "text": "Hello"}, + {"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'}, + ] + ) + is True + ) + + +def test_should_not_force_agent_speaks_first_when_history_ends_with_agent(): + service = _make_service() + + assert ( + service._should_agent_speak_first( + [{"role": "MESSAGE_ROLE_AGENT", "text": "How else can I help?"}] + ) + is False + ) + + +def test_should_add_resumption_user_message_only_when_history_ends_with_tool_result(): + service = _make_service() + + assert ( + service._should_add_resumption_user_message( + [{"role": "MESSAGE_ROLE_TOOL_RESULT", "text": '{"status":"done"}'}] + ) + is True + ) + assert ( + service._should_add_resumption_user_message( + [{"role": "MESSAGE_ROLE_AGENT", "text": "Assistant replied."}] + ) + is False + ) + + +def test_to_selected_tools_includes_registered_timeout(): + service = _make_service() + service.register_function( + "transition_to_next_node", + AsyncMock(), + timeout_secs=5.5, + ) + + selected_tools = service._to_selected_tools(_tool_schema()) + + assert selected_tools == [ + { + "temporaryTool": { + "modelToolName": "transition_to_next_node", + "description": "Move to the next workflow node", + "dynamicParameters": [ + { + "name": "reason", + "location": "PARAMETER_LOCATION_BODY", + "schema": {"type": "string"}, + "required": False, + } + ], + "client": {}, + "timeout": "5.5s", + } + } + ] + + +@pytest.mark.asyncio +async def test_receive_messages_ignores_benign_websocket_close(): + service = _make_service() + service._socket = _ClosingSocket( + ConnectionClosedError(None, Close(1000, "OK"), None) + ) + + await service._receive_messages() + + service.push_error.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_receive_messages_reports_unexpected_websocket_close(): + service = _make_service() + service._socket = _ClosingSocket( + ConnectionClosedError(None, Close(1011, "internal error"), None) + ) + + await service._receive_messages() + + service.push_error.assert_awaited_once() + + +def test_factory_creates_dograh_ultravox_realtime_service(): + user_config = UserConfiguration( + is_realtime=True, + realtime=UltravoxRealtimeLLMConfiguration( + provider="ultravox_realtime", + api_key="ultra-key", + model="ultravox-v0.7", + voice="Mark", + ), + ) + + service = create_realtime_llm_service( + user_config, + audio_config=SimpleNamespace(), + ) + + assert isinstance(service, DograhUltravoxRealtimeLLMService) + assert service._params.voice == "Mark" + + +def test_ultravox_realtime_configuration_defaults_to_mark_voice(): + config = UltravoxRealtimeLLMConfiguration( + provider="ultravox_realtime", + api_key="ultra-key", + model="ultravox-v0.7", + ) + + assert config.voice == "Mark" diff --git a/api/tests/test_unregistered_function_call.py b/api/tests/test_unregistered_function_call.py index 24ed9a1..5229b64 100644 --- a/api/tests/test_unregistered_function_call.py +++ b/api/tests/test_unregistered_function_call.py @@ -66,7 +66,7 @@ class TestUnregisteredFunctionCall: # Pipecat's missing-function handler returns a string error. assert isinstance(result_frame.result, str) - assert "not registered" in result_frame.result + assert "not currently available" in result_frame.result assert "nonexistent_tool" in result_frame.result # In-progress frame should also be emitted before the result so mute diff --git a/api/tests/test_workflow_create_route.py b/api/tests/test_workflow_create_route.py new file mode 100644 index 0000000..74338b7 --- /dev/null +++ b/api/tests/test_workflow_create_route.py @@ -0,0 +1,49 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.routes.workflow import router +from api.services.auth.depends import get_user + + +def _make_test_app() -> FastAPI: + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_user] = lambda: SimpleNamespace( + id=1, + provider_id="provider-1", + selected_organization_id=11, + ) + return app + + +def test_create_workflow_rejects_invalid_trigger_path_before_db_write(): + app = _make_test_app() + client = TestClient(app) + + with patch("api.routes.workflow.db_client") as mock_db: + response = client.post( + "/workflow/create/definition", + json={ + "name": "Support Agent", + "workflow_definition": { + "nodes": [ + { + "id": "trigger-1", + "type": "trigger", + "data": {"trigger_path": "support/west"}, + } + ], + "edges": [], + }, + }, + ) + + assert response.status_code == 422 + detail = response.json()["detail"] + assert detail["is_valid"] is False + assert detail["errors"][0]["field"] == "data.trigger_path" + assert "single URL path segment" in detail["errors"][0]["message"] + assert mock_db.mock_calls == [] diff --git a/api/tests/test_workflow_list_route.py b/api/tests/test_workflow_list_route.py new file mode 100644 index 0000000..0f1864b --- /dev/null +++ b/api/tests/test_workflow_list_route.py @@ -0,0 +1,52 @@ +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.routes.workflow import router +from api.services.auth.depends import get_user + + +def _make_test_app() -> FastAPI: + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_user] = lambda: SimpleNamespace( + id=1, + selected_organization_id=11, + ) + return app + + +def test_workflow_fetch_list_includes_workflow_uuid(): + app = _make_test_app() + client = TestClient(app) + + workflow = SimpleNamespace( + id=5, + name="Sales Agent", + status="active", + created_at=datetime(2026, 5, 22, 10, 30, tzinfo=timezone.utc), + folder_id=3, + workflow_uuid="workflow-uuid-123", + ) + + with patch("api.routes.workflow.db_client") as mock_db: + mock_db.get_all_workflows_for_listing = AsyncMock(return_value=[workflow]) + mock_db.get_workflow_run_counts = AsyncMock(return_value={workflow.id: 9}) + + response = client.get("/workflow/fetch") + + assert response.status_code == 200 + assert response.json() == [ + { + "id": workflow.id, + "name": workflow.name, + "status": workflow.status, + "created_at": "2026-05-22T10:30:00Z", + "total_runs": 9, + "folder_id": workflow.folder_id, + "workflow_uuid": workflow.workflow_uuid, + } + ] diff --git a/api/tests/test_workflow_qa_masking.py b/api/tests/test_workflow_qa_masking.py index 1377ab7..e1a1ac7 100644 --- a/api/tests/test_workflow_qa_masking.py +++ b/api/tests/test_workflow_qa_masking.py @@ -18,6 +18,25 @@ def _qa_node(node_id="qa-1", api_key="", **extra_data): return {"id": node_id, "type": "qa", "position": {"x": 0, "y": 0}, "data": data} +def _tuner_node(node_id="tuner-1", api_key="", **extra_data): + """Helper to build a Tuner node.""" + data = { + "name": "Tuner", + "tuner_enabled": True, + "tuner_agent_id": "sales-bot", + "tuner_workspace_id": 7, + **extra_data, + } + if api_key: + data["tuner_api_key"] = api_key + return { + "id": node_id, + "type": "tuner", + "position": {"x": 0, "y": 0}, + "data": data, + } + + def _agent_node(node_id="agent-1"): """Helper to build a non-QA node.""" return { @@ -66,6 +85,19 @@ class TestMaskWorkflowDefinition: assert "qa_api_key" not in masked["nodes"][0]["data"] assert masked["nodes"][1]["data"]["qa_api_key"] == mask_key("sk-secret1234") + def test_masks_tuner_api_key(self): + """Tuner node api_key is masked, showing only last 4 chars.""" + real_key = "tuner_live_abcdefghijklmnop" + wf = _make_workflow_def([_tuner_node(api_key=real_key)]) + + masked = mask_workflow_definition(wf) + + masked_key = masked["nodes"][0]["data"]["tuner_api_key"] + assert masked_key == mask_key(real_key) + assert masked_key.endswith("mnop") + assert masked_key.startswith("*") + assert real_key not in str(masked) + def test_qa_node_without_api_key(self): """QA node with no api_key is left as-is.""" wf = _make_workflow_def([_qa_node()]) @@ -154,6 +186,16 @@ class TestMergeWorkflowApiKeys: assert result["nodes"][0]["data"]["qa_api_key"] == new_key + def test_masked_tuner_key_is_restored(self): + """Masked Tuner keys round-trip without losing the stored secret.""" + real_key = "tuner_live_abcdefghijklmnop" + existing = _make_workflow_def([_tuner_node(api_key=real_key)]) + incoming = _make_workflow_def([_tuner_node(api_key=mask_key(real_key))]) + + result = merge_workflow_api_keys(incoming, existing) + + assert result["nodes"][0]["data"]["tuner_api_key"] == real_key + def test_no_incoming_api_key(self): """QA node without api_key in incoming is left alone.""" existing = _make_workflow_def([_qa_node(api_key="sk-existing-key1")]) diff --git a/api/tests/test_workflow_run_cost.py b/api/tests/test_workflow_run_cost.py new file mode 100644 index 0000000..c77424c --- /dev/null +++ b/api/tests/test_workflow_run_cost.py @@ -0,0 +1,181 @@ +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from api.services.pricing import workflow_run_cost as workflow_run_cost_mod +from api.services.pricing.workflow_run_cost import ( + apply_usage_delta_to_organization, + build_workflow_run_cost_info, + calculate_workflow_run_cost, +) + + +def _make_workflow_run(): + return SimpleNamespace( + id=123, + workflow_id=456, + mode="textchat", + created_at=datetime.now(UTC), + usage_info={ + "llm": {}, + "tts": {}, + "stt": {}, + "call_duration_seconds": 7, + }, + cost_info={}, + workflow=SimpleNamespace( + organization_id=42, + user=SimpleNamespace(selected_organization_id=42), + ), + ) + + +@pytest.mark.asyncio +async def test_build_workflow_run_cost_info_does_not_update_org_usage(monkeypatch): + workflow_run = _make_workflow_run() + get_org = AsyncMock(return_value=SimpleNamespace(id=42, price_per_second_usd=1.5)) + update_usage = AsyncMock() + + monkeypatch.setattr( + workflow_run_cost_mod.db_client, "get_organization_by_id", get_org + ) + monkeypatch.setattr( + workflow_run_cost_mod.db_client, "update_usage_after_run", update_usage + ) + + cost_info = await build_workflow_run_cost_info(workflow_run) + + assert cost_info is not None + assert cost_info["call_duration_seconds"] == 7 + assert "cost_breakdown" in cost_info + assert "dograh_token_usage" in cost_info + assert cost_info["charge_usd"] == 10.5 + update_usage.assert_not_called() + + +@pytest.mark.asyncio +async def test_calculate_workflow_run_cost_keeps_org_usage_side_effect_in_wrapper( + monkeypatch, +): + workflow_run = _make_workflow_run() + get_org = AsyncMock(return_value=SimpleNamespace(id=42, price_per_second_usd=None)) + update_run = AsyncMock() + update_usage = AsyncMock() + + monkeypatch.setattr( + workflow_run_cost_mod.db_client, + "get_workflow_run_by_id", + AsyncMock(return_value=workflow_run), + ) + monkeypatch.setattr( + workflow_run_cost_mod.db_client, "get_organization_by_id", get_org + ) + monkeypatch.setattr( + workflow_run_cost_mod.db_client, "update_workflow_run", update_run + ) + monkeypatch.setattr( + workflow_run_cost_mod.db_client, "update_usage_after_run", update_usage + ) + + await calculate_workflow_run_cost(workflow_run.id) + + update_run.assert_awaited_once() + saved_kwargs = update_run.await_args.kwargs + assert saved_kwargs["run_id"] == workflow_run.id + assert "cost_breakdown" in saved_kwargs["cost_info"] + update_usage.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_apply_usage_delta_to_organization_uses_incremental_costs( + monkeypatch, +): + workflow_run = _make_workflow_run() + workflow_run.cost_info = {"call_id": "preserve-me"} + + usage_delta_one = { + "llm": { + "OpenAILLMService#0|||gpt-4.1-mini": { + "prompt_tokens": 1_000, + "completion_tokens": 100, + "total_tokens": 1_100, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + } + }, + "tts": {}, + "stt": {}, + "call_duration_seconds": 3, + } + usage_delta_two = { + "llm": { + "OpenAILLMService#0|||gpt-4.1-mini": { + "prompt_tokens": 2_000, + "completion_tokens": 50, + "total_tokens": 2_050, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + } + }, + "tts": {}, + "stt": {}, + "call_duration_seconds": 4, + } + merged_usage = { + "llm": { + "OpenAILLMService#0|||gpt-4.1-mini": { + "prompt_tokens": 3_000, + "completion_tokens": 150, + "total_tokens": 3_150, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + } + }, + "tts": {}, + "stt": {}, + "call_duration_seconds": 7, + } + + get_org = AsyncMock(return_value=SimpleNamespace(id=42, price_per_second_usd=1.5)) + update_usage = AsyncMock() + + monkeypatch.setattr( + workflow_run_cost_mod.db_client, "get_organization_by_id", get_org + ) + monkeypatch.setattr( + workflow_run_cost_mod.db_client, "update_usage_after_run", update_usage + ) + + first_delta = await apply_usage_delta_to_organization(workflow_run, usage_delta_one) + second_delta = await apply_usage_delta_to_organization( + workflow_run, usage_delta_two + ) + total_workflow_run = SimpleNamespace(**workflow_run.__dict__) + total_workflow_run.usage_info = merged_usage + total_cost = await build_workflow_run_cost_info(total_workflow_run) + + assert first_delta is not None + assert second_delta is not None + assert total_cost is not None + assert update_usage.await_count == 2 + assert update_usage.await_args_list[0].args == ( + 42, + first_delta["dograh_token_usage"], + 3.0, + first_delta["charge_usd"], + ) + assert update_usage.await_args_list[1].args == ( + 42, + second_delta["dograh_token_usage"], + 4.0, + second_delta["charge_usd"], + ) + assert ( + first_delta["dograh_token_usage"] + second_delta["dograh_token_usage"] + ) == pytest.approx(total_cost["dograh_token_usage"]) + assert ( + first_delta["charge_usd"] + second_delta["charge_usd"] + == total_cost["charge_usd"] + ) diff --git a/api/tests/test_workflow_text_chat.py b/api/tests/test_workflow_text_chat.py new file mode 100644 index 0000000..1b830bf --- /dev/null +++ b/api/tests/test_workflow_text_chat.py @@ -0,0 +1,1194 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from api.db.models import OrganizationModel, UserModel +from api.schemas.user_configuration import UserConfiguration +from api.tests.integrations._run_pipeline_helpers import USER_CONFIGURATION +from pipecat.tests import MockLLMService + + +def _log_texts(logs: dict | None, event_type: str) -> list[str]: + events = (logs or {}).get("realtime_feedback_events") or [] + return [ + event.get("payload", {}).get("text", "") + for event in events + if event.get("type") == event_type + ] + + +async def _create_user_and_workflow( + db_session, + async_session, + *, + workflow_definition: dict, + suffix: str, +): + org = OrganizationModel(provider_id=f"textchat-org-{suffix}") + async_session.add(org) + await async_session.flush() + + user = UserModel( + provider_id=f"textchat-user-{suffix}", + selected_organization_id=org.id, + ) + async_session.add(user) + await async_session.flush() + + await db_session.update_user_configuration( + user_id=user.id, + configuration=UserConfiguration.model_validate(USER_CONFIGURATION), + ) + + workflow = await db_session.create_workflow( + name=f"Text Chat Workflow {suffix}", + workflow_definition=workflow_definition, + user_id=user.id, + organization_id=org.id, + ) + + return user, workflow + + +@pytest.mark.asyncio +async def test_text_chat_session_creation_executes_initial_assistant_turn( + db_session, + async_session, + test_client_factory, +): + workflow_definition = { + "nodes": [ + { + "id": "start", + "type": "startCall", + "position": {"x": 0, "y": 0}, + "data": { + "name": "Start", + "prompt": "You are a helpful assistant.", + "is_start": True, + "allow_interrupt": False, + "add_global_prompt": False, + }, + }, + { + "id": "end", + "type": "endCall", + "position": {"x": 0, "y": 200}, + "data": { + "name": "End", + "prompt": "Wrap up the conversation.", + "is_end": True, + "allow_interrupt": False, + "add_global_prompt": False, + }, + }, + ], + "edges": [ + { + "id": "start-end", + "source": "start", + "target": "end", + "data": {"label": "End Call", "condition": "When the task is done."}, + } + ], + } + + user, workflow = await _create_user_and_workflow( + db_session, + async_session, + workflow_definition=workflow_definition, + suffix="bootstrap", + ) + + llm = MockLLMService( + mock_steps=[ + MockLLMService.create_text_chunks("Hello from the workflow tester.") + ], + chunk_delay=0.001, + ) + + async with test_client_factory(user) as client: + with ( + patch( + "api.services.workflow.text_chat_runner.create_llm_service", + return_value=llm, + ), + patch( + "api.services.workflow.text_chat_runner.db_client.has_active_recordings", + new=AsyncMock(return_value=False), + ), + ): + create_response = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions", + json={}, + ) + assert create_response.status_code == 200 + created = create_response.json() + run_response = await client.get( + f"/api/v1/workflow/{workflow.id}/runs/{created['workflow_run_id']}" + ) + assert run_response.status_code == 200 + run_payload = run_response.json() + + turns = created["session_data"]["turns"] + assert created["revision"] == 2 + assert created["session_data"]["status"] == "idle" + assert len(turns) == 1 + assert turns[0]["status"] == "completed" + assert turns[0]["user_message"] is None + assert turns[0]["assistant_message"]["text"] == "Hello from the workflow tester." + assert turns[0]["checkpoint_after_turn"]["current_node_id"] == "start" + assert created["checkpoint"]["current_node_id"] == "start" + assert created["state"] == "running" + assert "Start" in (created["gathered_context"] or {}).get("nodes_visited", []) + workflow_run = await db_session.get_workflow_run_by_id(created["workflow_run_id"]) + assert workflow_run is not None + assert workflow_run.cost_info[ + "call_duration_seconds" + ] == workflow_run.usage_info.get("call_duration_seconds", 0) + assert "cost_breakdown" in workflow_run.cost_info + assert "dograh_token_usage" in workflow_run.cost_info + assert _log_texts(run_payload["logs"], "rtf-bot-text") == [ + "Hello from the workflow tester." + ] + + +@pytest.mark.asyncio +async def test_text_chat_message_executes_assistant_turn( + db_session, + async_session, + test_client_factory, +): + workflow_definition = { + "nodes": [ + { + "id": "start", + "type": "startCall", + "position": {"x": 0, "y": 0}, + "data": { + "name": "Start", + "prompt": "You are a helpful assistant.", + "is_start": True, + "allow_interrupt": False, + "add_global_prompt": False, + "greeting_type": "text", + "greeting": "Welcome to the workflow tester.", + }, + }, + { + "id": "end", + "type": "endCall", + "position": {"x": 0, "y": 200}, + "data": { + "name": "End", + "prompt": "Wrap up the conversation.", + "is_end": True, + "allow_interrupt": False, + "add_global_prompt": False, + }, + }, + ], + "edges": [ + { + "id": "start-end", + "source": "start", + "target": "end", + "data": {"label": "End Call", "condition": "When the task is done."}, + } + ], + } + + user, workflow = await _create_user_and_workflow( + db_session, + async_session, + workflow_definition=workflow_definition, + suffix="basic", + ) + + llm_responses = [ + MockLLMService(mock_steps=[], chunk_delay=0.001), + MockLLMService( + mock_steps=[ + MockLLMService.create_text_chunks("Hello from the workflow tester.") + ], + chunk_delay=0.001, + ), + ] + + async with test_client_factory(user) as client: + with ( + patch( + "api.services.workflow.text_chat_runner.create_llm_service", + side_effect=llm_responses, + ), + patch( + "api.services.workflow.text_chat_runner.db_client.has_active_recordings", + new=AsyncMock(return_value=False), + ), + ): + create_response = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions", + json={}, + ) + assert create_response.status_code == 200 + created = create_response.json() + + message_response = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions/{created['workflow_run_id']}/messages", + json={ + "text": "Hi there", + "expected_revision": created["revision"], + }, + ) + assert message_response.status_code == 200 + run_response = await client.get( + f"/api/v1/workflow/{workflow.id}/runs/{created['workflow_run_id']}" + ) + assert run_response.status_code == 200 + run_payload = run_response.json() + + payload = message_response.json() + turns = payload["session_data"]["turns"] + assert payload["revision"] == 4 + assert payload["session_data"]["status"] == "idle" + assert len(turns) == 2 + assert turns[0]["user_message"] is None + assert turns[0]["assistant_message"]["text"] == "Welcome to the workflow tester." + assert turns[1]["status"] == "completed" + assert turns[1]["user_message"]["text"] == "Hi there" + assert turns[1]["assistant_message"]["text"] == "Hello from the workflow tester." + assert turns[1]["checkpoint_after_turn"]["current_node_id"] == "start" + assert payload["checkpoint"]["current_node_id"] == "start" + assert payload["state"] == "running" + assert "Start" in (payload["gathered_context"] or {}).get("nodes_visited", []) + workflow_run = await db_session.get_workflow_run_by_id(created["workflow_run_id"]) + assert workflow_run is not None + assert workflow_run.cost_info[ + "call_duration_seconds" + ] == workflow_run.usage_info.get("call_duration_seconds", 0) + assert "cost_breakdown" in workflow_run.cost_info + assert "dograh_token_usage" in workflow_run.cost_info + assert _log_texts(run_payload["logs"], "rtf-user-transcription") == ["Hi there"] + assert _log_texts(run_payload["logs"], "rtf-bot-text") == [ + "Welcome to the workflow tester.", + "Hello from the workflow tester.", + ] + + +@pytest.mark.asyncio +async def test_text_chat_executes_deferred_tool_calls_after_text_response( + db_session, + async_session, + test_client_factory, +): + workflow_definition = { + "nodes": [ + { + "id": "start", + "type": "startCall", + "position": {"x": 0, "y": 0}, + "data": { + "name": "Start", + "prompt": "You are at the start node.", + "is_start": True, + "allow_interrupt": False, + "add_global_prompt": False, + "greeting_type": "text", + "greeting": "Welcome to the workflow tester.", + }, + }, + { + "id": "agent1", + "type": "agentNode", + "position": {"x": 0, "y": 200}, + "data": { + "name": "Agent One", + "prompt": "You are in agent one.", + "allow_interrupt": False, + "add_global_prompt": False, + }, + }, + ], + "edges": [ + { + "id": "start-agent1", + "source": "start", + "target": "agent1", + "data": { + "label": "Go To Agent One", + "condition": "Move to agent one.", + }, + } + ], + } + + user, workflow = await _create_user_and_workflow( + db_session, + async_session, + workflow_definition=workflow_definition, + suffix="mixed-tool-turn", + ) + + llm_responses = [ + MockLLMService(mock_steps=[], chunk_delay=0.001), + MockLLMService( + mock_steps=[ + MockLLMService.create_mixed_chunks( + "Let me transfer you.", + "go_to_agent_one", + {}, + tool_call_id="call_agent_one", + ), + MockLLMService.create_text_chunks("Agent one here."), + ], + chunk_delay=0.001, + ), + ] + + async with test_client_factory(user) as client: + with ( + patch( + "api.services.workflow.text_chat_runner.create_llm_service", + side_effect=llm_responses, + ), + patch( + "api.services.workflow.text_chat_runner.db_client.has_active_recordings", + new=AsyncMock(return_value=False), + ), + ): + create_response = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions", + json={}, + ) + assert create_response.status_code == 200 + session = create_response.json() + + message_response = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions/{session['workflow_run_id']}/messages", + json={ + "text": "Please transfer me", + "expected_revision": session["revision"], + }, + ) + assert message_response.status_code == 200 + run_response = await client.get( + f"/api/v1/workflow/{workflow.id}/runs/{session['workflow_run_id']}" + ) + assert run_response.status_code == 200 + + payload = message_response.json() + run_payload = run_response.json() + assistant_text = payload["session_data"]["turns"][1]["assistant_message"]["text"] + + assert "Let me transfer you." in assistant_text + assert "Agent one here." in assistant_text + assert payload["checkpoint"]["current_node_id"] == "agent1" + assert any( + event["type"] == "tool_call_started" + and event["payload"]["function_name"] == "go_to_agent_one" + for event in payload["session_data"]["turns"][1]["events"] + ) + node_transition_names = [ + event["payload"]["node_name"] + for event in run_payload["logs"]["realtime_feedback_events"] + if event["type"] == "rtf-node-transition" + ] + assert node_transition_names == ["Start", "Agent One"] + function_call_event_names = [ + event["type"] + for event in run_payload["logs"]["realtime_feedback_events"] + if event["type"] in {"rtf-function-call-start", "rtf-function-call-end"} + ] + assert function_call_event_names == [ + "rtf-function-call-start", + "rtf-function-call-end", + ] + + +@pytest.mark.asyncio +async def test_text_chat_chains_multiple_follow_up_completions_in_one_turn( + db_session, + async_session, + test_client_factory, +): + workflow_definition = { + "nodes": [ + { + "id": "start", + "type": "startCall", + "position": {"x": 0, "y": 0}, + "data": { + "name": "Start", + "prompt": "You are at the start node.", + "is_start": True, + "allow_interrupt": False, + "add_global_prompt": False, + "greeting_type": "text", + "greeting": "Welcome to the workflow tester.", + }, + }, + { + "id": "agent1", + "type": "agentNode", + "position": {"x": 0, "y": 200}, + "data": { + "name": "Agent One", + "prompt": "You are in agent one.", + "allow_interrupt": False, + "add_global_prompt": False, + }, + }, + { + "id": "agent2", + "type": "agentNode", + "position": {"x": 0, "y": 400}, + "data": { + "name": "Agent Two", + "prompt": "You are in agent two.", + "allow_interrupt": False, + "add_global_prompt": False, + }, + }, + ], + "edges": [ + { + "id": "start-agent1", + "source": "start", + "target": "agent1", + "data": { + "label": "Go To Agent One", + "condition": "Move to agent one.", + }, + }, + { + "id": "agent1-agent2", + "source": "agent1", + "target": "agent2", + "data": { + "label": "Go To Agent Two", + "condition": "Move to agent two.", + }, + }, + ], + } + + user, workflow = await _create_user_and_workflow( + db_session, + async_session, + workflow_definition=workflow_definition, + suffix="multi-hop-turn", + ) + + llm_responses = [ + MockLLMService(mock_steps=[], chunk_delay=0.001), + MockLLMService( + mock_steps=[ + MockLLMService.create_mixed_chunks( + "Moving to agent one.", + "go_to_agent_one", + {}, + tool_call_id="call_agent_one", + ), + MockLLMService.create_mixed_chunks( + "Moving to agent two.", + "go_to_agent_two", + {}, + tool_call_id="call_agent_two", + ), + MockLLMService.create_text_chunks("Agent two here."), + ], + chunk_delay=0.001, + ), + ] + + async with test_client_factory(user) as client: + with ( + patch( + "api.services.workflow.text_chat_runner.create_llm_service", + side_effect=llm_responses, + ), + patch( + "api.services.workflow.text_chat_runner.db_client.has_active_recordings", + new=AsyncMock(return_value=False), + ), + ): + create_response = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions", + json={}, + ) + assert create_response.status_code == 200 + session = create_response.json() + + message_response = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions/{session['workflow_run_id']}/messages", + json={ + "text": "Please route me through the flow", + "expected_revision": session["revision"], + }, + ) + assert message_response.status_code == 200 + + payload = message_response.json() + assistant_text = payload["session_data"]["turns"][1]["assistant_message"]["text"] + + assert "Moving to agent one." in assistant_text + assert "Moving to agent two." in assistant_text + assert "Agent two here." in assistant_text + assert payload["checkpoint"]["current_node_id"] == "agent2" + assert ( + sum( + 1 + for event in payload["session_data"]["turns"][1]["events"] + if event["type"] == "tool_call_started" + ) + == 2 + ) + + +@pytest.mark.asyncio +async def test_text_chat_greeting_only_plays_on_fresh_node_entry( + db_session, + async_session, + test_client_factory, +): + workflow_definition = { + "nodes": [ + { + "id": "start", + "type": "startCall", + "position": {"x": 0, "y": 0}, + "data": { + "name": "Start", + "prompt": "You are a helpful assistant.", + "is_start": True, + "allow_interrupt": False, + "add_global_prompt": False, + "greeting_type": "text", + "greeting": "Welcome to the workflow tester.", + }, + }, + { + "id": "end", + "type": "endCall", + "position": {"x": 0, "y": 200}, + "data": { + "name": "End", + "prompt": "Wrap up the conversation.", + "is_end": True, + "allow_interrupt": False, + "add_global_prompt": False, + }, + }, + ], + "edges": [ + { + "id": "start-end", + "source": "start", + "target": "end", + "data": {"label": "End Call", "condition": "When the task is done."}, + } + ], + } + + user, workflow = await _create_user_and_workflow( + db_session, + async_session, + workflow_definition=workflow_definition, + suffix="greeting-once", + ) + + llm_responses = [ + MockLLMService(mock_steps=[], chunk_delay=0.001), + MockLLMService( + mock_steps=[MockLLMService.create_text_chunks("First answer.")], + chunk_delay=0.001, + ), + MockLLMService( + mock_steps=[MockLLMService.create_text_chunks("Second answer.")], + chunk_delay=0.001, + ), + ] + + async with test_client_factory(user) as client: + with ( + patch( + "api.services.workflow.text_chat_runner.create_llm_service", + side_effect=llm_responses, + ), + patch( + "api.services.workflow.text_chat_runner.db_client.has_active_recordings", + new=AsyncMock(return_value=False), + ), + ): + create_response = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions", + json={}, + ) + assert create_response.status_code == 200 + session = create_response.json() + opening_text = session["session_data"]["turns"][0]["assistant_message"][ + "text" + ] + + first_message = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions/{session['workflow_run_id']}/messages", + json={ + "text": "First turn", + "expected_revision": session["revision"], + }, + ) + assert first_message.status_code == 200 + first_payload = first_message.json() + + second_message = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions/{session['workflow_run_id']}/messages", + json={ + "text": "Second turn", + "expected_revision": first_payload["revision"], + }, + ) + assert second_message.status_code == 200 + + first_text = first_payload["session_data"]["turns"][1]["assistant_message"]["text"] + second_text = second_message.json()["session_data"]["turns"][2][ + "assistant_message" + ]["text"] + + assert opening_text == "Welcome to the workflow tester." + assert "Welcome to the workflow tester." not in first_text + assert "First answer." in first_text + assert "Welcome to the workflow tester." not in second_text + assert "Second answer." in second_text + + +@pytest.mark.asyncio +async def test_text_chat_rewind_reuses_checkpoint_snapshot( + db_session, + async_session, + test_client_factory, +): + workflow_definition = { + "nodes": [ + { + "id": "start", + "type": "startCall", + "position": {"x": 0, "y": 0}, + "data": { + "name": "Start", + "prompt": "You are at the start node.", + "is_start": True, + "allow_interrupt": False, + "add_global_prompt": False, + "greeting_type": "text", + "greeting": "Welcome to the rewind test.", + }, + }, + { + "id": "agent1", + "type": "agentNode", + "position": {"x": 0, "y": 200}, + "data": { + "name": "Agent One", + "prompt": "You are in agent one.", + "allow_interrupt": False, + "add_global_prompt": False, + }, + }, + { + "id": "agent2", + "type": "agentNode", + "position": {"x": 0, "y": 400}, + "data": { + "name": "Agent Two", + "prompt": "You are in agent two.", + "allow_interrupt": False, + "add_global_prompt": False, + }, + }, + { + "id": "end", + "type": "endCall", + "position": {"x": 0, "y": 600}, + "data": { + "name": "End", + "prompt": "You are at the end node.", + "is_end": True, + "allow_interrupt": False, + "add_global_prompt": False, + }, + }, + ], + "edges": [ + { + "id": "start-agent1", + "source": "start", + "target": "agent1", + "data": { + "label": "Go To Agent One", + "condition": "Move to agent one.", + }, + }, + { + "id": "agent1-agent2", + "source": "agent1", + "target": "agent2", + "data": { + "label": "Go To Agent Two", + "condition": "Move to agent two.", + }, + }, + { + "id": "agent2-end", + "source": "agent2", + "target": "end", + "data": {"label": "Finish", "condition": "End the flow."}, + }, + ], + } + + user, workflow = await _create_user_and_workflow( + db_session, + async_session, + workflow_definition=workflow_definition, + suffix="rewind", + ) + + llm_responses = [ + MockLLMService(mock_steps=[], chunk_delay=0.001), + MockLLMService( + mock_steps=[ + MockLLMService.create_function_call_chunks( + "go_to_agent_one", + {}, + tool_call_id="call_agent_one", + ), + MockLLMService.create_text_chunks("Agent one here."), + ], + chunk_delay=0.001, + ), + MockLLMService( + mock_steps=[ + MockLLMService.create_function_call_chunks( + "go_to_agent_two", + {}, + tool_call_id="call_agent_two", + ), + MockLLMService.create_text_chunks("Agent two here."), + ], + chunk_delay=0.001, + ), + MockLLMService( + mock_steps=[MockLLMService.create_text_chunks("Back in agent one.")], + chunk_delay=0.001, + ), + ] + + async with test_client_factory(user) as client: + with ( + patch( + "api.services.workflow.text_chat_runner.create_llm_service", + side_effect=llm_responses, + ), + patch( + "api.services.workflow.text_chat_runner.db_client.has_active_recordings", + new=AsyncMock(return_value=False), + ), + ): + create_response = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions", + json={}, + ) + assert create_response.status_code == 200 + session = create_response.json() + + first_message = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions/{session['workflow_run_id']}/messages", + json={ + "text": "First turn", + "expected_revision": session["revision"], + }, + ) + assert first_message.status_code == 200 + first_payload = first_message.json() + first_turn_id = first_payload["session_data"]["turns"][1]["id"] + assert first_payload["checkpoint"]["current_node_id"] == "agent1" + + second_message = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions/{session['workflow_run_id']}/messages", + json={ + "text": "Second turn", + "expected_revision": first_payload["revision"], + }, + ) + assert second_message.status_code == 200 + second_payload = second_message.json() + assert second_payload["checkpoint"]["current_node_id"] == "agent2" + + rewind_response = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions/{session['workflow_run_id']}/rewind", + json={ + "cursor_turn_id": first_turn_id, + "expected_revision": second_payload["revision"], + }, + ) + assert rewind_response.status_code == 200 + rewound = rewind_response.json() + assert rewound["session_data"]["cursor_turn_id"] == first_turn_id + rewound_run_response = await client.get( + f"/api/v1/workflow/{workflow.id}/runs/{session['workflow_run_id']}" + ) + assert rewound_run_response.status_code == 200 + rewound_run_payload = rewound_run_response.json() + + third_message = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions/{session['workflow_run_id']}/messages", + json={ + "text": "Third turn after rewind", + "expected_revision": rewound["revision"], + }, + ) + assert third_message.status_code == 200 + final_run_response = await client.get( + f"/api/v1/workflow/{workflow.id}/runs/{session['workflow_run_id']}" + ) + assert final_run_response.status_code == 200 + final_run_payload = final_run_response.json() + + payload = third_message.json() + assert payload["checkpoint"]["current_node_id"] == "agent1" + assert payload["session_data"]["discarded_future"] + assert len(payload["session_data"]["turns"]) == 3 + assert payload["session_data"]["turns"][1]["id"] == first_turn_id + assert ( + payload["session_data"]["turns"][2]["assistant_message"]["text"] + == "Back in agent one." + ) + assert _log_texts(rewound_run_payload["logs"], "rtf-user-transcription") == [ + "First turn" + ] + assert "Second turn" not in _log_texts( + rewound_run_payload["logs"], "rtf-user-transcription" + ) + assert "Agent two here." not in _log_texts( + rewound_run_payload["logs"], "rtf-bot-text" + ) + assert _log_texts(final_run_payload["logs"], "rtf-user-transcription") == [ + "First turn", + "Third turn after rewind", + ] + assert _log_texts(final_run_payload["logs"], "rtf-bot-text") == [ + "Welcome to the rewind test.", + "Agent one here.", + "Back in agent one.", + ] + + +@pytest.mark.asyncio +async def test_text_chat_session_is_not_accessible_from_another_org( + db_session, + async_session, + test_client_factory, +): + workflow_definition = { + "nodes": [ + { + "id": "start", + "type": "startCall", + "position": {"x": 0, "y": 0}, + "data": { + "name": "Start", + "prompt": "You are a helpful assistant.", + "is_start": True, + "allow_interrupt": False, + "add_global_prompt": False, + }, + }, + { + "id": "end", + "type": "endCall", + "position": {"x": 0, "y": 200}, + "data": { + "name": "End", + "prompt": "Wrap up the conversation.", + "is_end": True, + "allow_interrupt": False, + "add_global_prompt": False, + }, + }, + ], + "edges": [ + { + "id": "start-end", + "source": "start", + "target": "end", + "data": {"label": "End Call", "condition": "When the task is done."}, + } + ], + } + + owner_user, workflow = await _create_user_and_workflow( + db_session, + async_session, + workflow_definition=workflow_definition, + suffix="owner", + ) + other_user, _ = await _create_user_and_workflow( + db_session, + async_session, + workflow_definition=workflow_definition, + suffix="other", + ) + + async with test_client_factory(owner_user) as owner_client: + llm = MockLLMService( + mock_steps=[ + MockLLMService.create_text_chunks("Hello from the workflow tester.") + ], + chunk_delay=0.001, + ) + with ( + patch( + "api.services.workflow.text_chat_runner.create_llm_service", + return_value=llm, + ), + patch( + "api.services.workflow.text_chat_runner.db_client.has_active_recordings", + new=AsyncMock(return_value=False), + ), + ): + create_response = await owner_client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions", + json={}, + ) + assert create_response.status_code == 200 + created = create_response.json() + + async with test_client_factory(other_user) as other_client: + get_response = await other_client.get( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions/{created['workflow_run_id']}" + ) + assert get_response.status_code == 404 + + +@pytest.mark.asyncio +async def test_text_chat_session_creation_requires_selected_org_scope( + db_session, + async_session, + test_client_factory, +): + workflow_definition = { + "nodes": [ + { + "id": "start", + "type": "startCall", + "position": {"x": 0, "y": 0}, + "data": { + "name": "Start", + "prompt": "You are a helpful assistant.", + "is_start": True, + "allow_interrupt": False, + "add_global_prompt": False, + }, + } + ], + "edges": [], + } + + org_a = OrganizationModel(provider_id="textchat-scope-a") + org_b = OrganizationModel(provider_id="textchat-scope-b") + async_session.add_all([org_a, org_b]) + await async_session.flush() + + user = UserModel( + provider_id="textchat-scope-user", + selected_organization_id=org_a.id, + ) + async_session.add(user) + await async_session.flush() + + await db_session.update_user_configuration( + user_id=user.id, + configuration=UserConfiguration.model_validate(USER_CONFIGURATION), + ) + + workflow = await db_session.create_workflow( + name="Cross-org workflow", + workflow_definition=workflow_definition, + user_id=user.id, + organization_id=org_b.id, + ) + + llm = MockLLMService( + mock_steps=[MockLLMService.create_text_chunks("Should never run.")], + chunk_delay=0.001, + ) + + async with test_client_factory(user) as client: + with ( + patch( + "api.services.workflow.text_chat_runner.create_llm_service", + return_value=llm, + ), + patch( + "api.services.workflow.text_chat_runner.db_client.has_active_recordings", + new=AsyncMock(return_value=False), + ), + ): + create_response = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions", + json={}, + ) + + assert create_response.status_code == 404 + _, total_count = await db_session.get_workflow_runs_by_workflow_id( + workflow.id, + organization_id=org_b.id, + ) + assert total_count == 0 + + +@pytest.mark.asyncio +async def test_text_chat_session_creation_rejects_quota_before_creating_run( + db_session, + async_session, + test_client_factory, +): + workflow_definition = { + "nodes": [ + { + "id": "start", + "type": "startCall", + "position": {"x": 0, "y": 0}, + "data": { + "name": "Start", + "prompt": "You are a helpful assistant.", + "is_start": True, + "allow_interrupt": False, + "add_global_prompt": False, + }, + } + ], + "edges": [], + } + + user, workflow = await _create_user_and_workflow( + db_session, + async_session, + workflow_definition=workflow_definition, + suffix="quota-create", + ) + + async with test_client_factory(user) as client: + with patch( + "api.routes.workflow_text_chat.check_dograh_quota", + new=AsyncMock( + return_value=SimpleNamespace( + has_quota=False, + error_message="Quota exceeded", + ) + ), + ): + create_response = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions", + json={}, + ) + + assert create_response.status_code == 402 + assert create_response.json()["detail"] == "Quota exceeded" + _, total_count = await db_session.get_workflow_runs_by_workflow_id( + workflow.id, + organization_id=workflow.organization_id, + ) + assert total_count == 0 + + +@pytest.mark.asyncio +async def test_text_chat_append_rejects_quota_without_mutating_session( + db_session, + async_session, + test_client_factory, +): + workflow_definition = { + "nodes": [ + { + "id": "start", + "type": "startCall", + "position": {"x": 0, "y": 0}, + "data": { + "name": "Start", + "prompt": "You are a helpful assistant.", + "is_start": True, + "allow_interrupt": False, + "add_global_prompt": False, + }, + } + ], + "edges": [], + } + + user, workflow = await _create_user_and_workflow( + db_session, + async_session, + workflow_definition=workflow_definition, + suffix="quota-append", + ) + + llm = MockLLMService( + mock_steps=[ + MockLLMService.create_text_chunks("Hello from the workflow tester.") + ], + chunk_delay=0.001, + ) + + async with test_client_factory(user) as client: + with ( + patch( + "api.routes.workflow_text_chat.check_dograh_quota", + new=AsyncMock( + side_effect=[ + SimpleNamespace(has_quota=True, error_message=""), + SimpleNamespace( + has_quota=False, + error_message="Quota exceeded on append", + ), + ] + ), + ), + patch( + "api.services.workflow.text_chat_runner.create_llm_service", + return_value=llm, + ), + patch( + "api.services.workflow.text_chat_runner.db_client.has_active_recordings", + new=AsyncMock(return_value=False), + ), + ): + create_response = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions", + json={}, + ) + assert create_response.status_code == 200 + created = create_response.json() + + append_response = await client.post( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions/{created['workflow_run_id']}/messages", + json={ + "text": "This should be rejected", + "expected_revision": created["revision"], + }, + ) + assert append_response.status_code == 402 + + session_response = await client.get( + f"/api/v1/workflow/{workflow.id}/text-chat/sessions/{created['workflow_run_id']}" + ) + assert session_response.status_code == 200 + + session_payload = session_response.json() + assert append_response.json()["detail"] == "Quota exceeded on append" + assert session_payload["revision"] == created["revision"] + assert session_payload["session_data"]["turns"] == created["session_data"]["turns"] + assert ( + session_payload["session_data"]["status"] == created["session_data"]["status"] + ) diff --git a/api/tests/test_workflow_versioning.py b/api/tests/test_workflow_versioning.py index 800fe73..1b723b3 100644 --- a/api/tests/test_workflow_versioning.py +++ b/api/tests/test_workflow_versioning.py @@ -182,7 +182,7 @@ class TestSaveDraft: workflow_definition=GRAPH_V2, ) - refreshed = await db_session.get_workflow(workflow.id) + refreshed = await db_session.get_workflow_by_id(workflow.id) assert refreshed.released_definition_id == original_released_id async def test_save_draft_twice_updates_in_place( @@ -264,7 +264,7 @@ class TestPublishDraft: await db_session.publish_workflow_draft(workflow.id) - refreshed = await db_session.get_workflow(workflow.id) + refreshed = await db_session.get_workflow_by_id(workflow.id) assert refreshed.released_definition_id == draft.id async def test_publish_sets_published_at(self, db_session, workflow_with_v1): @@ -346,7 +346,7 @@ class TestDiscardDraft: ) await db_session.discard_workflow_draft(workflow.id) - refreshed = await db_session.get_workflow(workflow.id) + refreshed = await db_session.get_workflow_by_id(workflow.id) assert refreshed.released_definition_id == original_released_id async def test_discard_when_no_draft_raises(self, db_session, workflow_with_v1): @@ -464,7 +464,7 @@ class TestRevert: await db_session.revert_to_version(workflow.id, v1_id) - refreshed = await db_session.get_workflow(workflow.id) + refreshed = await db_session.get_workflow_by_id(workflow.id) assert refreshed.released_definition_id == v2.id # still V2 diff --git a/deploy/templates/nginx.remote.conf.template b/deploy/templates/nginx.remote.conf.template new file mode 100644 index 0000000..fa51c4b --- /dev/null +++ b/deploy/templates/nginx.remote.conf.template @@ -0,0 +1,82 @@ +__DOGRAH_UPSTREAM_BLOCK__ + +server { + listen 80; + server_name __DOGRAH_PUBLIC_HOST__; + + # Redirect all HTTP to HTTPS + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name __DOGRAH_PUBLIC_HOST__; + + ssl_certificate /etc/nginx/certs/local.crt; + ssl_certificate_key /etc/nginx/certs/local.key; + + # Basic TLS settings + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + + # Backend API and WebSockets - bypass the UI, go straight to the + # api workers via the least_conn upstream defined above. + location /api/v1/ { + proxy_pass http://dograh_api; + proxy_http_version 1.1; + + # Retry on a dead/restarting worker + proxy_next_upstream error timeout http_502 http_503 http_504; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # Long-lived WebSockets (audio streaming, signaling) + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + + # Don't buffer streamed responses + proxy_buffering off; + client_max_body_size 100M; + } + + location / { + proxy_pass http://ui:3010; + proxy_http_version 1.1; + + # Important for WebSockets / hot reload etc. + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # Rewrite localhost MinIO URLs in API responses to use current domain + sub_filter 'http://localhost:9000/voice-audio/' 'https://$host/voice-audio/'; + sub_filter_once off; + sub_filter_types application/json text/html; + } + + location /voice-audio/ { + proxy_pass http://minio:9000/voice-audio/; + + proxy_http_version 1.1; + + # Headers for file downloads from MinIO + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # Allow large file downloads + proxy_buffering off; + client_max_body_size 100M; + } +} diff --git a/deploy/templates/turnserver.remote.conf.template b/deploy/templates/turnserver.remote.conf.template new file mode 100644 index 0000000..6e6c222 --- /dev/null +++ b/deploy/templates/turnserver.remote.conf.template @@ -0,0 +1,28 @@ +# Coturn TURN Server - Docker Configuration +# Auto-generated by Dograh remote config renderer. + +# Listener ports +listening-port=3478 +tls-listening-port=5349 + +# Relay port range +min-port=49152 +max-port=49200 + +# Network - external IP / host for NAT traversal +external-ip=__DOGRAH_TURN_EXTERNAL_IP__ + +# Realm +realm=dograh.com + +# Authentication (TURN REST API with time-limited credentials) +use-auth-secret +static-auth-secret=__DOGRAH_TURN_SECRET__ + +# Security +fingerprint +no-cli +no-multicast-peers + +# Logging +log-file=stdout diff --git a/docker-compose.yaml b/docker-compose.yaml index 307c6d6..f453449 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -59,21 +59,69 @@ services: networks: - app-network + dograh-init: + image: bash:5.2 + container_name: dograh_init + profiles: ["remote", "local-turn"] + environment: + ENVIRONMENT: "${ENVIRONMENT:-local}" + SERVER_IP: "${SERVER_IP:-}" + PUBLIC_HOST: "${PUBLIC_HOST:-}" + PUBLIC_BASE_URL: "${PUBLIC_BASE_URL:-}" + BACKEND_API_ENDPOINT: "${BACKEND_API_ENDPOINT:-http://localhost:8000}" + MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}" + TURN_HOST: "${TURN_HOST:-}" + TURN_SECRET: "${TURN_SECRET:-}" + FASTAPI_WORKERS: "${FASTAPI_WORKERS:-1}" + volumes: + - ./scripts:/workspace/scripts:ro + - ./deploy:/workspace/deploy:ro + - ./certs:/certs:ro + - nginx-generated:/generated/nginx + - coturn-generated:/generated/coturn + command: + - /workspace/scripts/run_dograh_init.sh + nginx: image: nginx:alpine container_name: nginx_https profiles: ["remote"] depends_on: - - ui + dograh-init: + condition: service_completed_successfully + ui: + condition: service_started ports: - "80:80" - "443:443" volumes: - - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - nginx-generated:/etc/nginx/conf.d:ro - ./certs:/etc/nginx/certs:ro networks: - app-network + coturn: + image: coturn/coturn:4.8.0 + container_name: coturn + restart: unless-stopped + profiles: ["remote", "local-turn"] + depends_on: + dograh-init: + condition: service_completed_successfully + ports: + - "3478:3478/udp" + - "3478:3478/tcp" + - "5349:5349/udp" + - "5349:5349/tcp" + - "49152-49200:49152-49200/udp" + volumes: + - coturn-generated:/etc/coturn:ro + command: + - -c + - /etc/coturn/turnserver.conf + networks: + - app-network + api: image: ${REGISTRY:-dograhai}/dograh-api:latest volumes: @@ -108,8 +156,8 @@ services: MINIO_SECURE: "false" # Number of uvicorn worker processes (each is its own process bound to a - # distinct port starting at 8000). nginx load-balances across them with - # least_conn — see setup_remote.sh. + # distinct port starting at 8000). dograh-init renders nginx upstreams + # from this value and nginx load-balances across them with least_conn. FASTAPI_WORKERS: "${FASTAPI_WORKERS:-1}" # Langfuse — credentials can be set here or per-organization via the UI @@ -195,25 +243,6 @@ services: networks: - app-network - coturn: - image: coturn/coturn:4.8.0 - container_name: coturn - restart: unless-stopped - profiles: ["remote", "local-turn"] - ports: - - "3478:3478/udp" - - "3478:3478/tcp" - - "5349:5349/udp" - - "5349:5349/tcp" - - "49152-49200:49152-49200/udp" - volumes: - - ./turnserver.conf:/etc/coturn/turnserver.conf:ro - command: - - -c - - /etc/coturn/turnserver.conf - networks: - - app-network - volumes: postgres_data: redis_data: @@ -221,6 +250,10 @@ volumes: driver: local shared-tmp: driver: local + nginx-generated: + driver: local + coturn-generated: + driver: local networks: app-network: diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 0000000..2daec40 --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,53 @@ +# Mintlify documentation + +## Working relationship + +- You can push back on ideas-this can lead to better documentation. Cite sources and explain your reasoning when you do so +- ALWAYS ask for clarification rather than making assumptions +- NEVER lie, guess, or make up information + +## Project context + +- Format: MDX files with YAML frontmatter +- Config: docs.json for navigation, theme, settings +- Components: Mintlify components + +## Content strategy + +- Document just enough for user success - not too much, not too little +- Prioritize accuracy and usability of information +- Make content evergreen when possible +- Search for existing information before adding new content. Avoid duplication unless it is done for a strategic reason +- Check existing patterns for consistency +- Start by making the smallest reasonable changes + +## Frontmatter requirements for pages + +- title: Clear, descriptive page title +- description: Concise summary for SEO/navigation + +## Writing standards + +- Second-person voice ("you") +- Prerequisites at start of procedural content +- Test all code examples before publishing +- Match style and formatting of existing pages +- Include both basic and advanced use cases +- Language tags on all code blocks +- Alt text on all images +- Relative paths for internal links + +## Git workflow + +- NEVER use --no-verify when committing +- Ask how to handle uncommitted changes before starting +- Create a new branch when no clear branch exists for changes +- Commit frequently throughout development +- NEVER skip or disable pre-commit hooks + +## Do not + +- Skip frontmatter on any MDX file +- Use absolute URLs for internal links +- Include untested code examples +- Make assumptions - always ask for clarification diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 2daec40..43c994c 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -1,53 +1 @@ -# Mintlify documentation - -## Working relationship - -- You can push back on ideas-this can lead to better documentation. Cite sources and explain your reasoning when you do so -- ALWAYS ask for clarification rather than making assumptions -- NEVER lie, guess, or make up information - -## Project context - -- Format: MDX files with YAML frontmatter -- Config: docs.json for navigation, theme, settings -- Components: Mintlify components - -## Content strategy - -- Document just enough for user success - not too much, not too little -- Prioritize accuracy and usability of information -- Make content evergreen when possible -- Search for existing information before adding new content. Avoid duplication unless it is done for a strategic reason -- Check existing patterns for consistency -- Start by making the smallest reasonable changes - -## Frontmatter requirements for pages - -- title: Clear, descriptive page title -- description: Concise summary for SEO/navigation - -## Writing standards - -- Second-person voice ("you") -- Prerequisites at start of procedural content -- Test all code examples before publishing -- Match style and formatting of existing pages -- Include both basic and advanced use cases -- Language tags on all code blocks -- Alt text on all images -- Relative paths for internal links - -## Git workflow - -- NEVER use --no-verify when committing -- Ask how to handle uncommitted changes before starting -- Create a new branch when no clear branch exists for changes -- Commit frequently throughout development -- NEVER skip or disable pre-commit hooks - -## Do not - -- Skip frontmatter on any MDX file -- Use absolute URLs for internal links -- Include untested code examples -- Make assumptions - always ask for clarification +@AGENTS.md diff --git a/docs/api-reference/calls.mdx b/docs/api-reference/calls.mdx index c7149c8..294cfc8 100644 --- a/docs/api-reference/calls.mdx +++ b/docs/api-reference/calls.mdx @@ -5,11 +5,37 @@ description: "Initiate outbound calls and trigger agents via the API" | Method | Endpoint | Quick Link | |---|---|---| -| `POST` | `/public/agent/{uuid}` | [Trigger an outbound call](/api-reference/calls/trigger) | +| `POST` | `/public/agent/{uuid}` | [Trigger an outbound call by API Trigger node](/api-reference/calls/trigger) | +| `POST` | `/public/agent/workflow/{workflow_uuid}` | [Trigger an outbound call by workflow UUID](/api-reference/calls/trigger-workflow) | | `GET` | `/workflow/{workflow_id}/runs/{run_id}` | [Retrieve call details](/api-reference/calls/get-run) | | `GET` | `/public/download/workflow/{token}/{artifact_type}` | [Download recordings and transcripts](/api-reference/calls/download) | | `POST` | `/telephony/inbound/{workflow_id}` | [Inbound call webhook](/api-reference/calls/inbound) | +## Choose the right public call route + +Dograh exposes two public outbound call route families. They are **not** + interchangeable, even though both path parameters look like UUIDs. + +| Use this when | Production route | Test route | Identifier you pass | +|---|---|---|---| +| You added an **[API Trigger node](/voice-agent/api-trigger)** to the workflow and want to call that trigger | `/public/agent/{uuid}` | `/public/agent/test/{uuid}` | The trigger UUID (`trigger_path`) from the API Trigger node | +| You want to execute the workflow by its stable **Agent UUID** instead of a trigger node | `/public/agent/workflow/{workflow_uuid}` | `/public/agent/test/workflow/{workflow_uuid}` | The workflow UUID from the agent's **[Agent UUID](/configurations/agent-uuid)** field | + + +Do not pass a workflow UUID to `/public/agent/{uuid}` and do not pass a trigger UUID +to `/public/agent/workflow/{workflow_uuid}`. Dograh treats these as different +identifier types and the request will fail if you mix them up. + + +Once Dograh resolves the target agent, both route families behave the same: + +- They accept the same request body +- They return the same response shape +- They validate the same `X-API-Key` organization boundary +- They use the same telephony configuration selection rules + +If you specifically need the API Trigger route, see [Trigger an outbound call by API Trigger node](/api-reference/calls/trigger). To execute by workflow UUID, see [Trigger an outbound call by workflow UUID](/api-reference/calls/trigger-workflow). + ## Using initial context `initial_context` passes runtime data into the agent at call time. Values are available as template variables in your agent's prompt using double-brace syntax. diff --git a/docs/api-reference/calls/trigger-workflow.mdx b/docs/api-reference/calls/trigger-workflow.mdx new file mode 100644 index 0000000..ef3b01b --- /dev/null +++ b/docs/api-reference/calls/trigger-workflow.mdx @@ -0,0 +1,25 @@ +--- +title: "Trigger an Outbound Call by Workflow UUID" +description: "Initiate an outbound call using a workflow's stable Agent UUID" +openapi: "POST /api/v1/public/agent/workflow/{workflow_uuid}" +--- + +Use this endpoint when you want to execute a workflow directly by its stable Agent UUID instead of through an API Trigger node. + +The `workflow_uuid` is the workflow's Agent UUID. It is different from an API Trigger node's `trigger_path`. + +To find and copy the Agent UUID in the UI, see [Agent UUID](/configurations/agent-uuid). + +Use `workflow_run_id` from the response to later [retrieve call details](/api-reference/calls/get-run), recordings, and transcripts. + +Pass `initial_context` to inject runtime data as template variables into the agent's prompt. See [Using initial context](/api-reference/calls#using-initial-context). + +Pass `telephony_configuration_id` to route the call through a specific telephony configuration instead of your organization's default. The id is shown on each row in **Telephony configurations** (`https://app.dograh.com/telephony-configurations` for hosted or `http://localhost:3010/telephony-configurations` for local). + + +This route expects a workflow UUID. Do not pass an API Trigger node UUID here. If you want to execute via an API Trigger node, use [Trigger an outbound call](/api-reference/calls/trigger) instead. + + + +Your telephony provider must be configured before outbound calls will connect. See [Telephony](/integrations/telephony/overview) for setup instructions. + diff --git a/docs/api-reference/calls/trigger.mdx b/docs/api-reference/calls/trigger.mdx index 1d37e15..31d242e 100644 --- a/docs/api-reference/calls/trigger.mdx +++ b/docs/api-reference/calls/trigger.mdx @@ -1,10 +1,12 @@ --- -title: "Trigger an Outbound Call" -description: "Initiate an outbound call using an agent's public UUID" +title: "Trigger an Outbound Call by API Trigger Node" +description: "Initiate an outbound call using an API Trigger node UUID" openapi: "POST /api/v1/public/agent/{uuid}" --- -The simplest way to initiate a call programmatically. The `uuid` comes from the [API Trigger node](/voice-agent/api-trigger) in your agent — add the node to your workflow and copy its auto-generated `trigger_path`. +Use this endpoint when you want to execute a workflow through an [API Trigger node](/voice-agent/api-trigger). + +The `uuid` comes from the API Trigger node in your agent. Add the node to your workflow and copy its auto-generated `trigger_path`. Use `workflow_run_id` from the response to later [retrieve call details](/api-reference/calls/get-run), recordings, and transcripts. @@ -12,6 +14,10 @@ Pass `initial_context` to inject runtime data as template variables into the age Pass `telephony_configuration_id` to route the call through a specific telephony configuration instead of your organization's default. The id is shown on each row in **Telephony configurations** (`https://app.dograh.com/telephony-configurations` for hosted or `http://localhost:3010/telephony-configurations` for local). + +This route expects an API Trigger node UUID (`trigger_path`). Do not pass a workflow UUID here. If you want to execute by workflow UUID, use [Trigger an outbound call by workflow UUID](/api-reference/calls/trigger-workflow) instead. + + Your telephony provider must be configured before outbound calls will connect. See [Telephony](/integrations/telephony/overview) for setup instructions. diff --git a/docs/api-reference/openapi.json b/docs/api-reference/openapi.json index b53b9ed..207cbcf 100644 --- a/docs/api-reference/openapi.json +++ b/docs/api-reference/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Dograh API","description":"API for the Dograh app","version":"1.0.0"},"servers":[{"url":"https://app.dograh.com","description":"Production"},{"url":"http://localhost:8000","description":"Local development"}],"paths":{"/api/v1/telephony/initiate-call":{"post":{"tags":["main"],"summary":"Initiate Call","description":"Initiate a call using the configured telephony provider from web browser. This is\nsupposed to be a test call method for the draft version of the agent.","operationId":"initiate_call_api_v1_telephony_initiate_call_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InitiateCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"test_phone_call","x-sdk-description":"Place a test call from a workflow to a phone number."}},"/api/v1/telephony/inbound/run":{"post":{"tags":["main"],"summary":"Handle Inbound Run","description":"Workflow-agnostic inbound dispatcher.\n\nAll providers can point a single webhook at this endpoint instead of one\nURL per workflow. The dispatcher resolves the org from the webhook's\naccount_id and the workflow from the called number's\n``inbound_workflow_id``. This is what ``configure_inbound`` writes into\neach provider's resource so per-workflow webhook bookkeeping disappears.\n\nProvider-specific signature/timestamp headers are not enumerated here \u2014\neach provider's ``verify_inbound_signature`` reads its own headers from\nthe dict, so adding a new provider doesn't require changes to this route.","operationId":"handle_inbound_run_api_v1_telephony_inbound_run_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"}}}},"/api/v1/telephony/inbound/fallback":{"post":{"tags":["main"],"summary":"Handle Inbound Fallback","description":"Fallback endpoint that returns audio message when calls cannot be processed.","operationId":"handle_inbound_fallback_api_v1_telephony_inbound_fallback_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"}}}},"/api/v1/telephony/inbound/{workflow_id}":{"post":{"tags":["main"],"summary":"Handle Inbound Telephony","description":"[LEGACY] Per-workflow inbound webhook.\n\nSuperseded by ``POST /inbound/run``, which resolves the workflow from\nthe called number's ``inbound_workflow_id`` and lets a single webhook\nURL serve every workflow in the org. New integrations should point\ntheir provider at ``/inbound/run``; this route is kept only for\nexisting provider configurations that still encode ``workflow_id``\nin the URL.","operationId":"handle_inbound_telephony_api_v1_telephony_inbound__workflow_id__post","deprecated":true,"parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/transfer-result/{transfer_id}":{"post":{"tags":["main"],"summary":"Complete Transfer Function Call","description":"Webhook endpoint to complete the function call with transfer result.\n\nCalled by Twilio's StatusCallback when the transfer call status changes.","operationId":"complete_transfer_function_call_api_v1_telephony_transfer_result__transfer_id__post","parameters":[{"name":"transfer_id","in":"path","required":true,"schema":{"type":"string","title":"Transfer Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/cloudonix/status-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Cloudonix Status Callback","description":"Handle Cloudonix-specific status callbacks.\n\nCloudonix sends call status updates to the callback URL specified during call initiation.","operationId":"handle_cloudonix_status_callback_api_v1_telephony_cloudonix_status_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/cloudonix/cdr":{"post":{"tags":["main"],"summary":"Handle Cloudonix Cdr","description":"Handle Cloudonix CDR (Call Detail Record) webhooks.\n\nCloudonix sends CDR records when calls complete. The CDR contains:\n- domain: Used to identify the organization\n- call_id: Used to find the workflow run\n- disposition: Call termination status (ANSWER, BUSY, CANCEL, FAILED, CONGESTION, NOANSWER)\n- duration/billsec: Call duration information","operationId":"handle_cloudonix_cdr_api_v1_telephony_cloudonix_cdr_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"}}}},"/api/v1/telephony/plivo/hangup-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Plivo Hangup Callback","description":"Handle Plivo hangup callbacks.","operationId":"handle_plivo_hangup_callback_api_v1_telephony_plivo_hangup_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}},{"name":"x-plivo-signature-v3","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Plivo-Signature-V3"}},{"name":"x-plivo-signature-ma-v3","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Plivo-Signature-Ma-V3"}},{"name":"x-plivo-signature-v3-nonce","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Plivo-Signature-V3-Nonce"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/plivo/ring-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Plivo Ring Callback","description":"Handle Plivo ring callbacks.","operationId":"handle_plivo_ring_callback_api_v1_telephony_plivo_ring_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}},{"name":"x-plivo-signature-v3","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Plivo-Signature-V3"}},{"name":"x-plivo-signature-ma-v3","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Plivo-Signature-Ma-V3"}},{"name":"x-plivo-signature-v3-nonce","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Plivo-Signature-V3-Nonce"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/telnyx/events/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Telnyx Events","description":"Handle Telnyx Call Control webhook events.\n\nTelnyx sends all call lifecycle events (call.initiated, call.answered,\ncall.hangup, streaming.started, streaming.stopped) as JSON POST requests.","operationId":"handle_telnyx_events_api_v1_telephony_telnyx_events__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/telnyx/transfer-result/{transfer_id}":{"post":{"tags":["main"],"summary":"Handle Telnyx Transfer Result","description":"Handle Telnyx Call Control events for the transfer destination leg.\n\nThe destination leg is dialed by :meth:`TelnyxProvider.transfer_call` with\nthis URL as ``webhook_url``. Telnyx sends every event for that leg here.\nOutcomes:\n\n- ``call.answered``: seed a conference with the destination's live\n ``call_control_id``, stamp ``conference_id`` onto the TransferContext,\n and publish ``DESTINATION_ANSWERED`` so ``transfer_call_handler`` can\n end the pipeline. ``TelnyxConferenceStrategy`` then joins the caller\n into this conference at pipeline teardown.\n- ``call.hangup`` pre-answer (no ``conference_id`` on the context):\n publish ``TRANSFER_FAILED`` so the LLM can recover.\n- ``call.hangup`` post-answer (``conference_id`` set): the destination\n left a bridged conference; hang up the caller's leg to tear down the\n empty bridge (Telnyx's create_conference doesn't accept\n ``end_conference_on_exit`` on the seed leg).\n\nEvent references:\n - call.answered: https://developers.telnyx.com/api-reference/callbacks/call-answered\n - call.hangup: https://developers.telnyx.com/api-reference/callbacks/call-hangup","operationId":"handle_telnyx_transfer_result_api_v1_telephony_telnyx_transfer_result__transfer_id__post","parameters":[{"name":"transfer_id","in":"path","required":true,"schema":{"type":"string","title":"Transfer Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/twilio/status-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Twilio Status Callback","description":"Handle Twilio-specific status callbacks.","operationId":"handle_twilio_status_callback_api_v1_telephony_twilio_status_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}},{"name":"x-webhook-signature","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Webhook-Signature"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/vobiz/hangup-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Vobiz Hangup Callback","description":"Handle Vobiz hangup callback (sent when call ends).\n\nVobiz sends callbacks to hangup_url when the call terminates.\nThis includes call duration, status, and billing information.","operationId":"handle_vobiz_hangup_callback_api_v1_telephony_vobiz_hangup_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}},{"name":"x-vobiz-signature","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Signature"}},{"name":"x-vobiz-timestamp","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Timestamp"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/vobiz/ring-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Vobiz Ring Callback","description":"Handle Vobiz ring callback (sent when call starts ringing).\n\nVobiz can send callbacks to ring_url when the call starts ringing.\nThis is optional and used for tracking ringing status.","operationId":"handle_vobiz_ring_callback_api_v1_telephony_vobiz_ring_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}},{"name":"x-vobiz-signature","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Signature"}},{"name":"x-vobiz-timestamp","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Timestamp"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}":{"post":{"tags":["main"],"summary":"Handle Vobiz Hangup Callback By Workflow","description":"Handle Vobiz hangup callback with workflow_id - finds workflow run by call_id.","operationId":"handle_vobiz_hangup_callback_by_workflow_api_v1_telephony_vobiz_hangup_callback_workflow__workflow_id__post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"x-vobiz-signature","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Signature"}},{"name":"x-vobiz-timestamp","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Timestamp"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/vonage/events/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Vonage Events","description":"Handle Vonage-specific event webhooks.\n\nVonage sends all call events to a single endpoint.\nEvents include: started, ringing, answered, complete, failed, etc.","operationId":"handle_vonage_events_api_v1_telephony_vonage_events__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/superuser/impersonate":{"post":{"tags":["main","superuser"],"summary":"Impersonate","description":"Impersonate a user as a super-admin.\nInternally, Stack Auth requires the **provider user ID** (a UUID-ish string)\nto create an impersonation session.","operationId":"impersonate_api_v1_superuser_impersonate_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImpersonateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImpersonateResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/superuser/workflow-runs":{"get":{"tags":["main","superuser"],"summary":"Get Workflow Runs","description":"Get paginated list of all workflow runs with organization information.\nRequires superuser privileges.\n\nFilters should be provided as a JSON-encoded array of filter criteria.\nExample: [{\"field\": \"id\", \"type\": \"number\", \"value\": {\"value\": 680}}]","operationId":"get_workflow_runs_api_v1_superuser_workflow_runs_get","parameters":[{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Page number (starts from 1)","default":1,"title":"Page"},"description":"Page number (starts from 1)"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Number of items per page","default":50,"title":"Limit"},"description":"Number of items per page"},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded filter criteria","title":"Filters"},"description":"JSON-encoded filter criteria"},{"name":"sort_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Field to sort by (e.g., 'duration', 'created_at')","title":"Sort By"},"description":"Field to sort by (e.g., 'duration', 'created_at')"},{"name":"sort_order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Sort order ('asc' or 'desc')","default":"desc","title":"Sort Order"},"description":"Sort order ('asc' or 'desc')"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuperuserWorkflowRunsListResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/validate":{"post":{"tags":["main"],"summary":"Validate Workflow","description":"Validate all nodes in a workflow to ensure they have required fields.\n\nArgs:\n workflow_id: The ID of the workflow to validate\n user: The authenticated user\n\nReturns:\n Object indicating if workflow is valid and any invalid nodes/edges","operationId":"validate_workflow_api_v1_workflow__workflow_id__validate_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateWorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/create/definition":{"post":{"tags":["main"],"summary":"Create Workflow","description":"Create a new workflow from the client\n\nArgs:\n request: The create workflow request\n user: The user to create the workflow for","operationId":"create_workflow_api_v1_workflow_create_definition_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkflowRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"create_workflow","x-sdk-description":"Create a new workflow from a workflow definition."}},"/api/v1/workflow/create/template":{"post":{"tags":["main"],"summary":"Create Workflow From Template","description":"Create a new workflow from a natural language template request.\n\nThis endpoint:\n1. Uses mps_service_key_client to call MPS workflow API\n2. Passes organization ID (authenticated mode) or created_by (OSS mode)\n3. Creates the workflow in the database\n\nArgs:\n request: The template creation request with call_type, use_case, and activity_description\n user: The authenticated user\n\nReturns:\n The created workflow\n\nRaises:\n HTTPException: If MPS API call fails","operationId":"create_workflow_from_template_api_v1_workflow_create_template_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkflowTemplateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/count":{"get":{"tags":["main"],"summary":"Get Workflow Count","description":"Get workflow counts for the authenticated user's organization.\n\nThis is a lightweight endpoint for checking if the user has workflows,\nuseful for redirect logic without fetching full workflow data.","operationId":"get_workflow_count_api_v1_workflow_count_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCountResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/fetch":{"get":{"tags":["main"],"summary":"Get Workflows","description":"Get all workflows for the authenticated user's organization.\n\nReturns a lightweight response with only essential fields for listing.\nUse GET /workflow/fetch/{workflow_id} to get full workflow details.","operationId":"get_workflows_api_v1_workflow_fetch_get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by status - can be single value (active/archived) or comma-separated (active,archived)","title":"Status"},"description":"Filter by status - can be single value (active/archived) or comma-separated (active,archived)"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowListResponse"},"title":"Response Get Workflows Api V1 Workflow Fetch Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_workflows","x-sdk-description":"List all workflows in the authenticated organization."}},"/api/v1/workflow/fetch/{workflow_id}":{"get":{"tags":["main"],"summary":"Get Workflow","description":"Get a single workflow by ID.\n\nIf a draft version exists, returns the draft content for editing.\nOtherwise returns the published version's content.","operationId":"get_workflow_api_v1_workflow_fetch__workflow_id__get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"get_workflow","x-sdk-description":"Get a single workflow by ID (returns draft if one exists, else published)."}},"/api/v1/workflow/{workflow_id}/versions":{"get":{"tags":["main"],"summary":"Get Workflow Versions","description":"List versions for a workflow, newest first.\n\nPass `limit`/`offset` to page through long histories. With no `limit`,\nreturns every version (legacy behavior).","operationId":"get_workflow_versions_api_v1_workflow__workflow_id__versions_get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":100,"minimum":1},{"type":"null"}],"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowVersionResponse"},"title":"Response Get Workflow Versions Api V1 Workflow Workflow Id Versions Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/publish":{"post":{"tags":["main"],"summary":"Publish Workflow","description":"Publish the current draft version of a workflow.\n\nDrafts are allowed to be incomplete (so the editor can save mid-edit),\nbut a published version is what runtime executes \u2014 so this is the gate\nwhere the full DTO + graph + trigger-conflict checks must pass.","operationId":"publish_workflow_api_v1_workflow__workflow_id__publish_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/create-draft":{"post":{"tags":["main"],"summary":"Create Workflow Draft","description":"Create a draft version from the current published version.\n\nIf a draft already exists, returns the existing draft.","operationId":"create_workflow_draft_api_v1_workflow__workflow_id__create_draft_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVersionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/summary":{"get":{"tags":["main"],"summary":"Get Workflows Summary","description":"Get minimal workflow information (id and name only) for all workflows","operationId":"get_workflows_summary_api_v1_workflow_summary_get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by status (e.g. 'active' or 'archived'). Omit to return all.","title":"Status"},"description":"Filter by status (e.g. 'active' or 'archived'). Omit to return all."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowSummaryResponse"},"title":"Response Get Workflows Summary Api V1 Workflow Summary Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/status":{"put":{"tags":["main"],"summary":"Update Workflow Status","description":"Update the status of a workflow (e.g., archive/unarchive).\n\nArgs:\n workflow_id: The ID of the workflow to update\n request: The status update request\n\nReturns:\n The updated workflow","operationId":"update_workflow_status_api_v1_workflow__workflow_id__status_put","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkflowStatusRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}":{"put":{"tags":["main"],"summary":"Update Workflow","description":"Update an existing workflow.\n\nArgs:\n workflow_id: The ID of the workflow to update\n request: The update request containing the new name and workflow definition\n\nReturns:\n The updated workflow\n\nRaises:\n HTTPException: If the workflow is not found or if there's a database error","operationId":"update_workflow_api_v1_workflow__workflow_id__put","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkflowRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"update_workflow","x-sdk-description":"Update a workflow's name and/or definition. Saves as a new draft."}},"/api/v1/workflow/{workflow_id}/duplicate":{"post":{"tags":["main"],"summary":"Duplicate Workflow Endpoint","description":"Duplicate a workflow including its definition, configuration, recordings, and triggers.","operationId":"duplicate_workflow_endpoint_api_v1_workflow__workflow_id__duplicate_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/runs":{"post":{"tags":["main"],"summary":"Create Workflow Run","description":"Create a new workflow run when the user decides to execute the workflow via chat or voice\n\nArgs:\n workflow_id: The ID of the workflow to run\n request: The create workflow run request\n user: The user to create the workflow run for","operationId":"create_workflow_run_api_v1_workflow__workflow_id__runs_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkflowRunRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkflowRunResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["main"],"summary":"Get Workflow Runs","description":"Get workflow runs with optional filtering and sorting.\n\nFilters should be provided as a JSON-encoded array of filter criteria.\nExample: [{\"attribute\": \"dateRange\", \"value\": {\"from\": \"2024-01-01\", \"to\": \"2024-01-31\"}}]","operationId":"get_workflow_runs_api_v1_workflow__workflow_id__runs_get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","default":1,"title":"Page"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":50,"title":"Limit"}},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded filter criteria","title":"Filters"},"description":"JSON-encoded filter criteria"},{"name":"sort_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Field to sort by (e.g., 'duration', 'created_at')","title":"Sort By"},"description":"Field to sort by (e.g., 'duration', 'created_at')"},{"name":"sort_order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Sort order ('asc' or 'desc')","default":"desc","title":"Sort Order"},"description":"Sort order ('asc' or 'desc')"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/runs/{run_id}":{"get":{"tags":["main"],"summary":"Get Workflow Run","operationId":"get_workflow_run_api_v1_workflow__workflow_id__runs__run_id__get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"run_id","in":"path","required":true,"schema":{"type":"integer","title":"Run Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/report":{"get":{"tags":["main"],"summary":"Download Workflow Report","description":"Download a CSV report of completed runs for a workflow.","operationId":"download_workflow_report_api_v1_workflow__workflow_id__report_get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter runs created on or after this datetime (ISO 8601)","title":"Start Date"},"description":"Filter runs created on or after this datetime (ISO 8601)"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter runs created on or before this datetime (ISO 8601)","title":"End Date"},"description":"Filter runs created on or before this datetime (ISO 8601)"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/templates":{"get":{"tags":["main"],"summary":"Get Workflow Templates","description":"Get all available workflow templates.\n\nReturns:\n List of workflow templates","operationId":"get_workflow_templates_api_v1_workflow_templates_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/WorkflowTemplateResponse"},"type":"array","title":"Response Get Workflow Templates Api V1 Workflow Templates Get"}}}},"404":{"description":"Not found"}}}},"/api/v1/workflow/templates/duplicate":{"post":{"tags":["main"],"summary":"Duplicate Workflow Template","description":"Duplicate a workflow template to create a new workflow for the user.\n\nArgs:\n request: The duplicate template request\n user: The authenticated user\n\nReturns:\n The newly created workflow","operationId":"duplicate_workflow_template_api_v1_workflow_templates_duplicate_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateTemplateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/ambient-noise/upload-url":{"post":{"tags":["main"],"summary":"Get a presigned URL to upload a custom ambient noise audio file","description":"Generate a presigned PUT URL for uploading a custom ambient noise file.","operationId":"get_ambient_noise_upload_url_api_v1_workflow_ambient_noise_upload_url_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AmbientNoiseUploadRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AmbientNoiseUploadResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/configurations/defaults":{"get":{"tags":["main"],"summary":"Get Default Configurations","operationId":"get_default_configurations_api_v1_user_configurations_defaults_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DefaultConfigurationsResponse"}}}},"404":{"description":"Not found"}}}},"/api/v1/user/auth/user":{"get":{"tags":["main"],"summary":"Get Auth User","operationId":"get_auth_user_api_v1_user_auth_user_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthUserResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/configurations/user":{"get":{"tags":["main"],"summary":"Get User Configurations","operationId":"get_user_configurations_api_v1_user_configurations_user_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConfigurationRequestResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main"],"summary":"Update User Configurations","operationId":"update_user_configurations_api_v1_user_configurations_user_put","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConfigurationRequestResponseSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConfigurationRequestResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/configurations/user/validate":{"get":{"tags":["main"],"summary":"Validate User Configurations","operationId":"validate_user_configurations_api_v1_user_configurations_user_validate_get","parameters":[{"name":"validity_ttl_seconds","in":"query","required":false,"schema":{"type":"integer","maximum":86400,"minimum":0,"default":60,"title":"Validity Ttl Seconds"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIKeyStatusResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/api-keys":{"get":{"tags":["main"],"summary":"Get Api Keys","description":"Get all API keys for the user's selected organization.","operationId":"get_api_keys_api_v1_user_api_keys_get","parameters":[{"name":"include_archived","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Archived"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/APIKeyResponse"},"title":"Response Get Api Keys Api V1 User Api Keys Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main"],"summary":"Create Api Key","description":"Create a new API key for the user's selected organization.","operationId":"create_api_key_api_v1_user_api_keys_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAPIKeyRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAPIKeyResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/api-keys/{api_key_id}":{"delete":{"tags":["main"],"summary":"Archive Api Key","description":"Archive an API key (soft delete).","operationId":"archive_api_key_api_v1_user_api_keys__api_key_id__delete","parameters":[{"name":"api_key_id","in":"path","required":true,"schema":{"type":"integer","title":"Api Key Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Archive Api Key Api V1 User Api Keys Api Key Id Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/api-keys/{api_key_id}/reactivate":{"put":{"tags":["main"],"summary":"Reactivate Api Key","description":"Reactivate an archived API key.","operationId":"reactivate_api_key_api_v1_user_api_keys__api_key_id__reactivate_put","parameters":[{"name":"api_key_id","in":"path","required":true,"schema":{"type":"integer","title":"Api Key Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Reactivate Api Key Api V1 User Api Keys Api Key Id Reactivate Put"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/configurations/voices/{provider}":{"get":{"tags":["main"],"summary":"Get Voices","description":"Get available voices for a TTS provider.","operationId":"get_voices_api_v1_user_configurations_voices__provider__get","parameters":[{"name":"provider","in":"path","required":true,"schema":{"enum":["elevenlabs","deepgram","sarvam","cartesia","dograh","rime"],"type":"string","title":"Provider"}},{"name":"model","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model"}},{"name":"language","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VoicesResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/create":{"post":{"tags":["main"],"summary":"Create Campaign","description":"Create a new campaign","operationId":"create_campaign_api_v1_campaign_create_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCampaignRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/":{"get":{"tags":["main"],"summary":"Get Campaigns","description":"Get campaigns for user's organization","operationId":"get_campaigns_api_v1_campaign__get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}":{"get":{"tags":["main"],"summary":"Get Campaign","description":"Get campaign details","operationId":"get_campaign_api_v1_campaign__campaign_id__get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["main"],"summary":"Update Campaign","description":"Update campaign settings (name, retry config, max concurrency, schedule)","operationId":"update_campaign_api_v1_campaign__campaign_id__patch","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCampaignRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/start":{"post":{"tags":["main"],"summary":"Start Campaign","description":"Start campaign execution","operationId":"start_campaign_api_v1_campaign__campaign_id__start_post","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/pause":{"post":{"tags":["main"],"summary":"Pause Campaign","description":"Pause campaign execution","operationId":"pause_campaign_api_v1_campaign__campaign_id__pause_post","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/runs":{"get":{"tags":["main"],"summary":"Get Campaign Runs","description":"Get campaign workflow runs with pagination, filters and sorting","operationId":"get_campaign_runs_api_v1_campaign__campaign_id__runs_get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","default":1,"title":"Page"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":50,"title":"Limit"}},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded filter criteria","title":"Filters"},"description":"JSON-encoded filter criteria"},{"name":"sort_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Field to sort by (e.g., 'duration', 'created_at')","title":"Sort By"},"description":"Field to sort by (e.g., 'duration', 'created_at')"},{"name":"sort_order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Sort order ('asc' or 'desc')","default":"desc","title":"Sort Order"},"description":"Sort order ('asc' or 'desc')"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignRunsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/redial":{"post":{"tags":["main"],"summary":"Redial Campaign","description":"Create a new campaign that re-dials unique subscribers from a completed\ncampaign whose latest call resulted in voicemail, no-answer, or busy.\n\nThe new campaign is created in 'created' state with queued_runs pre-seeded\nfrom the parent's original initial contexts. A campaign can be redialed at\nmost once.","operationId":"redial_campaign_api_v1_campaign__campaign_id__redial_post","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RedialCampaignRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/resume":{"post":{"tags":["main"],"summary":"Resume Campaign","description":"Resume a paused campaign","operationId":"resume_campaign_api_v1_campaign__campaign_id__resume_post","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/progress":{"get":{"tags":["main"],"summary":"Get Campaign Progress","description":"Get current campaign progress and statistics","operationId":"get_campaign_progress_api_v1_campaign__campaign_id__progress_get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignProgressResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/source-download-url":{"get":{"tags":["main"],"summary":"Get Campaign Source Download Url","description":"Get presigned download URL for campaign CSV source file\n\nOnly works for CSV source type. For Google Sheets, use the source_id directly.\nValidates that the campaign belongs to the user's organization for security.","operationId":"get_campaign_source_download_url_api_v1_campaign__campaign_id__source_download_url_get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignSourceDownloadResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/report":{"get":{"tags":["main"],"summary":"Download Campaign Report","description":"Download a CSV report of completed campaign runs.","operationId":"download_campaign_report_api_v1_campaign__campaign_id__report_get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter runs created on or after this datetime (ISO 8601)","title":"Start Date"},"description":"Filter runs created on or after this datetime (ISO 8601)"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter runs created on or before this datetime (ISO 8601)","title":"End Date"},"description":"Filter runs created on or before this datetime (ISO 8601)"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/credentials/":{"get":{"tags":["main"],"summary":"List Credentials","description":"List all webhook credentials for the user's organization.\n\nReturns:\n List of credentials (without sensitive data)","operationId":"list_credentials_api_v1_credentials__get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CredentialResponse"},"title":"Response List Credentials Api V1 Credentials Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_credentials","x-sdk-description":"List webhook credentials available to the authenticated organization."},"post":{"tags":["main"],"summary":"Create Credential","description":"Create a new webhook credential.\n\nArgs:\n request: The credential creation request\n\nReturns:\n The created credential (without sensitive data)","operationId":"create_credential_api_v1_credentials__post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCredentialRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CredentialResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/credentials/{credential_uuid}":{"get":{"tags":["main"],"summary":"Get Credential","description":"Get a specific webhook credential by UUID.\n\nArgs:\n credential_uuid: The UUID of the credential\n\nReturns:\n The credential (without sensitive data)","operationId":"get_credential_api_v1_credentials__credential_uuid__get","parameters":[{"name":"credential_uuid","in":"path","required":true,"schema":{"type":"string","title":"Credential Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CredentialResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main"],"summary":"Update Credential","description":"Update a webhook credential.\n\nArgs:\n credential_uuid: The UUID of the credential to update\n request: The update request\n\nReturns:\n The updated credential (without sensitive data)","operationId":"update_credential_api_v1_credentials__credential_uuid__put","parameters":[{"name":"credential_uuid","in":"path","required":true,"schema":{"type":"string","title":"Credential Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCredentialRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CredentialResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main"],"summary":"Delete Credential","description":"Delete (soft delete) a webhook credential.\n\nArgs:\n credential_uuid: The UUID of the credential to delete\n\nReturns:\n Success message","operationId":"delete_credential_api_v1_credentials__credential_uuid__delete","parameters":[{"name":"credential_uuid","in":"path","required":true,"schema":{"type":"string","title":"Credential Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Credential Api V1 Credentials Credential Uuid Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/tools/":{"get":{"tags":["main"],"summary":"List Tools","description":"List all tools for the user's organization.\n\nArgs:\n status: Optional filter by status (active, archived, draft)\n category: Optional filter by category (http_api, native, integration)\n\nReturns:\n List of tools","operationId":"list_tools_api_v1_tools__get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status"}},{"name":"category","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Category"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ToolResponse"},"title":"Response List Tools Api V1 Tools Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_tools","x-sdk-description":"List tools available to the authenticated organization."},"post":{"tags":["main"],"summary":"Create Tool","description":"Create a new tool.\n\nArgs:\n request: The tool creation request\n\nReturns:\n The created tool","operationId":"create_tool_api_v1_tools__post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateToolRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/tools/{tool_uuid}":{"get":{"tags":["main"],"summary":"Get Tool","description":"Get a specific tool by UUID.\n\nArgs:\n tool_uuid: The UUID of the tool\n\nReturns:\n The tool","operationId":"get_tool_api_v1_tools__tool_uuid__get","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main"],"summary":"Update Tool","description":"Update a tool.\n\nArgs:\n tool_uuid: The UUID of the tool to update\n request: The update request\n\nReturns:\n The updated tool","operationId":"update_tool_api_v1_tools__tool_uuid__put","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateToolRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main"],"summary":"Delete Tool","description":"Archive (soft delete) a tool.\n\nArgs:\n tool_uuid: The UUID of the tool to delete\n\nReturns:\n Success message","operationId":"delete_tool_api_v1_tools__tool_uuid__delete","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Tool Api V1 Tools Tool Uuid Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/tools/{tool_uuid}/unarchive":{"post":{"tags":["main"],"summary":"Unarchive Tool","description":"Unarchive a tool (restore from archived state).\n\nArgs:\n tool_uuid: The UUID of the tool to unarchive\n\nReturns:\n The unarchived tool","operationId":"unarchive_tool_api_v1_tools__tool_uuid__unarchive_post","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/integration/":{"get":{"tags":["main"],"summary":"Get Integrations","description":"Get all integrations for the user's selected organization.\n\nReturns:\n List of integrations associated with the user's selected organization","operationId":"get_integrations_api_v1_integration__get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/IntegrationResponse"},"title":"Response Get Integrations Api V1 Integration Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/integration/session":{"post":{"tags":["main"],"summary":"Create Session","description":"Create a Nango session for the user's selected organization.\n\nReturns:\n Session token and ID for the created session","operationId":"create_session_api_v1_integration_session_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SessionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/integration/{integration_id}":{"put":{"tags":["main"],"summary":"Update Integration","description":"Update an integration's selected files (for Google Sheets).\n\nArgs:\n integration_id: The ID of the integration to update\n request: The update request containing selected files\n user: The authenticated user\n\nReturns:\n Updated integration details","operationId":"update_integration_api_v1_integration__integration_id__put","parameters":[{"name":"integration_id","in":"path","required":true,"schema":{"type":"integer","title":"Integration Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateIntegrationRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IntegrationResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/integration/{integration_id}/access-token":{"get":{"tags":["main"],"summary":"Get Integration Access Token","description":"Get the latest access token for an integration from Nango.\n\nArgs:\n integration_id: The ID of the integration\n user: The authenticated user\n\nReturns:\n Dict containing access token and expiration info","operationId":"get_integration_access_token_api_v1_integration__integration_id__access_token_get","parameters":[{"name":"integration_id","in":"path","required":true,"schema":{"type":"integer","title":"Integration Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AccessTokenResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-providers/metadata":{"get":{"tags":["main","organizations"],"summary":"Get Telephony Providers Metadata","description":"Return the list of available telephony providers and their form schemas.\n\nThe UI uses this to render the configuration form generically instead of\nhard-coding fields per provider. Adding a new provider only requires\ndeclaring its ui_metadata in providers//__init__.py.","operationId":"get_telephony_providers_metadata_api_v1_organizations_telephony_providers_metadata_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyProvidersMetadataResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-config-warnings":{"get":{"tags":["main","organizations"],"summary":"Get Telephony Config Warnings","description":"Return aggregated warning counts for the current org's telephony configs.\n\nToday this surfaces only Telnyx configs missing ``webhook_public_key``;\nadditional warning types should be added as new fields on the response.","operationId":"get_telephony_config_warnings_api_v1_organizations_telephony_config_warnings_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigWarningsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs":{"get":{"tags":["main","organizations"],"summary":"List Telephony Configurations","description":"List the org's telephony configurations with phone-number counts.","operationId":"list_telephony_configurations_api_v1_organizations_telephony_configs_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationListResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main","organizations"],"summary":"Create Telephony Configuration","description":"Create a new telephony configuration for the org.","operationId":"create_telephony_configuration_api_v1_organizations_telephony_configs_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationDetail"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}":{"get":{"tags":["main","organizations"],"summary":"Get Telephony Configuration By Id","operationId":"get_telephony_configuration_by_id_api_v1_organizations_telephony_configs__config_id__get","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationDetail"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main","organizations"],"summary":"Update Telephony Configuration","operationId":"update_telephony_configuration_api_v1_organizations_telephony_configs__config_id__put","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationUpdateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationDetail"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main","organizations"],"summary":"Delete Telephony Configuration","operationId":"delete_telephony_configuration_api_v1_organizations_telephony_configs__config_id__delete","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}/set-default-outbound":{"post":{"tags":["main","organizations"],"summary":"Set Default Outbound","operationId":"set_default_outbound_api_v1_organizations_telephony_configs__config_id__set_default_outbound_post","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationDetail"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}/phone-numbers":{"get":{"tags":["main","organizations"],"summary":"List Phone Numbers","operationId":"list_phone_numbers_api_v1_organizations_telephony_configs__config_id__phone_numbers_get","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberListResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main","organizations"],"summary":"Create Phone Number","operationId":"create_phone_number_api_v1_organizations_telephony_configs__config_id__phone_numbers_post","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}/phone-numbers/{phone_number_id}":{"get":{"tags":["main","organizations"],"summary":"Get Phone Number","operationId":"get_phone_number_api_v1_organizations_telephony_configs__config_id__phone_numbers__phone_number_id__get","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"phone_number_id","in":"path","required":true,"schema":{"type":"integer","title":"Phone Number Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main","organizations"],"summary":"Update Phone Number","operationId":"update_phone_number_api_v1_organizations_telephony_configs__config_id__phone_numbers__phone_number_id__put","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"phone_number_id","in":"path","required":true,"schema":{"type":"integer","title":"Phone Number Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberUpdateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main","organizations"],"summary":"Delete Phone Number","operationId":"delete_phone_number_api_v1_organizations_telephony_configs__config_id__phone_numbers__phone_number_id__delete","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"phone_number_id","in":"path","required":true,"schema":{"type":"integer","title":"Phone Number Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}/phone-numbers/{phone_number_id}/set-default-caller":{"post":{"tags":["main","organizations"],"summary":"Set Default Caller Id","operationId":"set_default_caller_id_api_v1_organizations_telephony_configs__config_id__phone_numbers__phone_number_id__set_default_caller_post","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"phone_number_id","in":"path","required":true,"schema":{"type":"integer","title":"Phone Number Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-config":{"get":{"tags":["main","organizations"],"summary":"Get Telephony Configuration","description":"Legacy: returns the org's default config in the original per-provider\nresponse shape so the existing single-form UI keeps working. Prefer the\nmulti-config endpoints (``/telephony-configs``) for new clients.","operationId":"get_telephony_configuration_api_v1_organizations_telephony_config_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main","organizations"],"summary":"Save Telephony Configuration","description":"Legacy: upserts the org's default config (and its phone numbers) in the\noriginal payload shape so existing UI clients keep working. Prefer the\nmulti-config + phone-number endpoints for new clients.","operationId":"save_telephony_configuration_api_v1_organizations_telephony_config_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ARIConfigurationRequest"},{"$ref":"#/components/schemas/CloudonixConfigurationRequest"},{"$ref":"#/components/schemas/PlivoConfigurationRequest"},{"$ref":"#/components/schemas/TelnyxConfigurationRequest"},{"$ref":"#/components/schemas/TwilioConfigurationRequest"},{"$ref":"#/components/schemas/VobizConfigurationRequest"},{"$ref":"#/components/schemas/VonageConfigurationRequest"}],"discriminator":{"propertyName":"provider","mapping":{"ari":"#/components/schemas/ARIConfigurationRequest","cloudonix":"#/components/schemas/CloudonixConfigurationRequest","plivo":"#/components/schemas/PlivoConfigurationRequest","telnyx":"#/components/schemas/TelnyxConfigurationRequest","twilio":"#/components/schemas/TwilioConfigurationRequest","vobiz":"#/components/schemas/VobizConfigurationRequest","vonage":"#/components/schemas/VonageConfigurationRequest"}},"title":"Request"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/langfuse-credentials":{"get":{"tags":["main","organizations"],"summary":"Get Langfuse Credentials","description":"Get Langfuse credentials for the user's organization with masked sensitive fields.","operationId":"get_langfuse_credentials_api_v1_organizations_langfuse_credentials_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LangfuseCredentialsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main","organizations"],"summary":"Save Langfuse Credentials","description":"Save Langfuse credentials for the user's organization.","operationId":"save_langfuse_credentials_api_v1_organizations_langfuse_credentials_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LangfuseCredentialsRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main","organizations"],"summary":"Delete Langfuse Credentials","description":"Delete Langfuse credentials for the user's organization.","operationId":"delete_langfuse_credentials_api_v1_organizations_langfuse_credentials_delete","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/campaign-defaults":{"get":{"tags":["main","organizations"],"summary":"Get Campaign Defaults","description":"Get campaign limits for the user's organization.\n\nReturns the organization's concurrent call limit and default retry configuration.","operationId":"get_campaign_defaults_api_v1_organizations_campaign_defaults_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignDefaultsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/s3/signed-url":{"get":{"tags":["main","s3"],"summary":"Generate a signed S3 URL","description":"Return a short-lived signed URL for a file stored on S3 / MinIO.\n\nAccess Control:\n* Keys that embed an organization ID (``{prefix}/{org_id}/...``) are\n authorized by matching the org_id against the requesting user's\n organization.\n* Legacy keys (``recordings/{run_id}.wav``, ``transcripts/{run_id}.txt``)\n are authorized via the workflow run they belong to.\n* Superusers can request any key.","operationId":"get_signed_url_api_v1_s3_signed_url_get","parameters":[{"name":"key","in":"query","required":true,"schema":{"type":"string","description":"S3 object key","title":"Key"},"description":"S3 object key"},{"name":"expires_in","in":"query","required":false,"schema":{"type":"integer","default":3600,"title":"Expires In"}},{"name":"inline","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Inline"}},{"name":"storage_backend","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Storage backend to use (e.g. 'minio', 's3'). When omitted the backend is inferred from the resource.","title":"Storage Backend"},"description":"Storage backend to use (e.g. 'minio', 's3'). When omitted the backend is inferred from the resource."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/S3SignedUrlResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/s3/file-metadata":{"get":{"tags":["main","s3"],"summary":"Get file metadata for debugging","description":"Get file metadata including creation timestamp for debugging.\n\nAccess Control:\n* Superusers can request any key.\n* Regular users can only request resources belonging to **their** workflow runs.","operationId":"get_file_metadata_api_v1_s3_file_metadata_get","parameters":[{"name":"key","in":"query","required":true,"schema":{"type":"string","description":"S3 object key","title":"Key"},"description":"S3 object key"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileMetadataResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/s3/presigned-upload-url":{"post":{"tags":["main","s3"],"summary":"Generate a presigned URL for direct CSV upload","description":"Generate a presigned PUT URL for direct CSV file upload to S3/MinIO.\n\nThis endpoint enables browser-to-storage uploads without passing through the backend\n\nAccess Control:\n* All authenticated users can upload CSV files scoped to their organization.\n* Files are stored with organization-scoped keys for multi-tenancy.\n\nReturns:\n* upload_url: Presigned URL (valid for 15 minutes) for PUT request\n* file_key: Unique storage key to use as source_id in campaign creation\n* expires_in: URL expiration time in seconds","operationId":"get_presigned_upload_url_api_v1_s3_presigned_upload_url_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignedUploadUrlRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignedUploadUrlResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/service-keys":{"get":{"tags":["main"],"summary":"Get Service Keys","description":"Get all service keys for the user's organization.","operationId":"get_service_keys_api_v1_user_service_keys_get","parameters":[{"name":"include_archived","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Archived"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ServiceKeyResponse"},"title":"Response Get Service Keys Api V1 User Service Keys Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main"],"summary":"Create Service Key","description":"Create a new service key for the user's organization.","operationId":"create_service_key_api_v1_user_service_keys_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateServiceKeyRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateServiceKeyResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/service-keys/{service_key_id}":{"delete":{"tags":["main"],"summary":"Archive Service Key","description":"Archive a service key.","operationId":"archive_service_key_api_v1_user_service_keys__service_key_id__delete","parameters":[{"name":"service_key_id","in":"path","required":true,"schema":{"type":"string","title":"Service Key Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/service-keys/{service_key_id}/reactivate":{"put":{"tags":["main"],"summary":"Reactivate Service Key","description":"Reactivate an archived service key.\n\nNote: This endpoint is provided for API compatibility but service key\nreactivation is not supported by MPS. Once archived, a service key\ncannot be reactivated and a new key must be created instead.","operationId":"reactivate_service_key_api_v1_user_service_keys__service_key_id__reactivate_put","parameters":[{"name":"service_key_id","in":"path","required":true,"schema":{"type":"string","title":"Service Key Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/looptalk/test-sessions":{"post":{"tags":["main"],"summary":"Create Test Session","description":"Create a new LoopTalk test session.","operationId":"create_test_session_api_v1_looptalk_test_sessions_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTestSessionRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestSessionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["main"],"summary":"List Test Sessions","description":"List LoopTalk test sessions.","operationId":"list_test_sessions_api_v1_looptalk_test_sessions_get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status"}},{"name":"load_test_group_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Load Test Group Id"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":20,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","default":0,"title":"Offset"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TestSessionResponse"},"title":"Response List Test Sessions Api V1 Looptalk Test Sessions Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/looptalk/test-sessions/{test_session_id}":{"get":{"tags":["main"],"summary":"Get Test Session","description":"Get a specific test session.","operationId":"get_test_session_api_v1_looptalk_test_sessions__test_session_id__get","parameters":[{"name":"test_session_id","in":"path","required":true,"schema":{"type":"integer","title":"Test Session Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestSessionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/looptalk/test-sessions/{test_session_id}/start":{"post":{"tags":["main"],"summary":"Start Test Session","description":"Start a LoopTalk test session.","operationId":"start_test_session_api_v1_looptalk_test_sessions__test_session_id__start_post","parameters":[{"name":"test_session_id","in":"path","required":true,"schema":{"type":"integer","title":"Test Session Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/looptalk/test-sessions/{test_session_id}/stop":{"post":{"tags":["main"],"summary":"Stop Test Session","description":"Stop a running test session.","operationId":"stop_test_session_api_v1_looptalk_test_sessions__test_session_id__stop_post","parameters":[{"name":"test_session_id","in":"path","required":true,"schema":{"type":"integer","title":"Test Session Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/looptalk/test-sessions/{test_session_id}/conversation":{"get":{"tags":["main"],"summary":"Get Test Session Conversation","description":"Get conversation details for a test session.","operationId":"get_test_session_conversation_api_v1_looptalk_test_sessions__test_session_id__conversation_get","parameters":[{"name":"test_session_id","in":"path","required":true,"schema":{"type":"integer","title":"Test Session Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/looptalk/load-tests":{"post":{"tags":["main"],"summary":"Create Load Test","description":"Create and start a load test.","operationId":"create_load_test_api_v1_looptalk_load_tests_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateLoadTestRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Create Load Test Api V1 Looptalk Load Tests Post"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/looptalk/load-tests/{load_test_group_id}/stats":{"get":{"tags":["main"],"summary":"Get Load Test Stats","description":"Get statistics for a load test group.","operationId":"get_load_test_stats_api_v1_looptalk_load_tests__load_test_group_id__stats_get","parameters":[{"name":"load_test_group_id","in":"path","required":true,"schema":{"type":"string","title":"Load Test Group Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoadTestStatsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/looptalk/active-tests":{"get":{"tags":["main"],"summary":"Get Active Tests","description":"Get information about currently active test sessions.","operationId":"get_active_tests_api_v1_looptalk_active_tests_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/current-period":{"get":{"tags":["main"],"summary":"Get Current Period Usage","description":"Get current billing period usage for the user's organization.","operationId":"get_current_period_usage_api_v1_organizations_usage_current_period_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CurrentUsageResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/mps-credits":{"get":{"tags":["main"],"summary":"Get Mps Credits","description":"Get aggregated usage and quota from MPS.\n\nOSS users: queries by provider_id (created_by).\nHosted users: queries by organization_id.","operationId":"get_mps_credits_api_v1_organizations_usage_mps_credits_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MPSCreditsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/runs":{"get":{"tags":["main"],"summary":"Get Usage History","description":"Get paginated workflow runs with usage for the organization.","operationId":"get_usage_history_api_v1_organizations_usage_runs_get","parameters":[{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`.","examples":["2026-04-01T00:00:00Z"],"title":"Start Date"},"description":"ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`."},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`.","examples":["2026-05-01T00:00:00Z"],"title":"End Date"},"description":"ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`."},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":50,"title":"Limit"}},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded array of filter objects. Each object has the shape:\n\n```json\n{ \"attribute\": \"\", \"type\": \"\", \"value\": }\n```\n\nSupported `attribute` / `type` / `value` combinations:\n\n| attribute | type | value shape | matches |\n|-----------------|---------------|----------------------------------------------|------------------------------------------------------|\n| `runId` | `number` | `{ \"value\": 12345 }` | exact run id |\n| `workflowId` | `number` | `{ \"value\": 42 }` | exact agent (workflow) id |\n| `campaignId` | `number` | `{ \"value\": 7 }` | exact campaign id |\n| `callerNumber` | `text` | `{ \"value\": \"415555\" }` | substring match on `initial_context.caller_number` |\n| `calledNumber` | `text` | `{ \"value\": \"9911848\" }` | substring match on `initial_context.called_number` |\n| `dispositionCode` | `multiSelect` | `{ \"codes\": [\"XFER\", \"DNC\"] }` | any of the codes in `gathered_context.mapped_call_disposition` |\n| `duration` | `numberRange` | `{ \"min\": 60, \"max\": 300 }` | call duration (seconds), inclusive bounds |\n\nUnknown attributes and unsupported `type` values are silently ignored.\n\nDate filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.\n","examples":["[{\"attribute\":\"callerNumber\",\"type\":\"text\",\"value\":{\"value\":\"415555\"}}]","[{\"attribute\":\"campaignId\",\"type\":\"number\",\"value\":{\"value\":7}},{\"attribute\":\"duration\",\"type\":\"numberRange\",\"value\":{\"min\":60,\"max\":300}}]","[{\"attribute\":\"dispositionCode\",\"type\":\"multiSelect\",\"value\":{\"codes\":[\"XFER\",\"DNC\"]}}]"],"title":"Filters"},"description":"JSON-encoded array of filter objects. Each object has the shape:\n\n```json\n{ \"attribute\": \"\", \"type\": \"\", \"value\": }\n```\n\nSupported `attribute` / `type` / `value` combinations:\n\n| attribute | type | value shape | matches |\n|-----------------|---------------|----------------------------------------------|------------------------------------------------------|\n| `runId` | `number` | `{ \"value\": 12345 }` | exact run id |\n| `workflowId` | `number` | `{ \"value\": 42 }` | exact agent (workflow) id |\n| `campaignId` | `number` | `{ \"value\": 7 }` | exact campaign id |\n| `callerNumber` | `text` | `{ \"value\": \"415555\" }` | substring match on `initial_context.caller_number` |\n| `calledNumber` | `text` | `{ \"value\": \"9911848\" }` | substring match on `initial_context.called_number` |\n| `dispositionCode` | `multiSelect` | `{ \"codes\": [\"XFER\", \"DNC\"] }` | any of the codes in `gathered_context.mapped_call_disposition` |\n| `duration` | `numberRange` | `{ \"min\": 60, \"max\": 300 }` | call duration (seconds), inclusive bounds |\n\nUnknown attributes and unsupported `type` values are silently ignored.\n\nDate filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.\n"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsageHistoryResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/runs/report":{"get":{"tags":["main"],"summary":"Download Usage Runs Report","description":"Download a CSV of runs matching the same filters as `/usage/runs`.","operationId":"download_usage_runs_report_api_v1_organizations_usage_runs_report_get","parameters":[{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`.","title":"Start Date"},"description":"ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`."},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`.","title":"End Date"},"description":"ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`."},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded array of filter objects. Each object has the shape:\n\n```json\n{ \"attribute\": \"\", \"type\": \"\", \"value\": }\n```\n\nSupported `attribute` / `type` / `value` combinations:\n\n| attribute | type | value shape | matches |\n|-----------------|---------------|----------------------------------------------|------------------------------------------------------|\n| `runId` | `number` | `{ \"value\": 12345 }` | exact run id |\n| `workflowId` | `number` | `{ \"value\": 42 }` | exact agent (workflow) id |\n| `campaignId` | `number` | `{ \"value\": 7 }` | exact campaign id |\n| `callerNumber` | `text` | `{ \"value\": \"415555\" }` | substring match on `initial_context.caller_number` |\n| `calledNumber` | `text` | `{ \"value\": \"9911848\" }` | substring match on `initial_context.called_number` |\n| `dispositionCode` | `multiSelect` | `{ \"codes\": [\"XFER\", \"DNC\"] }` | any of the codes in `gathered_context.mapped_call_disposition` |\n| `duration` | `numberRange` | `{ \"min\": 60, \"max\": 300 }` | call duration (seconds), inclusive bounds |\n\nUnknown attributes and unsupported `type` values are silently ignored.\n\nDate filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.\n","title":"Filters"},"description":"JSON-encoded array of filter objects. Each object has the shape:\n\n```json\n{ \"attribute\": \"\", \"type\": \"\", \"value\": }\n```\n\nSupported `attribute` / `type` / `value` combinations:\n\n| attribute | type | value shape | matches |\n|-----------------|---------------|----------------------------------------------|------------------------------------------------------|\n| `runId` | `number` | `{ \"value\": 12345 }` | exact run id |\n| `workflowId` | `number` | `{ \"value\": 42 }` | exact agent (workflow) id |\n| `campaignId` | `number` | `{ \"value\": 7 }` | exact campaign id |\n| `callerNumber` | `text` | `{ \"value\": \"415555\" }` | substring match on `initial_context.caller_number` |\n| `calledNumber` | `text` | `{ \"value\": \"9911848\" }` | substring match on `initial_context.called_number` |\n| `dispositionCode` | `multiSelect` | `{ \"codes\": [\"XFER\", \"DNC\"] }` | any of the codes in `gathered_context.mapped_call_disposition` |\n| `duration` | `numberRange` | `{ \"min\": 60, \"max\": 300 }` | call duration (seconds), inclusive bounds |\n\nUnknown attributes and unsupported `type` values are silently ignored.\n\nDate filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.\n"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/daily-breakdown":{"get":{"tags":["main"],"summary":"Get Daily Usage Breakdown","description":"Get daily usage breakdown for the last N days. Only available for organizations with pricing.","operationId":"get_daily_usage_breakdown_api_v1_organizations_usage_daily_breakdown_get","parameters":[{"name":"days","in":"query","required":false,"schema":{"type":"integer","maximum":30,"minimum":1,"description":"Number of days to include","default":7,"title":"Days"},"description":"Number of days to include"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DailyUsageBreakdownResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/reports/daily":{"get":{"tags":["main"],"summary":"Get Daily Report","description":"Get daily report for the specified date and timezone.\nIf workflow_id is provided, filters results to that specific workflow.\nIf workflow_id is None, includes all workflows for the organization.","operationId":"get_daily_report_api_v1_organizations_reports_daily_get","parameters":[{"name":"date","in":"query","required":true,"schema":{"type":"string","description":"Date in YYYY-MM-DD format","title":"Date"},"description":"Date in YYYY-MM-DD format"},{"name":"timezone","in":"query","required":true,"schema":{"type":"string","description":"IANA timezone (e.g., 'America/New_York')","title":"Timezone"},"description":"IANA timezone (e.g., 'America/New_York')"},{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Optional workflow ID to filter by","title":"Workflow Id"},"description":"Optional workflow ID to filter by"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DailyReportResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/reports/workflows":{"get":{"tags":["main"],"summary":"Get Workflow Options","description":"Get all workflows for the user's organization.\nUsed to populate the workflow selector dropdown in the reports page.","operationId":"get_workflow_options_api_v1_organizations_reports_workflows_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowOption"},"title":"Response Get Workflow Options Api V1 Organizations Reports Workflows Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/reports/daily/runs":{"get":{"tags":["main"],"summary":"Get Daily Runs Detail","description":"Get detailed workflow runs for the specified date.\nUsed for CSV export functionality.","operationId":"get_daily_runs_detail_api_v1_organizations_reports_daily_runs_get","parameters":[{"name":"date","in":"query","required":true,"schema":{"type":"string","description":"Date in YYYY-MM-DD format","title":"Date"},"description":"Date in YYYY-MM-DD format"},{"name":"timezone","in":"query","required":true,"schema":{"type":"string","description":"IANA timezone (e.g., 'America/New_York')","title":"Timezone"},"description":"IANA timezone (e.g., 'America/New_York')"},{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Optional workflow ID to filter by","title":"Workflow Id"},"description":"Optional workflow ID to filter by"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowRunDetail"},"title":"Response Get Daily Runs Detail Api V1 Organizations Reports Daily Runs Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/turn/credentials":{"get":{"tags":["main","turn"],"summary":"Get Turn Credentials","description":"Get time-limited TURN credentials for WebRTC connections.\n\nThis endpoint generates ephemeral TURN credentials that are:\n- Valid for the configured TTL (default: 24 hours)\n- Cryptographically bound to the user via HMAC\n- Compatible with coturn's use-auth-secret mode\n\nReturns:\n TurnCredentialsResponse with username, password, ttl, and TURN URIs","operationId":"get_turn_credentials_api_v1_turn_credentials_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TurnCredentialsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/embed/init":{"post":{"tags":["main"],"summary":"Initialize Embed Session","description":"Initialize an embed session with token validation and domain checking.\n\nThis endpoint:\n1. Validates the embed token\n2. Checks domain whitelist\n3. Creates a workflow run\n4. Generates a temporary session token\n5. Returns configuration for the widget","operationId":"initialize_embed_session_api_v1_public_embed_init_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InitEmbedRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InitEmbedResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"options":{"tags":["main"],"summary":"Options Init","description":"Handle CORS preflight for init endpoint","operationId":"options_init_api_v1_public_embed_init_options","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"}}}},"/api/v1/public/embed/config/{token}":{"get":{"tags":["main"],"summary":"Get Embed Config","description":"Get embed configuration without creating a session.\n\nThis endpoint is used to fetch widget configuration for display purposes\nwithout actually starting a call session.","operationId":"get_embed_config_api_v1_public_embed_config__token__get","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","title":"Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmbedConfigResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"options":{"tags":["main"],"summary":"Options Config","description":"Handle CORS preflight for config endpoint","operationId":"options_config_api_v1_public_embed_config__token__options","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","title":"Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/embed/turn-credentials/{session_token}":{"get":{"tags":["main"],"summary":"Get Public Turn Credentials","description":"Get TURN credentials for an embed session.\n\nThis endpoint allows embedded widgets to obtain TURN server credentials\nfor WebRTC connections without requiring authentication.\n\nArgs:\n session_token: The session token from embed initialization\n\nReturns:\n TurnCredentialsResponse with username, password, ttl, and TURN URIs","operationId":"get_public_turn_credentials_api_v1_public_embed_turn_credentials__session_token__get","parameters":[{"name":"session_token","in":"path","required":true,"schema":{"type":"string","title":"Session Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TurnCredentialsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"options":{"tags":["main"],"summary":"Options Turn Credentials","description":"Handle CORS preflight for TURN credentials endpoint","operationId":"options_turn_credentials_api_v1_public_embed_turn_credentials__session_token__options","parameters":[{"name":"session_token","in":"path","required":true,"schema":{"type":"string","title":"Session Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/agent/{uuid}":{"post":{"tags":["main"],"summary":"Initiate Call","description":"Initiate a phone call against the published agent.\n\nExecutes the workflow's currently released definition.","operationId":"initiate_call_api_v1_public_agent__uuid__post","parameters":[{"name":"uuid","in":"path","required":true,"schema":{"type":"string","title":"Uuid"}},{"name":"X-API-Key","in":"header","required":true,"schema":{"type":"string","title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/agent/test/{uuid}":{"post":{"tags":["main"],"summary":"Initiate Call Test","description":"Initiate a phone call against the latest draft of the agent.\n\nUseful for verifying changes before publishing. Falls back to the\npublished definition when no draft exists.","operationId":"initiate_call_test_api_v1_public_agent_test__uuid__post","parameters":[{"name":"uuid","in":"path","required":true,"schema":{"type":"string","title":"Uuid"}},{"name":"X-API-Key","in":"header","required":true,"schema":{"type":"string","title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/download/workflow/{token}/{artifact_type}":{"get":{"tags":["main"],"summary":"Download Workflow Artifact","description":"Download a workflow recording or transcript via public access token.\n\nThis endpoint:\n1. Validates the public access token\n2. Looks up the corresponding workflow run\n3. Generates a signed URL for the requested artifact\n4. Redirects to the signed URL\n\nArgs:\n token: The public access token (UUID format)\n artifact_type: Type of artifact - \"recording\" or \"transcript\"\n inline: If true, sets Content-Disposition to inline for browser preview\n\nReturns:\n RedirectResponse to the signed URL (302 redirect)\n\nRaises:\n HTTPException 404: If token is invalid or artifact not found","operationId":"download_workflow_artifact_api_v1_public_download_workflow__token___artifact_type__get","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","title":"Token"}},{"name":"artifact_type","in":"path","required":true,"schema":{"enum":["recording","transcript"],"type":"string","title":"Artifact Type"}},{"name":"inline","in":"query","required":false,"schema":{"type":"boolean","description":"Display inline in browser instead of download","default":false,"title":"Inline"},"description":"Display inline in browser instead of download"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/embed-token":{"post":{"tags":["main"],"summary":"Create Or Update Embed Token","description":"Create or update an embed token for a workflow.\nEach workflow can have only one active embed token.","operationId":"create_or_update_embed_token_api_v1_workflow__workflow_id__embed_token_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmbedTokenRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmbedTokenResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["main"],"summary":"Get Embed Token","description":"Get the embed token for a workflow if it exists.","operationId":"get_embed_token_api_v1_workflow__workflow_id__embed_token_get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/EmbedTokenResponse"},{"type":"null"}],"title":"Response Get Embed Token Api V1 Workflow Workflow Id Embed Token Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main"],"summary":"Deactivate Embed Token","description":"Deactivate the embed token for a workflow.","operationId":"deactivate_embed_token_api_v1_workflow__workflow_id__embed_token_delete","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Deactivate Embed Token Api V1 Workflow Workflow Id Embed Token Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/knowledge-base/upload-url":{"post":{"tags":["main","knowledge-base"],"summary":"Get presigned URL for document upload","description":"Generate a presigned PUT URL for uploading a document.\n\nThis endpoint:\n1. Generates a unique document UUID for organizing the S3 key\n2. Generates a presigned S3/MinIO URL for uploading the file\n3. Returns the upload URL and document metadata\n\nAfter uploading to the returned URL, call /process-document to create\nthe document record and trigger processing.\n\nAccess Control:\n* All authenticated users can upload documents scoped to their organization.","operationId":"get_upload_url_api_v1_knowledge_base_upload_url_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentUploadRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentUploadResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/knowledge-base/process-document":{"post":{"tags":["main","knowledge-base"],"summary":"Trigger document processing","description":"Trigger asynchronous processing of an uploaded document.\n\nThis endpoint should be called after successfully uploading a file to the presigned URL.\nIt will:\n1. Create a document record in the database with the specified UUID\n2. Enqueue a background task to process the document (chunking and embedding)\n\nThe document status will be updated from 'pending' -> 'processing' -> 'completed' or 'failed'.\n\nEmbedding:\nUses OpenAI text-embedding-3-small (1536-dimensional embeddings, requires API key configured in Model Configurations).\n\nAccess Control:\n* Users can only process documents in their organization.","operationId":"process_document_api_v1_knowledge_base_process_document_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProcessDocumentRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/knowledge-base/documents":{"get":{"tags":["main","knowledge-base"],"summary":"List documents","description":"List all documents for the user's organization.\n\nAccess Control:\n* Users can only see documents from their organization.","operationId":"list_documents_api_v1_knowledge_base_documents_get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by processing status","title":"Status"},"description":"Filter by processing status"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":100,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_documents","x-sdk-description":"List knowledge base documents available to the authenticated organization."}},"/api/v1/knowledge-base/documents/{document_uuid}":{"get":{"tags":["main","knowledge-base"],"summary":"Get document details","description":"Get details of a specific document.\n\nAccess Control:\n* Users can only access documents from their organization.","operationId":"get_document_api_v1_knowledge_base_documents__document_uuid__get","parameters":[{"name":"document_uuid","in":"path","required":true,"schema":{"type":"string","title":"Document Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main","knowledge-base"],"summary":"Delete document","description":"Soft delete a document and its chunks.\n\nAccess Control:\n* Users can only delete documents from their organization.","operationId":"delete_document_api_v1_knowledge_base_documents__document_uuid__delete","parameters":[{"name":"document_uuid","in":"path","required":true,"schema":{"type":"string","title":"Document Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/knowledge-base/search":{"post":{"tags":["main","knowledge-base"],"summary":"Search for similar chunks","description":"Search for document chunks similar to the query.\n\nThis endpoint uses vector similarity search to find relevant chunks.\nResults are returned without threshold filtering - apply similarity\nthresholds at the application layer after optional reranking.\n\nAccess Control:\n* Users can only search documents from their organization.","operationId":"search_chunks_api_v1_knowledge_base_search_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChunkSearchRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChunkSearchResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow-recordings/upload-url":{"post":{"tags":["main","workflow-recordings"],"summary":"Get presigned URLs for recording uploads","description":"Generate presigned PUT URLs for uploading one or more audio recordings.","operationId":"get_upload_urls_api_v1_workflow_recordings_upload_url_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchRecordingUploadRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchRecordingUploadResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow-recordings/":{"post":{"tags":["main","workflow-recordings"],"summary":"Create recording records after upload","description":"Create one or more recording records after audio files have been uploaded.","operationId":"create_recordings_api_v1_workflow_recordings__post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchRecordingCreateRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchRecordingCreateResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["main","workflow-recordings"],"summary":"List recordings","description":"List recordings for the organization, optionally filtered.","operationId":"list_recordings_api_v1_workflow_recordings__get","parameters":[{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filter by workflow ID","title":"Workflow Id"},"description":"Filter by workflow ID"},{"name":"tts_provider","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by TTS provider","title":"Tts Provider"},"description":"Filter by TTS provider"},{"name":"tts_model","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by TTS model","title":"Tts Model"},"description":"Filter by TTS model"},{"name":"tts_voice_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by TTS voice ID","title":"Tts Voice Id"},"description":"Filter by TTS voice ID"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordingListResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_recordings","x-sdk-description":"List workflow recordings available to the authenticated organization."}},"/api/v1/workflow-recordings/{recording_id}":{"delete":{"tags":["main","workflow-recordings"],"summary":"Delete a recording","description":"Soft delete a recording.","operationId":"delete_recording_api_v1_workflow_recordings__recording_id__delete","parameters":[{"name":"recording_id","in":"path","required":true,"schema":{"type":"string","title":"Recording Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow-recordings/{id}":{"patch":{"tags":["main","workflow-recordings"],"summary":"Update a recording's Recording ID","description":"Update the recording_id (descriptive name) of a recording.","operationId":"update_recording_api_v1_workflow_recordings__id__patch","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordingUpdateRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordingResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow-recordings/transcribe":{"post":{"tags":["main","workflow-recordings"],"summary":"Transcribe an audio file","description":"Transcribe an uploaded audio file using MPS STT.","operationId":"transcribe_audio_api_v1_workflow_recordings_transcribe_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_transcribe_audio_api_v1_workflow_recordings_transcribe_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/signup":{"post":{"tags":["main","auth"],"summary":"Signup","operationId":"signup_api_v1_auth_signup_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/login":{"post":{"tags":["main","auth"],"summary":"Login","operationId":"login_api_v1_auth_login_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/me":{"get":{"tags":["main","auth"],"summary":"Get Current User","operationId":"get_current_user_api_v1_auth_me_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/node-types":{"get":{"tags":["main"],"summary":"List Node Types","description":"List every registered NodeSpec.\n\nSDK clients should pin to `spec_version` and warn if the server reports\na higher version than what they were generated against.","operationId":"list_node_types_api_v1_node_types_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeTypesResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_node_types","x-sdk-description":"List every registered node type with its spec. Pinned to spec_version."}},"/api/v1/node-types/{name}":{"get":{"tags":["main"],"summary":"Get Node Type","operationId":"get_node_type_api_v1_node_types__name__get","parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeSpec"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"get_node_type","x-sdk-description":"Fetch a single node spec by name."}},"/api/v1/health":{"get":{"tags":["main"],"summary":"Health","operationId":"health_api_v1_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}},"404":{"description":"Not found"}}}}},"components":{"schemas":{"APIKeyResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"key_prefix":{"type":"string","title":"Key Prefix"},"is_active":{"type":"boolean","title":"Is Active"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"last_used_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Used At"},"archived_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Archived At"}},"type":"object","required":["id","name","key_prefix","is_active","created_at"],"title":"APIKeyResponse"},"APIKeyStatus":{"properties":{"model":{"type":"string","title":"Model"},"message":{"type":"string","title":"Message"}},"type":"object","required":["model","message"],"title":"APIKeyStatus"},"APIKeyStatusResponse":{"properties":{"status":{"items":{"$ref":"#/components/schemas/APIKeyStatus"},"type":"array","title":"Status"}},"type":"object","required":["status"],"title":"APIKeyStatusResponse"},"ARIConfigurationRequest":{"properties":{"provider":{"type":"string","const":"ari","title":"Provider","default":"ari"},"ari_endpoint":{"type":"string","title":"Ari Endpoint","description":"ARI base URL (e.g., http://asterisk.example.com:8088)"},"app_name":{"type":"string","title":"App Name","description":"Stasis application name registered in Asterisk"},"app_password":{"type":"string","title":"App Password","description":"ARI user password"},"ws_client_name":{"type":"string","title":"Ws Client Name","description":"websocket_client.conf connection name for externalMedia (e.g., dograh_staging)","default":""},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of SIP extensions/numbers for outbound calls (optional)"}},"type":"object","required":["ari_endpoint","app_name","app_password"],"title":"ARIConfigurationRequest","description":"Request schema for Asterisk ARI configuration."},"ARIConfigurationResponse":{"properties":{"provider":{"type":"string","const":"ari","title":"Provider","default":"ari"},"ari_endpoint":{"type":"string","title":"Ari Endpoint"},"app_name":{"type":"string","title":"App Name"},"app_password":{"type":"string","title":"App Password"},"ws_client_name":{"type":"string","title":"Ws Client Name","default":""},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["ari_endpoint","app_name","app_password","from_numbers"],"title":"ARIConfigurationResponse","description":"Response schema for ARI configuration with masked sensitive fields."},"AccessTokenResponse":{"properties":{"access_token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Access Token"},"refresh_token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Refresh Token"},"expires_at":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Expires At"},"connection_id":{"type":"string","title":"Connection Id"}},"type":"object","required":["access_token","refresh_token","expires_at","connection_id"],"title":"AccessTokenResponse"},"AmbientNoiseUploadRequest":{"properties":{"workflow_id":{"type":"integer","title":"Workflow Id"},"filename":{"type":"string","title":"Filename"},"mime_type":{"type":"string","title":"Mime Type","default":"audio/wav"},"file_size":{"type":"integer","maximum":10485760.0,"exclusiveMinimum":0.0,"title":"File Size","description":"Max 10MB"}},"type":"object","required":["workflow_id","filename","file_size"],"title":"AmbientNoiseUploadRequest"},"AmbientNoiseUploadResponse":{"properties":{"upload_url":{"type":"string","title":"Upload Url"},"storage_key":{"type":"string","title":"Storage Key"},"storage_backend":{"type":"string","title":"Storage Backend"}},"type":"object","required":["upload_url","storage_key","storage_backend"],"title":"AmbientNoiseUploadResponse"},"AuthResponse":{"properties":{"token":{"type":"string","title":"Token"},"user":{"$ref":"#/components/schemas/UserResponse"}},"type":"object","required":["token","user"],"title":"AuthResponse"},"AuthUserResponse":{"properties":{"id":{"type":"integer","title":"Id"},"is_superuser":{"type":"boolean","title":"Is Superuser"}},"type":"object","required":["id","is_superuser"],"title":"AuthUserResponse"},"BatchRecordingCreateRequestSchema":{"properties":{"recordings":{"items":{"$ref":"#/components/schemas/RecordingCreateRequestSchema"},"type":"array","maxItems":20,"minItems":1,"title":"Recordings","description":"List of recordings to create"}},"type":"object","required":["recordings"],"title":"BatchRecordingCreateRequestSchema","description":"Request schema for creating one or more recording records after upload."},"BatchRecordingCreateResponseSchema":{"properties":{"recordings":{"items":{"$ref":"#/components/schemas/RecordingResponseSchema"},"type":"array","title":"Recordings","description":"Created recording records"}},"type":"object","required":["recordings"],"title":"BatchRecordingCreateResponseSchema","description":"Response schema for recording creation."},"BatchRecordingUploadRequestSchema":{"properties":{"files":{"items":{"$ref":"#/components/schemas/FileDescriptor"},"type":"array","maxItems":20,"minItems":1,"title":"Files","description":"List of files to upload"}},"type":"object","required":["files"],"title":"BatchRecordingUploadRequestSchema","description":"Request schema for getting presigned upload URLs for one or more files."},"BatchRecordingUploadResponseSchema":{"properties":{"items":{"items":{"$ref":"#/components/schemas/RecordingUploadResponseSchema"},"type":"array","title":"Items","description":"Upload URLs for each file"}},"type":"object","required":["items"],"title":"BatchRecordingUploadResponseSchema","description":"Response schema with presigned upload URLs."},"Body_transcribe_audio_api_v1_workflow_recordings_transcribe_post":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File"},"language":{"type":"string","title":"Language","default":"en"}},"type":"object","required":["file"],"title":"Body_transcribe_audio_api_v1_workflow_recordings_transcribe_post"},"CalculatorToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version","default":1},"type":{"type":"string","const":"calculator","title":"Type","description":"Tool type"}},"type":"object","required":["type"],"title":"CalculatorToolDefinition","description":"Tool definition for Calculator tools (no configuration needed)."},"CallDispositionCodes":{"properties":{"disposition_codes":{"items":{"type":"string"},"type":"array","title":"Disposition Codes","default":[]}},"type":"object","title":"CallDispositionCodes"},"CallType":{"type":"string","enum":["inbound","outbound"],"title":"CallType"},"CampaignDefaultsResponse":{"properties":{"concurrent_call_limit":{"type":"integer","title":"Concurrent Call Limit"},"from_numbers_count":{"type":"integer","title":"From Numbers Count"},"default_retry_config":{"$ref":"#/components/schemas/RetryConfigResponse"},"last_campaign_settings":{"anyOf":[{"$ref":"#/components/schemas/LastCampaignSettingsResponse"},{"type":"null"}]}},"type":"object","required":["concurrent_call_limit","from_numbers_count","default_retry_config"],"title":"CampaignDefaultsResponse"},"CampaignLogEntryResponse":{"properties":{"ts":{"type":"string","title":"Ts"},"level":{"type":"string","title":"Level"},"event":{"type":"string","title":"Event"},"message":{"type":"string","title":"Message"},"details":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Details"}},"type":"object","required":["ts","level","event","message"],"title":"CampaignLogEntryResponse","description":"A single timestamped entry from the campaign's append-only log.\n\nSurfaced in the UI so operators can see why a campaign moved to\npaused / failed without digging through server logs."},"CampaignProgressResponse":{"properties":{"campaign_id":{"type":"integer","title":"Campaign Id"},"state":{"type":"string","title":"State"},"total_rows":{"type":"integer","title":"Total Rows"},"processed_rows":{"type":"integer","title":"Processed Rows"},"failed_calls":{"type":"integer","title":"Failed Calls"},"progress_percentage":{"type":"number","title":"Progress Percentage"},"source_sync":{"additionalProperties":true,"type":"object","title":"Source Sync"},"rate_limit":{"type":"integer","title":"Rate Limit"},"started_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Started At"},"completed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Completed At"}},"type":"object","required":["campaign_id","state","total_rows","processed_rows","failed_calls","progress_percentage","source_sync","rate_limit","started_at","completed_at"],"title":"CampaignProgressResponse"},"CampaignResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"workflow_id":{"type":"integer","title":"Workflow Id"},"workflow_name":{"type":"string","title":"Workflow Name"},"state":{"type":"string","title":"State"},"source_type":{"type":"string","title":"Source Type"},"source_id":{"type":"string","title":"Source Id"},"total_rows":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Total Rows"},"processed_rows":{"type":"integer","title":"Processed Rows"},"failed_rows":{"type":"integer","title":"Failed Rows"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"started_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Started At"},"completed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Completed At"},"retry_config":{"$ref":"#/components/schemas/RetryConfigResponse"},"max_concurrency":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Concurrency"},"schedule_config":{"anyOf":[{"$ref":"#/components/schemas/ScheduleConfigResponse"},{"type":"null"}]},"circuit_breaker":{"anyOf":[{"$ref":"#/components/schemas/CircuitBreakerConfigResponse"},{"type":"null"}]},"executed_count":{"type":"integer","title":"Executed Count","default":0},"total_queued_count":{"type":"integer","title":"Total Queued Count","default":0},"parent_campaign_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Parent Campaign Id"},"redialed_campaign_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Redialed Campaign Id"},"telephony_configuration_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Telephony Configuration Id"},"telephony_configuration_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Telephony Configuration Name"},"logs":{"items":{"$ref":"#/components/schemas/CampaignLogEntryResponse"},"type":"array","title":"Logs"}},"type":"object","required":["id","name","workflow_id","workflow_name","state","source_type","source_id","total_rows","processed_rows","failed_rows","created_at","started_at","completed_at","retry_config"],"title":"CampaignResponse"},"CampaignRunsResponse":{"properties":{"runs":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Runs"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"limit":{"type":"integer","title":"Limit"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["runs","total_count","page","limit","total_pages"],"title":"CampaignRunsResponse","description":"Paginated response for campaign workflow runs"},"CampaignSourceDownloadResponse":{"properties":{"download_url":{"type":"string","title":"Download Url"},"expires_in":{"type":"integer","title":"Expires In"}},"type":"object","required":["download_url","expires_in"],"title":"CampaignSourceDownloadResponse"},"CampaignsResponse":{"properties":{"campaigns":{"items":{"$ref":"#/components/schemas/CampaignResponse"},"type":"array","title":"Campaigns"}},"type":"object","required":["campaigns"],"title":"CampaignsResponse"},"ChunkResponseSchema":{"properties":{"id":{"type":"integer","title":"Id"},"document_id":{"type":"integer","title":"Document Id"},"chunk_text":{"type":"string","title":"Chunk Text"},"contextualized_text":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Contextualized Text"},"chunk_index":{"type":"integer","title":"Chunk Index"},"chunk_metadata":{"additionalProperties":true,"type":"object","title":"Chunk Metadata"},"filename":{"type":"string","title":"Filename"},"document_uuid":{"type":"string","title":"Document Uuid"},"similarity":{"type":"number","title":"Similarity"}},"type":"object","required":["id","document_id","chunk_text","contextualized_text","chunk_index","chunk_metadata","filename","document_uuid","similarity"],"title":"ChunkResponseSchema","description":"Response schema for a document chunk."},"ChunkSearchRequestSchema":{"properties":{"query":{"type":"string","title":"Query","description":"Search query text"},"limit":{"type":"integer","maximum":50.0,"minimum":1.0,"title":"Limit","description":"Maximum number of results","default":5},"document_uuids":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Document Uuids","description":"Filter by specific document UUIDs"},"min_similarity":{"anyOf":[{"type":"number","maximum":1.0,"minimum":0.0},{"type":"null"}],"title":"Min Similarity","description":"Minimum similarity threshold"}},"type":"object","required":["query"],"title":"ChunkSearchRequestSchema","description":"Request schema for searching similar chunks."},"ChunkSearchResponseSchema":{"properties":{"chunks":{"items":{"$ref":"#/components/schemas/ChunkResponseSchema"},"type":"array","title":"Chunks"},"query":{"type":"string","title":"Query"},"total_results":{"type":"integer","title":"Total Results"}},"type":"object","required":["chunks","query","total_results"],"title":"ChunkSearchResponseSchema","description":"Response schema for chunk search results."},"CircuitBreakerConfigRequest":{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":true},"failure_threshold":{"type":"number","maximum":1.0,"minimum":0.0,"title":"Failure Threshold","default":0.5},"window_seconds":{"type":"integer","maximum":600.0,"minimum":30.0,"title":"Window Seconds","default":120},"min_calls_in_window":{"type":"integer","maximum":100.0,"minimum":1.0,"title":"Min Calls In Window","default":5}},"type":"object","title":"CircuitBreakerConfigRequest"},"CircuitBreakerConfigResponse":{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":false},"failure_threshold":{"type":"number","title":"Failure Threshold","default":0.5},"window_seconds":{"type":"integer","title":"Window Seconds","default":120},"min_calls_in_window":{"type":"integer","title":"Min Calls In Window","default":5}},"type":"object","title":"CircuitBreakerConfigResponse"},"CloudonixConfigurationRequest":{"properties":{"provider":{"type":"string","const":"cloudonix","title":"Provider","default":"cloudonix"},"bearer_token":{"type":"string","title":"Bearer Token","description":"Cloudonix API Bearer Token"},"domain_id":{"type":"string","title":"Domain Id","description":"Cloudonix Domain ID"},"application_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Name","description":"Cloudonix Voice Application name. The application's url is updated when inbound workflows are attached to numbers on this domain. If omitted, an application is auto-created on save and its name is stored on the configuration."},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Cloudonix phone numbers (optional)"}},"type":"object","required":["bearer_token","domain_id"],"title":"CloudonixConfigurationRequest","description":"Request schema for Cloudonix configuration."},"CloudonixConfigurationResponse":{"properties":{"provider":{"type":"string","const":"cloudonix","title":"Provider","default":"cloudonix"},"bearer_token":{"type":"string","title":"Bearer Token"},"domain_id":{"type":"string","title":"Domain Id"},"application_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Name"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["bearer_token","domain_id","from_numbers"],"title":"CloudonixConfigurationResponse","description":"Response schema for Cloudonix configuration with masked sensitive fields."},"CreateAPIKeyRequest":{"properties":{"name":{"type":"string","title":"Name"}},"type":"object","required":["name"],"title":"CreateAPIKeyRequest"},"CreateAPIKeyResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"key_prefix":{"type":"string","title":"Key Prefix"},"api_key":{"type":"string","title":"Api Key"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","name","key_prefix","api_key","created_at"],"title":"CreateAPIKeyResponse"},"CreateCampaignRequest":{"properties":{"name":{"type":"string","maxLength":255,"minLength":1,"title":"Name"},"workflow_id":{"type":"integer","title":"Workflow Id"},"source_type":{"type":"string","pattern":"^(google-sheet|csv)$","title":"Source Type"},"source_id":{"type":"string","title":"Source Id"},"telephony_configuration_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Telephony Configuration Id"},"retry_config":{"anyOf":[{"$ref":"#/components/schemas/RetryConfigRequest"},{"type":"null"}]},"max_concurrency":{"anyOf":[{"type":"integer","maximum":100.0,"minimum":1.0},{"type":"null"}],"title":"Max Concurrency"},"schedule_config":{"anyOf":[{"$ref":"#/components/schemas/ScheduleConfigRequest"},{"type":"null"}]},"circuit_breaker":{"anyOf":[{"$ref":"#/components/schemas/CircuitBreakerConfigRequest"},{"type":"null"}]}},"type":"object","required":["name","workflow_id","source_type","source_id"],"title":"CreateCampaignRequest"},"CreateCredentialRequest":{"properties":{"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"credential_type":{"$ref":"#/components/schemas/WebhookCredentialType"},"credential_data":{"additionalProperties":true,"type":"object","title":"Credential Data"}},"type":"object","required":["name","credential_type","credential_data"],"title":"CreateCredentialRequest","description":"Request schema for creating a webhook credential."},"CreateLoadTestRequest":{"properties":{"name_prefix":{"type":"string","title":"Name Prefix"},"actor_workflow_id":{"type":"integer","title":"Actor Workflow Id"},"adversary_workflow_id":{"type":"integer","title":"Adversary Workflow Id"},"test_count":{"type":"integer","maximum":10.0,"minimum":1.0,"title":"Test Count"},"config":{"additionalProperties":true,"type":"object","title":"Config"}},"type":"object","required":["name_prefix","actor_workflow_id","adversary_workflow_id","test_count"],"title":"CreateLoadTestRequest"},"CreateServiceKeyRequest":{"properties":{"name":{"type":"string","title":"Name"},"expires_in_days":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Expires In Days","default":90}},"type":"object","required":["name"],"title":"CreateServiceKeyRequest"},"CreateServiceKeyResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"service_key":{"type":"string","title":"Service Key"},"key_prefix":{"type":"string","title":"Key Prefix"},"expires_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Expires At"}},"type":"object","required":["id","name","service_key","key_prefix"],"title":"CreateServiceKeyResponse"},"CreateTestSessionRequest":{"properties":{"name":{"type":"string","title":"Name"},"actor_workflow_id":{"type":"integer","title":"Actor Workflow Id"},"adversary_workflow_id":{"type":"integer","title":"Adversary Workflow Id"},"config":{"additionalProperties":true,"type":"object","title":"Config"}},"type":"object","required":["name","actor_workflow_id","adversary_workflow_id"],"title":"CreateTestSessionRequest"},"CreateToolRequest":{"properties":{"name":{"type":"string","maxLength":255,"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"category":{"type":"string","title":"Category","default":"http_api"},"icon":{"anyOf":[{"type":"string","maxLength":50},{"type":"null"}],"title":"Icon","default":"globe"},"icon_color":{"anyOf":[{"type":"string","maxLength":7},{"type":"null"}],"title":"Icon Color","default":"#3B82F6"},"definition":{"oneOf":[{"$ref":"#/components/schemas/HttpApiToolDefinition"},{"$ref":"#/components/schemas/EndCallToolDefinition"},{"$ref":"#/components/schemas/TransferCallToolDefinition"},{"$ref":"#/components/schemas/CalculatorToolDefinition"}],"title":"Definition","discriminator":{"propertyName":"type","mapping":{"calculator":"#/components/schemas/CalculatorToolDefinition","end_call":"#/components/schemas/EndCallToolDefinition","http_api":"#/components/schemas/HttpApiToolDefinition","transfer_call":"#/components/schemas/TransferCallToolDefinition"}}}},"type":"object","required":["name","definition"],"title":"CreateToolRequest","description":"Request schema for creating a tool."},"CreateWorkflowRequest":{"properties":{"name":{"type":"string","title":"Name"},"workflow_definition":{"additionalProperties":true,"type":"object","title":"Workflow Definition"}},"type":"object","required":["name","workflow_definition"],"title":"CreateWorkflowRequest"},"CreateWorkflowRunRequest":{"properties":{"mode":{"type":"string","title":"Mode"},"name":{"type":"string","title":"Name"}},"type":"object","required":["mode","name"],"title":"CreateWorkflowRunRequest"},"CreateWorkflowRunResponse":{"properties":{"id":{"type":"integer","title":"Id"},"workflow_id":{"type":"integer","title":"Workflow Id"},"name":{"type":"string","title":"Name"},"mode":{"type":"string","title":"Mode"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"definition_id":{"type":"integer","title":"Definition Id"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"}},"type":"object","required":["id","workflow_id","name","mode","created_at","definition_id"],"title":"CreateWorkflowRunResponse"},"CreateWorkflowTemplateRequest":{"properties":{"call_type":{"type":"string","enum":["inbound","outbound"],"title":"Call Type"},"use_case":{"type":"string","title":"Use Case"},"activity_description":{"type":"string","title":"Activity Description"}},"type":"object","required":["call_type","use_case","activity_description"],"title":"CreateWorkflowTemplateRequest"},"CreatedByResponse":{"properties":{"id":{"type":"integer","title":"Id"},"provider_id":{"type":"string","title":"Provider Id"}},"type":"object","required":["id","provider_id"],"title":"CreatedByResponse","description":"Response schema for the user who created a tool."},"CredentialResponse":{"properties":{"uuid":{"type":"string","title":"Uuid"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"credential_type":{"type":"string","title":"Credential Type"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["uuid","name","description","credential_type","created_at","updated_at"],"title":"CredentialResponse","description":"Response schema for a webhook credential (never includes sensitive data)."},"CurrentUsageResponse":{"properties":{"period_start":{"type":"string","title":"Period Start"},"period_end":{"type":"string","title":"Period End"},"used_dograh_tokens":{"type":"number","title":"Used Dograh Tokens"},"quota_dograh_tokens":{"type":"integer","title":"Quota Dograh Tokens"},"percentage_used":{"type":"number","title":"Percentage Used"},"next_refresh_date":{"type":"string","title":"Next Refresh Date"},"quota_enabled":{"type":"boolean","title":"Quota Enabled"},"total_duration_seconds":{"type":"integer","title":"Total Duration Seconds"},"used_amount_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Used Amount Usd"},"quota_amount_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Quota Amount Usd"},"currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Currency"},"price_per_second_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Price Per Second Usd"}},"type":"object","required":["period_start","period_end","used_dograh_tokens","quota_dograh_tokens","percentage_used","next_refresh_date","quota_enabled","total_duration_seconds"],"title":"CurrentUsageResponse"},"DailyReportResponse":{"properties":{"date":{"type":"string","title":"Date"},"timezone":{"type":"string","title":"Timezone"},"workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Workflow Id"},"metrics":{"additionalProperties":{"type":"integer"},"type":"object","title":"Metrics"},"disposition_distribution":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Disposition Distribution"},"call_duration_distribution":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Call Duration Distribution"}},"type":"object","required":["date","timezone","workflow_id","metrics","disposition_distribution","call_duration_distribution"],"title":"DailyReportResponse"},"DailyUsageBreakdownResponse":{"properties":{"breakdown":{"items":{"$ref":"#/components/schemas/DailyUsageItem"},"type":"array","title":"Breakdown"},"total_minutes":{"type":"number","title":"Total Minutes"},"total_cost_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Total Cost Usd"},"total_dograh_tokens":{"type":"number","title":"Total Dograh Tokens"},"currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Currency"}},"type":"object","required":["breakdown","total_minutes","total_dograh_tokens"],"title":"DailyUsageBreakdownResponse"},"DailyUsageItem":{"properties":{"date":{"type":"string","title":"Date"},"minutes":{"type":"number","title":"Minutes"},"cost_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cost Usd"},"dograh_tokens":{"type":"number","title":"Dograh Tokens"},"call_count":{"type":"integer","title":"Call Count"}},"type":"object","required":["date","minutes","dograh_tokens","call_count"],"title":"DailyUsageItem"},"DefaultConfigurationsResponse":{"properties":{"llm":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Llm"},"tts":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Tts"},"stt":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Stt"},"embeddings":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Embeddings"},"realtime":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Realtime"},"default_providers":{"additionalProperties":{"type":"string"},"type":"object","title":"Default Providers"}},"type":"object","required":["llm","tts","stt","embeddings","realtime","default_providers"],"title":"DefaultConfigurationsResponse"},"DisplayOptions":{"properties":{"show":{"anyOf":[{"additionalProperties":{"items":{},"type":"array"},"type":"object"},{"type":"null"}],"title":"Show"},"hide":{"anyOf":[{"additionalProperties":{"items":{},"type":"array"},"type":"object"},{"type":"null"}],"title":"Hide"}},"additionalProperties":false,"type":"object","title":"DisplayOptions","description":"Conditional visibility rules.\n\n`show` keys are AND-combined: this property is visible only when EVERY\nreferenced field's value matches one of the listed values.\n\n`hide` keys are OR-combined: this property is hidden when ANY referenced\nfield's value matches one of the listed values.\n\nExample:\n DisplayOptions(show={\"extraction_enabled\": [True]})\n DisplayOptions(show={\"greeting_type\": [\"audio\"]})"},"DocumentListResponseSchema":{"properties":{"documents":{"items":{"$ref":"#/components/schemas/DocumentResponseSchema"},"type":"array","title":"Documents"},"total":{"type":"integer","title":"Total"},"limit":{"type":"integer","title":"Limit"},"offset":{"type":"integer","title":"Offset"}},"type":"object","required":["documents","total","limit","offset"],"title":"DocumentListResponseSchema","description":"Response schema for list of documents."},"DocumentResponseSchema":{"properties":{"id":{"type":"integer","title":"Id"},"document_uuid":{"type":"string","title":"Document Uuid"},"filename":{"type":"string","title":"Filename"},"file_size_bytes":{"type":"integer","title":"File Size Bytes"},"file_hash":{"type":"string","title":"File Hash"},"mime_type":{"type":"string","title":"Mime Type"},"processing_status":{"type":"string","title":"Processing Status"},"processing_error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Processing Error"},"total_chunks":{"type":"integer","title":"Total Chunks"},"retrieval_mode":{"type":"string","title":"Retrieval Mode","default":"chunked"},"custom_metadata":{"additionalProperties":true,"type":"object","title":"Custom Metadata"},"docling_metadata":{"additionalProperties":true,"type":"object","title":"Docling Metadata"},"source_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source Url"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"organization_id":{"type":"integer","title":"Organization Id"},"created_by":{"type":"integer","title":"Created By"},"is_active":{"type":"boolean","title":"Is Active"}},"type":"object","required":["id","document_uuid","filename","file_size_bytes","file_hash","mime_type","processing_status","total_chunks","custom_metadata","docling_metadata","created_at","updated_at","organization_id","created_by","is_active"],"title":"DocumentResponseSchema","description":"Response schema for document metadata."},"DocumentUploadRequestSchema":{"properties":{"filename":{"type":"string","title":"Filename","description":"Name of the file to upload"},"mime_type":{"type":"string","title":"Mime Type","description":"MIME type of the file"},"custom_metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Custom Metadata","description":"Optional custom metadata"}},"type":"object","required":["filename","mime_type"],"title":"DocumentUploadRequestSchema","description":"Request schema for initiating document upload."},"DocumentUploadResponseSchema":{"properties":{"upload_url":{"type":"string","title":"Upload Url","description":"Signed URL for uploading the file"},"document_uuid":{"type":"string","title":"Document Uuid","description":"Unique identifier for the document"},"s3_key":{"type":"string","title":"S3 Key","description":"S3 key where file should be uploaded"}},"type":"object","required":["upload_url","document_uuid","s3_key"],"title":"DocumentUploadResponseSchema","description":"Response schema containing upload URL and document metadata."},"DuplicateTemplateRequest":{"properties":{"template_id":{"type":"integer","title":"Template Id"},"workflow_name":{"type":"string","title":"Workflow Name"}},"type":"object","required":["template_id","workflow_name"],"title":"DuplicateTemplateRequest"},"EmbedConfigResponse":{"properties":{"workflow_id":{"type":"integer","title":"Workflow Id"},"settings":{"additionalProperties":true,"type":"object","title":"Settings"},"theme":{"type":"string","title":"Theme"},"position":{"type":"string","title":"Position"},"button_text":{"type":"string","title":"Button Text"},"button_color":{"type":"string","title":"Button Color"},"size":{"type":"string","title":"Size"},"auto_start":{"type":"boolean","title":"Auto Start"}},"type":"object","required":["workflow_id","settings","theme","position","button_text","button_color","size","auto_start"],"title":"EmbedConfigResponse","description":"Response model for embed configuration"},"EmbedTokenRequest":{"properties":{"allowed_domains":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Allowed Domains"},"settings":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Settings"},"usage_limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Usage Limit"},"expires_in_days":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Expires In Days","default":30}},"type":"object","title":"EmbedTokenRequest"},"EmbedTokenResponse":{"properties":{"id":{"type":"integer","title":"Id"},"token":{"type":"string","title":"Token"},"allowed_domains":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Allowed Domains"},"settings":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Settings"},"is_active":{"type":"boolean","title":"Is Active"},"usage_count":{"type":"integer","title":"Usage Count"},"usage_limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Usage Limit"},"expires_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Expires At"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"embed_script":{"type":"string","title":"Embed Script"}},"type":"object","required":["id","token","allowed_domains","settings","is_active","usage_count","usage_limit","expires_at","created_at","embed_script"],"title":"EmbedTokenResponse"},"EndCallConfig":{"properties":{"messageType":{"type":"string","enum":["none","custom","audio"],"title":"Messagetype","description":"Type of goodbye message","default":"none"},"customMessage":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessage","description":"Custom message to play before ending the call"},"audioRecordingId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Audiorecordingid","description":"Recording ID for audio goodbye message"},"endCallReason":{"type":"boolean","title":"Endcallreason","description":"When enabled, LLM must provide a reason for ending the call. The reason is set as call disposition and added to call tags.","default":false},"endCallReasonDescription":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Endcallreasondescription","description":"Description shown to the LLM for the reason parameter. Used only when endCallReason is enabled."}},"type":"object","title":"EndCallConfig","description":"Configuration for End Call tools."},"EndCallToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version","default":1},"type":{"type":"string","const":"end_call","title":"Type","description":"Tool type"},"config":{"$ref":"#/components/schemas/EndCallConfig","description":"End Call configuration"}},"type":"object","required":["type","config"],"title":"EndCallToolDefinition","description":"Tool definition for End Call tools."},"FileDescriptor":{"properties":{"filename":{"type":"string","title":"Filename","description":"Original filename of the audio file"},"mime_type":{"type":"string","title":"Mime Type","description":"MIME type of the audio file","default":"audio/wav"},"file_size":{"type":"integer","maximum":5242880.0,"exclusiveMinimum":0.0,"title":"File Size","description":"File size in bytes (max 5MB)"}},"type":"object","required":["filename","file_size"],"title":"FileDescriptor","description":"Descriptor for a single file in a batch upload request."},"FileMetadataResponse":{"properties":{"key":{"type":"string","title":"Key"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["key","metadata"],"title":"FileMetadataResponse"},"GraphConstraints":{"properties":{"min_incoming":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Min Incoming"},"max_incoming":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Incoming"},"min_outgoing":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Min Outgoing"},"max_outgoing":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Outgoing"}},"additionalProperties":false,"type":"object","title":"GraphConstraints","description":"Per-node-type graph rules. WorkflowGraph enforces these at validation."},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HealthResponse":{"properties":{"status":{"type":"string","title":"Status"},"version":{"type":"string","title":"Version"},"backend_api_endpoint":{"type":"string","title":"Backend Api Endpoint"},"deployment_mode":{"type":"string","title":"Deployment Mode"},"auth_provider":{"type":"string","title":"Auth Provider"},"turn_enabled":{"type":"boolean","title":"Turn Enabled"},"force_turn_relay":{"type":"boolean","title":"Force Turn Relay"}},"type":"object","required":["status","version","backend_api_endpoint","deployment_mode","auth_provider","turn_enabled","force_turn_relay"],"title":"HealthResponse"},"HttpApiConfig":{"properties":{"method":{"type":"string","title":"Method","description":"HTTP method (GET, POST, PUT, PATCH, DELETE)"},"url":{"type":"string","title":"Url","description":"Target URL"},"headers":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Headers","description":"Static headers to include"},"credential_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credential Uuid","description":"Reference to ExternalCredentialModel for auth"},"parameters":{"anyOf":[{"items":{"$ref":"#/components/schemas/ToolParameter"},"type":"array"},{"type":"null"}],"title":"Parameters","description":"Parameters that the tool accepts from LLM"},"timeout_ms":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Timeout Ms","description":"Request timeout in milliseconds","default":5000},"customMessage":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessage","description":"Custom message to play after tool execution"},"customMessageType":{"anyOf":[{"type":"string","enum":["text","audio"]},{"type":"null"}],"title":"Custommessagetype","description":"Type of custom message: text or audio"},"customMessageRecordingId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessagerecordingid","description":"Recording ID for audio custom message"}},"type":"object","required":["method","url"],"title":"HttpApiConfig","description":"Configuration for HTTP API tools."},"HttpApiToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version","default":1},"type":{"type":"string","const":"http_api","title":"Type","description":"Tool type"},"config":{"$ref":"#/components/schemas/HttpApiConfig","description":"HTTP API configuration"}},"type":"object","required":["type","config"],"title":"HttpApiToolDefinition","description":"Tool definition for HTTP API tools."},"ImpersonateRequest":{"properties":{"provider_user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider User Id"},"user_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"User Id"}},"type":"object","title":"ImpersonateRequest","description":"Request payload for superadmin impersonation.\n\nEither ``provider_user_id`` **or** ``user_id`` must be supplied. If both are\nprovided, ``provider_user_id`` takes precedence."},"ImpersonateResponse":{"properties":{"refresh_token":{"type":"string","title":"Refresh Token"},"access_token":{"type":"string","title":"Access Token"}},"type":"object","required":["refresh_token","access_token"],"title":"ImpersonateResponse"},"InitEmbedRequest":{"properties":{"token":{"type":"string","title":"Token"},"context_variables":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Context Variables"}},"type":"object","required":["token"],"title":"InitEmbedRequest","description":"Request model for initializing an embed session"},"InitEmbedResponse":{"properties":{"session_token":{"type":"string","title":"Session Token"},"workflow_run_id":{"type":"integer","title":"Workflow Run Id"},"config":{"additionalProperties":true,"type":"object","title":"Config"}},"type":"object","required":["session_token","workflow_run_id","config"],"title":"InitEmbedResponse","description":"Response model for embed initialization"},"InitiateCallRequest":{"properties":{"workflow_id":{"type":"integer","title":"Workflow Id"},"workflow_run_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Workflow Run Id"},"phone_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone Number"},"telephony_configuration_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Telephony Configuration Id"},"from_phone_number_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"From Phone Number Id"}},"type":"object","required":["workflow_id"],"title":"InitiateCallRequest"},"IntegrationResponse":{"properties":{"id":{"type":"integer","title":"Id"},"integration_id":{"type":"string","title":"Integration Id"},"organisation_id":{"type":"integer","title":"Organisation Id"},"created_by":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Created By"},"provider":{"type":"string","title":"Provider"},"is_active":{"type":"boolean","title":"Is Active"},"created_at":{"type":"string","title":"Created At"},"action":{"type":"string","title":"Action"},"provider_data":{"additionalProperties":true,"type":"object","title":"Provider Data"}},"type":"object","required":["id","integration_id","organisation_id","created_by","provider","is_active","created_at","action","provider_data"],"title":"IntegrationResponse"},"ItemKind":{"type":"string","enum":["node","edge","workflow"],"title":"ItemKind"},"LangfuseCredentialsRequest":{"properties":{"host":{"type":"string","title":"Host"},"public_key":{"type":"string","title":"Public Key"},"secret_key":{"type":"string","title":"Secret Key"}},"type":"object","required":["host","public_key","secret_key"],"title":"LangfuseCredentialsRequest"},"LangfuseCredentialsResponse":{"properties":{"host":{"type":"string","title":"Host","default":""},"public_key":{"type":"string","title":"Public Key","default":""},"secret_key":{"type":"string","title":"Secret Key","default":""},"configured":{"type":"boolean","title":"Configured","default":false}},"type":"object","title":"LangfuseCredentialsResponse"},"LastCampaignSettingsResponse":{"properties":{"retry_config":{"anyOf":[{"$ref":"#/components/schemas/RetryConfigResponse"},{"type":"null"}]},"max_concurrency":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Concurrency"},"schedule_config":{"anyOf":[{"$ref":"#/components/schemas/ScheduleConfigResponse"},{"type":"null"}]},"circuit_breaker":{"anyOf":[{"$ref":"#/components/schemas/CircuitBreakerConfigResponse"},{"type":"null"}]}},"type":"object","title":"LastCampaignSettingsResponse"},"LoadTestStatsResponse":{"properties":{"total":{"type":"integer","title":"Total"},"pending":{"type":"integer","title":"Pending"},"running":{"type":"integer","title":"Running"},"completed":{"type":"integer","title":"Completed"},"failed":{"type":"integer","title":"Failed"},"sessions":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Sessions"}},"type":"object","required":["total","pending","running","completed","failed","sessions"],"title":"LoadTestStatsResponse"},"LoginRequest":{"properties":{"email":{"type":"string","format":"email","title":"Email"},"password":{"type":"string","title":"Password"}},"type":"object","required":["email","password"],"title":"LoginRequest"},"MPSCreditsResponse":{"properties":{"total_credits_used":{"type":"number","title":"Total Credits Used"},"remaining_credits":{"type":"number","title":"Remaining Credits"},"total_quota":{"type":"number","title":"Total Quota"}},"type":"object","required":["total_credits_used","remaining_credits","total_quota"],"title":"MPSCreditsResponse"},"NodeCategory":{"type":"string","enum":["call_node","global_node","trigger","integration"],"title":"NodeCategory","description":"Drives grouping in the AddNodePanel UI."},"NodeExample":{"properties":{"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"data":{"additionalProperties":true,"type":"object","title":"Data"}},"additionalProperties":false,"type":"object","required":["name","data"],"title":"NodeExample","description":"A worked example LLMs can pattern-match. Keep small and realistic."},"NodeSpec":{"properties":{"name":{"type":"string","title":"Name"},"display_name":{"type":"string","title":"Display Name"},"description":{"type":"string","minLength":1,"title":"Description","description":"Human-facing explanation shown in AddNodePanel."},"llm_hint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Llm Hint","description":"LLM-only guidance; omitted from the UI."},"category":{"$ref":"#/components/schemas/NodeCategory"},"icon":{"type":"string","title":"Icon"},"version":{"type":"string","title":"Version","default":"1.0.0"},"properties":{"items":{"$ref":"#/components/schemas/PropertySpec"},"type":"array","title":"Properties"},"examples":{"items":{"$ref":"#/components/schemas/NodeExample"},"type":"array","title":"Examples"},"graph_constraints":{"anyOf":[{"$ref":"#/components/schemas/GraphConstraints"},{"type":"null"}]}},"additionalProperties":false,"type":"object","required":["name","display_name","description","category","icon","properties"],"title":"NodeSpec","description":"Single source of truth for a node type."},"NodeTypesResponse":{"properties":{"spec_version":{"type":"string","title":"Spec Version"},"node_types":{"items":{"$ref":"#/components/schemas/NodeSpec"},"type":"array","title":"Node Types"}},"type":"object","required":["spec_version","node_types"],"title":"NodeTypesResponse"},"PhoneNumberCreateRequest":{"properties":{"address":{"type":"string","maxLength":255,"minLength":1,"title":"Address"},"country_code":{"anyOf":[{"type":"string","maxLength":2,"minLength":2},{"type":"null"}],"title":"Country Code"},"label":{"anyOf":[{"type":"string","maxLength":64},{"type":"null"}],"title":"Label"},"inbound_workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Inbound Workflow Id"},"is_active":{"type":"boolean","title":"Is Active","default":true},"is_default_caller_id":{"type":"boolean","title":"Is Default Caller Id","default":false},"extra_metadata":{"additionalProperties":true,"type":"object","title":"Extra Metadata"}},"type":"object","required":["address"],"title":"PhoneNumberCreateRequest","description":"Create a new phone number under a telephony configuration.\n\n``address_normalized`` and ``address_type`` are computed server-side from\n``address`` (and ``country_code`` if PSTN). ``address`` itself is stored\nverbatim for display."},"PhoneNumberListResponse":{"properties":{"phone_numbers":{"items":{"$ref":"#/components/schemas/PhoneNumberResponse"},"type":"array","title":"Phone Numbers"}},"type":"object","required":["phone_numbers"],"title":"PhoneNumberListResponse"},"PhoneNumberResponse":{"properties":{"id":{"type":"integer","title":"Id"},"telephony_configuration_id":{"type":"integer","title":"Telephony Configuration Id"},"address":{"type":"string","title":"Address"},"address_normalized":{"type":"string","title":"Address Normalized"},"address_type":{"type":"string","title":"Address Type"},"country_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Country Code"},"label":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Label"},"inbound_workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Inbound Workflow Id"},"inbound_workflow_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Inbound Workflow Name"},"is_active":{"type":"boolean","title":"Is Active"},"is_default_caller_id":{"type":"boolean","title":"Is Default Caller Id"},"extra_metadata":{"additionalProperties":true,"type":"object","title":"Extra Metadata"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"provider_sync":{"anyOf":[{"$ref":"#/components/schemas/ProviderSyncStatus"},{"type":"null"}]}},"type":"object","required":["id","telephony_configuration_id","address","address_normalized","address_type","is_active","is_default_caller_id","extra_metadata","created_at","updated_at"],"title":"PhoneNumberResponse"},"PhoneNumberUpdateRequest":{"properties":{"label":{"anyOf":[{"type":"string","maxLength":64},{"type":"null"}],"title":"Label"},"inbound_workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Inbound Workflow Id"},"clear_inbound_workflow":{"type":"boolean","title":"Clear Inbound Workflow","default":false},"is_active":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Active"},"country_code":{"anyOf":[{"type":"string","maxLength":2,"minLength":2},{"type":"null"}],"title":"Country Code"},"extra_metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Extra Metadata"}},"type":"object","title":"PhoneNumberUpdateRequest","description":"Partial update. ``address`` is intentionally immutable \u2014 to change a\nnumber, delete the row and create a new one."},"PlivoConfigurationRequest":{"properties":{"provider":{"type":"string","const":"plivo","title":"Provider","default":"plivo"},"auth_id":{"type":"string","title":"Auth Id","description":"Plivo Auth ID"},"auth_token":{"type":"string","title":"Auth Token","description":"Plivo Auth Token"},"application_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Id","description":"Plivo Application ID. The application's answer_url is updated when inbound workflows are attached to numbers on this account. If omitted, an application is auto-created on save and its id is stored on the configuration."},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Plivo phone numbers"}},"type":"object","required":["auth_id","auth_token"],"title":"PlivoConfigurationRequest","description":"Request schema for Plivo configuration."},"PlivoConfigurationResponse":{"properties":{"provider":{"type":"string","const":"plivo","title":"Provider","default":"plivo"},"auth_id":{"type":"string","title":"Auth Id"},"auth_token":{"type":"string","title":"Auth Token"},"application_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Id"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["auth_id","auth_token","from_numbers"],"title":"PlivoConfigurationResponse","description":"Response schema for Plivo configuration with masked sensitive fields."},"PresignedUploadUrlRequest":{"properties":{"file_name":{"type":"string","pattern":".*\\.csv$","title":"File Name","description":"CSV filename"},"file_size":{"type":"integer","maximum":10485760.0,"exclusiveMinimum":0.0,"title":"File Size","description":"File size in bytes (max 10MB)"},"content_type":{"type":"string","title":"Content Type","description":"File content type","default":"text/csv"}},"type":"object","required":["file_name","file_size"],"title":"PresignedUploadUrlRequest"},"PresignedUploadUrlResponse":{"properties":{"upload_url":{"type":"string","title":"Upload Url"},"file_key":{"type":"string","title":"File Key"},"expires_in":{"type":"integer","title":"Expires In"}},"type":"object","required":["upload_url","file_key","expires_in"],"title":"PresignedUploadUrlResponse"},"ProcessDocumentRequestSchema":{"properties":{"document_uuid":{"type":"string","title":"Document Uuid","description":"Document UUID to process"},"s3_key":{"type":"string","title":"S3 Key","description":"S3 key of the uploaded file"},"retrieval_mode":{"type":"string","title":"Retrieval Mode","description":"Retrieval mode: 'chunked' for vector search or 'full_document' for full text retrieval","default":"chunked"}},"type":"object","required":["document_uuid","s3_key"],"title":"ProcessDocumentRequestSchema","description":"Request schema for triggering document processing."},"PropertyOption":{"properties":{"value":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"boolean"},{"type":"number"}],"title":"Value"},"label":{"type":"string","title":"Label"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},"additionalProperties":false,"type":"object","required":["value","label"],"title":"PropertyOption","description":"An option in an `options` or `multi_options` dropdown."},"PropertySpec":{"properties":{"name":{"type":"string","title":"Name"},"type":{"$ref":"#/components/schemas/PropertyType"},"display_name":{"type":"string","title":"Display Name"},"description":{"type":"string","minLength":1,"title":"Description","description":"Human-facing explanation shown in the UI."},"llm_hint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Llm Hint","description":"LLM-only guidance; omitted from the UI."},"default":{"title":"Default"},"required":{"type":"boolean","title":"Required","default":false},"placeholder":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Placeholder"},"display_options":{"anyOf":[{"$ref":"#/components/schemas/DisplayOptions"},{"type":"null"}]},"options":{"anyOf":[{"items":{"$ref":"#/components/schemas/PropertyOption"},"type":"array"},{"type":"null"}],"title":"Options"},"properties":{"anyOf":[{"items":{"$ref":"#/components/schemas/PropertySpec"},"type":"array"},{"type":"null"}],"title":"Properties"},"min_value":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Min Value"},"max_value":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Max Value"},"min_length":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Min Length"},"max_length":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Length"},"pattern":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Pattern"},"editor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Editor"},"extra":{"additionalProperties":true,"type":"object","title":"Extra"}},"additionalProperties":false,"type":"object","required":["name","type","display_name","description"],"title":"PropertySpec","description":"Single field on a node.\n\n`description` is HUMAN-FACING \u2014 shown under the field in the edit\ndialog. Keep it concise and explain what the field does.\n\n`llm_hint` is LLM-FACING \u2014 appears only in the `get_node_type` MCP\nresponse and in SDK schema output. Use it for catalog tool references\n(e.g., \"Use `list_recordings`\"), array shape, expected value idioms,\nor anything that would be noise in the UI. Optional; omit when the\n`description` already suffices for both audiences."},"PropertyType":{"type":"string","enum":["string","number","boolean","options","multi_options","fixed_collection","json","tool_refs","document_refs","recording_ref","credential_ref","mention_textarea","url"],"title":"PropertyType","description":"Bounded vocabulary of property types the renderer dispatches on.\n\nAdding a value here requires a matching arm in the frontend\n`` switch and (where relevant) the SDK codegen template."},"ProviderSyncStatus":{"properties":{"ok":{"type":"boolean","title":"Ok"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"}},"type":"object","required":["ok"],"title":"ProviderSyncStatus","description":"Result of pushing a phone-number change to the upstream provider.\n\nReturned alongside create/update responses when the route attempted to\nsync inbound webhook configuration. ``ok=False`` is a warning, not a\nfatal error \u2014 the DB write succeeded."},"RecordingCreateRequestSchema":{"properties":{"recording_id":{"type":"string","title":"Recording Id","description":"Short recording ID from upload step"},"tts_provider":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Provider","description":"TTS provider (e.g. elevenlabs)"},"tts_model":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Model","description":"TTS model name"},"tts_voice_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Voice Id","description":"TTS voice identifier"},"transcript":{"type":"string","title":"Transcript","description":"User-provided transcript of the recording"},"storage_key":{"type":"string","title":"Storage Key","description":"Storage key from upload step"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata","description":"Optional metadata (file_size, duration, etc.)"}},"type":"object","required":["recording_id","transcript","storage_key"],"title":"RecordingCreateRequestSchema","description":"Request schema for creating a recording record after upload."},"RecordingListResponseSchema":{"properties":{"recordings":{"items":{"$ref":"#/components/schemas/RecordingResponseSchema"},"type":"array","title":"Recordings"},"total":{"type":"integer","title":"Total"}},"type":"object","required":["recordings","total"],"title":"RecordingListResponseSchema","description":"Response schema for list of recordings."},"RecordingResponseSchema":{"properties":{"id":{"type":"integer","title":"Id"},"recording_id":{"type":"string","title":"Recording Id"},"workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Workflow Id"},"organization_id":{"type":"integer","title":"Organization Id"},"tts_provider":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Provider"},"tts_model":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Model"},"tts_voice_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Voice Id"},"transcript":{"type":"string","title":"Transcript"},"storage_key":{"type":"string","title":"Storage Key"},"storage_backend":{"type":"string","title":"Storage Backend"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"},"created_by":{"type":"integer","title":"Created By"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"is_active":{"type":"boolean","title":"Is Active"}},"type":"object","required":["id","recording_id","organization_id","transcript","storage_key","storage_backend","metadata","created_by","created_at","is_active"],"title":"RecordingResponseSchema","description":"Response schema for a single recording."},"RecordingUpdateRequestSchema":{"properties":{"recording_id":{"type":"string","maxLength":64,"minLength":1,"pattern":"^[a-zA-Z0-9_-]+$","title":"Recording Id","description":"New descriptive recording ID (letters, numbers, hyphens, underscores only)"}},"type":"object","required":["recording_id"],"title":"RecordingUpdateRequestSchema","description":"Request schema for updating a recording's ID."},"RecordingUploadResponseSchema":{"properties":{"upload_url":{"type":"string","title":"Upload Url","description":"Presigned URL for uploading the audio"},"recording_id":{"type":"string","title":"Recording Id","description":"Short unique recording ID"},"storage_key":{"type":"string","title":"Storage Key","description":"Storage key where file will be uploaded"}},"type":"object","required":["upload_url","recording_id","storage_key"],"title":"RecordingUploadResponseSchema","description":"Response schema with presigned upload URL."},"RedialCampaignRequest":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":255,"minLength":1},{"type":"null"}],"title":"Name","description":"Name for the redial campaign"},"retry_on_voicemail":{"type":"boolean","title":"Retry On Voicemail","default":true},"retry_on_no_answer":{"type":"boolean","title":"Retry On No Answer","default":true},"retry_on_busy":{"type":"boolean","title":"Retry On Busy","default":true},"retry_config":{"anyOf":[{"$ref":"#/components/schemas/RetryConfigRequest"},{"type":"null"}]}},"type":"object","title":"RedialCampaignRequest"},"RetryConfigRequest":{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":true},"max_retries":{"type":"integer","maximum":10.0,"minimum":0.0,"title":"Max Retries","default":2},"retry_delay_seconds":{"type":"integer","maximum":3600.0,"minimum":30.0,"title":"Retry Delay Seconds","default":120},"retry_on_busy":{"type":"boolean","title":"Retry On Busy","default":true},"retry_on_no_answer":{"type":"boolean","title":"Retry On No Answer","default":true},"retry_on_voicemail":{"type":"boolean","title":"Retry On Voicemail","default":true}},"type":"object","title":"RetryConfigRequest"},"RetryConfigResponse":{"properties":{"enabled":{"type":"boolean","title":"Enabled"},"max_retries":{"type":"integer","title":"Max Retries"},"retry_delay_seconds":{"type":"integer","title":"Retry Delay Seconds"},"retry_on_busy":{"type":"boolean","title":"Retry On Busy"},"retry_on_no_answer":{"type":"boolean","title":"Retry On No Answer"},"retry_on_voicemail":{"type":"boolean","title":"Retry On Voicemail"}},"type":"object","required":["enabled","max_retries","retry_delay_seconds","retry_on_busy","retry_on_no_answer","retry_on_voicemail"],"title":"RetryConfigResponse"},"S3SignedUrlResponse":{"properties":{"url":{"type":"string","title":"Url"},"expires_in":{"type":"integer","title":"Expires In"}},"type":"object","required":["url","expires_in"],"title":"S3SignedUrlResponse"},"ScheduleConfigRequest":{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":true},"timezone":{"type":"string","title":"Timezone","default":"UTC"},"slots":{"items":{"$ref":"#/components/schemas/TimeSlotRequest"},"type":"array","maxItems":50,"minItems":1,"title":"Slots"}},"type":"object","required":["slots"],"title":"ScheduleConfigRequest"},"ScheduleConfigResponse":{"properties":{"enabled":{"type":"boolean","title":"Enabled"},"timezone":{"type":"string","title":"Timezone"},"slots":{"items":{"$ref":"#/components/schemas/TimeSlotResponse"},"type":"array","title":"Slots"}},"type":"object","required":["enabled","timezone","slots"],"title":"ScheduleConfigResponse"},"ServiceKeyResponse":{"properties":{"name":{"type":"string","title":"Name"},"id":{"type":"integer","title":"Id"},"key_prefix":{"type":"string","title":"Key Prefix"},"is_active":{"type":"boolean","title":"Is Active"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"last_used_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Used At"},"expires_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Expires At"},"archived_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Archived At"},"created_by":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created By"}},"type":"object","required":["name","id","key_prefix","is_active","created_at"],"title":"ServiceKeyResponse"},"SessionResponse":{"properties":{"session_token":{"type":"string","title":"Session Token"},"expires_at":{"type":"string","title":"Expires At"}},"type":"object","required":["session_token","expires_at"],"title":"SessionResponse"},"SignupRequest":{"properties":{"email":{"type":"string","format":"email","title":"Email"},"password":{"type":"string","title":"Password"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},"type":"object","required":["email","password"],"title":"SignupRequest"},"SuperuserWorkflowRunResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"workflow_id":{"type":"integer","title":"Workflow Id"},"workflow_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Name"},"user_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"User Id"},"organization_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Organization Id"},"organization_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Organization Name"},"mode":{"type":"string","title":"Mode"},"is_completed":{"type":"boolean","title":"Is Completed"},"recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Url"},"transcript_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Url"},"usage_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Usage Info"},"cost_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Cost Info"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"gathered_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Gathered Context"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","name","workflow_id","workflow_name","user_id","organization_id","organization_name","mode","is_completed","recording_url","transcript_url","usage_info","cost_info","initial_context","gathered_context","created_at"],"title":"SuperuserWorkflowRunResponse"},"SuperuserWorkflowRunsListResponse":{"properties":{"workflow_runs":{"items":{"$ref":"#/components/schemas/SuperuserWorkflowRunResponse"},"type":"array","title":"Workflow Runs"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"limit":{"type":"integer","title":"Limit"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["workflow_runs","total_count","page","limit","total_pages"],"title":"SuperuserWorkflowRunsListResponse"},"TelephonyConfigWarningsResponse":{"properties":{"telnyx_missing_webhook_public_key_count":{"type":"integer","title":"Telnyx Missing Webhook Public Key Count"}},"type":"object","required":["telnyx_missing_webhook_public_key_count"],"title":"TelephonyConfigWarningsResponse","description":"Aggregated telephony-configuration warning counts for the user's org.\n\nDrives the page banner and nav badge that nudge customers to finish\noptional-but-recommended configuration steps. Shape is a flat dict so\nnew warning types can be added without breaking the client."},"TelephonyConfigurationCreateRequest":{"properties":{"name":{"type":"string","maxLength":64,"minLength":1,"title":"Name"},"is_default_outbound":{"type":"boolean","title":"Is Default Outbound","default":false},"config":{"oneOf":[{"$ref":"#/components/schemas/ARIConfigurationRequest"},{"$ref":"#/components/schemas/CloudonixConfigurationRequest"},{"$ref":"#/components/schemas/PlivoConfigurationRequest"},{"$ref":"#/components/schemas/TelnyxConfigurationRequest"},{"$ref":"#/components/schemas/TwilioConfigurationRequest"},{"$ref":"#/components/schemas/VobizConfigurationRequest"},{"$ref":"#/components/schemas/VonageConfigurationRequest"}],"title":"Config","discriminator":{"propertyName":"provider","mapping":{"ari":"#/components/schemas/ARIConfigurationRequest","cloudonix":"#/components/schemas/CloudonixConfigurationRequest","plivo":"#/components/schemas/PlivoConfigurationRequest","telnyx":"#/components/schemas/TelnyxConfigurationRequest","twilio":"#/components/schemas/TwilioConfigurationRequest","vobiz":"#/components/schemas/VobizConfigurationRequest","vonage":"#/components/schemas/VonageConfigurationRequest"}}}},"type":"object","required":["name","config"],"title":"TelephonyConfigurationCreateRequest","description":"Body for ``POST /telephony-configs``.\n\n``config`` carries the provider-specific credential fields (the same\ndiscriminated union used by the legacy single-config endpoint). Any\n``from_numbers`` on the inner config are ignored \u2014 phone numbers are\nmanaged via the dedicated phone-numbers endpoints."},"TelephonyConfigurationDetail":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"provider":{"type":"string","title":"Provider"},"is_default_outbound":{"type":"boolean","title":"Is Default Outbound"},"credentials":{"additionalProperties":true,"type":"object","title":"Credentials"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","name","provider","is_default_outbound","credentials","created_at","updated_at"],"title":"TelephonyConfigurationDetail","description":"Body of ``GET /telephony-configs/{id}`` \u2014 credentials are masked."},"TelephonyConfigurationListItem":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"provider":{"type":"string","title":"Provider"},"is_default_outbound":{"type":"boolean","title":"Is Default Outbound"},"phone_number_count":{"type":"integer","title":"Phone Number Count","default":0},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","name","provider","is_default_outbound","created_at","updated_at"],"title":"TelephonyConfigurationListItem","description":"One row in ``GET /telephony-configs``."},"TelephonyConfigurationListResponse":{"properties":{"configurations":{"items":{"$ref":"#/components/schemas/TelephonyConfigurationListItem"},"type":"array","title":"Configurations"}},"type":"object","required":["configurations"],"title":"TelephonyConfigurationListResponse"},"TelephonyConfigurationResponse":{"properties":{"twilio":{"anyOf":[{"$ref":"#/components/schemas/TwilioConfigurationResponse"},{"type":"null"}]},"plivo":{"anyOf":[{"$ref":"#/components/schemas/PlivoConfigurationResponse"},{"type":"null"}]},"vonage":{"anyOf":[{"$ref":"#/components/schemas/VonageConfigurationResponse"},{"type":"null"}]},"vobiz":{"anyOf":[{"$ref":"#/components/schemas/VobizConfigurationResponse"},{"type":"null"}]},"cloudonix":{"anyOf":[{"$ref":"#/components/schemas/CloudonixConfigurationResponse"},{"type":"null"}]},"ari":{"anyOf":[{"$ref":"#/components/schemas/ARIConfigurationResponse"},{"type":"null"}]},"telnyx":{"anyOf":[{"$ref":"#/components/schemas/TelnyxConfigurationResponse"},{"type":"null"}]}},"type":"object","title":"TelephonyConfigurationResponse","description":"Top-level telephony configuration response.\n\nKeeps the per-provider field shape that the UI client depends on. When\nthe UI moves to metadata-driven forms, this can be replaced with a\nflat discriminated union."},"TelephonyConfigurationUpdateRequest":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":64,"minLength":1},{"type":"null"}],"title":"Name"},"config":{"anyOf":[{"oneOf":[{"$ref":"#/components/schemas/ARIConfigurationRequest"},{"$ref":"#/components/schemas/CloudonixConfigurationRequest"},{"$ref":"#/components/schemas/PlivoConfigurationRequest"},{"$ref":"#/components/schemas/TelnyxConfigurationRequest"},{"$ref":"#/components/schemas/TwilioConfigurationRequest"},{"$ref":"#/components/schemas/VobizConfigurationRequest"},{"$ref":"#/components/schemas/VonageConfigurationRequest"}],"discriminator":{"propertyName":"provider","mapping":{"ari":"#/components/schemas/ARIConfigurationRequest","cloudonix":"#/components/schemas/CloudonixConfigurationRequest","plivo":"#/components/schemas/PlivoConfigurationRequest","telnyx":"#/components/schemas/TelnyxConfigurationRequest","twilio":"#/components/schemas/TwilioConfigurationRequest","vobiz":"#/components/schemas/VobizConfigurationRequest","vonage":"#/components/schemas/VonageConfigurationRequest"}}},{"type":"null"}],"title":"Config"}},"type":"object","title":"TelephonyConfigurationUpdateRequest","description":"Body for ``PUT /telephony-configs/{id}``. Partial update."},"TelephonyProviderMetadata":{"properties":{"provider":{"type":"string","title":"Provider"},"display_name":{"type":"string","title":"Display Name"},"fields":{"items":{"$ref":"#/components/schemas/TelephonyProviderUIField"},"type":"array","title":"Fields"},"docs_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Docs Url"}},"type":"object","required":["provider","display_name","fields"],"title":"TelephonyProviderMetadata","description":"UI form metadata for a single telephony provider."},"TelephonyProviderUIField":{"properties":{"name":{"type":"string","title":"Name"},"label":{"type":"string","title":"Label"},"type":{"type":"string","title":"Type"},"required":{"type":"boolean","title":"Required"},"sensitive":{"type":"boolean","title":"Sensitive"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"placeholder":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Placeholder"}},"type":"object","required":["name","label","type","required","sensitive"],"title":"TelephonyProviderUIField","description":"One form field on a telephony provider's configuration UI."},"TelephonyProvidersMetadataResponse":{"properties":{"providers":{"items":{"$ref":"#/components/schemas/TelephonyProviderMetadata"},"type":"array","title":"Providers"}},"type":"object","required":["providers"],"title":"TelephonyProvidersMetadataResponse","description":"List of UI form definitions used by the telephony-config screen."},"TelnyxConfigurationRequest":{"properties":{"provider":{"type":"string","const":"telnyx","title":"Provider","default":"telnyx"},"api_key":{"type":"string","title":"Api Key","description":"Telnyx API Key"},"connection_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connection Id","description":"Telnyx Call Control Application ID (connection_id). If omitted, a Call Control Application is auto-created on save and its id is stored on the configuration."},"webhook_public_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Webhook Public Key","description":"Webhook public key from Mission Control Portal \u2192 Keys & Credentials \u2192 Public Key. Used to verify Telnyx webhook signatures."},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Telnyx phone numbers"}},"type":"object","required":["api_key"],"title":"TelnyxConfigurationRequest","description":"Request schema for Telnyx configuration."},"TelnyxConfigurationResponse":{"properties":{"provider":{"type":"string","const":"telnyx","title":"Provider","default":"telnyx"},"api_key":{"type":"string","title":"Api Key"},"connection_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connection Id"},"webhook_public_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Webhook Public Key"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["api_key","from_numbers"],"title":"TelnyxConfigurationResponse","description":"Response schema for Telnyx configuration with masked sensitive fields."},"TestSessionResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"status":{"type":"string","title":"Status"},"actor_workflow_id":{"type":"integer","title":"Actor Workflow Id"},"adversary_workflow_id":{"type":"integer","title":"Adversary Workflow Id"},"load_test_group_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Load Test Group Id"},"test_index":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Test Index"},"config":{"additionalProperties":true,"type":"object","title":"Config"},"results":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Results"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"started_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Started At"},"completed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Completed At"}},"type":"object","required":["id","name","status","actor_workflow_id","adversary_workflow_id","load_test_group_id","test_index","config","results","error","created_at","started_at","completed_at"],"title":"TestSessionResponse"},"TimeSlotRequest":{"properties":{"day_of_week":{"type":"integer","maximum":6.0,"minimum":0.0,"title":"Day Of Week"},"start_time":{"type":"string","pattern":"^\\d{2}:\\d{2}$","title":"Start Time"},"end_time":{"type":"string","pattern":"^\\d{2}:\\d{2}$","title":"End Time"}},"type":"object","required":["day_of_week","start_time","end_time"],"title":"TimeSlotRequest"},"TimeSlotResponse":{"properties":{"day_of_week":{"type":"integer","title":"Day Of Week"},"start_time":{"type":"string","title":"Start Time"},"end_time":{"type":"string","title":"End Time"}},"type":"object","required":["day_of_week","start_time","end_time"],"title":"TimeSlotResponse"},"ToolParameter":{"properties":{"name":{"type":"string","title":"Name","description":"Parameter name (used as key in request body)"},"type":{"type":"string","title":"Type","description":"Parameter type: string, number, or boolean"},"description":{"type":"string","title":"Description","description":"Description of what this parameter is for"},"required":{"type":"boolean","title":"Required","description":"Whether this parameter is required","default":true}},"type":"object","required":["name","type","description"],"title":"ToolParameter","description":"A parameter that the tool accepts."},"ToolResponse":{"properties":{"id":{"type":"integer","title":"Id"},"tool_uuid":{"type":"string","title":"Tool Uuid"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"category":{"type":"string","title":"Category"},"icon":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Icon"},"icon_color":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Icon Color"},"status":{"type":"string","title":"Status"},"definition":{"additionalProperties":true,"type":"object","title":"Definition"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"created_by":{"anyOf":[{"$ref":"#/components/schemas/CreatedByResponse"},{"type":"null"}]}},"type":"object","required":["id","tool_uuid","name","description","category","icon","icon_color","status","definition","created_at","updated_at"],"title":"ToolResponse","description":"Response schema for a tool."},"TransferCallConfig":{"properties":{"destination":{"type":"string","title":"Destination","description":"Phone number or SIP endpoint to transfer the call to (E.164 format e.g., +1234567890, or SIP endpoint e.g., PJSIP/1234)"},"messageType":{"type":"string","enum":["none","custom","audio"],"title":"Messagetype","description":"Type of message to play before transfer","default":"none"},"customMessage":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessage","description":"Custom message to play before transferring the call"},"audioRecordingId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Audiorecordingid","description":"Recording ID for audio message before transfer"},"timeout":{"type":"integer","maximum":120.0,"minimum":5.0,"title":"Timeout","description":"Maximum time in seconds to wait for destination to answer (5-120 seconds)","default":30}},"type":"object","required":["destination"],"title":"TransferCallConfig","description":"Configuration for Transfer Call tools."},"TransferCallToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version","default":1},"type":{"type":"string","const":"transfer_call","title":"Type","description":"Tool type"},"config":{"$ref":"#/components/schemas/TransferCallConfig","description":"Transfer Call configuration"}},"type":"object","required":["type","config"],"title":"TransferCallToolDefinition","description":"Tool definition for Transfer Call tools."},"TriggerCallRequest":{"properties":{"phone_number":{"type":"string","title":"Phone Number"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"telephony_configuration_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Telephony Configuration Id"}},"type":"object","required":["phone_number"],"title":"TriggerCallRequest","description":"Request model for triggering a call via API"},"TriggerCallResponse":{"properties":{"status":{"type":"string","title":"Status"},"workflow_run_id":{"type":"integer","title":"Workflow Run Id"},"workflow_run_name":{"type":"string","title":"Workflow Run Name"}},"type":"object","required":["status","workflow_run_id","workflow_run_name"],"title":"TriggerCallResponse","description":"Response model for successful call initiation"},"TurnCredentialsResponse":{"properties":{"username":{"type":"string","title":"Username"},"password":{"type":"string","title":"Password"},"ttl":{"type":"integer","title":"Ttl"},"uris":{"items":{"type":"string"},"type":"array","title":"Uris"}},"type":"object","required":["username","password","ttl","uris"],"title":"TurnCredentialsResponse","description":"Response model for TURN credentials."},"TwilioConfigurationRequest":{"properties":{"provider":{"type":"string","const":"twilio","title":"Provider","default":"twilio"},"account_sid":{"type":"string","title":"Account Sid","description":"Twilio Account SID"},"auth_token":{"type":"string","title":"Auth Token","description":"Twilio Auth Token"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Twilio phone numbers"}},"type":"object","required":["account_sid","auth_token"],"title":"TwilioConfigurationRequest","description":"Request schema for Twilio configuration."},"TwilioConfigurationResponse":{"properties":{"provider":{"type":"string","const":"twilio","title":"Provider","default":"twilio"},"account_sid":{"type":"string","title":"Account Sid"},"auth_token":{"type":"string","title":"Auth Token"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["account_sid","auth_token","from_numbers"],"title":"TwilioConfigurationResponse","description":"Response schema for Twilio configuration with masked sensitive fields."},"UpdateCampaignRequest":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":255,"minLength":1},{"type":"null"}],"title":"Name"},"retry_config":{"anyOf":[{"$ref":"#/components/schemas/RetryConfigRequest"},{"type":"null"}]},"max_concurrency":{"anyOf":[{"type":"integer","maximum":100.0,"minimum":1.0},{"type":"null"}],"title":"Max Concurrency"},"schedule_config":{"anyOf":[{"$ref":"#/components/schemas/ScheduleConfigRequest"},{"type":"null"}]},"circuit_breaker":{"anyOf":[{"$ref":"#/components/schemas/CircuitBreakerConfigRequest"},{"type":"null"}]}},"type":"object","title":"UpdateCampaignRequest"},"UpdateCredentialRequest":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"credential_type":{"anyOf":[{"$ref":"#/components/schemas/WebhookCredentialType"},{"type":"null"}]},"credential_data":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Credential Data"}},"type":"object","title":"UpdateCredentialRequest","description":"Request schema for updating a webhook credential."},"UpdateIntegrationRequest":{"properties":{"selected_files":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Selected Files"}},"type":"object","required":["selected_files"],"title":"UpdateIntegrationRequest"},"UpdateToolRequest":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":255},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"icon":{"anyOf":[{"type":"string","maxLength":50},{"type":"null"}],"title":"Icon"},"icon_color":{"anyOf":[{"type":"string","maxLength":7},{"type":"null"}],"title":"Icon Color"},"definition":{"anyOf":[{"oneOf":[{"$ref":"#/components/schemas/HttpApiToolDefinition"},{"$ref":"#/components/schemas/EndCallToolDefinition"},{"$ref":"#/components/schemas/TransferCallToolDefinition"},{"$ref":"#/components/schemas/CalculatorToolDefinition"}],"discriminator":{"propertyName":"type","mapping":{"calculator":"#/components/schemas/CalculatorToolDefinition","end_call":"#/components/schemas/EndCallToolDefinition","http_api":"#/components/schemas/HttpApiToolDefinition","transfer_call":"#/components/schemas/TransferCallToolDefinition"}}},{"type":"null"}],"title":"Definition"},"status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status"}},"type":"object","title":"UpdateToolRequest","description":"Request schema for updating a tool."},"UpdateWorkflowRequest":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"workflow_definition":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Workflow Definition"},"template_context_variables":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Template Context Variables"},"workflow_configurations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Workflow Configurations"}},"type":"object","title":"UpdateWorkflowRequest"},"UpdateWorkflowStatusRequest":{"properties":{"status":{"type":"string","title":"Status"}},"type":"object","required":["status"],"title":"UpdateWorkflowStatusRequest"},"UsageHistoryResponse":{"properties":{"runs":{"items":{"$ref":"#/components/schemas/WorkflowRunUsageResponse"},"type":"array","title":"Runs"},"total_dograh_tokens":{"type":"number","title":"Total Dograh Tokens"},"total_duration_seconds":{"type":"integer","title":"Total Duration Seconds"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"limit":{"type":"integer","title":"Limit"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["runs","total_dograh_tokens","total_duration_seconds","total_count","page","limit","total_pages"],"title":"UsageHistoryResponse"},"UserConfigurationRequestResponseSchema":{"properties":{"llm":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Llm"},"tts":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Tts"},"stt":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Stt"},"embeddings":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Embeddings"},"realtime":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Realtime"},"is_realtime":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Realtime"},"test_phone_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Test Phone Number"},"timezone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Timezone"},"organization_pricing":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"number"},{"type":"string"},{"type":"boolean"}]},"type":"object"},{"type":"null"}],"title":"Organization Pricing"}},"type":"object","title":"UserConfigurationRequestResponseSchema"},"UserResponse":{"properties":{"id":{"type":"integer","title":"Id"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"organization_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Organization Id"},"provider_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Id"}},"type":"object","required":["id","email"],"title":"UserResponse"},"ValidateWorkflowResponse":{"properties":{"is_valid":{"type":"boolean","title":"Is Valid"},"errors":{"items":{"$ref":"#/components/schemas/WorkflowError"},"type":"array","title":"Errors"}},"type":"object","required":["is_valid","errors"],"title":"ValidateWorkflowResponse"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"VobizConfigurationRequest":{"properties":{"provider":{"type":"string","const":"vobiz","title":"Provider","default":"vobiz"},"auth_id":{"type":"string","title":"Auth Id","description":"Vobiz Account ID (e.g., MA_SYQRLN1K)"},"auth_token":{"type":"string","title":"Auth Token","description":"Vobiz Auth Token"},"application_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Id","description":"Vobiz Application ID. The application's answer_url is updated when inbound workflows are attached to numbers on this account. If omitted, an application is auto-created on save and its id is stored on the configuration."},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Vobiz phone numbers (E.164 without + prefix)"}},"type":"object","required":["auth_id","auth_token"],"title":"VobizConfigurationRequest","description":"Request schema for Vobiz configuration."},"VobizConfigurationResponse":{"properties":{"provider":{"type":"string","const":"vobiz","title":"Provider","default":"vobiz"},"auth_id":{"type":"string","title":"Auth Id"},"auth_token":{"type":"string","title":"Auth Token"},"application_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Id"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["auth_id","auth_token","from_numbers"],"title":"VobizConfigurationResponse","description":"Response schema for Vobiz configuration with masked sensitive fields."},"VoiceInfo":{"properties":{"voice_id":{"type":"string","title":"Voice Id"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"accent":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Accent"},"gender":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Gender"},"language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language"},"preview_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Preview Url"}},"type":"object","required":["voice_id","name"],"title":"VoiceInfo"},"VoicesResponse":{"properties":{"provider":{"type":"string","title":"Provider"},"voices":{"items":{"$ref":"#/components/schemas/VoiceInfo"},"type":"array","title":"Voices"}},"type":"object","required":["provider","voices"],"title":"VoicesResponse"},"VonageConfigurationRequest":{"properties":{"provider":{"type":"string","const":"vonage","title":"Provider","default":"vonage"},"api_key":{"type":"string","title":"Api Key","description":"Vonage API Key"},"api_secret":{"type":"string","title":"Api Secret","description":"Vonage API Secret"},"application_id":{"type":"string","title":"Application Id","description":"Vonage Application ID"},"private_key":{"type":"string","title":"Private Key","description":"Private key for JWT generation"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Vonage phone numbers (without + prefix)"}},"type":"object","required":["api_key","api_secret","application_id","private_key"],"title":"VonageConfigurationRequest","description":"Request schema for Vonage configuration."},"VonageConfigurationResponse":{"properties":{"provider":{"type":"string","const":"vonage","title":"Provider","default":"vonage"},"application_id":{"type":"string","title":"Application Id"},"api_key":{"type":"string","title":"Api Key"},"api_secret":{"type":"string","title":"Api Secret"},"private_key":{"type":"string","title":"Private Key"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["application_id","api_key","api_secret","private_key","from_numbers"],"title":"VonageConfigurationResponse","description":"Response schema for Vonage configuration with masked sensitive fields."},"WebhookCredentialType":{"type":"string","enum":["none","api_key","bearer_token","basic_auth","custom_header"],"title":"WebhookCredentialType","description":"Webhook credential authentication types"},"WorkflowCountResponse":{"properties":{"total":{"type":"integer","title":"Total"},"active":{"type":"integer","title":"Active"},"archived":{"type":"integer","title":"Archived"}},"type":"object","required":["total","active","archived"],"title":"WorkflowCountResponse","description":"Response for workflow count endpoint."},"WorkflowError":{"properties":{"kind":{"$ref":"#/components/schemas/ItemKind"},"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id"},"field":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Field"},"message":{"type":"string","title":"Message"}},"type":"object","required":["kind","id","field","message"],"title":"WorkflowError"},"WorkflowListResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"status":{"type":"string","title":"Status"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"total_runs":{"type":"integer","title":"Total Runs"}},"type":"object","required":["id","name","status","created_at","total_runs"],"title":"WorkflowListResponse","description":"Lightweight response for workflow listings (excludes large fields)."},"WorkflowOption":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"}},"type":"object","required":["id","name"],"title":"WorkflowOption"},"WorkflowResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"status":{"type":"string","title":"Status"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"workflow_definition":{"additionalProperties":true,"type":"object","title":"Workflow Definition"},"current_definition_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Current Definition Id"},"template_context_variables":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Template Context Variables"},"call_disposition_codes":{"anyOf":[{"$ref":"#/components/schemas/CallDispositionCodes"},{"type":"null"}]},"total_runs":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Total Runs"},"workflow_configurations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Workflow Configurations"},"version_number":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Version Number"},"version_status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version Status"},"workflow_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Uuid"}},"type":"object","required":["id","name","status","created_at","workflow_definition","current_definition_id"],"title":"WorkflowResponse"},"WorkflowRunDetail":{"properties":{"phone_number":{"type":"string","title":"Phone Number"},"disposition":{"type":"string","title":"Disposition"},"duration_seconds":{"type":"number","title":"Duration Seconds"},"workflow_id":{"type":"integer","title":"Workflow Id"},"run_id":{"type":"integer","title":"Run Id"},"workflow_name":{"type":"string","title":"Workflow Name"},"created_at":{"type":"string","title":"Created At"}},"type":"object","required":["phone_number","disposition","duration_seconds","workflow_id","run_id","workflow_name","created_at"],"title":"WorkflowRunDetail"},"WorkflowRunResponseSchema":{"properties":{"id":{"type":"integer","title":"Id"},"workflow_id":{"type":"integer","title":"Workflow Id"},"name":{"type":"string","title":"Name"},"mode":{"type":"string","title":"Mode"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"is_completed":{"type":"boolean","title":"Is Completed"},"transcript_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Url"},"recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Url"},"cost_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Cost Info"},"definition_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Definition Id"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"gathered_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Gathered Context"},"call_type":{"$ref":"#/components/schemas/CallType"},"logs":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Logs"},"annotations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Annotations"}},"type":"object","required":["id","workflow_id","name","mode","created_at","is_completed","transcript_url","recording_url","cost_info","definition_id","call_type"],"title":"WorkflowRunResponseSchema"},"WorkflowRunUsageResponse":{"properties":{"id":{"type":"integer","title":"Id"},"workflow_id":{"type":"integer","title":"Workflow Id"},"workflow_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Name"},"name":{"type":"string","title":"Name"},"created_at":{"type":"string","title":"Created At"},"dograh_token_usage":{"type":"number","title":"Dograh Token Usage"},"call_duration_seconds":{"type":"integer","title":"Call Duration Seconds"},"recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Url"},"transcript_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Url"},"phone_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone Number","description":"Deprecated. Use caller_number and called_number instead.","deprecated":true},"caller_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Caller Number"},"called_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Called Number"},"call_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Call Type"},"disposition":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Disposition"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"gathered_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Gathered Context"},"charge_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Charge Usd"}},"type":"object","required":["id","workflow_id","workflow_name","name","created_at","dograh_token_usage","call_duration_seconds"],"title":"WorkflowRunUsageResponse"},"WorkflowRunsResponse":{"properties":{"runs":{"items":{"$ref":"#/components/schemas/WorkflowRunResponseSchema"},"type":"array","title":"Runs"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"limit":{"type":"integer","title":"Limit"},"total_pages":{"type":"integer","title":"Total Pages"},"applied_filters":{"anyOf":[{"items":{"additionalProperties":true,"type":"object"},"type":"array"},{"type":"null"}],"title":"Applied Filters"}},"type":"object","required":["runs","total_count","page","limit","total_pages"],"title":"WorkflowRunsResponse"},"WorkflowSummaryResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"}},"type":"object","required":["id","name"],"title":"WorkflowSummaryResponse"},"WorkflowTemplateResponse":{"properties":{"id":{"type":"integer","title":"Id"},"template_name":{"type":"string","title":"Template Name"},"template_description":{"type":"string","title":"Template Description"},"template_json":{"additionalProperties":true,"type":"object","title":"Template Json"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","template_name","template_description","template_json","created_at"],"title":"WorkflowTemplateResponse"},"WorkflowVersionResponse":{"properties":{"id":{"type":"integer","title":"Id"},"version_number":{"type":"integer","title":"Version Number"},"status":{"type":"string","title":"Status"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"published_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Published At"},"workflow_json":{"additionalProperties":true,"type":"object","title":"Workflow Json"},"workflow_configurations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Workflow Configurations"},"template_context_variables":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Template Context Variables"}},"type":"object","required":["id","version_number","status","created_at","workflow_json"],"title":"WorkflowVersionResponse"}}}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"Dograh API","description":"API for the Dograh app","version":"1.0.0"},"servers":[{"url":"https://app.dograh.com","description":"Production"},{"url":"http://localhost:8000","description":"Local development"}],"paths":{"/api/v1/telephony/initiate-call":{"post":{"tags":["main"],"summary":"Initiate Call","description":"Initiate a call using the configured telephony provider from web browser. This is\nsupposed to be a test call method for the draft version of the agent.","operationId":"initiate_call_api_v1_telephony_initiate_call_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InitiateCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"test_phone_call","x-sdk-description":"Place a test call from a workflow to a phone number."}},"/api/v1/telephony/inbound/run":{"post":{"tags":["main"],"summary":"Handle Inbound Run","description":"Workflow-agnostic inbound dispatcher.\n\nAll providers can point a single webhook at this endpoint instead of one\nURL per workflow. The dispatcher resolves the org from the webhook's\naccount_id and the workflow from the called number's\n``inbound_workflow_id``. This is what ``configure_inbound`` writes into\neach provider's resource so per-workflow webhook bookkeeping disappears.\n\nProvider-specific signature/timestamp headers are not enumerated here \u2014\neach provider's ``verify_inbound_signature`` reads its own headers from\nthe dict, so adding a new provider doesn't require changes to this route.","operationId":"handle_inbound_run_api_v1_telephony_inbound_run_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"}}}},"/api/v1/telephony/inbound/fallback":{"post":{"tags":["main"],"summary":"Handle Inbound Fallback","description":"Fallback endpoint that returns audio message when calls cannot be processed.","operationId":"handle_inbound_fallback_api_v1_telephony_inbound_fallback_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"}}}},"/api/v1/telephony/inbound/{workflow_id}":{"post":{"tags":["main"],"summary":"Handle Inbound Telephony","description":"[LEGACY] Per-workflow inbound webhook.\n\nSuperseded by ``POST /inbound/run``, which resolves the workflow from\nthe called number's ``inbound_workflow_id`` and lets a single webhook\nURL serve every workflow in the org. New integrations should point\ntheir provider at ``/inbound/run``; this route is kept only for\nexisting provider configurations that still encode ``workflow_id``\nin the URL.","operationId":"handle_inbound_telephony_api_v1_telephony_inbound__workflow_id__post","deprecated":true,"parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/transfer-result/{transfer_id}":{"post":{"tags":["main"],"summary":"Complete Transfer Function Call","description":"Webhook endpoint to complete the function call with transfer result.\n\nCalled by Twilio's StatusCallback when the transfer call status changes.","operationId":"complete_transfer_function_call_api_v1_telephony_transfer_result__transfer_id__post","parameters":[{"name":"transfer_id","in":"path","required":true,"schema":{"type":"string","title":"Transfer Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/cloudonix/status-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Cloudonix Status Callback","description":"Handle Cloudonix-specific status callbacks.\n\nCloudonix sends call status updates to the callback URL specified during call initiation.","operationId":"handle_cloudonix_status_callback_api_v1_telephony_cloudonix_status_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/cloudonix/cdr":{"post":{"tags":["main"],"summary":"Handle Cloudonix Cdr","description":"Handle Cloudonix CDR (Call Detail Record) webhooks.\n\nCloudonix sends CDR records when calls complete. The CDR contains:\n- domain: Used to identify the organization\n- call_id: Used to find the workflow run\n- disposition: Call termination status (ANSWER, BUSY, CANCEL, FAILED, CONGESTION, NOANSWER)\n- duration/billsec: Call duration information","operationId":"handle_cloudonix_cdr_api_v1_telephony_cloudonix_cdr_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"}}}},"/api/v1/telephony/plivo/hangup-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Plivo Hangup Callback","description":"Handle Plivo hangup callbacks.","operationId":"handle_plivo_hangup_callback_api_v1_telephony_plivo_hangup_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/plivo/ring-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Plivo Ring Callback","description":"Handle Plivo ring callbacks.","operationId":"handle_plivo_ring_callback_api_v1_telephony_plivo_ring_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/telnyx/events/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Telnyx Events","description":"Handle Telnyx Call Control webhook events.\n\nTelnyx sends all call lifecycle events (call.initiated, call.answered,\ncall.hangup, streaming.started, streaming.stopped) as JSON POST requests.","operationId":"handle_telnyx_events_api_v1_telephony_telnyx_events__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/telnyx/transfer-result/{transfer_id}":{"post":{"tags":["main"],"summary":"Handle Telnyx Transfer Result","description":"Handle Telnyx Call Control events for the transfer destination leg.\n\nThe destination leg is dialed by :meth:`TelnyxProvider.transfer_call` with\nthis URL as ``webhook_url``. Telnyx sends every event for that leg here.\nOutcomes:\n\n- ``call.answered``: seed a conference with the destination's live\n ``call_control_id``, stamp ``conference_id`` onto the TransferContext,\n and publish ``DESTINATION_ANSWERED`` so ``transfer_call_handler`` can\n end the pipeline. ``TelnyxConferenceStrategy`` then joins the caller\n into this conference at pipeline teardown.\n- ``call.hangup`` pre-answer (no ``conference_id`` on the context):\n publish ``TRANSFER_FAILED`` so the LLM can recover.\n- ``call.hangup`` post-answer (``conference_id`` set): the destination\n left a bridged conference; hang up the caller's leg to tear down the\n empty bridge (Telnyx's create_conference doesn't accept\n ``end_conference_on_exit`` on the seed leg).\n\nEvent references:\n - call.answered: https://developers.telnyx.com/api-reference/callbacks/call-answered\n - call.hangup: https://developers.telnyx.com/api-reference/callbacks/call-hangup","operationId":"handle_telnyx_transfer_result_api_v1_telephony_telnyx_transfer_result__transfer_id__post","parameters":[{"name":"transfer_id","in":"path","required":true,"schema":{"type":"string","title":"Transfer Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/twilio/status-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Twilio Status Callback","description":"Handle Twilio-specific status callbacks.","operationId":"handle_twilio_status_callback_api_v1_telephony_twilio_status_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/vobiz/hangup-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Vobiz Hangup Callback","description":"Handle Vobiz hangup callback (sent when call ends).\n\nVobiz sends callbacks to hangup_url when the call terminates.\nThis includes call duration, status, and billing information.","operationId":"handle_vobiz_hangup_callback_api_v1_telephony_vobiz_hangup_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}},{"name":"x-vobiz-signature","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Signature"}},{"name":"x-vobiz-timestamp","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Timestamp"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/vobiz/ring-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Vobiz Ring Callback","description":"Handle Vobiz ring callback (sent when call starts ringing).\n\nVobiz can send callbacks to ring_url when the call starts ringing.\nThis is optional and used for tracking ringing status.","operationId":"handle_vobiz_ring_callback_api_v1_telephony_vobiz_ring_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}},{"name":"x-vobiz-signature","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Signature"}},{"name":"x-vobiz-timestamp","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Timestamp"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}":{"post":{"tags":["main"],"summary":"Handle Vobiz Hangup Callback By Workflow","description":"Handle Vobiz hangup callback with workflow_id - finds workflow run by call_id.","operationId":"handle_vobiz_hangup_callback_by_workflow_api_v1_telephony_vobiz_hangup_callback_workflow__workflow_id__post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"x-vobiz-signature","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Signature"}},{"name":"x-vobiz-timestamp","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Timestamp"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/vonage/events/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Vonage Events","description":"Handle Vonage-specific event webhooks.\n\nVonage sends all call events to a single endpoint.\nEvents include: started, ringing, answered, complete, failed, etc.","operationId":"handle_vonage_events_api_v1_telephony_vonage_events__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/superuser/impersonate":{"post":{"tags":["main","superuser"],"summary":"Impersonate","description":"Impersonate a user as a super-admin.\nInternally, Stack Auth requires the **provider user ID** (a UUID-ish string)\nto create an impersonation session.","operationId":"impersonate_api_v1_superuser_impersonate_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImpersonateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImpersonateResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/superuser/workflow-runs":{"get":{"tags":["main","superuser"],"summary":"Get Workflow Runs","description":"Get paginated list of all workflow runs with organization information.\nRequires superuser privileges.\n\nFilters should be provided as a JSON-encoded array of filter criteria.\nExample: [{\"field\": \"id\", \"type\": \"number\", \"value\": {\"value\": 680}}]","operationId":"get_workflow_runs_api_v1_superuser_workflow_runs_get","parameters":[{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Page number (starts from 1)","default":1,"title":"Page"},"description":"Page number (starts from 1)"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Number of items per page","default":50,"title":"Limit"},"description":"Number of items per page"},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded filter criteria","title":"Filters"},"description":"JSON-encoded filter criteria"},{"name":"sort_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Field to sort by (e.g., 'duration', 'created_at')","title":"Sort By"},"description":"Field to sort by (e.g., 'duration', 'created_at')"},{"name":"sort_order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Sort order ('asc' or 'desc')","default":"desc","title":"Sort Order"},"description":"Sort order ('asc' or 'desc')"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuperuserWorkflowRunsListResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/validate":{"post":{"tags":["main"],"summary":"Validate Workflow","description":"Validate all nodes in a workflow to ensure they have required fields.\n\nArgs:\n workflow_id: The ID of the workflow to validate\n user: The authenticated user\n\nReturns:\n Object indicating if workflow is valid and any invalid nodes/edges","operationId":"validate_workflow_api_v1_workflow__workflow_id__validate_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateWorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/create/definition":{"post":{"tags":["main"],"summary":"Create Workflow","description":"Create a new workflow from the client\n\nArgs:\n request: The create workflow request\n user: The user to create the workflow for","operationId":"create_workflow_api_v1_workflow_create_definition_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkflowRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"create_workflow","x-sdk-description":"Create a new workflow from a workflow definition."}},"/api/v1/workflow/create/template":{"post":{"tags":["main"],"summary":"Create Workflow From Template","description":"Create a new workflow from a natural language template request.\n\nThis endpoint:\n1. Uses mps_service_key_client to call MPS workflow API\n2. Passes organization ID (authenticated mode) or created_by (OSS mode)\n3. Creates the workflow in the database\n\nArgs:\n request: The template creation request with call_type, use_case, and activity_description\n user: The authenticated user\n\nReturns:\n The created workflow\n\nRaises:\n HTTPException: If MPS API call fails","operationId":"create_workflow_from_template_api_v1_workflow_create_template_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkflowTemplateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/count":{"get":{"tags":["main"],"summary":"Get Workflow Count","description":"Get workflow counts for the authenticated user's organization.\n\nThis is a lightweight endpoint for checking if the user has workflows,\nuseful for redirect logic without fetching full workflow data.","operationId":"get_workflow_count_api_v1_workflow_count_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCountResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/fetch":{"get":{"tags":["main"],"summary":"Get Workflows","description":"Get all workflows for the authenticated user's organization.\n\nReturns a lightweight response with only essential fields for listing.\nUse GET /workflow/fetch/{workflow_id} to get full workflow details.","operationId":"get_workflows_api_v1_workflow_fetch_get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by status - can be single value (active/archived) or comma-separated (active,archived)","title":"Status"},"description":"Filter by status - can be single value (active/archived) or comma-separated (active,archived)"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowListResponse"},"title":"Response Get Workflows Api V1 Workflow Fetch Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_workflows","x-sdk-description":"List all workflows in the authenticated organization."}},"/api/v1/workflow/fetch/{workflow_id}":{"get":{"tags":["main"],"summary":"Get Workflow","description":"Get a single workflow by ID.\n\nIf a draft version exists, returns the draft content for editing.\nOtherwise returns the published version's content.","operationId":"get_workflow_api_v1_workflow_fetch__workflow_id__get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"get_workflow","x-sdk-description":"Get a single workflow by ID (returns draft if one exists, else published)."}},"/api/v1/workflow/{workflow_id}/versions":{"get":{"tags":["main"],"summary":"Get Workflow Versions","description":"List versions for a workflow, newest first.\n\nPass `limit`/`offset` to page through long histories. With no `limit`,\nreturns every version (legacy behavior).","operationId":"get_workflow_versions_api_v1_workflow__workflow_id__versions_get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":100,"minimum":1},{"type":"null"}],"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowVersionResponse"},"title":"Response Get Workflow Versions Api V1 Workflow Workflow Id Versions Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/publish":{"post":{"tags":["main"],"summary":"Publish Workflow","description":"Publish the current draft version of a workflow.\n\nDrafts are allowed to be incomplete (so the editor can save mid-edit),\nbut a published version is what runtime executes \u2014 so this is the gate\nwhere the full DTO + graph + trigger-conflict checks must pass.","operationId":"publish_workflow_api_v1_workflow__workflow_id__publish_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/create-draft":{"post":{"tags":["main"],"summary":"Create Workflow Draft","description":"Create a draft version from the current published version.\n\nIf a draft already exists, returns the existing draft.","operationId":"create_workflow_draft_api_v1_workflow__workflow_id__create_draft_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVersionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/summary":{"get":{"tags":["main"],"summary":"Get Workflows Summary","description":"Get minimal workflow information (id and name only) for all workflows","operationId":"get_workflows_summary_api_v1_workflow_summary_get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by status (e.g. 'active' or 'archived'). Omit to return all.","title":"Status"},"description":"Filter by status (e.g. 'active' or 'archived'). Omit to return all."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowSummaryResponse"},"title":"Response Get Workflows Summary Api V1 Workflow Summary Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/status":{"put":{"tags":["main"],"summary":"Update Workflow Status","description":"Update the status of a workflow (e.g., archive/unarchive).\n\nArgs:\n workflow_id: The ID of the workflow to update\n request: The status update request\n\nReturns:\n The updated workflow","operationId":"update_workflow_status_api_v1_workflow__workflow_id__status_put","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkflowStatusRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/folder":{"put":{"tags":["main"],"summary":"Move Workflow To Folder","description":"Move a workflow into a folder, or to \"Uncategorized\" (folder_id=null).\n\nValidates that the target folder belongs to the caller's organization \u2014\nthe FK alone proves the folder exists, not that the caller may use it.","operationId":"move_workflow_to_folder_api_v1_workflow__workflow_id__folder_put","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MoveWorkflowToFolderRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowListResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}":{"put":{"tags":["main"],"summary":"Update Workflow","description":"Update an existing workflow.\n\nArgs:\n workflow_id: The ID of the workflow to update\n request: The update request containing the new name and workflow definition\n\nReturns:\n The updated workflow\n\nRaises:\n HTTPException: If the workflow is not found or if there's a database error","operationId":"update_workflow_api_v1_workflow__workflow_id__put","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkflowRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"update_workflow","x-sdk-description":"Update a workflow's name and/or definition. Saves as a new draft."}},"/api/v1/workflow/{workflow_id}/duplicate":{"post":{"tags":["main"],"summary":"Duplicate Workflow Endpoint","description":"Duplicate a workflow including its definition, configuration, recordings, and triggers.","operationId":"duplicate_workflow_endpoint_api_v1_workflow__workflow_id__duplicate_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/runs":{"post":{"tags":["main"],"summary":"Create Workflow Run","description":"Create a new workflow run when the user decides to execute the workflow via chat or voice\n\nArgs:\n workflow_id: The ID of the workflow to run\n request: The create workflow run request\n user: The user to create the workflow run for","operationId":"create_workflow_run_api_v1_workflow__workflow_id__runs_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkflowRunRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkflowRunResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["main"],"summary":"Get Workflow Runs","description":"Get workflow runs with optional filtering and sorting.\n\nFilters should be provided as a JSON-encoded array of filter criteria.\nExample: [{\"attribute\": \"dateRange\", \"value\": {\"from\": \"2024-01-01\", \"to\": \"2024-01-31\"}}]","operationId":"get_workflow_runs_api_v1_workflow__workflow_id__runs_get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","default":1,"title":"Page"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":50,"title":"Limit"}},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded filter criteria","title":"Filters"},"description":"JSON-encoded filter criteria"},{"name":"sort_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Field to sort by (e.g., 'duration', 'created_at')","title":"Sort By"},"description":"Field to sort by (e.g., 'duration', 'created_at')"},{"name":"sort_order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Sort order ('asc' or 'desc')","default":"desc","title":"Sort Order"},"description":"Sort order ('asc' or 'desc')"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/runs/{run_id}":{"get":{"tags":["main"],"summary":"Get Workflow Run","operationId":"get_workflow_run_api_v1_workflow__workflow_id__runs__run_id__get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"run_id","in":"path","required":true,"schema":{"type":"integer","title":"Run Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/report":{"get":{"tags":["main"],"summary":"Download Workflow Report","description":"Download a CSV report of completed runs for a workflow.","operationId":"download_workflow_report_api_v1_workflow__workflow_id__report_get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter runs created on or after this datetime (ISO 8601)","title":"Start Date"},"description":"Filter runs created on or after this datetime (ISO 8601)"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter runs created on or before this datetime (ISO 8601)","title":"End Date"},"description":"Filter runs created on or before this datetime (ISO 8601)"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/templates":{"get":{"tags":["main"],"summary":"Get Workflow Templates","description":"Get all available workflow templates.\n\nReturns:\n List of workflow templates","operationId":"get_workflow_templates_api_v1_workflow_templates_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/WorkflowTemplateResponse"},"type":"array","title":"Response Get Workflow Templates Api V1 Workflow Templates Get"}}}},"404":{"description":"Not found"}}}},"/api/v1/workflow/templates/duplicate":{"post":{"tags":["main"],"summary":"Duplicate Workflow Template","description":"Duplicate a workflow template to create a new workflow for the user.\n\nArgs:\n request: The duplicate template request\n user: The authenticated user\n\nReturns:\n The newly created workflow","operationId":"duplicate_workflow_template_api_v1_workflow_templates_duplicate_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateTemplateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/ambient-noise/upload-url":{"post":{"tags":["main"],"summary":"Get a presigned URL to upload a custom ambient noise audio file","description":"Generate a presigned PUT URL for uploading a custom ambient noise file.","operationId":"get_ambient_noise_upload_url_api_v1_workflow_ambient_noise_upload_url_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AmbientNoiseUploadRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AmbientNoiseUploadResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/text-chat/sessions":{"post":{"tags":["main","workflow-text-chat"],"summary":"Create Text Chat Session","operationId":"create_text_chat_session_api_v1_workflow__workflow_id__text_chat_sessions_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTextChatSessionRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunTextSessionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/text-chat/sessions/{run_id}":{"get":{"tags":["main","workflow-text-chat"],"summary":"Get Text Chat Session","operationId":"get_text_chat_session_api_v1_workflow__workflow_id__text_chat_sessions__run_id__get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"run_id","in":"path","required":true,"schema":{"type":"integer","title":"Run Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunTextSessionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/text-chat/sessions/{run_id}/messages":{"post":{"tags":["main","workflow-text-chat"],"summary":"Append Text Chat Message","operationId":"append_text_chat_message_api_v1_workflow__workflow_id__text_chat_sessions__run_id__messages_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"run_id","in":"path","required":true,"schema":{"type":"integer","title":"Run Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppendTextChatMessageRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunTextSessionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/text-chat/sessions/{run_id}/rewind":{"post":{"tags":["main","workflow-text-chat"],"summary":"Rewind Text Chat Session","operationId":"rewind_text_chat_session_api_v1_workflow__workflow_id__text_chat_sessions__run_id__rewind_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"run_id","in":"path","required":true,"schema":{"type":"integer","title":"Run Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RewindTextChatSessionRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunTextSessionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/configurations/defaults":{"get":{"tags":["main"],"summary":"Get Default Configurations","operationId":"get_default_configurations_api_v1_user_configurations_defaults_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DefaultConfigurationsResponse"}}}},"404":{"description":"Not found"}}}},"/api/v1/user/auth/user":{"get":{"tags":["main"],"summary":"Get Auth User","operationId":"get_auth_user_api_v1_user_auth_user_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthUserResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/configurations/user":{"get":{"tags":["main"],"summary":"Get User Configurations","operationId":"get_user_configurations_api_v1_user_configurations_user_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConfigurationRequestResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main"],"summary":"Update User Configurations","operationId":"update_user_configurations_api_v1_user_configurations_user_put","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConfigurationRequestResponseSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConfigurationRequestResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/configurations/user/validate":{"get":{"tags":["main"],"summary":"Validate User Configurations","operationId":"validate_user_configurations_api_v1_user_configurations_user_validate_get","parameters":[{"name":"validity_ttl_seconds","in":"query","required":false,"schema":{"type":"integer","maximum":86400,"minimum":0,"default":60,"title":"Validity Ttl Seconds"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIKeyStatusResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/api-keys":{"get":{"tags":["main"],"summary":"Get Api Keys","description":"Get all API keys for the user's selected organization.","operationId":"get_api_keys_api_v1_user_api_keys_get","parameters":[{"name":"include_archived","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Archived"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/APIKeyResponse"},"title":"Response Get Api Keys Api V1 User Api Keys Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main"],"summary":"Create Api Key","description":"Create a new API key for the user's selected organization.","operationId":"create_api_key_api_v1_user_api_keys_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAPIKeyRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAPIKeyResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/api-keys/{api_key_id}":{"delete":{"tags":["main"],"summary":"Archive Api Key","description":"Archive an API key (soft delete).","operationId":"archive_api_key_api_v1_user_api_keys__api_key_id__delete","parameters":[{"name":"api_key_id","in":"path","required":true,"schema":{"type":"integer","title":"Api Key Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Archive Api Key Api V1 User Api Keys Api Key Id Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/api-keys/{api_key_id}/reactivate":{"put":{"tags":["main"],"summary":"Reactivate Api Key","description":"Reactivate an archived API key.","operationId":"reactivate_api_key_api_v1_user_api_keys__api_key_id__reactivate_put","parameters":[{"name":"api_key_id","in":"path","required":true,"schema":{"type":"integer","title":"Api Key Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Reactivate Api Key Api V1 User Api Keys Api Key Id Reactivate Put"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/configurations/voices/{provider}":{"get":{"tags":["main"],"summary":"Get Voices","description":"Get available voices for a TTS provider.","operationId":"get_voices_api_v1_user_configurations_voices__provider__get","parameters":[{"name":"provider","in":"path","required":true,"schema":{"enum":["elevenlabs","deepgram","sarvam","cartesia","dograh","rime"],"type":"string","title":"Provider"}},{"name":"model","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model"}},{"name":"language","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VoicesResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/create":{"post":{"tags":["main"],"summary":"Create Campaign","description":"Create a new campaign","operationId":"create_campaign_api_v1_campaign_create_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCampaignRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/":{"get":{"tags":["main"],"summary":"Get Campaigns","description":"Get campaigns for user's organization","operationId":"get_campaigns_api_v1_campaign__get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}":{"get":{"tags":["main"],"summary":"Get Campaign","description":"Get campaign details","operationId":"get_campaign_api_v1_campaign__campaign_id__get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["main"],"summary":"Update Campaign","description":"Update campaign settings (name, retry config, max concurrency, schedule)","operationId":"update_campaign_api_v1_campaign__campaign_id__patch","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCampaignRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/start":{"post":{"tags":["main"],"summary":"Start Campaign","description":"Start campaign execution","operationId":"start_campaign_api_v1_campaign__campaign_id__start_post","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/pause":{"post":{"tags":["main"],"summary":"Pause Campaign","description":"Pause campaign execution","operationId":"pause_campaign_api_v1_campaign__campaign_id__pause_post","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/runs":{"get":{"tags":["main"],"summary":"Get Campaign Runs","description":"Get campaign workflow runs with pagination, filters and sorting","operationId":"get_campaign_runs_api_v1_campaign__campaign_id__runs_get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","default":1,"title":"Page"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":50,"title":"Limit"}},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded filter criteria","title":"Filters"},"description":"JSON-encoded filter criteria"},{"name":"sort_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Field to sort by (e.g., 'duration', 'created_at')","title":"Sort By"},"description":"Field to sort by (e.g., 'duration', 'created_at')"},{"name":"sort_order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Sort order ('asc' or 'desc')","default":"desc","title":"Sort Order"},"description":"Sort order ('asc' or 'desc')"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignRunsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/redial":{"post":{"tags":["main"],"summary":"Redial Campaign","description":"Create a new campaign that re-dials unique subscribers from a completed\ncampaign whose latest call resulted in voicemail, no-answer, or busy.\n\nThe new campaign is created in 'created' state with queued_runs pre-seeded\nfrom the parent's original initial contexts. A campaign can be redialed at\nmost once.","operationId":"redial_campaign_api_v1_campaign__campaign_id__redial_post","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RedialCampaignRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/resume":{"post":{"tags":["main"],"summary":"Resume Campaign","description":"Resume a paused campaign","operationId":"resume_campaign_api_v1_campaign__campaign_id__resume_post","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/progress":{"get":{"tags":["main"],"summary":"Get Campaign Progress","description":"Get current campaign progress and statistics","operationId":"get_campaign_progress_api_v1_campaign__campaign_id__progress_get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignProgressResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/source-download-url":{"get":{"tags":["main"],"summary":"Get Campaign Source Download Url","description":"Get presigned download URL for campaign CSV source file\nValidates that the campaign belongs to the user's organization for security.","operationId":"get_campaign_source_download_url_api_v1_campaign__campaign_id__source_download_url_get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignSourceDownloadResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/report":{"get":{"tags":["main"],"summary":"Download Campaign Report","description":"Download a CSV report of completed campaign runs.","operationId":"download_campaign_report_api_v1_campaign__campaign_id__report_get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter runs created on or after this datetime (ISO 8601)","title":"Start Date"},"description":"Filter runs created on or after this datetime (ISO 8601)"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter runs created on or before this datetime (ISO 8601)","title":"End Date"},"description":"Filter runs created on or before this datetime (ISO 8601)"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/credentials/":{"get":{"tags":["main"],"summary":"List Credentials","description":"List all webhook credentials for the user's organization.\n\nReturns:\n List of credentials (without sensitive data)","operationId":"list_credentials_api_v1_credentials__get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CredentialResponse"},"title":"Response List Credentials Api V1 Credentials Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_credentials","x-sdk-description":"List webhook credentials available to the authenticated organization."},"post":{"tags":["main"],"summary":"Create Credential","description":"Create a new webhook credential.\n\nArgs:\n request: The credential creation request\n\nReturns:\n The created credential (without sensitive data)","operationId":"create_credential_api_v1_credentials__post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCredentialRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CredentialResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/credentials/{credential_uuid}":{"get":{"tags":["main"],"summary":"Get Credential","description":"Get a specific webhook credential by UUID.\n\nArgs:\n credential_uuid: The UUID of the credential\n\nReturns:\n The credential (without sensitive data)","operationId":"get_credential_api_v1_credentials__credential_uuid__get","parameters":[{"name":"credential_uuid","in":"path","required":true,"schema":{"type":"string","title":"Credential Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CredentialResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main"],"summary":"Update Credential","description":"Update a webhook credential.\n\nArgs:\n credential_uuid: The UUID of the credential to update\n request: The update request\n\nReturns:\n The updated credential (without sensitive data)","operationId":"update_credential_api_v1_credentials__credential_uuid__put","parameters":[{"name":"credential_uuid","in":"path","required":true,"schema":{"type":"string","title":"Credential Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCredentialRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CredentialResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main"],"summary":"Delete Credential","description":"Delete (soft delete) a webhook credential.\n\nArgs:\n credential_uuid: The UUID of the credential to delete\n\nReturns:\n Success message","operationId":"delete_credential_api_v1_credentials__credential_uuid__delete","parameters":[{"name":"credential_uuid","in":"path","required":true,"schema":{"type":"string","title":"Credential Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Credential Api V1 Credentials Credential Uuid Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/tools/":{"get":{"tags":["main"],"summary":"List Tools","description":"List all tools for the user's organization.\n\nArgs:\n status: Optional filter by status (active, archived, draft)\n category: Optional filter by category (http_api, native, integration)\n\nReturns:\n List of tools","operationId":"list_tools_api_v1_tools__get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status"}},{"name":"category","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Category"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ToolResponse"},"title":"Response List Tools Api V1 Tools Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_tools","x-sdk-description":"List tools available to the authenticated organization."},"post":{"tags":["main"],"summary":"Create Tool","description":"Create a new tool.\n\nArgs:\n request: The tool creation request\n\nReturns:\n The created tool","operationId":"create_tool_api_v1_tools__post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateToolRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/tools/{tool_uuid}":{"get":{"tags":["main"],"summary":"Get Tool","description":"Get a specific tool by UUID.\n\nArgs:\n tool_uuid: The UUID of the tool\n\nReturns:\n The tool","operationId":"get_tool_api_v1_tools__tool_uuid__get","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main"],"summary":"Update Tool","description":"Update a tool.\n\nArgs:\n tool_uuid: The UUID of the tool to update\n request: The update request\n\nReturns:\n The updated tool","operationId":"update_tool_api_v1_tools__tool_uuid__put","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateToolRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main"],"summary":"Delete Tool","description":"Archive (soft delete) a tool.\n\nArgs:\n tool_uuid: The UUID of the tool to delete\n\nReturns:\n Success message","operationId":"delete_tool_api_v1_tools__tool_uuid__delete","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Tool Api V1 Tools Tool Uuid Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/tools/{tool_uuid}/mcp/refresh":{"post":{"tags":["main"],"summary":"Refresh Mcp Tools","description":"Re-discover an MCP tool's server catalog and overwrite the cached\n``definition.config.discovered_tools``. Server down \u2192 200 with error\n(cache not overwritten on transient failure).","operationId":"refresh_mcp_tools_api_v1_tools__tool_uuid__mcp_refresh_post","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpRefreshResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/tools/{tool_uuid}/unarchive":{"post":{"tags":["main"],"summary":"Unarchive Tool","description":"Unarchive a tool (restore from archived state).\n\nArgs:\n tool_uuid: The UUID of the tool to unarchive\n\nReturns:\n The unarchived tool","operationId":"unarchive_tool_api_v1_tools__tool_uuid__unarchive_post","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-providers/metadata":{"get":{"tags":["main","organizations"],"summary":"Get Telephony Providers Metadata","description":"Return the list of available telephony providers and their form schemas.\n\nThe UI uses this to render the configuration form generically instead of\nhard-coding fields per provider. Adding a new provider only requires\ndeclaring its ui_metadata in providers//__init__.py.","operationId":"get_telephony_providers_metadata_api_v1_organizations_telephony_providers_metadata_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyProvidersMetadataResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-config-warnings":{"get":{"tags":["main","organizations"],"summary":"Get Telephony Config Warnings","description":"Return aggregated warning counts for the current org's telephony configs.\n\nToday this surfaces only Telnyx configs missing ``webhook_public_key``;\nadditional warning types should be added as new fields on the response.","operationId":"get_telephony_config_warnings_api_v1_organizations_telephony_config_warnings_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigWarningsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs":{"get":{"tags":["main","organizations"],"summary":"List Telephony Configurations","description":"List the org's telephony configurations with phone-number counts.","operationId":"list_telephony_configurations_api_v1_organizations_telephony_configs_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationListResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main","organizations"],"summary":"Create Telephony Configuration","description":"Create a new telephony configuration for the org.","operationId":"create_telephony_configuration_api_v1_organizations_telephony_configs_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationDetail"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}":{"get":{"tags":["main","organizations"],"summary":"Get Telephony Configuration By Id","operationId":"get_telephony_configuration_by_id_api_v1_organizations_telephony_configs__config_id__get","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationDetail"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main","organizations"],"summary":"Update Telephony Configuration","operationId":"update_telephony_configuration_api_v1_organizations_telephony_configs__config_id__put","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationUpdateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationDetail"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main","organizations"],"summary":"Delete Telephony Configuration","operationId":"delete_telephony_configuration_api_v1_organizations_telephony_configs__config_id__delete","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}/set-default-outbound":{"post":{"tags":["main","organizations"],"summary":"Set Default Outbound","operationId":"set_default_outbound_api_v1_organizations_telephony_configs__config_id__set_default_outbound_post","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationDetail"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}/phone-numbers":{"get":{"tags":["main","organizations"],"summary":"List Phone Numbers","operationId":"list_phone_numbers_api_v1_organizations_telephony_configs__config_id__phone_numbers_get","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberListResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main","organizations"],"summary":"Create Phone Number","operationId":"create_phone_number_api_v1_organizations_telephony_configs__config_id__phone_numbers_post","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}/phone-numbers/{phone_number_id}":{"get":{"tags":["main","organizations"],"summary":"Get Phone Number","operationId":"get_phone_number_api_v1_organizations_telephony_configs__config_id__phone_numbers__phone_number_id__get","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"phone_number_id","in":"path","required":true,"schema":{"type":"integer","title":"Phone Number Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main","organizations"],"summary":"Update Phone Number","operationId":"update_phone_number_api_v1_organizations_telephony_configs__config_id__phone_numbers__phone_number_id__put","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"phone_number_id","in":"path","required":true,"schema":{"type":"integer","title":"Phone Number Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberUpdateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main","organizations"],"summary":"Delete Phone Number","operationId":"delete_phone_number_api_v1_organizations_telephony_configs__config_id__phone_numbers__phone_number_id__delete","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"phone_number_id","in":"path","required":true,"schema":{"type":"integer","title":"Phone Number Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}/phone-numbers/{phone_number_id}/set-default-caller":{"post":{"tags":["main","organizations"],"summary":"Set Default Caller Id","operationId":"set_default_caller_id_api_v1_organizations_telephony_configs__config_id__phone_numbers__phone_number_id__set_default_caller_post","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"phone_number_id","in":"path","required":true,"schema":{"type":"integer","title":"Phone Number Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-config":{"get":{"tags":["main","organizations"],"summary":"Get Telephony Configuration","description":"Legacy: returns the org's default config in the original per-provider\nresponse shape so the existing single-form UI keeps working. Prefer the\nmulti-config endpoints (``/telephony-configs``) for new clients.","operationId":"get_telephony_configuration_api_v1_organizations_telephony_config_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main","organizations"],"summary":"Save Telephony Configuration","description":"Legacy: upserts the org's default config (and its phone numbers) in the\noriginal payload shape so existing UI clients keep working. Prefer the\nmulti-config + phone-number endpoints for new clients.","operationId":"save_telephony_configuration_api_v1_organizations_telephony_config_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ARIConfigurationRequest"},{"$ref":"#/components/schemas/CloudonixConfigurationRequest"},{"$ref":"#/components/schemas/PlivoConfigurationRequest"},{"$ref":"#/components/schemas/TelnyxConfigurationRequest"},{"$ref":"#/components/schemas/TwilioConfigurationRequest"},{"$ref":"#/components/schemas/VobizConfigurationRequest"},{"$ref":"#/components/schemas/VonageConfigurationRequest"}],"discriminator":{"propertyName":"provider","mapping":{"ari":"#/components/schemas/ARIConfigurationRequest","cloudonix":"#/components/schemas/CloudonixConfigurationRequest","plivo":"#/components/schemas/PlivoConfigurationRequest","telnyx":"#/components/schemas/TelnyxConfigurationRequest","twilio":"#/components/schemas/TwilioConfigurationRequest","vobiz":"#/components/schemas/VobizConfigurationRequest","vonage":"#/components/schemas/VonageConfigurationRequest"}},"title":"Request"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/langfuse-credentials":{"get":{"tags":["main","organizations"],"summary":"Get Langfuse Credentials","description":"Get Langfuse credentials for the user's organization with masked sensitive fields.","operationId":"get_langfuse_credentials_api_v1_organizations_langfuse_credentials_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LangfuseCredentialsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main","organizations"],"summary":"Save Langfuse Credentials","description":"Save Langfuse credentials for the user's organization.","operationId":"save_langfuse_credentials_api_v1_organizations_langfuse_credentials_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LangfuseCredentialsRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main","organizations"],"summary":"Delete Langfuse Credentials","description":"Delete Langfuse credentials for the user's organization.","operationId":"delete_langfuse_credentials_api_v1_organizations_langfuse_credentials_delete","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/campaign-defaults":{"get":{"tags":["main","organizations"],"summary":"Get Campaign Defaults","description":"Get campaign limits for the user's organization.\n\nReturns the organization's concurrent call limit and default retry configuration.","operationId":"get_campaign_defaults_api_v1_organizations_campaign_defaults_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignDefaultsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/s3/signed-url":{"get":{"tags":["main","s3"],"summary":"Generate a signed S3 URL","description":"Return a short-lived signed URL for a file stored on S3 / MinIO.\n\nAccess Control:\n* Keys that embed an organization ID (``{prefix}/{org_id}/...``) are\n authorized by matching the org_id against the requesting user's\n organization.\n* Legacy keys (``recordings/{run_id}.wav``, ``transcripts/{run_id}.txt``)\n are authorized via the workflow run they belong to.\n* Superusers can request any key.","operationId":"get_signed_url_api_v1_s3_signed_url_get","parameters":[{"name":"key","in":"query","required":true,"schema":{"type":"string","description":"S3 object key","title":"Key"},"description":"S3 object key"},{"name":"expires_in","in":"query","required":false,"schema":{"type":"integer","default":3600,"title":"Expires In"}},{"name":"inline","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Inline"}},{"name":"storage_backend","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Storage backend to use (e.g. 'minio', 's3'). When omitted the backend is inferred from the resource.","title":"Storage Backend"},"description":"Storage backend to use (e.g. 'minio', 's3'). When omitted the backend is inferred from the resource."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/S3SignedUrlResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/s3/file-metadata":{"get":{"tags":["main","s3"],"summary":"Get file metadata for debugging","description":"Get file metadata including creation timestamp for debugging.\n\nAccess Control:\n* Superusers can request any key.\n* Regular users can only request resources belonging to **their** workflow runs.","operationId":"get_file_metadata_api_v1_s3_file_metadata_get","parameters":[{"name":"key","in":"query","required":true,"schema":{"type":"string","description":"S3 object key","title":"Key"},"description":"S3 object key"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileMetadataResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/s3/presigned-upload-url":{"post":{"tags":["main","s3"],"summary":"Generate a presigned URL for direct CSV upload","description":"Generate a presigned PUT URL for direct CSV file upload to S3/MinIO.\n\nThis endpoint enables browser-to-storage uploads without passing through the backend\n\nAccess Control:\n* All authenticated users can upload CSV files scoped to their organization.\n* Files are stored with organization-scoped keys for multi-tenancy.\n\nReturns:\n* upload_url: Presigned URL (valid for 15 minutes) for PUT request\n* file_key: Unique storage key to use as source_id in campaign creation\n* expires_in: URL expiration time in seconds","operationId":"get_presigned_upload_url_api_v1_s3_presigned_upload_url_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignedUploadUrlRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignedUploadUrlResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/service-keys":{"get":{"tags":["main"],"summary":"Get Service Keys","description":"Get all service keys for the user's organization.","operationId":"get_service_keys_api_v1_user_service_keys_get","parameters":[{"name":"include_archived","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Archived"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ServiceKeyResponse"},"title":"Response Get Service Keys Api V1 User Service Keys Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main"],"summary":"Create Service Key","description":"Create a new service key for the user's organization.","operationId":"create_service_key_api_v1_user_service_keys_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateServiceKeyRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateServiceKeyResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/service-keys/{service_key_id}":{"delete":{"tags":["main"],"summary":"Archive Service Key","description":"Archive a service key.","operationId":"archive_service_key_api_v1_user_service_keys__service_key_id__delete","parameters":[{"name":"service_key_id","in":"path","required":true,"schema":{"type":"string","title":"Service Key Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/service-keys/{service_key_id}/reactivate":{"put":{"tags":["main"],"summary":"Reactivate Service Key","description":"Reactivate an archived service key.\n\nNote: This endpoint is provided for API compatibility but service key\nreactivation is not supported by MPS. Once archived, a service key\ncannot be reactivated and a new key must be created instead.","operationId":"reactivate_service_key_api_v1_user_service_keys__service_key_id__reactivate_put","parameters":[{"name":"service_key_id","in":"path","required":true,"schema":{"type":"string","title":"Service Key Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/current-period":{"get":{"tags":["main"],"summary":"Get Current Period Usage","description":"Get current billing period usage for the user's organization.","operationId":"get_current_period_usage_api_v1_organizations_usage_current_period_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CurrentUsageResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/mps-credits":{"get":{"tags":["main"],"summary":"Get Mps Credits","description":"Get aggregated usage and quota from MPS.\n\nOSS users: queries by provider_id (created_by).\nHosted users: queries by organization_id.","operationId":"get_mps_credits_api_v1_organizations_usage_mps_credits_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MPSCreditsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/runs":{"get":{"tags":["main"],"summary":"Get Usage History","description":"Get paginated workflow runs with usage for the organization.","operationId":"get_usage_history_api_v1_organizations_usage_runs_get","parameters":[{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`.","examples":["2026-04-01T00:00:00Z"],"title":"Start Date"},"description":"ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`."},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`.","examples":["2026-05-01T00:00:00Z"],"title":"End Date"},"description":"ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`."},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":50,"title":"Limit"}},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded array of filter objects. Each object has the shape:\n\n```json\n{ \"attribute\": \"\", \"type\": \"\", \"value\": }\n```\n\nSupported `attribute` / `type` / `value` combinations:\n\n| attribute | type | value shape | matches |\n|-----------------|---------------|----------------------------------------------|------------------------------------------------------|\n| `runId` | `number` | `{ \"value\": 12345 }` | exact run id |\n| `workflowId` | `number` | `{ \"value\": 42 }` | exact agent (workflow) id |\n| `campaignId` | `number` | `{ \"value\": 7 }` | exact campaign id |\n| `callerNumber` | `text` | `{ \"value\": \"415555\" }` | substring match on `initial_context.caller_number` |\n| `calledNumber` | `text` | `{ \"value\": \"9911848\" }` | substring match on `initial_context.called_number` |\n| `dispositionCode` | `multiSelect` | `{ \"codes\": [\"XFER\", \"DNC\"] }` | any of the codes in `gathered_context.mapped_call_disposition` |\n| `duration` | `numberRange` | `{ \"min\": 60, \"max\": 300 }` | call duration (seconds), inclusive bounds |\n\nUnknown attributes and unsupported `type` values are silently ignored.\n\nDate filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.\n","examples":["[{\"attribute\":\"callerNumber\",\"type\":\"text\",\"value\":{\"value\":\"415555\"}}]","[{\"attribute\":\"campaignId\",\"type\":\"number\",\"value\":{\"value\":7}},{\"attribute\":\"duration\",\"type\":\"numberRange\",\"value\":{\"min\":60,\"max\":300}}]","[{\"attribute\":\"dispositionCode\",\"type\":\"multiSelect\",\"value\":{\"codes\":[\"XFER\",\"DNC\"]}}]"],"title":"Filters"},"description":"JSON-encoded array of filter objects. Each object has the shape:\n\n```json\n{ \"attribute\": \"\", \"type\": \"\", \"value\": }\n```\n\nSupported `attribute` / `type` / `value` combinations:\n\n| attribute | type | value shape | matches |\n|-----------------|---------------|----------------------------------------------|------------------------------------------------------|\n| `runId` | `number` | `{ \"value\": 12345 }` | exact run id |\n| `workflowId` | `number` | `{ \"value\": 42 }` | exact agent (workflow) id |\n| `campaignId` | `number` | `{ \"value\": 7 }` | exact campaign id |\n| `callerNumber` | `text` | `{ \"value\": \"415555\" }` | substring match on `initial_context.caller_number` |\n| `calledNumber` | `text` | `{ \"value\": \"9911848\" }` | substring match on `initial_context.called_number` |\n| `dispositionCode` | `multiSelect` | `{ \"codes\": [\"XFER\", \"DNC\"] }` | any of the codes in `gathered_context.mapped_call_disposition` |\n| `duration` | `numberRange` | `{ \"min\": 60, \"max\": 300 }` | call duration (seconds), inclusive bounds |\n\nUnknown attributes and unsupported `type` values are silently ignored.\n\nDate filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.\n"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsageHistoryResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/runs/report":{"get":{"tags":["main"],"summary":"Download Usage Runs Report","description":"Download a CSV of runs matching the same filters as `/usage/runs`.","operationId":"download_usage_runs_report_api_v1_organizations_usage_runs_report_get","parameters":[{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`.","title":"Start Date"},"description":"ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`."},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`.","title":"End Date"},"description":"ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`."},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded array of filter objects. Each object has the shape:\n\n```json\n{ \"attribute\": \"\", \"type\": \"\", \"value\": }\n```\n\nSupported `attribute` / `type` / `value` combinations:\n\n| attribute | type | value shape | matches |\n|-----------------|---------------|----------------------------------------------|------------------------------------------------------|\n| `runId` | `number` | `{ \"value\": 12345 }` | exact run id |\n| `workflowId` | `number` | `{ \"value\": 42 }` | exact agent (workflow) id |\n| `campaignId` | `number` | `{ \"value\": 7 }` | exact campaign id |\n| `callerNumber` | `text` | `{ \"value\": \"415555\" }` | substring match on `initial_context.caller_number` |\n| `calledNumber` | `text` | `{ \"value\": \"9911848\" }` | substring match on `initial_context.called_number` |\n| `dispositionCode` | `multiSelect` | `{ \"codes\": [\"XFER\", \"DNC\"] }` | any of the codes in `gathered_context.mapped_call_disposition` |\n| `duration` | `numberRange` | `{ \"min\": 60, \"max\": 300 }` | call duration (seconds), inclusive bounds |\n\nUnknown attributes and unsupported `type` values are silently ignored.\n\nDate filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.\n","title":"Filters"},"description":"JSON-encoded array of filter objects. Each object has the shape:\n\n```json\n{ \"attribute\": \"\", \"type\": \"\", \"value\": }\n```\n\nSupported `attribute` / `type` / `value` combinations:\n\n| attribute | type | value shape | matches |\n|-----------------|---------------|----------------------------------------------|------------------------------------------------------|\n| `runId` | `number` | `{ \"value\": 12345 }` | exact run id |\n| `workflowId` | `number` | `{ \"value\": 42 }` | exact agent (workflow) id |\n| `campaignId` | `number` | `{ \"value\": 7 }` | exact campaign id |\n| `callerNumber` | `text` | `{ \"value\": \"415555\" }` | substring match on `initial_context.caller_number` |\n| `calledNumber` | `text` | `{ \"value\": \"9911848\" }` | substring match on `initial_context.called_number` |\n| `dispositionCode` | `multiSelect` | `{ \"codes\": [\"XFER\", \"DNC\"] }` | any of the codes in `gathered_context.mapped_call_disposition` |\n| `duration` | `numberRange` | `{ \"min\": 60, \"max\": 300 }` | call duration (seconds), inclusive bounds |\n\nUnknown attributes and unsupported `type` values are silently ignored.\n\nDate filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.\n"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/daily-breakdown":{"get":{"tags":["main"],"summary":"Get Daily Usage Breakdown","description":"Get daily usage breakdown for the last N days. Only available for organizations with pricing.","operationId":"get_daily_usage_breakdown_api_v1_organizations_usage_daily_breakdown_get","parameters":[{"name":"days","in":"query","required":false,"schema":{"type":"integer","maximum":30,"minimum":1,"description":"Number of days to include","default":7,"title":"Days"},"description":"Number of days to include"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DailyUsageBreakdownResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/reports/daily":{"get":{"tags":["main"],"summary":"Get Daily Report","description":"Get daily report for the specified date and timezone.\nIf workflow_id is provided, filters results to that specific workflow.\nIf workflow_id is None, includes all workflows for the organization.","operationId":"get_daily_report_api_v1_organizations_reports_daily_get","parameters":[{"name":"date","in":"query","required":true,"schema":{"type":"string","description":"Date in YYYY-MM-DD format","title":"Date"},"description":"Date in YYYY-MM-DD format"},{"name":"timezone","in":"query","required":true,"schema":{"type":"string","description":"IANA timezone (e.g., 'America/New_York')","title":"Timezone"},"description":"IANA timezone (e.g., 'America/New_York')"},{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Optional workflow ID to filter by","title":"Workflow Id"},"description":"Optional workflow ID to filter by"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DailyReportResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/reports/workflows":{"get":{"tags":["main"],"summary":"Get Workflow Options","description":"Get all workflows for the user's organization.\nUsed to populate the workflow selector dropdown in the reports page.","operationId":"get_workflow_options_api_v1_organizations_reports_workflows_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowOption"},"title":"Response Get Workflow Options Api V1 Organizations Reports Workflows Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/reports/daily/runs":{"get":{"tags":["main"],"summary":"Get Daily Runs Detail","description":"Get detailed workflow runs for the specified date.\nUsed for CSV export functionality.","operationId":"get_daily_runs_detail_api_v1_organizations_reports_daily_runs_get","parameters":[{"name":"date","in":"query","required":true,"schema":{"type":"string","description":"Date in YYYY-MM-DD format","title":"Date"},"description":"Date in YYYY-MM-DD format"},{"name":"timezone","in":"query","required":true,"schema":{"type":"string","description":"IANA timezone (e.g., 'America/New_York')","title":"Timezone"},"description":"IANA timezone (e.g., 'America/New_York')"},{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Optional workflow ID to filter by","title":"Workflow Id"},"description":"Optional workflow ID to filter by"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowRunDetail"},"title":"Response Get Daily Runs Detail Api V1 Organizations Reports Daily Runs Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/turn/credentials":{"get":{"tags":["main","turn"],"summary":"Get Turn Credentials","description":"Get time-limited TURN credentials for WebRTC connections.\n\nThis endpoint generates ephemeral TURN credentials that are:\n- Valid for the configured TTL (default: 24 hours)\n- Cryptographically bound to the user via HMAC\n- Compatible with coturn's use-auth-secret mode\n\nReturns:\n TurnCredentialsResponse with username, password, ttl, and TURN URIs","operationId":"get_turn_credentials_api_v1_turn_credentials_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TurnCredentialsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/embed/init":{"post":{"tags":["main"],"summary":"Initialize Embed Session","description":"Initialize an embed session with token validation and domain checking.\n\nThis endpoint:\n1. Validates the embed token\n2. Checks domain whitelist\n3. Creates a workflow run\n4. Generates a temporary session token\n5. Returns configuration for the widget","operationId":"initialize_embed_session_api_v1_public_embed_init_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InitEmbedRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InitEmbedResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"options":{"tags":["main"],"summary":"Options Init","description":"Handle CORS preflight for init endpoint","operationId":"options_init_api_v1_public_embed_init_options","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"}}}},"/api/v1/public/embed/config/{token}":{"get":{"tags":["main"],"summary":"Get Embed Config","description":"Get embed configuration without creating a session.\n\nThis endpoint is used to fetch widget configuration for display purposes\nwithout actually starting a call session.","operationId":"get_embed_config_api_v1_public_embed_config__token__get","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","title":"Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmbedConfigResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"options":{"tags":["main"],"summary":"Options Config","description":"Handle CORS preflight for config endpoint","operationId":"options_config_api_v1_public_embed_config__token__options","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","title":"Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/embed/turn-credentials/{session_token}":{"get":{"tags":["main"],"summary":"Get Public Turn Credentials","description":"Get TURN credentials for an embed session.\n\nThis endpoint allows embedded widgets to obtain TURN server credentials\nfor WebRTC connections without requiring authentication.\n\nArgs:\n session_token: The session token from embed initialization\n\nReturns:\n TurnCredentialsResponse with username, password, ttl, and TURN URIs","operationId":"get_public_turn_credentials_api_v1_public_embed_turn_credentials__session_token__get","parameters":[{"name":"session_token","in":"path","required":true,"schema":{"type":"string","title":"Session Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TurnCredentialsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"options":{"tags":["main"],"summary":"Options Turn Credentials","description":"Handle CORS preflight for TURN credentials endpoint","operationId":"options_turn_credentials_api_v1_public_embed_turn_credentials__session_token__options","parameters":[{"name":"session_token","in":"path","required":true,"schema":{"type":"string","title":"Session Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/agent/{uuid}":{"post":{"tags":["main"],"summary":"Initiate Call","description":"Initiate a phone call against the published agent.\n\nExecutes the workflow's currently released definition.","operationId":"initiate_call_api_v1_public_agent__uuid__post","parameters":[{"name":"uuid","in":"path","required":true,"schema":{"type":"string","title":"Uuid"}},{"name":"X-API-Key","in":"header","required":true,"schema":{"type":"string","title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/agent/test/{uuid}":{"post":{"tags":["main"],"summary":"Initiate Call Test","description":"Initiate a phone call against the latest draft of the agent.\n\nUseful for verifying changes before publishing. Falls back to the\npublished definition when no draft exists.","operationId":"initiate_call_test_api_v1_public_agent_test__uuid__post","parameters":[{"name":"uuid","in":"path","required":true,"schema":{"type":"string","title":"Uuid"}},{"name":"X-API-Key","in":"header","required":true,"schema":{"type":"string","title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/agent/workflow/{workflow_uuid}":{"post":{"tags":["main"],"summary":"Initiate Call By Workflow Uuid","description":"Initiate a phone call against the published workflow identified by UUID.","operationId":"initiate_call_by_workflow_uuid_api_v1_public_agent_workflow__workflow_uuid__post","parameters":[{"name":"workflow_uuid","in":"path","required":true,"schema":{"type":"string","title":"Workflow Uuid"}},{"name":"X-API-Key","in":"header","required":true,"schema":{"type":"string","title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/agent/test/workflow/{workflow_uuid}":{"post":{"tags":["main"],"summary":"Initiate Call Test By Workflow Uuid","description":"Initiate a phone call against the latest draft of the workflow by UUID.","operationId":"initiate_call_test_by_workflow_uuid_api_v1_public_agent_test_workflow__workflow_uuid__post","parameters":[{"name":"workflow_uuid","in":"path","required":true,"schema":{"type":"string","title":"Workflow Uuid"}},{"name":"X-API-Key","in":"header","required":true,"schema":{"type":"string","title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/download/workflow/{token}/{artifact_type}":{"get":{"tags":["main"],"summary":"Download Workflow Artifact","description":"Download a workflow recording or transcript via public access token.\n\nThis endpoint:\n1. Validates the public access token\n2. Looks up the corresponding workflow run\n3. Generates a signed URL for the requested artifact\n4. Redirects to the signed URL\n\nArgs:\n token: The public access token (UUID format)\n artifact_type: Type of artifact - \"recording\" or \"transcript\"\n inline: If true, sets Content-Disposition to inline for browser preview\n\nReturns:\n RedirectResponse to the signed URL (302 redirect)\n\nRaises:\n HTTPException 404: If token is invalid or artifact not found","operationId":"download_workflow_artifact_api_v1_public_download_workflow__token___artifact_type__get","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","title":"Token"}},{"name":"artifact_type","in":"path","required":true,"schema":{"enum":["recording","transcript"],"type":"string","title":"Artifact Type"}},{"name":"inline","in":"query","required":false,"schema":{"type":"boolean","description":"Display inline in browser instead of download","default":false,"title":"Inline"},"description":"Display inline in browser instead of download"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/embed-token":{"post":{"tags":["main"],"summary":"Create Or Update Embed Token","description":"Create or update an embed token for a workflow.\nEach workflow can have only one active embed token.","operationId":"create_or_update_embed_token_api_v1_workflow__workflow_id__embed_token_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmbedTokenRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmbedTokenResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["main"],"summary":"Get Embed Token","description":"Get the embed token for a workflow if it exists.","operationId":"get_embed_token_api_v1_workflow__workflow_id__embed_token_get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/EmbedTokenResponse"},{"type":"null"}],"title":"Response Get Embed Token Api V1 Workflow Workflow Id Embed Token Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main"],"summary":"Deactivate Embed Token","description":"Deactivate the embed token for a workflow.","operationId":"deactivate_embed_token_api_v1_workflow__workflow_id__embed_token_delete","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Deactivate Embed Token Api V1 Workflow Workflow Id Embed Token Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/knowledge-base/upload-url":{"post":{"tags":["main","knowledge-base"],"summary":"Get presigned URL for document upload","description":"Generate a presigned PUT URL for uploading a document.\n\nThis endpoint:\n1. Generates a unique document UUID for organizing the S3 key\n2. Generates a presigned S3/MinIO URL for uploading the file\n3. Returns the upload URL and document metadata\n\nAfter uploading to the returned URL, call /process-document to create\nthe document record and trigger processing.\n\nAccess Control:\n* All authenticated users can upload documents scoped to their organization.","operationId":"get_upload_url_api_v1_knowledge_base_upload_url_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentUploadRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentUploadResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/knowledge-base/process-document":{"post":{"tags":["main","knowledge-base"],"summary":"Trigger document processing","description":"Trigger asynchronous processing of an uploaded document.\n\nThis endpoint should be called after successfully uploading a file to the presigned URL.\nIt will:\n1. Create a document record in the database with the specified UUID\n2. Enqueue a background task to process the document (chunking and embedding)\n\nThe document status will be updated from 'pending' -> 'processing' -> 'completed' or 'failed'.\n\nEmbedding:\nUses OpenAI text-embedding-3-small (1536-dimensional embeddings, requires API key configured in Model Configurations).\n\nAccess Control:\n* Users can only process documents in their organization.","operationId":"process_document_api_v1_knowledge_base_process_document_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProcessDocumentRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/knowledge-base/documents":{"get":{"tags":["main","knowledge-base"],"summary":"List documents","description":"List all documents for the user's organization.\n\nAccess Control:\n* Users can only see documents from their organization.","operationId":"list_documents_api_v1_knowledge_base_documents_get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by processing status","title":"Status"},"description":"Filter by processing status"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":100,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_documents","x-sdk-description":"List knowledge base documents available to the authenticated organization."}},"/api/v1/knowledge-base/documents/{document_uuid}":{"get":{"tags":["main","knowledge-base"],"summary":"Get document details","description":"Get details of a specific document.\n\nAccess Control:\n* Users can only access documents from their organization.","operationId":"get_document_api_v1_knowledge_base_documents__document_uuid__get","parameters":[{"name":"document_uuid","in":"path","required":true,"schema":{"type":"string","title":"Document Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main","knowledge-base"],"summary":"Delete document","description":"Soft delete a document and its chunks.\n\nAccess Control:\n* Users can only delete documents from their organization.","operationId":"delete_document_api_v1_knowledge_base_documents__document_uuid__delete","parameters":[{"name":"document_uuid","in":"path","required":true,"schema":{"type":"string","title":"Document Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/knowledge-base/search":{"post":{"tags":["main","knowledge-base"],"summary":"Search for similar chunks","description":"Search for document chunks similar to the query.\n\nThis endpoint uses vector similarity search to find relevant chunks.\nResults are returned without threshold filtering - apply similarity\nthresholds at the application layer after optional reranking.\n\nAccess Control:\n* Users can only search documents from their organization.","operationId":"search_chunks_api_v1_knowledge_base_search_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChunkSearchRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChunkSearchResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow-recordings/upload-url":{"post":{"tags":["main","workflow-recordings"],"summary":"Get presigned URLs for recording uploads","description":"Generate presigned PUT URLs for uploading one or more audio recordings.","operationId":"get_upload_urls_api_v1_workflow_recordings_upload_url_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchRecordingUploadRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchRecordingUploadResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow-recordings/":{"post":{"tags":["main","workflow-recordings"],"summary":"Create recording records after upload","description":"Create one or more recording records after audio files have been uploaded.","operationId":"create_recordings_api_v1_workflow_recordings__post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchRecordingCreateRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchRecordingCreateResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["main","workflow-recordings"],"summary":"List recordings","description":"List recordings for the organization, optionally filtered.","operationId":"list_recordings_api_v1_workflow_recordings__get","parameters":[{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filter by workflow ID","title":"Workflow Id"},"description":"Filter by workflow ID"},{"name":"tts_provider","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by TTS provider","title":"Tts Provider"},"description":"Filter by TTS provider"},{"name":"tts_model","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by TTS model","title":"Tts Model"},"description":"Filter by TTS model"},{"name":"tts_voice_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by TTS voice ID","title":"Tts Voice Id"},"description":"Filter by TTS voice ID"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordingListResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_recordings","x-sdk-description":"List workflow recordings available to the authenticated organization."}},"/api/v1/workflow-recordings/{recording_id}":{"delete":{"tags":["main","workflow-recordings"],"summary":"Delete a recording","description":"Soft delete a recording.","operationId":"delete_recording_api_v1_workflow_recordings__recording_id__delete","parameters":[{"name":"recording_id","in":"path","required":true,"schema":{"type":"string","title":"Recording Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow-recordings/{id}":{"patch":{"tags":["main","workflow-recordings"],"summary":"Update a recording's Recording ID","description":"Update the recording_id (descriptive name) of a recording.","operationId":"update_recording_api_v1_workflow_recordings__id__patch","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordingUpdateRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordingResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow-recordings/transcribe":{"post":{"tags":["main","workflow-recordings"],"summary":"Transcribe an audio file","description":"Transcribe an uploaded audio file using MPS STT.","operationId":"transcribe_audio_api_v1_workflow_recordings_transcribe_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_transcribe_audio_api_v1_workflow_recordings_transcribe_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/folder/":{"get":{"tags":["main"],"summary":"List Folders","description":"List all folders in the authenticated user's organization.","operationId":"list_folders_api_v1_folder__get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FolderResponse"},"title":"Response List Folders Api V1 Folder Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main"],"summary":"Create Folder","description":"Create a new folder in the authenticated user's organization.","operationId":"create_folder_api_v1_folder__post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateFolderRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/folder/{folder_id}":{"put":{"tags":["main"],"summary":"Rename Folder","description":"Rename a folder owned by the authenticated user's organization.","operationId":"rename_folder_api_v1_folder__folder_id__put","parameters":[{"name":"folder_id","in":"path","required":true,"schema":{"type":"integer","title":"Folder Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateFolderRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main"],"summary":"Delete Folder","description":"Delete a folder. Member agents are moved to \"Uncategorized\", not deleted.","operationId":"delete_folder_api_v1_folder__folder_id__delete","parameters":[{"name":"folder_id","in":"path","required":true,"schema":{"type":"integer","title":"Folder Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"boolean"},"title":"Response Delete Folder Api V1 Folder Folder Id Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/signup":{"post":{"tags":["main","auth"],"summary":"Signup","operationId":"signup_api_v1_auth_signup_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/login":{"post":{"tags":["main","auth"],"summary":"Login","operationId":"login_api_v1_auth_login_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/me":{"get":{"tags":["main","auth"],"summary":"Get Current User","operationId":"get_current_user_api_v1_auth_me_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/node-types":{"get":{"tags":["main"],"summary":"List Node Types","description":"List every registered NodeSpec.\n\nSDK clients should pin to `spec_version` and warn if the server reports\na higher version than what they were generated against.","operationId":"list_node_types_api_v1_node_types_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeTypesResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_node_types","x-sdk-description":"List every registered node type with its spec. Pinned to spec_version."}},"/api/v1/node-types/{name}":{"get":{"tags":["main"],"summary":"Get Node Type","operationId":"get_node_type_api_v1_node_types__name__get","parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeSpec"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"get_node_type","x-sdk-description":"Fetch a single node spec by name."}},"/api/v1/health":{"get":{"tags":["main"],"summary":"Health","operationId":"health_api_v1_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}},"404":{"description":"Not found"}}}}},"components":{"schemas":{"APIKeyResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"key_prefix":{"type":"string","title":"Key Prefix"},"is_active":{"type":"boolean","title":"Is Active"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"last_used_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Used At"},"archived_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Archived At"}},"type":"object","required":["id","name","key_prefix","is_active","created_at"],"title":"APIKeyResponse"},"APIKeyStatus":{"properties":{"model":{"type":"string","title":"Model"},"message":{"type":"string","title":"Message"}},"type":"object","required":["model","message"],"title":"APIKeyStatus"},"APIKeyStatusResponse":{"properties":{"status":{"items":{"$ref":"#/components/schemas/APIKeyStatus"},"type":"array","title":"Status"}},"type":"object","required":["status"],"title":"APIKeyStatusResponse"},"ARIConfigurationRequest":{"properties":{"provider":{"type":"string","const":"ari","title":"Provider","default":"ari"},"ari_endpoint":{"type":"string","title":"Ari Endpoint","description":"ARI base URL (e.g., http://asterisk.example.com:8088)"},"app_name":{"type":"string","title":"App Name","description":"Stasis application name registered in Asterisk"},"app_password":{"type":"string","title":"App Password","description":"ARI user password"},"ws_client_name":{"type":"string","title":"Ws Client Name","description":"websocket_client.conf connection name for externalMedia (e.g., dograh_staging)","default":""},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of SIP extensions/numbers for outbound calls (optional)"}},"type":"object","required":["ari_endpoint","app_name","app_password"],"title":"ARIConfigurationRequest","description":"Request schema for Asterisk ARI configuration."},"ARIConfigurationResponse":{"properties":{"provider":{"type":"string","const":"ari","title":"Provider","default":"ari"},"ari_endpoint":{"type":"string","title":"Ari Endpoint"},"app_name":{"type":"string","title":"App Name"},"app_password":{"type":"string","title":"App Password"},"ws_client_name":{"type":"string","title":"Ws Client Name","default":""},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["ari_endpoint","app_name","app_password","from_numbers"],"title":"ARIConfigurationResponse","description":"Response schema for ARI configuration with masked sensitive fields."},"AmbientNoiseUploadRequest":{"properties":{"workflow_id":{"type":"integer","title":"Workflow Id"},"filename":{"type":"string","title":"Filename"},"mime_type":{"type":"string","title":"Mime Type","default":"audio/wav"},"file_size":{"type":"integer","maximum":10485760.0,"exclusiveMinimum":0.0,"title":"File Size","description":"Max 10MB"}},"type":"object","required":["workflow_id","filename","file_size"],"title":"AmbientNoiseUploadRequest"},"AmbientNoiseUploadResponse":{"properties":{"upload_url":{"type":"string","title":"Upload Url"},"storage_key":{"type":"string","title":"Storage Key"},"storage_backend":{"type":"string","title":"Storage Backend"}},"type":"object","required":["upload_url","storage_key","storage_backend"],"title":"AmbientNoiseUploadResponse"},"AppendTextChatMessageRequest":{"properties":{"text":{"type":"string","minLength":1,"title":"Text"},"expected_revision":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Expected Revision"}},"type":"object","required":["text"],"title":"AppendTextChatMessageRequest"},"AuthResponse":{"properties":{"token":{"type":"string","title":"Token"},"user":{"$ref":"#/components/schemas/UserResponse"}},"type":"object","required":["token","user"],"title":"AuthResponse"},"AuthUserResponse":{"properties":{"id":{"type":"integer","title":"Id"},"is_superuser":{"type":"boolean","title":"Is Superuser"}},"type":"object","required":["id","is_superuser"],"title":"AuthUserResponse"},"BatchRecordingCreateRequestSchema":{"properties":{"recordings":{"items":{"$ref":"#/components/schemas/RecordingCreateRequestSchema"},"type":"array","maxItems":20,"minItems":1,"title":"Recordings","description":"List of recordings to create"}},"type":"object","required":["recordings"],"title":"BatchRecordingCreateRequestSchema","description":"Request schema for creating one or more recording records after upload."},"BatchRecordingCreateResponseSchema":{"properties":{"recordings":{"items":{"$ref":"#/components/schemas/RecordingResponseSchema"},"type":"array","title":"Recordings","description":"Created recording records"}},"type":"object","required":["recordings"],"title":"BatchRecordingCreateResponseSchema","description":"Response schema for recording creation."},"BatchRecordingUploadRequestSchema":{"properties":{"files":{"items":{"$ref":"#/components/schemas/FileDescriptor"},"type":"array","maxItems":20,"minItems":1,"title":"Files","description":"List of files to upload"}},"type":"object","required":["files"],"title":"BatchRecordingUploadRequestSchema","description":"Request schema for getting presigned upload URLs for one or more files."},"BatchRecordingUploadResponseSchema":{"properties":{"items":{"items":{"$ref":"#/components/schemas/RecordingUploadResponseSchema"},"type":"array","title":"Items","description":"Upload URLs for each file"}},"type":"object","required":["items"],"title":"BatchRecordingUploadResponseSchema","description":"Response schema with presigned upload URLs."},"Body_transcribe_audio_api_v1_workflow_recordings_transcribe_post":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File"},"language":{"type":"string","title":"Language","default":"en"}},"type":"object","required":["file"],"title":"Body_transcribe_audio_api_v1_workflow_recordings_transcribe_post"},"CalculatorToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version","default":1},"type":{"type":"string","const":"calculator","title":"Type","description":"Tool type"}},"type":"object","required":["type"],"title":"CalculatorToolDefinition","description":"Tool definition for Calculator tools (no configuration needed)."},"CallDispositionCodes":{"properties":{"disposition_codes":{"items":{"type":"string"},"type":"array","title":"Disposition Codes","default":[]}},"type":"object","title":"CallDispositionCodes"},"CallType":{"type":"string","enum":["inbound","outbound"],"title":"CallType"},"CampaignDefaultsResponse":{"properties":{"concurrent_call_limit":{"type":"integer","title":"Concurrent Call Limit"},"from_numbers_count":{"type":"integer","title":"From Numbers Count"},"default_retry_config":{"$ref":"#/components/schemas/RetryConfigResponse"},"last_campaign_settings":{"anyOf":[{"$ref":"#/components/schemas/LastCampaignSettingsResponse"},{"type":"null"}]}},"type":"object","required":["concurrent_call_limit","from_numbers_count","default_retry_config"],"title":"CampaignDefaultsResponse"},"CampaignLogEntryResponse":{"properties":{"ts":{"type":"string","title":"Ts"},"level":{"type":"string","title":"Level"},"event":{"type":"string","title":"Event"},"message":{"type":"string","title":"Message"},"details":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Details"}},"type":"object","required":["ts","level","event","message"],"title":"CampaignLogEntryResponse","description":"A single timestamped entry from the campaign's append-only log.\n\nSurfaced in the UI so operators can see why a campaign moved to\npaused / failed without digging through server logs."},"CampaignProgressResponse":{"properties":{"campaign_id":{"type":"integer","title":"Campaign Id"},"state":{"type":"string","title":"State"},"total_rows":{"type":"integer","title":"Total Rows"},"processed_rows":{"type":"integer","title":"Processed Rows"},"failed_calls":{"type":"integer","title":"Failed Calls"},"progress_percentage":{"type":"number","title":"Progress Percentage"},"source_sync":{"additionalProperties":true,"type":"object","title":"Source Sync"},"rate_limit":{"type":"integer","title":"Rate Limit"},"started_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Started At"},"completed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Completed At"}},"type":"object","required":["campaign_id","state","total_rows","processed_rows","failed_calls","progress_percentage","source_sync","rate_limit","started_at","completed_at"],"title":"CampaignProgressResponse"},"CampaignResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"workflow_id":{"type":"integer","title":"Workflow Id"},"workflow_name":{"type":"string","title":"Workflow Name"},"state":{"type":"string","title":"State"},"source_type":{"type":"string","title":"Source Type"},"source_id":{"type":"string","title":"Source Id"},"total_rows":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Total Rows"},"processed_rows":{"type":"integer","title":"Processed Rows"},"failed_rows":{"type":"integer","title":"Failed Rows"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"started_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Started At"},"completed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Completed At"},"retry_config":{"$ref":"#/components/schemas/RetryConfigResponse"},"max_concurrency":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Concurrency"},"schedule_config":{"anyOf":[{"$ref":"#/components/schemas/ScheduleConfigResponse"},{"type":"null"}]},"circuit_breaker":{"anyOf":[{"$ref":"#/components/schemas/CircuitBreakerConfigResponse"},{"type":"null"}]},"executed_count":{"type":"integer","title":"Executed Count","default":0},"total_queued_count":{"type":"integer","title":"Total Queued Count","default":0},"parent_campaign_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Parent Campaign Id"},"redialed_campaign_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Redialed Campaign Id"},"telephony_configuration_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Telephony Configuration Id"},"telephony_configuration_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Telephony Configuration Name"},"logs":{"items":{"$ref":"#/components/schemas/CampaignLogEntryResponse"},"type":"array","title":"Logs"}},"type":"object","required":["id","name","workflow_id","workflow_name","state","source_type","source_id","total_rows","processed_rows","failed_rows","created_at","started_at","completed_at","retry_config"],"title":"CampaignResponse"},"CampaignRunsResponse":{"properties":{"runs":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Runs"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"limit":{"type":"integer","title":"Limit"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["runs","total_count","page","limit","total_pages"],"title":"CampaignRunsResponse","description":"Paginated response for campaign workflow runs"},"CampaignSourceDownloadResponse":{"properties":{"download_url":{"type":"string","title":"Download Url"},"expires_in":{"type":"integer","title":"Expires In"}},"type":"object","required":["download_url","expires_in"],"title":"CampaignSourceDownloadResponse"},"CampaignsResponse":{"properties":{"campaigns":{"items":{"$ref":"#/components/schemas/CampaignResponse"},"type":"array","title":"Campaigns"}},"type":"object","required":["campaigns"],"title":"CampaignsResponse"},"ChunkResponseSchema":{"properties":{"id":{"type":"integer","title":"Id"},"document_id":{"type":"integer","title":"Document Id"},"chunk_text":{"type":"string","title":"Chunk Text"},"contextualized_text":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Contextualized Text"},"chunk_index":{"type":"integer","title":"Chunk Index"},"chunk_metadata":{"additionalProperties":true,"type":"object","title":"Chunk Metadata"},"filename":{"type":"string","title":"Filename"},"document_uuid":{"type":"string","title":"Document Uuid"},"similarity":{"type":"number","title":"Similarity"}},"type":"object","required":["id","document_id","chunk_text","contextualized_text","chunk_index","chunk_metadata","filename","document_uuid","similarity"],"title":"ChunkResponseSchema","description":"Response schema for a document chunk."},"ChunkSearchRequestSchema":{"properties":{"query":{"type":"string","title":"Query","description":"Search query text"},"limit":{"type":"integer","maximum":50.0,"minimum":1.0,"title":"Limit","description":"Maximum number of results","default":5},"document_uuids":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Document Uuids","description":"Filter by specific document UUIDs"},"min_similarity":{"anyOf":[{"type":"number","maximum":1.0,"minimum":0.0},{"type":"null"}],"title":"Min Similarity","description":"Minimum similarity threshold"}},"type":"object","required":["query"],"title":"ChunkSearchRequestSchema","description":"Request schema for searching similar chunks."},"ChunkSearchResponseSchema":{"properties":{"chunks":{"items":{"$ref":"#/components/schemas/ChunkResponseSchema"},"type":"array","title":"Chunks"},"query":{"type":"string","title":"Query"},"total_results":{"type":"integer","title":"Total Results"}},"type":"object","required":["chunks","query","total_results"],"title":"ChunkSearchResponseSchema","description":"Response schema for chunk search results."},"CircuitBreakerConfigRequest":{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":true},"failure_threshold":{"type":"number","maximum":1.0,"minimum":0.0,"title":"Failure Threshold","default":0.5},"window_seconds":{"type":"integer","maximum":600.0,"minimum":30.0,"title":"Window Seconds","default":120},"min_calls_in_window":{"type":"integer","maximum":100.0,"minimum":1.0,"title":"Min Calls In Window","default":5}},"type":"object","title":"CircuitBreakerConfigRequest"},"CircuitBreakerConfigResponse":{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":false},"failure_threshold":{"type":"number","title":"Failure Threshold","default":0.5},"window_seconds":{"type":"integer","title":"Window Seconds","default":120},"min_calls_in_window":{"type":"integer","title":"Min Calls In Window","default":5}},"type":"object","title":"CircuitBreakerConfigResponse"},"CloudonixConfigurationRequest":{"properties":{"provider":{"type":"string","const":"cloudonix","title":"Provider","default":"cloudonix"},"bearer_token":{"type":"string","title":"Bearer Token","description":"Cloudonix API Bearer Token"},"domain_id":{"type":"string","title":"Domain Id","description":"Cloudonix Domain ID"},"application_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Name","description":"Cloudonix Voice Application name. The application's url is updated when inbound workflows are attached to numbers on this domain. If omitted, an application is auto-created on save and its name is stored on the configuration."},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Cloudonix phone numbers (optional)"}},"type":"object","required":["bearer_token","domain_id"],"title":"CloudonixConfigurationRequest","description":"Request schema for Cloudonix configuration."},"CloudonixConfigurationResponse":{"properties":{"provider":{"type":"string","const":"cloudonix","title":"Provider","default":"cloudonix"},"bearer_token":{"type":"string","title":"Bearer Token"},"domain_id":{"type":"string","title":"Domain Id"},"application_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Name"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["bearer_token","domain_id","from_numbers"],"title":"CloudonixConfigurationResponse","description":"Response schema for Cloudonix configuration with masked sensitive fields."},"CreateAPIKeyRequest":{"properties":{"name":{"type":"string","title":"Name"}},"type":"object","required":["name"],"title":"CreateAPIKeyRequest"},"CreateAPIKeyResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"key_prefix":{"type":"string","title":"Key Prefix"},"api_key":{"type":"string","title":"Api Key"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","name","key_prefix","api_key","created_at"],"title":"CreateAPIKeyResponse"},"CreateCampaignRequest":{"properties":{"name":{"type":"string","maxLength":255,"minLength":1,"title":"Name"},"workflow_id":{"type":"integer","title":"Workflow Id"},"source_type":{"type":"string","pattern":"^csv$","title":"Source Type"},"source_id":{"type":"string","title":"Source Id"},"telephony_configuration_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Telephony Configuration Id"},"retry_config":{"anyOf":[{"$ref":"#/components/schemas/RetryConfigRequest"},{"type":"null"}]},"max_concurrency":{"anyOf":[{"type":"integer","maximum":100.0,"minimum":1.0},{"type":"null"}],"title":"Max Concurrency"},"schedule_config":{"anyOf":[{"$ref":"#/components/schemas/ScheduleConfigRequest"},{"type":"null"}]},"circuit_breaker":{"anyOf":[{"$ref":"#/components/schemas/CircuitBreakerConfigRequest"},{"type":"null"}]}},"type":"object","required":["name","workflow_id","source_type","source_id"],"title":"CreateCampaignRequest"},"CreateCredentialRequest":{"properties":{"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"credential_type":{"$ref":"#/components/schemas/WebhookCredentialType"},"credential_data":{"additionalProperties":true,"type":"object","title":"Credential Data"}},"type":"object","required":["name","credential_type","credential_data"],"title":"CreateCredentialRequest","description":"Request schema for creating a webhook credential."},"CreateFolderRequest":{"properties":{"name":{"type":"string","maxLength":100,"minLength":1,"title":"Name"}},"type":"object","required":["name"],"title":"CreateFolderRequest"},"CreateServiceKeyRequest":{"properties":{"name":{"type":"string","title":"Name"},"expires_in_days":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Expires In Days","default":90}},"type":"object","required":["name"],"title":"CreateServiceKeyRequest"},"CreateServiceKeyResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"service_key":{"type":"string","title":"Service Key"},"key_prefix":{"type":"string","title":"Key Prefix"},"expires_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Expires At"}},"type":"object","required":["id","name","service_key","key_prefix"],"title":"CreateServiceKeyResponse"},"CreateTextChatSessionRequest":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"annotations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Annotations"}},"type":"object","title":"CreateTextChatSessionRequest"},"CreateToolRequest":{"properties":{"name":{"type":"string","maxLength":255,"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"category":{"type":"string","title":"Category","default":"http_api"},"icon":{"anyOf":[{"type":"string","maxLength":50},{"type":"null"}],"title":"Icon","default":"globe"},"icon_color":{"anyOf":[{"type":"string","maxLength":7},{"type":"null"}],"title":"Icon Color","default":"#3B82F6"},"definition":{"oneOf":[{"$ref":"#/components/schemas/HttpApiToolDefinition"},{"$ref":"#/components/schemas/EndCallToolDefinition"},{"$ref":"#/components/schemas/TransferCallToolDefinition"},{"$ref":"#/components/schemas/CalculatorToolDefinition"},{"$ref":"#/components/schemas/McpToolDefinition"}],"title":"Definition","discriminator":{"propertyName":"type","mapping":{"calculator":"#/components/schemas/CalculatorToolDefinition","end_call":"#/components/schemas/EndCallToolDefinition","http_api":"#/components/schemas/HttpApiToolDefinition","mcp":"#/components/schemas/McpToolDefinition","transfer_call":"#/components/schemas/TransferCallToolDefinition"}}}},"type":"object","required":["name","definition"],"title":"CreateToolRequest","description":"Request schema for creating a tool."},"CreateWorkflowRequest":{"properties":{"name":{"type":"string","title":"Name"},"workflow_definition":{"additionalProperties":true,"type":"object","title":"Workflow Definition"}},"type":"object","required":["name","workflow_definition"],"title":"CreateWorkflowRequest"},"CreateWorkflowRunRequest":{"properties":{"mode":{"type":"string","title":"Mode"},"name":{"type":"string","title":"Name"}},"type":"object","required":["mode","name"],"title":"CreateWorkflowRunRequest"},"CreateWorkflowRunResponse":{"properties":{"id":{"type":"integer","title":"Id"},"workflow_id":{"type":"integer","title":"Workflow Id"},"name":{"type":"string","title":"Name"},"mode":{"type":"string","title":"Mode"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"definition_id":{"type":"integer","title":"Definition Id"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"}},"type":"object","required":["id","workflow_id","name","mode","created_at","definition_id"],"title":"CreateWorkflowRunResponse"},"CreateWorkflowTemplateRequest":{"properties":{"call_type":{"type":"string","enum":["inbound","outbound"],"title":"Call Type"},"use_case":{"type":"string","title":"Use Case"},"activity_description":{"type":"string","title":"Activity Description"}},"type":"object","required":["call_type","use_case","activity_description"],"title":"CreateWorkflowTemplateRequest"},"CreatedByResponse":{"properties":{"id":{"type":"integer","title":"Id"},"provider_id":{"type":"string","title":"Provider Id"}},"type":"object","required":["id","provider_id"],"title":"CreatedByResponse","description":"Response schema for the user who created a tool."},"CredentialResponse":{"properties":{"uuid":{"type":"string","title":"Uuid"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"credential_type":{"type":"string","title":"Credential Type"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["uuid","name","description","credential_type","created_at","updated_at"],"title":"CredentialResponse","description":"Response schema for a webhook credential (never includes sensitive data)."},"CurrentUsageResponse":{"properties":{"period_start":{"type":"string","title":"Period Start"},"period_end":{"type":"string","title":"Period End"},"used_dograh_tokens":{"type":"number","title":"Used Dograh Tokens"},"quota_dograh_tokens":{"type":"integer","title":"Quota Dograh Tokens"},"percentage_used":{"type":"number","title":"Percentage Used"},"next_refresh_date":{"type":"string","title":"Next Refresh Date"},"quota_enabled":{"type":"boolean","title":"Quota Enabled"},"total_duration_seconds":{"type":"integer","title":"Total Duration Seconds"},"used_amount_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Used Amount Usd"},"quota_amount_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Quota Amount Usd"},"currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Currency"},"price_per_second_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Price Per Second Usd"}},"type":"object","required":["period_start","period_end","used_dograh_tokens","quota_dograh_tokens","percentage_used","next_refresh_date","quota_enabled","total_duration_seconds"],"title":"CurrentUsageResponse"},"DailyReportResponse":{"properties":{"date":{"type":"string","title":"Date"},"timezone":{"type":"string","title":"Timezone"},"workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Workflow Id"},"metrics":{"additionalProperties":{"type":"integer"},"type":"object","title":"Metrics"},"disposition_distribution":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Disposition Distribution"},"call_duration_distribution":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Call Duration Distribution"}},"type":"object","required":["date","timezone","workflow_id","metrics","disposition_distribution","call_duration_distribution"],"title":"DailyReportResponse"},"DailyUsageBreakdownResponse":{"properties":{"breakdown":{"items":{"$ref":"#/components/schemas/DailyUsageItem"},"type":"array","title":"Breakdown"},"total_minutes":{"type":"number","title":"Total Minutes"},"total_cost_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Total Cost Usd"},"total_dograh_tokens":{"type":"number","title":"Total Dograh Tokens"},"currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Currency"}},"type":"object","required":["breakdown","total_minutes","total_dograh_tokens"],"title":"DailyUsageBreakdownResponse"},"DailyUsageItem":{"properties":{"date":{"type":"string","title":"Date"},"minutes":{"type":"number","title":"Minutes"},"cost_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cost Usd"},"dograh_tokens":{"type":"number","title":"Dograh Tokens"},"call_count":{"type":"integer","title":"Call Count"}},"type":"object","required":["date","minutes","dograh_tokens","call_count"],"title":"DailyUsageItem"},"DefaultConfigurationsResponse":{"properties":{"llm":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Llm"},"tts":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Tts"},"stt":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Stt"},"embeddings":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Embeddings"},"realtime":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Realtime"},"default_providers":{"additionalProperties":{"type":"string"},"type":"object","title":"Default Providers"}},"type":"object","required":["llm","tts","stt","embeddings","realtime","default_providers"],"title":"DefaultConfigurationsResponse"},"DisplayOptions":{"properties":{"show":{"anyOf":[{"additionalProperties":{"items":{},"type":"array"},"type":"object"},{"type":"null"}],"title":"Show"},"hide":{"anyOf":[{"additionalProperties":{"items":{},"type":"array"},"type":"object"},{"type":"null"}],"title":"Hide"}},"additionalProperties":false,"type":"object","title":"DisplayOptions","description":"Conditional visibility rules.\n\n`show` keys are AND-combined: this property is visible only when EVERY\nreferenced field's value matches one of the listed values.\n\n`hide` keys are OR-combined: this property is hidden when ANY referenced\nfield's value matches one of the listed values.\n\nExample:\n DisplayOptions(show={\"extraction_enabled\": [True]})\n DisplayOptions(show={\"greeting_type\": [\"audio\"]})"},"DocumentListResponseSchema":{"properties":{"documents":{"items":{"$ref":"#/components/schemas/DocumentResponseSchema"},"type":"array","title":"Documents"},"total":{"type":"integer","title":"Total"},"limit":{"type":"integer","title":"Limit"},"offset":{"type":"integer","title":"Offset"}},"type":"object","required":["documents","total","limit","offset"],"title":"DocumentListResponseSchema","description":"Response schema for list of documents."},"DocumentResponseSchema":{"properties":{"id":{"type":"integer","title":"Id"},"document_uuid":{"type":"string","title":"Document Uuid"},"filename":{"type":"string","title":"Filename"},"file_size_bytes":{"type":"integer","title":"File Size Bytes"},"file_hash":{"type":"string","title":"File Hash"},"mime_type":{"type":"string","title":"Mime Type"},"processing_status":{"type":"string","title":"Processing Status"},"processing_error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Processing Error"},"total_chunks":{"type":"integer","title":"Total Chunks"},"retrieval_mode":{"type":"string","title":"Retrieval Mode","default":"chunked"},"custom_metadata":{"additionalProperties":true,"type":"object","title":"Custom Metadata"},"docling_metadata":{"additionalProperties":true,"type":"object","title":"Docling Metadata"},"source_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source Url"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"organization_id":{"type":"integer","title":"Organization Id"},"created_by":{"type":"integer","title":"Created By"},"is_active":{"type":"boolean","title":"Is Active"}},"type":"object","required":["id","document_uuid","filename","file_size_bytes","file_hash","mime_type","processing_status","total_chunks","custom_metadata","docling_metadata","created_at","updated_at","organization_id","created_by","is_active"],"title":"DocumentResponseSchema","description":"Response schema for document metadata."},"DocumentUploadRequestSchema":{"properties":{"filename":{"type":"string","title":"Filename","description":"Name of the file to upload"},"mime_type":{"type":"string","title":"Mime Type","description":"MIME type of the file"},"custom_metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Custom Metadata","description":"Optional custom metadata"}},"type":"object","required":["filename","mime_type"],"title":"DocumentUploadRequestSchema","description":"Request schema for initiating document upload."},"DocumentUploadResponseSchema":{"properties":{"upload_url":{"type":"string","title":"Upload Url","description":"Signed URL for uploading the file"},"document_uuid":{"type":"string","title":"Document Uuid","description":"Unique identifier for the document"},"s3_key":{"type":"string","title":"S3 Key","description":"S3 key where file should be uploaded"}},"type":"object","required":["upload_url","document_uuid","s3_key"],"title":"DocumentUploadResponseSchema","description":"Response schema containing upload URL and document metadata."},"DuplicateTemplateRequest":{"properties":{"template_id":{"type":"integer","title":"Template Id"},"workflow_name":{"type":"string","title":"Workflow Name"}},"type":"object","required":["template_id","workflow_name"],"title":"DuplicateTemplateRequest"},"EmbedConfigResponse":{"properties":{"workflow_id":{"type":"integer","title":"Workflow Id"},"settings":{"additionalProperties":true,"type":"object","title":"Settings"},"theme":{"type":"string","title":"Theme"},"position":{"type":"string","title":"Position"},"button_text":{"type":"string","title":"Button Text"},"button_color":{"type":"string","title":"Button Color"},"size":{"type":"string","title":"Size"},"auto_start":{"type":"boolean","title":"Auto Start"}},"type":"object","required":["workflow_id","settings","theme","position","button_text","button_color","size","auto_start"],"title":"EmbedConfigResponse","description":"Response model for embed configuration"},"EmbedTokenRequest":{"properties":{"allowed_domains":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Allowed Domains"},"settings":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Settings"},"usage_limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Usage Limit"},"expires_in_days":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Expires In Days","default":30}},"type":"object","title":"EmbedTokenRequest"},"EmbedTokenResponse":{"properties":{"id":{"type":"integer","title":"Id"},"token":{"type":"string","title":"Token"},"allowed_domains":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Allowed Domains"},"settings":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Settings"},"is_active":{"type":"boolean","title":"Is Active"},"usage_count":{"type":"integer","title":"Usage Count"},"usage_limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Usage Limit"},"expires_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Expires At"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"embed_script":{"type":"string","title":"Embed Script"}},"type":"object","required":["id","token","allowed_domains","settings","is_active","usage_count","usage_limit","expires_at","created_at","embed_script"],"title":"EmbedTokenResponse"},"EndCallConfig":{"properties":{"messageType":{"type":"string","enum":["none","custom","audio"],"title":"Messagetype","description":"Type of goodbye message","default":"none"},"customMessage":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessage","description":"Custom message to play before ending the call"},"audioRecordingId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Audiorecordingid","description":"Recording ID for audio goodbye message"},"endCallReason":{"type":"boolean","title":"Endcallreason","description":"When enabled, LLM must provide a reason for ending the call. The reason is set as call disposition and added to call tags.","default":false},"endCallReasonDescription":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Endcallreasondescription","description":"Description shown to the LLM for the reason parameter. Used only when endCallReason is enabled."}},"type":"object","title":"EndCallConfig","description":"Configuration for End Call tools."},"EndCallToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version","default":1},"type":{"type":"string","const":"end_call","title":"Type","description":"Tool type"},"config":{"$ref":"#/components/schemas/EndCallConfig","description":"End Call configuration"}},"type":"object","required":["type","config"],"title":"EndCallToolDefinition","description":"Tool definition for End Call tools."},"FileDescriptor":{"properties":{"filename":{"type":"string","title":"Filename","description":"Original filename of the audio file"},"mime_type":{"type":"string","title":"Mime Type","description":"MIME type of the audio file","default":"audio/wav"},"file_size":{"type":"integer","maximum":5242880.0,"exclusiveMinimum":0.0,"title":"File Size","description":"File size in bytes (max 5MB)"}},"type":"object","required":["filename","file_size"],"title":"FileDescriptor","description":"Descriptor for a single file in a batch upload request."},"FileMetadataResponse":{"properties":{"key":{"type":"string","title":"Key"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["key","metadata"],"title":"FileMetadataResponse"},"FolderResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","name","created_at"],"title":"FolderResponse"},"GraphConstraints":{"properties":{"min_incoming":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Min Incoming"},"max_incoming":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Incoming"},"min_outgoing":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Min Outgoing"},"max_outgoing":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Outgoing"}},"additionalProperties":false,"type":"object","title":"GraphConstraints","description":"Per-node-type graph rules. WorkflowGraph enforces these at validation."},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HealthResponse":{"properties":{"status":{"type":"string","title":"Status"},"version":{"type":"string","title":"Version"},"backend_api_endpoint":{"type":"string","title":"Backend Api Endpoint"},"deployment_mode":{"type":"string","title":"Deployment Mode"},"auth_provider":{"type":"string","title":"Auth Provider"},"turn_enabled":{"type":"boolean","title":"Turn Enabled"},"force_turn_relay":{"type":"boolean","title":"Force Turn Relay"}},"type":"object","required":["status","version","backend_api_endpoint","deployment_mode","auth_provider","turn_enabled","force_turn_relay"],"title":"HealthResponse"},"HttpApiConfig":{"properties":{"method":{"type":"string","title":"Method","description":"HTTP method (GET, POST, PUT, PATCH, DELETE)"},"url":{"type":"string","title":"Url","description":"Target URL"},"headers":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Headers","description":"Static headers to include"},"credential_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credential Uuid","description":"Reference to ExternalCredentialModel for auth"},"parameters":{"anyOf":[{"items":{"$ref":"#/components/schemas/ToolParameter"},"type":"array"},{"type":"null"}],"title":"Parameters","description":"Parameters that the tool accepts from LLM"},"preset_parameters":{"anyOf":[{"items":{"$ref":"#/components/schemas/PresetToolParameter"},"type":"array"},{"type":"null"}],"title":"Preset Parameters","description":"Parameters injected by Dograh from fixed values or workflow context templates"},"timeout_ms":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Timeout Ms","description":"Request timeout in milliseconds","default":5000},"customMessage":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessage","description":"Custom message to play after tool execution"},"customMessageType":{"anyOf":[{"type":"string","enum":["text","audio"]},{"type":"null"}],"title":"Custommessagetype","description":"Type of custom message: text or audio"},"customMessageRecordingId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessagerecordingid","description":"Recording ID for audio custom message"}},"type":"object","required":["method","url"],"title":"HttpApiConfig","description":"Configuration for HTTP API tools."},"HttpApiToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version","default":1},"type":{"type":"string","const":"http_api","title":"Type","description":"Tool type"},"config":{"$ref":"#/components/schemas/HttpApiConfig","description":"HTTP API configuration"}},"type":"object","required":["type","config"],"title":"HttpApiToolDefinition","description":"Tool definition for HTTP API tools."},"ImpersonateRequest":{"properties":{"provider_user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider User Id"},"user_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"User Id"}},"type":"object","title":"ImpersonateRequest","description":"Request payload for superadmin impersonation.\n\nEither ``provider_user_id`` **or** ``user_id`` must be supplied. If both are\nprovided, ``provider_user_id`` takes precedence."},"ImpersonateResponse":{"properties":{"refresh_token":{"type":"string","title":"Refresh Token"},"access_token":{"type":"string","title":"Access Token"}},"type":"object","required":["refresh_token","access_token"],"title":"ImpersonateResponse"},"InitEmbedRequest":{"properties":{"token":{"type":"string","title":"Token"},"context_variables":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Context Variables"}},"type":"object","required":["token"],"title":"InitEmbedRequest","description":"Request model for initializing an embed session"},"InitEmbedResponse":{"properties":{"session_token":{"type":"string","title":"Session Token"},"workflow_run_id":{"type":"integer","title":"Workflow Run Id"},"config":{"additionalProperties":true,"type":"object","title":"Config"}},"type":"object","required":["session_token","workflow_run_id","config"],"title":"InitEmbedResponse","description":"Response model for embed initialization"},"InitiateCallRequest":{"properties":{"workflow_id":{"type":"integer","title":"Workflow Id"},"workflow_run_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Workflow Run Id"},"phone_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone Number"},"telephony_configuration_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Telephony Configuration Id"},"from_phone_number_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"From Phone Number Id"}},"type":"object","required":["workflow_id"],"title":"InitiateCallRequest"},"ItemKind":{"type":"string","enum":["node","edge","workflow"],"title":"ItemKind"},"LangfuseCredentialsRequest":{"properties":{"host":{"type":"string","title":"Host"},"public_key":{"type":"string","title":"Public Key"},"secret_key":{"type":"string","title":"Secret Key"}},"type":"object","required":["host","public_key","secret_key"],"title":"LangfuseCredentialsRequest"},"LangfuseCredentialsResponse":{"properties":{"host":{"type":"string","title":"Host","default":""},"public_key":{"type":"string","title":"Public Key","default":""},"secret_key":{"type":"string","title":"Secret Key","default":""},"configured":{"type":"boolean","title":"Configured","default":false}},"type":"object","title":"LangfuseCredentialsResponse"},"LastCampaignSettingsResponse":{"properties":{"retry_config":{"anyOf":[{"$ref":"#/components/schemas/RetryConfigResponse"},{"type":"null"}]},"max_concurrency":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Concurrency"},"schedule_config":{"anyOf":[{"$ref":"#/components/schemas/ScheduleConfigResponse"},{"type":"null"}]},"circuit_breaker":{"anyOf":[{"$ref":"#/components/schemas/CircuitBreakerConfigResponse"},{"type":"null"}]}},"type":"object","title":"LastCampaignSettingsResponse"},"LoginRequest":{"properties":{"email":{"type":"string","format":"email","title":"Email"},"password":{"type":"string","title":"Password"}},"type":"object","required":["email","password"],"title":"LoginRequest"},"MPSCreditsResponse":{"properties":{"total_credits_used":{"type":"number","title":"Total Credits Used"},"remaining_credits":{"type":"number","title":"Remaining Credits"},"total_quota":{"type":"number","title":"Total Quota"}},"type":"object","required":["total_credits_used","remaining_credits","total_quota"],"title":"MPSCreditsResponse"},"McpRefreshResponse":{"properties":{"tool_uuid":{"type":"string","title":"Tool Uuid"},"discovered_tools":{"items":{},"type":"array","title":"Discovered Tools"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["tool_uuid"],"title":"McpRefreshResponse","description":"Result of re-discovering an MCP server's tool catalog."},"McpToolConfig":{"properties":{"transport":{"type":"string","const":"streamable_http","title":"Transport","description":"MCP transport protocol","default":"streamable_http"},"url":{"type":"string","title":"Url","description":"MCP server URL (must be http:// or https://)"},"credential_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credential Uuid","description":"Reference to ExternalCredentialModel for auth"},"tools_filter":{"items":{"type":"string"},"type":"array","title":"Tools Filter","description":"Allowlist of MCP tool names to expose (empty = all tools)"},"timeout_secs":{"type":"integer","title":"Timeout Secs","description":"Connection timeout in seconds","default":30},"sse_read_timeout_secs":{"type":"integer","title":"Sse Read Timeout Secs","description":"SSE read timeout in seconds","default":300},"discovered_tools":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Discovered Tools","description":"Server-managed cache of the MCP server's tool catalog [{name, description}]. Populated best-effort by the backend."}},"type":"object","required":["url"],"title":"McpToolConfig","description":"Configuration for an MCP tool definition."},"McpToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version","default":1},"type":{"type":"string","const":"mcp","title":"Type","description":"Tool type"},"config":{"$ref":"#/components/schemas/McpToolConfig","description":"MCP server configuration"}},"type":"object","required":["type","config"],"title":"McpToolDefinition","description":"Persisted MCP tool definition."},"MoveWorkflowToFolderRequest":{"properties":{"folder_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Folder Id"}},"type":"object","title":"MoveWorkflowToFolderRequest","description":"Move a workflow into a folder, or to \"Uncategorized\" when null."},"NodeCategory":{"type":"string","enum":["call_node","global_node","trigger","integration"],"title":"NodeCategory","description":"Drives grouping in the AddNodePanel UI."},"NodeExample":{"properties":{"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"data":{"additionalProperties":true,"type":"object","title":"Data"}},"additionalProperties":false,"type":"object","required":["name","data"],"title":"NodeExample","description":"A worked example LLMs can pattern-match. Keep small and realistic."},"NodeSpec":{"properties":{"name":{"type":"string","title":"Name"},"display_name":{"type":"string","title":"Display Name"},"description":{"type":"string","minLength":1,"title":"Description","description":"Human-facing explanation shown in AddNodePanel."},"llm_hint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Llm Hint","description":"LLM-only guidance; omitted from the UI."},"category":{"$ref":"#/components/schemas/NodeCategory"},"icon":{"type":"string","title":"Icon"},"version":{"type":"string","title":"Version","default":"1.0.0"},"properties":{"items":{"$ref":"#/components/schemas/PropertySpec"},"type":"array","title":"Properties"},"examples":{"items":{"$ref":"#/components/schemas/NodeExample"},"type":"array","title":"Examples"},"graph_constraints":{"anyOf":[{"$ref":"#/components/schemas/GraphConstraints"},{"type":"null"}]}},"additionalProperties":false,"type":"object","required":["name","display_name","description","category","icon","properties"],"title":"NodeSpec","description":"Single source of truth for a node type."},"NodeTypesResponse":{"properties":{"spec_version":{"type":"string","title":"Spec Version"},"node_types":{"items":{"$ref":"#/components/schemas/NodeSpec"},"type":"array","title":"Node Types"}},"type":"object","required":["spec_version","node_types"],"title":"NodeTypesResponse"},"PhoneNumberCreateRequest":{"properties":{"address":{"type":"string","maxLength":255,"minLength":1,"title":"Address"},"country_code":{"anyOf":[{"type":"string","maxLength":2,"minLength":2},{"type":"null"}],"title":"Country Code"},"label":{"anyOf":[{"type":"string","maxLength":64},{"type":"null"}],"title":"Label"},"inbound_workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Inbound Workflow Id"},"is_active":{"type":"boolean","title":"Is Active","default":true},"is_default_caller_id":{"type":"boolean","title":"Is Default Caller Id","default":false},"extra_metadata":{"additionalProperties":true,"type":"object","title":"Extra Metadata"}},"type":"object","required":["address"],"title":"PhoneNumberCreateRequest","description":"Create a new phone number under a telephony configuration.\n\n``address_normalized`` and ``address_type`` are computed server-side from\n``address`` (and ``country_code`` if PSTN). ``address`` itself is stored\nverbatim for display."},"PhoneNumberListResponse":{"properties":{"phone_numbers":{"items":{"$ref":"#/components/schemas/PhoneNumberResponse"},"type":"array","title":"Phone Numbers"}},"type":"object","required":["phone_numbers"],"title":"PhoneNumberListResponse"},"PhoneNumberResponse":{"properties":{"id":{"type":"integer","title":"Id"},"telephony_configuration_id":{"type":"integer","title":"Telephony Configuration Id"},"address":{"type":"string","title":"Address"},"address_normalized":{"type":"string","title":"Address Normalized"},"address_type":{"type":"string","title":"Address Type"},"country_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Country Code"},"label":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Label"},"inbound_workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Inbound Workflow Id"},"inbound_workflow_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Inbound Workflow Name"},"is_active":{"type":"boolean","title":"Is Active"},"is_default_caller_id":{"type":"boolean","title":"Is Default Caller Id"},"extra_metadata":{"additionalProperties":true,"type":"object","title":"Extra Metadata"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"provider_sync":{"anyOf":[{"$ref":"#/components/schemas/ProviderSyncStatus"},{"type":"null"}]}},"type":"object","required":["id","telephony_configuration_id","address","address_normalized","address_type","is_active","is_default_caller_id","extra_metadata","created_at","updated_at"],"title":"PhoneNumberResponse"},"PhoneNumberUpdateRequest":{"properties":{"label":{"anyOf":[{"type":"string","maxLength":64},{"type":"null"}],"title":"Label"},"inbound_workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Inbound Workflow Id"},"clear_inbound_workflow":{"type":"boolean","title":"Clear Inbound Workflow","default":false},"is_active":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Active"},"country_code":{"anyOf":[{"type":"string","maxLength":2,"minLength":2},{"type":"null"}],"title":"Country Code"},"extra_metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Extra Metadata"}},"type":"object","title":"PhoneNumberUpdateRequest","description":"Partial update. ``address`` is intentionally immutable \u2014 to change a\nnumber, delete the row and create a new one."},"PlivoConfigurationRequest":{"properties":{"provider":{"type":"string","const":"plivo","title":"Provider","default":"plivo"},"auth_id":{"type":"string","title":"Auth Id","description":"Plivo Auth ID"},"auth_token":{"type":"string","title":"Auth Token","description":"Plivo Auth Token"},"application_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Id","description":"Plivo Application ID. The application's answer_url is updated when inbound workflows are attached to numbers on this account. If omitted, an application is auto-created on save and its id is stored on the configuration."},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Plivo phone numbers"}},"type":"object","required":["auth_id","auth_token"],"title":"PlivoConfigurationRequest","description":"Request schema for Plivo configuration."},"PlivoConfigurationResponse":{"properties":{"provider":{"type":"string","const":"plivo","title":"Provider","default":"plivo"},"auth_id":{"type":"string","title":"Auth Id"},"auth_token":{"type":"string","title":"Auth Token"},"application_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Id"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["auth_id","auth_token","from_numbers"],"title":"PlivoConfigurationResponse","description":"Response schema for Plivo configuration with masked sensitive fields."},"PresetToolParameter":{"properties":{"name":{"type":"string","title":"Name","description":"Parameter name (used as key in request body)"},"type":{"type":"string","title":"Type","description":"Parameter type: string, number, or boolean"},"value_template":{"type":"string","title":"Value Template","description":"Fixed value or template, e.g. {{initial_context.phone_number}}"},"required":{"type":"boolean","title":"Required","description":"Whether the parameter must resolve to a non-empty value","default":true}},"type":"object","required":["name","type","value_template"],"title":"PresetToolParameter","description":"A parameter injected by Dograh at runtime."},"PresignedUploadUrlRequest":{"properties":{"file_name":{"type":"string","pattern":".*\\.csv$","title":"File Name","description":"CSV filename"},"file_size":{"type":"integer","maximum":10485760.0,"exclusiveMinimum":0.0,"title":"File Size","description":"File size in bytes (max 10MB)"},"content_type":{"type":"string","title":"Content Type","description":"File content type","default":"text/csv"}},"type":"object","required":["file_name","file_size"],"title":"PresignedUploadUrlRequest"},"PresignedUploadUrlResponse":{"properties":{"upload_url":{"type":"string","title":"Upload Url"},"file_key":{"type":"string","title":"File Key"},"expires_in":{"type":"integer","title":"Expires In"}},"type":"object","required":["upload_url","file_key","expires_in"],"title":"PresignedUploadUrlResponse"},"ProcessDocumentRequestSchema":{"properties":{"document_uuid":{"type":"string","title":"Document Uuid","description":"Document UUID to process"},"s3_key":{"type":"string","title":"S3 Key","description":"S3 key of the uploaded file"},"retrieval_mode":{"type":"string","title":"Retrieval Mode","description":"Retrieval mode: 'chunked' for vector search or 'full_document' for full text retrieval","default":"chunked"}},"type":"object","required":["document_uuid","s3_key"],"title":"ProcessDocumentRequestSchema","description":"Request schema for triggering document processing."},"PropertyOption":{"properties":{"value":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"boolean"},{"type":"number"}],"title":"Value"},"label":{"type":"string","title":"Label"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},"additionalProperties":false,"type":"object","required":["value","label"],"title":"PropertyOption","description":"An option in an `options` or `multi_options` dropdown."},"PropertySpec":{"properties":{"name":{"type":"string","title":"Name"},"type":{"$ref":"#/components/schemas/PropertyType"},"display_name":{"type":"string","title":"Display Name"},"description":{"type":"string","minLength":1,"title":"Description","description":"Human-facing explanation shown in the UI."},"llm_hint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Llm Hint","description":"LLM-only guidance; omitted from the UI."},"default":{"title":"Default"},"required":{"type":"boolean","title":"Required","default":false},"placeholder":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Placeholder"},"display_options":{"anyOf":[{"$ref":"#/components/schemas/DisplayOptions"},{"type":"null"}]},"options":{"anyOf":[{"items":{"$ref":"#/components/schemas/PropertyOption"},"type":"array"},{"type":"null"}],"title":"Options"},"properties":{"anyOf":[{"items":{"$ref":"#/components/schemas/PropertySpec"},"type":"array"},{"type":"null"}],"title":"Properties"},"min_value":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Min Value"},"max_value":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Max Value"},"min_length":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Min Length"},"max_length":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Length"},"pattern":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Pattern"},"editor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Editor"},"extra":{"additionalProperties":true,"type":"object","title":"Extra"}},"additionalProperties":false,"type":"object","required":["name","type","display_name","description"],"title":"PropertySpec","description":"Single field on a node.\n\n`description` is HUMAN-FACING \u2014 shown under the field in the edit\ndialog. Keep it concise and explain what the field does.\n\n`llm_hint` is LLM-FACING \u2014 appears only in the `get_node_type` MCP\nresponse and in SDK schema output. Use it for catalog tool references\n(e.g., \"Use `list_recordings`\"), array shape, expected value idioms,\nor anything that would be noise in the UI. Optional; omit when the\n`description` already suffices for both audiences."},"PropertyType":{"type":"string","enum":["string","number","boolean","options","multi_options","fixed_collection","json","tool_refs","document_refs","recording_ref","credential_ref","mention_textarea","url"],"title":"PropertyType","description":"Bounded vocabulary of property types the renderer dispatches on.\n\nAdding a value here requires a matching arm in the frontend\n`` switch and (where relevant) the SDK codegen template."},"ProviderSyncStatus":{"properties":{"ok":{"type":"boolean","title":"Ok"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"}},"type":"object","required":["ok"],"title":"ProviderSyncStatus","description":"Result of pushing a phone-number change to the upstream provider.\n\nReturned alongside create/update responses when the route attempted to\nsync inbound webhook configuration. ``ok=False`` is a warning, not a\nfatal error \u2014 the DB write succeeded."},"RecordingCreateRequestSchema":{"properties":{"recording_id":{"type":"string","title":"Recording Id","description":"Short recording ID from upload step"},"tts_provider":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Provider","description":"TTS provider (e.g. elevenlabs)"},"tts_model":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Model","description":"TTS model name"},"tts_voice_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Voice Id","description":"TTS voice identifier"},"transcript":{"type":"string","title":"Transcript","description":"User-provided transcript of the recording"},"storage_key":{"type":"string","title":"Storage Key","description":"Storage key from upload step"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata","description":"Optional metadata (file_size, duration, etc.)"}},"type":"object","required":["recording_id","transcript","storage_key"],"title":"RecordingCreateRequestSchema","description":"Request schema for creating a recording record after upload."},"RecordingListResponseSchema":{"properties":{"recordings":{"items":{"$ref":"#/components/schemas/RecordingResponseSchema"},"type":"array","title":"Recordings"},"total":{"type":"integer","title":"Total"}},"type":"object","required":["recordings","total"],"title":"RecordingListResponseSchema","description":"Response schema for list of recordings."},"RecordingResponseSchema":{"properties":{"id":{"type":"integer","title":"Id"},"recording_id":{"type":"string","title":"Recording Id"},"workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Workflow Id"},"organization_id":{"type":"integer","title":"Organization Id"},"tts_provider":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Provider"},"tts_model":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Model"},"tts_voice_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Voice Id"},"transcript":{"type":"string","title":"Transcript"},"storage_key":{"type":"string","title":"Storage Key"},"storage_backend":{"type":"string","title":"Storage Backend"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"},"created_by":{"type":"integer","title":"Created By"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"is_active":{"type":"boolean","title":"Is Active"}},"type":"object","required":["id","recording_id","organization_id","transcript","storage_key","storage_backend","metadata","created_by","created_at","is_active"],"title":"RecordingResponseSchema","description":"Response schema for a single recording."},"RecordingUpdateRequestSchema":{"properties":{"recording_id":{"type":"string","maxLength":64,"minLength":1,"pattern":"^[a-zA-Z0-9_-]+$","title":"Recording Id","description":"New descriptive recording ID (letters, numbers, hyphens, underscores only)"}},"type":"object","required":["recording_id"],"title":"RecordingUpdateRequestSchema","description":"Request schema for updating a recording's ID."},"RecordingUploadResponseSchema":{"properties":{"upload_url":{"type":"string","title":"Upload Url","description":"Presigned URL for uploading the audio"},"recording_id":{"type":"string","title":"Recording Id","description":"Short unique recording ID"},"storage_key":{"type":"string","title":"Storage Key","description":"Storage key where file will be uploaded"}},"type":"object","required":["upload_url","recording_id","storage_key"],"title":"RecordingUploadResponseSchema","description":"Response schema with presigned upload URL."},"RedialCampaignRequest":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":255,"minLength":1},{"type":"null"}],"title":"Name","description":"Name for the redial campaign"},"retry_on_voicemail":{"type":"boolean","title":"Retry On Voicemail","default":true},"retry_on_no_answer":{"type":"boolean","title":"Retry On No Answer","default":true},"retry_on_busy":{"type":"boolean","title":"Retry On Busy","default":true},"retry_config":{"anyOf":[{"$ref":"#/components/schemas/RetryConfigRequest"},{"type":"null"}]}},"type":"object","title":"RedialCampaignRequest"},"RetryConfigRequest":{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":true},"max_retries":{"type":"integer","maximum":10.0,"minimum":0.0,"title":"Max Retries","default":2},"retry_delay_seconds":{"type":"integer","maximum":3600.0,"minimum":30.0,"title":"Retry Delay Seconds","default":120},"retry_on_busy":{"type":"boolean","title":"Retry On Busy","default":true},"retry_on_no_answer":{"type":"boolean","title":"Retry On No Answer","default":true},"retry_on_voicemail":{"type":"boolean","title":"Retry On Voicemail","default":true}},"type":"object","title":"RetryConfigRequest"},"RetryConfigResponse":{"properties":{"enabled":{"type":"boolean","title":"Enabled"},"max_retries":{"type":"integer","title":"Max Retries"},"retry_delay_seconds":{"type":"integer","title":"Retry Delay Seconds"},"retry_on_busy":{"type":"boolean","title":"Retry On Busy"},"retry_on_no_answer":{"type":"boolean","title":"Retry On No Answer"},"retry_on_voicemail":{"type":"boolean","title":"Retry On Voicemail"}},"type":"object","required":["enabled","max_retries","retry_delay_seconds","retry_on_busy","retry_on_no_answer","retry_on_voicemail"],"title":"RetryConfigResponse"},"RewindTextChatSessionRequest":{"properties":{"cursor_turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor Turn Id"},"expected_revision":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Expected Revision"}},"type":"object","title":"RewindTextChatSessionRequest"},"S3SignedUrlResponse":{"properties":{"url":{"type":"string","title":"Url"},"expires_in":{"type":"integer","title":"Expires In"}},"type":"object","required":["url","expires_in"],"title":"S3SignedUrlResponse"},"ScheduleConfigRequest":{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":true},"timezone":{"type":"string","title":"Timezone","default":"UTC"},"slots":{"items":{"$ref":"#/components/schemas/TimeSlotRequest"},"type":"array","maxItems":50,"minItems":1,"title":"Slots"}},"type":"object","required":["slots"],"title":"ScheduleConfigRequest"},"ScheduleConfigResponse":{"properties":{"enabled":{"type":"boolean","title":"Enabled"},"timezone":{"type":"string","title":"Timezone"},"slots":{"items":{"$ref":"#/components/schemas/TimeSlotResponse"},"type":"array","title":"Slots"}},"type":"object","required":["enabled","timezone","slots"],"title":"ScheduleConfigResponse"},"ServiceKeyResponse":{"properties":{"name":{"type":"string","title":"Name"},"id":{"type":"integer","title":"Id"},"key_prefix":{"type":"string","title":"Key Prefix"},"is_active":{"type":"boolean","title":"Is Active"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"last_used_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Used At"},"expires_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Expires At"},"archived_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Archived At"},"created_by":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created By"}},"type":"object","required":["name","id","key_prefix","is_active","created_at"],"title":"ServiceKeyResponse"},"SignupRequest":{"properties":{"email":{"type":"string","format":"email","title":"Email"},"password":{"type":"string","title":"Password"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},"type":"object","required":["email","password"],"title":"SignupRequest"},"SuperuserWorkflowRunResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"workflow_id":{"type":"integer","title":"Workflow Id"},"workflow_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Name"},"user_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"User Id"},"organization_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Organization Id"},"organization_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Organization Name"},"mode":{"type":"string","title":"Mode"},"is_completed":{"type":"boolean","title":"Is Completed"},"recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Url"},"transcript_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Url"},"usage_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Usage Info"},"cost_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Cost Info"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"gathered_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Gathered Context"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","name","workflow_id","workflow_name","user_id","organization_id","organization_name","mode","is_completed","recording_url","transcript_url","usage_info","cost_info","initial_context","gathered_context","created_at"],"title":"SuperuserWorkflowRunResponse"},"SuperuserWorkflowRunsListResponse":{"properties":{"workflow_runs":{"items":{"$ref":"#/components/schemas/SuperuserWorkflowRunResponse"},"type":"array","title":"Workflow Runs"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"limit":{"type":"integer","title":"Limit"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["workflow_runs","total_count","page","limit","total_pages"],"title":"SuperuserWorkflowRunsListResponse"},"TelephonyConfigWarningsResponse":{"properties":{"telnyx_missing_webhook_public_key_count":{"type":"integer","title":"Telnyx Missing Webhook Public Key Count"}},"type":"object","required":["telnyx_missing_webhook_public_key_count"],"title":"TelephonyConfigWarningsResponse","description":"Aggregated telephony-configuration warning counts for the user's org.\n\nDrives the page banner and nav badge that nudge customers to finish\noptional-but-recommended configuration steps. Shape is a flat dict so\nnew warning types can be added without breaking the client."},"TelephonyConfigurationCreateRequest":{"properties":{"name":{"type":"string","maxLength":64,"minLength":1,"title":"Name"},"is_default_outbound":{"type":"boolean","title":"Is Default Outbound","default":false},"config":{"oneOf":[{"$ref":"#/components/schemas/ARIConfigurationRequest"},{"$ref":"#/components/schemas/CloudonixConfigurationRequest"},{"$ref":"#/components/schemas/PlivoConfigurationRequest"},{"$ref":"#/components/schemas/TelnyxConfigurationRequest"},{"$ref":"#/components/schemas/TwilioConfigurationRequest"},{"$ref":"#/components/schemas/VobizConfigurationRequest"},{"$ref":"#/components/schemas/VonageConfigurationRequest"}],"title":"Config","discriminator":{"propertyName":"provider","mapping":{"ari":"#/components/schemas/ARIConfigurationRequest","cloudonix":"#/components/schemas/CloudonixConfigurationRequest","plivo":"#/components/schemas/PlivoConfigurationRequest","telnyx":"#/components/schemas/TelnyxConfigurationRequest","twilio":"#/components/schemas/TwilioConfigurationRequest","vobiz":"#/components/schemas/VobizConfigurationRequest","vonage":"#/components/schemas/VonageConfigurationRequest"}}}},"type":"object","required":["name","config"],"title":"TelephonyConfigurationCreateRequest","description":"Body for ``POST /telephony-configs``.\n\n``config`` carries the provider-specific credential fields (the same\ndiscriminated union used by the legacy single-config endpoint). Any\n``from_numbers`` on the inner config are ignored \u2014 phone numbers are\nmanaged via the dedicated phone-numbers endpoints."},"TelephonyConfigurationDetail":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"provider":{"type":"string","title":"Provider"},"is_default_outbound":{"type":"boolean","title":"Is Default Outbound"},"credentials":{"additionalProperties":true,"type":"object","title":"Credentials"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","name","provider","is_default_outbound","credentials","created_at","updated_at"],"title":"TelephonyConfigurationDetail","description":"Body of ``GET /telephony-configs/{id}`` \u2014 credentials are masked."},"TelephonyConfigurationListItem":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"provider":{"type":"string","title":"Provider"},"is_default_outbound":{"type":"boolean","title":"Is Default Outbound"},"phone_number_count":{"type":"integer","title":"Phone Number Count","default":0},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","name","provider","is_default_outbound","created_at","updated_at"],"title":"TelephonyConfigurationListItem","description":"One row in ``GET /telephony-configs``."},"TelephonyConfigurationListResponse":{"properties":{"configurations":{"items":{"$ref":"#/components/schemas/TelephonyConfigurationListItem"},"type":"array","title":"Configurations"}},"type":"object","required":["configurations"],"title":"TelephonyConfigurationListResponse"},"TelephonyConfigurationResponse":{"properties":{"twilio":{"anyOf":[{"$ref":"#/components/schemas/TwilioConfigurationResponse"},{"type":"null"}]},"plivo":{"anyOf":[{"$ref":"#/components/schemas/PlivoConfigurationResponse"},{"type":"null"}]},"vonage":{"anyOf":[{"$ref":"#/components/schemas/VonageConfigurationResponse"},{"type":"null"}]},"vobiz":{"anyOf":[{"$ref":"#/components/schemas/VobizConfigurationResponse"},{"type":"null"}]},"cloudonix":{"anyOf":[{"$ref":"#/components/schemas/CloudonixConfigurationResponse"},{"type":"null"}]},"ari":{"anyOf":[{"$ref":"#/components/schemas/ARIConfigurationResponse"},{"type":"null"}]},"telnyx":{"anyOf":[{"$ref":"#/components/schemas/TelnyxConfigurationResponse"},{"type":"null"}]}},"type":"object","title":"TelephonyConfigurationResponse","description":"Top-level telephony configuration response.\n\nKeeps the per-provider field shape that the UI client depends on. When\nthe UI moves to metadata-driven forms, this can be replaced with a\nflat discriminated union."},"TelephonyConfigurationUpdateRequest":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":64,"minLength":1},{"type":"null"}],"title":"Name"},"config":{"anyOf":[{"oneOf":[{"$ref":"#/components/schemas/ARIConfigurationRequest"},{"$ref":"#/components/schemas/CloudonixConfigurationRequest"},{"$ref":"#/components/schemas/PlivoConfigurationRequest"},{"$ref":"#/components/schemas/TelnyxConfigurationRequest"},{"$ref":"#/components/schemas/TwilioConfigurationRequest"},{"$ref":"#/components/schemas/VobizConfigurationRequest"},{"$ref":"#/components/schemas/VonageConfigurationRequest"}],"discriminator":{"propertyName":"provider","mapping":{"ari":"#/components/schemas/ARIConfigurationRequest","cloudonix":"#/components/schemas/CloudonixConfigurationRequest","plivo":"#/components/schemas/PlivoConfigurationRequest","telnyx":"#/components/schemas/TelnyxConfigurationRequest","twilio":"#/components/schemas/TwilioConfigurationRequest","vobiz":"#/components/schemas/VobizConfigurationRequest","vonage":"#/components/schemas/VonageConfigurationRequest"}}},{"type":"null"}],"title":"Config"}},"type":"object","title":"TelephonyConfigurationUpdateRequest","description":"Body for ``PUT /telephony-configs/{id}``. Partial update."},"TelephonyProviderMetadata":{"properties":{"provider":{"type":"string","title":"Provider"},"display_name":{"type":"string","title":"Display Name"},"fields":{"items":{"$ref":"#/components/schemas/TelephonyProviderUIField"},"type":"array","title":"Fields"},"docs_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Docs Url"}},"type":"object","required":["provider","display_name","fields"],"title":"TelephonyProviderMetadata","description":"UI form metadata for a single telephony provider."},"TelephonyProviderUIField":{"properties":{"name":{"type":"string","title":"Name"},"label":{"type":"string","title":"Label"},"type":{"type":"string","title":"Type"},"required":{"type":"boolean","title":"Required"},"sensitive":{"type":"boolean","title":"Sensitive"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"placeholder":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Placeholder"}},"type":"object","required":["name","label","type","required","sensitive"],"title":"TelephonyProviderUIField","description":"One form field on a telephony provider's configuration UI."},"TelephonyProvidersMetadataResponse":{"properties":{"providers":{"items":{"$ref":"#/components/schemas/TelephonyProviderMetadata"},"type":"array","title":"Providers"}},"type":"object","required":["providers"],"title":"TelephonyProvidersMetadataResponse","description":"List of UI form definitions used by the telephony-config screen."},"TelnyxConfigurationRequest":{"properties":{"provider":{"type":"string","const":"telnyx","title":"Provider","default":"telnyx"},"api_key":{"type":"string","title":"Api Key","description":"Telnyx API Key"},"connection_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connection Id","description":"Telnyx Call Control Application ID (connection_id). If omitted, a Call Control Application is auto-created on save and its id is stored on the configuration."},"webhook_public_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Webhook Public Key","description":"Webhook public key from Mission Control Portal \u2192 Keys & Credentials \u2192 Public Key. Used to verify Telnyx webhook signatures."},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Telnyx phone numbers"}},"type":"object","required":["api_key"],"title":"TelnyxConfigurationRequest","description":"Request schema for Telnyx configuration."},"TelnyxConfigurationResponse":{"properties":{"provider":{"type":"string","const":"telnyx","title":"Provider","default":"telnyx"},"api_key":{"type":"string","title":"Api Key"},"connection_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connection Id"},"webhook_public_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Webhook Public Key"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["api_key","from_numbers"],"title":"TelnyxConfigurationResponse","description":"Response schema for Telnyx configuration with masked sensitive fields."},"TimeSlotRequest":{"properties":{"day_of_week":{"type":"integer","maximum":6.0,"minimum":0.0,"title":"Day Of Week"},"start_time":{"type":"string","pattern":"^\\d{2}:\\d{2}$","title":"Start Time"},"end_time":{"type":"string","pattern":"^\\d{2}:\\d{2}$","title":"End Time"}},"type":"object","required":["day_of_week","start_time","end_time"],"title":"TimeSlotRequest"},"TimeSlotResponse":{"properties":{"day_of_week":{"type":"integer","title":"Day Of Week"},"start_time":{"type":"string","title":"Start Time"},"end_time":{"type":"string","title":"End Time"}},"type":"object","required":["day_of_week","start_time","end_time"],"title":"TimeSlotResponse"},"ToolParameter":{"properties":{"name":{"type":"string","title":"Name","description":"Parameter name (used as key in request body)"},"type":{"type":"string","title":"Type","description":"Parameter type: string, number, or boolean"},"description":{"type":"string","title":"Description","description":"Description of what this parameter is for"},"required":{"type":"boolean","title":"Required","description":"Whether this parameter is required","default":true}},"type":"object","required":["name","type","description"],"title":"ToolParameter","description":"A parameter that the tool accepts."},"ToolResponse":{"properties":{"id":{"type":"integer","title":"Id"},"tool_uuid":{"type":"string","title":"Tool Uuid"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"category":{"type":"string","title":"Category"},"icon":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Icon"},"icon_color":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Icon Color"},"status":{"type":"string","title":"Status"},"definition":{"additionalProperties":true,"type":"object","title":"Definition"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"created_by":{"anyOf":[{"$ref":"#/components/schemas/CreatedByResponse"},{"type":"null"}]}},"type":"object","required":["id","tool_uuid","name","description","category","icon","icon_color","status","definition","created_at","updated_at"],"title":"ToolResponse","description":"Response schema for a tool."},"TransferCallConfig":{"properties":{"destination":{"type":"string","title":"Destination","description":"Phone number or SIP endpoint to transfer the call to (E.164 format e.g., +1234567890, or SIP endpoint e.g., PJSIP/1234)"},"messageType":{"type":"string","enum":["none","custom","audio"],"title":"Messagetype","description":"Type of message to play before transfer","default":"none"},"customMessage":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessage","description":"Custom message to play before transferring the call"},"audioRecordingId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Audiorecordingid","description":"Recording ID for audio message before transfer"},"timeout":{"type":"integer","maximum":120.0,"minimum":5.0,"title":"Timeout","description":"Maximum time in seconds to wait for destination to answer (5-120 seconds)","default":30}},"type":"object","required":["destination"],"title":"TransferCallConfig","description":"Configuration for Transfer Call tools."},"TransferCallToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version","default":1},"type":{"type":"string","const":"transfer_call","title":"Type","description":"Tool type"},"config":{"$ref":"#/components/schemas/TransferCallConfig","description":"Transfer Call configuration"}},"type":"object","required":["type","config"],"title":"TransferCallToolDefinition","description":"Tool definition for Transfer Call tools."},"TriggerCallRequest":{"properties":{"phone_number":{"type":"string","title":"Phone Number"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"telephony_configuration_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Telephony Configuration Id"}},"type":"object","required":["phone_number"],"title":"TriggerCallRequest","description":"Request model for triggering a call via API"},"TriggerCallResponse":{"properties":{"status":{"type":"string","title":"Status"},"workflow_run_id":{"type":"integer","title":"Workflow Run Id"},"workflow_run_name":{"type":"string","title":"Workflow Run Name"}},"type":"object","required":["status","workflow_run_id","workflow_run_name"],"title":"TriggerCallResponse","description":"Response model for successful call initiation"},"TurnCredentialsResponse":{"properties":{"username":{"type":"string","title":"Username"},"password":{"type":"string","title":"Password"},"ttl":{"type":"integer","title":"Ttl"},"uris":{"items":{"type":"string"},"type":"array","title":"Uris"}},"type":"object","required":["username","password","ttl","uris"],"title":"TurnCredentialsResponse","description":"Response model for TURN credentials."},"TwilioConfigurationRequest":{"properties":{"provider":{"type":"string","const":"twilio","title":"Provider","default":"twilio"},"account_sid":{"type":"string","title":"Account Sid","description":"Twilio Account SID"},"auth_token":{"type":"string","title":"Auth Token","description":"Twilio Auth Token"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Twilio phone numbers"}},"type":"object","required":["account_sid","auth_token"],"title":"TwilioConfigurationRequest","description":"Request schema for Twilio configuration."},"TwilioConfigurationResponse":{"properties":{"provider":{"type":"string","const":"twilio","title":"Provider","default":"twilio"},"account_sid":{"type":"string","title":"Account Sid"},"auth_token":{"type":"string","title":"Auth Token"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["account_sid","auth_token","from_numbers"],"title":"TwilioConfigurationResponse","description":"Response schema for Twilio configuration with masked sensitive fields."},"UpdateCampaignRequest":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":255,"minLength":1},{"type":"null"}],"title":"Name"},"retry_config":{"anyOf":[{"$ref":"#/components/schemas/RetryConfigRequest"},{"type":"null"}]},"max_concurrency":{"anyOf":[{"type":"integer","maximum":100.0,"minimum":1.0},{"type":"null"}],"title":"Max Concurrency"},"schedule_config":{"anyOf":[{"$ref":"#/components/schemas/ScheduleConfigRequest"},{"type":"null"}]},"circuit_breaker":{"anyOf":[{"$ref":"#/components/schemas/CircuitBreakerConfigRequest"},{"type":"null"}]}},"type":"object","title":"UpdateCampaignRequest"},"UpdateCredentialRequest":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"credential_type":{"anyOf":[{"$ref":"#/components/schemas/WebhookCredentialType"},{"type":"null"}]},"credential_data":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Credential Data"}},"type":"object","title":"UpdateCredentialRequest","description":"Request schema for updating a webhook credential."},"UpdateFolderRequest":{"properties":{"name":{"type":"string","maxLength":100,"minLength":1,"title":"Name"}},"type":"object","required":["name"],"title":"UpdateFolderRequest"},"UpdateToolRequest":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":255},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"icon":{"anyOf":[{"type":"string","maxLength":50},{"type":"null"}],"title":"Icon"},"icon_color":{"anyOf":[{"type":"string","maxLength":7},{"type":"null"}],"title":"Icon Color"},"definition":{"anyOf":[{"oneOf":[{"$ref":"#/components/schemas/HttpApiToolDefinition"},{"$ref":"#/components/schemas/EndCallToolDefinition"},{"$ref":"#/components/schemas/TransferCallToolDefinition"},{"$ref":"#/components/schemas/CalculatorToolDefinition"},{"$ref":"#/components/schemas/McpToolDefinition"}],"discriminator":{"propertyName":"type","mapping":{"calculator":"#/components/schemas/CalculatorToolDefinition","end_call":"#/components/schemas/EndCallToolDefinition","http_api":"#/components/schemas/HttpApiToolDefinition","mcp":"#/components/schemas/McpToolDefinition","transfer_call":"#/components/schemas/TransferCallToolDefinition"}}},{"type":"null"}],"title":"Definition"},"status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status"}},"type":"object","title":"UpdateToolRequest","description":"Request schema for updating a tool."},"UpdateWorkflowRequest":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"workflow_definition":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Workflow Definition"},"template_context_variables":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Template Context Variables"},"workflow_configurations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Workflow Configurations"}},"type":"object","title":"UpdateWorkflowRequest"},"UpdateWorkflowStatusRequest":{"properties":{"status":{"type":"string","title":"Status"}},"type":"object","required":["status"],"title":"UpdateWorkflowStatusRequest"},"UsageHistoryResponse":{"properties":{"runs":{"items":{"$ref":"#/components/schemas/WorkflowRunUsageResponse"},"type":"array","title":"Runs"},"total_dograh_tokens":{"type":"number","title":"Total Dograh Tokens"},"total_duration_seconds":{"type":"integer","title":"Total Duration Seconds"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"limit":{"type":"integer","title":"Limit"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["runs","total_dograh_tokens","total_duration_seconds","total_count","page","limit","total_pages"],"title":"UsageHistoryResponse"},"UserConfigurationRequestResponseSchema":{"properties":{"llm":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Llm"},"tts":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Tts"},"stt":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Stt"},"embeddings":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Embeddings"},"realtime":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Realtime"},"is_realtime":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Realtime"},"test_phone_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Test Phone Number"},"timezone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Timezone"},"organization_pricing":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"number"},{"type":"string"},{"type":"boolean"}]},"type":"object"},{"type":"null"}],"title":"Organization Pricing"}},"type":"object","title":"UserConfigurationRequestResponseSchema"},"UserResponse":{"properties":{"id":{"type":"integer","title":"Id"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"organization_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Organization Id"},"provider_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Id"}},"type":"object","required":["id","email"],"title":"UserResponse"},"ValidateWorkflowResponse":{"properties":{"is_valid":{"type":"boolean","title":"Is Valid"},"errors":{"items":{"$ref":"#/components/schemas/WorkflowError"},"type":"array","title":"Errors"}},"type":"object","required":["is_valid","errors"],"title":"ValidateWorkflowResponse"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"VobizConfigurationRequest":{"properties":{"provider":{"type":"string","const":"vobiz","title":"Provider","default":"vobiz"},"auth_id":{"type":"string","title":"Auth Id","description":"Vobiz Account ID (e.g., MA_SYQRLN1K)"},"auth_token":{"type":"string","title":"Auth Token","description":"Vobiz Auth Token"},"application_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Id","description":"Vobiz Application ID. The application's answer_url is updated when inbound workflows are attached to numbers on this account. If omitted, an application is auto-created on save and its id is stored on the configuration."},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Vobiz phone numbers (E.164 without + prefix)"}},"type":"object","required":["auth_id","auth_token"],"title":"VobizConfigurationRequest","description":"Request schema for Vobiz configuration."},"VobizConfigurationResponse":{"properties":{"provider":{"type":"string","const":"vobiz","title":"Provider","default":"vobiz"},"auth_id":{"type":"string","title":"Auth Id"},"auth_token":{"type":"string","title":"Auth Token"},"application_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Id"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["auth_id","auth_token","from_numbers"],"title":"VobizConfigurationResponse","description":"Response schema for Vobiz configuration with masked sensitive fields."},"VoiceInfo":{"properties":{"voice_id":{"type":"string","title":"Voice Id"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"accent":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Accent"},"gender":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Gender"},"language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language"},"preview_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Preview Url"}},"type":"object","required":["voice_id","name"],"title":"VoiceInfo"},"VoicesResponse":{"properties":{"provider":{"type":"string","title":"Provider"},"voices":{"items":{"$ref":"#/components/schemas/VoiceInfo"},"type":"array","title":"Voices"}},"type":"object","required":["provider","voices"],"title":"VoicesResponse"},"VonageConfigurationRequest":{"properties":{"provider":{"type":"string","const":"vonage","title":"Provider","default":"vonage"},"api_key":{"type":"string","title":"Api Key","description":"Vonage API Key"},"api_secret":{"type":"string","title":"Api Secret","description":"Vonage API Secret"},"application_id":{"type":"string","title":"Application Id","description":"Vonage Application ID"},"private_key":{"type":"string","title":"Private Key","description":"Private key for JWT generation"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Vonage phone numbers (without + prefix)"}},"type":"object","required":["api_key","api_secret","application_id","private_key"],"title":"VonageConfigurationRequest","description":"Request schema for Vonage configuration."},"VonageConfigurationResponse":{"properties":{"provider":{"type":"string","const":"vonage","title":"Provider","default":"vonage"},"application_id":{"type":"string","title":"Application Id"},"api_key":{"type":"string","title":"Api Key"},"api_secret":{"type":"string","title":"Api Secret"},"private_key":{"type":"string","title":"Private Key"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["application_id","api_key","api_secret","private_key","from_numbers"],"title":"VonageConfigurationResponse","description":"Response schema for Vonage configuration with masked sensitive fields."},"WebhookCredentialType":{"type":"string","enum":["none","api_key","bearer_token","basic_auth","custom_header"],"title":"WebhookCredentialType","description":"Webhook credential authentication types"},"WorkflowCountResponse":{"properties":{"total":{"type":"integer","title":"Total"},"active":{"type":"integer","title":"Active"},"archived":{"type":"integer","title":"Archived"}},"type":"object","required":["total","active","archived"],"title":"WorkflowCountResponse","description":"Response for workflow count endpoint."},"WorkflowError":{"properties":{"kind":{"$ref":"#/components/schemas/ItemKind"},"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id"},"field":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Field"},"message":{"type":"string","title":"Message"}},"type":"object","required":["kind","id","field","message"],"title":"WorkflowError"},"WorkflowListResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"status":{"type":"string","title":"Status"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"total_runs":{"type":"integer","title":"Total Runs"},"folder_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Folder Id"},"workflow_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Uuid"}},"type":"object","required":["id","name","status","created_at","total_runs"],"title":"WorkflowListResponse","description":"Lightweight response for workflow listings (excludes large fields)."},"WorkflowOption":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"}},"type":"object","required":["id","name"],"title":"WorkflowOption"},"WorkflowResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"status":{"type":"string","title":"Status"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"workflow_definition":{"additionalProperties":true,"type":"object","title":"Workflow Definition"},"current_definition_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Current Definition Id"},"template_context_variables":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Template Context Variables"},"call_disposition_codes":{"anyOf":[{"$ref":"#/components/schemas/CallDispositionCodes"},{"type":"null"}]},"total_runs":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Total Runs"},"workflow_configurations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Workflow Configurations"},"version_number":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Version Number"},"version_status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version Status"},"workflow_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Uuid"}},"type":"object","required":["id","name","status","created_at","workflow_definition","current_definition_id"],"title":"WorkflowResponse"},"WorkflowRunDetail":{"properties":{"phone_number":{"type":"string","title":"Phone Number"},"disposition":{"type":"string","title":"Disposition"},"duration_seconds":{"type":"number","title":"Duration Seconds"},"workflow_id":{"type":"integer","title":"Workflow Id"},"run_id":{"type":"integer","title":"Run Id"},"workflow_name":{"type":"string","title":"Workflow Name"},"created_at":{"type":"string","title":"Created At"}},"type":"object","required":["phone_number","disposition","duration_seconds","workflow_id","run_id","workflow_name","created_at"],"title":"WorkflowRunDetail"},"WorkflowRunResponseSchema":{"properties":{"id":{"type":"integer","title":"Id"},"workflow_id":{"type":"integer","title":"Workflow Id"},"name":{"type":"string","title":"Name"},"mode":{"type":"string","title":"Mode"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"is_completed":{"type":"boolean","title":"Is Completed"},"transcript_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Url"},"recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Url"},"cost_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Cost Info"},"definition_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Definition Id"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"gathered_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Gathered Context"},"call_type":{"$ref":"#/components/schemas/CallType"},"logs":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Logs"},"annotations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Annotations"}},"type":"object","required":["id","workflow_id","name","mode","created_at","is_completed","transcript_url","recording_url","cost_info","definition_id","call_type"],"title":"WorkflowRunResponseSchema"},"WorkflowRunTextSessionResponse":{"properties":{"workflow_run_id":{"type":"integer","title":"Workflow Run Id"},"workflow_id":{"type":"integer","title":"Workflow Id"},"name":{"type":"string","title":"Name"},"mode":{"type":"string","title":"Mode"},"state":{"type":"string","title":"State"},"is_completed":{"type":"boolean","title":"Is Completed"},"revision":{"type":"integer","title":"Revision"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"gathered_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Gathered Context"},"annotations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Annotations"},"session_data":{"additionalProperties":true,"type":"object","title":"Session Data"},"checkpoint":{"additionalProperties":true,"type":"object","title":"Checkpoint"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["workflow_run_id","workflow_id","name","mode","state","is_completed","revision","session_data","checkpoint","created_at"],"title":"WorkflowRunTextSessionResponse"},"WorkflowRunUsageResponse":{"properties":{"id":{"type":"integer","title":"Id"},"workflow_id":{"type":"integer","title":"Workflow Id"},"workflow_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Name"},"name":{"type":"string","title":"Name"},"created_at":{"type":"string","title":"Created At"},"dograh_token_usage":{"type":"number","title":"Dograh Token Usage"},"call_duration_seconds":{"type":"integer","title":"Call Duration Seconds"},"recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Url"},"transcript_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Url"},"phone_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone Number","description":"Deprecated. Use caller_number and called_number instead.","deprecated":true},"caller_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Caller Number"},"called_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Called Number"},"call_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Call Type"},"mode":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Mode"},"disposition":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Disposition"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"gathered_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Gathered Context"},"charge_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Charge Usd"}},"type":"object","required":["id","workflow_id","workflow_name","name","created_at","dograh_token_usage","call_duration_seconds"],"title":"WorkflowRunUsageResponse"},"WorkflowRunsResponse":{"properties":{"runs":{"items":{"$ref":"#/components/schemas/WorkflowRunResponseSchema"},"type":"array","title":"Runs"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"limit":{"type":"integer","title":"Limit"},"total_pages":{"type":"integer","title":"Total Pages"},"applied_filters":{"anyOf":[{"items":{"additionalProperties":true,"type":"object"},"type":"array"},{"type":"null"}],"title":"Applied Filters"}},"type":"object","required":["runs","total_count","page","limit","total_pages"],"title":"WorkflowRunsResponse"},"WorkflowSummaryResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"}},"type":"object","required":["id","name"],"title":"WorkflowSummaryResponse"},"WorkflowTemplateResponse":{"properties":{"id":{"type":"integer","title":"Id"},"template_name":{"type":"string","title":"Template Name"},"template_description":{"type":"string","title":"Template Description"},"template_json":{"additionalProperties":true,"type":"object","title":"Template Json"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","template_name","template_description","template_json","created_at"],"title":"WorkflowTemplateResponse"},"WorkflowVersionResponse":{"properties":{"id":{"type":"integer","title":"Id"},"version_number":{"type":"integer","title":"Version Number"},"status":{"type":"string","title":"Status"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"published_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Published At"},"workflow_json":{"additionalProperties":true,"type":"object","title":"Workflow Json"},"workflow_configurations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Workflow Configurations"},"template_context_variables":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Template Context Variables"}},"type":"object","required":["id","version_number","status","created_at","workflow_json"],"title":"WorkflowVersionResponse"}}}} \ No newline at end of file diff --git a/docs/configurations/agent-uuid.mdx b/docs/configurations/agent-uuid.mdx new file mode 100644 index 0000000..b7fcf46 --- /dev/null +++ b/docs/configurations/agent-uuid.mdx @@ -0,0 +1,43 @@ +--- +title: "Agent UUID" +description: "Find and use your workflow's stable Agent UUID" +--- + +The **Agent UUID** is the workflow's stable identifier. In API routes and payloads you may also see it called `workflow_uuid`. + +Unlike numeric workflow IDs, the Agent UUID is portable across environments and does not change when you publish new versions of the same workflow. + +## Where to find it + +You can copy the Agent UUID from two places in the dashboard. + +### From the workflow editor + +1. Open your agent in the workflow editor +2. Click the **⋮** menu in the top-right of the header +3. Click **Copy Agent UUID** + +### From the agent's Settings page + +1. Open the agent and go to **Settings** +2. Scroll to the **Agent UUID** section +3. Click the UUID code block, or use **Copy UUID** + +## Where you use it + +Use the Agent UUID for workflow-level routes such as: + +- `POST /api/v1/public/agent/workflow/{workflow_uuid}` +- `POST /api/v1/public/agent/test/workflow/{workflow_uuid}` +- `wss:///api/v1/agent-stream/{agent_uuid}` + + +Do not confuse the Agent UUID with an API Trigger node UUID (`trigger_path`). + +- Use the Agent UUID for workflow-level routes like `/public/agent/workflow/{workflow_uuid}` +- Use the API Trigger node UUID for trigger routes like `/public/agent/{uuid}` +- The API Trigger node path can be edited if you want something more descriptive +- The Agent UUID is generated by Dograh and cannot be changed + +See [API Trigger](/voice-agent/api-trigger) for trigger-node URLs. + diff --git a/docs/configurations/inference-providers.mdx b/docs/configurations/inference-providers.mdx index 3fb62ba..d31dbc8 100644 --- a/docs/configurations/inference-providers.mdx +++ b/docs/configurations/inference-providers.mdx @@ -120,4 +120,68 @@ To use Gemini 3.1 Live with Dograh, you need a Google Gemini API key. Follow the When using a Realtime provider like Gemini Live, you do not need to configure separate TTS and STT services — the realtime model handles speech in and out. However, you **must** still configure an **LLM** under the LLM tab: it powers variable extraction and QA analysis, which the realtime service does not perform. + + +## Gemini Live on Vertex AI + +If you want to run Gemini Live through your own Google Cloud project — for billing consolidation, VPC controls, regional residency, or enterprise IAM — Dograh also supports Gemini Live via **Vertex AI** as a separate provider (`google_vertex_realtime`). The default model is `google/gemini-live-2.5-flash-native-audio`. + +Unlike Google AI Studio (which uses a single Gemini API key), Vertex AI authenticates with a **service account** belonging to your Google Cloud project. + +### Prerequisites + +1. A Google Cloud project with billing enabled. +2. The Vertex AI API enabled on that project: + + ```bash + gcloud services enable aiplatform.googleapis.com --project=YOUR_PROJECT_ID + ``` + +3. A service account with the **Vertex AI User** role (`roles/aiplatform.user`) on the project: + + ```bash + gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \ + --member="serviceAccount:YOUR_SA@YOUR_PROJECT_ID.iam.gserviceaccount.com" \ + --role="roles/aiplatform.user" + ``` + +4. A **JSON** key for that service account (P12 keys are not supported). + +### Creating the service account key + +1. In the GCP Console, go to **IAM & Admin → Service Accounts**. +2. Pick an existing service account (or create a new one). +3. Open the **Keys** tab → **Add Key → Create new key**. +4. Choose **JSON** as the key type and click **Create**. +5. The key file will download to your computer — store it securely and treat it as a secret. + + + Always pick **JSON**, not P12. The Vertex AI client libraries used by Dograh only accept service-account JSON keys; P12 is a legacy format retained for older Google Workspace integrations. + + +### Configuring Vertex AI Realtime in Dograh + +1. Go to **Model Configurations** in your Dograh dashboard. +2. Enable the **Realtime** toggle. +3. Under the **Realtime** section, select `google_vertex_realtime` as the provider. +4. Fill in the fields: + + | Field | What to put in | + |---|---| + | **Model** | Vertex publisher/model id, e.g. `google/gemini-live-2.5-flash-native-audio` | + | **Voice** | One of the built-in voices (Puck, Charon, Kore, Fenrir, Aoede) | + | **Language** | BCP-47 code (e.g. `en-US`) | + | **Project Id** | The `project_id` value from your service-account JSON | + | **Location** | GCP region where the model is available (e.g. `us-east4`) | + | **Credentials** | Paste the **entire contents** of the service-account JSON file | + | **API Key** | Leave blank — Vertex AI does not use API keys | + +5. Save the configuration. + + + Paste the whole JSON file into the **Credentials** field — including `private_key`, `client_email`, and all other entries. Don't try to extract individual fields. If `Credentials` is left blank, Dograh falls back to **Application Default Credentials (ADC)** from the host environment, which is useful when running Dograh on a GCP VM or GKE pod with an attached service account. + + + + IAM changes can take up to ~60 seconds to propagate. If you see `Permission 'aiplatform.endpoints.predict' denied`, wait a minute and retry — or double-check that the role was granted to the same service account whose JSON you pasted. \ No newline at end of file diff --git a/docs/deployment/custom-domain.mdx b/docs/deployment/custom-domain.mdx index b25dacc..7cc050c 100644 --- a/docs/deployment/custom-domain.mdx +++ b/docs/deployment/custom-domain.mdx @@ -75,9 +75,10 @@ It will automatically: - Verify DNS configuration - Install Certbot - Generate Let's Encrypt SSL certificates -- Update nginx configuration +- Update the canonical public host/base URL settings in `.env` +- Validate the runtime config that `dograh-init` will render from `.env` - Configure automatic certificate renewal -- Restart Dograh services +- Restart Dograh services through the validated startup wrapper Once complete, your application will be available at `https://voice.yourcompany.com`. @@ -130,7 +131,7 @@ Replace `voice.yourcompany.com` with your actual domain name. Certbot will: 1. Verify that you control the domain 2. Generate SSL certificates -3. Store them in `/etc/letsencrypt/archive/voice.yourcompany.com/` +3. Store them in `/etc/letsencrypt/live/voice.yourcompany.com/` You'll be prompted to enter an email address for renewal notifications and agree to the terms of service. @@ -142,44 +143,31 @@ Copy the generated certificates to the dograh certs directory: ```bash cd dograh -sudo cp /etc/letsencrypt/archive/voice.yourcompany.com/fullchain1.pem certs/local.crt -sudo cp /etc/letsencrypt/archive/voice.yourcompany.com/privkey1.pem certs/local.key +sudo cp /etc/letsencrypt/live/voice.yourcompany.com/fullchain.pem certs/local.crt +sudo cp /etc/letsencrypt/live/voice.yourcompany.com/privkey.pem certs/local.key sudo chmod 644 certs/local.crt certs/local.key ``` -### Update nginx Configuration +### Update Canonical Public URL Settings -Update the nginx configuration to use your domain name. Open the nginx configuration file: +Update `.env` so the canonical remote settings point at your domain: ```bash -nano dograh/nginx.conf +nano dograh/.env ``` -Update the `server_name` directive with your domain: - -```nginx -server { - listen 443 ssl; - server_name voice.yourcompany.com; - - ssl_certificate /etc/nginx/certs/local.crt; - ssl_certificate_key /etc/nginx/certs/local.key; - - # ... rest of the configuration remains the same -} +```bash +PUBLIC_HOST=voice.yourcompany.com +PUBLIC_BASE_URL=https://voice.yourcompany.com ``` -### Add environment variable - -Replace `BACKEND_API_ENDPOINT` environment variable the `docker-compose.yaml` with your custom domain with the scheme. - ### Start Dograh Services -Start Dograh with the updated configuration: +Start Dograh through the validated startup wrapper so `dograh-init` regenerates nginx and coturn runtime config before Docker starts: ```bash cd dograh -sudo docker compose --profile remote up -d --pull always +./remote_up.sh ``` ### Access Your Application @@ -207,8 +195,8 @@ Add the following content (replace paths as needed): ```bash #!/bin/bash # Copy renewed certificates to dograh certs directory -cp /etc/letsencrypt/archive/voice.yourcompany.com/fullchain1.pem /home/ubuntu/dograh/certs/local.crt -cp /etc/letsencrypt/archive/voice.yourcompany.com/privkey1.pem /home/ubuntu/dograh/certs/local.key +cp /etc/letsencrypt/live/voice.yourcompany.com/fullchain.pem /home/ubuntu/dograh/certs/local.crt +cp /etc/letsencrypt/live/voice.yourcompany.com/privkey.pem /home/ubuntu/dograh/certs/local.key chmod 644 /home/ubuntu/dograh/certs/local.crt /home/ubuntu/dograh/certs/local.key # Restart nginx to load new certificates @@ -243,7 +231,7 @@ If Certbot fails to generate certificates: If you see SSL errors after setup: 1. Verify the certificates were copied correctly: `ls -la dograh/certs/` -2. Check that `nginx.conf` points to `/etc/nginx/certs/local.crt` and `/etc/nginx/certs/local.key` +2. Run `./remote_up.sh --preflight-only` in `dograh/` to verify the `dograh-init` runtime render matches `.env` 3. Restart the nginx container: `sudo docker compose --profile remote restart nginx` ### WebRTC Connection Issues @@ -251,5 +239,4 @@ If you see SSL errors after setup: If voice calls don't connect after domain setup: 1. Ensure TCP/UDP ports 3478, 5349, and UDP 49152-49200 are still open -2. Update the `.env` file with your domain name if needed for TURN server configuration - +2. Check that `PUBLIC_HOST` / `PUBLIC_BASE_URL` in `.env` match your domain, then re-run `./remote_up.sh` diff --git a/docs/deployment/docker.mdx b/docs/deployment/docker.mdx index f235d5c..d0f2959 100644 --- a/docs/deployment/docker.mdx +++ b/docs/deployment/docker.mdx @@ -57,16 +57,22 @@ The Quick Start above relies on direct peer-to-peer WebRTC between your browser For these cases, use the alternate local setup script which configures a coturn TURN server alongside the rest of the stack: -```bash + +```bash macOS/Linux curl -o setup_local.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/setup_local.sh && chmod +x setup_local.sh && ./setup_local.sh ``` +```powershell Windows +Invoke-WebRequest -OutFile setup_local.ps1 https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/setup_local.ps1 +.\setup_local.ps1 +``` + The script will prompt you for: - Whether to enable coturn (answer `y`) - The host browsers should use to reach TURN (press Enter for `127.0.0.1`; use your LAN IP if testing from another device on the same network) - A shared secret for the TURN server (press Enter to generate a random one) -It creates `docker-compose.yaml`, `turnserver.conf`, and a `.env` file with TURN credentials. Start the stack with the `local-turn` profile so coturn comes up alongside the other services: +It creates `docker-compose.yaml`, a `.env` file with TURN credentials, and the small helper bundle that `dograh-init` uses to render coturn config at startup. Start the stack with the `local-turn` profile so coturn comes up alongside the other services: ```bash docker compose --profile local-turn up --pull always @@ -118,9 +124,10 @@ The script will prompt you for: It will automatically: - Get the source — `docker-compose.yaml` only (prebuilt mode), or clone the full repo (build mode) -- Create and configure nginx.conf with your IP address +- Download the validated remote deployment helper bundle - Generate SSL certificates - Create an environment file with TURN server configuration +- Validate the runtime config that `dograh-init` will render from `.env` - Write a `docker-compose.override.yaml` with build directives (build mode only) ### Start the Application @@ -134,11 +141,11 @@ After the setup script completes, start Dograh. The script prints the exact comm ```bash Prebuilt mode cd dograh -sudo docker compose --profile remote up --pull always +./remote_up.sh ``` ```bash Build mode cd dograh -sudo docker compose --profile remote up -d --build +./remote_up.sh --build ``` @@ -174,12 +181,14 @@ The setup script creates the following files in the `dograh/` directory: |------|---------| | `docker-compose.yaml` | Main Docker Compose configuration | | `docker-compose.override.yaml` | Build directives for `api` and `ui` (**build mode only**) | -| `turnserver.conf` | Configuration for TURN server | -| `nginx.conf` | nginx reverse proxy configuration with your IP | +| `remote_up.sh` | Validated startup wrapper for the remote stack | +| `scripts/run_dograh_init.sh` | One-shot init renderer/validator used by Docker Compose | +| `scripts/lib/setup_common.sh` | Shared deployment helper library | +| `deploy/templates/` | nginx and coturn runtime config templates | | `generate_certificate.sh` | Script to regenerate SSL certificates | | `certs/local.crt` | Self-signed SSL certificate | | `certs/local.key` | SSL private key | -| `.env` | Environment variables (TURN secret, JWT secret, FastAPI worker count) | +| `.env` | Single source of truth for deployment settings (TURN secret, JWT secret, FastAPI worker count, public host/base URL) | ### Building from source diff --git a/docs/deployment/scaling.mdx b/docs/deployment/scaling.mdx index b544539..b3910ed 100644 --- a/docs/deployment/scaling.mdx +++ b/docs/deployment/scaling.mdx @@ -8,7 +8,7 @@ By default, the Dograh API container runs a single uvicorn worker. For productio This page covers how the multi-worker setup works, how to choose a worker count at install time, and how to change it on a running stack. -Multi-worker support requires **Dograh v1.29.0 or newer**. Earlier releases used `uvicorn --workers` and ship a different `setup_remote.sh` / `start_services_docker.sh` / `nginx.conf` layout — the steps below will not work on them. If your stack is older, [update first](/deployment/update) and then come back to this page. +Multi-worker support requires **Dograh v1.29.0 or newer**. Earlier releases used `uvicorn --workers` and a different remote deployment layout. If your stack is older, [update first](/deployment/update) and then come back to this page. ## How it works @@ -58,23 +58,11 @@ Press Enter for the default (`4`) or enter a different positive integer. Non-int SERVER_IP=... TURN_SECRET=... FASTAPI_WORKERS=8 ./setup_remote.sh ``` -The script wires the value into two places: - -- **`.env`** — sets `FASTAPI_WORKERS=N`, which `docker-compose.yaml` substitutes into the API container's environment. -- **`nginx.conf`** — generates an `upstream dograh_api` block with one `server api:800X` entry per worker. - -Both must agree, which is why the script generates them together. +The script stores the value in **`.env`** (`FASTAPI_WORKERS=N`). The supported startup path (`./remote_up.sh`) preflights the `dograh-init` render from that value before every remote start, so nginx and the API worker count stay aligned. ## Changing the worker count on a running stack -Once Dograh is running, increasing or decreasing the worker count is a two-file edit plus a restart. You'll touch: - -1. **`.env`** — controls how many uvicorn processes the API container spawns. -2. **`nginx.conf`** — controls which worker ports nginx forwards to. - - -Both files must stay in sync. If `.env` says `FASTAPI_WORKERS=8` but `nginx.conf` only lists 4 upstream servers, half your workers will be idle. If `nginx.conf` lists more upstreams than there are workers, those upstreams will throw connection errors and trip the `proxy_next_upstream` fallback. - +Once Dograh is running, increasing or decreasing the worker count is a one-file edit plus a restart. Change `.env`, then start through `./remote_up.sh` so `dograh-init` regenerates nginx runtime config before Docker starts the stack. ### Steps @@ -90,41 +78,21 @@ FASTAPI_WORKERS=4 FASTAPI_WORKERS=8 ``` -**2. Edit `nginx.conf`** and update the `upstream dograh_api` block so it has exactly one `server api:800X` line per worker, with ports starting at `8000`: - -```nginx -upstream dograh_api { - least_conn; - server api:8000 max_fails=3 fail_timeout=10s; - server api:8001 max_fails=3 fail_timeout=10s; - server api:8002 max_fails=3 fail_timeout=10s; - server api:8003 max_fails=3 fail_timeout=10s; - server api:8004 max_fails=3 fail_timeout=10s; # ← new - server api:8005 max_fails=3 fail_timeout=10s; # ← new - server api:8006 max_fails=3 fail_timeout=10s; # ← new - server api:8007 max_fails=3 fail_timeout=10s; # ← new - keepalive 32; -} -``` - -To **scale down**, remove the trailing `server` lines so the list matches the new `FASTAPI_WORKERS` value. - -**3. Recreate the affected containers.** The simplest path — brief downtime, no surprises: +**2. Recreate the stack through the validated wrapper.** The simplest path — brief downtime, no surprises: ```bash -sudo docker compose --profile remote down -sudo docker compose --profile remote up -d +./remote_up.sh ``` If you want to avoid downtime and your stack is healthy, you can recreate only the `api` and `nginx` containers: ```bash -sudo docker compose --profile remote up -d --force-recreate api nginx +./remote_up.sh -- api nginx ``` -`--force-recreate` ensures the api container picks up the new `FASTAPI_WORKERS` value and nginx re-reads the updated `nginx.conf` (which is mounted read-only from disk). +`remote_up.sh` validates `.env`, runs the same `dograh-init` render that Compose will use at startup, runs `docker compose config -q`, and then starts the requested services. -**4. Verify.** Confirm the right number of uvicorn processes are running. The API image is slim and doesn't include `ps`, so use Docker's host-side view instead: +**3. Verify.** Confirm the right number of uvicorn processes are running. The API image is slim and doesn't include `ps`, so use Docker's host-side view instead: ```bash sudo docker compose --profile remote top api | grep uvicorn diff --git a/docs/deployment/update.mdx b/docs/deployment/update.mdx index bbea9a0..c307b89 100644 --- a/docs/deployment/update.mdx +++ b/docs/deployment/update.mdx @@ -37,8 +37,8 @@ Always update **`dograh-api`** and **`dograh-ui`** to the **same tag**. The two - Asks for a target version (defaults to the latest release tag on GitHub). - Pulls `docker-compose.yaml` at that version and pins both `api` and `ui` images to it. -- Regenerates `nginx.conf` and `turnserver.conf` from the upstream templates, so newer features (like [multi-worker scaling](/deployment/scaling)) are wired up correctly without manual editing. -- Reads your existing `.env` and appends any new required keys with safe defaults — your `OSS_JWT_SECRET`, `TURN_SECRET`, and other values are never touched. +- Refreshes the remote helper bundle (`remote_up.sh` plus shared templates/helpers). +- Synchronizes the canonical remote keys in `.env` and validates the runtime config that `dograh-init` will render from it. - Backs up every file it changes with a `.bak.` suffix. From your install directory: @@ -55,20 +55,19 @@ You'll be prompted for the target version, defaulting to the most recent release TARGET_VERSION=1.28.0 DOGRAH_UPDATE_YES=1 bash update_remote.sh ``` -After the script finishes, apply the update by recreating the stack: +After the script finishes, apply the update through the validated startup wrapper: ```bash -sudo docker compose --profile remote down -sudo docker compose --profile remote up -d --pull always +./remote_up.sh ``` -The script overwrites `docker-compose.yaml`, `nginx.conf`, and `turnserver.conf` from upstream templates. If you've made local edits to any of these (extra environment variables, custom ports, modified nginx routes), check the `.bak.` files after the update and re-apply your edits. +The script overwrites `docker-compose.yaml` and the remote helper bundle (`remote_up.sh`, `scripts/run_dograh_init.sh`, `scripts/lib/setup_common.sh`, and `deploy/templates/*`) from the shared upstream deployment bundle. If you've made local edits to any of these, check the `.bak.` files after the update and re-apply your edits. ## Local deployment -For local Docker installs (the [Quick Start](/deployment/docker#quick-start) flow or `setup_local.sh`), there are no host-side config files to refresh — pull new images and restart: +For local Docker installs (the [Quick Start](/deployment/docker#quick-start) flow or `setup_local.sh` / `setup_local.ps1`), there are no host-side config files to refresh — pull new images and restart: ```bash docker compose down @@ -100,11 +99,10 @@ curl http://localhost:8000/api/v1/health # local ```bash cd dograh -for f in docker-compose.yaml nginx.conf turnserver.conf .env; do +for f in docker-compose.yaml nginx.conf turnserver.conf .env remote_up.sh scripts/run_dograh_init.sh scripts/lib/setup_common.sh deploy/templates/nginx.remote.conf.template deploy/templates/turnserver.remote.conf.template; do [[ -f "$f.bak." ]] && cp "$f.bak." "$f" done -sudo docker compose --profile remote down -sudo docker compose --profile remote up -d +./remote_up.sh ``` Your Postgres data volume persists across `down`/`up` cycles, so agents and call history are preserved. @@ -138,6 +136,6 @@ sudo docker compose --profile remote up -d If you update the `pipecat` submodule, you **must** run `git submodule update --init --recursive` before rebuilding, or the Docker build will not pick up `pipecat` changes. -If you maintain a fork with local customizations on top of upstream, merging conflicts in `docker-compose.yaml`, `nginx.conf`, `turnserver.conf`, or `setup_remote.sh` is up to you — resolve them as you would any other git merge. Leave `OSS_JWT_SECRET` and `TURN_SECRET` in `.env` unchanged across updates to preserve sessions and WebRTC auth. +If you maintain a fork with local customizations on top of upstream, merging conflicts in `docker-compose.yaml`, `remote_up.sh`, `scripts/run_dograh_init.sh`, `deploy/templates/*`, or `setup_remote.sh` is up to you — resolve them as you would any other git merge. Leave `OSS_JWT_SECRET` and `TURN_SECRET` in `.env` unchanged across updates to preserve sessions and WebRTC auth. The same migration warning above applies: rolling back across a schema change can leave the DB in a state the older API can't read. diff --git a/docs/docs.json b/docs/docs.json index 09b55f5..e5711fb 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -45,6 +45,7 @@ "configurations/voice", "configurations/transcriber", "configurations/api-keys", + "configurations/agent-uuid", "configurations/interruption", "configurations/tracing" ] @@ -71,7 +72,8 @@ { "group": "Custom Tools", "pages": [ - "voice-agent/tools/http-api" + "voice-agent/tools/http-api", + "voice-agent/tools/mcp-tool" ] } ] @@ -113,7 +115,8 @@ "group": "Integrations", "tag": "NEW", "pages": [ - "integrations/mcp" + "integrations/mcp", + "integrations/tuner" ] } ] @@ -203,6 +206,7 @@ "pages": [ "api-reference/calls", "api-reference/calls/trigger", + "api-reference/calls/trigger-workflow", "api-reference/calls/get-run", "api-reference/calls/list-runs", "api-reference/calls/download", @@ -308,4 +312,4 @@ "linkedin": "https://linkedin.com/company/dograh" } } -} \ No newline at end of file +} diff --git a/docs/images/hero.gif b/docs/images/hero.gif new file mode 100644 index 0000000..c98709b Binary files /dev/null and b/docs/images/hero.gif differ diff --git a/docs/images/tuner-agent-settings.png b/docs/images/tuner-agent-settings.png new file mode 100644 index 0000000..f9faaff Binary files /dev/null and b/docs/images/tuner-agent-settings.png differ diff --git a/docs/images/tuner-create-agent.png b/docs/images/tuner-create-agent.png new file mode 100644 index 0000000..fa3f57a Binary files /dev/null and b/docs/images/tuner-create-agent.png differ diff --git a/docs/images/tuner-dograh-workflow-builder.png b/docs/images/tuner-dograh-workflow-builder.png new file mode 100644 index 0000000..8cbbfa9 Binary files /dev/null and b/docs/images/tuner-dograh-workflow-builder.png differ diff --git a/docs/images/tuner-edit-modal.png b/docs/images/tuner-edit-modal.png new file mode 100644 index 0000000..87ad95d Binary files /dev/null and b/docs/images/tuner-edit-modal.png differ diff --git a/docs/images/tuner-integrations-panel.png b/docs/images/tuner-integrations-panel.png new file mode 100644 index 0000000..f9f72b2 Binary files /dev/null and b/docs/images/tuner-integrations-panel.png differ diff --git a/docs/images/tuner-node-configured.png b/docs/images/tuner-node-configured.png new file mode 100644 index 0000000..091a332 Binary files /dev/null and b/docs/images/tuner-node-configured.png differ diff --git a/docs/images/tuner-node-not-configured.png b/docs/images/tuner-node-not-configured.png new file mode 100644 index 0000000..f7257e3 Binary files /dev/null and b/docs/images/tuner-node-not-configured.png differ diff --git a/docs/images/tuner-workspace-settings.png b/docs/images/tuner-workspace-settings.png new file mode 100644 index 0000000..716e8ec Binary files /dev/null and b/docs/images/tuner-workspace-settings.png differ diff --git a/docs/integrations/telephony/agent-stream.mdx b/docs/integrations/telephony/agent-stream.mdx index 432db9e..f08a6b6 100644 --- a/docs/integrations/telephony/agent-stream.mdx +++ b/docs/integrations/telephony/agent-stream.mdx @@ -35,19 +35,9 @@ wss://app.dograh.com/api/v1/agent-stream/{agent_uuid} ## Get the Agent UUID -The Agent UUID is the workflow's stable identifier — it doesn't change when versions are published. You can copy it from two places in the dashboard: +The Agent UUID is the workflow's stable identifier — it doesn't change when versions are published. -**From the workflow editor** - -1. Open your agent in the workflow editor -2. Click the **⋮** (more options) menu in the top-right of the header -3. Click **Copy Agent UUID** — the toast confirms the copy - -**From the agent's Settings page** - -1. Open the agent and go to **Settings** -2. Scroll to the **Agent UUID** section (also linked in the right-side nav) -3. Click the UUID code block, or use the **Copy UUID** button +To find and copy it in the UI, see [Agent UUID](/configurations/agent-uuid). ## Connect to the WebSocket diff --git a/docs/integrations/telephony/custom.mdx b/docs/integrations/telephony/custom.mdx index 686ff7c..ad52208 100644 --- a/docs/integrations/telephony/custom.mdx +++ b/docs/integrations/telephony/custom.mdx @@ -193,7 +193,6 @@ If your provider POSTs webhooks to Dograh (answer URL, status callbacks, hangup ```python # providers/your_provider/routes.py from fastapi import APIRouter, Request -from api.services.telephony.factory import get_telephony_provider from api.services.telephony.status_processor import ( StatusCallbackRequest, _process_status_update, @@ -286,7 +285,7 @@ register(SPEC) | Field | Used by | | --- | --- | | `name` | Stored as the discriminator on every `TelephonyConfiguration` row and as the `WorkflowRunMode` value | -| `provider_cls` | `factory.get_telephony_provider*` | +| `provider_cls` | `factory.get_default_telephony_provider`, `get_telephony_provider_by_id`, `get_telephony_provider_for_run` | | `config_loader` | `factory._normalize_with_phone_numbers` (replaces the old if/elif chain) | | `transport_factory` | `run_pipeline_telephony` | | `audio_config` | `create_audio_config()` and `run_pipeline_telephony` | @@ -375,7 +374,7 @@ For end-to-end testing, save your provider through the telephony-configurations ## Best Practices -1. **Trust the registry** — never import another provider's class directly; resolve through `factory.get_telephony_provider*`. +1. **Trust the registry** — never import another provider's class directly; resolve through the factory helpers (`get_default_telephony_provider`, `get_telephony_provider_by_id`, etc.). 2. **Sensitive fields** — mark every credential field `sensitive=True` in `ProviderUIMetadata`. The save endpoint masks these on read and preserves the original when the client re-submits a masked value. 3. **Inbound signature verification** — always validate inbound webhook signatures in `verify_inbound_signature`. Returning `True` when no signature header is present is acceptable; return `False` when a signature *is* present but invalid. 4. **Transports load credentials lazily** — call `load_credentials_for_transport` with the `telephony_configuration_id` from the workflow run. Don't read the org's default config from `transport.py`. diff --git a/docs/integrations/telephony/vobiz.mdx b/docs/integrations/telephony/vobiz.mdx index f447469..28cd85a 100644 --- a/docs/integrations/telephony/vobiz.mdx +++ b/docs/integrations/telephony/vobiz.mdx @@ -11,7 +11,7 @@ Vobiz is a cloud-based telephony service provider that offers global reach with Before setting up Vobiz integration, you'll need: -- A [Vobiz account](https://vobiz.com) +- A [Vobiz account](https://vobiz.ai) - Auth ID and Auth Token from your Vobiz dashboard - A Vobiz **Application** (optional — leave the field blank in Dograh and we'll auto-create one for you on save, with the `answer_url` pre-set) - At least one Vobiz phone number @@ -158,4 +158,4 @@ Vobiz numbers don't carry an `answer_url` directly — the URL lives on a Vobiz - Test your configuration with a single call before running campaigns - Monitor Vobiz dashboard for usage and billing - Keep your Auth Token secure and rotate it periodically -- Use a dedicated Vobiz Application for Dograh so the shared `answer_url` doesn't conflict with other systems \ No newline at end of file +- Use a dedicated Vobiz Application for Dograh so the shared `answer_url` doesn't conflict with other systems diff --git a/docs/integrations/tuner.mdx b/docs/integrations/tuner.mdx new file mode 100644 index 0000000..e4d0198 --- /dev/null +++ b/docs/integrations/tuner.mdx @@ -0,0 +1,125 @@ +--- +title: "Tuner Integration" +description: "Connect Dograh to Tuner — the observability, simulation, and testing layer for voice" +--- + + diff --git a/docs/voice-agent/tools/introduction.mdx b/docs/voice-agent/tools/introduction.mdx index 1e10df5..2c6d035 100644 --- a/docs/voice-agent/tools/introduction.mdx +++ b/docs/voice-agent/tools/introduction.mdx @@ -3,7 +3,7 @@ title: "Tools" description: "Extend your voice agent's capabilities by giving it tools to perform actions during live conversations." --- -Tools let your AI agent take actions during a conversation — transfer calls, end calls, or call external APIs — based on the context of the conversation and your prompt instructions. +Tools let your AI agent take actions during a conversation — transfer calls, end calls, call external APIs, or invoke remote MCP servers — based on the context of the conversation and your prompt instructions. When a tool is attached to a workflow node, the LLM decides **when** to invoke it and **what parameters** to pass, based on the user's spoken intent and your node-level instructions. @@ -23,6 +23,7 @@ Pre-configured tools that handle common telephony operations out of the box: Tools you define to integrate with any external system: - [**HTTP API**](/voice-agent/tools/http-api) — Call any REST API endpoint during a conversation (e.g., CRM updates, data lookups, triggering automations) +- [**MCP Tool**](/voice-agent/tools/mcp-tool) — Connect an external MCP server and expose its remote tools to the LLM during a conversation ## How Tools Work diff --git a/docs/voice-agent/tools/mcp-tool.mdx b/docs/voice-agent/tools/mcp-tool.mdx new file mode 100644 index 0000000..3b9013e --- /dev/null +++ b/docs/voice-agent/tools/mcp-tool.mdx @@ -0,0 +1,90 @@ +--- +title: "MCP Tool" +description: "Connect an external MCP server to your voice agent so the LLM can call remote tools during a live conversation." +--- + + +This page is about using an external MCP server as a **tool inside a voice agent**. If you want Claude, Cursor, or another coding agent to control Dograh itself over MCP, see [Dograh MCP Server](/integrations/mcp). + + +MCP tools let a Dograh voice agent call tools exposed by a remote [Model Context Protocol](https://modelcontextprotocol.io/) server during a live conversation. Dograh discovers the remote tool catalog, turns those tools into LLM-callable functions, and forwards invocations to the MCP server over authenticated Streamable HTTP. + +## What to configure + +An MCP tool in Dograh has four important pieces: + +- **Name**: the server label shown in Dograh +- **Description**: tells the LLM when this MCP server is relevant +- **URL**: the remote MCP server endpoint (`http://` or `https://`) +- **Credential**: the auth Dograh should send when connecting to that server + +You can also set a **tool filter** to allow only specific remote MCP tools to be exposed. + +## Authentication + +Most hosted MCP servers expect: + +```http +Authorization: Bearer +``` + +So before creating the MCP tool, create a credential in Dograh with: + +- **Credential Type**: `Bearer Token` +- **Token**: the access token issued by your MCP server + +Then select that credential on the MCP tool. + + +If the remote MCP server documentation says to use Bearer auth, choose **Bearer Token** in the credential dialog. Dograh will translate that into the exact `Authorization: Bearer ` header on the MCP connection. + + +Dograh also supports other credential types, but **Bearer Token** is the default thing to try for third-party MCP servers unless their docs say otherwise. + +## How it works + +The runtime path is: + +1. When you save or refresh the MCP tool, Dograh opens a short-lived authenticated MCP session and fetches the remote tool catalog. +2. Dograh stores that catalog as the tool's discovered tools so the UI can show you which remote functions exist. +3. When a call starts, Dograh opens one live MCP session per attached MCP server and reuses your selected credential for that session. +4. For each node, Dograh exposes only the MCP tools allowed by the server-level filter and the node-level selection. +5. Dograh namespaces those remote tools into ordinary LLM function definitions so they can safely coexist with HTTP API tools, call transfer, end call, and other tools. +6. During the conversation, the LLM sees only the tool name, description, and argument schema. It does **not** see the secret. +7. When the LLM calls one of those tools, Dograh forwards the invocation to the MCP server over the active authenticated session, receives the result, and feeds that result back into the agent turn. + +In short: **Dograh handles discovery, authentication, session management, tool registration, and result plumbing; the LLM only decides when to call the tool and with which arguments.** + +## Creating an MCP tool + +1. Go to **Tools** and create a new tool. +2. Choose **MCP Server**. +3. Enter a clear name and a description that explains when the LLM should use this server. +4. Paste the MCP server URL. +5. Select the credential. In most cases this should be a **Bearer Token** credential. +6. Save the tool and confirm that Dograh discovered the remote tools. + +If the server exposes many tools, use filtering to keep only the ones your agent actually needs. + +## Attaching it to a node + +After the MCP tool is created: + +1. Open the workflow node where the tool should be available. +2. Add the MCP tool from the node's tool list. +3. Select only the remote MCP functions that should be callable on that node. +4. In the node prompt, tell the LLM exactly when to use those functions. + +The tighter the node-level selection and prompt guidance, the more reliable MCP tool usage becomes. + +## Best practices + +- Use one MCP server per logical integration when possible. +- Keep the tool description explicit about **when** the LLM should use that server. +- Expose only the minimum remote functions needed for each node. +- Prefer a **Bearer Token** credential unless the MCP server specifies another auth scheme. +- Test discovery first, then test a real phone/web call to confirm the LLM invokes the right MCP function with the right arguments. + + +If the remote MCP server is temporarily unavailable, Dograh degrades gracefully and the call can continue without those MCP tools rather than crashing the entire conversation. + diff --git a/pipecat b/pipecat index 31199bd..c771a50 160000 --- a/pipecat +++ b/pipecat @@ -1 +1 @@ -Subproject commit 31199bd1f155a872690ff1de24b0fa5ccff49396 +Subproject commit c771a50ed36c49002b4bf4e5cb66cf1e4b73c97d diff --git a/remote_up.sh b/remote_up.sh new file mode 100755 index 0000000..29dc784 --- /dev/null +++ b/remote_up.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_PATH="$SCRIPT_DIR/scripts/lib/setup_common.sh" +BOOTSTRAP_LIB="" + +if [[ ! -f "$LIB_PATH" ]]; then + BOOTSTRAP_LIB="$(mktemp)" + curl -fsSL -o "$BOOTSTRAP_LIB" "https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/lib/setup_common.sh" + LIB_PATH="$BOOTSTRAP_LIB" +fi + +cleanup() { + if [[ -n "$BOOTSTRAP_LIB" ]]; then + rm -f "$BOOTSTRAP_LIB" + fi +} +trap cleanup EXIT + +# shellcheck disable=SC1090 +. "$LIB_PATH" + +DOGRAH_DEPLOY_PROJECT_DIR="$SCRIPT_DIR" + +VALIDATE_ONLY=0 +MODE="pull" +EXTRA_ARGS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --build) + MODE="build" + ;; + --preflight-only|--validate-only) + VALIDATE_ONLY=1 + ;; + --) + shift + EXTRA_ARGS=("$@") + break + ;; + *) + EXTRA_ARGS+=("$1") + ;; + esac + shift +done + +cd "$SCRIPT_DIR" + +dograh_info "Running Dograh remote preflight..." +dograh_prepare_remote_install "$SCRIPT_DIR" +docker compose config -q +dograh_success "✓ dograh-init preflight validated" + +if [[ "$VALIDATE_ONLY" == "1" ]]; then + exit 0 +fi + +if [[ $EUID -eq 0 ]] || ! command -v sudo >/dev/null 2>&1; then + COMPOSE_CMD=(docker compose) +else + COMPOSE_CMD=(sudo docker compose) +fi + +if [[ "$MODE" == "build" ]]; then + CMD=("${COMPOSE_CMD[@]}" --profile remote up -d --build --force-recreate) +else + CMD=("${COMPOSE_CMD[@]}" --profile remote up -d --pull always --force-recreate) +fi + +# Bash 3.2 on macOS treats "${empty_array[@]}" as unbound under `set -u`. +if (( ${#EXTRA_ARGS[@]} )); then + CMD+=("${EXTRA_ARGS[@]}") +fi + +exec "${CMD[@]}" diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md new file mode 100644 index 0000000..0ffa116 --- /dev/null +++ b/scripts/AGENTS.md @@ -0,0 +1,56 @@ +# scripts/ + +## Bash ↔ PowerShell parity — keep them in sync + +Most contributor-facing scripts ship as a `.sh` + `.ps1` pair so macOS/Linux and Windows users get the same workflow. **When you edit one, edit the other in the same change.** Env-var names, defaults, flags, and behavior should match — if `start_services_dev.sh` reads `HEALTH_MAX_ATTEMPTS`, so should `start_services_dev.ps1`. + +Current pairs: + +- `setup_fork.{sh,ps1}` — contributor bootstrap (git remotes, submodule, venv, env files) +- `setup_requirements.{sh,ps1}` — Python + pipecat dependency install +- `start_services_dev.{sh,ps1}` — local backend launcher (auto-reload + health-check wait) +- `stop_services.{sh,ps1}` +- `makemigrate.{sh,ps1}` / `migrate.{sh,ps1}` — Alembic helpers +- `setup_local.{sh,ps1}` — OSS local Docker-compose setup (optional coturn/TURN) + +Bash-only (deployment / CI / OSS-user setup — not intended for Windows contributors): + +- `start_services.sh` — VM production +- `start_services_docker.sh` — Docker image CMD +- `rolling_update.sh` — zero-downtime VM redeploy +- `setup_remote.sh` — OSS remote Docker-compose setup +- `format.sh` / `lint.sh` / `pre_commit.sh` +- `generate_sdk.sh` / `release_sdks.sh` / `dump_docs_openapi.py` + +## Deployment Memory — current OSS Docker state + +This directory now has a shared deployment model for OSS Docker installs. If you touch any of the scripts below, assume they are coupled and review them together: + +- `scripts/lib/setup_common.sh` is the shared deployment helper library. It is sourced by `setup_local.sh`, `setup_remote.sh`, `update_remote.sh`, `setup_custom_domain.sh`, `run_dograh_init.sh`, and repo-root `remote_up.sh`. +- `setup_common.sh` must stay safe to source. It should not set shell options like `set -u` for callers. +- `.env` is the single operator-owned source of truth for remote deployment settings. Remote/runtime config should derive from it, not the other way around. +- Canonical remote keys in `.env`: `ENVIRONMENT`, `SERVER_IP`, `PUBLIC_HOST`, `PUBLIC_BASE_URL`, `BACKEND_API_ENDPOINT`, `MINIO_PUBLIC_ENDPOINT`, `TURN_HOST`, `TURN_SECRET`, `FASTAPI_WORKERS`, `OSS_JWT_SECRET`. +- `remote_up.sh` is the supported remote startup entrypoint. It runs preflight via `dograh_prepare_remote_install`, runs `docker compose config -q`, then starts the stack. +- `docker-compose.yaml` uses a one-shot `dograh-init` service for profiles `remote` and `local-turn`. +- `dograh-init` executes `scripts/run_dograh_init.sh`, which renders nginx/coturn runtime config into named volumes consumed by `nginx` and `coturn`. +- Remote nginx/coturn config is runtime-generated. Host-managed `nginx.conf` / `turnserver.conf` are legacy only; update flow may back them up and delete them, but current installs should not depend on them. +- `setup_remote.sh` writes `.env`, downloads the deployment helper bundle, generates self-signed certs, validates the init-based config, and tells operators to start via `./remote_up.sh` or `./remote_up.sh --build`. +- `update_remote.sh` is the migration/upgrade path for prebuilt remote installs. It refreshes `docker-compose.yaml`, `remote_up.sh`, `scripts/run_dograh_init.sh`, `scripts/lib/setup_common.sh`, and `deploy/templates/*`, backs up touched files, removes legacy host `nginx.conf` / `turnserver.conf`, and revalidates the init-based path. +- `setup_custom_domain.sh` is certificate/domain glue only. It must not own nginx config. It updates canonical public URL keys in `.env`, copies Let's Encrypt certs into `certs/`, installs renewal hook, and restarts through `./remote_up.sh`. +- `setup_local.{sh,ps1}` has an interactive `Enable coturn? [y/N]` prompt unless `ENABLE_COTURN` is preset. If coturn is enabled, it downloads the minimal helper bundle needed for `local-turn` (`setup_common.sh`, `run_dograh_init.sh`, templates) and relies on `dograh-init` to render coturn config. +- `setup_local.{sh,ps1}` must remain safe under unset env vars; use `${VAR:-}` guards in Bash and null/empty checks in PowerShell for optional inputs like `ENABLE_COTURN`, `TURN_HOST`, `TURN_SECRET`, `DOGRAH_SKIP_DOWNLOAD`. +- `run_dograh_init.sh` is an executable entrypoint, not a library. Compose runs it directly. If it ever gets refactored, keep the distinction between sourced helper logic (`lib/`) and executable entrypoints. +- `dograh_prepare_remote_install` in `setup_common.sh` currently does three things: sync canonical `.env` keys, reject legacy compose layouts that do not use `dograh-init`, and preflight the init render in a temp directory. +- `dograh_uses_init_compose_layout` / `dograh_require_init_compose_layout` are the guardrails for old installs. If a remote install still bind-mounts host `nginx.conf` / `turnserver.conf`, the intended fix path is `./update_remote.sh`. +- Templates live under `deploy/templates/`. `nginx.remote.conf.template` contains the static shape and `dograh_render_remote_nginx_conf` expands the multi-worker upstream block dynamically. `turnserver.remote.conf.template` is also rendered from env. +- If you rename/move any of these deployment files, update all of: bootstrap curl URLs inside scripts, helper-bundle download paths in `setup_common.sh`, backup lists in `update_remote.sh`, docs under `docs/deployment/`, and any existence checks in `setup_local.{sh,ps1}` / `setup_custom_domain.sh`. + +## The three "start" scripts — pick the right one + +| Script | Where it runs | Key behavior | +| -------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| `start_services_dev.sh` | Local dev shell | `uvicorn --reload`, exits after launching, restart by re-running, single arq worker, waits for `/api/v1/health` before exiting. | +| `start_services.sh` | VM production | Multi-port uvicorn behind nginx, `sudo nginx -t && systemctl reload`, writes `run/active_band` for `rolling_update.sh`. | +| `start_services_docker.sh` | Docker image `CMD` | PID 1: traps SIGTERM, uvicorn `--workers $FASTAPI_WORKERS`, `wait -n` so a dying child tears the container down. | + +If you find yourself adding nginx/sudo logic to the dev script, or `--reload` to the production/Docker scripts, stop — you probably want a different file. diff --git a/scripts/CLAUDE.md b/scripts/CLAUDE.md index 1c3e942..43c994c 100644 --- a/scripts/CLAUDE.md +++ b/scripts/CLAUDE.md @@ -1,30 +1 @@ -# scripts/ - -## Bash ↔ PowerShell parity — keep them in sync - -Most contributor-facing scripts ship as a `.sh` + `.ps1` pair so macOS/Linux and Windows users get the same workflow. **When you edit one, edit the other in the same change.** Env-var names, defaults, flags, and behavior should match — if `start_services_dev.sh` reads `HEALTH_MAX_ATTEMPTS`, so should `start_services_dev.ps1`. - -Current pairs: -- `setup_fork.{sh,ps1}` — contributor bootstrap (git remotes, submodule, venv, env files) -- `setup_requirements.{sh,ps1}` — Python + pipecat dependency install -- `start_services_dev.{sh,ps1}` — local backend launcher (auto-reload + health-check wait) -- `stop_services.{sh,ps1}` -- `makemigrate.{sh,ps1}` / `migrate.{sh,ps1}` — Alembic helpers - -Bash-only (deployment / CI / OSS-user setup — not intended for Windows contributors): -- `start_services.sh` — VM production -- `start_services_docker.sh` — Docker image CMD -- `rolling_update.sh` — zero-downtime VM redeploy -- `setup_local.sh` / `setup_remote.sh` — OSS Docker-compose setup -- `format.sh` / `lint.sh` / `pre_commit.sh` -- `generate_sdk.sh` / `release_sdks.sh` / `dump_docs_openapi.py` - -## The three "start" scripts — pick the right one - -| Script | Where it runs | Key behavior | -| ----------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `start_services_dev.sh` | Local dev shell | `uvicorn --reload`, exits after launching, restart by re-running, single arq worker, waits for `/api/v1/health` before exiting. | -| `start_services.sh` | VM production | Multi-port uvicorn behind nginx, `sudo nginx -t && systemctl reload`, writes `run/active_band` for `rolling_update.sh`. | -| `start_services_docker.sh` | Docker image `CMD` | PID 1: traps SIGTERM, uvicorn `--workers $FASTAPI_WORKERS`, `wait -n` so a dying child tears the container down. | - -If you find yourself adding nginx/sudo logic to the dev script, or `--reload` to the production/Docker scripts, stop — you probably want a different file. +@AGENTS.md diff --git a/scripts/format.sh b/scripts/format.sh index 93746b5..07de1ca 100755 --- a/scripts/format.sh +++ b/scripts/format.sh @@ -4,4 +4,6 @@ set -euo pipefail ruff check api --select I --select F401 --fix ruff format api +ruff format pipecat + (cd ui && npm run fix-lint) diff --git a/scripts/generate_sdk.sh b/scripts/generate_sdk.sh index da69408..56043d8 100755 --- a/scripts/generate_sdk.sh +++ b/scripts/generate_sdk.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Regenerate every file the SDKs derive from authoritative backend state: # -# 1. Typed node dataclasses / TS interfaces (from node_specs registry) +# 1. Typed node dataclasses / TS interfaces (from the model-backed +# node-spec registry) # 2. Filtered OpenAPI spec (routes tagged via @sdk_expose) # 3. Pydantic request/response models + TS interfaces (datamodel-codegen # / openapi-typescript) @@ -18,8 +19,9 @@ # is a devDependency of sdk/typescript; `npm install` in that dir is # done for you if node_modules is missing. # -# Invoked manually after editing any NodeSpec or after adding/removing an -# `@sdk_expose` decorator. CI runs this and asserts the git diff is empty. +# Invoked manually after editing workflow node models / node-spec metadata +# or after adding/removing an `@sdk_expose` decorator. CI runs this and +# asserts the git diff is empty. set -euo pipefail diff --git a/scripts/lib/setup_common.sh b/scripts/lib/setup_common.sh new file mode 100644 index 0000000..85758e6 --- /dev/null +++ b/scripts/lib/setup_common.sh @@ -0,0 +1,447 @@ +#!/usr/bin/env bash + +DOGRAH_DEPLOY_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DOGRAH_DEPLOY_REPO_ROOT="$(cd "$DOGRAH_DEPLOY_LIB_DIR/../.." 2>/dev/null && pwd || true)" + +: "${RED:=\033[0;31m}" +: "${GREEN:=\033[0;32m}" +: "${YELLOW:=\033[1;33m}" +: "${BLUE:=\033[0;34m}" +: "${NC:=\033[0m}" + +dograh_info() { + echo -e "${BLUE}$*${NC}" +} + +dograh_success() { + echo -e "${GREEN}$*${NC}" +} + +dograh_warn() { + echo -e "${YELLOW}$*${NC}" +} + +dograh_fail() { + echo -e "${RED}Error: $*${NC}" >&2 + exit 1 +} + +dograh_project_dir() { + if [[ -n "${DOGRAH_DEPLOY_PROJECT_DIR:-}" ]]; then + printf '%s\n' "$DOGRAH_DEPLOY_PROJECT_DIR" + else + pwd + fi +} + +dograh_template_path() { + local template_name=$1 + local candidate="" + local project_dir + + project_dir="$(dograh_project_dir)" + + for candidate in \ + "$project_dir/deploy/templates/$template_name" \ + "$DOGRAH_DEPLOY_REPO_ROOT/deploy/templates/$template_name" + do + if [[ -f "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + dograh_fail "Template '$template_name' not found" +} + +dograh_init_script_path() { + local candidate="" + local project_dir + + project_dir="$(dograh_project_dir)" + + for candidate in \ + "$project_dir/scripts/run_dograh_init.sh" \ + "$DOGRAH_DEPLOY_REPO_ROOT/scripts/run_dograh_init.sh" + do + if [[ -f "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + dograh_fail "run_dograh_init.sh not found" +} + +dograh_load_env_file() { + local env_file=${1:-.env} + + [[ -f "$env_file" ]] || dograh_fail "$env_file not found" + + set -a + # shellcheck disable=SC1090 + . "$env_file" + set +a +} + +dograh_host_from_url() { + local url=$1 + + url="${url#https://}" + url="${url#http://}" + url="${url%%/*}" + + printf '%s\n' "$url" +} + +dograh_is_ipv4() { + [[ "$1" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] +} + +dograh_is_local_ipv4() { + local ip=$1 + local o1 o2 o3 o4 octet + + dograh_is_ipv4 "$ip" || return 1 + IFS=. read -r o1 o2 o3 o4 <<< "$ip" + + for octet in "$o1" "$o2" "$o3" "$o4"; do + [[ "$octet" =~ ^[0-9]+$ ]] || return 1 + (( octet >= 0 && octet <= 255 )) || return 1 + done + + (( o1 == 10 )) && return 0 + (( o1 == 127 )) && return 0 + (( o1 == 169 && o2 == 254 )) && return 0 + (( o1 == 172 && o2 >= 16 && o2 <= 31 )) && return 0 + (( o1 == 192 && o2 == 168 )) && return 0 + (( o1 == 100 && o2 >= 64 && o2 <= 127 )) && return 0 + + return 1 +} + +dograh_infer_server_ip() { + local project_dir=${1:-$(dograh_project_dir)} + local turn_conf="$project_dir/turnserver.conf" + local ip="" + + if [[ -n "${SERVER_IP:-}" ]]; then + printf '%s\n' "$SERVER_IP" + return 0 + fi + + if [[ -f "$turn_conf" ]]; then + ip="$(sed -n 's/^external-ip=//p' "$turn_conf" | head -1)" + if [[ -n "$ip" ]]; then + printf '%s\n' "$ip" + return 0 + fi + fi + + if [[ -n "${TURN_HOST:-}" ]] && dograh_is_ipv4 "$TURN_HOST"; then + printf '%s\n' "$TURN_HOST" + return 0 + fi + + if [[ -n "${PUBLIC_HOST:-}" ]] && dograh_is_ipv4 "$PUBLIC_HOST"; then + printf '%s\n' "$PUBLIC_HOST" + return 0 + fi + + return 1 +} + +dograh_infer_public_base_url() { + if [[ -n "${PUBLIC_BASE_URL:-}" ]]; then + printf '%s\n' "${PUBLIC_BASE_URL%/}" + return 0 + fi + + if [[ -n "${BACKEND_API_ENDPOINT:-}" ]]; then + printf '%s\n' "${BACKEND_API_ENDPOINT%/}" + return 0 + fi + + if [[ -n "${PUBLIC_HOST:-}" ]]; then + printf 'https://%s\n' "$PUBLIC_HOST" + return 0 + fi + + if [[ -n "${SERVER_IP:-}" ]]; then + printf 'https://%s\n' "$SERVER_IP" + return 0 + fi + + return 1 +} + +dograh_infer_public_host() { + local public_base_url="" + + if [[ -n "${PUBLIC_HOST:-}" ]]; then + printf '%s\n' "$PUBLIC_HOST" + return 0 + fi + + public_base_url="$(dograh_infer_public_base_url 2>/dev/null || true)" + if [[ -n "$public_base_url" ]]; then + dograh_host_from_url "$public_base_url" + return 0 + fi + + if [[ -n "${TURN_HOST:-}" ]]; then + printf '%s\n' "$TURN_HOST" + return 0 + fi + + return 1 +} + +dograh_set_env_key() { + local env_file=$1 + local key=$2 + local value=$3 + local tmp_file="${env_file}.tmp.$$" + + awk -v key="$key" -v value="$value" ' + BEGIN { updated = 0 } + $0 ~ "^" key "=" { + print key "=" value + updated = 1 + next + } + { print } + END { + if (!updated) { + print key "=" value + } + } + ' "$env_file" > "$tmp_file" + + mv "$tmp_file" "$env_file" +} + +dograh_delete_env_key() { + local env_file=$1 + local key=$2 + local tmp_file="${env_file}.tmp.$$" + + awk -v key="$key" '$0 !~ "^" key "=" { print }' "$env_file" > "$tmp_file" + mv "$tmp_file" "$env_file" +} + +dograh_sync_remote_env_file() { + local env_file=${1:-.env} + local project_dir + local public_base_url="" + local public_host="" + local server_ip="" + + project_dir="$(cd "$(dirname "$env_file")" && pwd)" + dograh_load_env_file "$env_file" + + public_base_url="$(dograh_infer_public_base_url)" || dograh_fail "Could not determine PUBLIC_BASE_URL" + public_base_url="${public_base_url%/}" + public_host="$(dograh_infer_public_host)" || dograh_fail "Could not determine PUBLIC_HOST" + server_ip="$(dograh_infer_server_ip "$project_dir")" || dograh_fail "Could not determine SERVER_IP" + + [[ "$public_base_url" =~ ^https?:// ]] || dograh_fail "PUBLIC_BASE_URL must include http:// or https://" + dograh_is_ipv4 "$server_ip" || dograh_fail "SERVER_IP must be an IPv4 address (got: $server_ip)" + + dograh_set_env_key "$env_file" ENVIRONMENT "${ENVIRONMENT:-production}" + dograh_set_env_key "$env_file" SERVER_IP "$server_ip" + dograh_set_env_key "$env_file" PUBLIC_HOST "$public_host" + dograh_set_env_key "$env_file" PUBLIC_BASE_URL "$public_base_url" + dograh_set_env_key "$env_file" BACKEND_API_ENDPOINT "$public_base_url" + dograh_set_env_key "$env_file" MINIO_PUBLIC_ENDPOINT "$public_base_url" + dograh_set_env_key "$env_file" TURN_HOST "$public_host" +} + +dograh_validate_remote_runtime_env() { + [[ "${FASTAPI_WORKERS:-}" =~ ^[1-9][0-9]*$ ]] || dograh_fail "FASTAPI_WORKERS must be a positive integer" + [[ -n "${TURN_SECRET:-}" ]] || dograh_fail "TURN_SECRET is missing" + [[ -n "${PUBLIC_HOST:-}" ]] || dograh_fail "PUBLIC_HOST is missing" + [[ -n "${PUBLIC_BASE_URL:-}" ]] || dograh_fail "PUBLIC_BASE_URL is missing" + [[ -n "${BACKEND_API_ENDPOINT:-}" ]] || dograh_fail "BACKEND_API_ENDPOINT is missing" + [[ -n "${MINIO_PUBLIC_ENDPOINT:-}" ]] || dograh_fail "MINIO_PUBLIC_ENDPOINT is missing" + [[ -n "${TURN_HOST:-}" ]] || dograh_fail "TURN_HOST is missing" + dograh_is_ipv4 "${SERVER_IP:-}" || dograh_fail "SERVER_IP must be a valid IPv4 address" + [[ "${PUBLIC_BASE_URL}" =~ ^https?:// ]] || dograh_fail "PUBLIC_BASE_URL must include http:// or https://" + [[ "${BACKEND_API_ENDPOINT}" == "${PUBLIC_BASE_URL}" ]] || dograh_fail "BACKEND_API_ENDPOINT must match PUBLIC_BASE_URL" + [[ "${MINIO_PUBLIC_ENDPOINT}" == "${PUBLIC_BASE_URL}" ]] || dograh_fail "MINIO_PUBLIC_ENDPOINT must match PUBLIC_BASE_URL" + [[ "${TURN_HOST}" == "${PUBLIC_HOST}" ]] || dograh_fail "TURN_HOST must match PUBLIC_HOST" +} + +dograh_uses_init_compose_layout() { + local project_dir=${1:-$(dograh_project_dir)} + local compose_file="$project_dir/docker-compose.yaml" + + [[ -f "$compose_file" ]] || return 1 + grep -q "dograh-init:" "$compose_file" \ + && grep -q "nginx-generated:/etc/nginx/conf.d:ro" "$compose_file" \ + && grep -q "coturn-generated:/etc/coturn:ro" "$compose_file" +} + +dograh_require_init_compose_layout() { + local project_dir=${1:-$(dograh_project_dir)} + + if ! dograh_uses_init_compose_layout "$project_dir"; then + dograh_fail "This install uses the legacy remote compose layout. Run ./update_remote.sh first so Docker uses dograh-init generated config." + fi +} + +dograh_render_remote_nginx_conf() { + local project_dir=${1:-$(dograh_project_dir)} + local destination=${2:-"$project_dir/nginx.conf"} + local template="" + local tmp_upstream="" + + template="$(dograh_template_path "nginx.remote.conf.template")" + tmp_upstream="$(mktemp)" + + { + echo "# Backend API workers - one uvicorn process per port, balanced by least_conn." + echo "# Auto-generated by Dograh remote config renderer. Do not edit manually." + echo "upstream dograh_api {" + echo " least_conn;" + for ((i=0; i "$tmp_upstream" + + awk -v public_host="$PUBLIC_HOST" -v upstream_file="$tmp_upstream" ' + BEGIN { + while ((getline line < upstream_file) > 0) { + upstream = upstream line ORS + } + close(upstream_file) + } + { + gsub(/__DOGRAH_PUBLIC_HOST__/, public_host) + if ($0 == "__DOGRAH_UPSTREAM_BLOCK__") { + printf "%s", upstream + } else { + print + } + } + ' "$template" > "$destination" + + rm -f "$tmp_upstream" +} + +dograh_render_remote_turn_conf() { + local project_dir=${1:-$(dograh_project_dir)} + local destination=${2:-"$project_dir/turnserver.conf"} + local template="" + local external_ip="${TURN_EXTERNAL_IP:-${SERVER_IP:-}}" + + template="$(dograh_template_path "turnserver.remote.conf.template")" + [[ -n "$external_ip" ]] || dograh_fail "TURN external IP/host is missing" + + awk \ + -v external_ip="$external_ip" \ + -v turn_secret="$TURN_SECRET" \ + ' + { + gsub(/__DOGRAH_TURN_EXTERNAL_IP__/, external_ip) + gsub(/__DOGRAH_TURN_SECRET__/, turn_secret) + print + } + ' "$template" > "$destination" +} + +dograh_preflight_remote_init_render() { + local project_dir=${1:-$(dograh_project_dir)} + local env_file="$project_dir/.env" + local cert_dir="$project_dir/certs" + local init_script="" + local tmp_root="" + local nginx_conf="" + local turn_conf="" + local nginx_workers=0 + local rendered_secret="" + local rendered_ip="" + local rendered_server_name="" + + dograh_load_env_file "$env_file" + dograh_validate_remote_runtime_env + [[ -f "$cert_dir/local.crt" ]] || dograh_fail "certs/local.crt not found" + [[ -f "$cert_dir/local.key" ]] || dograh_fail "certs/local.key not found" + + init_script="$(dograh_init_script_path)" + tmp_root="$(mktemp -d)" + nginx_conf="$tmp_root/nginx/default.conf" + turn_conf="$tmp_root/coturn/turnserver.conf" + + ( + export ENVIRONMENT SERVER_IP PUBLIC_HOST PUBLIC_BASE_URL BACKEND_API_ENDPOINT MINIO_PUBLIC_ENDPOINT TURN_HOST TURN_SECRET FASTAPI_WORKERS + export DOGRAH_INIT_WORKSPACE_DIR="$project_dir" + export DOGRAH_INIT_OUTPUT_ROOT="$tmp_root" + export DOGRAH_INIT_CERTS_DIR="$cert_dir" + bash "$init_script" >/dev/null + ) + + [[ -f "$nginx_conf" ]] || dograh_fail "dograh-init did not render nginx config" + [[ -f "$turn_conf" ]] || dograh_fail "dograh-init did not render coturn config" + + nginx_workers=$(awk '/^[[:space:]]*server api:[0-9]+/ { count += 1 } END { print count + 0 }' "$nginx_conf") + [[ "$nginx_workers" -eq "$FASTAPI_WORKERS" ]] || dograh_fail "FASTAPI_WORKERS=$FASTAPI_WORKERS but nginx.conf has $nginx_workers upstream servers" + + rendered_server_name="$(awk '/^[[:space:]]*server_name / { print $2; exit }' "$nginx_conf" | sed 's/;$//')" + [[ "$rendered_server_name" == "$PUBLIC_HOST" ]] || dograh_fail "nginx.conf server_name ($rendered_server_name) does not match PUBLIC_HOST ($PUBLIC_HOST)" + + rendered_secret="$(sed -n 's/^static-auth-secret=//p' "$turn_conf" | head -1)" + [[ "$rendered_secret" == "$TURN_SECRET" ]] || dograh_fail "TURN_SECRET in .env does not match turnserver.conf" + + rendered_ip="$(sed -n 's/^external-ip=//p' "$turn_conf" | head -1)" + [[ "$rendered_ip" == "$SERVER_IP" ]] || dograh_fail "SERVER_IP in .env does not match turnserver.conf" + + rm -rf "$tmp_root" +} + +dograh_prepare_remote_install() { + local project_dir=${1:-$(dograh_project_dir)} + local env_file="$project_dir/.env" + + dograh_sync_remote_env_file "$env_file" + dograh_require_init_compose_layout "$project_dir" + dograh_preflight_remote_init_render "$project_dir" +} + +dograh_download_bundle_file_for_ref() { + local destination=$1 + local remote_path=$2 + local ref=${3:-main} + local raw_base="https://raw.githubusercontent.com/dograh-hq/dograh/$ref" + local fallback_base="https://raw.githubusercontent.com/dograh-hq/dograh/main" + + if ! curl -fsSL -o "$destination" "$raw_base/$remote_path"; then + dograh_warn "Warning: '$remote_path' not found at '$ref' - falling back to main" + curl -fsSL -o "$destination" "$fallback_base/$remote_path" + fi +} + +dograh_download_init_support_bundle() { + local project_dir=$1 + local ref=${2:-main} + + mkdir -p "$project_dir/scripts/lib" "$project_dir/deploy/templates" + + mkdir -p "$project_dir/scripts" + dograh_download_bundle_file_for_ref "$project_dir/scripts/lib/setup_common.sh" "scripts/lib/setup_common.sh" "$ref" + dograh_download_bundle_file_for_ref "$project_dir/scripts/run_dograh_init.sh" "scripts/run_dograh_init.sh" "$ref" + chmod +x "$project_dir/scripts/run_dograh_init.sh" + dograh_download_bundle_file_for_ref "$project_dir/deploy/templates/nginx.remote.conf.template" "deploy/templates/nginx.remote.conf.template" "$ref" + dograh_download_bundle_file_for_ref "$project_dir/deploy/templates/turnserver.remote.conf.template" "deploy/templates/turnserver.remote.conf.template" "$ref" +} + +dograh_download_remote_support_bundle() { + local project_dir=$1 + local ref=${2:-main} + + dograh_download_bundle_file_for_ref "$project_dir/remote_up.sh" "remote_up.sh" "$ref" + chmod +x "$project_dir/remote_up.sh" + dograh_download_init_support_bundle "$project_dir" "$ref" +} diff --git a/scripts/run_dograh_init.sh b/scripts/run_dograh_init.sh new file mode 100755 index 0000000..3637f86 --- /dev/null +++ b/scripts/run_dograh_init.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORKSPACE_DIR="${DOGRAH_INIT_WORKSPACE_DIR:-/workspace}" +OUTPUT_ROOT="${DOGRAH_INIT_OUTPUT_ROOT:-/generated}" +NGINX_OUTPUT_DIR="$OUTPUT_ROOT/nginx" +COTURN_OUTPUT_DIR="$OUTPUT_ROOT/coturn" +CERTS_DIR="${DOGRAH_INIT_CERTS_DIR:-/certs}" + +# shellcheck disable=SC1091 +. "$SCRIPT_DIR/lib/setup_common.sh" + +DOGRAH_DEPLOY_PROJECT_DIR="$WORKSPACE_DIR" + +mkdir -p "$NGINX_OUTPUT_DIR" "$COTURN_OUTPUT_DIR" + +if [[ "${ENVIRONMENT:-local}" == "production" ]]; then + dograh_validate_remote_runtime_env + [[ -f "$CERTS_DIR/local.crt" ]] || dograh_fail "certs/local.crt not found" + [[ -f "$CERTS_DIR/local.key" ]] || dograh_fail "certs/local.key not found" + + export TURN_EXTERNAL_IP="$SERVER_IP" + dograh_render_remote_nginx_conf "$WORKSPACE_DIR" "$NGINX_OUTPUT_DIR/default.conf" + dograh_render_remote_turn_conf "$WORKSPACE_DIR" "$COTURN_OUTPUT_DIR/turnserver.conf" + dograh_success "✓ dograh-init rendered remote nginx and coturn config" + exit 0 +fi + +if [[ -n "${TURN_SECRET:-}" && -n "${TURN_HOST:-}" ]]; then + export TURN_EXTERNAL_IP="$TURN_HOST" + dograh_render_remote_turn_conf "$WORKSPACE_DIR" "$COTURN_OUTPUT_DIR/turnserver.conf" + dograh_success "✓ dograh-init rendered local TURN config" + exit 0 +fi + +dograh_success "✓ dograh-init no-op for current profile" diff --git a/scripts/setup_custom_domain.sh b/scripts/setup_custom_domain.sh index fec11be..d3d3c78 100755 --- a/scripts/setup_custom_domain.sh +++ b/scripts/setup_custom_domain.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -e +set -euo pipefail # Colors for output RED='\033[0;31m' @@ -8,6 +8,26 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_PATH="$SCRIPT_DIR/lib/setup_common.sh" +BOOTSTRAP_LIB="" + +if [[ ! -f "$LIB_PATH" ]]; then + BOOTSTRAP_LIB="$(mktemp)" + curl -fsSL -o "$BOOTSTRAP_LIB" "https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/lib/setup_common.sh" + LIB_PATH="$BOOTSTRAP_LIB" +fi + +cleanup() { + if [[ -n "$BOOTSTRAP_LIB" ]]; then + rm -f "$BOOTSTRAP_LIB" + fi +} +trap cleanup EXIT + +# shellcheck disable=SC1090 +. "$LIB_PATH" + echo -e "${BLUE}" echo "╔══════════════════════════════════════════════════════════════╗" echo "║ Dograh Custom Domain Setup ║" @@ -15,13 +35,10 @@ echo "║ Automated Let's Encrypt SSL certificate setup ║" echo "╚══════════════════════════════════════════════════════════════╝" echo -e "${NC}" -# Check if running as root or with sudo if [[ $EUID -ne 0 ]]; then - echo -e "${RED}Error: This script must be run as root or with sudo${NC}" - exit 1 + dograh_fail "This script must be run as root or with sudo" fi -# Check if dograh directory exists if [[ ! -d "dograh" ]]; then echo -e "${RED}Error: 'dograh' directory not found.${NC}" echo -e "${YELLOW}Please run this script from the directory containing your Dograh installation.${NC}" @@ -30,29 +47,17 @@ if [[ ! -d "dograh" ]]; then exit 1 fi -# Get the domain name echo -e "${YELLOW}Enter your domain name (e.g., voice.yourcompany.com):${NC}" read -p "> " DOMAIN_NAME +[[ -n "$DOMAIN_NAME" ]] || dograh_fail "Domain name cannot be empty" -if [[ -z "$DOMAIN_NAME" ]]; then - echo -e "${RED}Error: Domain name cannot be empty${NC}" - exit 1 -fi - -# Basic domain validation if ! [[ "$DOMAIN_NAME" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$ ]]; then - echo -e "${RED}Error: Invalid domain name format${NC}" - exit 1 + dograh_fail "Invalid domain name format" fi -# Get email for Let's Encrypt notifications echo -e "${YELLOW}Enter your email address for SSL certificate notifications:${NC}" read -p "> " EMAIL_ADDRESS - -if [[ -z "$EMAIL_ADDRESS" ]]; then - echo -e "${RED}Error: Email address cannot be empty (required by Let's Encrypt)${NC}" - exit 1 -fi +[[ -n "$EMAIL_ADDRESS" ]] || dograh_fail "Email address cannot be empty (required by Let's Encrypt)" echo "" echo -e "${GREEN}Configuration:${NC}" @@ -60,13 +65,12 @@ echo -e " Domain: ${BLUE}$DOMAIN_NAME${NC}" echo -e " Email: ${BLUE}$EMAIL_ADDRESS${NC}" echo "" -# Verify DNS is pointing to this server echo -e "${BLUE}[1/7] Verifying DNS configuration...${NC}" -SERVER_IP=$(curl -s ifconfig.me || curl -s icanhazip.com || echo "") -RESOLVED_IP=$(dig +short "$DOMAIN_NAME" | tail -1) +SERVER_IP="$(curl -s ifconfig.me || curl -s icanhazip.com || echo "")" +RESOLVED_IP="$(dig +short "$DOMAIN_NAME" | tail -1)" if [[ -z "$SERVER_IP" ]]; then - echo -e "${YELLOW}Warning: Could not detect server's public IP${NC}" + dograh_warn "Warning: Could not detect server's public IP" elif [[ "$RESOLVED_IP" != "$SERVER_IP" ]]; then echo -e "${YELLOW}Warning: Domain '$DOMAIN_NAME' resolves to '$RESOLVED_IP' but this server's IP is '$SERVER_IP'${NC}" echo -e "${YELLOW}Make sure your DNS A record points to this server before proceeding.${NC}" @@ -80,7 +84,6 @@ else echo -e "${GREEN}✓ DNS is correctly configured (${RESOLVED_IP})${NC}" fi -# Detect package manager and install certbot echo -e "${BLUE}[2/7] Installing Certbot...${NC}" if command -v apt-get &> /dev/null; then apt-get update -qq @@ -90,14 +93,20 @@ elif command -v yum &> /dev/null; then elif command -v dnf &> /dev/null; then dnf install -y -q certbot else - echo -e "${RED}Error: Could not detect package manager. Please install certbot manually.${NC}" - exit 1 + dograh_fail "Could not detect package manager. Please install certbot manually." fi echo -e "${GREEN}✓ Certbot installed${NC}" -# Stop Dograh services to free port 80 echo -e "${BLUE}[3/7] Stopping Dograh services...${NC}" cd dograh +DOGRAH_DEPLOY_PROJECT_DIR="$(pwd)" + +if [[ ! -f remote_up.sh || ! -f scripts/lib/setup_common.sh ]]; then + dograh_download_remote_support_bundle "$(pwd)" "main" +fi + +dograh_require_init_compose_layout "$(pwd)" + if docker compose --profile remote ps --quiet 2>/dev/null | grep -q .; then docker compose --profile remote down echo -e "${GREEN}✓ Dograh services stopped${NC}" @@ -105,7 +114,6 @@ else echo -e "${YELLOW}⚠ No running services found${NC}" fi -# Generate SSL certificate echo -e "${BLUE}[4/7] Generating Let's Encrypt SSL certificate...${NC}" CERTBOT_OUTPUT=$(certbot certonly --standalone \ --non-interactive \ @@ -115,7 +123,6 @@ CERTBOT_OUTPUT=$(certbot certonly --standalone \ echo -e "${RED}✗ Certificate generation failed${NC}" echo "" - # Check for common errors and provide helpful hints if echo "$CERTBOT_OUTPUT" | grep -qi "timeout\|firewall\|connection"; then echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}" echo -e "${YELLOW} Port 80 appears to be blocked by a firewall.${NC}" @@ -123,14 +130,6 @@ CERTBOT_OUTPUT=$(certbot certonly --standalone \ echo "" echo -e "Let's Encrypt needs to connect to port 80 to verify domain ownership." echo "" - echo -e "${BLUE}If using AWS EC2:${NC}" - echo " 1. Go to AWS Console → EC2 → Security Groups" - echo " 2. Find the security group for your instance" - echo " 3. Add inbound rule: HTTP | TCP | Port 80 | 0.0.0.0/0" - echo "" - echo -e "${BLUE}If using another cloud provider:${NC}" - echo " • Ensure port 80 (TCP) is open for inbound traffic from all sources" - echo "" elif echo "$CERTBOT_OUTPUT" | grep -qi "too many\|rate.limit"; then echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}" echo -e "${YELLOW} Let's Encrypt rate limit reached.${NC}" @@ -160,161 +159,60 @@ CERTBOT_OUTPUT=$(certbot certonly --standalone \ } echo -e "${GREEN}✓ SSL certificate generated${NC}" -# Verify and display certificate location CERT_PATH="/etc/letsencrypt/live/$DOMAIN_NAME" echo "" echo -e "${BLUE}Certificate location:${NC}" echo -e " ${CERT_PATH}/" -if [[ -f "$CERT_PATH/fullchain.pem" ]]; then - echo -e " ${GREEN}✓${NC} fullchain.pem exists" -else - echo -e " ${RED}✗${NC} fullchain.pem NOT FOUND" -fi -if [[ -f "$CERT_PATH/privkey.pem" ]]; then - echo -e " ${GREEN}✓${NC} privkey.pem exists" -else - echo -e " ${RED}✗${NC} privkey.pem NOT FOUND" -fi +[[ -f "$CERT_PATH/fullchain.pem" ]] && echo -e " ${GREEN}✓${NC} fullchain.pem exists" || echo -e " ${RED}✗${NC} fullchain.pem NOT FOUND" +[[ -f "$CERT_PATH/privkey.pem" ]] && echo -e " ${GREEN}✓${NC} privkey.pem exists" || echo -e " ${RED}✗${NC} privkey.pem NOT FOUND" echo "" -# Copy certificates to dograh/certs directory -cp /etc/letsencrypt/archive/$DOMAIN_NAME/fullchain1.pem certs/local.crt -cp /etc/letsencrypt/archive/$DOMAIN_NAME/privkey1.pem certs/local.key +mkdir -p certs +cp "$CERT_PATH/fullchain.pem" certs/local.crt +cp "$CERT_PATH/privkey.pem" certs/local.key chmod 644 certs/local.crt certs/local.key echo -e "${GREEN}✓${NC} Certificates copied to certs/ directory" echo "" -# Update nginx.conf -echo -e "${BLUE}[5/7] Updating nginx configuration...${NC}" -cat > nginx.conf << NGINX_EOF -server { - listen 80; - server_name $DOMAIN_NAME; +echo -e "${BLUE}[5/7] Updating canonical remote settings and validating init-based config...${NC}" +dograh_load_env_file .env - # Redirect all HTTP to HTTPS - return 301 https://\$host\$request_uri; -} - -server { - listen 443 ssl; - server_name $DOMAIN_NAME; - - ssl_certificate /etc/nginx/certs/local.crt; - ssl_certificate_key /etc/nginx/certs/local.key; - - # TLS settings - ssl_protocols TLSv1.2 TLSv1.3; - ssl_prefer_server_ciphers on; - ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; - - # Backend API and WebSockets — bypass the UI, go straight to api:8000 - location /api/v1/ { - proxy_pass http://api:8000; - proxy_http_version 1.1; - - proxy_set_header Upgrade \$http_upgrade; - proxy_set_header Connection "upgrade"; - - proxy_set_header Host \$host; - proxy_set_header X-Real-IP \$remote_addr; - proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto https; - - # Long-lived WebSockets (audio streaming, signaling) - proxy_read_timeout 3600s; - proxy_send_timeout 3600s; - - # Don't buffer streamed responses - proxy_buffering off; - client_max_body_size 100M; - } - - location / { - proxy_pass http://ui:3010; - proxy_http_version 1.1; - - # Important for WebSockets / hot reload etc. - proxy_set_header Upgrade \$http_upgrade; - proxy_set_header Connection "upgrade"; - - proxy_set_header Host \$host; - proxy_set_header X-Real-IP \$remote_addr; - proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto https; - - # Rewrite localhost MinIO URLs in API responses to use current domain - sub_filter 'http://localhost:9000/voice-audio/' 'https://\$host/voice-audio/'; - sub_filter_once off; - sub_filter_types application/json text/html; - } - - location /voice-audio/ { - proxy_pass http://minio:9000/voice-audio/; - - proxy_http_version 1.1; - - # Headers for file downloads from MinIO - proxy_set_header Host \$host; - proxy_set_header X-Real-IP \$remote_addr; - proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto https; - - # Allow large file downloads - proxy_buffering off; - client_max_body_size 100M; - } -} -NGINX_EOF -echo -e "${GREEN}✓ nginx.conf updated${NC}" - -# Update .env file with domain name -echo -e "${BLUE}[6/8] Updating environment variables...${NC}" -if [[ -f ".env" ]]; then - # Update BACKEND_API_ENDPOINT to use domain (public URL the backend advertises) - sed -i.bak "s|^BACKEND_API_ENDPOINT=.*|BACKEND_API_ENDPOINT=https://$DOMAIN_NAME|" .env - # Drop any stale BACKEND_URL override — the ui container should use the - # internal Docker URL (http://api:8000) from docker-compose defaults. - sed -i.bak "/^BACKEND_URL=/d" .env - sed -i.bak "/^# Backend URL for UI$/d" .env - # Update TURN_HOST to use domain - sed -i.bak "s|^TURN_HOST=.*|TURN_HOST=$DOMAIN_NAME|" .env - # Update MINIO_PUBLIC_ENDPOINT to use domain (browsers fetch /voice-audio/* here) - if grep -q "^MINIO_PUBLIC_ENDPOINT=" .env; then - sed -i.bak "s|^MINIO_PUBLIC_ENDPOINT=.*|MINIO_PUBLIC_ENDPOINT=https://$DOMAIN_NAME|" .env - else - echo "MINIO_PUBLIC_ENDPOINT=https://$DOMAIN_NAME" >> .env - fi - rm -f .env.bak - echo -e "${GREEN}✓ .env updated with domain name${NC}" -else - echo -e "${YELLOW}⚠ .env file not found - skipping environment update${NC}" +if [[ -z "${SERVER_IP:-}" ]]; then + SERVER_IP="$(dograh_infer_server_ip "$(pwd)" || true)" fi -# Setup auto-renewal -echo -e "${BLUE}[7/8] Setting up automatic certificate renewal...${NC}" -DOGRAH_PATH=$(pwd) +[[ -n "${SERVER_IP:-}" ]] || dograh_fail "Could not determine SERVER_IP from the existing install" + +dograh_set_env_key .env SERVER_IP "$SERVER_IP" +dograh_set_env_key .env PUBLIC_HOST "$DOMAIN_NAME" +dograh_set_env_key .env PUBLIC_BASE_URL "https://$DOMAIN_NAME" +dograh_delete_env_key .env BACKEND_URL +dograh_prepare_remote_install "$(pwd)" +echo -e "${GREEN}✓ .env synchronized and init-based config validated${NC}" + +echo -e "${BLUE}[6/7] Setting up automatic certificate renewal...${NC}" +DOGRAH_PATH="$(pwd)" -# Create renewal hook script that copies new certificates and restarts nginx cat > /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh << HOOK_EOF #!/bin/bash -# Copy renewed certificates to dograh certs directory -cp /etc/letsencrypt/archive/$DOMAIN_NAME/fullchain1.pem $DOGRAH_PATH/certs/local.crt -cp /etc/letsencrypt/archive/$DOMAIN_NAME/privkey1.pem $DOGRAH_PATH/certs/local.key +cp /etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem $DOGRAH_PATH/certs/local.crt +cp /etc/letsencrypt/live/$DOMAIN_NAME/privkey.pem $DOGRAH_PATH/certs/local.key chmod 644 $DOGRAH_PATH/certs/local.crt $DOGRAH_PATH/certs/local.key -# Restart nginx to load new certificates cd $DOGRAH_PATH docker compose --profile remote restart nginx 2>/dev/null || true HOOK_EOF chmod +x /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh -# Test renewal -certbot renew --dry-run --quiet && echo -e "${GREEN}✓ Auto-renewal configured and tested${NC}" || echo -e "${YELLOW}⚠ Auto-renewal test had issues, but certificates are installed${NC}" +if certbot renew --dry-run --quiet; then + echo -e "${GREEN}✓ Auto-renewal configured and tested${NC}" +else + echo -e "${YELLOW}⚠ Auto-renewal test had issues, but certificates are installed${NC}" +fi -# Start Dograh services echo "" -echo -e "${BLUE}[8/8] Starting Dograh services...${NC}" -docker compose --profile remote up -d --pull always +echo -e "${BLUE}[7/7] Starting Dograh services through validated startup wrapper...${NC}" +./remote_up.sh echo "" echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}" @@ -331,8 +229,7 @@ echo -e " Private Key: $DOGRAH_PATH/certs/local.key" echo -e " Auto-renewal: Enabled (certificates renew automatically)" echo "" echo -e "${YELLOW}Files modified:${NC}" -echo " - dograh/nginx.conf (updated with domain name)" -echo " - dograh/.env (BACKEND_API_ENDPOINT and TURN_HOST updated)" +echo " - dograh/.env (canonical public host/base URL updated)" echo " - dograh/certs/local.crt (SSL certificate)" echo " - dograh/certs/local.key (SSL private key)" echo " - /etc/letsencrypt/renewal-hooks/deploy/dograh-reload.sh (renewal hook)" diff --git a/scripts/setup_local.ps1 b/scripts/setup_local.ps1 new file mode 100644 index 0000000..2958f30 --- /dev/null +++ b/scripts/setup_local.ps1 @@ -0,0 +1,301 @@ +#!/usr/bin/env pwsh + +$ErrorActionPreference = 'Stop' + +function Write-Info([string]$Message) { + Write-Host $Message -ForegroundColor Blue +} + +function Write-Success([string]$Message) { + Write-Host $Message -ForegroundColor Green +} + +function Write-Warn([string]$Message) { + Write-Host $Message -ForegroundColor Yellow +} + +function Fail([string]$Message) { + Write-Host "Error: $Message" -ForegroundColor Red + exit 1 +} + +function Test-IsEnabled([string]$Value) { + return $Value -eq 'true' +} + +function New-HexSecret([int]$ByteCount) { + $buffer = [byte[]]::new($ByteCount) + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { + $rng.GetBytes($buffer) + } finally { + $rng.Dispose() + } + return ($buffer | ForEach-Object { $_.ToString('x2') }) -join '' +} + +function Read-SecretValue([string]$Prompt) { + $readHostCommand = Get-Command Read-Host + if ($readHostCommand.Parameters.ContainsKey('MaskInput')) { + return Read-Host $Prompt -MaskInput + } + + $secureValue = Read-Host $Prompt -AsSecureString + $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue) + try { + return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + } finally { + [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } +} + +function Get-DefaultLanIPv4 { + try { + $routes = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop | + Sort-Object -Property RouteMetric, InterfaceMetric + + foreach ($route in $routes) { + $candidate = Get-NetIPAddress -AddressFamily IPv4 -InterfaceIndex $route.InterfaceIndex -ErrorAction Stop | + Where-Object { + $_.IPAddress -ne '127.0.0.1' -and + -not $_.IPAddress.StartsWith('169.254.') + } | + Select-Object -First 1 -ExpandProperty IPAddress + + if ($candidate) { + return $candidate + } + } + } catch { + # Fall back to generic interface enumeration below. + } + + try { + $interfaces = [System.Net.NetworkInformation.NetworkInterface]::GetAllNetworkInterfaces() | + Where-Object { + $_.OperationalStatus -eq [System.Net.NetworkInformation.OperationalStatus]::Up -and + $_.NetworkInterfaceType -ne [System.Net.NetworkInformation.NetworkInterfaceType]::Loopback + } + + foreach ($iface in $interfaces) { + foreach ($unicast in $iface.GetIPProperties().UnicastAddresses) { + if ($unicast.Address.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork) { + continue + } + + $candidate = $unicast.Address.IPAddressToString + if ($candidate -and $candidate -ne '127.0.0.1' -and -not $candidate.StartsWith('169.254.')) { + return $candidate + } + } + } + } catch { + return $null + } + + return $null +} + +function Download-File([string]$Url, [string]$Destination) { + $parent = Split-Path -Parent $Destination + if ($parent) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + + $params = @{ + Uri = $Url + OutFile = $Destination + ErrorAction = 'Stop' + } + + $invokeWebRequest = Get-Command Invoke-WebRequest + if ($invokeWebRequest.Parameters.ContainsKey('UseBasicParsing')) { + $params.UseBasicParsing = $true + } + + Invoke-WebRequest @params +} + +function Download-BundleFileForRef([string]$Destination, [string]$RemotePath, [string]$Ref) { + $rawBase = "https://raw.githubusercontent.com/dograh-hq/dograh/$Ref" + $fallbackBase = 'https://raw.githubusercontent.com/dograh-hq/dograh/main' + + try { + Download-File "$rawBase/$RemotePath" $Destination + } catch { + if ($Ref -eq 'main') { + throw + } + + Write-Warn "Warning: '$RemotePath' not found at '$Ref' - falling back to main" + Download-File "$fallbackBase/$RemotePath" $Destination + } +} + +function Download-InitSupportBundle([string]$ProjectDir, [string]$Ref) { + Download-BundleFileForRef (Join-Path $ProjectDir 'scripts/lib/setup_common.sh') 'scripts/lib/setup_common.sh' $Ref + Download-BundleFileForRef (Join-Path $ProjectDir 'scripts/run_dograh_init.sh') 'scripts/run_dograh_init.sh' $Ref + Download-BundleFileForRef (Join-Path $ProjectDir 'deploy/templates/nginx.remote.conf.template') 'deploy/templates/nginx.remote.conf.template' $Ref + Download-BundleFileForRef (Join-Path $ProjectDir 'deploy/templates/turnserver.remote.conf.template') 'deploy/templates/turnserver.remote.conf.template' $Ref +} + +function Assert-PathExists([string]$Path, [string]$Message) { + if (-not (Test-Path $Path)) { + Fail $Message + } +} + +Write-Info '' +Write-Info '╔══════════════════════════════════════════════════════════════╗' +Write-Info '║ Dograh Local Setup ║' +Write-Info '║ Local docker deployment, optional TURN server ║' +Write-Info '╚══════════════════════════════════════════════════════════════╝' +Write-Info '' + +if ([string]::IsNullOrEmpty($env:ENABLE_COTURN)) { + Write-Warn 'Enable coturn (TURN server) for WebRTC NAT traversal? [y/N]:' + $enableCoturnInput = Read-Host '>' + if ($enableCoturnInput -match '^[Yy]') { + $EnableCoturn = 'true' + } else { + $EnableCoturn = 'false' + } +} else { + $EnableCoturn = $env:ENABLE_COTURN +} + +$UseCoturn = Test-IsEnabled $EnableCoturn +$TurnHost = $env:TURN_HOST +$TurnSecret = $env:TURN_SECRET +$ForceTurnRelay = if ([string]::IsNullOrEmpty($env:FORCE_TURN_RELAY)) { 'false' } else { $env:FORCE_TURN_RELAY } + +if ($UseCoturn) { + $defaultTurnHost = Get-DefaultLanIPv4 + if ([string]::IsNullOrEmpty($defaultTurnHost)) { + $defaultTurnHost = '127.0.0.1' + } + + if ([string]::IsNullOrEmpty($TurnHost)) { + Write-Warn 'Enter the host browsers AND the API container will use to reach TURN' + Write-Warn "(press Enter for $defaultTurnHost):" + $TurnHost = Read-Host '>' + } + if ([string]::IsNullOrEmpty($TurnHost)) { + $TurnHost = $defaultTurnHost + } + + if ($TurnHost -notmatch '^[A-Za-z0-9.-]+$') { + Fail 'TURN host must be an IP address or hostname' + } + + if ([string]::IsNullOrEmpty($TurnSecret)) { + Write-Warn 'Enter a shared secret for the TURN server (press Enter to generate a random one):' + $TurnSecret = Read-SecretValue '>' + Write-Host '' + } + + if ([string]::IsNullOrEmpty($TurnSecret)) { + $TurnSecret = New-HexSecret 32 + Write-Info 'Generated random TURN secret' + } +} + +$EnableTelemetry = if ([string]::IsNullOrEmpty($env:ENABLE_TELEMETRY)) { 'true' } else { $env:ENABLE_TELEMETRY } +$Registry = if ([string]::IsNullOrEmpty($env:REGISTRY)) { 'ghcr.io/dograh-hq' } else { $env:REGISTRY } + +Write-Host '' +Write-Success 'Configuration:' +Write-Host " Coturn: $EnableCoturn" -ForegroundColor Blue +if ($UseCoturn) { + Write-Host " TURN Host: $TurnHost" -ForegroundColor Blue + Write-Host ' TURN Secret: ********' -ForegroundColor Blue + Write-Host " Force relay: $ForceTurnRelay" -ForegroundColor Blue +} +Write-Host " Telemetry: $EnableTelemetry" -ForegroundColor Blue +Write-Host " Registry: $Registry" -ForegroundColor Blue +Write-Host '' + +$TotalSteps = 2 +$CurrentDir = (Get-Location).Path + +if ($env:DOGRAH_SKIP_DOWNLOAD -ne '1') { + if ($UseCoturn) { + Write-Info "[1/$TotalSteps] Downloading docker-compose.yaml and TURN helper bundle..." + } else { + Write-Info "[1/$TotalSteps] Downloading docker-compose.yaml..." + } + + Download-File 'https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml' (Join-Path $CurrentDir 'docker-compose.yaml') + if ($UseCoturn) { + Download-InitSupportBundle $CurrentDir 'main' + } + + Write-Success '✓ Deployment files downloaded' +} else { + Write-Info "[1/$TotalSteps] Using docker-compose.yaml in current directory" +} + +if ($UseCoturn) { + Assert-PathExists 'scripts/run_dograh_init.sh' 'scripts/run_dograh_init.sh not found. Re-run setup_local.ps1 without DOGRAH_SKIP_DOWNLOAD=1, or use a full repo checkout.' + Assert-PathExists 'scripts/lib/setup_common.sh' 'scripts/lib/setup_common.sh not found. Re-run setup_local.ps1 without DOGRAH_SKIP_DOWNLOAD=1, or use a full repo checkout.' + Assert-PathExists 'deploy/templates/turnserver.remote.conf.template' 'deploy/templates/turnserver.remote.conf.template not found. Re-run setup_local.ps1 without DOGRAH_SKIP_DOWNLOAD=1, or use a full repo checkout.' +} + +Write-Info "[2/$TotalSteps] Creating environment file..." +$ossJwtSecret = New-HexSecret 32 + +$envLines = @( + '# Container registry for Dograh images' + "REGISTRY=$Registry" + '' + '# JWT secret for OSS authentication' + "OSS_JWT_SECRET=$ossJwtSecret" + '' + '# Telemetry (set to false to disable)' + "ENABLE_TELEMETRY=$EnableTelemetry" + '' + '# Relay-only ICE candidates for explicit TURN diagnostics' + "FORCE_TURN_RELAY=$ForceTurnRelay" +) + +if ($UseCoturn) { + $envLines += @( + '' + '# TURN Server Configuration (time-limited credentials via TURN REST API)' + "TURN_HOST=$TurnHost" + "TURN_SECRET=$TurnSecret" + ) +} + +$envContent = ($envLines -join [Environment]::NewLine) + [Environment]::NewLine +[System.IO.File]::WriteAllText((Join-Path $CurrentDir '.env'), $envContent, [System.Text.UTF8Encoding]::new($false)) +Write-Success '✓ .env file created' + +Write-Host '' +Write-Success '╔══════════════════════════════════════════════════════════════╗' +Write-Success '║ Setup Complete! ║' +Write-Success '╚══════════════════════════════════════════════════════════════╝' +Write-Host '' +Write-Host "Files created in $CurrentDir:" -ForegroundColor Blue +Write-Host ' - docker-compose.yaml' +Write-Host ' - .env' +if ($UseCoturn) { + Write-Host ' - scripts/run_dograh_init.sh' + Write-Host ' - scripts/lib/setup_common.sh' + Write-Host ' - deploy/templates/' +} +Write-Host '' +if ($UseCoturn) { + Write-Warn 'To start Dograh with TURN, run:' + Write-Host '' + Write-Host ' docker compose --profile local-turn up --pull always' -ForegroundColor Blue +} else { + Write-Warn 'To start Dograh, run:' + Write-Host '' + Write-Host ' docker compose up --pull always' -ForegroundColor Blue +} +Write-Host '' +Write-Warn 'Your application will be available at:' +Write-Host '' +Write-Host ' http://localhost:3010' -ForegroundColor Blue +Write-Host '' diff --git a/scripts/setup_local.sh b/scripts/setup_local.sh index 86a0b71..674185e 100755 --- a/scripts/setup_local.sh +++ b/scripts/setup_local.sh @@ -8,6 +8,26 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_PATH="$SCRIPT_DIR/lib/setup_common.sh" +BOOTSTRAP_LIB="" + +if [[ ! -f "$LIB_PATH" ]]; then + BOOTSTRAP_LIB="$(mktemp)" + curl -fsSL -o "$BOOTSTRAP_LIB" "https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/lib/setup_common.sh" + LIB_PATH="$BOOTSTRAP_LIB" +fi + +cleanup() { + if [[ -n "$BOOTSTRAP_LIB" ]]; then + rm -f "$BOOTSTRAP_LIB" + fi +} +trap cleanup EXIT + +# shellcheck disable=SC1090 +. "$LIB_PATH" + echo -e "${BLUE}" echo "╔══════════════════════════════════════════════════════════════╗" echo "║ Dograh Local Setup ║" @@ -16,7 +36,7 @@ echo "╚═══════════════════════ echo -e "${NC}" # Ask whether to enable coturn (skip prompt if ENABLE_COTURN is already set) -if [[ -z "$ENABLE_COTURN" ]]; then +if [[ -z "${ENABLE_COTURN:-}" ]]; then echo -e "${YELLOW}Enable coturn (TURN server) for WebRTC NAT traversal? [y/N]:${NC}" read -p "> " ENABLE_COTURN_INPUT if [[ "$ENABLE_COTURN_INPUT" =~ ^[Yy] ]]; then @@ -26,12 +46,12 @@ if [[ -z "$ENABLE_COTURN" ]]; then fi fi -if [[ "$ENABLE_COTURN" == "true" ]]; then +if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then # Pick a TURN_HOST that's reachable from BOTH the browser (running on the # host) and the API container (running in docker). 127.0.0.1 is tempting # but doesn't work for the api container — its own loopback isn't where - # coturn lives, so aiortc can't allocate a relay and FORCE_TURN_RELAY - # ends up with an empty answer SDP. The host's LAN IP works for both. + # coturn lives, so aiortc can't allocate a relay. The host's LAN IP works + # for both. detect_lan_ip() { local ip="" if command -v ipconfig >/dev/null 2>&1; then @@ -48,13 +68,15 @@ if [[ "$ENABLE_COTURN" == "true" ]]; then ip=$(hostname -I 2>/dev/null | awk '{print $1}') [[ -n "$ip" ]] && { echo "$ip"; return; } fi + + return 0 } DEFAULT_TURN_HOST="$(detect_lan_ip)" DEFAULT_TURN_HOST="${DEFAULT_TURN_HOST:-127.0.0.1}" # Get the host browsers/peers will use to reach the TURN server - if [[ -z "$TURN_HOST" ]]; then + if [[ -z "${TURN_HOST:-}" ]]; then echo -e "${YELLOW}Enter the host browsers AND the API container will use to reach TURN${NC}" echo -e "${YELLOW}(press Enter for ${DEFAULT_TURN_HOST}):${NC}" read -p "> " TURN_HOST @@ -68,18 +90,20 @@ if [[ "$ENABLE_COTURN" == "true" ]]; then fi # Get the TURN secret (skip prompt if TURN_SECRET is already set) - if [[ -z "$TURN_SECRET" ]]; then + if [[ -z "${TURN_SECRET:-}" ]]; then echo -e "${YELLOW}Enter a shared secret for the TURN server (press Enter to generate a random one):${NC}" read -sp "> " TURN_SECRET echo "" fi - if [[ -z "$TURN_SECRET" ]]; then + if [[ -z "${TURN_SECRET:-}" ]]; then TURN_SECRET=$(openssl rand -hex 32) echo -e "${BLUE}Generated random TURN secret${NC}" fi fi +FORCE_TURN_RELAY="${FORCE_TURN_RELAY:-false}" + # Telemetry opt-out (default: true) ENABLE_TELEMETRY="${ENABLE_TELEMETRY:-true}" @@ -88,10 +112,11 @@ REGISTRY="${REGISTRY:-ghcr.io/dograh-hq}" echo "" echo -e "${GREEN}Configuration:${NC}" -echo -e " Coturn: ${BLUE}$ENABLE_COTURN${NC}" -if [[ "$ENABLE_COTURN" == "true" ]]; then +echo -e " Coturn: ${BLUE}${ENABLE_COTURN:-false}${NC}" +if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then echo -e " TURN Host: ${BLUE}$TURN_HOST${NC}" echo -e " TURN Secret: ${BLUE}********${NC}" + echo -e " Force relay: ${BLUE}$FORCE_TURN_RELAY${NC}" fi echo -e " Telemetry: ${BLUE}$ENABLE_TELEMETRY${NC}" echo -e " Registry: ${BLUE}$REGISTRY${NC}" @@ -99,52 +124,26 @@ echo "" # Download compose file (skip when DOGRAH_SKIP_DOWNLOAD=1 — e.g. local repo testing). TOTAL_STEPS=2 -if [[ "$ENABLE_COTURN" == "true" ]]; then - TOTAL_STEPS=3 -fi -if [[ "$DOGRAH_SKIP_DOWNLOAD" != "1" ]]; then - echo -e "${BLUE}[1/$TOTAL_STEPS] Downloading docker-compose.yaml...${NC}" +if [[ "${DOGRAH_SKIP_DOWNLOAD:-}" != "1" ]]; then + if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then + echo -e "${BLUE}[1/$TOTAL_STEPS] Downloading docker-compose.yaml and TURN helper bundle...${NC}" + else + echo -e "${BLUE}[1/$TOTAL_STEPS] Downloading docker-compose.yaml...${NC}" + fi curl -sS -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml - echo -e "${GREEN}✓ docker-compose.yaml downloaded${NC}" + if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then + dograh_download_init_support_bundle "$(pwd)" "main" + fi + echo -e "${GREEN}✓ Deployment files downloaded${NC}" else echo -e "${BLUE}[1/$TOTAL_STEPS] Using docker-compose.yaml in current directory${NC}" fi -# Generate turnserver.conf if coturn is enabled -if [[ "$ENABLE_COTURN" == "true" ]]; then - echo -e "${BLUE}[2/$TOTAL_STEPS] Creating TURN server configuration...${NC}" - cat > turnserver.conf << TURN_EOF -# Coturn TURN Server - Docker Configuration (local) -# Auto-generated by setup_local.sh - -# Listener ports -listening-port=3478 -tls-listening-port=5349 - -# Relay port range -min-port=49152 -max-port=49200 - -# Network - external IP for NAT traversal -external-ip=$TURN_HOST - -# Realm -realm=dograh.com - -# Authentication (TURN REST API with time-limited credentials) -use-auth-secret -static-auth-secret=$TURN_SECRET - -# Security -fingerprint -no-cli -no-multicast-peers - -# Logging -log-file=stdout -TURN_EOF - echo -e "${GREEN}✓ turnserver.conf created${NC}" +if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then + [[ -f scripts/run_dograh_init.sh ]] || dograh_fail "scripts/run_dograh_init.sh not found. Re-run setup_local.sh without DOGRAH_SKIP_DOWNLOAD=1, or use a full repo checkout." + [[ -f scripts/lib/setup_common.sh ]] || dograh_fail "scripts/lib/setup_common.sh not found. Re-run setup_local.sh without DOGRAH_SKIP_DOWNLOAD=1, or use a full repo checkout." + [[ -f deploy/templates/turnserver.remote.conf.template ]] || dograh_fail "deploy/templates/turnserver.remote.conf.template not found. Re-run setup_local.sh without DOGRAH_SKIP_DOWNLOAD=1, or use a full repo checkout." fi # Generate .env @@ -161,9 +160,12 @@ OSS_JWT_SECRET=$OSS_JWT_SECRET # Telemetry (set to false to disable) ENABLE_TELEMETRY=$ENABLE_TELEMETRY + +# Relay-only ICE candidates for explicit TURN diagnostics +FORCE_TURN_RELAY=$FORCE_TURN_RELAY ENV_EOF -if [[ "$ENABLE_COTURN" == "true" ]]; then +if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then cat >> .env << ENV_EOF # TURN Server Configuration (time-limited credentials via TURN REST API) @@ -181,11 +183,13 @@ echo "" echo -e "Files created in ${BLUE}$(pwd)${NC}:" echo " - docker-compose.yaml" echo " - .env" -if [[ "$ENABLE_COTURN" == "true" ]]; then - echo " - turnserver.conf" +if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then + echo " - scripts/run_dograh_init.sh" + echo " - scripts/lib/setup_common.sh" + echo " - deploy/templates/" fi echo "" -if [[ "$ENABLE_COTURN" == "true" ]]; then +if [[ "${ENABLE_COTURN:-false}" == "true" ]]; then echo -e "${YELLOW}To start Dograh with TURN, run:${NC}" echo "" echo -e " ${BLUE}docker compose --profile local-turn up --pull always${NC}" diff --git a/scripts/setup_pipecat.sh b/scripts/setup_pipecat.sh new file mode 100755 index 0000000..04821b0 --- /dev/null +++ b/scripts/setup_pipecat.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +# Setup script for using pipecat as a git submodule + +# Get the project root directory (parent of scripts) +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +DOGRAH_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$DOGRAH_DIR" + +echo "Setting up pipecat as a git submodule..." + +# Initialize and update submodules +echo "Initializing git submodules..." +git submodule update --init --recursive + +# Install other requirements first so pipecat submodule wins any version conflicts +echo "Installing dograh API requirements..." +pip install -r api/requirements.txt + +# Install pipecat from submodule last so it overrides any pipecat-ai pulled in by dependencies +echo "Installing pipecat dependencies..." +pip install -e ./pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb] + +echo "Setup complete! Pipecat is now available as a git submodule." \ No newline at end of file diff --git a/scripts/setup_remote.sh b/scripts/setup_remote.sh index 334a516..d958b69 100755 --- a/scripts/setup_remote.sh +++ b/scripts/setup_remote.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -e +set -euo pipefail # Colors for output RED='\033[0;31m' @@ -8,6 +8,26 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_PATH="$SCRIPT_DIR/lib/setup_common.sh" +BOOTSTRAP_LIB="" + +if [[ ! -f "$LIB_PATH" ]]; then + BOOTSTRAP_LIB="$(mktemp)" + curl -fsSL -o "$BOOTSTRAP_LIB" "https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/lib/setup_common.sh" + LIB_PATH="$BOOTSTRAP_LIB" +fi + +cleanup() { + if [[ -n "$BOOTSTRAP_LIB" ]]; then + rm -f "$BOOTSTRAP_LIB" + fi +} +trap cleanup EXIT + +# shellcheck disable=SC1090 +. "$LIB_PATH" + echo -e "${BLUE}" echo "╔══════════════════════════════════════════════════════════════╗" echo "║ Dograh Remote Setup ║" @@ -16,24 +36,23 @@ echo "╚═══════════════════════ echo -e "${NC}" # Get the public IP address (skip prompt if SERVER_IP is already set) -if [[ -z "$SERVER_IP" ]]; then +if [[ -z "${SERVER_IP:-}" ]]; then echo -e "${YELLOW}Enter your server's public IP address:${NC}" read -p "> " SERVER_IP fi if [[ -z "$SERVER_IP" ]]; then - echo -e "${RED}Error: IP address cannot be empty${NC}" - exit 1 + dograh_fail "IP address cannot be empty" fi -# Validate IP address format (basic validation) -if ! [[ "$SERVER_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo -e "${RED}Error: Invalid IP address format${NC}" - exit 1 +if ! dograh_is_ipv4 "$SERVER_IP"; then + dograh_fail "Invalid IP address format" fi +FORCE_TURN_RELAY="${FORCE_TURN_RELAY:-false}" + # Get the TURN secret (skip prompt if TURN_SECRET is already set) -if [[ -z "$TURN_SECRET" ]]; then +if [[ -z "${TURN_SECRET:-}" ]]; then echo -e "${YELLOW}Enter a shared secret for the TURN server (press Enter to generate a random one):${NC}" read -sp "> " TURN_SECRET echo "" @@ -45,10 +64,8 @@ if [[ -z "$TURN_SECRET" ]]; then fi # Deployment mode. Skip prompt if DEPLOY_MODE is already set. Non-interactive -# callers (cloud-init, CI, terraform) without a TTY default to "prebuilt" so -# existing automation keeps working without changes - explicitly set -# DEPLOY_MODE=build to opt into source builds from a non-interactive context. -if [[ -z "$DEPLOY_MODE" ]]; then +# callers without a TTY default to "prebuilt" to keep automation stable. +if [[ -z "${DEPLOY_MODE:-}" ]]; then if [[ -t 0 ]]; then echo "" echo -e "${YELLOW}Deployment mode:${NC}" @@ -58,19 +75,16 @@ if [[ -z "$DEPLOY_MODE" ]]; then mode_choice="${mode_choice:-1}" case "$mode_choice" in 1|prebuilt) DEPLOY_MODE="prebuilt" ;; - 2|build) DEPLOY_MODE="build" ;; - *) echo -e "${RED}Error: invalid choice '$mode_choice'${NC}"; exit 1 ;; + 2|build) DEPLOY_MODE="build" ;; + *) dograh_fail "invalid choice '$mode_choice'" ;; esac else DEPLOY_MODE="prebuilt" fi fi -# Build mode needs source code - either use existing repo or clone fresh. -# Same TTY rule: prompt interactively, otherwise pick sensible defaults so -# automation that sets DEPLOY_MODE=build doesn't need to spell everything out. if [[ "$DEPLOY_MODE" == "build" ]]; then - if [[ -z "$REPO_SOURCE" ]]; then + if [[ -z "${REPO_SOURCE:-}" ]]; then if [[ -d ".git" ]] && [[ -f "docker-compose.yaml" ]]; then if [[ -t 0 ]]; then echo "" @@ -91,7 +105,7 @@ if [[ "$DEPLOY_MODE" == "build" ]]; then fi if [[ "$REPO_SOURCE" == "clone" ]]; then - if [[ -z "$FORK_REPO" ]]; then + if [[ -z "${FORK_REPO:-}" ]]; then if [[ -t 0 ]]; then echo "" echo -e "${YELLOW}GitHub repo to clone (format: owner/name):${NC}" @@ -101,7 +115,8 @@ if [[ "$DEPLOY_MODE" == "build" ]]; then FORK_REPO="dograh-hq/dograh" fi fi - if [[ -z "$BRANCH" ]]; then + + if [[ -z "${BRANCH:-}" ]]; then if [[ -t 0 ]]; then echo -e "${YELLOW}Branch:${NC}" read -p "[main]: " BRANCH @@ -113,13 +128,9 @@ if [[ "$DEPLOY_MODE" == "build" ]]; then fi fi -# Telemetry opt-out (default: true) ENABLE_TELEMETRY="${ENABLE_TELEMETRY:-true}" +FASTAPI_WORKERS="${FASTAPI_WORKERS:-}" -# Number of uvicorn worker processes. Each runs as its own process on a -# distinct port (8000, 8001, ...) and nginx balances across them with -# least_conn. Better than uvicorn --workers for long-lived WebSocket -# connections, which would otherwise stick to whichever worker accepted them. if [[ -z "$FASTAPI_WORKERS" ]]; then if [[ -t 0 ]]; then echo "" @@ -131,26 +142,15 @@ if [[ -z "$FASTAPI_WORKERS" ]]; then fi fi -if ! [[ "$FASTAPI_WORKERS" =~ ^[1-9][0-9]*$ ]]; then - echo -e "${RED}Error: FASTAPI_WORKERS must be a positive integer (got: $FASTAPI_WORKERS)${NC}" - exit 1 -fi +[[ "$FASTAPI_WORKERS" =~ ^[1-9][0-9]*$ ]] || dograh_fail "FASTAPI_WORKERS must be a positive integer (got: $FASTAPI_WORKERS)" -# Where setup artifacts (.env, certs, nginx.conf, etc.) will land. Build mode -# with an existing repo writes them next to docker-compose.yaml in cwd; -# everything else writes into a fresh dograh/ subdirectory. -if [[ "$DEPLOY_MODE" == "build" && "$REPO_SOURCE" == "existing" ]]; then +if [[ "$DEPLOY_MODE" == "build" && "${REPO_SOURCE:-}" == "existing" ]]; then TARGET_DIR="." else TARGET_DIR="dograh" fi -# Refuse to overwrite an existing install - re-running this script would -# regenerate OSS_JWT_SECRET (invalidating logged-in sessions), reset the -# TURN secret (breaking WebRTC auth), and overwrite nginx.conf customizations. -# Set DOGRAH_FORCE_OVERWRITE=1 to bypass; DOGRAH_SKIP_DOWNLOAD=1 (used by e2e) -# also bypasses since those flows manage state themselves. -if [[ "$DOGRAH_FORCE_OVERWRITE" != "1" && "$DOGRAH_SKIP_DOWNLOAD" != "1" ]]; then +if [[ "${DOGRAH_FORCE_OVERWRITE:-}" != "1" && "${DOGRAH_SKIP_DOWNLOAD:-}" != "1" ]]; then if [[ -f "$TARGET_DIR/.env" ]]; then if [[ "$TARGET_DIR" == "." ]]; then existing_path="$(pwd)/.env" @@ -164,7 +164,7 @@ if [[ "$DOGRAH_FORCE_OVERWRITE" != "1" && "$DOGRAH_SKIP_DOWNLOAD" != "1" ]]; the echo -e "${RED}Refusing to continue - re-running setup would:${NC}" echo -e "${RED} - overwrite .env (invalidates sessions, breaks TURN auth)${NC}" echo -e "${RED} - regenerate SSL certificates${NC}" - echo -e "${RED} - reset nginx.conf and turnserver.conf customizations${NC}" + echo -e "${RED} - replace the validated remote deployment bundle${NC}" echo "" echo -e "${BLUE}To upgrade an existing install, follow:${NC}" echo -e " ${BLUE}https://docs.dograh.com/deployment/update${NC}" @@ -176,11 +176,10 @@ if [[ "$DOGRAH_FORCE_OVERWRITE" != "1" && "$DOGRAH_SKIP_DOWNLOAD" != "1" ]]; the fi fi -# Total step count depends on mode (build adds the override-file step) if [[ "$DEPLOY_MODE" == "build" ]]; then - TOTAL=7 -else TOTAL=6 +else + TOTAL=5 fi echo "" @@ -188,26 +187,23 @@ echo -e "${GREEN}Configuration:${NC}" echo -e " Server IP: ${BLUE}$SERVER_IP${NC}" echo -e " TURN Secret: ${BLUE}********${NC}" echo -e " Deploy mode: ${BLUE}$DEPLOY_MODE${NC}" +echo -e " Force TURN relay: ${BLUE}$FORCE_TURN_RELAY${NC}" echo -e " FastAPI workers: ${BLUE}$FASTAPI_WORKERS${NC} (ports 8000..$((8000 + FASTAPI_WORKERS - 1)))" if [[ "$DEPLOY_MODE" == "build" ]]; then - if [[ "$REPO_SOURCE" == "clone" ]]; then - echo -e " Source: ${BLUE}clone $FORK_REPO@$BRANCH${NC}" + if [[ "${REPO_SOURCE:-}" == "clone" ]]; then + echo -e " Source: ${BLUE}clone $FORK_REPO@$BRANCH${NC}" else - echo -e " Source: ${BLUE}existing repo at $(pwd)${NC}" + echo -e " Source: ${BLUE}existing repo at $(pwd)${NC}" fi fi echo "" -# Step 1: get the source - either the standalone compose file (prebuilt mode) -# or the full repo (build mode). Skip the download/clone when -# DOGRAH_SKIP_DOWNLOAD=1 (e.g. e2e tests that already have everything in place). if [[ "$DEPLOY_MODE" == "build" ]]; then - if [[ "$DOGRAH_SKIP_DOWNLOAD" == "1" ]]; then + if [[ "${DOGRAH_SKIP_DOWNLOAD:-}" == "1" ]]; then echo -e "${BLUE}[1/$TOTAL] Using existing repo in current directory${NC}" - elif [[ "$REPO_SOURCE" == "clone" ]]; then + elif [[ "${REPO_SOURCE:-}" == "clone" ]]; then if [[ -e "dograh" ]]; then - echo -e "${RED}Error: 'dograh' directory already exists. Remove it or re-run with REPO_SOURCE=existing from inside it.${NC}" - exit 1 + dograh_fail "'dograh' directory already exists. Remove it or re-run with REPO_SOURCE=existing from inside it." fi echo -e "${BLUE}[1/$TOTAL] Cloning $FORK_REPO (branch: $BRANCH)...${NC}" git clone --branch "$BRANCH" --recurse-submodules "https://github.com/$FORK_REPO.git" dograh @@ -217,123 +213,26 @@ if [[ "$DEPLOY_MODE" == "build" ]]; then echo -e "${BLUE}[1/$TOTAL] Using existing repo at $(pwd)${NC}" fi else - if [[ "$DOGRAH_SKIP_DOWNLOAD" != "1" ]]; then + if [[ "${DOGRAH_SKIP_DOWNLOAD:-}" != "1" ]]; then mkdir -p dograh 2>/dev/null || true cd dograh - echo -e "${BLUE}[1/$TOTAL] Downloading docker-compose.yaml...${NC}" - curl -sS -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml - echo -e "${GREEN}✓ docker-compose.yaml downloaded${NC}" + echo -e "${BLUE}[1/$TOTAL] Downloading deployment bundle...${NC}" + curl -fsSL -o docker-compose.yaml "https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml" + dograh_download_remote_support_bundle "$(pwd)" "main" + echo -e "${GREEN}✓ Deployment bundle downloaded${NC}" else - echo -e "${BLUE}[1/$TOTAL] Using docker-compose.yaml in current directory${NC}" + echo -e "${BLUE}[1/$TOTAL] Using deployment files in current directory${NC}" fi fi -echo -e "${BLUE}[2/$TOTAL] Creating nginx.conf...${NC}" -# Build the upstream block first (needs shell interpolation for the server -# lines), then append the static server blocks via a quoted heredoc. The -# SERVER_IP_PLACEHOLDER gets replaced by sed below. -{ - echo "# Backend API workers — one uvicorn process per port, balanced by least_conn." - echo "# Generated by setup_remote.sh; regenerate to change worker count." - echo "upstream dograh_api {" - echo " least_conn;" - for ((i=0; i nginx.conf - -# Replace placeholder with actual IP -sed -i.bak "s/SERVER_IP_PLACEHOLDER/$SERVER_IP/g" nginx.conf && rm -f nginx.conf.bak -echo -e "${GREEN}✓ nginx.conf created${NC}" - -echo -e "${BLUE}[3/$TOTAL] Creating SSL certificate generation script...${NC}" +echo -e "${BLUE}[2/$TOTAL] Creating SSL certificate generation script...${NC}" cat > generate_certificate.sh << CERT_EOF #!/bin/bash mkdir -p certs @@ -346,50 +245,22 @@ CERT_EOF chmod +x generate_certificate.sh echo -e "${GREEN}✓ generate_certificate.sh created${NC}" -echo -e "${BLUE}[4/$TOTAL] Generating SSL certificates...${NC}" +echo -e "${BLUE}[3/$TOTAL] Generating SSL certificates...${NC}" ./generate_certificate.sh echo -e "${GREEN}✓ SSL certificates generated${NC}" -echo -e "${BLUE}[5/$TOTAL] Creating TURN server configuration...${NC}" -cat > turnserver.conf << TURN_EOF -# Coturn TURN Server - Docker Configuration -# Auto-generated by setup_remote.sh - -# Listener ports -listening-port=3478 -tls-listening-port=5349 - -# Relay port range -min-port=49152 -max-port=49200 - -# Network - external IP for NAT traversal -external-ip=$SERVER_IP - -# Realm -realm=dograh.com - -# Authentication (TURN REST API with time-limited credentials) -use-auth-secret -static-auth-secret=$TURN_SECRET - -# Security -fingerprint -no-cli -no-multicast-peers - -# Logging -log-file=stdout -TURN_EOF -echo -e "${GREEN}✓ turnserver.conf created${NC}" - -echo -e "${BLUE}[6/$TOTAL] Creating environment file...${NC}" +echo -e "${BLUE}[4/$TOTAL] Creating environment file...${NC}" OSS_JWT_SECRET=$(openssl rand -hex 32) cat > .env << ENV_EOF -# Change environment from local to production so that coturn filters local IPs +# Remote deployments run with production signaling and HTTPS defaults ENVIRONMENT=production +# Canonical public host/base URL for this install. +SERVER_IP=$SERVER_IP +PUBLIC_HOST=$SERVER_IP +PUBLIC_BASE_URL=https://$SERVER_IP + # Backend API endpoint (public URL the backend uses to build webhook/embed links) BACKEND_API_ENDPOINT=https://$SERVER_IP @@ -399,6 +270,8 @@ MINIO_PUBLIC_ENDPOINT=https://$SERVER_IP # TURN Server Configuration (time-limited credentials via TURN REST API) TURN_HOST=$SERVER_IP TURN_SECRET=$TURN_SECRET +# Relay-only ICE candidates for explicit TURN diagnostics +FORCE_TURN_RELAY=$FORCE_TURN_RELAY # JWT secret for OSS authentication OSS_JWT_SECRET=$OSS_JWT_SECRET @@ -407,18 +280,16 @@ OSS_JWT_SECRET=$OSS_JWT_SECRET ENABLE_TELEMETRY=$ENABLE_TELEMETRY # Number of uvicorn worker processes; nginx load-balances across them -# (ports 8000..$((8000 + FASTAPI_WORKERS - 1))) with least_conn. -# Must match the upstream block in nginx.conf — re-run setup_remote.sh -# (with DOGRAH_FORCE_OVERWRITE=1) to change. FASTAPI_WORKERS=$FASTAPI_WORKERS ENV_EOF echo -e "${GREEN}✓ .env file created${NC}" -# In build mode, write the override file that swaps prebuilt images for -# local builds. Compose auto-loads docker-compose.override.yaml, so no -f flag -# is needed at runtime. +echo -e "${BLUE}[5/$TOTAL] Validating remote init configuration...${NC}" +dograh_prepare_remote_install "$(pwd)" +echo -e "${GREEN}✓ Remote init configuration validated${NC}" + if [[ "$DEPLOY_MODE" == "build" ]]; then - echo -e "${BLUE}[7/$TOTAL] Creating docker-compose.override.yaml...${NC}" + echo -e "${BLUE}[6/$TOTAL] Creating docker-compose.override.yaml...${NC}" cat > docker-compose.override.yaml << 'OVERRIDE_EOF' # Auto-generated by setup_remote.sh (build mode). # Overrides docker-compose.yaml to build api and ui images from local source @@ -452,8 +323,9 @@ echo " - docker-compose.yaml" if [[ "$DEPLOY_MODE" == "build" ]]; then echo " - docker-compose.override.yaml (build directives)" fi -echo " - nginx.conf" -echo " - turnserver.conf" +echo " - remote_up.sh" +echo " - scripts/run_dograh_init.sh" +echo " - deploy/templates/" echo " - generate_certificate.sh" echo " - certs/local.crt" echo " - certs/local.key" @@ -461,28 +333,17 @@ echo " - .env" echo "" echo -e "${YELLOW}To start Dograh, run:${NC}" echo "" -# The script's own cd into dograh/ doesn't persist to the user's shell, so -# remind them to cd themselves — except when they're already there (build mode -# with REPO_SOURCE=existing, which writes into cwd). -if [[ "$DEPLOY_MODE" != "build" || "$REPO_SOURCE" != "existing" ]]; then +if [[ "$DEPLOY_MODE" != "build" || "${REPO_SOURCE:-}" != "existing" ]]; then echo -e " ${BLUE}cd $(pwd)${NC}" fi if [[ "$DEPLOY_MODE" == "build" ]]; then - echo -e " ${BLUE}sudo docker compose --profile remote up -d --build${NC}" + echo -e " ${BLUE}./remote_up.sh --build${NC}" echo "" echo -e "${YELLOW}A docker-compose.override.yaml has been created alongside${NC}" echo -e "${YELLOW}docker-compose.yaml. Compose auto-loads it, so no -f flag is${NC}" echo -e "${YELLOW}needed — it swaps the prebuilt images for local builds.${NC}" - echo "" - echo -e "${YELLOW}The first build can take several minutes${NC}" - echo -e "${YELLOW}(downloading base images, installing dependencies).${NC}" - echo -e "${YELLOW}If you know how to speed this up, we would love a pull request.${NC}" - echo "" - echo -e "${YELLOW}To rebuild after editing api/ or ui/ code:${NC}" - echo "" - echo -e " ${BLUE}sudo docker compose --profile remote build && sudo docker compose --profile remote up -d${NC}" else - echo -e " ${BLUE}sudo docker compose --profile remote up --pull always${NC}" + echo -e " ${BLUE}./remote_up.sh${NC}" fi echo "" echo -e "${YELLOW}Your application will be available at:${NC}" diff --git a/scripts/setup_requirements.sh b/scripts/setup_requirements.sh index f38ed38..201b952 100755 --- a/scripts/setup_requirements.sh +++ b/scripts/setup_requirements.sh @@ -51,7 +51,7 @@ fi # Install pipecat in editable mode with all extras echo "Installing pipecat dependencies..." -pip install -e ./pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb] +pip install -e ./pipecat[cartesia,deepgram,openai,elevenlabs,groq,google,azure,sarvam,soundfile,silero,webrtc,speechmatics,openrouter,camb,mcp] if [ "$DEV_MODE" -eq 1 ]; then echo "Installing pipecat dev dependencies..." diff --git a/scripts/update_remote.sh b/scripts/update_remote.sh index 45c2802..119439b 100755 --- a/scripts/update_remote.sh +++ b/scripts/update_remote.sh @@ -1,5 +1,5 @@ #!/bin/bash -set -e +set -euo pipefail # Colors for output RED='\033[0;31m' @@ -8,34 +8,39 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_PATH="$SCRIPT_DIR/lib/setup_common.sh" +BOOTSTRAP_LIB="" + +if [[ ! -f "$LIB_PATH" ]]; then + BOOTSTRAP_LIB="$(mktemp)" + curl -fsSL -o "$BOOTSTRAP_LIB" "https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/lib/setup_common.sh" + LIB_PATH="$BOOTSTRAP_LIB" +fi + +cleanup() { + if [[ -n "$BOOTSTRAP_LIB" ]]; then + rm -f "$BOOTSTRAP_LIB" + fi +} +trap cleanup EXIT + +# shellcheck disable=SC1090 +. "$LIB_PATH" + REPO="dograh-hq/dograh" TIMESTAMP=$(date +%Y%m%d-%H%M%S) echo -e "${BLUE}" echo "╔══════════════════════════════════════════════════════════════╗" echo "║ Dograh Remote Update ║" -echo "║ Refresh host-side configs and pin api/ui image versions ║" +echo "║ Refresh deployment files and validate runtime config ║" echo "╚══════════════════════════════════════════════════════════════╝" echo -e "${NC}" -# Refuse outside an install — nothing to update if these aren't here. -if [[ ! -f docker-compose.yaml ]]; then - echo -e "${RED}Error: docker-compose.yaml not found in $(pwd)${NC}" - echo -e "${RED}Run this script from your Dograh install directory${NC}" - echo -e "${RED}(the 'dograh/' folder created by setup_remote.sh).${NC}" - exit 1 -fi +[[ -f docker-compose.yaml ]] || dograh_fail "docker-compose.yaml not found in $(pwd)" +[[ -f .env ]] || dograh_fail ".env not found in $(pwd)" -if [[ ! -f .env ]]; then - echo -e "${RED}Error: .env not found in $(pwd)${NC}" - echo -e "${RED}This script updates an existing install — there is nothing here to update.${NC}" - echo -e "${RED}For a fresh install, see https://docs.dograh.com/deployment/docker${NC}" - exit 1 -fi - -# Build-mode installs update via git, not via this script. The presence of an -# override file is the definitive marker (created by setup_remote.sh in build -# mode and not in prebuilt mode). if [[ -f docker-compose.override.yaml ]]; then echo -e "${YELLOW}Build-mode install detected (docker-compose.override.yaml present).${NC}" echo "" @@ -44,61 +49,47 @@ if [[ -f docker-compose.override.yaml ]]; then echo -e " ${BLUE}git fetch${NC}" echo -e " ${BLUE}git checkout # or: git pull${NC}" echo -e " ${BLUE}git submodule update --init --recursive${NC}" - echo -e " ${BLUE}sudo docker compose --profile remote build${NC}" - echo -e " ${BLUE}sudo docker compose --profile remote up -d${NC}" + echo -e " ${BLUE}./remote_up.sh --build${NC}" echo "" echo -e "${YELLOW}See https://docs.dograh.com/deployment/update#updating-a-source-build${NC}" exit 1 fi -############################################################################### -### Discover existing config from .env -############################################################################### +_caller_FASTAPI_WORKERS="${FASTAPI_WORKERS:-}" +_caller_TARGET_VERSION="${TARGET_VERSION:-}" -# Save anything the caller exported before we overwrite from .env. -_caller_FASTAPI_WORKERS="$FASTAPI_WORKERS" -_caller_TARGET_VERSION="$TARGET_VERSION" +DOGRAH_DEPLOY_PROJECT_DIR="$(pwd)" +dograh_load_env_file .env -set -a -# shellcheck disable=SC1091 -. ./.env -set +a +[[ -n "${TURN_SECRET:-}" ]] || dograh_fail "TURN_SECRET not found in .env" -# SERVER_IP isn't a literal key in .env — derive it from BACKEND_API_ENDPOINT. -if [[ -z "$SERVER_IP" ]]; then - if [[ -n "$BACKEND_API_ENDPOINT" ]]; then - SERVER_IP="${BACKEND_API_ENDPOINT#https://}" - SERVER_IP="${SERVER_IP#http://}" +if [[ -n "$_caller_FASTAPI_WORKERS" ]]; then + FASTAPI_WORKERS="$_caller_FASTAPI_WORKERS" +fi + +if [[ -z "${FASTAPI_WORKERS:-}" ]]; then + if [[ -t 0 ]]; then + echo "" + echo -e "${YELLOW}FASTAPI_WORKERS not set in .env. Number of uvicorn workers nginx will load-balance:${NC}" + read -p "[4]: " FASTAPI_WORKERS + FASTAPI_WORKERS="${FASTAPI_WORKERS:-4}" + else + FASTAPI_WORKERS="4" fi fi -if [[ -z "$SERVER_IP" ]]; then - echo -e "${RED}Error: could not determine SERVER_IP from .env${NC}" - echo -e "${RED}Expected BACKEND_API_ENDPOINT=https:// in .env${NC}" - exit 1 -fi +[[ "$FASTAPI_WORKERS" =~ ^[1-9][0-9]*$ ]] || dograh_fail "FASTAPI_WORKERS must be a positive integer (got: $FASTAPI_WORKERS)" -if [[ -z "$TURN_SECRET" ]]; then - echo -e "${RED}Error: TURN_SECRET not found in .env${NC}" - exit 1 -fi - -# Reapply caller overrides on top of sourced .env so e.g. FASTAPI_WORKERS=8 ./update_remote.sh works. -[[ -n "$_caller_FASTAPI_WORKERS" ]] && FASTAPI_WORKERS="$_caller_FASTAPI_WORKERS" -[[ -n "$_caller_TARGET_VERSION" ]] && TARGET_VERSION="$_caller_TARGET_VERSION" - -############################################################################### -### Determine target version -############################################################################### +TARGET_VERSION="${_caller_TARGET_VERSION:-${TARGET_VERSION:-}}" if [[ -z "$TARGET_VERSION" ]]; then - echo -e "${BLUE}Fetching latest release tag from GitHub...${NC}" + dograh_info "Fetching latest release tag from GitHub..." LATEST_TAG=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null \ | grep -E '"tag_name":' | head -1 \ | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/' || true) if [[ -z "$LATEST_TAG" ]]; then - echo -e "${YELLOW}Could not auto-discover latest tag — defaulting to 'main'.${NC}" + dograh_warn "Could not auto-discover latest tag - defaulting to 'main'." LATEST_TAG="main" fi @@ -113,28 +104,19 @@ if [[ -z "$TARGET_VERSION" ]]; then fi fi -# "latest" isn't a real ref on GitHub — treat it as "latest release". if [[ "$TARGET_VERSION" == "latest" ]]; then TARGET_VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" 2>/dev/null \ | grep -E '"tag_name":' | head -1 \ | sed -E 's/.*"tag_name":[[:space:]]*"([^"]+)".*/\1/' || true) - if [[ -z "$TARGET_VERSION" ]]; then - echo -e "${RED}Error: could not resolve 'latest' to a release tag${NC}" - exit 1 - fi + [[ -n "$TARGET_VERSION" ]] || dograh_fail "could not resolve 'latest' to a release tag" fi - -# GitHub release tags use a 'dograh-v' prefix (e.g. dograh-v1.28.0); Docker -# image tags on Docker Hub drop both the prefix and the 'v' (e.g. ':1.28.0'). -# Users commonly type shortcuts like '1.28.0' or 'v1.28.0' — try all reasonable -# variants so the script accepts any of those forms. TRY_TAGS=("$TARGET_VERSION") case "$TARGET_VERSION" in main|HEAD) - ;; # branch refs — leave as-is + ;; dograh-*) - ;; # already in the full tag form + ;; v*) TRY_TAGS+=("dograh-$TARGET_VERSION") ;; @@ -143,7 +125,7 @@ case "$TARGET_VERSION" in ;; esac -echo -e "${BLUE}Validating target version: $TARGET_VERSION...${NC}" +dograh_info "Validating target version: $TARGET_VERSION..." RESOLVED_TAG="" for tag in "${TRY_TAGS[@]}"; do if curl -fsI "https://raw.githubusercontent.com/$REPO/$tag/docker-compose.yaml" >/dev/null 2>&1; then @@ -152,81 +134,49 @@ for tag in "${TRY_TAGS[@]}"; do fi done -if [[ -z "$RESOLVED_TAG" ]]; then - echo -e "${RED}Error: could not find a git tag matching '$TARGET_VERSION'${NC}" - echo -e "${RED}Tried: ${TRY_TAGS[*]}${NC}" - echo -e "${RED}See available releases at: https://github.com/$REPO/releases${NC}" - exit 1 -fi +[[ -n "$RESOLVED_TAG" ]] || dograh_fail "could not find a git tag matching '$TARGET_VERSION'" if [[ "$RESOLVED_TAG" != "$TARGET_VERSION" ]]; then - echo -e "${GREEN}✓ Resolved '$TARGET_VERSION' to git tag '$RESOLVED_TAG'${NC}" + dograh_success "✓ Resolved '$TARGET_VERSION' to git tag '$RESOLVED_TAG'" fi + TARGET_VERSION="$RESOLVED_TAG" RAW_BASE="https://raw.githubusercontent.com/$REPO/$TARGET_VERSION" - -# Derive the Docker image tag from the git tag. Tags on Docker Hub use bare -# semver — strip the 'dograh-' prefix and the leading 'v'. IMAGE_TAG="" + case "$TARGET_VERSION" in dograh-v*) IMAGE_TAG="${TARGET_VERSION#dograh-v}" ;; - v*) IMAGE_TAG="${TARGET_VERSION#v}" ;; + v*) IMAGE_TAG="${TARGET_VERSION#v}" ;; main|HEAD) IMAGE_TAG="" ;; - *) [[ "$TARGET_VERSION" =~ ^[0-9] ]] && IMAGE_TAG="$TARGET_VERSION" ;; + *) [[ "$TARGET_VERSION" =~ ^[0-9] ]] && IMAGE_TAG="$TARGET_VERSION" ;; esac -# Verify the image tag actually exists on Docker Hub. If not (e.g. CI hasn't -# published yet), fall back to ':latest' rather than pinning to a missing tag. if [[ -n "$IMAGE_TAG" ]]; then if curl -fsI "https://hub.docker.com/v2/repositories/dograhai/dograh-api/tags/$IMAGE_TAG/" >/dev/null 2>&1; then - echo -e "${GREEN}✓ Image tag :$IMAGE_TAG found on Docker Hub${NC}" + dograh_success "✓ Image tag :$IMAGE_TAG found on Docker Hub" else - echo -e "${YELLOW}Warning: image tag :$IMAGE_TAG not found on Docker Hub — leaving images at :latest${NC}" + dograh_warn "Warning: image tag :$IMAGE_TAG not found on Docker Hub - leaving images at :latest" IMAGE_TAG="" fi fi -############################################################################### -### Reconcile required keys that may be missing on older installs -############################################################################### - -if [[ -z "$FASTAPI_WORKERS" ]]; then - if [[ -t 0 ]]; then - echo "" - echo -e "${YELLOW}FASTAPI_WORKERS not set in .env. Number of uvicorn workers nginx will load-balance:${NC}" - read -p "[4]: " FASTAPI_WORKERS - FASTAPI_WORKERS="${FASTAPI_WORKERS:-4}" - else - FASTAPI_WORKERS="4" - fi -fi - -if ! [[ "$FASTAPI_WORKERS" =~ ^[1-9][0-9]*$ ]]; then - echo -e "${RED}Error: FASTAPI_WORKERS must be a positive integer (got: $FASTAPI_WORKERS)${NC}" - exit 1 -fi - -############################################################################### -### Summary + confirmation -############################################################################### - echo "" echo -e "${GREEN}Update plan:${NC}" -echo -e " Server IP: ${BLUE}$SERVER_IP${NC}" +echo -e " Server IP: ${BLUE}$(dograh_infer_server_ip "$(pwd)" || echo "unknown")${NC}" echo -e " Target version: ${BLUE}$TARGET_VERSION${NC}" echo -e " FastAPI workers: ${BLUE}$FASTAPI_WORKERS${NC} (ports 8000..$((8000 + FASTAPI_WORKERS - 1)))" echo "" echo -e "${YELLOW}Files that will be replaced (backups saved with suffix .bak.$TIMESTAMP):${NC}" echo " - docker-compose.yaml (pulled from GitHub at $TARGET_VERSION)" -echo " - nginx.conf (regenerated from this script's template)" -echo " - turnserver.conf (regenerated from this script's template)" -echo " - .env (existing values preserved; missing keys appended)" -echo "" -echo -e "${YELLOW}Any local customizations to these files will be overwritten — check the backup${NC}" -echo -e "${YELLOW}files if you need to re-apply edits afterwards.${NC}" +echo " - remote_up.sh (startup wrapper / preflight)" +echo " - scripts/run_dograh_init.sh" +echo " - scripts/lib/setup_common.sh" +echo " - deploy/templates/*.template" +echo " - .env (canonical remote keys synchronized)" +echo " - legacy nginx.conf / turnserver.conf backups will be kept if those files still exist" echo "" -if [[ -t 0 && "$DOGRAH_UPDATE_YES" != "1" ]]; then +if [[ -t 0 && "${DOGRAH_UPDATE_YES:-}" != "1" ]]; then read -p "Proceed? [y/N]: " confirm if ! [[ "$confirm" =~ ^[Yy] ]]; then echo -e "${RED}Aborted.${NC}" @@ -234,198 +184,44 @@ if [[ -t 0 && "$DOGRAH_UPDATE_YES" != "1" ]]; then fi fi -############################################################################### -### Step 1 — backups -############################################################################### - echo "" -echo -e "${BLUE}[1/5] Backing up existing files...${NC}" -for f in docker-compose.yaml nginx.conf turnserver.conf .env; do +echo -e "${BLUE}[1/3] Backing up existing files...${NC}" +for f in \ + docker-compose.yaml \ + nginx.conf \ + turnserver.conf \ + .env \ + remote_up.sh \ + scripts/run_dograh_init.sh \ + scripts/lib/setup_common.sh \ + deploy/templates/nginx.remote.conf.template \ + deploy/templates/turnserver.remote.conf.template +do if [[ -f "$f" ]]; then + mkdir -p "$(dirname "$f")" cp -p "$f" "$f.bak.$TIMESTAMP" echo -e " ${GREEN}✓ $f → $f.bak.$TIMESTAMP${NC}" fi done -############################################################################### -### Step 2 — docker-compose.yaml (download + pin image tags) -############################################################################### - -echo -e "${BLUE}[2/5] Downloading docker-compose.yaml at $TARGET_VERSION...${NC}" +echo -e "${BLUE}[2/3] Downloading deployment bundle at $TARGET_VERSION...${NC}" curl -fsSL -o docker-compose.yaml "$RAW_BASE/docker-compose.yaml" +dograh_download_remote_support_bundle "$(pwd)" "$TARGET_VERSION" +rm -f nginx.conf turnserver.conf -# Pin api/ui image tags when we resolved one. For branch refs (main) IMAGE_TAG -# is empty, so the images stay at ':latest' and `up --pull always` grabs the -# newest build of that branch. if [[ -n "$IMAGE_TAG" ]]; then sed -i.tmp -E "s#(dograh-(api|ui)):latest#\1:$IMAGE_TAG#g" docker-compose.yaml rm -f docker-compose.yaml.tmp - echo -e "${GREEN}✓ docker-compose.yaml updated; images pinned to :$IMAGE_TAG${NC}" + dograh_success "✓ docker-compose.yaml updated; images pinned to :$IMAGE_TAG" else - echo -e "${GREEN}✓ docker-compose.yaml updated (image tags left at :latest)${NC}" + dograh_success "✓ docker-compose.yaml updated (image tags left at :latest)" fi -############################################################################### -### Step 3 — nginx.conf (regenerate from embedded template) -############################################################################### - -echo -e "${BLUE}[3/5] Regenerating nginx.conf...${NC}" -{ - echo "# Backend API workers — one uvicorn process per port, balanced by least_conn." - echo "# Generated by update_remote.sh; regenerate to change worker count." - echo "upstream dograh_api {" - echo " least_conn;" - for ((i=0; i nginx.conf - -sed -i.tmp "s/SERVER_IP_PLACEHOLDER/$SERVER_IP/g" nginx.conf && rm -f nginx.conf.tmp -echo -e "${GREEN}✓ nginx.conf regenerated${NC}" - -############################################################################### -### Step 4 — turnserver.conf (regenerate from embedded template) -############################################################################### - -echo -e "${BLUE}[4/5] Regenerating turnserver.conf...${NC}" -cat > turnserver.conf << TURN_EOF -# Coturn TURN Server - Docker Configuration -# Auto-generated by update_remote.sh - -# Listener ports -listening-port=3478 -tls-listening-port=5349 - -# Relay port range -min-port=49152 -max-port=49200 - -# Network - external IP for NAT traversal -external-ip=$SERVER_IP - -# Realm -realm=dograh.com - -# Authentication (TURN REST API with time-limited credentials) -use-auth-secret -static-auth-secret=$TURN_SECRET - -# Security -fingerprint -no-cli -no-multicast-peers - -# Logging -log-file=stdout -TURN_EOF -echo -e "${GREEN}✓ turnserver.conf regenerated${NC}" - -############################################################################### -### Step 5 — reconcile .env (append missing keys; never overwrite existing) -############################################################################### - -echo -e "${BLUE}[5/5] Reconciling .env...${NC}" -if ! grep -q "^FASTAPI_WORKERS=" .env; then - { - echo "" - echo "# Number of uvicorn worker processes; nginx load-balances across them" - echo "# (ports 8000..$((8000 + FASTAPI_WORKERS - 1))) with least_conn." - echo "FASTAPI_WORKERS=$FASTAPI_WORKERS" - } >> .env - echo -e "${GREEN}✓ Added FASTAPI_WORKERS=$FASTAPI_WORKERS to .env${NC}" -else - echo -e "${GREEN}✓ .env already has FASTAPI_WORKERS — left unchanged${NC}" -fi - -############################################################################### -### Done — print restart + rollback instructions -############################################################################### +echo -e "${BLUE}[3/3] Synchronizing environment and validating init-based remote config...${NC}" +dograh_set_env_key .env FASTAPI_WORKERS "$FASTAPI_WORKERS" +dograh_prepare_remote_install "$(pwd)" +docker compose config -q +dograh_success "✓ Remote init configuration validated" echo "" echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}" @@ -434,15 +230,14 @@ echo -e "${GREEN}╚════════════════════ echo "" echo -e "Backups: ${BLUE}*.bak.$TIMESTAMP${NC}" echo "" -echo -e "${YELLOW}To apply, recreate the stack:${NC}" +echo -e "${YELLOW}To apply, restart through the validated wrapper:${NC}" echo "" -echo -e " ${BLUE}sudo docker compose --profile remote down${NC}" -echo -e " ${BLUE}sudo docker compose --profile remote up -d --pull always${NC}" +echo -e " ${BLUE}./remote_up.sh${NC}" echo "" -echo -e "${YELLOW}To roll back, restore the backups and recreate:${NC}" +echo -e "${YELLOW}To roll back, restore the backups and re-run the wrapper:${NC}" echo "" -echo -e " ${BLUE}for f in docker-compose.yaml nginx.conf turnserver.conf .env; do${NC}" -echo -e " ${BLUE} [[ -f \"\$f.bak.$TIMESTAMP\" ]] && cp \"\$f.bak.$TIMESTAMP\" \"\$f\"${NC}" +echo -e " ${BLUE}for f in docker-compose.yaml nginx.conf turnserver.conf .env remote_up.sh scripts/run_dograh_init.sh scripts/lib/setup_common.sh deploy/templates/nginx.remote.conf.template deploy/templates/turnserver.remote.conf.template; do${NC}" +echo -e " ${BLUE} [[ -f \"\$f.bak.$TIMESTAMP\" ]] && cp \"\$f.bak.$TIMESTAMP\" \"\$f\"${NC}" echo -e " ${BLUE}done${NC}" -echo -e " ${BLUE}sudo docker compose --profile remote down && sudo docker compose --profile remote up -d${NC}" +echo -e " ${BLUE}./remote_up.sh${NC}" echo "" diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 95c857f..bdfcdb6 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dograh-sdk" -version = "0.1.5" +version = "0.1.6" description = "Typed builder for Dograh voice-AI workflows" readme = "README.md" requires-python = ">=3.10" diff --git a/sdk/python/src/dograh_sdk/_generated_models.py b/sdk/python/src/dograh_sdk/_generated_models.py index b017934..95a3a28 100644 --- a/sdk/python/src/dograh_sdk/_generated_models.py +++ b/sdk/python/src/dograh_sdk/_generated_models.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: -# filename: dograh-openapi-XXXXXX.json.BPTCZt07wQ -# timestamp: 2026-05-13T16:22:23+00:00 +# filename: dograh-openapi-XXXXXX.json.N8gRI5v3bD +# timestamp: 2026-05-23T09:14:22+00:00 from __future__ import annotations @@ -247,6 +247,8 @@ class WorkflowListResponse(BaseModel): status: Annotated[str, Field(title='Status')] created_at: Annotated[AwareDatetime, Field(title='Created At')] total_runs: Annotated[int, Field(title='Total Runs')] + folder_id: Annotated[int | None, Field(title='Folder Id')] = None + workflow_uuid: Annotated[str | None, Field(title='Workflow Uuid')] = None class WorkflowResponse(BaseModel): diff --git a/sdk/python/src/dograh_sdk/codegen.py b/sdk/python/src/dograh_sdk/codegen.py index 9d5de7d..019b356 100644 --- a/sdk/python/src/dograh_sdk/codegen.py +++ b/sdk/python/src/dograh_sdk/codegen.py @@ -127,8 +127,8 @@ def _format_docstring(text: str, indent: int = 4) -> str: _FILE_HEADER = '''"""GENERATED — do not edit by hand. Regenerate with `python -m dograh_sdk.codegen` against the target -Dograh backend. Source of truth: each node's NodeSpec in the backend's -`api/services/workflow/node_specs/` directory. +Dograh backend. Source of truth: the backend's model-backed node-spec +catalog served from `/api/v1/node-types`. """ from __future__ import annotations diff --git a/sdk/python/src/dograh_sdk/typed/__init__.py b/sdk/python/src/dograh_sdk/typed/__init__.py index b5dcea3..f8811a9 100644 --- a/sdk/python/src/dograh_sdk/typed/__init__.py +++ b/sdk/python/src/dograh_sdk/typed/__init__.py @@ -10,6 +10,7 @@ from dograh_sdk.typed.global_node import GlobalNode from dograh_sdk.typed.qa import Qa from dograh_sdk.typed.start_call import StartCall from dograh_sdk.typed.trigger import Trigger +from dograh_sdk.typed.tuner import Tuner from dograh_sdk.typed.webhook import Webhook from dograh_sdk.typed._base import TypedNode @@ -20,6 +21,7 @@ __all__ = [ "Qa", "StartCall", "Trigger", + "Tuner", "TypedNode", "Webhook", ] diff --git a/sdk/python/src/dograh_sdk/typed/agent_node.py b/sdk/python/src/dograh_sdk/typed/agent_node.py index 3b8c21e..cdceb4b 100644 --- a/sdk/python/src/dograh_sdk/typed/agent_node.py +++ b/sdk/python/src/dograh_sdk/typed/agent_node.py @@ -1,8 +1,8 @@ """GENERATED — do not edit by hand. Regenerate with `python -m dograh_sdk.codegen` against the target -Dograh backend. Source of truth: each node's NodeSpec in the backend's -`api/services/workflow/node_specs/` directory. +Dograh backend. Source of truth: the backend's model-backed node-spec +catalog served from `/api/v1/node-types`. """ from __future__ import annotations @@ -16,8 +16,8 @@ from dograh_sdk.typed._base import TypedNode @dataclass(kw_only=True) class AgentNode_Extraction_variablesRow: """ - Each entry declares one variable to capture from the conversation, with - its name, type, and per-variable hint. + Each entry declares one variable to capture, with its name, data type, + and extraction hint. """ name: str @@ -70,8 +70,7 @@ class AgentNode(TypedNode): extraction_enabled: bool = False """ - When true, runs an LLM extraction pass on transition out of this node to - capture variables from the conversation. + When true, runs an LLM extraction pass for this node. """ extraction_prompt: Optional[str] = None @@ -81,8 +80,8 @@ class AgentNode(TypedNode): extraction_variables: list[AgentNode_Extraction_variablesRow] = field(default_factory=list) """ - Each entry declares one variable to capture from the conversation, with - its name, type, and per-variable hint. + Each entry declares one variable to capture, with its name, data type, + and extraction hint. """ tool_uuids: list[str] = field(default_factory=list) diff --git a/sdk/python/src/dograh_sdk/typed/end_call.py b/sdk/python/src/dograh_sdk/typed/end_call.py index 737205c..3c50772 100644 --- a/sdk/python/src/dograh_sdk/typed/end_call.py +++ b/sdk/python/src/dograh_sdk/typed/end_call.py @@ -1,8 +1,8 @@ """GENERATED — do not edit by hand. Regenerate with `python -m dograh_sdk.codegen` against the target -Dograh backend. Source of truth: each node's NodeSpec in the backend's -`api/services/workflow/node_specs/` directory. +Dograh backend. Source of truth: the backend's model-backed node-spec +catalog served from `/api/v1/node-types`. """ from __future__ import annotations @@ -26,11 +26,11 @@ class EndCall_Extraction_variablesRow: """ type: Literal['string', 'number', 'boolean'] = 'string' """ - The data type of the extracted value. + Data type of the extracted value. """ prompt: Optional[str] = None """ - Per-variable hint describing what to look for in the conversation. + Per-variable hint describing what to look for. """ @dataclass(kw_only=True) diff --git a/sdk/python/src/dograh_sdk/typed/global_node.py b/sdk/python/src/dograh_sdk/typed/global_node.py index 6453230..1cbf1b4 100644 --- a/sdk/python/src/dograh_sdk/typed/global_node.py +++ b/sdk/python/src/dograh_sdk/typed/global_node.py @@ -1,8 +1,8 @@ """GENERATED — do not edit by hand. Regenerate with `python -m dograh_sdk.codegen` against the target -Dograh backend. Source of truth: each node's NodeSpec in the backend's -`api/services/workflow/node_specs/` directory. +Dograh backend. Source of truth: the backend's model-backed node-spec +catalog served from `/api/v1/node-types`. """ from __future__ import annotations diff --git a/sdk/python/src/dograh_sdk/typed/qa.py b/sdk/python/src/dograh_sdk/typed/qa.py index 339e2af..f4d6d04 100644 --- a/sdk/python/src/dograh_sdk/typed/qa.py +++ b/sdk/python/src/dograh_sdk/typed/qa.py @@ -1,8 +1,8 @@ """GENERATED — do not edit by hand. Regenerate with `python -m dograh_sdk.codegen` against the target -Dograh backend. Source of truth: each node's NodeSpec in the backend's -`api/services/workflow/node_specs/` directory. +Dograh backend. Source of truth: the backend's model-backed node-spec +catalog served from `/api/v1/node-types`. """ from __future__ import annotations diff --git a/sdk/python/src/dograh_sdk/typed/start_call.py b/sdk/python/src/dograh_sdk/typed/start_call.py index b9e2562..33faae1 100644 --- a/sdk/python/src/dograh_sdk/typed/start_call.py +++ b/sdk/python/src/dograh_sdk/typed/start_call.py @@ -1,8 +1,8 @@ """GENERATED — do not edit by hand. Regenerate with `python -m dograh_sdk.codegen` against the target -Dograh backend. Source of truth: each node's NodeSpec in the backend's -`api/services/workflow/node_specs/` directory. +Dograh backend. Source of truth: the backend's model-backed node-spec +catalog served from `/api/v1/node-types`. """ from __future__ import annotations @@ -17,7 +17,7 @@ from dograh_sdk.typed._base import TypedNode class StartCall_Extraction_variablesRow: """ Each entry declares one variable to capture, with its name, data type, - and per-variable extraction hint. + and extraction hint. """ name: str @@ -97,8 +97,7 @@ class StartCall(TypedNode): extraction_enabled: bool = False """ - When true, runs an LLM extraction pass on transition out of this node to - capture variables from the opening turn. + When true, runs an LLM extraction pass for this node. """ extraction_prompt: Optional[str] = None @@ -109,7 +108,7 @@ class StartCall(TypedNode): extraction_variables: list[StartCall_Extraction_variablesRow] = field(default_factory=list) """ Each entry declares one variable to capture, with its name, data type, - and per-variable extraction hint. + and extraction hint. """ tool_uuids: list[str] = field(default_factory=list) diff --git a/sdk/python/src/dograh_sdk/typed/trigger.py b/sdk/python/src/dograh_sdk/typed/trigger.py index 3a6be4b..17e01de 100644 --- a/sdk/python/src/dograh_sdk/typed/trigger.py +++ b/sdk/python/src/dograh_sdk/typed/trigger.py @@ -1,8 +1,8 @@ """GENERATED — do not edit by hand. Regenerate with `python -m dograh_sdk.codegen` against the target -Dograh backend. Source of truth: each node's NodeSpec in the backend's -`api/services/workflow/node_specs/` directory. +Dograh backend. Source of truth: the backend's model-backed node-spec +catalog served from `/api/v1/node-types`. """ from __future__ import annotations @@ -46,10 +46,10 @@ class Trigger(TypedNode): trigger_path: Optional[str] = None """ - Auto-generated UUID-style path segment that uniquely identifies this - trigger. Used in both URLs: • Production: - `/api/v1/public/agent/` — executes the published agent. - • Test: `/api/v1/public/agent/test/` — executes the latest - draft. Do not edit manually. + Path segment that uniquely identifies this trigger. Used in both URLs: + • Production: `/api/v1/public/agent/` — executes the + published agent. • Test: `/api/v1/public/agent/test/` — + executes the latest draft. Can be customized to a descriptive value up + to 36 characters using letters, numbers, hyphens, or underscores. """ diff --git a/sdk/python/src/dograh_sdk/typed/tuner.py b/sdk/python/src/dograh_sdk/typed/tuner.py new file mode 100644 index 0000000..331fa06 --- /dev/null +++ b/sdk/python/src/dograh_sdk/typed/tuner.py @@ -0,0 +1,50 @@ +"""GENERATED — do not edit by hand. + +Regenerate with `python -m dograh_sdk.codegen` against the target +Dograh backend. Source of truth: the backend's model-backed node-spec +catalog served from `/api/v1/node-types`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, ClassVar, Literal, Optional + +from dograh_sdk.typed._base import TypedNode + + +@dataclass(kw_only=True) +class Tuner(TypedNode): + """ + Export the completed call to Tuner for Agent Observability LLM hint: + Tuner is a post-call observability export. It does not participate in + the conversation graph and should not be connected to other nodes. + """ + + type: ClassVar[str] = 'tuner' + + tuner_agent_id: str + """ + The agent identifier registered in your Tuner workspace. + """ + + tuner_workspace_id: float + """ + Your numeric Tuner workspace ID. + """ + + tuner_api_key: str + """ + Bearer token used when posting completed calls to Tuner. + """ + + name: str = 'Tuner' + """ + Short identifier for this Tuner export configuration. + """ + + tuner_enabled: bool = True + """ + When false, Dograh skips exporting this call to Tuner. + """ + diff --git a/sdk/python/src/dograh_sdk/typed/webhook.py b/sdk/python/src/dograh_sdk/typed/webhook.py index 993a74e..305c0e4 100644 --- a/sdk/python/src/dograh_sdk/typed/webhook.py +++ b/sdk/python/src/dograh_sdk/typed/webhook.py @@ -1,8 +1,8 @@ """GENERATED — do not edit by hand. Regenerate with `python -m dograh_sdk.codegen` against the target -Dograh backend. Source of truth: each node's NodeSpec in the backend's -`api/services/workflow/node_specs/` directory. +Dograh backend. Source of truth: the backend's model-backed node-spec +catalog served from `/api/v1/node-types`. """ from __future__ import annotations diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index 04972b4..6bc8b26 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@dograh/sdk", - "version": "0.1.5", + "version": "0.1.6", "description": "Typed builder for Dograh voice-AI workflows", "license": "BSD-2-Clause", "author": "Zansat Technologies Private Limited", diff --git a/sdk/typescript/scripts/codegen.mts b/sdk/typescript/scripts/codegen.mts index 954b1c0..cbde7e3 100644 --- a/sdk/typescript/scripts/codegen.mts +++ b/sdk/typescript/scripts/codegen.mts @@ -127,8 +127,8 @@ function renderSpecFile(spec: NodeSpec): string { const header = `// GENERATED — do not edit by hand. // // Regenerate with \`npm run codegen\` against the target Dograh backend. -// Source of truth: each node's NodeSpec in the backend's -// \`api/services/workflow/node_specs/\` directory. +// Source of truth: the backend's model-backed node-spec catalog served +// from \`/api/v1/node-types\`. `; const nested: string[] = []; diff --git a/sdk/typescript/src/_generated_models.ts b/sdk/typescript/src/_generated_models.ts index f12cd2b..f31f7d2 100644 --- a/sdk/typescript/src/_generated_models.ts +++ b/sdk/typescript/src/_generated_models.ts @@ -707,6 +707,10 @@ export interface components { created_at: string; /** Total Runs */ total_runs: number; + /** Folder Id */ + folder_id?: number | null; + /** Workflow Uuid */ + workflow_uuid?: string | null; }; /** WorkflowResponse */ WorkflowResponse: { diff --git a/sdk/typescript/src/typed/agent-node.ts b/sdk/typescript/src/typed/agent-node.ts index 2fd05e8..ec969e9 100644 --- a/sdk/typescript/src/typed/agent-node.ts +++ b/sdk/typescript/src/typed/agent-node.ts @@ -1,11 +1,11 @@ // GENERATED — do not edit by hand. // // Regenerate with `npm run codegen` against the target Dograh backend. -// Source of truth: each node's NodeSpec in the backend's -// `api/services/workflow/node_specs/` directory. +// Source of truth: the backend's model-backed node-spec catalog served +// from `/api/v1/node-types`. /** - * Each entry declares one variable to capture from the conversation, with its name, type, and per-variable hint. + * Each entry declares one variable to capture, with its name, data type, and extraction hint. */ export interface AgentNodeExtraction_variablesRow { /** @@ -46,7 +46,7 @@ export interface AgentNode { */ add_global_prompt?: boolean; /** - * When true, runs an LLM extraction pass on transition out of this node to capture variables from the conversation. + * When true, runs an LLM extraction pass for this node. */ extraction_enabled?: boolean; /** @@ -54,7 +54,7 @@ export interface AgentNode { */ extraction_prompt?: string; /** - * Each entry declares one variable to capture from the conversation, with its name, type, and per-variable hint. + * Each entry declares one variable to capture, with its name, data type, and extraction hint. */ extraction_variables?: Array; /** diff --git a/sdk/typescript/src/typed/end-call.ts b/sdk/typescript/src/typed/end-call.ts index 7d0aed6..3142792 100644 --- a/sdk/typescript/src/typed/end-call.ts +++ b/sdk/typescript/src/typed/end-call.ts @@ -1,8 +1,8 @@ // GENERATED — do not edit by hand. // // Regenerate with `npm run codegen` against the target Dograh backend. -// Source of truth: each node's NodeSpec in the backend's -// `api/services/workflow/node_specs/` directory. +// Source of truth: the backend's model-backed node-spec catalog served +// from `/api/v1/node-types`. /** * Each entry declares one variable to capture from the conversation, with its name, data type, and a per-variable extraction hint. @@ -13,11 +13,11 @@ export interface EndCallExtraction_variablesRow { */ name: string; /** - * The data type of the extracted value. + * Data type of the extracted value. */ type: "string" | "number" | "boolean"; /** - * Per-variable hint describing what to look for in the conversation. + * Per-variable hint describing what to look for. */ prompt?: string; } diff --git a/sdk/typescript/src/typed/global-node.ts b/sdk/typescript/src/typed/global-node.ts index 98140fd..812cae6 100644 --- a/sdk/typescript/src/typed/global-node.ts +++ b/sdk/typescript/src/typed/global-node.ts @@ -1,8 +1,8 @@ // GENERATED — do not edit by hand. // // Regenerate with `npm run codegen` against the target Dograh backend. -// Source of truth: each node's NodeSpec in the backend's -// `api/services/workflow/node_specs/` directory. +// Source of truth: the backend's model-backed node-spec catalog served +// from `/api/v1/node-types`. /** diff --git a/sdk/typescript/src/typed/index.ts b/sdk/typescript/src/typed/index.ts index 72450a8..d715891 100644 --- a/sdk/typescript/src/typed/index.ts +++ b/sdk/typescript/src/typed/index.ts @@ -9,6 +9,7 @@ export { type GlobalNode, globalNode } from "./global-node.js"; export { type Qa, qa } from "./qa.js"; export { type StartCall, startCall } from "./start-call.js"; export { type Trigger, trigger } from "./trigger.js"; +export { type Tuner, tuner } from "./tuner.js"; export { type Webhook, webhook } from "./webhook.js"; import type { @@ -18,8 +19,9 @@ import type { Qa, StartCall, Trigger, + Tuner, Webhook, } from "./index.js"; /** Discriminated union of every generated typed node. */ -export type TypedNode = AgentNode | EndCall | GlobalNode | Qa | StartCall | Trigger | Webhook; +export type TypedNode = AgentNode | EndCall | GlobalNode | Qa | StartCall | Trigger | Tuner | Webhook; diff --git a/sdk/typescript/src/typed/qa.ts b/sdk/typescript/src/typed/qa.ts index 7f93152..e7f5233 100644 --- a/sdk/typescript/src/typed/qa.ts +++ b/sdk/typescript/src/typed/qa.ts @@ -1,8 +1,8 @@ // GENERATED — do not edit by hand. // // Regenerate with `npm run codegen` against the target Dograh backend. -// Source of truth: each node's NodeSpec in the backend's -// `api/services/workflow/node_specs/` directory. +// Source of truth: the backend's model-backed node-spec catalog served +// from `/api/v1/node-types`. /** diff --git a/sdk/typescript/src/typed/start-call.ts b/sdk/typescript/src/typed/start-call.ts index f243850..5ed9bb2 100644 --- a/sdk/typescript/src/typed/start-call.ts +++ b/sdk/typescript/src/typed/start-call.ts @@ -1,11 +1,11 @@ // GENERATED — do not edit by hand. // // Regenerate with `npm run codegen` against the target Dograh backend. -// Source of truth: each node's NodeSpec in the backend's -// `api/services/workflow/node_specs/` directory. +// Source of truth: the backend's model-backed node-spec catalog served +// from `/api/v1/node-types`. /** - * Each entry declares one variable to capture, with its name, data type, and per-variable extraction hint. + * Each entry declares one variable to capture, with its name, data type, and extraction hint. */ export interface StartCallExtraction_variablesRow { /** @@ -68,7 +68,7 @@ export interface StartCall { */ delayed_start_duration?: number; /** - * When true, runs an LLM extraction pass on transition out of this node to capture variables from the opening turn. + * When true, runs an LLM extraction pass for this node. */ extraction_enabled?: boolean; /** @@ -76,7 +76,7 @@ export interface StartCall { */ extraction_prompt?: string; /** - * Each entry declares one variable to capture, with its name, data type, and per-variable extraction hint. + * Each entry declares one variable to capture, with its name, data type, and extraction hint. */ extraction_variables?: Array; /** diff --git a/sdk/typescript/src/typed/trigger.ts b/sdk/typescript/src/typed/trigger.ts index 2dbe27d..33f8f69 100644 --- a/sdk/typescript/src/typed/trigger.ts +++ b/sdk/typescript/src/typed/trigger.ts @@ -1,8 +1,8 @@ // GENERATED — do not edit by hand. // // Regenerate with `npm run codegen` against the target Dograh backend. -// Source of truth: each node's NodeSpec in the backend's -// `api/services/workflow/node_specs/` directory. +// Source of truth: the backend's model-backed node-spec catalog served +// from `/api/v1/node-types`. /** @@ -28,10 +28,10 @@ export interface Trigger { */ enabled?: boolean; /** - * Auto-generated UUID-style path segment that uniquely identifies this trigger. Used in both URLs: + * Path segment that uniquely identifies this trigger. Used in both URLs: * • Production: `/api/v1/public/agent/` — executes the published agent. * • Test: `/api/v1/public/agent/test/` — executes the latest draft. - * Do not edit manually. + * Can be customized to a descriptive value up to 36 characters using letters, numbers, hyphens, or underscores. */ trigger_path?: string; } diff --git a/sdk/typescript/src/typed/tuner.ts b/sdk/typescript/src/typed/tuner.ts new file mode 100644 index 0000000..fc9170c --- /dev/null +++ b/sdk/typescript/src/typed/tuner.ts @@ -0,0 +1,40 @@ +// GENERATED — do not edit by hand. +// +// Regenerate with `npm run codegen` against the target Dograh backend. +// Source of truth: the backend's model-backed node-spec catalog served +// from `/api/v1/node-types`. + + +/** + * Export the completed call to Tuner for Agent Observability + * + * LLM hint: Tuner is a post-call observability export. It does not participate in the conversation graph and should not be connected to other nodes. + */ +export interface Tuner { + type: "tuner"; + /** + * Short identifier for this Tuner export configuration. + */ + name?: string; + /** + * When false, Dograh skips exporting this call to Tuner. + */ + tuner_enabled?: boolean; + /** + * The agent identifier registered in your Tuner workspace. + */ + tuner_agent_id: string; + /** + * Your numeric Tuner workspace ID. + */ + tuner_workspace_id: number; + /** + * Bearer token used when posting completed calls to Tuner. + */ + tuner_api_key: string; +} + +/** Factory — sets `type` for you so you don't repeat the discriminator. */ +export function tuner(input: Omit): Tuner { + return { type: "tuner", ...input }; +} diff --git a/sdk/typescript/src/typed/webhook.ts b/sdk/typescript/src/typed/webhook.ts index f3d39f2..87cfea0 100644 --- a/sdk/typescript/src/typed/webhook.ts +++ b/sdk/typescript/src/typed/webhook.ts @@ -1,8 +1,8 @@ // GENERATED — do not edit by hand. // // Regenerate with `npm run codegen` against the target Dograh backend. -// Source of truth: each node's NodeSpec in the backend's -// `api/services/workflow/node_specs/` directory. +// Source of truth: the backend's model-backed node-spec catalog served +// from `/api/v1/node-types`. /** * Additional HTTP headers to include with the request. diff --git a/ui/package-lock.json b/ui/package-lock.json index 3b04565..9923bb0 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,15 +1,14 @@ { "name": "ui", - "version": "1.29.0", + "version": "1.30.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ui", - "version": "1.29.0", + "version": "1.30.1", "dependencies": { "@dagrejs/dagre": "^1.1.4", - "@nangohq/frontend": "^0.69.47", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.12", @@ -27,7 +26,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@sentry/nextjs": "^9.28.1", "@stackframe/stack": "^2.8.80", - "@xyflow/react": "^12.9.2", + "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -2485,26 +2484,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@nangohq/frontend": { - "version": "0.69.47", - "resolved": "https://registry.npmjs.org/@nangohq/frontend/-/frontend-0.69.47.tgz", - "integrity": "sha512-PyRSh6DTXkqya40v4EwJ8myiGV99H4izlnkD9Ldz/Aenb/Q7r6nrP41U7dHn8phdHCOEsLLF9y9CjKXBnA5Smg==", - "license": "SEE LICENSE IN LICENSE FILE IN GIT REPOSITORY", - "dependencies": { - "@nangohq/types": "0.69.47" - } - }, - "node_modules/@nangohq/types": { - "version": "0.69.47", - "resolved": "https://registry.npmjs.org/@nangohq/types/-/types-0.69.47.tgz", - "integrity": "sha512-mgw3hQbtSeYbxj/A6ncjg31oJiZkR7FoNHwbv7LpULBDg6C2J9gZ0UbFtZfS8XZxOXUoLWvRYKN0rHcVrD+MmA==", - "license": "SEE LICENSE IN LICENSE FILE IN GIT REPOSITORY", - "dependencies": { - "axios": "1.13.5", - "json-schema": "0.4.0", - "type-fest": "4.41.0" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.8", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.8.tgz", @@ -11248,12 +11227,12 @@ "peer": true }, "node_modules/@xyflow/react": { - "version": "12.9.2", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.2.tgz", - "integrity": "sha512-Xr+LFcysHCCoc5KRHaw+FwbqbWYxp9tWtk1mshNcqy25OAPuaKzXSdqIMNOA82TIXF/gFKo0Wgpa6PU7wUUVqw==", + "version": "12.10.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", + "integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.72", + "@xyflow/system": "0.0.76", "classcat": "^5.0.3", "zustand": "^4.4.0" }, @@ -11291,9 +11270,9 @@ } }, "node_modules/@xyflow/system": { - "version": "0.0.72", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz", - "integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==", + "version": "0.0.76", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz", + "integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==", "license": "MIT", "dependencies": { "@types/d3-drag": "^3.0.7", @@ -11682,12 +11661,6 @@ "tslib": "^2.4.0" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -11723,17 +11696,6 @@ "node": ">=4" } }, - "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" - } - }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -11978,6 +11940,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -12264,18 +12227,6 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -12818,15 +12769,6 @@ "dev": true, "license": "MIT" }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", @@ -12896,6 +12838,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -13043,6 +12986,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13052,6 +12996,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -13096,6 +13041,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -13108,6 +13054,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -13929,26 +13876,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -13965,22 +13892,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/forwarded-parse": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", @@ -14069,6 +13980,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -14102,6 +14014,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -14213,6 +14126,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -14289,6 +14203,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -14301,6 +14216,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -15105,12 +15021,6 @@ "license": "MIT", "peer": true }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -15520,6 +15430,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -15568,6 +15479,7 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -15577,6 +15489,7 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -18152,18 +18065,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", diff --git a/ui/package.json b/ui/package.json index 85050f7..1e4f7c5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "ui", - "version": "1.29.0", + "version": "1.31.0", "private": true, "scripts": { "dev": "cross-env NODE_OPTIONS=--enable-source-maps next dev --turbopack", @@ -13,7 +13,6 @@ }, "dependencies": { "@dagrejs/dagre": "^1.1.4", - "@nangohq/frontend": "^0.69.47", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-collapsible": "^1.1.12", @@ -31,7 +30,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@sentry/nextjs": "^9.28.1", "@stackframe/stack": "^2.8.80", - "@xyflow/react": "^12.9.2", + "@xyflow/react": "^12.10.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", diff --git a/ui/src/app/api/config/latest-version/route.ts b/ui/src/app/api/config/latest-version/route.ts new file mode 100644 index 0000000..0652dd6 --- /dev/null +++ b/ui/src/app/api/config/latest-version/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; + +const GHCR_IMAGES = ["dograh-hq/dograh-ui", "dograh-hq/dograh-api"] as const; +const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/; +const REVALIDATE_SECONDS = 60 * 60; + +type Semver = [number, number, number]; + +function parseSemver(tag: string): Semver | null { + const m = tag.match(SEMVER_RE); + if (!m) return null; + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} + +function compareSemver(a: Semver, b: Semver): number { + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i] - b[i]; + } + return 0; +} + +async function fetchLatestTag(image: string): Promise { + const tokenRes = await fetch( + `https://ghcr.io/token?scope=repository:${image}:pull&service=ghcr.io`, + { next: { revalidate: REVALIDATE_SECONDS } }, + ); + if (!tokenRes.ok) return null; + const { token } = (await tokenRes.json()) as { token?: string }; + if (!token) return null; + + const tagsRes = await fetch(`https://ghcr.io/v2/${image}/tags/list`, { + headers: { Authorization: `Bearer ${token}` }, + next: { revalidate: REVALIDATE_SECONDS }, + }); + if (!tagsRes.ok) return null; + const { tags } = (await tagsRes.json()) as { tags?: string[] }; + + let latest: { tag: string; parsed: Semver } | null = null; + for (const tag of tags ?? []) { + const parsed = parseSemver(tag); + if (!parsed) continue; + if (!latest || compareSemver(parsed, latest.parsed) > 0) { + latest = { tag, parsed }; + } + } + return latest?.tag ?? null; +} + +export async function GET() { + try { + const results = await Promise.all(GHCR_IMAGES.map(fetchLatestTag)); + + // Only advertise an update once every image has published a tag at that + // version — otherwise we'd nudge users to upgrade before the matching + // container actually exists. + let minLatest: { tag: string; parsed: Semver } | null = null; + for (const tag of results) { + if (!tag) return NextResponse.json({ latest: null }, { status: 200 }); + const parsed = parseSemver(tag); + if (!parsed) return NextResponse.json({ latest: null }, { status: 200 }); + if (!minLatest || compareSemver(parsed, minLatest.parsed) < 0) { + minLatest = { tag, parsed }; + } + } + + return NextResponse.json( + { latest: minLatest?.tag ?? null }, + { + headers: { + "Cache-Control": `public, max-age=${REVALIDATE_SECONDS}, s-maxage=${REVALIDATE_SECONDS}`, + }, + }, + ); + } catch { + return NextResponse.json({ latest: null }, { status: 200 }); + } +} diff --git a/ui/src/app/campaigns/GoogleSheetSelector.tsx b/ui/src/app/campaigns/GoogleSheetSelector.tsx deleted file mode 100644 index de3cfc6..0000000 --- a/ui/src/app/campaigns/GoogleSheetSelector.tsx +++ /dev/null @@ -1,237 +0,0 @@ -'use client'; - -import { useEffect, useState } from 'react'; -import { toast } from 'sonner'; - -import { getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet, getIntegrationsApiV1IntegrationGet } from '@/client/sdk.gen'; -import type { IntegrationResponse } from '@/client/types.gen'; -import { Button } from '@/components/ui/button'; -import { Label } from '@/components/ui/label'; -import logger from '@/lib/logger'; - -interface GoogleSheetSelectorProps { - accessToken: string; - onSheetSelected: (sheetUrl: string, sheetName: string) => void; - selectedSheetUrl?: string; -} - -interface PickerBuilder { - addView: (viewId: string) => PickerBuilder; - setOAuthToken: (token: string) => PickerBuilder; - setDeveloperKey: (key: string) => PickerBuilder; - setCallback: (callback: (data: { action: string; docs?: Array<{ id: string; name: string; url: string }> }) => void) => PickerBuilder; - setTitle: (title: string) => PickerBuilder; - build: () => { setVisible: (visible: boolean) => void }; -} - -declare global { - interface Window { - gapi: { - load: (library: string, callback: () => void) => void; - }; - google: { - picker: { - PickerBuilder: new () => PickerBuilder; - ViewId: { - SPREADSHEETS: string; - }; - Action: { - PICKED: string; - }; - }; - }; - } -} - -// Google API configuration -const GOOGLE_API_KEY = process.env.NEXT_PUBLIC_GOOGLE_API_KEY || ''; - -export default function GoogleSheetSelector({ accessToken, onSheetSelected, selectedSheetUrl }: GoogleSheetSelectorProps) { - const [loading, setLoading] = useState(false); - const [pickerApiLoaded, setPickerApiLoaded] = useState(false); - const [googleIntegration, setGoogleIntegration] = useState(null); - const [selectedSheetName, setSelectedSheetName] = useState(''); - const [checkingIntegration, setCheckingIntegration] = useState(true); - - // Load Google Picker API - useEffect(() => { - const script = document.createElement('script'); - script.src = 'https://apis.google.com/js/api.js'; - script.onload = () => { - window.gapi.load('picker', () => { - setPickerApiLoaded(true); - logger.info('Google Picker API loaded'); - }); - }; - document.body.appendChild(script); - - return () => { - if (document.body.contains(script)) { - document.body.removeChild(script); - } - }; - }, []); - - // Check for Google Sheet integration - useEffect(() => { - const checkGoogleIntegration = async () => { - if (!accessToken) { - return; - } - - try { - const response = await getIntegrationsApiV1IntegrationGet({ - headers: { - 'Authorization': `Bearer ${accessToken}`, - } - }); - - if (response.data) { - const integrations = Array.isArray(response.data) ? response.data : [response.data]; - const googleSheet = integrations.find((i: IntegrationResponse) => i.provider === 'google-sheet'); - setGoogleIntegration(googleSheet || null); - } - } catch (error) { - logger.error('Failed to check Google integration:', error); - } finally { - setCheckingIntegration(false); - } - }; - - checkGoogleIntegration(); - }, [accessToken]); - - const fetchGoogleAccessToken = async () => { - if (!googleIntegration) return null; - - try { - const response = await getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet({ - path: { - integration_id: googleIntegration.id, - }, - headers: { - Authorization: `Bearer ${accessToken}`, - } - }); - - if (response.data?.access_token) { - return response.data.access_token; - } - return null; - } catch (error) { - logger.error('Failed to fetch Google access token:', error); - return null; - } - }; - - const openGooglePicker = async () => { - if (!pickerApiLoaded) { - toast.error('Google Picker is still loading. Please try again.'); - return; - } - - if (!GOOGLE_API_KEY) { - toast.error('Google API Key is not configured.'); - return; - } - - if (!googleIntegration) { - toast.error('Please connect Google Sheets in the Integrations page first.'); - return; - } - - setLoading(true); - - try { - const token = await fetchGoogleAccessToken(); - if (!token) { - toast.error('Failed to get Google access token. Please re-authorize in Integrations.'); - setLoading(false); - return; - } - - const picker = new window.google.picker.PickerBuilder() - .addView(window.google.picker.ViewId.SPREADSHEETS) - .setOAuthToken(token) - .setDeveloperKey(GOOGLE_API_KEY) - .setCallback((data: { action: string; docs?: Array<{ id: string; name: string; url: string }> }) => { - if (data.action === window.google.picker.Action.PICKED && data.docs && data.docs.length > 0) { - const doc = data.docs[0]; - setSelectedSheetName(doc.name); - onSheetSelected(doc.url, doc.name); - toast.success(`Selected: ${doc.name}`); - } - setLoading(false); - }) - .setTitle('Select a Google Sheet for your campaign') - .build(); - - picker.setVisible(true); - } catch (error) { - toast.error('Error opening Google Picker'); - logger.error('Error opening Google Picker:', error); - setLoading(false); - } - }; - - if (checkingIntegration) { - return ( -
- -
Checking Google integration...
-
- ); - } - - if (!googleIntegration) { - return ( -
- -
-

- Google Sheets integration not found -

-

- Please go to the{' '} - - Integrations page - - {' '}and connect your Google account first. -

-
-
- ); - } - - return ( -
- -
- - {selectedSheetUrl && ( - - )} -
-

- Select a Google Sheet from your connected Google account -

-
- ); -} diff --git a/ui/src/app/campaigns/new/page.tsx b/ui/src/app/campaigns/new/page.tsx index 4d77841..aab2adc 100644 --- a/ui/src/app/campaigns/new/page.tsx +++ b/ui/src/app/campaigns/new/page.tsx @@ -30,7 +30,6 @@ import { useAuth } from '@/lib/auth'; import CampaignAdvancedSettings, { getTimezoneValue, type TimeSlot } from '../CampaignAdvancedSettings'; import CsvUploadSelector from '../CsvUploadSelector'; -import GoogleSheetSelector from '../GoogleSheetSelector'; export default function NewCampaignPage() { const { user, getAccessToken, redirectToLogin, loading } = useAuth(); @@ -39,12 +38,11 @@ export default function NewCampaignPage() { // Form state const [campaignName, setCampaignName] = useState(''); const [selectedWorkflowId, setSelectedWorkflowId] = useState(''); - const [sourceType, setSourceType] = useState<'google-sheet' | 'csv'>('csv'); + const [sourceType, setSourceType] = useState<'csv'>('csv'); const [sourceId, setSourceId] = useState(''); const [selectedFileName, setSelectedFileName] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [createError, setCreateError] = useState(null); - const [userAccessToken, setUserAccessToken] = useState(''); // Workflows state const [workflows, setWorkflows] = useState([]); @@ -97,7 +95,6 @@ export default function NewCampaignPage() { if (!user) return; try { const accessToken = await getAccessToken(); - setUserAccessToken(accessToken); const response = await getWorkflowsSummaryApiV1WorkflowSummaryGet({ headers: { 'Authorization': `Bearer ${accessToken}`, @@ -342,12 +339,6 @@ export default function NewCampaignPage() { router.push('/campaigns'); }; - // Handle sheet selection - const handleSheetSelected = (sheetUrl: string) => { - setSourceId(sheetUrl); - setCreateError(null); - }; - // Handle CSV file upload const handleFileUploaded = (fileKey: string, fileName: string) => { setSourceId(fileKey); @@ -481,7 +472,7 @@ export default function NewCampaignPage() { @@ -500,18 +490,10 @@ export default function NewCampaignPage() {

- {sourceType === 'google-sheet' ? ( - - ) : ( - - )} + {/* Advanced Settings */} { - setIsLoading(true); - try { - if (!user) { - throw new Error('User not authenticated'); - } - const accessToken = await getAccessToken(); - - // Fetch session details from our API - const sessionResponse = await createSessionApiV1IntegrationSessionPost({ - headers: { - 'Authorization': `Bearer ${accessToken}`, - }, - }); - - if (!sessionResponse.data?.session_token) { - throw new Error('Failed to get session token'); - } - - // Initialize Nango and open connect UI - const nango = new Nango(); - const connect = nango.openConnectUI({ - onEvent: (event) => { - if (event.type === 'close') { - // Handle modal closed - setIsLoading(false); - logger.info('Nango connect UI closed'); - } else if (event.type === 'connect') { - // Handle auth flow successful - setIsLoading(false); - logger.info('Integration connected successfully'); - // Refresh the page to show new integrations - window.location.reload(); - } - }, - }); - - // Set the session token to initialize the connect UI - connect.setSessionToken(sessionResponse.data.session_token); - - } catch (err) { - logger.error(`Error creating integration: ${err}`); - setIsLoading(false); - // You might want to show a toast notification here - alert('Failed to create integration. Please try again.'); - } - }; - - return ( - - ); -} diff --git a/ui/src/app/integrations/[id]/gmail/page.tsx b/ui/src/app/integrations/[id]/gmail/page.tsx deleted file mode 100644 index 7bb1695..0000000 --- a/ui/src/app/integrations/[id]/gmail/page.tsx +++ /dev/null @@ -1,390 +0,0 @@ -'use client'; - -import { useParams, useRouter } from 'next/navigation'; -import { useCallback, useEffect, useState } from 'react'; - -import { getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet } from '@/client/sdk.gen'; -import { useAuth } from '@/lib/auth'; -import logger from '@/lib/logger'; - -interface Email { - id: string; - threadId: string; - subject: string; - from: string; - snippet: string; - date: string; -} - -interface EmailDetail { - id: string; - threadId: string; - subject: string; - from: string; - to: string; - date: string; - body: string; -} - -interface GmailHeader { - name: string; - value: string; -} - -interface GmailPayloadPart { - mimeType: string; - body: { - data?: string; - }; -} - -export default function GmailSearchPage() { - const params = useParams(); - const router = useRouter(); - const integrationId = parseInt(params.id as string); - const { getAccessToken, redirectToLogin } = useAuth(); - - const [accessToken, setAccessToken] = useState(null); - const [searchQuery, setSearchQuery] = useState(''); - const [emails, setEmails] = useState([]); - const [selectedEmail, setSelectedEmail] = useState(null); - const [replyText, setReplyText] = useState(''); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [sendingReply, setSendingReply] = useState(false); - - const fetchAccessToken = useCallback(async () => { - try { - const token = await getAccessToken(); - if (!token) { - redirectToLogin(); - return; - } - - const response = await getIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGet({ - path: { integration_id: integrationId }, - headers: { Authorization: `Bearer ${token}` }, - }); - - if (response.data?.access_token) { - setAccessToken(response.data.access_token); - } else { - setError('Failed to get access token'); - } - } catch (err) { - logger.error('Error fetching access token:', err); - setError('Failed to fetch access token. Please try again.'); - } - }, [getAccessToken, redirectToLogin, integrationId]); - - useEffect(() => { - fetchAccessToken(); - }, [fetchAccessToken]); - - const searchEmails = async () => { - if (!accessToken || !searchQuery.trim()) return; - - setLoading(true); - setError(null); - setEmails([]); - setSelectedEmail(null); - - try { - const response = await fetch( - `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(searchQuery)}&maxResults=20`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ); - - if (!response.ok) { - throw new Error(`Gmail API error: ${response.statusText}`); - } - - const data = await response.json(); - - if (!data.messages || data.messages.length === 0) { - setEmails([]); - return; - } - - // Fetch details for each message - const emailPromises = data.messages.map(async (msg: { id: string }) => { - const msgResponse = await fetch( - `https://gmail.googleapis.com/gmail/v1/users/me/messages/${msg.id}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ); - return msgResponse.json(); - }); - - const emailDetails = await Promise.all(emailPromises); - - const formattedEmails: Email[] = emailDetails.map((email) => { - const headers = email.payload.headers as GmailHeader[]; - const subject = headers.find((h) => h.name === 'Subject')?.value || 'No Subject'; - const from = headers.find((h) => h.name === 'From')?.value || 'Unknown'; - const date = headers.find((h) => h.name === 'Date')?.value || ''; - - return { - id: email.id, - threadId: email.threadId, - subject, - from, - snippet: email.snippet || '', - date, - }; - }); - - setEmails(formattedEmails); - } catch (err) { - logger.error('Error searching emails:', err); - setError('Failed to search emails. Please try again.'); - } finally { - setLoading(false); - } - }; - - const loadEmailDetail = async (emailId: string) => { - if (!accessToken) return; - - setLoading(true); - setError(null); - - try { - const response = await fetch( - `https://gmail.googleapis.com/gmail/v1/users/me/messages/${emailId}?format=full`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - } - ); - - if (!response.ok) { - throw new Error(`Gmail API error: ${response.statusText}`); - } - - const email = await response.json(); - const headers = email.payload.headers as GmailHeader[]; - - const subject = headers.find((h) => h.name === 'Subject')?.value || 'No Subject'; - const from = headers.find((h) => h.name === 'From')?.value || 'Unknown'; - const to = headers.find((h) => h.name === 'To')?.value || 'Unknown'; - const date = headers.find((h) => h.name === 'Date')?.value || ''; - - // Extract email body - let body = ''; - if (email.payload.body.data) { - body = atob(email.payload.body.data.replace(/-/g, '+').replace(/_/g, '/')); - } else if (email.payload.parts) { - const parts = email.payload.parts as GmailPayloadPart[]; - const textPart = parts.find((part) => part.mimeType === 'text/plain'); - if (textPart && textPart.body.data) { - body = atob(textPart.body.data.replace(/-/g, '+').replace(/_/g, '/')); - } - } - - setSelectedEmail({ - id: email.id, - threadId: email.threadId, - subject, - from, - to, - date, - body, - }); - } catch (err) { - logger.error('Error loading email detail:', err); - setError('Failed to load email details. Please try again.'); - } finally { - setLoading(false); - } - }; - - const sendReply = async () => { - if (!accessToken || !selectedEmail || !replyText.trim()) return; - - setSendingReply(true); - setError(null); - - try { - // Create the email message - const to = selectedEmail.from.match(/<(.+)>/)?.[1] || selectedEmail.from; - const subject = selectedEmail.subject.startsWith('Re:') - ? selectedEmail.subject - : `Re: ${selectedEmail.subject}`; - - const messageParts = [ - `To: ${to}`, - `Subject: ${subject}`, - `In-Reply-To: ${selectedEmail.id}`, - `References: ${selectedEmail.id}`, - '', - replyText, - ]; - - const message = messageParts.join('\n'); - const encodedMessage = btoa(message) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - - const response = await fetch( - `https://gmail.googleapis.com/gmail/v1/users/me/messages/send`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - raw: encodedMessage, - threadId: selectedEmail.threadId, - }), - } - ); - - if (!response.ok) { - throw new Error(`Gmail API error: ${response.statusText}`); - } - - alert('Reply sent successfully!'); - setReplyText(''); - } catch (err) { - logger.error('Error sending reply:', err); - setError('Failed to send reply. Please try again.'); - } finally { - setSendingReply(false); - } - }; - - return ( -
-
- -

Gmail Search

-
- - {/* Search Section */} -
-
- setSearchQuery(e.target.value)} - onKeyPress={(e) => e.key === 'Enter' && searchEmails()} - placeholder="Search emails (e.g., from:user@example.com, subject:meeting)" - className="flex-1 px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" - disabled={!accessToken || loading} - /> - -
-
- - {error && ( -
- {error} -
- )} - -
- {/* Email List */} -
-
-

Search Results

-
-
- {emails.length === 0 && !loading && ( -
- {searchQuery ? 'No emails found' : 'Enter a search query to find emails'} -
- )} - {emails.map((email) => ( -
loadEmailDetail(email.id)} - className={`p-4 cursor-pointer hover:bg-muted/50 ${ - selectedEmail?.id === email.id ? 'bg-accent' : '' - }`} - > -
{email.subject}
-
{email.from}
-
{email.snippet}
-
{email.date}
-
- ))} -
-
- - {/* Email Detail and Reply */} -
-
-

Email Details

-
- {selectedEmail ? ( -
-
-
- Subject: {selectedEmail.subject} -
-
- From: {selectedEmail.from} -
-
- To: {selectedEmail.to} -
-
- Date: {selectedEmail.date} -
-
- -
-
{selectedEmail.body}
-
- -
-

Reply

-