From cdb27c1d4fe1d617651004245debf912aa6a2ae1 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Wed, 3 Jun 2026 18:47:04 +0530 Subject: [PATCH 01/14] fix: use runtime BACKEND_URL for proxying (#411) * fix: use runtime BACKEND_URL for proxying Fixes #400 * chore: run formatter --- ...84be6596b36_make_email_case_insensitive.py | 26 +++-- .../src/dograh_sdk/_generated_models.py | 4 +- ui/next.config.ts | 5 - ui/src/app/api/v1/[...path]/route.ts | 104 ++++++++++++++++++ ui/src/lib/auth/config.ts | 4 +- ui/src/middleware.ts | 4 +- 6 files changed, 130 insertions(+), 17 deletions(-) create mode 100644 ui/src/app/api/v1/[...path]/route.ts diff --git a/api/alembic/versions/384be6596b36_make_email_case_insensitive.py b/api/alembic/versions/384be6596b36_make_email_case_insensitive.py index a300f477..11357c98 100644 --- a/api/alembic/versions/384be6596b36_make_email_case_insensitive.py +++ b/api/alembic/versions/384be6596b36_make_email_case_insensitive.py @@ -5,28 +5,38 @@ Revises: 6bd9f67ec994 Create Date: 2026-06-02 07:58:00.002359 """ + from typing import Sequence, Union -from alembic import op import sqlalchemy as sa - +from alembic import op # revision identifiers, used by Alembic. -revision: str = '384be6596b36' -down_revision: Union[str, None] = '6bd9f67ec994' +revision: str = "384be6596b36" +down_revision: Union[str, None] = "6bd9f67ec994" 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.drop_index(op.f('ix_users_email'), table_name='users') - op.create_index('ix_users_email_lower', 'users', [sa.literal_column('lower(email)')], unique=True, postgresql_where=sa.text('email IS NOT NULL')) + op.drop_index(op.f("ix_users_email"), table_name="users") + op.create_index( + "ix_users_email_lower", + "users", + [sa.literal_column("lower(email)")], + unique=True, + postgresql_where=sa.text("email IS NOT NULL"), + ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_index('ix_users_email_lower', table_name='users', postgresql_where=sa.text('email IS NOT NULL')) - op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.drop_index( + "ix_users_email_lower", + table_name="users", + postgresql_where=sa.text("email IS NOT NULL"), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) # ### end Alembic commands ### diff --git a/sdk/python/src/dograh_sdk/_generated_models.py b/sdk/python/src/dograh_sdk/_generated_models.py index 7f2fa8a7..ec27e029 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-rs5H7P.json -# timestamp: 2026-06-02T06:01:29+00:00 +# filename: dograh-openapi-uraOZf.json +# timestamp: 2026-06-03T11:53:30+00:00 from __future__ import annotations diff --git a/ui/next.config.ts b/ui/next.config.ts index 1b8a3996..98242c20 100644 --- a/ui/next.config.ts +++ b/ui/next.config.ts @@ -9,11 +9,6 @@ const nextConfig: NextConfig = { }, async rewrites() { return [ - // API proxy for backend calls (excluding Next.js API routes) - { - source: "/api/:path((?!config|auth).*)*", - destination: `${process.env.BACKEND_URL || 'http://localhost:8000'}/api/:path*`, - }, { source: "/ingest/static/:path*", destination: "https://us-assets.i.posthog.com/static/:path*", diff --git a/ui/src/app/api/v1/[...path]/route.ts b/ui/src/app/api/v1/[...path]/route.ts new file mode 100644 index 00000000..7f89b0ab --- /dev/null +++ b/ui/src/app/api/v1/[...path]/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { getServerBackendUrl } from "@/lib/apiClient"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const HOP_BY_HOP_HEADERS = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +function trimTrailingSlash(url: string) { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +function buildBackendUrl(request: NextRequest) { + const backendUrl = trimTrailingSlash(getServerBackendUrl()); + return `${backendUrl}${request.nextUrl.pathname}${request.nextUrl.search}`; +} + +function createRequestHeaders(request: NextRequest) { + const headers = new Headers(request.headers); + + for (const header of HOP_BY_HOP_HEADERS) { + headers.delete(header); + } + + headers.delete("accept-encoding"); + headers.delete("content-length"); + headers.delete("host"); + + return headers; +} + +function createResponseHeaders(response: Response) { + const headers = new Headers(response.headers); + const setCookies = response.headers.getSetCookie(); + + for (const header of HOP_BY_HOP_HEADERS) { + headers.delete(header); + } + + headers.delete("content-encoding"); + headers.delete("content-length"); + headers.delete("set-cookie"); + + for (const cookie of setCookies) { + headers.append("set-cookie", cookie); + } + + return headers; +} + +async function getRequestBody(request: NextRequest) { + if (request.method === "GET" || request.method === "HEAD") { + return undefined; + } + + return request.arrayBuffer(); +} + +async function proxyRequest(request: NextRequest) { + const backendUrl = buildBackendUrl(request); + + try { + const response = await fetch(backendUrl, { + method: request.method, + headers: createRequestHeaders(request), + body: await getRequestBody(request), + cache: "no-store", + }); + + return new Response(request.method === "HEAD" ? null : response.body, { + status: response.status, + statusText: response.statusText, + headers: createResponseHeaders(response), + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown backend proxy error"; + + return NextResponse.json( + { + detail: `Backend request failed while proxying to ${backendUrl}: ${message}`, + }, + { status: 502 }, + ); + } +} + +export const GET = proxyRequest; +export const POST = proxyRequest; +export const PUT = proxyRequest; +export const PATCH = proxyRequest; +export const DELETE = proxyRequest; +export const OPTIONS = proxyRequest; +export const HEAD = proxyRequest; diff --git a/ui/src/lib/auth/config.ts b/ui/src/lib/auth/config.ts index b58927bc..1958297d 100644 --- a/ui/src/lib/auth/config.ts +++ b/ui/src/lib/auth/config.ts @@ -1,5 +1,7 @@ import "server-only"; +import { getServerBackendUrl } from "@/lib/apiClient"; + let cachedAuthProvider: string | null = null; /** @@ -12,7 +14,7 @@ export async function getAuthProvider(): Promise { } try { - const backendUrl = process.env.BACKEND_URL || "http://localhost:8000"; + const backendUrl = getServerBackendUrl(); const res = await fetch(`${backendUrl}/api/v1/health`, { next: { revalidate: 300 }, }); diff --git a/ui/src/middleware.ts b/ui/src/middleware.ts index f73231a9..12014dcb 100644 --- a/ui/src/middleware.ts +++ b/ui/src/middleware.ts @@ -1,6 +1,8 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +import { getServerBackendUrl } from '@/lib/apiClient'; + const OSS_TOKEN_COOKIE = 'dograh_auth_token'; // Paths that don't require authentication in OSS mode @@ -14,7 +16,7 @@ async function fetchAuthProvider(): Promise { } try { - const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; + const backendUrl = getServerBackendUrl(); const res = await fetch(`${backendUrl}/api/v1/health`); if (res.ok) { const data = await res.json(); From dace4a7efcd37a32fb3c708cc05a4115be214ca1 Mon Sep 17 00:00:00 2001 From: nuthalapativarun Date: Wed, 3 Jun 2026 08:57:44 -0700 Subject: [PATCH 02/14] fix: add CORS preflight handler and ACAO header for embed config endpoint (#403) * fix: add CORS preflight handler and ACAO header for embed config endpoint The GET /public/embed/config/{token} endpoint is fetched by external websites (third-party embed sites). The global CORSMiddleware only covers first-party origins, so external origins received no Access-Control-Allow- Origin header, causing browser preflight failures. Add an OPTIONS /config/{token} handler that validates the origin against the token's allowed_domains list and returns the appropriate CORS headers. Also inject Access-Control-Allow-Origin into the GET response via FastAPI's response parameter so the actual request succeeds cross-origin. Closes #383 * fix: complete public embed CORS handling --------- Co-authored-by: Abhishek Kumar --- api/app.py | 9 + api/routes/public_embed.py | 262 ++++++++++++++++++-------- api/tests/test_public_embed_cors.py | 274 ++++++++++++++++++++++++++++ 3 files changed, 466 insertions(+), 79 deletions(-) create mode 100644 api/tests/test_public_embed_cors.py diff --git a/api/app.py b/api/app.py index b2b28111..1dd9413f 100644 --- a/api/app.py +++ b/api/app.py @@ -117,6 +117,15 @@ app.add_middleware( allow_headers=["*"], ) + +def _add_public_embed_cors_middleware() -> None: + from api.routes.public_embed import PublicEmbedCORSMiddleware + + app.add_middleware(PublicEmbedCORSMiddleware, api_prefix=API_PREFIX) + + +_add_public_embed_cors_middleware() + api_router = APIRouter() # include subrouters here diff --git a/api/routes/public_embed.py b/api/routes/public_embed.py index 058def54..e8a699a7 100644 --- a/api/routes/public_embed.py +++ b/api/routes/public_embed.py @@ -7,6 +7,7 @@ They handle CORS, domain validation, and session management for embedded workflo import secrets from datetime import UTC, datetime, timedelta from typing import Optional +from urllib.parse import urlsplit from fastapi import ( APIRouter, @@ -16,6 +17,8 @@ from fastapi import ( ) from loguru import logger from pydantic import BaseModel +from starlette.datastructures import Headers +from starlette.types import ASGIApp, Receive, Scope, Send from api.db import db_client from api.enums import WorkflowRunMode @@ -27,6 +30,9 @@ from api.routes.turn_credentials import ( router = APIRouter(prefix="/public/embed") +EMBED_CORS_ALLOW_HEADERS = "Content-Type, Origin" +EMBED_CORS_MAX_AGE = "86400" + class InitEmbedRequest(BaseModel): """Request model for initializing an embed session""" @@ -70,11 +76,9 @@ def validate_origin(origin: str, allowed_domains: list) -> bool: # If no domains specified, allow all origins return True - # Extract domain from origin (remove protocol) - if "://" in origin: - domain = origin.split("://")[1].split("/")[0].split(":")[0] - else: - domain = origin + domain, origin_port = _parse_origin_host_port(origin) + if not domain: + return False # Normalize domain for www matching def normalize_www(d: str) -> tuple[str, str]: @@ -87,16 +91,23 @@ def validate_origin(origin: str, allowed_domains: list) -> bool: domain_variants = normalize_www(domain) for allowed in allowed_domains: + allowed = str(allowed).strip().lower() if allowed == "*": return True - elif allowed.startswith("*."): + allowed_domain, allowed_port = _parse_origin_host_port(allowed) + if not allowed_domain: + continue + if allowed_port is not None and allowed_port != origin_port: + continue + + if allowed_domain.startswith("*."): # Wildcard subdomain matching - base_domain = allowed[2:] + base_domain = allowed_domain[2:] if domain == base_domain or domain.endswith("." + base_domain): return True else: # Check both www and non-www versions - allowed_variants = normalize_www(allowed) + allowed_variants = normalize_www(allowed_domain) # If any variant of domain matches any variant of allowed, it's valid if any( dv in allowed_variants or av in domain_variants @@ -108,6 +119,24 @@ def validate_origin(origin: str, allowed_domains: list) -> bool: return False +def _parse_origin_host_port(value: str) -> tuple[str, str | None]: + candidate = value.strip().lower() + if not candidate: + return "", None + + if "://" not in candidate and not candidate.startswith("//"): + candidate = f"//{candidate}" + + parsed = urlsplit(candidate) + try: + parsed_port = parsed.port + except ValueError: + parsed_port = None + + port = str(parsed_port) if parsed_port is not None else None + return (parsed.hostname or "").rstrip("."), port + + def generate_session_token() -> str: """Generate a cryptographically secure session token""" return f"emb_session_{secrets.token_urlsafe(32)}" @@ -121,8 +150,120 @@ def get_request_origin(request: Request) -> str: return origin +def _cors_response(origin: str, methods: str) -> Response: + return Response( + headers={ + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": methods, + "Access-Control-Allow-Headers": EMBED_CORS_ALLOW_HEADERS, + "Access-Control-Max-Age": EMBED_CORS_MAX_AGE, + "Vary": "Origin", + } + ) + + +def _allow_embed_origin(response: Response, origin: str) -> None: + response.headers["Access-Control-Allow-Origin"] = origin + vary = response.headers.get("Vary") + if not vary: + response.headers["Vary"] = "Origin" + return + + vary_values = {value.strip().lower() for value in vary.split(",")} + if "origin" not in vary_values: + response.headers["Vary"] = f"{vary}, Origin" + + +async def _config_preflight_response(token: str, origin: str) -> Response: + embed_token = await db_client.get_embed_token_by_token(token) + if not embed_token or not embed_token.is_active: + return Response(status_code=403) + + if not validate_origin(origin, embed_token.allowed_domains or []): + return Response(status_code=403) + + return _cors_response(origin, "GET, OPTIONS") + + +async def _turn_credentials_preflight_response( + session_token: str, origin: str +) -> Response: + embed_session = await db_client.get_embed_session_by_token(session_token) + if not embed_session: + return Response(status_code=403) + + if embed_session.expires_at and embed_session.expires_at < datetime.now(UTC): + return Response(status_code=403) + + embed_token = await db_client.get_embed_token_by_id(embed_session.embed_token_id) + if not embed_token: + return Response(status_code=403) + + if not validate_origin(origin, embed_token.allowed_domains or []): + return Response(status_code=403) + + return _cors_response(origin, "GET, OPTIONS") + + +async def build_public_embed_preflight_response( + path: str, origin: str, requested_method: str, api_prefix: str = "/api/v1" +) -> Response | None: + """Handle embed preflights before global CORSMiddleware rejects external sites.""" + public_embed_prefix = f"{api_prefix.rstrip('/')}/public/embed" + + if path == f"{public_embed_prefix}/init": + if requested_method.upper() != "POST": + return Response(status_code=405) + return _cors_response(origin, "POST, OPTIONS") + + config_prefix = f"{public_embed_prefix}/config/" + if path.startswith(config_prefix): + if requested_method.upper() != "GET": + return Response(status_code=405) + token = path[len(config_prefix) :].split("/", 1)[0] + return await _config_preflight_response(token, origin) + + turn_credentials_prefix = f"{public_embed_prefix}/turn-credentials/" + if path.startswith(turn_credentials_prefix): + if requested_method.upper() != "GET": + return Response(status_code=405) + session_token = path[len(turn_credentials_prefix) :].split("/", 1)[0] + return await _turn_credentials_preflight_response(session_token, origin) + + return None + + +class PublicEmbedCORSMiddleware: + """Allow token-gated embed CORS before global SaaS CORS rejects preflights.""" + + def __init__(self, app: ASGIApp, api_prefix: str = "/api/v1"): + self.app = app + self.api_prefix = api_prefix + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or scope.get("method") != "OPTIONS": + await self.app(scope, receive, send) + return + + headers = Headers(scope=scope) + origin = headers.get("origin") + requested_method = headers.get("access-control-request-method") + + if origin and requested_method: + response = await build_public_embed_preflight_response( + scope.get("path", ""), origin, requested_method, self.api_prefix + ) + if response is not None: + await response(scope, receive, send) + return + + await self.app(scope, receive, send) + + @router.post("/init", response_model=InitEmbedResponse) -async def initialize_embed_session(request: Request, init_request: InitEmbedRequest): +async def initialize_embed_session( + request: Request, init_request: InitEmbedRequest, response: Response +): """Initialize an embed session with token validation and domain checking. This endpoint: @@ -158,6 +299,9 @@ async def initialize_embed_session(request: Request, init_request: InitEmbedRequ ) raise HTTPException(status_code=403, detail=f"Domain not allowed: {origin}") + if origin: + _allow_embed_origin(response, origin) + # Create workflow run try: workflow_run = await db_client.create_workflow_run( @@ -204,8 +348,19 @@ async def initialize_embed_session(request: Request, init_request: InitEmbedRequ ) +@router.options("/config/{token}") +async def options_embed_config(token: str, request: Request): + """Fallback OPTIONS handler for the embed config endpoint. + + Browser preflights include Access-Control-Request-Method and are handled by + PublicEmbedCORSMiddleware before global CORS. This keeps non-conformant + OPTIONS requests on the same validation path. + """ + return await _config_preflight_response(token, request.headers.get("origin", "")) + + @router.get("/config/{token}", response_model=EmbedConfigResponse) -async def get_embed_config(token: str, request: Request): +async def get_embed_config(token: str, request: Request, response: Response): """Get embed configuration without creating a session. This endpoint is used to fetch widget configuration for display purposes @@ -226,6 +381,11 @@ async def get_embed_config(token: str, request: Request): if not validate_origin(origin, embed_token.allowed_domains or []): raise HTTPException(status_code=403, detail=f"Domain not allowed: {origin}") + # Set CORS header explicitly; the global CORSMiddleware covers only + # first-party origins; this endpoint is fetched by external embed sites. + if origin: + _allow_embed_origin(response, origin) + # Extract settings with defaults settings = embed_token.settings or {} @@ -243,24 +403,20 @@ async def get_embed_config(token: str, request: Request): @router.options("/init") async def options_init(request: Request): - """Handle CORS preflight for init endpoint""" + """Fallback OPTIONS handler for init endpoint.""" + # Browser preflights are handled by PublicEmbedCORSMiddleware before global CORS. # For init endpoint, we need to check the token in the request body # But OPTIONS requests don't have body, so we'll be permissive # The actual validation happens in the POST request origin = request.headers.get("origin", "*") - return Response( - headers={ - "Access-Control-Allow-Origin": origin, - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Origin", - "Access-Control-Max-Age": "86400", - } - ) + return _cors_response(origin, "POST, OPTIONS") @router.get("/turn-credentials/{session_token}", response_model=TurnCredentialsResponse) -async def get_public_turn_credentials(session_token: str, request: Request): +async def get_public_turn_credentials( + session_token: str, request: Request, response: Response +): """Get TURN credentials for an embed session. This endpoint allows embedded widgets to obtain TURN server credentials @@ -295,6 +451,9 @@ async def get_public_turn_credentials(session_token: str, request: Request): ) raise HTTPException(status_code=403, detail=f"Domain not allowed: {origin}") + if origin: + _allow_embed_origin(response, origin) + # Check if TURN is configured if not TURN_SECRET: raise HTTPException( @@ -316,63 +475,8 @@ async def get_public_turn_credentials(session_token: str, request: Request): @router.options("/turn-credentials/{session_token}") async def options_turn_credentials(request: Request, session_token: str): - """Handle CORS preflight for TURN credentials endpoint""" - origin = request.headers.get("origin", "*") - - # Try to validate the session token and get allowed domains - allowed_origin = origin - try: - embed_session = await db_client.get_embed_session_by_token(session_token) - if embed_session: - embed_token = await db_client.get_embed_token_by_id( - embed_session.embed_token_id - ) - if embed_token: - # Check if origin is in allowed domains (empty means allow all) - if validate_origin(origin, embed_token.allowed_domains or []): - allowed_origin = origin - else: - allowed_origin = "" - except Exception: - # On error, be permissive for OPTIONS - pass - - return Response( - headers={ - "Access-Control-Allow-Origin": allowed_origin, - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - "Access-Control-Max-Age": "86400", - } - ) - - -@router.options("/config/{token}") -async def options_config(request: Request, token: str): - """Handle CORS preflight for config endpoint""" - # Get origin header - origin = request.headers.get("origin", "*") - - # Try to validate the token and get allowed domains - allowed_origin = origin - try: - embed_token = await db_client.get_embed_token_by_token(token) - if embed_token and embed_token.is_active: - # Check if origin is in allowed domains - if validate_origin(origin, embed_token.allowed_domains or []): - allowed_origin = origin - else: - # If not allowed, don't include the origin - allowed_origin = "" - except Exception: - # On error, be permissive for OPTIONS - pass - - return Response( - headers={ - "Access-Control-Allow-Origin": allowed_origin, - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - "Access-Control-Max-Age": "86400", - } + """Fallback OPTIONS handler for TURN credentials endpoint.""" + # Browser preflights are handled by PublicEmbedCORSMiddleware before global CORS. + return await _turn_credentials_preflight_response( + session_token, request.headers.get("origin", "") ) diff --git a/api/tests/test_public_embed_cors.py b/api/tests/test_public_embed_cors.py new file mode 100644 index 00000000..5683f38c --- /dev/null +++ b/api/tests/test_public_embed_cors.py @@ -0,0 +1,274 @@ +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.testclient import TestClient + +from api.routes.public_embed import PublicEmbedCORSMiddleware, router + +app = FastAPI() +app.add_middleware( + CORSMiddleware, + allow_origins=["https://app.dograh.com"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.add_middleware(PublicEmbedCORSMiddleware, api_prefix="/api/v1") +app.include_router(router, prefix="/api/v1") +client = TestClient(app, raise_server_exceptions=False) + +_ACTIVE_TOKEN = SimpleNamespace( + id=10, + is_active=True, + expires_at=None, + allowed_domains=[], + workflow_id=1, + created_by=7, + usage_limit=None, + usage_count=0, + settings={}, +) + +_RESTRICTED_TOKEN = SimpleNamespace( + id=20, + is_active=True, + expires_at=None, + allowed_domains=["allowed.example.com"], + workflow_id=2, + created_by=7, + usage_limit=None, + usage_count=0, + settings={}, +) + +_LOCALHOST_TOKEN = SimpleNamespace( + id=30, + is_active=True, + expires_at=None, + allowed_domains=["localhost:3000", "localhost:3020"], + workflow_id=3, + created_by=7, + usage_limit=None, + usage_count=0, + settings={}, +) + + +@pytest.fixture(autouse=True) +def _patch_db(monkeypatch): + async def _get_token(token): + if token == "valid": + return _ACTIVE_TOKEN + if token == "restricted": + return _RESTRICTED_TOKEN + if token == "localhost": + return _LOCALHOST_TOKEN + return None + + async def _get_token_by_id(token_id): + if token_id == _ACTIVE_TOKEN.id: + return _ACTIVE_TOKEN + if token_id == _RESTRICTED_TOKEN.id: + return _RESTRICTED_TOKEN + if token_id == _LOCALHOST_TOKEN.id: + return _LOCALHOST_TOKEN + return None + + async def _get_session(session_token): + if session_token == "session-valid": + return SimpleNamespace(embed_token_id=_ACTIVE_TOKEN.id, expires_at=None) + if session_token == "session-restricted": + return SimpleNamespace(embed_token_id=_RESTRICTED_TOKEN.id, expires_at=None) + return None + + async def _create_workflow_run(**_kwargs): + return SimpleNamespace(id=123) + + async def _noop(*_args, **_kwargs): + return None + + monkeypatch.setattr( + "api.routes.public_embed.db_client.get_embed_token_by_token", + _get_token, + ) + monkeypatch.setattr( + "api.routes.public_embed.db_client.get_embed_token_by_id", + _get_token_by_id, + ) + monkeypatch.setattr( + "api.routes.public_embed.db_client.get_embed_session_by_token", + _get_session, + ) + monkeypatch.setattr( + "api.routes.public_embed.db_client.create_workflow_run", + _create_workflow_run, + ) + monkeypatch.setattr( + "api.routes.public_embed.db_client.create_embed_session", + _noop, + ) + monkeypatch.setattr( + "api.routes.public_embed.db_client.increment_embed_token_usage", + _noop, + ) + monkeypatch.setattr("api.routes.public_embed.TURN_SECRET", "test-secret") + monkeypatch.setattr( + "api.routes.public_embed.generate_turn_credentials", + lambda _user_id: { + "username": "turn-user", + "password": "turn-password", + "ttl": 3600, + "uris": ["turn:example.com:3478"], + }, + ) + + +def _assert_embed_cors(resp, origin: str): + assert resp.headers.get("access-control-allow-origin") == origin + assert "origin" in { + value.strip().lower() for value in resp.headers.get("vary", "").split(",") + } + + +def test_options_config_returns_acao_for_allowed_origin(): + origin = "https://mysite.vercel.app" + resp = client.options( + "/api/v1/public/embed/config/valid", + headers={ + "Origin": origin, + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_options_config_accepts_allowed_localhost_port(): + origin = "http://localhost:3020" + resp = client.options( + "/api/v1/public/embed/config/localhost", + headers={ + "Origin": origin, + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_options_config_rejects_unknown_token(): + resp = client.options( + "/api/v1/public/embed/config/unknown", + headers={ + "Origin": "https://mysite.vercel.app", + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 403 + + +def test_options_config_rejects_disallowed_origin(): + resp = client.options( + "/api/v1/public/embed/config/restricted", + headers={ + "Origin": "https://notallowed.example.com", + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 403 + + +def test_get_config_includes_acao_header(): + origin = "https://mysite.vercel.app" + resp = client.get( + "/api/v1/public/embed/config/valid", + headers={"Origin": origin}, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_get_config_accepts_allowed_localhost_port(): + origin = "http://localhost:3020" + resp = client.get( + "/api/v1/public/embed/config/localhost", + headers={"Origin": origin}, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_get_config_rejects_unlisted_localhost_port(): + resp = client.get( + "/api/v1/public/embed/config/localhost", + headers={"Origin": "http://localhost:3021"}, + ) + assert resp.status_code == 403 + + +def test_get_config_rejects_disallowed_origin(): + resp = client.get( + "/api/v1/public/embed/config/restricted", + headers={"Origin": "https://notallowed.example.com"}, + ) + assert resp.status_code == 403 + + +def test_init_includes_acao_header(): + origin = "https://mysite.vercel.app" + resp = client.post( + "/api/v1/public/embed/init", + headers={"Origin": origin}, + json={"token": "valid"}, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_turn_credentials_includes_acao_header(): + origin = "https://mysite.vercel.app" + resp = client.get( + "/api/v1/public/embed/turn-credentials/session-valid", + headers={"Origin": origin}, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_options_init_returns_acao_for_allowed_origin(): + origin = "https://mysite.vercel.app" + resp = client.options( + "/api/v1/public/embed/init", + headers={ + "Origin": origin, + "Access-Control-Request-Method": "POST", + }, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_options_turn_credentials_returns_acao_for_allowed_origin(): + origin = "https://mysite.vercel.app" + resp = client.options( + "/api/v1/public/embed/turn-credentials/session-valid", + headers={ + "Origin": origin, + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_options_turn_credentials_rejects_disallowed_origin(): + resp = client.options( + "/api/v1/public/embed/turn-credentials/session-restricted", + headers={ + "Origin": "https://notallowed.example.com", + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 403 From 2326a2f65ac3f496d6d21c0672e7c24dc289d943 Mon Sep 17 00:00:00 2001 From: Sabiha Khan <87858386+chewwbaka@users.noreply.github.com> Date: Wed, 3 Jun 2026 21:41:13 +0530 Subject: [PATCH 03/14] chore(main): release dograh 1.34.0 (#389) --- .release-please-manifest.json | 2 +- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ api/pyproject.toml | 2 +- ui/package.json | 2 +- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0fef5f8a..dc38bc3d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.33.0" + ".": "1.34.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8842b706..894aa1f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 1.34.0 (2026-06-03) + + + +## What's Changed +### Features +* feat: add mcp guides for various topic and stages for bot building by @a6kme in https://github.com/dograh-hq/dograh/pull/380 +* feat: allow overriding base URL of OpenAI STT and TTS by @developer603 in https://github.com/dograh-hq/dograh/pull/377 +* feat: add Azure AI multi-provider support (TTS, STT, Embeddings, Realtime) by @vishaldhateria in https://github.com/dograh-hq/dograh/pull/381 +### Bug Fixes +* fix: support object and array parameters in custom HTTP tools by @mvanhorn in https://github.com/dograh-hq/dograh/pull/373 +* fix(telephony): resolve transfer context via call-sid index instead of KEYS scan by @shiminshen in https://github.com/dograh-hq/dograh/pull/387 +* fix(webrtc): enforce embed allowed-domain policy on public signaling websocket by @shiminshen in https://github.com/dograh-hq/dograh/pull/388 +* fix: use runtime BACKEND_URL for proxying by @a6kme in https://github.com/dograh-hq/dograh/pull/411 +* fix: add CORS preflight handler and ACAO header for embed config endpoint by @nuthalapativarun in https://github.com/dograh-hq/dograh/pull/403 +### Other Changes +* Add Sarvam LLM, update Sarvam STT models, expose usage_info on run detail by @abhaybabbar in https://github.com/dograh-hq/dograh/pull/351 +* fix: make email lookup case-insensitive in get_user_by_email by @developer603 in https://github.com/dograh-hq/dograh/pull/397 + +## New Contributors +* @abhaybabbar made their first contribution in https://github.com/dograh-hq/dograh/pull/351 +* @mvanhorn made their first contribution in https://github.com/dograh-hq/dograh/pull/373 +* @developer603 made their first contribution in https://github.com/dograh-hq/dograh/pull/377 +* @vishaldhateria made their first contribution in https://github.com/dograh-hq/dograh/pull/381 +* @shiminshen made their first contribution in https://github.com/dograh-hq/dograh/pull/387 + +**Full Changelog**: https://github.com/dograh-hq/dograh/compare/dograh-v1.33.0...dograh-v1.34.0 + ## [1.33.0](https://github.com/dograh-hq/dograh/compare/dograh-v1.32.0...dograh-v1.33.0) (2026-05-31) diff --git a/api/pyproject.toml b/api/pyproject.toml index e5c764bc..b0368db3 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,5 +1,5 @@ [project] name = "dograh-api" -version = "1.33.0" +version = "1.34.0" description = "Backend API for Dograh voice AI platform" requires-python = ">=3.13,<3.14" diff --git a/ui/package.json b/ui/package.json index cb82d1ac..77c8e3cf 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "ui", - "version": "1.33.0", + "version": "1.34.0", "private": true, "scripts": { "dev": "cross-env NODE_OPTIONS=--enable-source-maps next dev --turbopack", From 79ff04d8a97516bf0843912dddfce2e7913f7f94 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Fri, 5 Jun 2026 12:14:37 +0530 Subject: [PATCH 04/14] fix: harden vobiz signature verification Fixes #327 --- .../telephony/providers/vobiz/routes.py | 241 ++++++------------ api/tests/telephony/vobiz/test_routes.py | 153 +++++++++++ 2 files changed, 229 insertions(+), 165 deletions(-) diff --git a/api/services/telephony/providers/vobiz/routes.py b/api/services/telephony/providers/vobiz/routes.py index 15c2def9..6e8e1317 100644 --- a/api/services/telephony/providers/vobiz/routes.py +++ b/api/services/telephony/providers/vobiz/routes.py @@ -6,9 +6,8 @@ provider registry — see ProviderSpec.router. import json from datetime import UTC, datetime -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 @@ -29,6 +28,30 @@ from api.utils.telephony_helper import ( router = APIRouter() +async def _verify_vobiz_callback( + provider, + webhook_url: str, + callback_data: dict, + headers: dict, + raw_body: str, + *, + log_prefix: str, +) -> None: + """Verify a Vobiz callback signature, failing closed. + + Vobiz signs every callback, so a missing signature header is an invalid + request — ``provider.verify_inbound_signature`` returns ``False`` for both + missing and forged signatures. Reject with HTTP 403 (per Vobiz's + callback-validation docs) so the caller never reaches status processing. + """ + is_valid = await provider.verify_inbound_signature( + webhook_url, callback_data, headers, raw_body + ) + if not is_valid: + logger.warning(f"{log_prefix} Invalid or missing Vobiz callback signature") + raise HTTPException(status_code=403, detail="Invalid webhook signature") + + @router.post("/vobiz-xml", include_in_schema=False) async def handle_vobiz_xml_webhook( workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int @@ -65,8 +88,6 @@ async def handle_vobiz_xml_webhook( async def handle_vobiz_hangup_callback( workflow_run_id: int, request: Request, - x_vobiz_signature: Optional[str] = Header(None), - x_vobiz_timestamp: Optional[str] = Header(None), ): """Handle Vobiz hangup callback (sent when call ends). @@ -75,82 +96,23 @@ async def handle_vobiz_hangup_callback( """ set_current_run_id(workflow_run_id) - # Logging all headers and body to understand what Vobiz actually sends all_headers = dict(request.headers) - logger.info( - f"[run {workflow_run_id}] Vobiz hangup callback - Headers: {json.dumps(all_headers)}" - ) # 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( - f"[run {workflow_run_id}] Vobiz hangup callback - Body: {json.dumps(callback_data)}" - ) logger.info( f"[run {workflow_run_id}] Received Vobiz hangup callback {json.dumps(callback_data)}" ) - # Verify signature if Vobiz provided any supported signature header. - has_vobiz_signature = any( - header in all_headers - for header in ( - "x-vobiz-signature-v3", - "x-vobiz-signature-ma-v3", - "x-vobiz-signature-v2", - "x-vobiz-signature-ma-v2", - ) - ) - if has_vobiz_signature: - # We need the workflow run to get organization for provider credentials - workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) - if not workflow_run: - logger.warning( - f"[run {workflow_run_id}] Workflow run not found for signature verification" - ) - return {"status": "error", "reason": "workflow_run_not_found"} - - workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) - if not workflow: - logger.warning( - f"[run {workflow_run_id}] Workflow not found for signature verification" - ) - return {"status": "error", "reason": "workflow_not_found"} - - provider = await get_telephony_provider_for_run( - workflow_run, workflow.organization_id - ) - - # Verify signature - backend_endpoint, _ = await get_backend_endpoints() - webhook_url = f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/{workflow_run_id}" - - is_valid = await provider.verify_inbound_signature( - webhook_url, - callback_data, - all_headers, - raw_body, - ) - - if not is_valid: - logger.warning( - f"[run {workflow_run_id}] Invalid Vobiz hangup callback signature" - ) - return {"status": "error", "reason": "invalid_signature"} - - logger.info(f"[run {workflow_run_id}] Vobiz hangup callback signature verified") - else: - # Get workflow run for processing (signature verification already got it if needed) - workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) + workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) if not workflow_run: logger.warning( f"[run {workflow_run_id}] Workflow run not found for Vobiz hangup callback" ) return {"status": "ignored", "reason": "workflow_run_not_found"} - # Get workflow and provider workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) if not workflow: logger.warning(f"[run {workflow_run_id}] Workflow not found") @@ -160,6 +122,21 @@ async def handle_vobiz_hangup_callback( workflow_run, workflow.organization_id ) + # Fail closed: Vobiz signs every callback, so reject unsigned/forged ones + # before they can mutate call state. + backend_endpoint, _ = await get_backend_endpoints() + webhook_url = ( + f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/{workflow_run_id}" + ) + await _verify_vobiz_callback( + provider, + webhook_url, + callback_data, + all_headers, + raw_body, + log_prefix=f"[run {workflow_run_id}]", + ) + logger.debug( f"[run {workflow_run_id}] Processing Vobiz hangup with provider: {provider.PROVIDER_NAME}" ) @@ -167,10 +144,6 @@ async def handle_vobiz_hangup_callback( # Parse the callback data into generic format parsed_data = provider.parse_status_callback(callback_data) - logger.debug( - f"[run {workflow_run_id}] Parsed Vobiz callback data: {json.dumps(parsed_data)}" - ) - # Create StatusCallbackRequest from parsed data status_update = StatusCallbackRequest( call_id=parsed_data["call_id"], @@ -194,8 +167,6 @@ async def handle_vobiz_hangup_callback( async def handle_vobiz_ring_callback( workflow_run_id: int, request: Request, - x_vobiz_signature: Optional[str] = Header(None), - x_vobiz_timestamp: Optional[str] = Header(None), ): """Handle Vobiz ring callback (sent when call starts ringing). @@ -204,84 +175,46 @@ async def handle_vobiz_ring_callback( """ set_current_run_id(workflow_run_id) - # Logging all headers and body to understand what Vobiz actually sends all_headers = dict(request.headers) - logger.info( - f"[run {workflow_run_id}] Vobiz ring callback - Headers: {json.dumps(all_headers)}" - ) # 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( - f"[run {workflow_run_id}] Vobiz ring callback - Body: {json.dumps(callback_data)}" - ) - logger.info( f"[run {workflow_run_id}] Received Vobiz ring callback {json.dumps(callback_data)}" ) - # Verify signature if Vobiz provided any supported signature header. - has_vobiz_signature = any( - header in all_headers - for header in ( - "x-vobiz-signature-v3", - "x-vobiz-signature-ma-v3", - "x-vobiz-signature-v2", - "x-vobiz-signature-ma-v2", - ) - ) - if has_vobiz_signature: - # We need the workflow run to get organization for provider credentials - workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) - if not workflow_run: - logger.warning( - f"[run {workflow_run_id}] Workflow run not found for signature verification" - ) - return {"status": "error", "reason": "workflow_run_not_found"} - - workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) - if not workflow: - logger.warning( - f"[run {workflow_run_id}] Workflow not found for signature verification" - ) - return {"status": "error", "reason": "workflow_not_found"} - - provider = await get_telephony_provider_for_run( - workflow_run, workflow.organization_id - ) - - # Verify signature - backend_endpoint, _ = await get_backend_endpoints() - webhook_url = ( - f"{backend_endpoint}/api/v1/telephony/vobiz/ring-callback/{workflow_run_id}" - ) - - is_valid = await provider.verify_inbound_signature( - webhook_url, - callback_data, - all_headers, - raw_body, - ) - - if not is_valid: - logger.warning( - f"[run {workflow_run_id}] Invalid Vobiz ring callback signature" - ) - return {"status": "error", "reason": "invalid_signature"} - - logger.info(f"[run {workflow_run_id}] Vobiz ring callback signature verified") - else: - # Get workflow run for processing (signature verification already got it if needed) - workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) + workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) if not workflow_run: logger.warning( f"[run {workflow_run_id}] Workflow run not found for Vobiz ring callback" ) return {"status": "ignored", "reason": "workflow_run_not_found"} + workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) + if not workflow: + logger.warning(f"[run {workflow_run_id}] Workflow not found") + return {"status": "ignored", "reason": "workflow_not_found"} + + provider = await get_telephony_provider_for_run( + workflow_run, workflow.organization_id + ) + + # Fail closed: reject unsigned/forged ring callbacks before logging them. + backend_endpoint, _ = await get_backend_endpoints() + webhook_url = ( + f"{backend_endpoint}/api/v1/telephony/vobiz/ring-callback/{workflow_run_id}" + ) + await _verify_vobiz_callback( + provider, + webhook_url, + callback_data, + all_headers, + raw_body, + log_prefix=f"[run {workflow_run_id}]", + ) + # Log the ringing event telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", []) ring_log = { @@ -308,15 +241,10 @@ async def handle_vobiz_ring_callback( async def handle_vobiz_hangup_callback_by_workflow( workflow_id: int, request: Request, - x_vobiz_signature: Optional[str] = Header(None), - x_vobiz_timestamp: Optional[str] = Header(None), ): """Handle Vobiz hangup callback with workflow_id - finds workflow run by call_id.""" all_headers = dict(request.headers) - logger.info( - f"[workflow {workflow_id}] Vobiz hangup callback - Headers: {json.dumps(all_headers)}" - ) try: callback_data, raw_body = await parse_webhook_request(request) @@ -364,35 +292,18 @@ async def handle_vobiz_hangup_callback_by_workflow( workflow_run, workflow.organization_id ) - has_vobiz_signature = any( - header in all_headers - for header in ( - "x-vobiz-signature-v3", - "x-vobiz-signature-ma-v3", - "x-vobiz-signature-v2", - "x-vobiz-signature-ma-v2", - ) + # Fail closed: Vobiz signs every callback, so reject unsigned/forged ones + # before they can mutate call state. + backend_endpoint, _ = await get_backend_endpoints() + webhook_url = f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}" + await _verify_vobiz_callback( + provider, + webhook_url, + callback_data, + all_headers, + raw_body, + log_prefix=f"[workflow {workflow_id}]", ) - if has_vobiz_signature: - backend_endpoint, _ = await get_backend_endpoints() - webhook_url = f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}" - - is_valid = await provider.verify_inbound_signature( - webhook_url, - callback_data, - all_headers, - raw_body, - ) - - if not is_valid: - logger.warning( - f"[workflow {workflow_id}] Invalid Vobiz hangup callback signature" - ) - return {"status": "error", "message": "invalid_signature"} - - logger.info( - f"[workflow {workflow_id}] Vobiz hangup callback signature verified" - ) try: parsed_data = provider.parse_status_callback(callback_data) diff --git a/api/tests/telephony/vobiz/test_routes.py b/api/tests/telephony/vobiz/test_routes.py index 7b80d468..cfccfc90 100644 --- a/api/tests/telephony/vobiz/test_routes.py +++ b/api/tests/telephony/vobiz/test_routes.py @@ -6,11 +6,13 @@ from unittest.mock import AsyncMock, patch from urllib.parse import urlencode import pytest +from fastapi import HTTPException 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_hangup_callback_by_workflow, handle_vobiz_ring_callback, ) @@ -225,3 +227,154 @@ async def test_vobiz_verify_inbound_signature_rejects_missing_signature(): {}, {}, ) + + +@pytest.mark.asyncio +async def test_vobiz_hangup_callback_rejects_missing_signature(): + """An unsigned hangup callback must be rejected before status processing.""" + provider = _provider() + form_data = { + "CallUUID": "call-123", + "CallStatus": "completed", + "From": "15551230001", + "To": "15551230002", + "Direction": "outbound", + "Duration": "12", + } + # No x-vobiz-signature-* headers — the callback is unsigned. + request = _request( + path="/api/v1/telephony/vobiz/hangup-callback/123", + form_data=form_data, + ) + + 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) + ) + + with pytest.raises(HTTPException) as exc_info: + await handle_vobiz_hangup_callback( + workflow_run_id=123, + request=request, + ) + + assert exc_info.value.status_code == 403 + process_status.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_vobiz_ring_callback_rejects_missing_signature(): + """An unsigned ring callback must be rejected before it is logged.""" + provider = _provider() + form_data = { + "CallUUID": "call-123", + "CallStatus": "ringing", + "From": "15551230001", + "To": "15551230002", + } + # No x-vobiz-signature-* headers — the callback is unsigned. + request = _request( + path="/api/v1/telephony/vobiz/ring-callback/123", + form_data=form_data, + ) + + 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() + + with pytest.raises(HTTPException) as exc_info: + await handle_vobiz_ring_callback( + workflow_run_id=123, + request=request, + ) + + assert exc_info.value.status_code == 403 + db_client.update_workflow_run.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_vobiz_hangup_callback_by_workflow_rejects_missing_signature(): + """An unsigned by-workflow hangup callback must be rejected before processing.""" + provider = _provider() + form_data = { + "CallUUID": "call-123", + "CallStatus": "completed", + "From": "15551230001", + "To": "15551230002", + "Direction": "outbound", + "Duration": "12", + } + # No x-vobiz-signature-* headers — the callback is unsigned. + request = _request( + path="/api/v1/telephony/vobiz/hangup-callback/workflow/7", + form_data=form_data, + ) + + 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_by_id = AsyncMock( + return_value=SimpleNamespace(organization_id=11) + ) + db_client.get_workflow_run_by_call_id = AsyncMock( + return_value=SimpleNamespace(id=123, workflow_id=7) + ) + + with pytest.raises(HTTPException) as exc_info: + await handle_vobiz_hangup_callback_by_workflow( + workflow_id=7, + request=request, + ) + + assert exc_info.value.status_code == 403 + process_status.assert_not_awaited() From 418592178c165e9ffdbc0775dd1fe5cdacbe3d8b Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Fri, 5 Jun 2026 14:16:56 +0530 Subject: [PATCH 05/14] chore: update docs for pre-call data fetch --- api/services/pipecat/pre_call_fetch.py | 43 ++++++++---- api/tests/test_pre_call_fetch.py | 66 +++++++++++++++++++ docs/core-concepts/context-and-variables.mdx | 20 ++---- docs/images/template-variables.png | Bin 0 -> 186688 bytes docs/voice-agent/api-trigger.mdx | 2 +- docs/voice-agent/pre-call-data-fetch.mdx | 22 ++++--- docs/voice-agent/template-variables.mdx | 34 ++++++++-- 7 files changed, 142 insertions(+), 45 deletions(-) create mode 100644 api/tests/test_pre_call_fetch.py create mode 100644 docs/images/template-variables.png diff --git a/api/services/pipecat/pre_call_fetch.py b/api/services/pipecat/pre_call_fetch.py index 77761117..8a2025bb 100644 --- a/api/services/pipecat/pre_call_fetch.py +++ b/api/services/pipecat/pre_call_fetch.py @@ -15,6 +15,29 @@ from api.utils.credential_auth import build_auth_header PRE_CALL_FETCH_TIMEOUT_SECONDS = 10 +def _extract_initial_context(response_data: Dict[str, Any]) -> Dict[str, Any]: + """Pull the context variables out of a pre-call fetch response. + + The canonical key is ``initial_context``. The legacy ``dynamic_variables`` + key is still accepted for backward compatibility, so existing endpoints + keep working; ``initial_context`` takes precedence when both are present. + + Either key may appear at the top level or nested under ``call_inbound``: + {"call_inbound": {"initial_context": {...}}} | {"initial_context": {...}} + {"call_inbound": {"dynamic_variables": {...}}} | {"dynamic_variables": {...}} + """ + container = response_data.get("call_inbound") + if not isinstance(container, dict): + container = response_data + + for key in ("initial_context", "dynamic_variables"): + value = container.get(key) + if isinstance(value, dict): + return value + + return {} + + async def execute_pre_call_fetch( *, url: str, @@ -77,24 +100,16 @@ async def execute_pre_call_fetch( ) return {} - # Extract dynamic_variables from Retell-compatible response - # Supports: {call_inbound: {dynamic_variables: {...}}} - # or: {dynamic_variables: {...}} - dynamic_vars = {} - call_inbound = response_data.get("call_inbound") - if isinstance(call_inbound, dict): - dynamic_vars = call_inbound.get("dynamic_variables", {}) - elif "dynamic_variables" in response_data: - dynamic_vars = response_data["dynamic_variables"] - - if not isinstance(dynamic_vars, dict): - dynamic_vars = {} + # Extract the variables to merge into initial_context. Prefers + # the canonical `initial_context` key, falling back to the + # legacy `dynamic_variables` key for backward compatibility. + initial_context_vars = _extract_initial_context(response_data) logger.info( f"Pre-call fetch: success ({response.status_code}), " - f"dynamic_variables keys: {list(dynamic_vars.keys())}" + f"initial_context keys: {list(initial_context_vars.keys())}" ) - return dynamic_vars + return initial_context_vars else: logger.warning( f"Pre-call fetch: HTTP {response.status_code} - " diff --git a/api/tests/test_pre_call_fetch.py b/api/tests/test_pre_call_fetch.py new file mode 100644 index 00000000..8016da21 --- /dev/null +++ b/api/tests/test_pre_call_fetch.py @@ -0,0 +1,66 @@ +from api.services.pipecat.pre_call_fetch import _extract_initial_context + + +class TestExtractInitialContext: + """Tests for _extract_initial_context, the pre-call fetch response parser.""" + + def test_initial_context_nested_under_call_inbound(self): + """The canonical `initial_context` key nested under `call_inbound`.""" + response = {"call_inbound": {"initial_context": {"customer_name": "Jane"}}} + assert _extract_initial_context(response) == {"customer_name": "Jane"} + + def test_initial_context_at_top_level(self): + """The canonical `initial_context` key at the top level.""" + response = {"initial_context": {"customer_name": "Jane"}} + assert _extract_initial_context(response) == {"customer_name": "Jane"} + + def test_legacy_dynamic_variables_nested(self): + """The legacy `dynamic_variables` key still works nested under `call_inbound`.""" + response = {"call_inbound": {"dynamic_variables": {"customer_name": "Jane"}}} + assert _extract_initial_context(response) == {"customer_name": "Jane"} + + def test_legacy_dynamic_variables_at_top_level(self): + """The legacy `dynamic_variables` key still works at the top level.""" + response = {"dynamic_variables": {"customer_name": "Jane"}} + assert _extract_initial_context(response) == {"customer_name": "Jane"} + + def test_initial_context_takes_precedence_over_legacy(self): + """When both keys are present, `initial_context` wins.""" + response = { + "call_inbound": { + "initial_context": {"source": "new"}, + "dynamic_variables": {"source": "legacy"}, + } + } + assert _extract_initial_context(response) == {"source": "new"} + + def test_falls_back_to_legacy_when_initial_context_not_a_dict(self): + """A non-dict `initial_context` falls back to `dynamic_variables`.""" + response = { + "initial_context": None, + "dynamic_variables": {"customer_name": "Jane"}, + } + assert _extract_initial_context(response) == {"customer_name": "Jane"} + + def test_nested_values_preserved(self): + """Nested objects pass through untouched for dot-notation access.""" + response = { + "call_inbound": { + "initial_context": {"customer": {"address": {"city": "LA"}}} + } + } + assert _extract_initial_context(response) == { + "customer": {"address": {"city": "LA"}} + } + + def test_empty_when_no_known_keys(self): + """A response with neither key yields an empty dict.""" + assert _extract_initial_context({"call_inbound": {"agent_id": 1}}) == {} + + def test_empty_when_call_inbound_missing(self): + """No `call_inbound` and no top-level keys yields an empty dict.""" + assert _extract_initial_context({}) == {} + + def test_non_dict_vars_yield_empty(self): + """A non-dict value under a known key yields an empty dict.""" + assert _extract_initial_context({"initial_context": "nope"}) == {} diff --git a/docs/core-concepts/context-and-variables.mdx b/docs/core-concepts/context-and-variables.mdx index bfd81b02..274689c4 100644 --- a/docs/core-concepts/context-and-variables.mdx +++ b/docs/core-concepts/context-and-variables.mdx @@ -18,20 +18,10 @@ initial_context ──► Agent ──► gathered_context Data available to the agent before the call starts — the contact's name, account details, appointment information, anything the agent should know upfront. It can be set from several places: -- **API trigger** — pass it in the request body when calling `POST /public/agent/{uuid}` or `POST /telephony/initiate-call` -- **Campaign CSV** — columns beyond `phone_number` automatically become `initial_context` fields for each contact's call -- **Dashboard** — set default template context variables on the agent, used when no external context is provided - -```json -{ - "phone_number": "+14155550100", - "initial_context": { - "customer_name": "Jane Smith", - "plan": "premium", - "renewal_date": "April 1" - } -} -``` +- **[API trigger](/voice-agent/api-trigger)** — pass it in the request body when calling `POST /public/agent/{uuid}` or `POST /telephony/initiate-call` +- **[Campaign CSV](/core-concepts/campaigns)** — columns beyond `phone_number` automatically become `initial_context` fields for each contact's call +- **[Pre-call data fetch](/voice-agent/pre-call-data-fetch)** — enrich the context with data from your CRM or ERP via an HTTP call as the call starts, before the agent speaks +- **[Agent Settings](/voice-agent/template-variables#using-template-variables-for-testing)** — set template context variables on the agent for testing; they're included in test calls from the workflow editor and ignored on production calls ### Template variables @@ -103,7 +93,7 @@ Data the agent collects *during* the call. You configure what to extract in the Extracted variables -`gathered_context` is returned in the run record after the call completes and is available in [webhook payloads](/developer/webhooks) for downstream processing. +`gathered_context` is returned in the run record after the call completes and is available in [webhook payloads](/developer/webhooks) for downstream processing. It is **not** available as a template variable in Agent prompts — prompts can only reference `initial_context` fields. ## Data flow example diff --git a/docs/images/template-variables.png b/docs/images/template-variables.png new file mode 100644 index 0000000000000000000000000000000000000000..3594f9da133c3710909ec0e4103f6186424b4313 GIT binary patch literal 186688 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

S2ewKVa_Uu#~dnfxO05h;Ccj1K7<1EsEyl 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 z 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 Dq6G)A literal 0 HcmV?d00001 diff --git a/docs/voice-agent/api-trigger.mdx b/docs/voice-agent/api-trigger.mdx index 1a464b6d..e5168c7c 100644 --- a/docs/voice-agent/api-trigger.mdx +++ b/docs/voice-agent/api-trigger.mdx @@ -118,7 +118,7 @@ For example, if your request includes: } ``` -You can reference the user's name in your prompt as `{{initial_context.user.name}}`. +You can reference the user's name in your agent prompt as `{{user.name}}` — in Agent prompts, `initial_context` fields are referenced directly by name (not prefixed with `initial_context.`). See [template variables](/voice-agent/template-variables) for the exact syntax in prompts versus webhook payloads. See [Context & Variables](/core-concepts/context-and-variables) for more on how data flows through a call. diff --git a/docs/voice-agent/pre-call-data-fetch.mdx b/docs/voice-agent/pre-call-data-fetch.mdx index 53d1d9ae..a793930b 100644 --- a/docs/voice-agent/pre-call-data-fetch.mdx +++ b/docs/voice-agent/pre-call-data-fetch.mdx @@ -11,7 +11,7 @@ Pre-Call Data Fetch allows you to enrich the call context with external data bef 1. A call arrives (inbound) or is initiated (outbound). 2. Dograh sends a **POST** request to your configured endpoint with a standardized payload. 3. The caller hears a ring-back tone while waiting for the response. -4. Your API responds with a JSON object containing `dynamic_variables`. +4. Your API responds with a JSON object containing an `initial_context` object. 5. The variables are merged into the call's initial context. 6. The voice agent starts with full access to the fetched data via `{{variable_name}}` syntax. @@ -50,12 +50,12 @@ The `Content-Type` header is set to `application/json`. If you configured a cred ## Expected Response Format -Your API should return a **JSON object** with a `2xx` status code. The variables to inject into the call context should be placed inside the `dynamic_variables` key: +Your API should return a **JSON object** with a `2xx` status code. The variables to inject into the call context should be placed inside the `initial_context` key: ```json { "call_inbound": { - "dynamic_variables": { + "initial_context": { "customer_name": "Jane Doe", "account_status": "active", "loyalty_tier": "gold", @@ -65,34 +65,38 @@ Your API should return a **JSON object** with a `2xx` status code. The variables } ``` -You can also place `dynamic_variables` at the top level: +You can also place `initial_context` at the top level: ```json { - "dynamic_variables": { + "initial_context": { "customer_name": "Jane Doe", "account_status": "active" } } ``` + +The legacy `dynamic_variables` key is still accepted as a drop-in alias for `initial_context`, so existing integrations keep working without any changes. Use `initial_context` for new integrations. If a response contains both keys, `initial_context` takes precedence. + + After the response is received, you can reference these values anywhere template variables are supported: - **Greeting**: `Hello {{customer_name}}, thank you for calling!` - **Prompt**: `The customer is a {{loyalty_tier}} member with {{open_tickets}} open support tickets.` -If the response is not a valid JSON object, does not contain `dynamic_variables`, or the request fails or times out, the call proceeds normally without the additional context. The pre-call fetch never blocks or fails a call. +If the response is not a valid JSON object, does not contain `initial_context` (or the legacy `dynamic_variables`), or the request fails or times out, the call proceeds normally without the additional context. The pre-call fetch never blocks or fails a call. ## Nested Variables -If your `dynamic_variables` contain nested objects, you can access them using dot notation: +If your `initial_context` contains nested objects, you can access them using dot notation: ```json { "call_inbound": { - "dynamic_variables": { + "initial_context": { "customer": { "name": "Jane Doe", "address": { @@ -153,7 +157,7 @@ app.post("/dograh/pre-call", async (req, res) => { res.json({ call_inbound: { - dynamic_variables: { + initial_context: { customer_name: customer.name, account_status: customer.status, loyalty_tier: customer.tier, diff --git a/docs/voice-agent/template-variables.mdx b/docs/voice-agent/template-variables.mdx index 3db4a56b..325317fe 100644 --- a/docs/voice-agent/template-variables.mdx +++ b/docs/voice-agent/template-variables.mdx @@ -4,13 +4,23 @@ description: "You can use Template Variables in your prompts for your Agent node --- ### Template Rendering -You can reference template variables which is passed as [`initial_context`](/core-concepts/context-and-variables#initial_context) either using the [API Trigger](/voice-agent/api-trigger) or when uploading a Sheet for a [campaign](/core-concepts/campaigns). You can also use any extracted variable as [`gathered_context`](/core-concepts/context-and-variables#gathered_context) -The template rendering can take nested values. +You reference template variables with `{{double_brace}}` syntax. The data comes from [`initial_context`](/core-concepts/context-and-variables#initial_context) — set via the [API Trigger](/voice-agent/api-trigger), a [campaign](/core-concepts/campaigns) sheet, or a [Pre-Call Data Fetch](/voice-agent/pre-call-data-fetch) that enriches the context when the call starts — and, in Webhook payloads only, from [`gathered_context`](/core-concepts/context-and-variables#gathered_context) (variables extracted during the call). -Example: If the initial context is +**The syntax depends on where you use it:** -``` +| Where | `initial_context` | `gathered_context` | +| --- | --- | --- | +| Agent node prompts | `{{field_name}}` (referenced directly) | Not available | +| Webhook Node payloads | `{{initial_context.field_name}}` | `{{gathered_context.field_name}}` | + +#### Agent node prompts + +In an Agent node prompt, reference each `initial_context` field **directly by name**. Nested values are supported with dot notation. + +Example: if the initial context is + +```json { "initial_context": { "user": { @@ -20,14 +30,26 @@ Example: If the initial context is } ``` -You can write your prompt to access the user's name as below +write your prompt to access the user's name as below: -Prompt: `You are Alice, who is talking to {{initial_context.user.name}}.` +Prompt: `You are Alice, who is talking to {{user.name}}.` + + +Variables extracted during the call (`gathered_context`) are **not** available in Agent prompts — a prompt can only reference `initial_context` fields. To act on extracted data, send it to a [Webhook Node](/voice-agent/webhook). + + +#### Webhook Node payloads + +When constructing a [Webhook Node](/voice-agent/webhook) payload, the context objects are nested under their names, so reference them with the `initial_context.` and `gathered_context.` prefixes: + +Payload value: `{{initial_context.user.name}}` or `{{gathered_context.call_disposition}}` ### Using Template Variables for Testing Template variables defined in your workflow **Settings > Context Variables** are included in test calls (both web and phone) made from the workflow editor. This is useful for simulating data that would normally come from telephony or an API trigger. +Template Variables panel in workflow Settings, showing a customer_name variable and fields to add new key/value pairs + For example, you can set `caller_number` and `called_number` as context variables to test [Pre-Call Data Fetch](/voice-agent/pre-call-data-fetch#testing-with-test-calls) without needing a real inbound call. From 49e68b49d573f672d5ac922e42f1e832ec886353 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Fri, 5 Jun 2026 14:30:02 +0530 Subject: [PATCH 06/14] fix: harden the postgres default password --- docker-compose.yaml | 8 ++++++-- scripts/setup_local.ps1 | 6 ++++++ scripts/setup_local.sh | 6 ++++++ scripts/setup_remote.sh | 6 ++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index d440aa10..0bd27178 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,7 +3,11 @@ services: image: pgvector/pgvector:pg17 environment: POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres + # Sourced from .env. Defaults to "postgres" + # NOTE: changing this on an existing install does NOT + # re-key the database — the password is baked into the volume on first init. + # You can manually change the password using psql in the container + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-postgres}" POSTGRES_DB: postgres logging: driver: "json-file" @@ -136,7 +140,7 @@ services: BACKEND_API_ENDPOINT: "${BACKEND_API_ENDPOINT:-http://localhost:8000}" # Database configuration (using containerized postgres) - DATABASE_URL: "postgresql+asyncpg://postgres:postgres@postgres:5432/postgres" + DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres" # Redis configuration (using containerized redis) REDIS_URL: "redis://:redissecret@redis:6379" diff --git a/scripts/setup_local.ps1 b/scripts/setup_local.ps1 index 2958f307..d8b99137 100644 --- a/scripts/setup_local.ps1 +++ b/scripts/setup_local.ps1 @@ -243,6 +243,7 @@ if ($UseCoturn) { Write-Info "[2/$TotalSteps] Creating environment file..." $ossJwtSecret = New-HexSecret 32 +$postgresPassword = New-HexSecret 32 $envLines = @( '# Container registry for Dograh images' @@ -251,6 +252,11 @@ $envLines = @( '# JWT secret for OSS authentication' "OSS_JWT_SECRET=$ossJwtSecret" '' + '# PostgreSQL password. Used by the postgres container on first init and by' + "# the API's DATABASE_URL. Do not change after the first start — the password" + '# is baked into the postgres data volume when it is first created.' + "POSTGRES_PASSWORD=$postgresPassword" + '' '# Telemetry (set to false to disable)' "ENABLE_TELEMETRY=$EnableTelemetry" '' diff --git a/scripts/setup_local.sh b/scripts/setup_local.sh index 674185e1..e94fb60c 100755 --- a/scripts/setup_local.sh +++ b/scripts/setup_local.sh @@ -150,6 +150,7 @@ fi ENV_STEP=$TOTAL_STEPS echo -e "${BLUE}[$ENV_STEP/$TOTAL_STEPS] Creating environment file...${NC}" OSS_JWT_SECRET=$(openssl rand -hex 32) +POSTGRES_PASSWORD=$(openssl rand -hex 32) cat > .env << ENV_EOF # Container registry for Dograh images @@ -158,6 +159,11 @@ REGISTRY=$REGISTRY # JWT secret for OSS authentication OSS_JWT_SECRET=$OSS_JWT_SECRET +# PostgreSQL password. Used by the postgres container on first init and by the +# API's DATABASE_URL. Do not change after the first start — the password is +# baked into the postgres data volume when it is first created. +POSTGRES_PASSWORD=$POSTGRES_PASSWORD + # Telemetry (set to false to disable) ENABLE_TELEMETRY=$ENABLE_TELEMETRY diff --git a/scripts/setup_remote.sh b/scripts/setup_remote.sh index d958b694..919c881d 100755 --- a/scripts/setup_remote.sh +++ b/scripts/setup_remote.sh @@ -251,6 +251,7 @@ echo -e "${GREEN}✓ SSL certificates generated${NC}" echo -e "${BLUE}[4/$TOTAL] Creating environment file...${NC}" OSS_JWT_SECRET=$(openssl rand -hex 32) +POSTGRES_PASSWORD=$(openssl rand -hex 32) cat > .env << ENV_EOF # Remote deployments run with production signaling and HTTPS defaults @@ -276,6 +277,11 @@ FORCE_TURN_RELAY=$FORCE_TURN_RELAY # JWT secret for OSS authentication OSS_JWT_SECRET=$OSS_JWT_SECRET +# PostgreSQL password. Used by the postgres container on first init and by the +# API's DATABASE_URL. Do not change after the first start — the password is +# baked into the postgres data volume when it is first created. +POSTGRES_PASSWORD=$POSTGRES_PASSWORD + # Telemetry (set to false to disable) ENABLE_TELEMETRY=$ENABLE_TELEMETRY From cdbd06c8d9b40909def4b019fe386558a9e5366d Mon Sep 17 00:00:00 2001 From: Abhishek Date: Tue, 9 Jun 2026 16:10:26 +0530 Subject: [PATCH 07/14] feat: add config v2 to simplify billing (#428) * feat: add model config v2 * chore: centralize user org selection * chore: move preferences to platform settings * fix: decouple org preference and ai model preferences --- api/db/organization_usage_client.py | 27 +- api/enums.py | 5 + api/routes/auth.py | 11 +- api/routes/knowledge_base.py | 19 +- api/routes/organization.py | 253 ++- api/routes/telephony.py | 14 +- api/routes/user.py | 93 +- api/routes/workflow.py | 123 +- api/routes/workflow_text_chat.py | 22 +- api/schemas/ai_model_configuration.py | 170 ++ api/schemas/organization_preferences.py | 6 + api/schemas/user_configuration.py | 6 +- api/services/auth/depends.py | 23 +- .../configuration/ai_model_configuration.py | 484 +++++ api/services/configuration/masking.py | 34 +- api/services/configuration/registry.py | 15 + api/services/organization_preferences.py | 62 + api/services/pipecat/run_pipeline.py | 42 +- api/services/quota_service.py | 32 +- api/services/workflow/qa/llm_config.py | 23 +- api/services/workflow/text_chat_runner.py | 22 +- api/tasks/knowledge_base_processing.py | 16 +- api/tests/test_ai_model_configuration_v2.py | 295 +++ api/tests/test_masked_key_rejection.py | 40 +- api/tests/test_telephony_routes.py | 55 + api/tests/test_workflow_text_chat.py | 32 + docs/api-reference/openapi.json | 2 +- .../src/dograh_sdk/_generated_models.py | 4 +- ui/src/app/model-configurations/page.tsx | 18 +- ui/src/app/reports/page.tsx | 22 +- ui/src/app/settings/page.tsx | 14 + ui/src/app/usage/page.tsx | 59 +- .../components/PhoneCallDialog.tsx | 108 +- .../workflow/[workflowId]/settings/page.tsx | 271 ++- ui/src/client/index.ts | 4 +- ui/src/client/sdk.gen.ts | 65 +- ui/src/client/types.gen.ts | 1920 ++++++++++++++++- .../AIModelConfigurationV2Editor.tsx | 419 ++++ ui/src/components/ModelConfigurationV2.tsx | 274 +++ .../OrganizationPreferencesSection.tsx | 221 ++ .../components/ServiceConfigurationForm.tsx | 71 +- ui/src/types/workflow-configurations.ts | 3 + 42 files changed, 5135 insertions(+), 264 deletions(-) create mode 100644 api/schemas/ai_model_configuration.py create mode 100644 api/schemas/organization_preferences.py create mode 100644 api/services/configuration/ai_model_configuration.py create mode 100644 api/services/organization_preferences.py create mode 100644 api/tests/test_ai_model_configuration_v2.py create mode 100644 ui/src/components/AIModelConfigurationV2Editor.tsx create mode 100644 ui/src/components/ModelConfigurationV2.tsx create mode 100644 ui/src/components/OrganizationPreferencesSection.tsx diff --git a/api/db/organization_usage_client.py b/api/db/organization_usage_client.py index 928bf8be..f69f1957 100644 --- a/api/db/organization_usage_client.py +++ b/api/db/organization_usage_client.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import joinedload from api.db.base_client import BaseDBClient from api.db.filters import apply_workflow_run_filters from api.db.models import ( + OrganizationConfigurationModel, OrganizationModel, OrganizationUsageCycleModel, UserConfigurationModel, @@ -17,6 +18,7 @@ from api.db.models import ( WorkflowModel, WorkflowRunModel, ) +from api.enums import OrganizationConfigurationKey from api.schemas.user_configuration import UserConfiguration @@ -440,8 +442,29 @@ class OrganizationUsageClient(BaseDBClient): """Get daily usage breakdown for an organization with pricing.""" async with self.async_session() as session: - # Get user timezone if user_id is provided + # Get org timezone preference first, then fall back to legacy user config. user_timezone = "UTC" # Default timezone + pref_result = await session.execute( + select(OrganizationConfigurationModel).where( + OrganizationConfigurationModel.organization_id == organization_id, + OrganizationConfigurationModel.key.in_( + [ + OrganizationConfigurationKey.ORGANIZATION_PREFERENCES.value, + OrganizationConfigurationKey.MODEL_CONFIGURATION_PREFERENCES.value, + ] + ), + ) + ) + pref_rows = pref_result.scalars().all() + pref_by_key = {pref.key: pref for pref in pref_rows} + pref_obj = pref_by_key.get( + OrganizationConfigurationKey.ORGANIZATION_PREFERENCES.value + ) or pref_by_key.get( + OrganizationConfigurationKey.MODEL_CONFIGURATION_PREFERENCES.value + ) + if pref_obj and pref_obj.value: + user_timezone = pref_obj.value.get("timezone") or user_timezone + if user_id: config_result = await session.execute( select(UserConfigurationModel).where( @@ -453,7 +476,7 @@ class OrganizationUsageClient(BaseDBClient): user_config = UserConfiguration.model_validate( config_obj.configuration ) - if user_config.timezone: + if user_config.timezone and user_timezone == "UTC": user_timezone = user_config.timezone # Validate timezone string diff --git a/api/enums.py b/api/enums.py index 12557057..6ca1085f 100644 --- a/api/enums.py +++ b/api/enums.py @@ -89,6 +89,11 @@ class OrganizationConfigurationKey(Enum): LANGFUSE_CREDENTIALS = ( "LANGFUSE_CREDENTIALS" # Org-level Langfuse tracing credentials ) + MODEL_CONFIGURATION_V2 = ( + "MODEL_CONFIGURATION_V2" # Org-level v2 AI model configuration + ) + ORGANIZATION_PREFERENCES = "ORGANIZATION_PREFERENCES" # Org-level defaults such as timezone/test call number + MODEL_CONFIGURATION_PREFERENCES = "MODEL_CONFIGURATION_PREFERENCES" # Deprecated; read fallback for old org preferences class WorkflowStatus(Enum): diff --git a/api/routes/auth.py b/api/routes/auth.py index b6773a69..6083b875 100644 --- a/api/routes/auth.py +++ b/api/routes/auth.py @@ -3,9 +3,12 @@ from loguru import logger from api.db import db_client from api.db.models import UserModel -from api.enums import PostHogEvent +from api.enums import OrganizationConfigurationKey, PostHogEvent from api.schemas.auth import AuthResponse, LoginRequest, SignupRequest, UserResponse from api.services.auth.depends import create_user_configuration_with_mps_key, get_user +from api.services.configuration.ai_model_configuration import ( + convert_legacy_ai_model_configuration_to_v2, +) from api.services.posthog_client import capture_event from api.utils.auth import create_jwt_token, hash_password, verify_password @@ -47,6 +50,12 @@ async def signup(request: SignupRequest): ) if mps_config: await db_client.update_user_configuration(user.id, mps_config) + model_config_v2 = convert_legacy_ai_model_configuration_to_v2(mps_config) + await db_client.upsert_configuration( + organization.id, + OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value, + model_config_v2.model_dump(mode="json", exclude_none=True), + ) except Exception: logger.warning( "Failed to create default configuration for OSS user", exc_info=True diff --git a/api/routes/knowledge_base.py b/api/routes/knowledge_base.py index 5bf4b0ae..d9156871 100644 --- a/api/routes/knowledge_base.py +++ b/api/routes/knowledge_base.py @@ -369,6 +369,10 @@ async def search_chunks( try: # Import here to avoid circular dependency + from api.services.configuration.ai_model_configuration import ( + apply_managed_embeddings_base_url, + get_resolved_ai_model_configuration, + ) from api.services.configuration.registry import ServiceProviders from api.services.gen_ai import ( AzureOpenAIEmbeddingService, @@ -376,10 +380,15 @@ async def search_chunks( ) # Try to get user's embeddings configuration - user_config = await db_client.get_user_configurations(user.id) + resolved_config = await get_resolved_ai_model_configuration( + user_id=user.id, + organization_id=user.selected_organization_id, + ) + user_config = resolved_config.effective embeddings_api_key = None embeddings_model = None embeddings_provider = None + embeddings_base_url = None embeddings_endpoint = None embeddings_api_version = None @@ -388,6 +397,10 @@ async def search_chunks( embeddings_model = user_config.embeddings.model embeddings_provider = getattr(user_config.embeddings, "provider", None) embeddings_endpoint = getattr(user_config.embeddings, "endpoint", None) + embeddings_base_url = apply_managed_embeddings_base_url( + provider=embeddings_provider, + base_url=getattr(user_config.embeddings, "base_url", None), + ) embeddings_api_version = getattr( user_config.embeddings, "api_version", None ) @@ -406,9 +419,7 @@ async def search_chunks( db_client=db_client, api_key=embeddings_api_key, model_id=embeddings_model or "text-embedding-3-small", - base_url=getattr(user_config.embeddings, "base_url", None) - if user_config.embeddings - else None, + base_url=embeddings_base_url, ) # Perform search diff --git a/api/routes/organization.py b/api/routes/organization.py index f60a4133..4fb8e850 100644 --- a/api/routes/organization.py +++ b/api/routes/organization.py @@ -1,6 +1,6 @@ from typing import List, Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from loguru import logger from pydantic import BaseModel from sqlalchemy.exc import IntegrityError @@ -10,6 +10,14 @@ from api.db import db_client from api.db.models import UserModel from api.db.telephony_configuration_client import TelephonyConfigurationInUseError from api.enums import OrganizationConfigurationKey, PostHogEvent +from api.schemas.ai_model_configuration import ( + DOGRAH_DEFAULT_LANGUAGE, + DOGRAH_DEFAULT_VOICE, + DOGRAH_SPEED_OPTIONS, + OrganizationAIModelConfigurationResponse, + OrganizationAIModelConfigurationV2, +) +from api.schemas.organization_preferences import OrganizationPreferences from api.schemas.telephony_config import ( TelephonyConfigRequest, TelephonyConfigurationCreateRequest, @@ -26,8 +34,31 @@ from api.schemas.telephony_phone_number import ( PhoneNumberUpdateRequest, ProviderSyncStatus, ) -from api.services.auth.depends import get_user -from api.services.configuration.masking import is_mask_of, mask_key +from api.services.auth.depends import get_user, get_user_with_selected_organization +from api.services.configuration.ai_model_configuration import ( + check_for_masked_keys_in_ai_model_configuration_v2, + compile_ai_model_configuration_v2, + convert_legacy_ai_model_configuration_to_v2, + get_organization_ai_model_configuration_v2, + get_resolved_ai_model_configuration, + mask_ai_model_configuration_v2, + merge_ai_model_configuration_v2_secrets, + migrate_workflow_model_configurations_to_v2, + upsert_organization_ai_model_configuration_v2, +) +from api.services.configuration.check_validity import UserConfigurationValidator +from api.services.configuration.defaults import DEFAULT_SERVICE_PROVIDERS +from api.services.configuration.masking import is_mask_of, mask_key, mask_user_config +from api.services.configuration.registry import ( + DOGRAH_STT_LANGUAGES, + REGISTRY, + ServiceProviders, + ServiceType, +) +from api.services.organization_preferences import ( + get_organization_preferences, + upsert_organization_preferences, +) from api.services.posthog_client import capture_event from api.services.telephony import registry as telephony_registry from api.services.telephony.factory import get_telephony_provider_by_id @@ -159,6 +190,222 @@ async def get_telephony_config_warnings(user: UserModel = Depends(get_user)): ) +# --------------------------------------------------------------------------- +# AI model configurations v2 +# --------------------------------------------------------------------------- + + +def _byok_provider_schemas(service_type: ServiceType) -> dict[str, dict]: + return { + provider: model_cls.model_json_schema() + for provider, model_cls in REGISTRY[service_type].items() + if provider != ServiceProviders.DOGRAH.value + } + + +async def _model_configuration_v2_response( + *, + user: UserModel, + configuration: OrganizationAIModelConfigurationV2 | None = None, +) -> OrganizationAIModelConfigurationResponse: + resolved = await get_resolved_ai_model_configuration( + user_id=user.id, + organization_id=user.selected_organization_id, + ) + raw_configuration = ( + configuration + if configuration is not None + else resolved.organization_configuration + ) + return OrganizationAIModelConfigurationResponse( + configuration=mask_ai_model_configuration_v2(raw_configuration), + effective_configuration=mask_user_config(resolved.effective), + source=resolved.source, + ) + + +@router.get("/model-configurations/v2/defaults") +async def get_model_configuration_v2_defaults( + user: UserModel = Depends(get_user_with_selected_organization), +): + byok_default_providers = { + service: provider + for service, provider in DEFAULT_SERVICE_PROVIDERS.items() + if provider != ServiceProviders.DOGRAH.value + } + return { + "dograh": { + "voices": [DOGRAH_DEFAULT_VOICE], + "speeds": list(DOGRAH_SPEED_OPTIONS), + "languages": DOGRAH_STT_LANGUAGES, + "defaults": { + "voice": DOGRAH_DEFAULT_VOICE, + "speed": 1.0, + "language": DOGRAH_DEFAULT_LANGUAGE, + }, + }, + "byok": { + "pipeline": { + "llm": _byok_provider_schemas(ServiceType.LLM), + "tts": _byok_provider_schemas(ServiceType.TTS), + "stt": _byok_provider_schemas(ServiceType.STT), + "embeddings": _byok_provider_schemas(ServiceType.EMBEDDINGS), + "default_providers": byok_default_providers, + }, + "realtime": { + "realtime": _byok_provider_schemas(ServiceType.REALTIME), + "llm": _byok_provider_schemas(ServiceType.LLM), + "embeddings": _byok_provider_schemas(ServiceType.EMBEDDINGS), + "default_providers": byok_default_providers, + }, + }, + } + + +@router.get( + "/model-configurations/v2", + response_model=OrganizationAIModelConfigurationResponse, +) +async def get_model_configuration_v2( + user: UserModel = Depends(get_user_with_selected_organization), +): + return await _model_configuration_v2_response(user=user) + + +@router.put( + "/model-configurations/v2", + response_model=OrganizationAIModelConfigurationResponse, +) +async def save_model_configuration_v2( + request: OrganizationAIModelConfigurationV2, + user: UserModel = Depends(get_user_with_selected_organization), +): + organization_id = user.selected_organization_id + existing = await get_organization_ai_model_configuration_v2(organization_id) + configuration = merge_ai_model_configuration_v2_secrets(request, existing) + try: + check_for_masked_keys_in_ai_model_configuration_v2(configuration) + effective = compile_ai_model_configuration_v2(configuration) + await UserConfigurationValidator().validate( + effective, + organization_id=organization_id, + created_by=user.provider_id, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=exc.args[0]) + + await upsert_organization_ai_model_configuration_v2( + organization_id, + configuration, + ) + return await _model_configuration_v2_response( + user=user, + configuration=configuration, + ) + + +@router.get("/model-configurations/v2/migration-preview") +async def preview_model_configuration_v2_migration( + user: UserModel = Depends(get_user_with_selected_organization), +): + legacy = await db_client.get_user_configurations(user.id) + try: + configuration = convert_legacy_ai_model_configuration_to_v2(legacy) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + return { + "configuration": mask_ai_model_configuration_v2(configuration), + "effective_configuration": mask_user_config( + compile_ai_model_configuration_v2(configuration) + ), + } + + +@router.post( + "/model-configurations/v2/migrate", + response_model=OrganizationAIModelConfigurationResponse, +) +async def migrate_model_configuration_v2( + force: bool = Query(default=False), + user: UserModel = Depends(get_user_with_selected_organization), +): + organization_id = user.selected_organization_id + existing = await get_organization_ai_model_configuration_v2(organization_id) + if existing is not None and not force: + raise HTTPException( + status_code=409, + detail="Organization already has a v2 model configuration", + ) + + legacy = await db_client.get_user_configurations(user.id) + try: + configuration = convert_legacy_ai_model_configuration_to_v2(legacy) + effective = compile_ai_model_configuration_v2(configuration) + await UserConfigurationValidator().validate( + effective, + organization_id=organization_id, + created_by=user.provider_id, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=exc.args[0]) + + await upsert_organization_ai_model_configuration_v2( + organization_id, + configuration, + ) + await migrate_workflow_model_configurations_to_v2( + organization_id=organization_id, + fallback_user_config=legacy, + ) + return await _model_configuration_v2_response( + user=user, + configuration=configuration, + ) + + +@router.get("/preferences", response_model=OrganizationPreferences) +async def get_preferences( + user: UserModel = Depends(get_user_with_selected_organization), +): + organization_id = user.selected_organization_id + return await get_organization_preferences(organization_id) + + +@router.put("/preferences", response_model=OrganizationPreferences) +async def save_preferences( + request: OrganizationPreferences, + user: UserModel = Depends(get_user_with_selected_organization), +): + organization_id = user.selected_organization_id + return await upsert_organization_preferences( + organization_id, + request, + ) + + +@router.get( + "/model-configurations/preferences", + response_model=OrganizationPreferences, + include_in_schema=False, +) +async def get_model_configuration_preferences_legacy( + user: UserModel = Depends(get_user_with_selected_organization), +): + return await get_preferences(user=user) + + +@router.put( + "/model-configurations/preferences", + response_model=OrganizationPreferences, + include_in_schema=False, +) +async def save_model_configuration_preferences_legacy( + request: OrganizationPreferences, + user: UserModel = Depends(get_user_with_selected_organization), +): + return await save_preferences(request=request, user=user) + + def preserve_masked_fields(provider: str, request_dict: dict, existing: dict): """If the client re-submitted a masked sensitive field, restore the original.""" for field_name in _sensitive_fields(provider): diff --git a/api/routes/telephony.py b/api/routes/telephony.py index 86bbbc02..7dbeab93 100644 --- a/api/routes/telephony.py +++ b/api/routes/telephony.py @@ -53,7 +53,7 @@ class InitiateCallRequest(BaseModel): workflow_run_id: int | None = None phone_number: str | None = None # Optional explicit telephony config to use for the test call. If omitted, - # falls back to the user's per-user default (when set), then the org default. + # falls back to the org default. telephony_configuration_id: int | None = None # Optional caller-ID phone number to dial out from. Must belong to the # resolved telephony configuration; otherwise the provider picks one. @@ -82,7 +82,12 @@ async def initiate_call( """Initiate a call using the configured telephony provider from web browser. This is supposed to be a test call method for the draft version of the agent.""" - user_configuration = await db_client.get_user_configurations(user.id) + from api.services.organization_preferences import get_organization_preferences + + preferences = await get_organization_preferences( + user.selected_organization_id, + db=db_client, + ) # Resolve which telephony config to use: explicit request value, otherwise # the org's default outbound config. @@ -116,13 +121,12 @@ async def initiate_call( detail="telephony_not_configured", ) - phone_number = request.phone_number or user_configuration.test_phone_number + phone_number = request.phone_number or preferences.test_phone_number if not phone_number: raise HTTPException( status_code=400, - detail="Phone number must be provided in request or set in user " - "configuration", + detail="Phone number must be provided in request or set in organization preferences", ) workflow = await db_client.get_workflow( diff --git a/api/routes/user.py b/api/routes/user.py index 20d0a41e..d352f809 100644 --- a/api/routes/user.py +++ b/api/routes/user.py @@ -10,6 +10,9 @@ from api.db.models import ( UserModel, ) from api.services.auth.depends import get_user +from api.services.configuration.ai_model_configuration import ( + get_resolved_ai_model_configuration, +) from api.services.configuration.check_validity import ( APIKeyStatusResponse, UserConfigurationValidator, @@ -19,6 +22,10 @@ from api.services.configuration.masking import check_for_masked_keys, mask_user_ from api.services.configuration.merge import merge_user_configurations from api.services.configuration.registry import REGISTRY, ServiceType from api.services.mps_service_key_client import mps_service_key_client +from api.services.organization_preferences import ( + get_organization_preferences, + upsert_organization_preferences, +) router = APIRouter(prefix="/user") @@ -91,8 +98,17 @@ class UserConfigurationRequestResponseSchema(BaseModel): async def get_user_configurations( user: UserModel = Depends(get_user), ) -> UserConfigurationRequestResponseSchema: - user_configurations = await db_client.get_user_configurations(user.id) - masked_config = mask_user_config(user_configurations) + resolved_config = await get_resolved_ai_model_configuration( + user_id=user.id, + organization_id=user.selected_organization_id, + ) + masked_config = mask_user_config(resolved_config.effective) + if user.selected_organization_id: + preferences = await get_organization_preferences(user.selected_organization_id) + if preferences.test_phone_number is not None: + masked_config["test_phone_number"] = preferences.test_phone_number + if preferences.timezone is not None: + masked_config["timezone"] = preferences.timezone # Add organization pricing info if available if user.selected_organization_id: @@ -118,34 +134,61 @@ async def update_user_configurations( # Remove organization_pricing from incoming dict as it's read-only incoming_dict.pop("organization_pricing", None) + preferences_update = { + key: incoming_dict.pop(key) + for key in ("test_phone_number", "timezone") + if key in incoming_dict + } - # Merge via helper - try: - user_configurations = merge_user_configurations(existing_config, incoming_dict) - except ValidationError as e: - raise HTTPException(status_code=422, detail=str(e)) + if incoming_dict: + # Merge via helper + try: + user_configurations = merge_user_configurations( + existing_config, incoming_dict + ) + except ValidationError as e: + raise HTTPException(status_code=422, detail=str(e)) - try: - check_for_masked_keys(user_configurations) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) + try: + check_for_masked_keys(user_configurations) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) - try: - validator = UserConfigurationValidator() - await validator.validate( - user_configurations, - organization_id=user.selected_organization_id, - created_by=user.provider_id, + try: + validator = UserConfigurationValidator() + await validator.validate( + user_configurations, + organization_id=user.selected_organization_id, + created_by=user.provider_id, + ) + except ValueError as e: + raise HTTPException(status_code=422, detail=e.args[0]) + + user_configurations = await db_client.update_user_configuration( + user.id, user_configurations ) - except ValueError as e: - raise HTTPException(status_code=422, detail=e.args[0]) + else: + user_configurations = existing_config - user_configurations = await db_client.update_user_configuration( - user.id, user_configurations - ) + if user.selected_organization_id and preferences_update: + preferences = await get_organization_preferences(user.selected_organization_id) + if "test_phone_number" in preferences_update: + preferences.test_phone_number = preferences_update["test_phone_number"] + if "timezone" in preferences_update: + preferences.timezone = preferences_update["timezone"] + await upsert_organization_preferences( + user.selected_organization_id, + preferences, + ) # Return masked version of updated config masked_config = mask_user_config(user_configurations) + if user.selected_organization_id: + preferences = await get_organization_preferences(user.selected_organization_id) + if preferences.test_phone_number is not None: + masked_config["test_phone_number"] = preferences.test_phone_number + if preferences.timezone is not None: + masked_config["timezone"] = preferences.timezone # Add organization pricing info if available if user.selected_organization_id: @@ -165,7 +208,11 @@ async def validate_user_configurations( validity_ttl_seconds: int = Query(default=60, ge=0, le=86400), user: UserModel = Depends(get_user), ) -> APIKeyStatusResponse: - configurations = await db_client.get_user_configurations(user.id) + resolved_config = await get_resolved_ai_model_configuration( + user_id=user.id, + organization_id=user.selected_organization_id, + ) + configurations = resolved_config.effective if ( configurations.last_validated_at diff --git a/api/routes/workflow.py b/api/routes/workflow.py index 7adf0864..9157c5cf 100644 --- a/api/routes/workflow.py +++ b/api/routes/workflow.py @@ -16,9 +16,18 @@ from api.db.agent_trigger_client import TriggerPathConflictError from api.db.models import UserModel from api.db.workflow_template_client import WorkflowTemplateClient from api.enums import CallType, PostHogEvent, StorageBackend +from api.schemas.ai_model_configuration import OrganizationAIModelConfigurationV2 from api.schemas.workflow import WorkflowRunResponseSchema from api.sdk_expose import sdk_expose from api.services.auth.depends import get_user +from api.services.configuration.ai_model_configuration import ( + WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY, + check_for_masked_keys_in_ai_model_configuration_v2, + compile_ai_model_configuration_v2, + convert_legacy_ai_model_configuration_to_v2, + get_resolved_ai_model_configuration, + merge_ai_model_configuration_v2_secrets, +) from api.services.configuration.check_validity import UserConfigurationValidator from api.services.configuration.masking import ( mask_workflow_configurations, @@ -955,12 +964,74 @@ async def update_workflow( existing_def, ) - # Validate model_overrides: resolve onto global config, then - # run the same validator used by the user-configurations endpoint. - # Also stamp the current global API key into the override so the override - # remains functional if the global config later switches to a different provider. + # Validate model overrides. v2 uses a complete workflow-level model + # configuration; legacy v1 uses partial service overlays. workflow_configurations = request.workflow_configurations - if workflow_configurations and workflow_configurations.get("model_overrides"): + if workflow_configurations and workflow_configurations.get( + WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY + ): + existing_workflow = await db_client.get_workflow( + workflow_id, organization_id=user.selected_organization_id + ) + if existing_workflow is None: + raise HTTPException( + status_code=404, detail=f"Workflow with id {workflow_id} not found" + ) + existing_draft = await db_client.get_draft_version(workflow_id) + existing_configs = ( + existing_draft.workflow_configurations + if existing_draft + else existing_workflow.released_definition.workflow_configurations + ) + existing_v2_override = (existing_configs or {}).get( + WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY + ) + try: + incoming_v2_override = ( + OrganizationAIModelConfigurationV2.model_validate( + workflow_configurations[ + WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY + ] + ) + ) + existing_v2_override_config = ( + OrganizationAIModelConfigurationV2.model_validate( + existing_v2_override + ) + if existing_v2_override + else None + ) + v2_override = merge_ai_model_configuration_v2_secrets( + incoming_v2_override, + existing_v2_override_config, + ) + if existing_v2_override_config is None: + resolved_config = await get_resolved_ai_model_configuration( + user_id=user.id, + organization_id=user.selected_organization_id, + ) + v2_override = merge_ai_model_configuration_v2_secrets( + v2_override, + resolved_config.organization_configuration, + ) + check_for_masked_keys_in_ai_model_configuration_v2(v2_override) + effective = compile_ai_model_configuration_v2(v2_override) + await UserConfigurationValidator().validate( + effective, + organization_id=user.selected_organization_id, + created_by=user.provider_id, + ) + except (ValidationError, ValueError) as e: + raise HTTPException(status_code=422, detail=str(e)) + workflow_configurations = { + **workflow_configurations, + WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY: v2_override.model_dump( + mode="json", + exclude_none=True, + ), + } + workflow_configurations.pop("model_overrides", None) + elif workflow_configurations and workflow_configurations.get("model_overrides"): existing_workflow = await db_client.get_workflow( workflow_id, organization_id=user.selected_organization_id ) @@ -978,24 +1049,46 @@ async def update_workflow( workflow_configurations, existing_configs, ) - user_config = await db_client.get_user_configurations(user.id) + resolved_config = await get_resolved_ai_model_configuration( + user_id=user.id, + organization_id=user.selected_organization_id, + ) + user_config = resolved_config.effective try: enriched_overrides = enrich_overrides_with_api_keys( workflow_configurations["model_overrides"], user_config, ) effective = resolve_effective_config(user_config, enriched_overrides) - await UserConfigurationValidator().validate( - effective, - organization_id=user.selected_organization_id, - created_by=user.provider_id, - ) + if resolved_config.source == "organization_v2": + v2_override = convert_legacy_ai_model_configuration_to_v2(effective) + await UserConfigurationValidator().validate( + compile_ai_model_configuration_v2(v2_override), + organization_id=user.selected_organization_id, + created_by=user.provider_id, + ) + else: + await UserConfigurationValidator().validate( + effective, + organization_id=user.selected_organization_id, + created_by=user.provider_id, + ) except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) - workflow_configurations = { - **workflow_configurations, - "model_overrides": enriched_overrides, - } + if resolved_config.source == "organization_v2": + workflow_configurations = { + **workflow_configurations, + WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY: v2_override.model_dump( + mode="json", + exclude_none=True, + ), + } + workflow_configurations.pop("model_overrides", None) + else: + workflow_configurations = { + **workflow_configurations, + "model_overrides": enriched_overrides, + } # Reject upfront if any new trigger path collides with another # workflow's trigger — keeps the workflow record from diff --git a/api/routes/workflow_text_chat.py b/api/routes/workflow_text_chat.py index 71d1b909..b4650118 100644 --- a/api/routes/workflow_text_chat.py +++ b/api/routes/workflow_text_chat.py @@ -9,7 +9,7 @@ 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.auth.depends import get_user_with_selected_organization from api.services.quota_service import check_dograh_quota from api.services.workflow.text_chat_session_service import ( TextChatPendingTurnLostError, @@ -96,12 +96,6 @@ def _revision_conflict_detail(e: Any) -> dict[str, Any]: } -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: @@ -114,9 +108,8 @@ async def _load_text_session_or_404( 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 + run_id, organization_id=user.selected_organization_id ) if not text_session or not text_session.workflow_run: raise HTTPException(status_code=404, detail="Text chat session not found") @@ -158,9 +151,8 @@ async def _execute_pending_turn_response( async def create_text_chat_session( workflow_id: int, request: CreateTextChatSessionRequest, - user: UserModel = Depends(get_user), + user: UserModel = Depends(get_user_with_selected_organization), ) -> 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()}" @@ -172,7 +164,7 @@ async def create_text_chat_session( user_id=user.id, initial_context=request.initial_context, use_draft=True, - organization_id=organization_id, + organization_id=user.selected_organization_id, ) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @@ -220,7 +212,7 @@ async def create_text_chat_session( async def get_text_chat_session( workflow_id: int, run_id: int, - user: UserModel = Depends(get_user), + user: UserModel = Depends(get_user_with_selected_organization), ) -> WorkflowRunTextSessionResponse: text_session = await _load_text_session_or_404(workflow_id, run_id, user) return _build_response(text_session) @@ -234,7 +226,7 @@ async def append_text_chat_message( workflow_id: int, run_id: int, request: AppendTextChatMessageRequest, - user: UserModel = Depends(get_user), + user: UserModel = Depends(get_user_with_selected_organization), ) -> WorkflowRunTextSessionResponse: text_session = await _load_text_session_or_404(workflow_id, run_id, user) await _ensure_text_chat_quota(user, workflow_id) @@ -264,7 +256,7 @@ async def rewind_text_chat_session( workflow_id: int, run_id: int, request: RewindTextChatSessionRequest, - user: UserModel = Depends(get_user), + user: UserModel = Depends(get_user_with_selected_organization), ) -> WorkflowRunTextSessionResponse: text_session = await _load_text_session_or_404(workflow_id, run_id, user) try: diff --git a/api/schemas/ai_model_configuration.py b/api/schemas/ai_model_configuration.py new file mode 100644 index 00000000..dcc3a6e7 --- /dev/null +++ b/api/schemas/ai_model_configuration.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field, model_validator + +from api.schemas.user_configuration import EffectiveAIModelConfiguration +from api.services.configuration.registry import ( + DograhEmbeddingsConfiguration, + DograhLLMService, + DograhSTTService, + DograhTTSService, + EmbeddingsConfig, + LLMConfig, + RealtimeConfig, + ServiceProviders, + STTConfig, + TTSConfig, +) + +DOGRAH_SPEED_OPTIONS: tuple[float, ...] = (0.8, 1.0, 1.2) +DOGRAH_DEFAULT_VOICE = "default" +DOGRAH_DEFAULT_LANGUAGE = "multi" + + +class DograhManagedAIModelConfiguration(BaseModel): + api_key: str + voice: str = DOGRAH_DEFAULT_VOICE + speed: float = Field(default=1.0) + language: str = DOGRAH_DEFAULT_LANGUAGE + + @model_validator(mode="after") + def validate_speed(self): + if self.speed not in DOGRAH_SPEED_OPTIONS: + allowed = ", ".join(str(speed) for speed in DOGRAH_SPEED_OPTIONS) + raise ValueError(f"Dograh speed must be one of: {allowed}") + return self + + +class BYOKPipelineAIModelConfiguration(BaseModel): + llm: LLMConfig + tts: TTSConfig + stt: STTConfig + embeddings: EmbeddingsConfig | None = None + + @model_validator(mode="after") + def reject_dograh_providers(self): + _reject_dograh_provider("llm", self.llm) + _reject_dograh_provider("tts", self.tts) + _reject_dograh_provider("stt", self.stt) + _reject_dograh_provider("embeddings", self.embeddings) + return self + + +class BYOKRealtimeAIModelConfiguration(BaseModel): + realtime: RealtimeConfig + llm: LLMConfig + embeddings: EmbeddingsConfig | None = None + + @model_validator(mode="after") + def reject_dograh_providers(self): + _reject_dograh_provider("llm", self.llm) + _reject_dograh_provider("embeddings", self.embeddings) + return self + + +class BYOKAIModelConfiguration(BaseModel): + mode: Literal["pipeline", "realtime"] + pipeline: BYOKPipelineAIModelConfiguration | None = None + realtime: BYOKRealtimeAIModelConfiguration | None = None + + @model_validator(mode="after") + def validate_selected_mode(self): + if self.mode == "pipeline" and self.pipeline is None: + raise ValueError("byok.pipeline is required when byok.mode is pipeline") + if self.mode == "realtime" and self.realtime is None: + raise ValueError("byok.realtime is required when byok.mode is realtime") + return self + + +class OrganizationAIModelConfigurationV2(BaseModel): + version: Literal[2] = 2 + mode: Literal["dograh", "byok"] + dograh: DograhManagedAIModelConfiguration | None = None + byok: BYOKAIModelConfiguration | None = None + + @model_validator(mode="after") + def validate_selected_mode(self): + if self.mode == "dograh" and self.dograh is None: + raise ValueError("dograh configuration is required when mode is dograh") + if self.mode == "byok" and self.byok is None: + raise ValueError("byok configuration is required when mode is byok") + return self + + +class OrganizationAIModelConfigurationResponse(BaseModel): + configuration: dict | None + effective_configuration: dict + source: Literal["organization_v2", "legacy_user_v1", "empty"] + + +def compile_ai_model_configuration_v2( + configuration: OrganizationAIModelConfigurationV2, +) -> EffectiveAIModelConfiguration: + if configuration.mode == "dograh": + if configuration.dograh is None: + raise ValueError("dograh configuration is required") + return _compile_dograh_configuration(configuration.dograh) + + if configuration.byok is None: + raise ValueError("byok configuration is required") + if configuration.byok.mode == "pipeline": + if configuration.byok.pipeline is None: + raise ValueError("byok.pipeline is required") + pipeline = configuration.byok.pipeline + return EffectiveAIModelConfiguration( + llm=pipeline.llm, + tts=pipeline.tts, + stt=pipeline.stt, + embeddings=pipeline.embeddings, + is_realtime=False, + ) + + if configuration.byok.realtime is None: + raise ValueError("byok.realtime is required") + realtime = configuration.byok.realtime + return EffectiveAIModelConfiguration( + llm=realtime.llm, + realtime=realtime.realtime, + embeddings=realtime.embeddings, + is_realtime=True, + ) + + +def _compile_dograh_configuration( + configuration: DograhManagedAIModelConfiguration, +) -> EffectiveAIModelConfiguration: + return EffectiveAIModelConfiguration( + llm=DograhLLMService( + provider=ServiceProviders.DOGRAH, + api_key=configuration.api_key, + model="default", + ), + tts=DograhTTSService( + provider=ServiceProviders.DOGRAH, + api_key=configuration.api_key, + model="default", + voice=configuration.voice, + speed=configuration.speed, + ), + stt=DograhSTTService( + provider=ServiceProviders.DOGRAH, + api_key=configuration.api_key, + model="default", + language=configuration.language, + ), + embeddings=DograhEmbeddingsConfiguration( + provider=ServiceProviders.DOGRAH, + api_key=configuration.api_key, + model="default", + ), + is_realtime=False, + ) + + +def _reject_dograh_provider(section: str, service) -> None: + if service is None: + return + if getattr(service, "provider", None) == ServiceProviders.DOGRAH: + raise ValueError(f"BYOK {section} cannot use Dograh provider") diff --git a/api/schemas/organization_preferences.py b/api/schemas/organization_preferences.py new file mode 100644 index 00000000..ffc98404 --- /dev/null +++ b/api/schemas/organization_preferences.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class OrganizationPreferences(BaseModel): + test_phone_number: str | None = None + timezone: str | None = None diff --git a/api/schemas/user_configuration.py b/api/schemas/user_configuration.py index 2e62396a..1f8d8ff2 100644 --- a/api/schemas/user_configuration.py +++ b/api/schemas/user_configuration.py @@ -11,7 +11,7 @@ from api.services.configuration.registry import ( ) -class UserConfiguration(BaseModel): +class EffectiveAIModelConfiguration(BaseModel): llm: LLMConfig | None = None stt: STTConfig | None = None tts: TTSConfig | None = None @@ -31,3 +31,7 @@ class UserConfiguration(BaseModel): if isinstance(realtime, dict) and not realtime.get("api_key"): data.pop("realtime", None) return data + + +# Backward-compatible alias for legacy persistence and existing call sites. +UserConfiguration = EffectiveAIModelConfiguration diff --git a/api/services/auth/depends.py b/api/services/auth/depends.py index 7ffabfb7..7a284926 100644 --- a/api/services/auth/depends.py +++ b/api/services/auth/depends.py @@ -1,7 +1,7 @@ from typing import Annotated, Optional import httpx -from fastapi import Header, HTTPException, Query, WebSocket +from fastapi import Depends, Header, HTTPException, Query, WebSocket from loguru import logger from pydantic import ValidationError @@ -119,6 +119,19 @@ async def get_user( await db_client.update_user_configuration( user_model.id, mps_config ) + from api.enums import OrganizationConfigurationKey + from api.services.configuration.ai_model_configuration import ( + convert_legacy_ai_model_configuration_to_v2, + ) + + model_config_v2 = convert_legacy_ai_model_configuration_to_v2( + mps_config + ) + await db_client.upsert_configuration( + organization.id, + OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value, + model_config_v2.model_dump(mode="json", exclude_none=True), + ) except Exception as exc: raise HTTPException( @@ -129,6 +142,14 @@ async def get_user( return user_model +async def get_user_with_selected_organization( + user: Annotated[UserModel, Depends(get_user)], +) -> UserModel: + if not user.selected_organization_id: + raise HTTPException(status_code=400, detail="No organization selected") + return user + + async def _handle_oss_auth(authorization: str | None) -> UserModel: """ Handle authentication for OSS deployment mode. diff --git a/api/services/configuration/ai_model_configuration.py b/api/services/configuration/ai_model_configuration.py new file mode 100644 index 00000000..1b9a00f6 --- /dev/null +++ b/api/services/configuration/ai_model_configuration.py @@ -0,0 +1,484 @@ +from __future__ import annotations + +import copy +from dataclasses import dataclass +from typing import Literal + +from loguru import logger +from pydantic import ValidationError +from sqlalchemy import select, update +from sqlalchemy.orm import selectinload + +from api.constants import MPS_API_URL +from api.db import db_client +from api.db.models import WorkflowDefinitionModel, WorkflowModel +from api.enums import OrganizationConfigurationKey +from api.schemas.ai_model_configuration import ( + DOGRAH_DEFAULT_LANGUAGE, + DOGRAH_DEFAULT_VOICE, + DOGRAH_SPEED_OPTIONS, + BYOKAIModelConfiguration, + BYOKPipelineAIModelConfiguration, + BYOKRealtimeAIModelConfiguration, + DograhManagedAIModelConfiguration, + OrganizationAIModelConfigurationV2, + compile_ai_model_configuration_v2, +) +from api.schemas.user_configuration import EffectiveAIModelConfiguration +from api.services.configuration.masking import ( + SERVICE_SECRET_FIELDS, + contains_masked_key, + mask_key, + resolve_masked_api_keys, +) +from api.services.configuration.registry import ServiceProviders +from api.services.configuration.resolve import resolve_effective_config + +AIModelConfigurationSource = Literal["organization_v2", "legacy_user_v1", "empty"] +WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY = "model_configuration_v2_override" + + +@dataclass +class ResolvedAIModelConfiguration: + effective: EffectiveAIModelConfiguration + source: AIModelConfigurationSource + organization_configuration: OrganizationAIModelConfigurationV2 | None = None + + +@dataclass +class WorkflowAIModelConfigurationMigrationResult: + workflow_count: int = 0 + definition_count: int = 0 + workflow_ids: list[int] | None = None + + +async def get_resolved_ai_model_configuration( + *, + user_id: int | None, + organization_id: int | None, +) -> ResolvedAIModelConfiguration: + organization_configuration = await get_organization_ai_model_configuration_v2( + organization_id + ) + if organization_configuration is not None: + return ResolvedAIModelConfiguration( + effective=compile_ai_model_configuration_v2(organization_configuration), + source="organization_v2", + organization_configuration=organization_configuration, + ) + + if user_id is None: + return ResolvedAIModelConfiguration( + effective=EffectiveAIModelConfiguration(), + source="empty", + ) + + legacy = await db_client.get_user_configurations(user_id) + return ResolvedAIModelConfiguration( + effective=legacy, + source="legacy_user_v1" if _has_model_services(legacy) else "empty", + ) + + +async def get_effective_ai_model_configuration_for_workflow( + *, + user_id: int | None, + organization_id: int | None, + workflow_configurations: dict | None, +) -> EffectiveAIModelConfiguration: + workflow_configurations = workflow_configurations or {} + v2_override = workflow_configurations.get( + WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY + ) + if v2_override: + return compile_ai_model_configuration_v2( + OrganizationAIModelConfigurationV2.model_validate(v2_override) + ) + + resolved_config = await get_resolved_ai_model_configuration( + user_id=user_id, + organization_id=organization_id, + ) + return resolve_effective_config( + resolved_config.effective, + workflow_configurations.get("model_overrides"), + ) + + +async def get_organization_ai_model_configuration_v2( + organization_id: int | None, +) -> OrganizationAIModelConfigurationV2 | None: + if organization_id is None: + return None + row = await db_client.get_configuration( + organization_id, + OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value, + ) + if row is None or not row.value: + return None + try: + return OrganizationAIModelConfigurationV2.model_validate(row.value) + except ValidationError as exc: + logger.warning( + "Invalid org AI model configuration v2 for organization " + f"{organization_id}: {exc}. Falling back to legacy configuration." + ) + return None + + +async def upsert_organization_ai_model_configuration_v2( + organization_id: int, + configuration: OrganizationAIModelConfigurationV2, +) -> OrganizationAIModelConfigurationV2: + await db_client.upsert_configuration( + organization_id, + OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value, + configuration.model_dump(mode="json", exclude_none=True), + ) + return configuration + + +async def migrate_workflow_model_configurations_to_v2( + *, + organization_id: int, + fallback_user_config: EffectiveAIModelConfiguration, +) -> WorkflowAIModelConfigurationMigrationResult: + workflows = await _list_workflows_for_model_configuration_migration(organization_id) + owner_configs: dict[int, EffectiveAIModelConfiguration] = {} + workflow_updates: list[tuple[int, dict]] = [] + definition_updates: list[tuple[int, dict]] = [] + migrated_workflow_ids: set[int] = set() + + for workflow in workflows: + base_config = fallback_user_config + if workflow.user_id is not None: + if workflow.user_id not in owner_configs: + owner_configs[ + workflow.user_id + ] = await db_client.get_user_configurations(workflow.user_id) + base_config = owner_configs[workflow.user_id] + + workflow_configs, workflow_changed = ( + migrate_workflow_configuration_model_override_to_v2( + workflow.workflow_configurations, + base_config, + ) + ) + if workflow_changed: + workflow_updates.append((workflow.id, workflow_configs)) + migrated_workflow_ids.add(workflow.id) + + for definition in workflow.definitions: + definition_configs, definition_changed = ( + migrate_workflow_configuration_model_override_to_v2( + definition.workflow_configurations, + base_config, + ) + ) + if definition_changed: + definition_updates.append((definition.id, definition_configs)) + migrated_workflow_ids.add(workflow.id) + + if workflow_updates or definition_updates: + async with db_client.async_session() as session: + for workflow_id, workflow_configs in workflow_updates: + await session.execute( + update(WorkflowModel) + .where(WorkflowModel.id == workflow_id) + .values(workflow_configurations=workflow_configs) + ) + for definition_id, definition_configs in definition_updates: + await session.execute( + update(WorkflowDefinitionModel) + .where(WorkflowDefinitionModel.id == definition_id) + .values(workflow_configurations=definition_configs) + ) + await session.commit() + + return WorkflowAIModelConfigurationMigrationResult( + workflow_count=len(migrated_workflow_ids), + definition_count=len(definition_updates), + workflow_ids=sorted(migrated_workflow_ids), + ) + + +def migrate_workflow_configuration_model_override_to_v2( + workflow_configurations: dict | None, + base_config: EffectiveAIModelConfiguration, +) -> tuple[dict, bool]: + if not isinstance(workflow_configurations, dict): + return {}, False + + migrated = copy.deepcopy(workflow_configurations) + model_overrides = migrated.get("model_overrides") + existing_v2_override = migrated.get(WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY) + if not isinstance(model_overrides, dict): + if "model_overrides" in migrated: + migrated.pop("model_overrides", None) + return migrated, True + return migrated, False + + if not existing_v2_override: + effective = resolve_effective_config(base_config, model_overrides) + v2_override = convert_legacy_ai_model_configuration_to_v2(effective) + migrated[WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY] = v2_override.model_dump( + mode="json", exclude_none=True + ) + migrated.pop("model_overrides", None) + return migrated, True + + +def merge_ai_model_configuration_v2_secrets( + incoming: OrganizationAIModelConfigurationV2, + existing: OrganizationAIModelConfigurationV2 | None, +) -> OrganizationAIModelConfigurationV2: + if existing is None: + return incoming + + incoming_dict = incoming.model_dump(mode="json", exclude_none=True) + existing_dict = existing.model_dump(mode="json", exclude_none=True) + + if incoming_dict.get("mode") == "dograh" and existing_dict.get("mode") == "dograh": + incoming_dograh = incoming_dict.get("dograh") or {} + existing_dograh = existing_dict.get("dograh") or {} + incoming_key = incoming_dograh.get("api_key") + existing_key = existing_dograh.get("api_key") + if incoming_key and existing_key and contains_masked_key(incoming_key): + incoming_dograh["api_key"] = resolve_masked_api_keys( + incoming_key, + existing_key, + ) + + if incoming_dict.get("mode") == "byok" and existing_dict.get("mode") == "byok": + _merge_byok_secret_fields(incoming_dict.get("byok"), existing_dict.get("byok")) + + return OrganizationAIModelConfigurationV2.model_validate(incoming_dict) + + +def check_for_masked_keys_in_ai_model_configuration_v2( + configuration: OrganizationAIModelConfigurationV2, +) -> None: + data = configuration.model_dump(mode="json", exclude_none=True) + _raise_if_masked_secret(data) + + +def mask_ai_model_configuration_v2( + configuration: OrganizationAIModelConfigurationV2 | None, +) -> dict | None: + if configuration is None: + return None + data = configuration.model_dump(mode="json", exclude_none=True) + _mask_secret_fields(data) + return data + + +def convert_legacy_ai_model_configuration_to_v2( + configuration: EffectiveAIModelConfiguration, +) -> OrganizationAIModelConfigurationV2: + dograh_key = _first_dograh_api_key(configuration) + if dograh_key: + return _convert_any_dograh_legacy_configuration(configuration, dograh_key) + + if configuration.is_realtime: + if configuration.realtime is None or configuration.llm is None: + raise ValueError("Realtime legacy configuration is incomplete") + return OrganizationAIModelConfigurationV2( + mode="byok", + byok=BYOKAIModelConfiguration( + mode="realtime", + realtime=BYOKRealtimeAIModelConfiguration( + realtime=configuration.realtime, + llm=configuration.llm, + embeddings=configuration.embeddings, + ), + ), + ) + + if ( + configuration.llm is None + or configuration.tts is None + or configuration.stt is None + ): + raise ValueError("Pipeline legacy configuration is incomplete") + return OrganizationAIModelConfigurationV2( + mode="byok", + byok=BYOKAIModelConfiguration( + mode="pipeline", + pipeline=BYOKPipelineAIModelConfiguration( + llm=configuration.llm, + tts=configuration.tts, + stt=configuration.stt, + embeddings=configuration.embeddings, + ), + ), + ) + + +def dograh_embeddings_base_url() -> str: + return f"{MPS_API_URL}/api/v1/llm" + + +def apply_managed_embeddings_base_url( + *, + provider: str | None, + base_url: str | None, +) -> str | None: + if provider == ServiceProviders.DOGRAH.value or provider == ServiceProviders.DOGRAH: + return dograh_embeddings_base_url() + return base_url + + +def _merge_byok_secret_fields(incoming_byok: dict | None, existing_byok: dict | None): + if not isinstance(incoming_byok, dict) or not isinstance(existing_byok, dict): + return + incoming_mode = incoming_byok.get("mode") + existing_mode = existing_byok.get("mode") + if incoming_mode != existing_mode: + return + section_names = ( + ("llm", "tts", "stt", "embeddings") + if incoming_mode == "pipeline" + else ("realtime", "llm", "embeddings") + ) + incoming_container = incoming_byok.get(incoming_mode) + existing_container = existing_byok.get(existing_mode) + if not isinstance(incoming_container, dict) or not isinstance( + existing_container, dict + ): + return + for section_name in section_names: + incoming_section = incoming_container.get(section_name) + existing_section = existing_container.get(section_name) + if isinstance(incoming_section, dict) and isinstance(existing_section, dict): + _merge_service_secret_fields(incoming_section, existing_section) + + +async def _list_workflows_for_model_configuration_migration( + organization_id: int, +) -> list[WorkflowModel]: + async with db_client.async_session() as session: + result = await session.execute( + select(WorkflowModel) + .options(selectinload(WorkflowModel.definitions)) + .where(WorkflowModel.organization_id == organization_id) + ) + return list(result.scalars().unique().all()) + + +def _merge_service_secret_fields(incoming: dict, existing: dict): + if ( + incoming.get("provider") is not None + and existing.get("provider") is not None + and incoming.get("provider") != existing.get("provider") + ): + return + for secret_field in SERVICE_SECRET_FIELDS: + if secret_field not in existing: + continue + incoming_secret = incoming.get(secret_field) + existing_secret = existing[secret_field] + if incoming_secret is None: + incoming[secret_field] = existing_secret + elif contains_masked_key(incoming_secret): + incoming[secret_field] = resolve_masked_api_keys( + incoming_secret, + existing_secret, + ) + + +def _raise_if_masked_secret(value): + if isinstance(value, dict): + for key, nested in value.items(): + if key in SERVICE_SECRET_FIELDS and contains_masked_key(nested): + raise ValueError( + f"The {key} appears to be masked. Please provide the actual " + "value, not the masked value." + ) + _raise_if_masked_secret(nested) + elif isinstance(value, list): + for item in value: + _raise_if_masked_secret(item) + + +def _mask_secret_fields(value): + if isinstance(value, dict): + for key, nested in list(value.items()): + if key in SERVICE_SECRET_FIELDS and nested: + value[key] = _mask_secret_value(nested) + else: + _mask_secret_fields(nested) + elif isinstance(value, list): + for item in value: + _mask_secret_fields(item) + + +def _mask_secret_value(value): + if isinstance(value, list): + return [mask_key(item) for item in value] + return mask_key(value) + + +def _has_model_services(configuration: EffectiveAIModelConfiguration) -> bool: + return any( + service is not None + for service in ( + configuration.llm, + configuration.tts, + configuration.stt, + configuration.embeddings, + configuration.realtime, + ) + ) + + +def _convert_any_dograh_legacy_configuration( + configuration: EffectiveAIModelConfiguration, + dograh_key: str, +) -> OrganizationAIModelConfigurationV2: + speed = getattr(configuration.tts, "speed", 1.0) + if speed not in DOGRAH_SPEED_OPTIONS: + speed = 1.0 + return OrganizationAIModelConfigurationV2( + mode="dograh", + dograh=DograhManagedAIModelConfiguration( + api_key=dograh_key, + voice=getattr(configuration.tts, "voice", DOGRAH_DEFAULT_VOICE) + or DOGRAH_DEFAULT_VOICE, + speed=speed, + language=getattr(configuration.stt, "language", DOGRAH_DEFAULT_LANGUAGE) + or DOGRAH_DEFAULT_LANGUAGE, + ), + ) + + +def _first_dograh_api_key(configuration: EffectiveAIModelConfiguration) -> str | None: + for service in ( + configuration.llm, + configuration.tts, + configuration.stt, + configuration.embeddings, + configuration.realtime, + ): + if service is None or _provider(service) != ServiceProviders.DOGRAH: + continue + try: + return _single_api_key(service) + except ValueError: + continue + return None + + +def _provider(service): + return getattr(service, "provider", None) + + +def _single_api_key(service) -> str: + if hasattr(service, "get_all_api_keys"): + keys = service.get_all_api_keys() + if len(keys) != 1: + raise ValueError("Expected exactly one API key") + return keys[0] + key = getattr(service, "api_key", None) + if not key: + raise ValueError("Expected an API key") + return key diff --git a/api/services/configuration/masking.py b/api/services/configuration/masking.py index 877cad97..adbc6215 100644 --- a/api/services/configuration/masking.py +++ b/api/services/configuration/masking.py @@ -151,21 +151,35 @@ def mask_workflow_configurations(config: Optional[Dict]) -> Optional[Dict]: masked = copy.deepcopy(config) model_overrides = masked.get("model_overrides") - if not isinstance(model_overrides, dict): - return masked + if isinstance(model_overrides, dict): + for section in MODEL_OVERRIDE_FIELDS: + override = model_overrides.get(section) + if not isinstance(override, dict): + continue + for secret_field in SERVICE_SECRET_FIELDS: + raw = override.get(secret_field) + if raw: + override[secret_field] = _mask_secret_value(raw) - for section in MODEL_OVERRIDE_FIELDS: - override = model_overrides.get(section) - if not isinstance(override, dict): - continue - for secret_field in SERVICE_SECRET_FIELDS: - raw = override.get(secret_field) - if raw: - override[secret_field] = _mask_secret_value(raw) + v2_override = masked.get("model_configuration_v2_override") + if isinstance(v2_override, dict): + _mask_nested_service_secrets(v2_override) return masked +def _mask_nested_service_secrets(value): + if isinstance(value, dict): + for key, nested in list(value.items()): + if key in SERVICE_SECRET_FIELDS and nested: + value[key] = _mask_secret_value(nested) + else: + _mask_nested_service_secrets(nested) + elif isinstance(value, list): + for item in value: + _mask_nested_service_secrets(item) + + # --------------------------------------------------------------------------- # Workflow definition helpers – mask / merge node API keys # --------------------------------------------------------------------------- diff --git a/api/services/configuration/registry.py b/api/services/configuration/registry.py index f05c5f71..9fa9ee3b 100644 --- a/api/services/configuration/registry.py +++ b/api/services/configuration/registry.py @@ -1472,11 +1472,26 @@ class AzureOpenAIEmbeddingsConfiguration(BaseEmbeddingsConfiguration): ) +DOGRAH_EMBEDDING_MODELS = ["default"] + + +@register_embeddings +class DograhEmbeddingsConfiguration(BaseEmbeddingsConfiguration): + model_config = DOGRAH_PROVIDER_MODEL_CONFIG + provider: Literal[ServiceProviders.DOGRAH] = ServiceProviders.DOGRAH + model: str = Field( + default="default", + description="Dograh-managed embedding model.", + json_schema_extra={"examples": DOGRAH_EMBEDDING_MODELS}, + ) + + EmbeddingsConfig = Annotated[ Union[ OpenAIEmbeddingsConfiguration, OpenRouterEmbeddingsConfiguration, AzureOpenAIEmbeddingsConfiguration, + DograhEmbeddingsConfiguration, ], Field(discriminator="provider"), ] diff --git a/api/services/organization_preferences.py b/api/services/organization_preferences.py new file mode 100644 index 00000000..82204ea0 --- /dev/null +++ b/api/services/organization_preferences.py @@ -0,0 +1,62 @@ +from inspect import isawaitable + +from loguru import logger +from pydantic import ValidationError + +from api.db import db_client +from api.enums import OrganizationConfigurationKey +from api.schemas.organization_preferences import OrganizationPreferences + + +async def get_organization_preferences( + organization_id: int | None, + db=None, +) -> OrganizationPreferences: + if organization_id is None: + return OrganizationPreferences() + + db = db or db_client + row = await _get_configuration( + db, + organization_id, + OrganizationConfigurationKey.ORGANIZATION_PREFERENCES.value, + ) + if row is None: + row = await _get_configuration( + db, + organization_id, + OrganizationConfigurationKey.MODEL_CONFIGURATION_PREFERENCES.value, + ) + return _parse_preferences(row.value if row is not None else None, organization_id) + + +async def upsert_organization_preferences( + organization_id: int, + preferences: OrganizationPreferences, +) -> OrganizationPreferences: + await db_client.upsert_configuration( + organization_id, + OrganizationConfigurationKey.ORGANIZATION_PREFERENCES.value, + preferences.model_dump(mode="json", exclude_none=True), + ) + return preferences + + +async def _get_configuration(db, organization_id: int, key: str): + row = db.get_configuration(organization_id, key) + if isawaitable(row): + row = await row + return row + + +def _parse_preferences(value, organization_id: int) -> OrganizationPreferences: + if not value or not isinstance(value, dict): + return OrganizationPreferences() + try: + return OrganizationPreferences.model_validate(value) + except ValidationError as exc: + logger.warning( + "Invalid organization preferences for organization " + f"{organization_id}: {exc}. Returning defaults." + ) + return OrganizationPreferences() diff --git a/api/services/pipecat/run_pipeline.py b/api/services/pipecat/run_pipeline.py index 7ce41d87..63c11f53 100644 --- a/api/services/pipecat/run_pipeline.py +++ b/api/services/pipecat/run_pipeline.py @@ -195,14 +195,17 @@ async def run_pipeline_telephony( # 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 + from api.services.configuration.ai_model_configuration import ( + get_effective_ai_model_configuration_for_workflow, + ) - 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") + user_config = await get_effective_ai_model_configuration_for_workflow( + user_id=user_id, + organization_id=workflow.organization_id if workflow else None, + workflow_configurations=run_configs, ) is_realtime = bool(user_config.is_realtime and user_config.realtime is not None) @@ -272,15 +275,18 @@ async def run_pipeline_smallwebrtc( # 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 + from api.services.configuration.ai_model_configuration import ( + get_effective_ai_model_configuration_for_workflow, + ) 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") + user_config = await get_effective_ai_model_configuration_for_workflow( + user_id=user_id, + organization_id=workflow.organization_id if workflow else None, + workflow_configurations=run_configs, ) is_realtime = bool(user_config.is_realtime and user_config.realtime is not None) @@ -380,11 +386,14 @@ async def _run_pipeline( # 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 + from api.services.configuration.ai_model_configuration import ( + get_effective_ai_model_configuration_for_workflow, + ) - user_config = await db_client.get_user_configurations(user_id) - user_config = resolve_effective_config( - user_config, run_configs.get("model_overrides") + user_config = await get_effective_ai_model_configuration_for_workflow( + user_id=user_id, + organization_id=workflow.organization_id, + workflow_configurations=run_configs, ) else: user_config = resolved_user_config @@ -508,10 +517,17 @@ async def _run_pipeline( embeddings_endpoint = None embeddings_api_version = None if user_config and user_config.embeddings: + from api.services.configuration.ai_model_configuration import ( + apply_managed_embeddings_base_url, + ) + embeddings_api_key = user_config.embeddings.api_key embeddings_model = user_config.embeddings.model embeddings_provider = getattr(user_config.embeddings, "provider", None) - embeddings_base_url = getattr(user_config.embeddings, "base_url", None) + embeddings_base_url = apply_managed_embeddings_base_url( + provider=embeddings_provider, + base_url=getattr(user_config.embeddings, "base_url", None), + ) embeddings_endpoint = getattr(user_config.embeddings, "endpoint", None) embeddings_api_version = getattr(user_config.embeddings, "api_version", None) diff --git a/api/services/quota_service.py b/api/services/quota_service.py index 23c7120d..6114ae99 100644 --- a/api/services/quota_service.py +++ b/api/services/quota_service.py @@ -10,8 +10,10 @@ from loguru import logger from api.db import db_client from api.db.models import UserModel +from api.services.configuration.ai_model_configuration import ( + get_effective_ai_model_configuration_for_workflow, +) from api.services.configuration.registry import ServiceProviders -from api.services.configuration.resolve import resolve_effective_config from api.services.mps_service_key_client import mps_service_key_client @@ -48,17 +50,20 @@ async def check_dograh_quota( if quota is insufficient. """ try: - # Get user configurations - user_config = await db_client.get_user_configurations(user.id) + organization_id = user.selected_organization_id + workflow_configurations = None if workflow_id is not None: workflow = await db_client.get_workflow_by_id(workflow_id) if workflow: - model_overrides = (workflow.workflow_configurations or {}).get( - "model_overrides" - ) - if model_overrides: - user_config = resolve_effective_config(user_config, model_overrides) + organization_id = workflow.organization_id + workflow_configurations = workflow.workflow_configurations + + user_config = await get_effective_ai_model_configuration_for_workflow( + user_id=user.id, + organization_id=organization_id, + workflow_configurations=workflow_configurations, + ) # Check if user is using any Dograh service using_dograh = False @@ -76,6 +81,13 @@ async def check_dograh_quota( using_dograh = True dograh_api_keys.add(user_config.tts.api_key) + if ( + user_config.embeddings + and user_config.embeddings.provider == ServiceProviders.DOGRAH + ): + using_dograh = True + dograh_api_keys.add(user_config.embeddings.api_key) + # If not using Dograh, quota check passes if not using_dograh: return QuotaCheckResult(has_quota=True) @@ -84,7 +96,9 @@ async def check_dograh_quota( for api_key in dograh_api_keys: try: usage = await mps_service_key_client.check_service_key_usage( - api_key, created_by=user.provider_id + api_key, + organization_id=organization_id, + created_by=user.provider_id, ) remaining = usage.get("remaining_credits", 0.0) diff --git a/api/services/workflow/qa/llm_config.py b/api/services/workflow/qa/llm_config.py index 9c1159a6..ec3ae41d 100644 --- a/api/services/workflow/qa/llm_config.py +++ b/api/services/workflow/qa/llm_config.py @@ -2,7 +2,6 @@ import random -from api.db import db_client from api.db.models import WorkflowRunModel from api.services.workflow.dto import QANodeData @@ -54,7 +53,27 @@ async def resolve_user_llm_config( llm_config: dict = {} if user_id: - user_configuration = await db_client.get_user_configurations(user_id) + from api.services.configuration.ai_model_configuration import ( + get_effective_ai_model_configuration_for_workflow, + ) + + workflow_configurations = {} + if workflow_run.definition: + workflow_configurations = ( + workflow_run.definition.workflow_configurations or {} + ) + elif workflow_run.workflow: + workflow_configurations = ( + workflow_run.workflow.workflow_configurations or {} + ) + + user_configuration = await get_effective_ai_model_configuration_for_workflow( + user_id=user_id, + organization_id=workflow_run.workflow.organization_id + if workflow_run.workflow + else None, + workflow_configurations=workflow_configurations, + ) llm_config = user_configuration.model_dump(exclude_none=True).get("llm", {}) provider = llm_config.get("provider", "openai") diff --git a/api/services/workflow/text_chat_runner.py b/api/services/workflow/text_chat_runner.py index 83a4ad15..59073c80 100644 --- a/api/services/workflow/text_chat_runner.py +++ b/api/services/workflow/text_chat_runner.py @@ -32,7 +32,6 @@ 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 ( @@ -410,9 +409,14 @@ async def execute_text_chat_pending_turn( 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") + from api.services.configuration.ai_model_configuration import ( + get_effective_ai_model_configuration_for_workflow, + ) + + user_config = await get_effective_ai_model_configuration_for_workflow( + user_id=workflow_run.workflow.user.id, + organization_id=workflow.organization_id, + workflow_configurations=run_configs, ) if user_config.llm is None: raise ValueError("Text chat requires an LLM configuration") @@ -466,9 +470,17 @@ async def execute_text_chat_pending_turn( embeddings_model = None embeddings_base_url = None if user_config.embeddings: + from api.services.configuration.ai_model_configuration import ( + apply_managed_embeddings_base_url, + ) + embeddings_api_key = user_config.embeddings.api_key embeddings_model = user_config.embeddings.model - embeddings_base_url = getattr(user_config.embeddings, "base_url", None) + embeddings_provider = getattr(user_config.embeddings, "provider", None) + embeddings_base_url = apply_managed_embeddings_base_url( + provider=embeddings_provider, + 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( diff --git a/api/tasks/knowledge_base_processing.py b/api/tasks/knowledge_base_processing.py index 4e943299..f496ac0e 100644 --- a/api/tasks/knowledge_base_processing.py +++ b/api/tasks/knowledge_base_processing.py @@ -157,12 +157,24 @@ async def process_knowledge_base_document( embeddings_endpoint = None embeddings_api_version = None if document.created_by: - user_config = await db_client.get_user_configurations(document.created_by) + from api.services.configuration.ai_model_configuration import ( + apply_managed_embeddings_base_url, + get_resolved_ai_model_configuration, + ) + + resolved_config = await get_resolved_ai_model_configuration( + user_id=document.created_by, + organization_id=document.organization_id, + ) + user_config = resolved_config.effective if user_config.embeddings: embeddings_provider = getattr(user_config.embeddings, "provider", None) embeddings_api_key = user_config.embeddings.api_key embeddings_model = user_config.embeddings.model - embeddings_base_url = getattr(user_config.embeddings, "base_url", None) + embeddings_base_url = apply_managed_embeddings_base_url( + provider=embeddings_provider, + base_url=getattr(user_config.embeddings, "base_url", None), + ) embeddings_endpoint = getattr(user_config.embeddings, "endpoint", None) embeddings_api_version = getattr( user_config.embeddings, "api_version", None diff --git a/api/tests/test_ai_model_configuration_v2.py b/api/tests/test_ai_model_configuration_v2.py new file mode 100644 index 00000000..023a330e --- /dev/null +++ b/api/tests/test_ai_model_configuration_v2.py @@ -0,0 +1,295 @@ +import pytest +from pydantic import ValidationError + +from api.schemas.ai_model_configuration import ( + DograhManagedAIModelConfiguration, + OrganizationAIModelConfigurationV2, + compile_ai_model_configuration_v2, +) +from api.schemas.user_configuration import UserConfiguration +from api.services.configuration.ai_model_configuration import ( + WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY, + check_for_masked_keys_in_ai_model_configuration_v2, + convert_legacy_ai_model_configuration_to_v2, + mask_ai_model_configuration_v2, + merge_ai_model_configuration_v2_secrets, + migrate_workflow_configuration_model_override_to_v2, +) +from api.services.configuration.masking import mask_key +from api.services.configuration.registry import ( + DeepgramSTTConfiguration, + DograhLLMService, + DograhSTTService, + DograhTTSService, + ElevenlabsTTSConfiguration, + OpenAIEmbeddingsConfiguration, + OpenAILLMService, +) + + +def test_dograh_v2_compiles_to_effective_managed_pipeline_with_embeddings(): + config = OrganizationAIModelConfigurationV2( + mode="dograh", + dograh=DograhManagedAIModelConfiguration( + api_key="mps-secret", + voice="default", + speed=1.2, + language="multi", + ), + ) + + effective = compile_ai_model_configuration_v2(config) + + assert effective.is_realtime is False + assert effective.llm.provider == "dograh" + assert effective.llm.model == "default" + assert effective.tts.provider == "dograh" + assert effective.tts.speed == 1.2 + assert effective.stt.provider == "dograh" + assert effective.stt.language == "multi" + assert effective.embeddings.provider == "dograh" + assert effective.embeddings.model == "default" + + +def test_dograh_v2_rejects_non_predefined_speed(): + with pytest.raises(ValidationError): + OrganizationAIModelConfigurationV2( + mode="dograh", + dograh=DograhManagedAIModelConfiguration( + api_key="mps-secret", + speed=1.5, + ), + ) + + +def test_byok_v2_rejects_dograh_provider(): + with pytest.raises(ValidationError): + OrganizationAIModelConfigurationV2.model_validate( + { + "mode": "byok", + "byok": { + "mode": "pipeline", + "pipeline": { + "llm": { + "provider": "dograh", + "api_key": "mps-secret", + "model": "default", + }, + "tts": { + "provider": "dograh", + "api_key": "mps-secret", + "model": "default", + "voice": "default", + }, + "stt": { + "provider": "dograh", + "api_key": "mps-secret", + "model": "default", + }, + }, + }, + } + ) + + +def test_masked_dograh_key_is_preserved_when_saving_same_mode(): + existing = OrganizationAIModelConfigurationV2( + mode="dograh", + dograh=DograhManagedAIModelConfiguration(api_key="mps-real-secret"), + ) + incoming = OrganizationAIModelConfigurationV2( + mode="dograh", + dograh=DograhManagedAIModelConfiguration(api_key=mask_key("mps-real-secret")), + ) + + merged = merge_ai_model_configuration_v2_secrets(incoming, existing) + + assert merged.dograh.api_key == "mps-real-secret" + check_for_masked_keys_in_ai_model_configuration_v2(merged) + + +def test_masked_v2_configuration_masks_nested_service_keys(): + config = OrganizationAIModelConfigurationV2( + mode="byok", + byok={ + "mode": "pipeline", + "pipeline": { + "llm": { + "provider": "openai", + "api_key": "sk-real-secret", + "model": "gpt-4.1", + }, + "tts": { + "provider": "elevenlabs", + "api_key": "el-real-secret", + "model": "eleven_flash_v2_5", + "voice": "Rachel", + }, + "stt": { + "provider": "deepgram", + "api_key": "dg-real-secret", + "model": "nova-3-general", + }, + }, + }, + ) + + masked = mask_ai_model_configuration_v2(config) + + assert masked["byok"]["pipeline"]["llm"]["api_key"] == mask_key("sk-real-secret") + assert masked["byok"]["pipeline"]["tts"]["api_key"] == mask_key("el-real-secret") + assert masked["byok"]["pipeline"]["stt"]["api_key"] == mask_key("dg-real-secret") + + +def test_legacy_all_dograh_pipeline_converts_to_dograh_v2(): + legacy = UserConfiguration( + llm=DograhLLMService( + provider="dograh", + api_key=["mps-secret"], + model="default", + ), + tts=DograhTTSService( + provider="dograh", + api_key=["mps-secret"], + model="default", + voice="default", + speed=1.0, + ), + stt=DograhSTTService( + provider="dograh", + api_key=["mps-secret"], + model="default", + language="multi", + ), + ) + + config = convert_legacy_ai_model_configuration_to_v2(legacy) + + assert config.mode == "dograh" + assert config.dograh.api_key == "mps-secret" + + +def test_legacy_mixed_dograh_pipeline_converts_to_dograh_v2(): + legacy = UserConfiguration( + llm=OpenAILLMService( + provider="openai", + api_key="sk-llm", + model="gpt-4.1", + ), + tts=DograhTTSService( + provider="dograh", + api_key="mps-tts", + model="default", + voice="default", + ), + stt=DograhSTTService( + provider="dograh", + api_key="mps-stt", + model="default", + ), + embeddings=OpenAIEmbeddingsConfiguration( + provider="openai", + api_key="sk-emb", + model="text-embedding-3-small", + ), + ) + + config = convert_legacy_ai_model_configuration_to_v2(legacy) + + assert config.mode == "dograh" + assert config.dograh.api_key == "mps-tts" + assert config.dograh.voice == "default" + + +def test_legacy_byok_pipeline_converts_to_byok_v2(): + legacy = UserConfiguration( + llm=OpenAILLMService( + provider="openai", + api_key="sk-llm", + model="gpt-4.1", + ), + tts=ElevenlabsTTSConfiguration( + provider="elevenlabs", + api_key="el-tts", + model="eleven_flash_v2_5", + voice="Rachel", + ), + stt=DeepgramSTTConfiguration( + provider="deepgram", + api_key="dg-stt", + model="nova-3-general", + ), + embeddings=OpenAIEmbeddingsConfiguration( + provider="openai", + api_key="sk-emb", + model="text-embedding-3-small", + ), + ) + + config = convert_legacy_ai_model_configuration_to_v2(legacy) + + assert config.mode == "byok" + assert config.byok.mode == "pipeline" + assert config.byok.pipeline.llm.provider == "openai" + assert config.byok.pipeline.tts.provider == "elevenlabs" + + +def test_workflow_model_override_migration_removes_v1_override_and_sets_v2(): + base = UserConfiguration( + llm=OpenAILLMService( + provider="openai", + api_key="sk-llm", + model="gpt-4.1", + ), + tts=ElevenlabsTTSConfiguration( + provider="elevenlabs", + api_key="el-tts", + model="eleven_flash_v2_5", + voice="Rachel", + ), + stt=DeepgramSTTConfiguration( + provider="deepgram", + api_key="dg-stt", + model="nova-3-general", + ), + ) + workflow_configurations = { + "ambient_noise_configuration": {"enabled": False}, + "model_overrides": { + "tts": { + "provider": "dograh", + "api_key": "mps-workflow", + "model": "default", + "voice": "default", + } + }, + } + + migrated, changed = migrate_workflow_configuration_model_override_to_v2( + workflow_configurations, + base, + ) + + assert changed is True + assert "model_overrides" not in migrated + assert migrated["ambient_noise_configuration"] == {"enabled": False} + v2_override = migrated[WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY] + assert v2_override["mode"] == "dograh" + assert v2_override["dograh"]["api_key"] == "mps-workflow" + + +def test_workflow_model_override_migration_removes_invalid_v1_override_marker(): + base = UserConfiguration() + workflow_configurations = { + "ambient_noise_configuration": {"enabled": False}, + "model_overrides": None, + } + + migrated, changed = migrate_workflow_configuration_model_override_to_v2( + workflow_configurations, + base, + ) + + assert changed is True + assert "model_overrides" not in migrated + assert migrated["ambient_noise_configuration"] == {"enabled": False} diff --git a/api/tests/test_masked_key_rejection.py b/api/tests/test_masked_key_rejection.py index c6fdb51b..9bf1f549 100644 --- a/api/tests/test_masked_key_rejection.py +++ b/api/tests/test_masked_key_rejection.py @@ -1,3 +1,4 @@ +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from fastapi import FastAPI @@ -14,14 +15,14 @@ from api.services.configuration.registry import ( ) -def _make_test_app(): +def _make_test_app(selected_organization_id=None): app = FastAPI() app.include_router(router) mock_user = MagicMock() mock_user.id = 1 mock_user.is_superuser = False - mock_user.selected_organization_id = None + mock_user.selected_organization_id = selected_organization_id app.dependency_overrides[get_user] = lambda: mock_user return app @@ -210,3 +211,38 @@ class TestMaskedKeyRejection: ) assert response.status_code == 200 + + def test_preference_only_update_does_not_validate_or_save_model_config(self): + """Saving a test phone number through the legacy endpoint must not touch models.""" + app = _make_test_app(selected_organization_id=11) + client = TestClient(app) + preferences = SimpleNamespace(test_phone_number=None, timezone=None) + + with ( + patch("api.routes.user.db_client") as mock_db, + patch("api.routes.user.UserConfigurationValidator") as mock_validator, + patch( + "api.routes.user.get_organization_preferences", + new=AsyncMock(return_value=preferences), + ), + patch( + "api.routes.user.upsert_organization_preferences", + new=AsyncMock(return_value=preferences), + ) as upsert_preferences, + ): + existing = _existing_openai_config() + mock_db.get_user_configurations = AsyncMock(return_value=existing) + mock_db.update_user_configuration = AsyncMock() + mock_db.get_organization_by_id = AsyncMock(return_value=None) + mock_validator.return_value.validate = AsyncMock() + + response = client.put( + "/user/configurations/user", + json={"test_phone_number": "+15551234567"}, + ) + + assert response.status_code == 200 + assert response.json()["test_phone_number"] == "+15551234567" + mock_db.update_user_configuration.assert_not_called() + mock_validator.return_value.validate.assert_not_called() + upsert_preferences.assert_awaited_once() diff --git a/api/tests/test_telephony_routes.py b/api/tests/test_telephony_routes.py index 49c2f8d4..76c8a54d 100644 --- a/api/tests/test_telephony_routes.py +++ b/api/tests/test_telephony_routes.py @@ -103,6 +103,61 @@ def test_initiate_call_executes_as_workflow_owner_for_shared_org_workflow(): assert initiate_kwargs["workflow_id"] == workflow.id assert initiate_kwargs["user_id"] == workflow.user_id assert "user_id=99" in initiate_kwargs["webhook_url"] + mock_db.get_user_configurations.assert_not_called() + + +def test_initiate_call_uses_organization_preference_phone_number(): + 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="+15550000000") + ) + mock_db.get_configuration = Mock( + return_value=SimpleNamespace(value={"test_phone_number": "+15557654321"}) + ) + 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={}, + ) + ) + mock_db.update_workflow_run = AsyncMock() + + response = client.post( + "/telephony/initiate-call", + json={"workflow_id": workflow.id}, + ) + + assert response.status_code == 200 + assert provider.initiate_call.await_args.kwargs["to_number"] == "+15557654321" + mock_db.get_user_configurations.assert_not_called() def test_initiate_call_rejects_existing_run_for_different_workflow(): diff --git a/api/tests/test_workflow_text_chat.py b/api/tests/test_workflow_text_chat.py index 1b830bf8..219f333f 100644 --- a/api/tests/test_workflow_text_chat.py +++ b/api/tests/test_workflow_text_chat.py @@ -51,6 +51,38 @@ async def _create_user_and_workflow( return user, workflow +@pytest.mark.asyncio +async def test_text_chat_session_creation_requires_selected_organization(): + from httpx import ASGITransport, AsyncClient + + from api.app import app + from api.services.auth.depends import get_user + + user = UserModel(provider_id="textchat-user-no-selected-org") + + async def mock_get_user(): + return user + + original_override = app.dependency_overrides.get(get_user) + app.dependency_overrides[get_user] = mock_get_user + + try: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/workflow/123/text-chat/sessions", json={} + ) + finally: + if original_override: + app.dependency_overrides[get_user] = original_override + else: + app.dependency_overrides.pop(get_user, None) + + assert response.status_code == 400 + assert response.json() == {"detail": "No organization selected"} + + @pytest.mark.asyncio async def test_text_chat_session_creation_executes_initial_assistant_turn( db_session, diff --git a/docs/api-reference/openapi.json b/docs/api-reference/openapi.json index 1eb198e3..dfc4587b 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"}}],"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"}}}}},"x-sdk-method":"create_tool","x-sdk-description":"Create a reusable tool for the authenticated organization."}},"/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."},"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":"Display name for the tool.","llm_hint":"Use a concise action-oriented name; this influences the function name shown to the agent."},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Description shown to the agent when deciding whether to call it.","llm_hint":"State exactly when the agent should call the tool and what result it gets."},"category":{"type":"string","enum":["http_api","end_call","transfer_call","calculator","native","integration","mcp"],"title":"Category","description":"Tool category. Must match definition.type.","default":"http_api"},"icon":{"anyOf":[{"type":"string","maxLength":50},{"type":"null"}],"title":"Icon","description":"Lucide icon identifier.","default":"globe"},"icon_color":{"anyOf":[{"type":"string","maxLength":7},{"type":"null"}],"title":"Icon Color","description":"Hex color for the tool icon.","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","description":"Typed tool 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 reusable 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, the model 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 model 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","enum":["GET","POST","PUT","PATCH","DELETE"],"title":"Method","description":"HTTP method to use for the request.","llm_hint":"Use one of GET, POST, PUT, PATCH, DELETE."},"url":{"type":"string","title":"Url","description":"Target HTTP or HTTPS URL.","llm_hint":"Use the final endpoint URL. Authentication belongs in credential_uuid, not embedded in the URL."},"headers":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Headers","description":"Static headers to include with every request.","llm_hint":"Do not place secrets here. Store secrets in the UI credential manager and reference them with credential_uuid."},"credential_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credential Uuid","description":"Reference to an external credential for request authentication.","llm_hint":"Use a credential_uuid returned by list_credentials. The MCP flow does not create credential secrets."},"parameters":{"anyOf":[{"items":{"$ref":"#/components/schemas/ToolParameter"},"type":"array"},{"type":"null"}],"title":"Parameters","description":"Parameters the model must provide when calling this tool."},"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","minimum":1.0},{"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."},"customMessageRecordingId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessagerecordingid","description":"Recording ID for an 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 use http:// or https://.","llm_hint":"Use the server's streamable HTTP MCP endpoint."},"credential_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credential Uuid","description":"Reference to an external credential for MCP server auth.","llm_hint":"Use a credential_uuid returned by list_credentials. Credentials are created by the user in the UI."},"tools_filter":{"items":{"type":"string"},"type":"array","title":"Tools Filter","description":"Allowlist of MCP tool names to expose. Empty exposes all tools.","llm_hint":"Use exact MCP tool names from the remote server catalog when you need to restrict the exposed tools."},"timeout_secs":{"type":"integer","minimum":0.0,"title":"Timeout Secs","description":"Connection timeout in seconds.","default":30},"sse_read_timeout_secs":{"type":"integer","minimum":0.0,"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.","llm_hint":"Do not author this field; the server fills it."}},"type":"object","required":["url"],"title":"McpToolConfig","description":"Configuration for a customer MCP server 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 a key in the request body."},"type":{"type":"string","enum":["string","number","boolean","object","array"],"title":"Type","description":"JSON type for the resolved value.","llm_hint":"Allowed values are string, number, boolean, object, and array."},"value_template":{"type":"string","title":"Value Template","description":"Fixed value or template, e.g. {{initial_context.phone_number}}.","llm_hint":"Use {{initial_context.*}} for call-start context and {{gathered_context.*}} for values extracted during the call."},"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 a key in the tool request body.","llm_hint":"Use a stable snake_case name the agent can naturally fill."},"type":{"type":"string","enum":["string","number","boolean","object","array"],"title":"Type","description":"JSON type for the parameter value.","llm_hint":"Allowed values are string, number, boolean, object, and array."},"description":{"type":"string","title":"Description","description":"Description shown to the model for this parameter.","llm_hint":"Write this as an instruction to the agent: what value to provide and when."},"required":{"type":"boolean","title":"Required","description":"Whether this parameter is required when the tool is called.","default":true}},"type":"object","required":["name","type","description"],"title":"ToolParameter","description":"A parameter that the tool accepts from the model at call time."},"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 reusable tool."},"TransferCallConfig":{"properties":{"destination":{"type":"string","title":"Destination","description":"Phone number or SIP endpoint to transfer the call to, e.g. +1234567890 or 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."},"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 seconds to wait for the destination to answer.","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 reusable 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"},"transcript_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Public Url"},"recording_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Public Url"},"public_access_token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Public Access Token"},"cost_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Cost Info"},"usage_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Usage 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"},"recording_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Public Url"},"transcript_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Public Url"},"public_access_token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Public Access Token"},"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 +{"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"}}],"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"}}],"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"}}],"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"}}}}},"x-sdk-method":"create_tool","x-sdk-description":"Create a reusable tool for the authenticated organization."}},"/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/model-configurations/v2/defaults":{"get":{"tags":["main","organizations"],"summary":"Get Model Configuration V2 Defaults","operationId":"get_model_configuration_v2_defaults_api_v1_organizations_model_configurations_v2_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":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/model-configurations/v2":{"get":{"tags":["main","organizations"],"summary":"Get Model Configuration V2","operationId":"get_model_configuration_v2_api_v1_organizations_model_configurations_v2_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/OrganizationAIModelConfigurationResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main","organizations"],"summary":"Save Model Configuration V2","operationId":"save_model_configuration_v2_api_v1_organizations_model_configurations_v2_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/OrganizationAIModelConfigurationV2"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationAIModelConfigurationResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/model-configurations/v2/migration-preview":{"get":{"tags":["main","organizations"],"summary":"Preview Model Configuration V2 Migration","operationId":"preview_model_configuration_v2_migration_api_v1_organizations_model_configurations_v2_migration_preview_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/model-configurations/v2/migrate":{"post":{"tags":["main","organizations"],"summary":"Migrate Model Configuration V2","operationId":"migrate_model_configuration_v2_api_v1_organizations_model_configurations_v2_migrate_post","parameters":[{"name":"force","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Force"}},{"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/OrganizationAIModelConfigurationResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/preferences":{"get":{"tags":["main","organizations"],"summary":"Get Preferences","operationId":"get_preferences_api_v1_organizations_preferences_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/OrganizationPreferences"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main","organizations"],"summary":"Save Preferences","operationId":"save_preferences_api_v1_organizations_preferences_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/OrganizationPreferences"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationPreferences"}}}},"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":"Fallback OPTIONS handler 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}":{"options":{"tags":["main"],"summary":"Options Embed Config","description":"Fallback OPTIONS handler for the embed config endpoint.\n\nBrowser preflights include Access-Control-Request-Method and are handled by\nPublicEmbedCORSMiddleware before global CORS. This keeps non-conformant\nOPTIONS requests on the same validation path.","operationId":"options_embed_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"}}}}}},"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"}}}}}}},"/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":"Fallback OPTIONS handler 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."},"AWSBedrockLLMConfiguration":{"properties":{"provider":{"type":"string","const":"aws_bedrock","title":"Provider","default":"aws_bedrock"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Api Key","description":"Not used for Bedrock \u2014 authentication is via the AWS credentials above. Leave blank."},"model":{"type":"string","title":"Model","description":"Bedrock model ID \u2014 include the region inference-profile prefix (e.g. 'us.').","default":"us.amazon.nova-pro-v1:0","examples":["us.amazon.nova-pro-v1:0","us.amazon.nova-lite-v1:0","us.amazon.nova-micro-v1:0","us.anthropic.claude-sonnet-4-20250514-v1:0","us.anthropic.claude-3-5-sonnet-20241022-v2:0","us.anthropic.claude-haiku-4-5-20251001-v1:0"],"allow_custom_input":true},"aws_access_key":{"type":"string","title":"Aws Access Key","description":"AWS access key ID with bedrock:InvokeModel permission.","default":""},"aws_secret_key":{"type":"string","title":"Aws Secret Key","description":"AWS secret access key paired with the access key ID.","default":""},"aws_region":{"type":"string","title":"Aws Region","description":"AWS region where the Bedrock model is available.","default":"us-east-1"}},"type":"object","title":"AWS Bedrock"},"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"},"AssemblyAISTTConfiguration":{"properties":{"provider":{"type":"string","const":"assemblyai","title":"Provider","default":"assemblyai"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"AssemblyAI realtime STT model.","default":"u3-rt-pro","examples":["u3-rt-pro"]},"language":{"type":"string","title":"Language","description":"ISO 639-1 language code.","default":"en","examples":["en","es","de","fr","pt","it"]}},"type":"object","required":["api_key"],"title":"AssemblyAI"},"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"},"AzureLLMService":{"properties":{"provider":{"type":"string","const":"azure","title":"Provider","default":"azure"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Azure deployment name (not the upstream OpenAI model id).","default":"gpt-4.1-mini","examples":["gpt-4.1-mini"],"allow_custom_input":true},"endpoint":{"type":"string","title":"Endpoint","description":"Azure OpenAI resource endpoint (e.g. https://.openai.azure.com)."}},"type":"object","required":["api_key","endpoint"],"title":"Azure OpenAI"},"AzureOpenAIEmbeddingsConfiguration":{"properties":{"provider":{"type":"string","const":"azure","title":"Provider","default":"azure"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Azure OpenAI embedding deployment name. The deployment must return 1536-dimensional embeddings.","default":"text-embedding-3-small","examples":["text-embedding-3-small","text-embedding-ada-002"],"allow_custom_input":true},"endpoint":{"type":"string","title":"Endpoint","description":"Azure OpenAI resource endpoint (e.g. https://.openai.azure.com)."},"api_version":{"type":"string","title":"Api Version","description":"Azure OpenAI API version for embeddings.","default":"2024-02-15-preview"}},"type":"object","required":["api_key","endpoint"],"title":"Azure OpenAI"},"AzureRealtimeLLMConfiguration":{"properties":{"provider":{"type":"string","const":"azure_realtime","title":"Provider","default":"azure_realtime"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Azure OpenAI realtime deployment name.","default":"gpt-4o-realtime-preview","examples":["gpt-4o-realtime-preview"],"allow_custom_input":true},"endpoint":{"type":"string","title":"Endpoint","description":"Azure OpenAI resource endpoint (e.g. https://.openai.azure.com)."},"voice":{"type":"string","title":"Voice","description":"Voice the model speaks in.","default":"alloy","examples":["alloy","ash","ballad","coral","echo","sage","shimmer","verse"],"allow_custom_input":true},"api_version":{"type":"string","title":"Api Version","description":"Azure OpenAI API version.","default":"2025-04-01-preview","examples":["2025-04-01-preview","2024-10-01-preview","2024-12-17"]}},"type":"object","required":["api_key","endpoint"],"title":"Azure OpenAI Realtime","description":"Azure OpenAI Realtime API \u2014 low-latency speech-to-speech conversations.","provider_docs_url":"https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/realtime-audio-quickstart"},"AzureSpeechSTTConfiguration":{"properties":{"provider":{"type":"string","const":"azure_speech","title":"Provider","default":"azure_speech"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Azure Speech recognition model (use 'latest_long' for continuous recognition).","default":"latest_long","examples":["latest_long","latest_short"]},"region":{"type":"string","title":"Region","description":"Azure region for Speech Services (e.g. 'eastus', 'westeurope').","default":"eastus","examples":["eastus","eastus2","westus","westus2","westus3","centralus","northcentralus","southcentralus","westcentralus","westeurope","northeurope","uksouth","ukwest","francecentral","switzerlandnorth","germanywestcentral","norwayeast","australiaeast","eastasia","southeastasia","japaneast","japanwest","koreacentral","centralindia","southindia","brazilsouth"]},"language":{"type":"string","title":"Language","description":"BCP-47 language code for recognition.","default":"en-US","examples":["en-US","en-GB","en-AU","en-CA","en-IN","es-ES","es-MX","fr-FR","fr-CA","de-DE","it-IT","ja-JP","ko-KR","zh-CN","pt-BR","pt-PT","ru-RU","ar-SA","nl-NL","pl-PL","hi-IN"],"allow_custom_input":true}},"type":"object","required":["api_key"],"title":"Azure Speech Services","description":"Azure Cognitive Services Speech \u2014 TTS and STT via the Azure Speech SDK.","provider_docs_url":"https://learn.microsoft.com/en-us/azure/ai-services/speech-service/"},"AzureSpeechTTSConfiguration":{"properties":{"provider":{"type":"string","const":"azure_speech","title":"Provider","default":"azure_speech"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Azure Speech synthesis engine (neural voices only).","default":"neural","examples":["neural"]},"region":{"type":"string","title":"Region","description":"Azure region for Speech Services (e.g. 'eastus', 'westeurope').","default":"eastus","examples":["eastus","eastus2","westus","westus2","westus3","centralus","northcentralus","southcentralus","westcentralus","westeurope","northeurope","uksouth","ukwest","francecentral","switzerlandnorth","germanywestcentral","norwayeast","australiaeast","eastasia","southeastasia","japaneast","japanwest","koreacentral","centralindia","southindia","brazilsouth"]},"voice":{"type":"string","title":"Voice","description":"Azure Neural voice name (e.g. 'en-US-AriaNeural').","default":"en-US-AriaNeural","examples":["en-US-AriaNeural","en-US-GuyNeural","en-US-JennyNeural","en-US-DavisNeural","en-US-AmberNeural","en-US-AnaNeural","en-US-AshleyNeural","en-US-BrandonNeural","en-US-ChristopherNeural","en-US-ElizabethNeural","en-US-EricNeural","en-US-JacobNeural","en-US-MichelleNeural","en-US-MonicaNeural","en-US-NancyNeural","en-US-RogerNeural","en-US-SaraNeural","en-US-SteffanNeural","en-US-TonyNeural"],"allow_custom_input":true},"language":{"type":"string","title":"Language","description":"BCP-47 language code for synthesis.","default":"en-US","examples":["en-US","en-GB","en-AU","en-CA","en-IN","es-ES","es-MX","fr-FR","fr-CA","de-DE","it-IT","ja-JP","ko-KR","zh-CN","zh-HK","zh-TW","pt-BR","pt-PT","ru-RU","ar-SA","nl-NL","pl-PL","sv-SE","hi-IN"],"allow_custom_input":true},"speed":{"type":"number","maximum":2.0,"minimum":0.5,"title":"Speed","description":"Speech speed multiplier (0.5 to 2.0).","default":1.0}},"type":"object","required":["api_key"],"title":"Azure Speech Services","description":"Azure Cognitive Services Speech \u2014 TTS and STT via the Azure Speech SDK.","provider_docs_url":"https://learn.microsoft.com/en-us/azure/ai-services/speech-service/"},"BYOKAIModelConfiguration":{"properties":{"mode":{"type":"string","enum":["pipeline","realtime"],"title":"Mode"},"pipeline":{"anyOf":[{"$ref":"#/components/schemas/BYOKPipelineAIModelConfiguration"},{"type":"null"}]},"realtime":{"anyOf":[{"$ref":"#/components/schemas/BYOKRealtimeAIModelConfiguration"},{"type":"null"}]}},"type":"object","required":["mode"],"title":"BYOKAIModelConfiguration"},"BYOKPipelineAIModelConfiguration":{"properties":{"llm":{"oneOf":[{"$ref":"#/components/schemas/OpenAILLMService"},{"$ref":"#/components/schemas/GoogleVertexLLMConfiguration"},{"$ref":"#/components/schemas/GroqLLMService"},{"$ref":"#/components/schemas/OpenRouterLLMConfiguration"},{"$ref":"#/components/schemas/GoogleLLMService"},{"$ref":"#/components/schemas/AzureLLMService"},{"$ref":"#/components/schemas/DograhLLMService"},{"$ref":"#/components/schemas/AWSBedrockLLMConfiguration"},{"$ref":"#/components/schemas/SpeachesLLMConfiguration"},{"$ref":"#/components/schemas/MiniMaxLLMConfiguration"},{"$ref":"#/components/schemas/SarvamLLMConfiguration"}],"title":"Llm","discriminator":{"propertyName":"provider","mapping":{"aws_bedrock":"#/components/schemas/AWSBedrockLLMConfiguration","azure":"#/components/schemas/AzureLLMService","dograh":"#/components/schemas/DograhLLMService","google":"#/components/schemas/GoogleLLMService","google_vertex":"#/components/schemas/GoogleVertexLLMConfiguration","groq":"#/components/schemas/GroqLLMService","minimax":"#/components/schemas/MiniMaxLLMConfiguration","openai":"#/components/schemas/OpenAILLMService","openrouter":"#/components/schemas/OpenRouterLLMConfiguration","sarvam":"#/components/schemas/SarvamLLMConfiguration","speaches":"#/components/schemas/SpeachesLLMConfiguration"}}},"tts":{"oneOf":[{"$ref":"#/components/schemas/DeepgramTTSConfiguration"},{"$ref":"#/components/schemas/GoogleTTSConfiguration"},{"$ref":"#/components/schemas/OpenAITTSService"},{"$ref":"#/components/schemas/ElevenlabsTTSConfiguration"},{"$ref":"#/components/schemas/CartesiaTTSConfiguration"},{"$ref":"#/components/schemas/DograhTTSService"},{"$ref":"#/components/schemas/SarvamTTSConfiguration"},{"$ref":"#/components/schemas/CambTTSConfiguration"},{"$ref":"#/components/schemas/RimeTTSConfiguration"},{"$ref":"#/components/schemas/SpeachesTTSConfiguration"},{"$ref":"#/components/schemas/MiniMaxTTSConfiguration"},{"$ref":"#/components/schemas/AzureSpeechTTSConfiguration"}],"title":"Tts","discriminator":{"propertyName":"provider","mapping":{"azure_speech":"#/components/schemas/AzureSpeechTTSConfiguration","camb":"#/components/schemas/CambTTSConfiguration","cartesia":"#/components/schemas/CartesiaTTSConfiguration","deepgram":"#/components/schemas/DeepgramTTSConfiguration","dograh":"#/components/schemas/DograhTTSService","elevenlabs":"#/components/schemas/ElevenlabsTTSConfiguration","google":"#/components/schemas/GoogleTTSConfiguration","minimax":"#/components/schemas/MiniMaxTTSConfiguration","openai":"#/components/schemas/OpenAITTSService","rime":"#/components/schemas/RimeTTSConfiguration","sarvam":"#/components/schemas/SarvamTTSConfiguration","speaches":"#/components/schemas/SpeachesTTSConfiguration"}}},"stt":{"oneOf":[{"$ref":"#/components/schemas/DeepgramSTTConfiguration"},{"$ref":"#/components/schemas/CartesiaSTTConfiguration"},{"$ref":"#/components/schemas/OpenAISTTConfiguration"},{"$ref":"#/components/schemas/GoogleSTTConfiguration"},{"$ref":"#/components/schemas/DograhSTTService"},{"$ref":"#/components/schemas/SpeechmaticsSTTConfiguration"},{"$ref":"#/components/schemas/SarvamSTTConfiguration"},{"$ref":"#/components/schemas/SpeachesSTTConfiguration"},{"$ref":"#/components/schemas/AssemblyAISTTConfiguration"},{"$ref":"#/components/schemas/GladiaSTTConfiguration"},{"$ref":"#/components/schemas/AzureSpeechSTTConfiguration"}],"title":"Stt","discriminator":{"propertyName":"provider","mapping":{"assemblyai":"#/components/schemas/AssemblyAISTTConfiguration","azure_speech":"#/components/schemas/AzureSpeechSTTConfiguration","cartesia":"#/components/schemas/CartesiaSTTConfiguration","deepgram":"#/components/schemas/DeepgramSTTConfiguration","dograh":"#/components/schemas/DograhSTTService","gladia":"#/components/schemas/GladiaSTTConfiguration","google":"#/components/schemas/GoogleSTTConfiguration","openai":"#/components/schemas/OpenAISTTConfiguration","sarvam":"#/components/schemas/SarvamSTTConfiguration","speaches":"#/components/schemas/SpeachesSTTConfiguration","speechmatics":"#/components/schemas/SpeechmaticsSTTConfiguration"}}},"embeddings":{"anyOf":[{"oneOf":[{"$ref":"#/components/schemas/OpenAIEmbeddingsConfiguration"},{"$ref":"#/components/schemas/OpenRouterEmbeddingsConfiguration"},{"$ref":"#/components/schemas/AzureOpenAIEmbeddingsConfiguration"},{"$ref":"#/components/schemas/DograhEmbeddingsConfiguration"}],"discriminator":{"propertyName":"provider","mapping":{"azure":"#/components/schemas/AzureOpenAIEmbeddingsConfiguration","dograh":"#/components/schemas/DograhEmbeddingsConfiguration","openai":"#/components/schemas/OpenAIEmbeddingsConfiguration","openrouter":"#/components/schemas/OpenRouterEmbeddingsConfiguration"}}},{"type":"null"}],"title":"Embeddings"}},"type":"object","required":["llm","tts","stt"],"title":"BYOKPipelineAIModelConfiguration"},"BYOKRealtimeAIModelConfiguration":{"properties":{"realtime":{"oneOf":[{"$ref":"#/components/schemas/OpenAIRealtimeLLMConfiguration"},{"$ref":"#/components/schemas/GrokRealtimeLLMConfiguration"},{"$ref":"#/components/schemas/UltravoxRealtimeLLMConfiguration"},{"$ref":"#/components/schemas/GoogleRealtimeLLMConfiguration"},{"$ref":"#/components/schemas/GoogleVertexRealtimeLLMConfiguration"},{"$ref":"#/components/schemas/AzureRealtimeLLMConfiguration"}],"title":"Realtime","discriminator":{"propertyName":"provider","mapping":{"azure_realtime":"#/components/schemas/AzureRealtimeLLMConfiguration","google_realtime":"#/components/schemas/GoogleRealtimeLLMConfiguration","google_vertex_realtime":"#/components/schemas/GoogleVertexRealtimeLLMConfiguration","grok_realtime":"#/components/schemas/GrokRealtimeLLMConfiguration","openai_realtime":"#/components/schemas/OpenAIRealtimeLLMConfiguration","ultravox_realtime":"#/components/schemas/UltravoxRealtimeLLMConfiguration"}}},"llm":{"oneOf":[{"$ref":"#/components/schemas/OpenAILLMService"},{"$ref":"#/components/schemas/GoogleVertexLLMConfiguration"},{"$ref":"#/components/schemas/GroqLLMService"},{"$ref":"#/components/schemas/OpenRouterLLMConfiguration"},{"$ref":"#/components/schemas/GoogleLLMService"},{"$ref":"#/components/schemas/AzureLLMService"},{"$ref":"#/components/schemas/DograhLLMService"},{"$ref":"#/components/schemas/AWSBedrockLLMConfiguration"},{"$ref":"#/components/schemas/SpeachesLLMConfiguration"},{"$ref":"#/components/schemas/MiniMaxLLMConfiguration"},{"$ref":"#/components/schemas/SarvamLLMConfiguration"}],"title":"Llm","discriminator":{"propertyName":"provider","mapping":{"aws_bedrock":"#/components/schemas/AWSBedrockLLMConfiguration","azure":"#/components/schemas/AzureLLMService","dograh":"#/components/schemas/DograhLLMService","google":"#/components/schemas/GoogleLLMService","google_vertex":"#/components/schemas/GoogleVertexLLMConfiguration","groq":"#/components/schemas/GroqLLMService","minimax":"#/components/schemas/MiniMaxLLMConfiguration","openai":"#/components/schemas/OpenAILLMService","openrouter":"#/components/schemas/OpenRouterLLMConfiguration","sarvam":"#/components/schemas/SarvamLLMConfiguration","speaches":"#/components/schemas/SpeachesLLMConfiguration"}}},"embeddings":{"anyOf":[{"oneOf":[{"$ref":"#/components/schemas/OpenAIEmbeddingsConfiguration"},{"$ref":"#/components/schemas/OpenRouterEmbeddingsConfiguration"},{"$ref":"#/components/schemas/AzureOpenAIEmbeddingsConfiguration"},{"$ref":"#/components/schemas/DograhEmbeddingsConfiguration"}],"discriminator":{"propertyName":"provider","mapping":{"azure":"#/components/schemas/AzureOpenAIEmbeddingsConfiguration","dograh":"#/components/schemas/DograhEmbeddingsConfiguration","openai":"#/components/schemas/OpenAIEmbeddingsConfiguration","openrouter":"#/components/schemas/OpenRouterEmbeddingsConfiguration"}}},{"type":"null"}],"title":"Embeddings"}},"type":"object","required":["realtime","llm"],"title":"BYOKRealtimeAIModelConfiguration"},"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."},"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"},"CambTTSConfiguration":{"properties":{"provider":{"type":"string","const":"camb","title":"Provider","default":"camb"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Camb.ai TTS model.","default":"mars-flash","examples":["mars-flash","mars-pro","mars-instruct"]},"voice":{"type":"string","title":"Voice","description":"Camb.ai voice ID.","default":"147320"},"language":{"type":"string","title":"Language","description":"BCP-47 language code.","default":"en-us"}},"type":"object","required":["api_key"],"title":"Camb.ai"},"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"},"CartesiaSTTConfiguration":{"properties":{"provider":{"type":"string","const":"cartesia","title":"Provider","default":"cartesia"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Cartesia STT model.","default":"ink-whisper","examples":["ink-whisper"]}},"type":"object","required":["api_key"],"title":"Cartesia"},"CartesiaTTSConfiguration":{"properties":{"provider":{"type":"string","const":"cartesia","title":"Provider","default":"cartesia"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Cartesia TTS model.","default":"sonic-3","examples":["sonic-3"]},"voice":{"type":"string","title":"Voice","description":"Cartesia voice UUID from your Cartesia dashboard.","default":"3faa81ae-d3d8-4ab1-9e44-e50e46d33c30"},"speed":{"type":"number","maximum":1.5,"minimum":0.6,"title":"Speed","description":"Speed of the voice.","default":1.0},"volume":{"type":"number","maximum":2.0,"minimum":0.5,"title":"Volume","description":"Volume multiplier for generated speech.","default":1.0}},"type":"object","required":["api_key"],"title":"Cartesia"},"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":"Display name for the tool.","llm_hint":"Use a concise action-oriented name; this influences the function name shown to the agent."},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Description shown to the agent when deciding whether to call it.","llm_hint":"State exactly when the agent should call the tool and what result it gets."},"category":{"type":"string","enum":["http_api","end_call","transfer_call","calculator","native","integration","mcp"],"title":"Category","description":"Tool category. Must match definition.type.","default":"http_api"},"icon":{"anyOf":[{"type":"string","maxLength":50},{"type":"null"}],"title":"Icon","description":"Lucide icon identifier.","default":"globe"},"icon_color":{"anyOf":[{"type":"string","maxLength":7},{"type":"null"}],"title":"Icon Color","description":"Hex color for the tool icon.","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","description":"Typed tool 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 reusable 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"},"DeepgramSTTConfiguration":{"properties":{"provider":{"type":"string","const":"deepgram","title":"Provider","default":"deepgram"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Deepgram STT model.","default":"nova-3-general","examples":["nova-3-general","flux-general-en","flux-general-multi"]},"language":{"type":"string","title":"Language","description":"Language code; 'multi' enables auto-detect (Nova-3 only).","default":"multi","examples":["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"],"model_options":{"flux-general-en":["en"],"nova-3-general":["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"]}}},"type":"object","required":["api_key"],"title":"Deepgram"},"DeepgramTTSConfiguration":{"properties":{"provider":{"type":"string","const":"deepgram","title":"Provider","default":"deepgram"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"voice":{"type":"string","title":"Voice","description":"Deepgram voice ID (model is inferred from the 'aura-N' prefix).","default":"aura-2-helena-en"}},"type":"object","required":["api_key"],"title":"Deepgram"},"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."},"DograhEmbeddingsConfiguration":{"properties":{"provider":{"type":"string","const":"dograh","title":"Provider","default":"dograh"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Dograh-managed embedding model.","default":"default","examples":["default"]}},"type":"object","required":["api_key"],"title":"Dograh"},"DograhLLMService":{"properties":{"provider":{"type":"string","const":"dograh","title":"Provider","default":"dograh"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Dograh-hosted model tier.","default":"default","examples":["default","accurate","fast","lite","zen"],"allow_custom_input":true}},"type":"object","required":["api_key"],"title":"Dograh"},"DograhManagedAIModelConfiguration":{"properties":{"api_key":{"type":"string","title":"Api Key"},"voice":{"type":"string","title":"Voice","default":"default"},"speed":{"type":"number","title":"Speed","default":1.0},"language":{"type":"string","title":"Language","default":"multi"}},"type":"object","required":["api_key"],"title":"DograhManagedAIModelConfiguration"},"DograhSTTService":{"properties":{"provider":{"type":"string","const":"dograh","title":"Provider","default":"dograh"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Dograh STT tier.","default":"default","examples":["default"]},"language":{"type":"string","title":"Language","description":"Language code; use 'multi' for auto-detect.","default":"multi","examples":["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"]}},"type":"object","required":["api_key"],"title":"Dograh"},"DograhTTSService":{"properties":{"provider":{"type":"string","const":"dograh","title":"Provider","default":"dograh"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Dograh TTS tier.","default":"default","examples":["default"]},"voice":{"type":"string","title":"Voice","description":"Voice preset.","default":"default"},"speed":{"type":"number","maximum":2.0,"minimum":0.5,"title":"Speed","description":"Speed of the voice.","default":1.0}},"type":"object","required":["api_key"],"title":"Dograh"},"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"},"ElevenlabsTTSConfiguration":{"properties":{"provider":{"type":"string","const":"elevenlabs","title":"Provider","default":"elevenlabs"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"voice":{"type":"string","title":"Voice","description":"ElevenLabs voice ID from your Voice Library.","default":"21m00Tcm4TlvDq8ikWAM"},"speed":{"type":"number","maximum":2.0,"minimum":0.1,"title":"Speed","description":"Speed of the voice.","default":1.0},"model":{"type":"string","title":"Model","description":"ElevenLabs TTS model.","default":"eleven_flash_v2_5","examples":["eleven_flash_v2_5"]},"base_url":{"type":"string","title":"Base Url","description":"ElevenLabs API base URL. Override to use a Data Residency endpoint (e.g. https://api.eu.residency.elevenlabs.io) for GDPR / HIPAA / regional compliance.","default":"https://api.elevenlabs.io"}},"type":"object","required":["api_key"],"title":"ElevenLabs"},"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, the model 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 model 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"},"GladiaSTTConfiguration":{"properties":{"provider":{"type":"string","const":"gladia","title":"Provider","default":"gladia"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Gladia STT model.","default":"solaria-1","examples":["solaria-1"]},"language":{"type":"string","title":"Language","description":"ISO 639-1 language code.","default":"en","examples":["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"]}},"type":"object","required":["api_key"],"title":"Gladia"},"GoogleLLMService":{"properties":{"provider":{"type":"string","const":"google","title":"Provider","default":"google"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Gemini model on Google AI Studio (not Vertex).","default":"gemini-2.0-flash","examples":["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"],"allow_custom_input":true}},"type":"object","required":["api_key"],"title":"Google"},"GoogleRealtimeLLMConfiguration":{"properties":{"provider":{"type":"string","const":"google_realtime","title":"Provider","default":"google_realtime"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Gemini Live model on Google AI Studio (not Vertex).","default":"gemini-3.1-flash-live-preview","examples":["gemini-3.1-flash-live-preview"],"allow_custom_input":true},"voice":{"type":"string","title":"Voice","description":"Voice the model speaks in.","default":"Puck","examples":["Puck","Charon","Kore","Fenrir","Aoede"],"allow_custom_input":true},"language":{"type":"string","title":"Language","description":"ISO 639-1 language code.","default":"en","examples":["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"],"allow_custom_input":true}},"type":"object","required":["api_key"],"title":"Google Realtime"},"GoogleSTTConfiguration":{"properties":{"provider":{"type":"string","const":"google","title":"Provider","default":"google"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Api Key","description":"Not used for Google Cloud STT. Leave blank."},"model":{"type":"string","title":"Model","description":"Google Cloud Speech-to-Text V2 recognition model.","default":"latest_long","examples":["latest_long","latest_short","chirp_3"],"allow_custom_input":true},"language":{"type":"string","title":"Language","description":"Primary BCP-47 language code for recognition.","default":"en-US","examples":["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"],"allow_custom_input":true,"docs_url":"https://docs.cloud.google.com/speech-to-text/docs/speech-to-text-supported-languages"},"location":{"type":"string","title":"Location","description":"Google Cloud Speech-to-Text region (for example 'global' or 'us-central1').","default":"global"},"credentials":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credentials","description":"Paste the entire Google Cloud service-account JSON. If omitted, the server falls back to Application Default Credentials (ADC).","multiline":true}},"type":"object","title":"Google Cloud"},"GoogleTTSConfiguration":{"properties":{"provider":{"type":"string","const":"google","title":"Provider","default":"google"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Api Key","description":"Not used for Google Cloud TTS. Leave blank."},"model":{"type":"string","title":"Model","description":"Google Cloud low-latency TTS engine. Dograh maps this to Pipecat's streaming Google TTS service for Chirp 3 HD and Journey voices.","default":"chirp_3_hd","examples":["chirp_3_hd"],"allow_custom_input":true},"voice":{"type":"string","title":"Voice","description":"Google Cloud voice name. Use a Chirp 3 HD or Journey voice for streaming TTS.","default":"en-US-Chirp3-HD-Charon","examples":["en-US-Chirp3-HD-Charon"],"allow_custom_input":true},"language":{"type":"string","title":"Language","description":"BCP-47 language code for synthesis.","default":"en-US","examples":["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"],"allow_custom_input":true},"speed":{"type":"number","maximum":2.0,"minimum":0.25,"title":"Speed","description":"Speech speed multiplier for Google streaming TTS.","default":1.0},"location":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Location","description":"Optional Google Cloud regional Text-to-Speech endpoint (for example 'us-central1'). Leave blank to use the default endpoint."},"credentials":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credentials","description":"Paste the entire Google Cloud service-account JSON. If omitted, the server falls back to Application Default Credentials (ADC).","multiline":true}},"type":"object","title":"Google Cloud"},"GoogleVertexLLMConfiguration":{"properties":{"provider":{"type":"string","const":"google_vertex","title":"Provider","default":"google_vertex"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Api Key","description":"Not used for Vertex AI \u2014 authentication is via the service account in `credentials` (or ADC). Leave blank."},"model":{"type":"string","title":"Model","description":"Gemini model on Vertex AI.","default":"gemini-2.5-flash","examples":["gemini-2.5-flash","gemini-2.5-flash-lite","gemini-3.1-flash-lite","gemini-3.5-flash"],"allow_custom_input":true},"project_id":{"type":"string","title":"Project Id","description":"Google Cloud project ID for Vertex AI."},"location":{"type":"string","title":"Location","description":"GCP region for the Vertex AI endpoint (e.g. 'global').","default":"global"},"credentials":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credentials","description":"Paste the entire service-account JSON file contents. If omitted, falls back to Application Default Credentials (ADC).","multiline":true}},"type":"object","required":["project_id"],"title":"Google Vertex"},"GoogleVertexRealtimeLLMConfiguration":{"properties":{"provider":{"type":"string","const":"google_vertex_realtime","title":"Provider","default":"google_vertex_realtime"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Api Key","description":"Not used for Vertex AI \u2014 authentication is via the service account in `credentials` (or ADC). Leave blank."},"model":{"type":"string","title":"Model","description":"Vertex AI publisher/model identifier.","default":"google/gemini-live-2.5-flash-native-audio","examples":["google/gemini-live-2.5-flash-native-audio"],"allow_custom_input":true},"voice":{"type":"string","title":"Voice","description":"Voice the model speaks in.","default":"Charon","examples":["Puck","Charon","Kore","Fenrir","Aoede"],"allow_custom_input":true},"language":{"type":"string","title":"Language","description":"BCP-47 language code (e.g. 'en-US').","default":"en","examples":["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"],"allow_custom_input":true},"project_id":{"type":"string","title":"Project Id","description":"Google Cloud project ID for Vertex AI."},"location":{"type":"string","title":"Location","description":"GCP region for the Vertex AI endpoint (e.g. 'global').","default":"global"},"credentials":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credentials","description":"Paste the entire service-account JSON file contents. If omitted, falls back to Application Default Credentials (ADC).","multiline":true}},"type":"object","required":["project_id"],"title":"Google Vertex Realtime"},"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."},"GrokRealtimeLLMConfiguration":{"properties":{"provider":{"type":"string","const":"grok_realtime","title":"Provider","default":"grok_realtime"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Grok realtime voice-agent model.","default":"grok-voice-think-fast-1.0","examples":["grok-voice-think-fast-1.0"],"allow_custom_input":true},"voice":{"type":"string","title":"Voice","description":"Voice the model speaks in.","default":"Ara","examples":["Ara","Rex","Sal","Eve","Leo"],"allow_custom_input":true}},"type":"object","required":["api_key"],"title":"Grok Realtime"},"GroqLLMService":{"properties":{"provider":{"type":"string","const":"groq","title":"Provider","default":"groq"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Groq-hosted model identifier.","default":"llama-3.3-70b-versatile","examples":["llama-3.3-70b-versatile","deepseek-r1-distill-llama-70b","qwen-qwq-32b","meta-llama/llama-4-scout-17b-16e-instruct","meta-llama/llama-4-maverick-17b-128e-instruct","gemma2-9b-it","llama-3.1-8b-instant","openai/gpt-oss-120b"],"allow_custom_input":true}},"type":"object","required":["api_key"],"title":"Groq"},"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","enum":["GET","POST","PUT","PATCH","DELETE"],"title":"Method","description":"HTTP method to use for the request.","llm_hint":"Use one of GET, POST, PUT, PATCH, DELETE."},"url":{"type":"string","title":"Url","description":"Target HTTP or HTTPS URL.","llm_hint":"Use the final endpoint URL. Authentication belongs in credential_uuid, not embedded in the URL."},"headers":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Headers","description":"Static headers to include with every request.","llm_hint":"Do not place secrets here. Store secrets in the UI credential manager and reference them with credential_uuid."},"credential_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credential Uuid","description":"Reference to an external credential for request authentication.","llm_hint":"Use a credential_uuid returned by list_credentials. The MCP flow does not create credential secrets."},"parameters":{"anyOf":[{"items":{"$ref":"#/components/schemas/ToolParameter"},"type":"array"},{"type":"null"}],"title":"Parameters","description":"Parameters the model must provide when calling this tool."},"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","minimum":1.0},{"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."},"customMessageRecordingId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessagerecordingid","description":"Recording ID for an 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 use http:// or https://.","llm_hint":"Use the server's streamable HTTP MCP endpoint."},"credential_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credential Uuid","description":"Reference to an external credential for MCP server auth.","llm_hint":"Use a credential_uuid returned by list_credentials. Credentials are created by the user in the UI."},"tools_filter":{"items":{"type":"string"},"type":"array","title":"Tools Filter","description":"Allowlist of MCP tool names to expose. Empty exposes all tools.","llm_hint":"Use exact MCP tool names from the remote server catalog when you need to restrict the exposed tools."},"timeout_secs":{"type":"integer","minimum":0.0,"title":"Timeout Secs","description":"Connection timeout in seconds.","default":30},"sse_read_timeout_secs":{"type":"integer","minimum":0.0,"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.","llm_hint":"Do not author this field; the server fills it."}},"type":"object","required":["url"],"title":"McpToolConfig","description":"Configuration for a customer MCP server 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."},"MiniMaxLLMConfiguration":{"properties":{"provider":{"type":"string","const":"minimax","title":"Provider","default":"minimax"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"MiniMax chat model.","default":"MiniMax-M2.7","examples":["MiniMax-M2.7","MiniMax-M2.7-highspeed"],"allow_custom_input":true},"base_url":{"type":"string","title":"Base Url","description":"MiniMax OpenAI-compatible API endpoint.","default":"https://api.minimax.io/v1"},"temperature":{"type":"number","maximum":2.0,"exclusiveMinimum":0.0,"title":"Temperature","description":"Sampling temperature. MiniMax requires > 0.","default":1.0}},"type":"object","required":["api_key"],"title":"MiniMaxLLMConfiguration"},"MiniMaxTTSConfiguration":{"properties":{"provider":{"type":"string","const":"minimax","title":"Provider","default":"minimax"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"MiniMax TTS model.","default":"speech-2.8-hd","examples":["speech-2.8-hd","speech-2.8-turbo"]},"voice":{"type":"string","title":"Voice","description":"MiniMax voice ID.","default":"English_Graceful_Lady","examples":["English_Graceful_Lady","English_Insightful_Speaker","English_radiant_girl","English_Persuasive_Man","English_Lucky_Robot","English_expressive_narrator"],"allow_custom_input":true},"base_url":{"type":"string","title":"Base Url","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).","default":"https://api.minimax.io/v1/t2a_v2"},"speed":{"type":"number","maximum":2.0,"minimum":0.5,"title":"Speed","description":"Speech speed (0.5 to 2.0).","default":1.0},"group_id":{"type":"string","title":"Group Id","description":"MiniMax Group ID (found in your MiniMax dashboard under Account \u2192 Group)."}},"type":"object","required":["api_key","group_id"],"title":"MiniMaxTTSConfiguration"},"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"},"OpenAIEmbeddingsConfiguration":{"properties":{"provider":{"type":"string","const":"openai","title":"Provider","default":"openai"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"OpenAI embedding model.","default":"text-embedding-3-small","examples":["text-embedding-3-small"]}},"type":"object","required":["api_key"],"title":"OpenAI"},"OpenAILLMService":{"properties":{"provider":{"type":"string","const":"openai","title":"Provider","default":"openai"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"OpenAI chat model to use.","default":"gpt-4.1","examples":["gpt-4.1","gpt-4.1-mini","gpt-4.1-nano","gpt-5","gpt-5-mini","gpt-5-nano","gpt-3.5-turbo"],"allow_custom_input":true},"base_url":{"type":"string","title":"Base Url","description":"Override only if using an OpenAI-compatible API (e.g. local LLM, proxy).","default":"https://api.openai.com/v1"}},"type":"object","required":["api_key"],"title":"OpenAI"},"OpenAIRealtimeLLMConfiguration":{"properties":{"provider":{"type":"string","const":"openai_realtime","title":"Provider","default":"openai_realtime"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"OpenAI realtime (speech-to-speech) model.","default":"gpt-realtime-2","examples":["gpt-realtime-2"],"allow_custom_input":true},"voice":{"type":"string","title":"Voice","description":"Voice the model speaks in.","default":"alloy","examples":["alloy","ash","ballad","coral","echo","sage","shimmer","verse"],"allow_custom_input":true}},"type":"object","required":["api_key"],"title":"OpenAI Realtime"},"OpenAISTTConfiguration":{"properties":{"provider":{"type":"string","const":"openai","title":"Provider","default":"openai"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"OpenAI transcription model.","default":"gpt-4o-transcribe","examples":["gpt-4o-transcribe"]},"base_url":{"type":"string","title":"Base Url","description":"Override only if using an OpenAI-compatible API (e.g. local STT, proxy).","default":"https://api.openai.com/v1"}},"type":"object","required":["api_key"],"title":"OpenAI"},"OpenAITTSService":{"properties":{"provider":{"type":"string","const":"openai","title":"Provider","default":"openai"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"OpenAI TTS model.","default":"gpt-4o-mini-tts","examples":["gpt-4o-mini-tts"]},"voice":{"type":"string","title":"Voice","description":"OpenAI TTS voice name.","default":"alloy"},"base_url":{"type":"string","title":"Base Url","description":"Override only if using an OpenAI-compatible API (e.g. local TTS, proxy).","default":"https://api.openai.com/v1"}},"type":"object","required":["api_key"],"title":"OpenAI"},"OpenRouterEmbeddingsConfiguration":{"properties":{"provider":{"type":"string","const":"openrouter","title":"Provider","default":"openrouter"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"OpenRouter-hosted embedding model slug.","default":"openai/text-embedding-3-small","examples":["openai/text-embedding-3-small"]},"base_url":{"type":"string","title":"Base Url","description":"Override only if proxying OpenRouter through your own gateway.","default":"https://openrouter.ai/api/v1"}},"type":"object","required":["api_key"],"title":"Open Router"},"OpenRouterLLMConfiguration":{"properties":{"provider":{"type":"string","const":"openrouter","title":"Provider","default":"openrouter"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"OpenRouter model slug in 'vendor/model' form.","default":"openai/gpt-4.1","examples":["openai/gpt-4.1","openai/gpt-4.1-mini","anthropic/claude-sonnet-4","google/gemini-2.5-flash","google/gemini-2.0-flash","meta-llama/llama-3.3-70b-instruct","deepseek/deepseek-chat-v3-0324"],"allow_custom_input":true},"base_url":{"type":"string","title":"Base Url","description":"Override only if proxying OpenRouter through your own gateway.","default":"https://openrouter.ai/api/v1"}},"type":"object","required":["api_key"],"title":"Open Router"},"OrganizationAIModelConfigurationResponse":{"properties":{"configuration":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Configuration"},"effective_configuration":{"additionalProperties":true,"type":"object","title":"Effective Configuration"},"source":{"type":"string","enum":["organization_v2","legacy_user_v1","empty"],"title":"Source"}},"type":"object","required":["configuration","effective_configuration","source"],"title":"OrganizationAIModelConfigurationResponse"},"OrganizationAIModelConfigurationV2":{"properties":{"version":{"type":"integer","const":2,"title":"Version","default":2},"mode":{"type":"string","enum":["dograh","byok"],"title":"Mode"},"dograh":{"anyOf":[{"$ref":"#/components/schemas/DograhManagedAIModelConfiguration"},{"type":"null"}]},"byok":{"anyOf":[{"$ref":"#/components/schemas/BYOKAIModelConfiguration"},{"type":"null"}]}},"type":"object","required":["mode"],"title":"OrganizationAIModelConfigurationV2"},"OrganizationPreferences":{"properties":{"test_phone_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Test Phone Number"},"timezone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Timezone"}},"type":"object","title":"OrganizationPreferences"},"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 a key in the request body."},"type":{"type":"string","enum":["string","number","boolean","object","array"],"title":"Type","description":"JSON type for the resolved value.","llm_hint":"Allowed values are string, number, boolean, object, and array."},"value_template":{"type":"string","title":"Value Template","description":"Fixed value or template, e.g. {{initial_context.phone_number}}.","llm_hint":"Use {{initial_context.*}} for call-start context and {{gathered_context.*}} for values extracted during the call."},"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"},"RimeTTSConfiguration":{"properties":{"provider":{"type":"string","const":"rime","title":"Provider","default":"rime"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Rime TTS model.","default":"arcana","examples":["arcana","mistv3","mistv2","mist"],"allow_custom_input":true},"voice":{"type":"string","title":"Voice","description":"Rime voice ID.","default":"celeste"},"speed":{"type":"number","maximum":2.0,"minimum":0.5,"title":"Speed","description":"Speech speed multiplier.","default":1.0},"language":{"type":"string","title":"Language","description":"ISO 639-1 language code.","default":"en","examples":["en","de","fr","es","hi"],"allow_custom_input":true}},"type":"object","required":["api_key"],"title":"Rime"},"S3SignedUrlResponse":{"properties":{"url":{"type":"string","title":"Url"},"expires_in":{"type":"integer","title":"Expires In"}},"type":"object","required":["url","expires_in"],"title":"S3SignedUrlResponse"},"SarvamLLMConfiguration":{"properties":{"provider":{"type":"string","const":"sarvam","title":"Provider","default":"sarvam"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Sarvam chat model. Use sarvam-30b for low-latency voice agents; sarvam-105b for complex multi-step reasoning.","default":"sarvam-30b","examples":["sarvam-30b","sarvam-105b"],"allow_custom_input":true},"temperature":{"type":"number","maximum":2.0,"minimum":0.0,"title":"Temperature","description":"Sampling temperature. Sarvam recommends 0.5 for balanced conversational responses.","default":0.5}},"type":"object","required":["api_key"],"title":"Sarvam"},"SarvamSTTConfiguration":{"properties":{"provider":{"type":"string","const":"sarvam","title":"Provider","default":"sarvam"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Sarvam STT model. saarika:v2.5 transcribes in the spoken language; saaras:v3 is the recommended model with flexible output modes.","default":"saarika:v2.5","examples":["saarika:v2.5","saaras:v3"]},"language":{"type":"string","title":"Language","description":"BCP-47 language code. Use unknown for automatic language detection.","default":"unknown","examples":["unknown","hi-IN","bn-IN","gu-IN","kn-IN","ml-IN","mr-IN","od-IN","pa-IN","ta-IN","te-IN","en-IN"],"model_options":{"saaras:v3":["unknown","hi-IN","bn-IN","gu-IN","kn-IN","ml-IN","mr-IN","od-IN","pa-IN","ta-IN","te-IN","en-IN","as-IN","ur-IN","ne-IN","kok-IN","ks-IN","sd-IN","sa-IN","sat-IN","mni-IN","brx-IN","mai-IN","doi-IN"],"saarika:v2.5":["unknown","hi-IN","bn-IN","gu-IN","kn-IN","ml-IN","mr-IN","od-IN","pa-IN","ta-IN","te-IN","en-IN"]}}},"type":"object","required":["api_key"],"title":"Sarvam"},"SarvamTTSConfiguration":{"properties":{"provider":{"type":"string","const":"sarvam","title":"Provider","default":"sarvam"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Sarvam TTS model (voice list depends on this).","default":"bulbul:v2","examples":["bulbul:v2","bulbul:v3"]},"voice":{"type":"string","title":"Voice","description":"Sarvam voice name; must match the selected model's voice list.","default":"anushka","examples":["anushka","manisha","vidya","arya","abhilash","karun","hitesh"],"model_options":{"bulbul:v2":["anushka","manisha","vidya","arya","abhilash","karun","hitesh"],"bulbul:v3":["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"]}},"language":{"type":"string","title":"Language","description":"BCP-47 Indian-language code (e.g. hi-IN, en-IN).","default":"hi-IN","examples":["bn-IN","en-IN","gu-IN","hi-IN","kn-IN","ml-IN","mr-IN","od-IN","pa-IN","ta-IN","te-IN","as-IN"]}},"type":"object","required":["api_key"],"title":"Sarvam"},"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"},"SpeachesLLMConfiguration":{"properties":{"provider":{"type":"string","const":"speaches","title":"Provider","default":"speaches"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Api Key","description":"Usually not required for self-hosted endpoints. Leave blank unless your server enforces one."},"model":{"type":"string","title":"Model","description":"Model name as exposed by your OpenAI-compatible server.","default":"llama3","examples":["llama3","mistral","phi3","qwen2","gemma2","deepseek-r1"],"allow_custom_input":true},"base_url":{"type":"string","title":"Base Url","description":"OpenAI-compatible endpoint (Ollama, vLLM, etc.).","default":"http://localhost:11434/v1"}},"type":"object","title":"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"},"SpeachesSTTConfiguration":{"properties":{"provider":{"type":"string","const":"speaches","title":"Provider","default":"speaches"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Api Key","description":"Usually not required for self-hosted STT. Leave blank unless enforced."},"model":{"type":"string","title":"Model","description":"Whisper model identifier as served by your STT endpoint.","default":"Systran/faster-distil-whisper-small.en","examples":["Systran/faster-distil-whisper-small.en","Systran/faster-whisper-large-v3"],"allow_custom_input":true},"language":{"type":"string","title":"Language","description":"ISO 639-1 language code.","default":"en","examples":["en","ar","nl","fr","de","hi","it","pt","es"],"allow_custom_input":true},"base_url":{"type":"string","title":"Base Url","description":"OpenAI-compatible STT endpoint (Speaches, etc.).","default":"http://localhost:8000/v1"}},"type":"object","title":"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"},"SpeachesTTSConfiguration":{"properties":{"provider":{"type":"string","const":"speaches","title":"Provider","default":"speaches"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Api Key","description":"Usually not required for self-hosted TTS. Leave blank unless enforced."},"model":{"type":"string","title":"Model","description":"Model name as served by your TTS endpoint (e.g. Kokoro-FastAPI).","default":"kokoro","examples":["hexgrad/Kokoro-82M"],"allow_custom_input":true},"voice":{"type":"string","title":"Voice","description":"Voice ID for the TTS engine.","default":"af_heart","allow_custom_input":true},"base_url":{"type":"string","title":"Base Url","description":"OpenAI-compatible TTS endpoint (Kokoro-FastAPI, etc.).","default":"http://localhost:8000/v1"},"speed":{"type":"number","maximum":4.0,"minimum":0.25,"title":"Speed","description":"Speech speed (0.25 to 4.0).","default":1.0}},"type":"object","title":"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"},"SpeechmaticsSTTConfiguration":{"properties":{"provider":{"type":"string","const":"speechmatics","title":"Provider","default":"speechmatics"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Speechmatics operating point: 'standard' or 'enhanced'.","default":"enhanced"},"language":{"type":"string","title":"Language","description":"ISO 639-1 language code.","default":"en","examples":["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"]}},"type":"object","required":["api_key"],"title":"Speechmatics"},"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 a key in the tool request body.","llm_hint":"Use a stable snake_case name the agent can naturally fill."},"type":{"type":"string","enum":["string","number","boolean","object","array"],"title":"Type","description":"JSON type for the parameter value.","llm_hint":"Allowed values are string, number, boolean, object, and array."},"description":{"type":"string","title":"Description","description":"Description shown to the model for this parameter.","llm_hint":"Write this as an instruction to the agent: what value to provide and when."},"required":{"type":"boolean","title":"Required","description":"Whether this parameter is required when the tool is called.","default":true}},"type":"object","required":["name","type","description"],"title":"ToolParameter","description":"A parameter that the tool accepts from the model at call time."},"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 reusable tool."},"TransferCallConfig":{"properties":{"destination":{"type":"string","title":"Destination","description":"Phone number or SIP endpoint to transfer the call to, e.g. +1234567890 or 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."},"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 seconds to wait for the destination to answer.","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."},"UltravoxRealtimeLLMConfiguration":{"properties":{"provider":{"type":"string","const":"ultravox_realtime","title":"Provider","default":"ultravox_realtime"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Ultravox realtime voice-agent model.","default":"ultravox-v0.7","examples":["ultravox-v0.7","fixie-ai/ultravox"],"allow_custom_input":true},"voice":{"type":"string","title":"Voice","description":"Ultravox voice name or voice ID.","default":"Mark"}},"type":"object","required":["api_key"],"title":"Ultravox Realtime"},"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 reusable 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"},"transcript_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Public Url"},"recording_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Public Url"},"public_access_token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Public Access Token"},"cost_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Cost Info"},"usage_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Usage 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"},"recording_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Public Url"},"transcript_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Public Url"},"public_access_token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Public Access Token"},"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/sdk/python/src/dograh_sdk/_generated_models.py b/sdk/python/src/dograh_sdk/_generated_models.py index ec27e029..48a5a546 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-uraOZf.json -# timestamp: 2026-06-03T11:53:30+00:00 +# filename: dograh-openapi-XXXXXX.json.mKgFDhNhca +# timestamp: 2026-06-09T10:10:10+00:00 from __future__ import annotations diff --git a/ui/src/app/model-configurations/page.tsx b/ui/src/app/model-configurations/page.tsx index fbe694a6..70d860d5 100644 --- a/ui/src/app/model-configurations/page.tsx +++ b/ui/src/app/model-configurations/page.tsx @@ -1,13 +1,25 @@ -import ServiceConfiguration from "@/components/ServiceConfiguration"; +import ModelConfigurationV2 from "@/components/ModelConfigurationV2"; import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation"; -export default function ServiceConfigurationPage() { +interface ServiceConfigurationPageProps { + searchParams?: Promise<{ + action?: string | string[]; + }>; +} + +export default async function ServiceConfigurationPage({ searchParams }: ServiceConfigurationPageProps) { + const params = searchParams ? await searchParams : {}; + const action = Array.isArray(params.action) ? params.action[0] : params.action; + return (

diff --git a/ui/src/app/reports/page.tsx b/ui/src/app/reports/page.tsx index 37140924..770e1724 100644 --- a/ui/src/app/reports/page.tsx +++ b/ui/src/app/reports/page.tsx @@ -7,6 +7,7 @@ import { useEffect,useState } from 'react'; import { getDailyReportApiV1OrganizationsReportsDailyGet, getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet, + getPreferencesApiV1OrganizationsPreferencesGet, getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet } from '@/client/sdk.gen'; import type { WorkflowRunDetail } from '@/client/types.gen'; @@ -16,7 +17,6 @@ import { Card } from '@/components/ui/card'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Skeleton } from '@/components/ui/skeleton'; -import { useUserConfig } from '@/context/UserConfigContext'; import { useAuth } from '@/lib/auth'; import { DispositionChart } from './components/DispositionChart'; @@ -57,11 +57,9 @@ export default function ReportsPage() { const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const { userConfig } = useUserConfig(); + const [timezone, setTimezone] = useState('America/New_York'); const auth = useAuth(); - const timezone = userConfig?.timezone || 'America/New_York'; - // Fetch workflows on mount useEffect(() => { const fetchWorkflows = async () => { @@ -80,6 +78,22 @@ export default function ReportsPage() { fetchWorkflows(); }, [auth.isAuthenticated]); + useEffect(() => { + const fetchPreferences = async () => { + if (!auth.isAuthenticated) return; + + try { + const response = await getPreferencesApiV1OrganizationsPreferencesGet(); + if (response.data?.timezone) { + setTimezone(response.data.timezone); + } + } catch (err) { + console.error('Failed to fetch organization preferences:', err); + } + }; + fetchPreferences(); + }, [auth.isAuthenticated]); + // Fetch report data when date or workflow changes useEffect(() => { const fetchReport = async () => { diff --git a/ui/src/app/settings/page.tsx b/ui/src/app/settings/page.tsx index 84345d77..a28ba44f 100644 --- a/ui/src/app/settings/page.tsx +++ b/ui/src/app/settings/page.tsx @@ -3,6 +3,7 @@ import { ExternalLink } from "lucide-react"; import { MCPSection } from "@/components/MCPSection"; +import { OrganizationPreferencesSection } from "@/components/OrganizationPreferencesSection"; import { TelemetrySection } from "@/components/TelemetrySection"; import { Card, @@ -23,6 +24,19 @@ export default function SettingsPage() {

+ + + Preferences + + Set organization-wide defaults such as the test phone number and + timezone. + + + + + + + MCP Server diff --git a/ui/src/app/usage/page.tsx b/ui/src/app/usage/page.tsx index e66355e5..181d1791 100644 --- a/ui/src/app/usage/page.tsx +++ b/ui/src/app/usage/page.tsx @@ -6,8 +6,8 @@ import { useCallback, useEffect, useId, useState } from 'react'; import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select'; import { toast } from 'sonner'; -import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getUsageHistoryApiV1OrganizationsUsageRunsGet } from '@/client/sdk.gen'; -import type { DailyUsageBreakdownResponse, MpsCreditsResponse, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen'; +import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getPreferencesApiV1OrganizationsPreferencesGet, getUsageHistoryApiV1OrganizationsUsageRunsGet, savePreferencesApiV1OrganizationsPreferencesPut } from '@/client/sdk.gen'; +import type { DailyUsageBreakdownResponse, MpsCreditsResponse, OrganizationPreferences, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen'; import { CallTypeCell } from '@/components/CallTypeCell'; import { DailyUsageTable } from '@/components/DailyUsageTable'; import { FilterBuilder } from '@/components/filters/FilterBuilder'; @@ -36,7 +36,7 @@ const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone; export default function UsagePage() { const router = useRouter(); const searchParams = useSearchParams(); - const { userConfig, saveUserConfig, loading: userConfigLoading, organizationPricing } = useUserConfig(); + const { organizationPricing } = useUserConfig(); const auth = useAuth(); // MPS credits state @@ -74,6 +74,8 @@ export default function UsagePage() { const localTimezone = getLocalTimezone(); const [selectedTimezone, setSelectedTimezone] = useState(''); const [savingTimezone, setSavingTimezone] = useState(false); + const [preferences, setPreferences] = useState({}); + const [preferencesLoading, setPreferencesLoading] = useState(true); const timezoneSelectId = useId(); // Stable ID for react-select to prevent hydration mismatch // Fetch MPS credits @@ -168,6 +170,23 @@ export default function UsagePage() { } }, [auth.isAuthenticated, organizationPricing]); + const fetchPreferences = useCallback(async () => { + if (!auth.isAuthenticated) return; + + setPreferencesLoading(true); + try { + const response = await getPreferencesApiV1OrganizationsPreferencesGet(); + const nextPreferences = response.data || {}; + setPreferences(nextPreferences); + setSelectedTimezone(nextPreferences.timezone || localTimezone); + } catch (error) { + console.error('Failed to fetch organization preferences:', error); + setSelectedTimezone(localTimezone); + } finally { + setPreferencesLoading(false); + } + }, [auth.isAuthenticated, localTimezone]); + // Download a CSV of all runs matching the current filters. const handleDownloadReport = async () => { if (!auth.isAuthenticated) return; @@ -203,31 +222,31 @@ export default function UsagePage() { const handleTimezoneChange = async (timezone: ITimezoneOption | string) => { setSelectedTimezone(timezone); setSavingTimezone(true); + const previousTimezone = preferences.timezone || localTimezone; try { const tzValue = typeof timezone === 'string' ? timezone : timezone.value; - await saveUserConfig({ timezone: tzValue }); + const response = await savePreferencesApiV1OrganizationsPreferencesPut({ + body: { + ...preferences, + timezone: tzValue, + }, + }); + if (response.error) { + throw new Error('Failed to save timezone'); + } + setPreferences(response.data || { ...preferences, timezone: tzValue }); } catch (error) { console.error('Failed to save timezone:', error); - // Revert to previous timezone on error - const prevTz = userConfig?.timezone || localTimezone; - setSelectedTimezone(prevTz); + setSelectedTimezone(previousTimezone); } finally { setSavingTimezone(false); } }; - // Update timezone when userConfig loads + // Update timezone when organization preferences load. useEffect(() => { - if (!userConfigLoading) { - // Config has loaded - set the timezone - if (userConfig?.timezone) { - setSelectedTimezone(userConfig.timezone); - } else { - // No saved timezone, use local - setSelectedTimezone(localTimezone); - } - } - }, [userConfig, userConfigLoading, localTimezone]); + fetchPreferences(); + }, [fetchPreferences]); // Initial load - fetch when auth becomes available useEffect(() => { @@ -340,8 +359,8 @@ export default function UsagePage() { instanceId={timezoneSelectId} value={selectedTimezone} onChange={handleTimezoneChange} - isDisabled={savingTimezone || userConfigLoading} - placeholder={userConfigLoading ? "Loading..." : "Select timezone"} + isDisabled={savingTimezone || preferencesLoading} + placeholder={preferencesLoading ? "Loading..." : "Select timezone"} styles={{ control: (base, state) => ({ ...base, diff --git a/ui/src/app/workflow/[workflowId]/components/PhoneCallDialog.tsx b/ui/src/app/workflow/[workflowId]/components/PhoneCallDialog.tsx index 5e737ae2..3b1c99a7 100644 --- a/ui/src/app/workflow/[workflowId]/components/PhoneCallDialog.tsx +++ b/ui/src/app/workflow/[workflowId]/components/PhoneCallDialog.tsx @@ -4,15 +4,21 @@ import 'react-international-phone/style.css'; import { Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { PhoneInput } from 'react-international-phone'; import { + getPreferencesApiV1OrganizationsPreferencesGet, initiateCallApiV1TelephonyInitiateCallPost, listPhoneNumbersApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersGet, - listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet + listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet, + savePreferencesApiV1OrganizationsPreferencesPut, } from '@/client/sdk.gen'; -import type { PhoneNumberResponse, TelephonyConfigurationListItem } from '@/client/types.gen'; +import type { + OrganizationPreferences, + PhoneNumberResponse, + TelephonyConfigurationListItem, +} from '@/client/types.gen'; import { Button } from "@/components/ui/button"; import { Dialog, @@ -33,6 +39,7 @@ import { SelectValue, } from "@/components/ui/select"; import { useUserConfig } from "@/context/UserConfigContext"; +import { detailFromError } from "@/lib/apiError"; interface PhoneCallDialogProps { open: boolean; @@ -48,21 +55,40 @@ export const PhoneCallDialog = ({ user, }: PhoneCallDialogProps) => { const router = useRouter(); - const { userConfig, saveUserConfig } = useUserConfig(); - const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || ""); + const { refreshConfig } = useUserConfig(); + const [preferences, setPreferences] = useState({}); + const [preferencesLoaded, setPreferencesLoaded] = useState(false); + const [phoneNumber, setPhoneNumber] = useState(""); const [callLoading, setCallLoading] = useState(false); const [callError, setCallError] = useState(null); const [callSuccessMsg, setCallSuccessMsg] = useState(null); const [phoneChanged, setPhoneChanged] = useState(false); const [checkingConfig, setCheckingConfig] = useState(false); const [needsConfiguration, setNeedsConfiguration] = useState(null); - const [sipMode, setSipMode] = useState(() => /^(PJSIP|SIP)\//i.test(userConfig?.test_phone_number || "")); + const [sipMode, setSipMode] = useState(false); const [telephonyConfigs, setTelephonyConfigs] = useState([]); const [selectedConfigId, setSelectedConfigId] = useState(""); const [fromPhoneNumbers, setFromPhoneNumbers] = useState([]); const [selectedFromPhoneNumberId, setSelectedFromPhoneNumberId] = useState(""); const [loadingPhoneNumbers, setLoadingPhoneNumbers] = useState(false); + const fetchPreferences = useCallback(async () => { + const result = + await getPreferencesApiV1OrganizationsPreferencesGet(); + if (result.error) { + throw new Error(detailFromError(result.error, "Failed to load phone preferences")); + } + return result.data || {}; + }, []); + + const applyPreferences = useCallback((nextPreferences: OrganizationPreferences) => { + const saved = nextPreferences.test_phone_number || ""; + setPreferences(nextPreferences); + setPhoneNumber(saved); + setSipMode(/^(PJSIP|SIP)\//i.test(saved)); + setPhoneChanged(false); + }, []); + // Check telephony configuration when dialog opens useEffect(() => { const checkConfig = async () => { @@ -97,6 +123,33 @@ export const PhoneCallDialog = ({ checkConfig(); }, [open]); + // Load organization-scoped call preferences when dialog opens. + useEffect(() => { + if (!open) return; + + let cancelled = false; + setPreferencesLoaded(false); + + const loadPreferences = async () => { + try { + const nextPreferences = await fetchPreferences(); + if (cancelled) return; + applyPreferences(nextPreferences); + setPreferencesLoaded(true); + } catch (err) { + if (cancelled) return; + applyPreferences({}); + setPreferencesLoaded(false); + setCallError(err instanceof Error ? err.message : "Failed to load phone preferences"); + } + }; + + loadPreferences(); + return () => { + cancelled = true; + }; + }, [applyPreferences, fetchPreferences, open]); + // Reset state when dialog closes useEffect(() => { if (!open) { @@ -149,22 +202,9 @@ export const PhoneCallDialog = ({ }; }, [open, selectedConfigId]); - // Keep phoneNumber in sync with userConfig when dialog opens - useEffect(() => { - if (open) { - const saved = userConfig?.test_phone_number || ""; - setPhoneNumber(saved); - setSipMode(/^(PJSIP|SIP)\//i.test(saved)); - setPhoneChanged(false); - setCallError(null); - setCallSuccessMsg(null); - setCallLoading(false); - } - }, [open, userConfig?.test_phone_number]); - const handlePhoneInputChange = (formattedValue: string) => { setPhoneNumber(formattedValue); - setPhoneChanged(formattedValue !== userConfig?.test_phone_number); + setPhoneChanged(formattedValue !== (preferences.test_phone_number || "")); setCallError(null); setCallSuccessMsg(null); }; @@ -174,17 +214,39 @@ export const PhoneCallDialog = ({ router.push('/telephony-configurations'); }; + const savePhoneNumberPreference = async () => { + const currentPreferences = preferencesLoaded ? preferences : await fetchPreferences(); + const result = + await savePreferencesApiV1OrganizationsPreferencesPut({ + body: { + ...currentPreferences, + test_phone_number: phoneNumber || null, + }, + }); + + if (result.error) { + throw new Error(detailFromError(result.error, "Failed to save phone preferences")); + } + if (!result.data) { + throw new Error("Failed to save phone preferences"); + } + + setPreferences(result.data); + setPreferencesLoaded(true); + setPhoneChanged(false); + await refreshConfig(); + }; + const handleStartCall = async () => { setCallLoading(true); setCallError(null); setCallSuccessMsg(null); try { - if (!user || !userConfig) return; + if (!user) return; // Save phone number if it has changed if (phoneChanged) { - await saveUserConfig({ ...userConfig, test_phone_number: phoneNumber }); - setPhoneChanged(false); + await savePhoneNumberPreference(); } const response = await initiateCallApiV1TelephonyInitiateCallPost({ diff --git a/ui/src/app/workflow/[workflowId]/settings/page.tsx b/ui/src/app/workflow/[workflowId]/settings/page.tsx index b1bff348..28d2a5ba 100644 --- a/ui/src/app/workflow/[workflowId]/settings/page.tsx +++ b/ui/src/app/workflow/[workflowId]/settings/page.tsx @@ -7,8 +7,22 @@ import { useParams, useRouter } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { downloadWorkflowReportApiV1WorkflowWorkflowIdReportGet, getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost, getWorkflowApiV1WorkflowFetchWorkflowIdGet } from "@/client/sdk.gen"; -import type { WorkflowResponse } from "@/client/types.gen"; +import { + downloadWorkflowReportApiV1WorkflowWorkflowIdReportGet, + getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost, + getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get, + getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet, + getWorkflowApiV1WorkflowFetchWorkflowIdGet, +} from "@/client/sdk.gen"; +import type { + OrganizationAiModelConfigurationResponse, + OrganizationAiModelConfigurationV2, + WorkflowResponse, +} from "@/client/types.gen"; +import { + AIModelConfigurationV2Editor, + type ModelConfigurationDefaultsV2, +} from "@/components/AIModelConfigurationV2Editor"; import { FlowEdge, FlowNode } from "@/components/flow/types"; import { LLMConfigSelector } from "@/components/LLMConfigSelector"; import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm"; @@ -26,6 +40,7 @@ import { Textarea } from "@/components/ui/textarea"; import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation"; import { UnsavedChangesProvider, useUnsavedChanges, useUnsavedChangesContext } from "@/context/UnsavedChangesContext"; import { useAudioPlayback } from "@/hooks/useAudioPlayback"; +import { detailFromError } from "@/lib/apiError"; import { useAuth } from "@/lib/auth"; import logger from "@/lib/logger"; import { @@ -1040,6 +1055,182 @@ function AgentUuidSection({ workflowUuid }: { workflowUuid: string }) { ); } +// --------------------------------------------------------------------------- +// Section: Model Overrides +// --------------------------------------------------------------------------- + +function withoutModelConfigurationOverrides(configurations: WorkflowConfigurations): WorkflowConfigurations { + const next = { ...configurations }; + delete next.model_overrides; + delete next.model_configuration_v2_override; + return next; +} + +function WorkflowModelOverridesSection({ + workflowConfigurations, + workflowName, + onSave, + modelConfigurationDefaults, + organizationModelConfiguration, + modelConfigurationLoading, + modelConfigurationError, +}: { + workflowConfigurations: WorkflowConfigurations; + workflowName: string; + onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise; + modelConfigurationDefaults: ModelConfigurationDefaultsV2 | null; + organizationModelConfiguration: OrganizationAiModelConfigurationResponse | null; + modelConfigurationLoading: boolean; + modelConfigurationError: string | null; +}) { + const savedV2Override = workflowConfigurations.model_configuration_v2_override; + const hasSavedModelOverride = Boolean(savedV2Override || workflowConfigurations.model_overrides); + const [overrideEnabled, setOverrideEnabled] = useState(Boolean(savedV2Override)); + const [isRemovingOverride, setIsRemovingOverride] = useState(false); + + useEffect(() => { + setOverrideEnabled(Boolean(workflowConfigurations.model_configuration_v2_override)); + }, [workflowConfigurations.model_configuration_v2_override]); + + const source = organizationModelConfiguration?.source || "empty"; + const isV2 = source === "organization_v2"; + + const saveLegacyOverrides = async (config: Record) => { + const nextConfigurations = withoutModelConfigurationOverrides(workflowConfigurations); + const modelOverrides = config.model_overrides as WorkflowConfigurations["model_overrides"] | undefined; + if (modelOverrides) { + nextConfigurations.model_overrides = modelOverrides; + } + await onSave(nextConfigurations, workflowName); + }; + + const saveV2Override = async (configuration: OrganizationAiModelConfigurationV2) => { + const nextConfigurations = withoutModelConfigurationOverrides(workflowConfigurations); + nextConfigurations.model_configuration_v2_override = configuration; + await onSave(nextConfigurations, workflowName); + toast.success("Model override saved"); + }; + + const removeV2Override = async () => { + setIsRemovingOverride(true); + try { + await onSave(withoutModelConfigurationOverrides(workflowConfigurations), workflowName); + setOverrideEnabled(false); + toast.success("Using organization model configuration"); + } finally { + setIsRemovingOverride(false); + } + }; + + return ( + + + + + Model Overrides + + + {isV2 + ? "Override the full organization model configuration for this workflow." + : "Override global model settings for this workflow. Toggle individual services to customize."}{" "} +
Learn more + + + + {modelConfigurationLoading && ( +
+ + Loading model configuration +
+ )} + + {modelConfigurationError && ( +
+ {modelConfigurationError} +
+ )} + + {!modelConfigurationLoading && !modelConfigurationError && !isV2 && ( + <> + {source === "legacy_user_v1" && ( +
+

+ This workflow is using legacy model overrides. Migrate organization model configuration to use v2 overrides. +

+ +
+ )} + + + )} + + {!modelConfigurationLoading && !modelConfigurationError && isV2 && modelConfigurationDefaults && organizationModelConfiguration && ( + <> +
+
+ +

+ {overrideEnabled + ? "This workflow uses its own complete model configuration." + : "This workflow uses the organization model configuration."} +

+
+ +
+ + {overrideEnabled ? ( + + ) : ( +
+

+ Using organization model configuration. +

+ {hasSavedModelOverride && ( + + )} +
+ )} + + )} +
+ + ); +} + // --------------------------------------------------------------------------- // Main Page // --------------------------------------------------------------------------- @@ -1127,6 +1318,11 @@ function WorkflowSettingsInner({ const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false); const [activeSection, setActiveSection] = useState("general"); + const [modelConfigurationDefaults, setModelConfigurationDefaults] = useState(null); + const [organizationModelConfiguration, setOrganizationModelConfiguration] = useState(null); + const [modelConfigurationLoading, setModelConfigurationLoading] = useState(true); + const [modelConfigurationError, setModelConfigurationError] = useState(null); + const hasFetchedModelConfiguration = useRef(false); const workflowId = workflow.id; @@ -1166,6 +1362,37 @@ function WorkflowSettingsInner({ user, }); + useEffect(() => { + if (hasFetchedModelConfiguration.current) return; + hasFetchedModelConfiguration.current = true; + + const loadModelConfiguration = async () => { + setModelConfigurationLoading(true); + setModelConfigurationError(null); + const [defaultsResult, configurationResult] = await Promise.all([ + getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet(), + getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get(), + ]); + + if (defaultsResult.error) { + setModelConfigurationError(detailFromError(defaultsResult.error, "Failed to load model configuration defaults")); + setModelConfigurationLoading(false); + return; + } + if (configurationResult.error) { + setModelConfigurationError(detailFromError(configurationResult.error, "Failed to load model configuration")); + setModelConfigurationLoading(false); + return; + } + + setModelConfigurationDefaults(defaultsResult.data as ModelConfigurationDefaultsV2); + setOrganizationModelConfiguration(configurationResult.data || null); + setModelConfigurationLoading(false); + }; + + loadModelConfiguration(); + }, []); + // Intersection observer for active sidebar link useEffect(() => { const ids = NAV_ITEMS.map((n) => n.id); @@ -1218,37 +1445,15 @@ function WorkflowSettingsInner({ onSave={saveWorkflowConfigurations} /> - {/* Model Overrides */} - - - - - Model Overrides - - - Override global model settings for this workflow. Toggle individual services to - customize.{" "} - Learn more - - - - { - await saveWorkflowConfigurations( - { - ...workflowConfigurations, - model_overrides: - config.model_overrides as WorkflowConfigurations["model_overrides"], - } as WorkflowConfigurations, - workflowName, - ); - }} - /> - - + {/* Template Variables */} = Options2 & { /** @@ -936,6 +936,55 @@ export const getTelephonyProvidersMetadataApiV1OrganizationsTelephonyProvidersMe */ export const getTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/telephony-config-warnings', ...options }); +/** + * Get Model Configuration V2 Defaults + */ +export const getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/model-configurations/v2/defaults', ...options }); + +/** + * Get Model Configuration V2 + */ +export const getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/model-configurations/v2', ...options }); + +/** + * Save Model Configuration V2 + */ +export const saveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Put = (options: Options) => (options.client ?? client).put({ + url: '/api/v1/organizations/model-configurations/v2', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Preview Model Configuration V2 Migration + */ +export const previewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/model-configurations/v2/migration-preview', ...options }); + +/** + * Migrate Model Configuration V2 + */ +export const migrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePost = (options?: Options) => (options?.client ?? client).post({ url: '/api/v1/organizations/model-configurations/v2/migrate', ...options }); + +/** + * Get Preferences + */ +export const getPreferencesApiV1OrganizationsPreferencesGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/preferences', ...options }); + +/** + * Save Preferences + */ +export const savePreferencesApiV1OrganizationsPreferencesPut = (options: Options) => (options.client ?? client).put({ + url: '/api/v1/organizations/preferences', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * List Telephony Configurations * @@ -1261,7 +1310,7 @@ export const getTurnCredentialsApiV1TurnCredentialsGet = (options?: Options) => (options?.client ?? client).options({ url: '/api/v1/public/embed/init', ...options }); @@ -1297,11 +1346,15 @@ export const initializeEmbedSessionApiV1PublicEmbedInitPost = (options: Options) => (options.client ?? client).get({ url: '/api/v1/public/embed/config/{token}', ...options }); /** - * Options Config + * Options Embed Config * - * Handle CORS preflight for config endpoint + * Fallback OPTIONS handler for the embed config endpoint. + * + * Browser preflights include Access-Control-Request-Method and are handled by + * PublicEmbedCORSMiddleware before global CORS. This keeps non-conformant + * OPTIONS requests on the same validation path. */ -export const optionsConfigApiV1PublicEmbedConfigTokenOptions = (options: Options) => (options.client ?? client).options({ url: '/api/v1/public/embed/config/{token}', ...options }); +export const optionsEmbedConfigApiV1PublicEmbedConfigTokenOptions = (options: Options) => (options.client ?? client).options({ url: '/api/v1/public/embed/config/{token}', ...options }); /** * Get Public Turn Credentials @@ -1322,7 +1375,7 @@ export const getPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionToken /** * Options Turn Credentials * - * Handle CORS preflight for TURN credentials endpoint + * Fallback OPTIONS handler for TURN credentials endpoint. */ export const optionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptions = (options: Options) => (options.client ?? client).options({ url: '/api/v1/public/embed/turn-credentials/{session_token}', ...options }); diff --git a/ui/src/client/types.gen.ts b/ui/src/client/types.gen.ts index 8398f5f8..a44e7565 100644 --- a/ui/src/client/types.gen.ts +++ b/ui/src/client/types.gen.ts @@ -136,6 +136,46 @@ export type AriConfigurationResponse = { from_numbers: Array; }; +/** + * AWS Bedrock + */ +export type AwsBedrockLlmConfiguration = { + /** + * Provider + */ + provider?: 'aws_bedrock'; + /** + * Api Key + * + * Not used for Bedrock — authentication is via the AWS credentials above. Leave blank. + */ + api_key?: string | Array | null; + /** + * Model + * + * Bedrock model ID — include the region inference-profile prefix (e.g. 'us.'). + */ + model?: string; + /** + * Aws Access Key + * + * AWS access key ID with bedrock:InvokeModel permission. + */ + aws_access_key?: string; + /** + * Aws Secret Key + * + * AWS secret access key paired with the access key ID. + */ + aws_secret_key?: string; + /** + * Aws Region + * + * AWS region where the Bedrock model is available. + */ + aws_region?: string; +}; + /** * AmbientNoiseUploadRequest */ @@ -192,6 +232,32 @@ export type AppendTextChatMessageRequest = { expected_revision?: number | null; }; +/** + * AssemblyAI + */ +export type AssemblyAisttConfiguration = { + /** + * Provider + */ + provider?: 'assemblyai'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * AssemblyAI realtime STT model. + */ + model?: string; + /** + * Language + * + * ISO 639-1 language code. + */ + language?: string; +}; + /** * AuthResponse */ @@ -217,6 +283,354 @@ export type AuthUserResponse = { is_superuser: boolean; }; +/** + * Azure OpenAI + */ +export type AzureLlmService = { + /** + * Provider + */ + provider?: 'azure'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Azure deployment name (not the upstream OpenAI model id). + */ + model?: string; + /** + * Endpoint + * + * Azure OpenAI resource endpoint (e.g. https://.openai.azure.com). + */ + endpoint: string; +}; + +/** + * Azure OpenAI + */ +export type AzureOpenAiEmbeddingsConfiguration = { + /** + * Provider + */ + provider?: 'azure'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Azure OpenAI embedding deployment name. The deployment must return 1536-dimensional embeddings. + */ + model?: string; + /** + * Endpoint + * + * Azure OpenAI resource endpoint (e.g. https://.openai.azure.com). + */ + endpoint: string; + /** + * Api Version + * + * Azure OpenAI API version for embeddings. + */ + api_version?: string; +}; + +/** + * Azure OpenAI Realtime + * + * Azure OpenAI Realtime API — low-latency speech-to-speech conversations. + */ +export type AzureRealtimeLlmConfiguration = { + /** + * Provider + */ + provider?: 'azure_realtime'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Azure OpenAI realtime deployment name. + */ + model?: string; + /** + * Endpoint + * + * Azure OpenAI resource endpoint (e.g. https://.openai.azure.com). + */ + endpoint: string; + /** + * Voice + * + * Voice the model speaks in. + */ + voice?: string; + /** + * Api Version + * + * Azure OpenAI API version. + */ + api_version?: string; +}; + +/** + * Azure Speech Services + * + * Azure Cognitive Services Speech — TTS and STT via the Azure Speech SDK. + */ +export type AzureSpeechSttConfiguration = { + /** + * Provider + */ + provider?: 'azure_speech'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Azure Speech recognition model (use 'latest_long' for continuous recognition). + */ + model?: string; + /** + * Region + * + * Azure region for Speech Services (e.g. 'eastus', 'westeurope'). + */ + region?: string; + /** + * Language + * + * BCP-47 language code for recognition. + */ + language?: string; +}; + +/** + * Azure Speech Services + * + * Azure Cognitive Services Speech — TTS and STT via the Azure Speech SDK. + */ +export type AzureSpeechTtsConfiguration = { + /** + * Provider + */ + provider?: 'azure_speech'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Azure Speech synthesis engine (neural voices only). + */ + model?: string; + /** + * Region + * + * Azure region for Speech Services (e.g. 'eastus', 'westeurope'). + */ + region?: string; + /** + * Voice + * + * Azure Neural voice name (e.g. 'en-US-AriaNeural'). + */ + voice?: string; + /** + * Language + * + * BCP-47 language code for synthesis. + */ + language?: string; + /** + * Speed + * + * Speech speed multiplier (0.5 to 2.0). + */ + speed?: number; +}; + +/** + * BYOKAIModelConfiguration + */ +export type ByokaiModelConfiguration = { + /** + * Mode + */ + mode: 'pipeline' | 'realtime'; + pipeline?: ByokPipelineAiModelConfiguration | null; + realtime?: ByokRealtimeAiModelConfiguration | null; +}; + +/** + * BYOKPipelineAIModelConfiguration + */ +export type ByokPipelineAiModelConfiguration = { + /** + * Llm + */ + llm: ({ + provider: 'openai'; + } & OpenAillmService) | ({ + provider: 'google_vertex'; + } & GoogleVertexLlmConfiguration) | ({ + provider: 'groq'; + } & GroqLlmService) | ({ + provider: 'openrouter'; + } & OpenRouterLlmConfiguration) | ({ + provider: 'google'; + } & GoogleLlmService) | ({ + provider: 'azure'; + } & AzureLlmService) | ({ + provider: 'dograh'; + } & DograhLlmService) | ({ + provider: 'aws_bedrock'; + } & AwsBedrockLlmConfiguration) | ({ + provider: 'speaches'; + } & SpeachesLlmConfiguration) | ({ + provider: 'minimax'; + } & MiniMaxLlmConfiguration) | ({ + provider: 'sarvam'; + } & SarvamLlmConfiguration); + /** + * Tts + */ + tts: ({ + provider: 'deepgram'; + } & DeepgramTtsConfiguration) | ({ + provider: 'google'; + } & GoogleTtsConfiguration) | ({ + provider: 'openai'; + } & OpenAittsService) | ({ + provider: 'elevenlabs'; + } & ElevenlabsTtsConfiguration) | ({ + provider: 'cartesia'; + } & CartesiaTtsConfiguration) | ({ + provider: 'dograh'; + } & DograhTtsService) | ({ + provider: 'sarvam'; + } & SarvamTtsConfiguration) | ({ + provider: 'camb'; + } & CambTtsConfiguration) | ({ + provider: 'rime'; + } & RimeTtsConfiguration) | ({ + provider: 'speaches'; + } & SpeachesTtsConfiguration) | ({ + provider: 'minimax'; + } & MiniMaxTtsConfiguration) | ({ + provider: 'azure_speech'; + } & AzureSpeechTtsConfiguration); + /** + * Stt + */ + stt: ({ + provider: 'deepgram'; + } & DeepgramSttConfiguration) | ({ + provider: 'cartesia'; + } & CartesiaSttConfiguration) | ({ + provider: 'openai'; + } & OpenAisttConfiguration) | ({ + provider: 'google'; + } & GoogleSttConfiguration) | ({ + provider: 'dograh'; + } & DograhSttService) | ({ + provider: 'speechmatics'; + } & SpeechmaticsSttConfiguration) | ({ + provider: 'sarvam'; + } & SarvamSttConfiguration) | ({ + provider: 'speaches'; + } & SpeachesSttConfiguration) | ({ + provider: 'assemblyai'; + } & AssemblyAisttConfiguration) | ({ + provider: 'gladia'; + } & GladiaSttConfiguration) | ({ + provider: 'azure_speech'; + } & AzureSpeechSttConfiguration); + /** + * Embeddings + */ + embeddings?: ({ + provider: 'openai'; + } & OpenAiEmbeddingsConfiguration) | ({ + provider: 'openrouter'; + } & OpenRouterEmbeddingsConfiguration) | ({ + provider: 'azure'; + } & AzureOpenAiEmbeddingsConfiguration) | ({ + provider: 'dograh'; + } & DograhEmbeddingsConfiguration) | null; +}; + +/** + * BYOKRealtimeAIModelConfiguration + */ +export type ByokRealtimeAiModelConfiguration = { + /** + * Realtime + */ + realtime: ({ + provider: 'openai_realtime'; + } & OpenAiRealtimeLlmConfiguration) | ({ + provider: 'grok_realtime'; + } & GrokRealtimeLlmConfiguration) | ({ + provider: 'ultravox_realtime'; + } & UltravoxRealtimeLlmConfiguration) | ({ + provider: 'google_realtime'; + } & GoogleRealtimeLlmConfiguration) | ({ + provider: 'google_vertex_realtime'; + } & GoogleVertexRealtimeLlmConfiguration) | ({ + provider: 'azure_realtime'; + } & AzureRealtimeLlmConfiguration); + /** + * Llm + */ + llm: ({ + provider: 'openai'; + } & OpenAillmService) | ({ + provider: 'google_vertex'; + } & GoogleVertexLlmConfiguration) | ({ + provider: 'groq'; + } & GroqLlmService) | ({ + provider: 'openrouter'; + } & OpenRouterLlmConfiguration) | ({ + provider: 'google'; + } & GoogleLlmService) | ({ + provider: 'azure'; + } & AzureLlmService) | ({ + provider: 'dograh'; + } & DograhLlmService) | ({ + provider: 'aws_bedrock'; + } & AwsBedrockLlmConfiguration) | ({ + provider: 'speaches'; + } & SpeachesLlmConfiguration) | ({ + provider: 'minimax'; + } & MiniMaxLlmConfiguration) | ({ + provider: 'sarvam'; + } & SarvamLlmConfiguration); + /** + * Embeddings + */ + embeddings?: ({ + provider: 'openai'; + } & OpenAiEmbeddingsConfiguration) | ({ + provider: 'openrouter'; + } & OpenRouterEmbeddingsConfiguration) | ({ + provider: 'azure'; + } & AzureOpenAiEmbeddingsConfiguration) | ({ + provider: 'dograh'; + } & DograhEmbeddingsConfiguration) | null; +}; + /** * BatchRecordingCreateRequestSchema * @@ -322,6 +736,38 @@ export type CallDispositionCodes = { */ export type CallType = 'inbound' | 'outbound'; +/** + * Camb.ai + */ +export type CambTtsConfiguration = { + /** + * Provider + */ + provider?: 'camb'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Camb.ai TTS model. + */ + model?: string; + /** + * Voice + * + * Camb.ai voice ID. + */ + voice?: string; + /** + * Language + * + * BCP-47 language code. + */ + language?: string; +}; + /** * CampaignDefaultsResponse */ @@ -566,6 +1012,64 @@ export type CampaignsResponse = { campaigns: Array; }; +/** + * Cartesia + */ +export type CartesiaSttConfiguration = { + /** + * Provider + */ + provider?: 'cartesia'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Cartesia STT model. + */ + model?: string; +}; + +/** + * Cartesia + */ +export type CartesiaTtsConfiguration = { + /** + * Provider + */ + provider?: 'cartesia'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Cartesia TTS model. + */ + model?: string; + /** + * Voice + * + * Cartesia voice UUID from your Cartesia dashboard. + */ + voice?: string; + /** + * Speed + * + * Speed of the voice. + */ + speed?: number; + /** + * Volume + * + * Volume multiplier for generated speech. + */ + volume?: number; +}; + /** * ChunkResponseSchema * @@ -1264,6 +1768,52 @@ export type DailyUsageItem = { call_count: number; }; +/** + * Deepgram + */ +export type DeepgramSttConfiguration = { + /** + * Provider + */ + provider?: 'deepgram'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Deepgram STT model. + */ + model?: string; + /** + * Language + * + * Language code; 'multi' enables auto-detect (Nova-3 only). + */ + language?: string; +}; + +/** + * Deepgram + */ +export type DeepgramTtsConfiguration = { + /** + * Provider + */ + provider?: 'deepgram'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Voice + * + * Deepgram voice ID (model is inferred from the 'aura-N' prefix). + */ + voice?: string; +}; + /** * DefaultConfigurationsResponse */ @@ -1508,6 +2058,126 @@ export type DocumentUploadResponseSchema = { s3_key: string; }; +/** + * Dograh + */ +export type DograhEmbeddingsConfiguration = { + /** + * Provider + */ + provider?: 'dograh'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Dograh-managed embedding model. + */ + model?: string; +}; + +/** + * Dograh + */ +export type DograhLlmService = { + /** + * Provider + */ + provider?: 'dograh'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Dograh-hosted model tier. + */ + model?: string; +}; + +/** + * DograhManagedAIModelConfiguration + */ +export type DograhManagedAiModelConfiguration = { + /** + * Api Key + */ + api_key: string; + /** + * Voice + */ + voice?: string; + /** + * Speed + */ + speed?: number; + /** + * Language + */ + language?: string; +}; + +/** + * Dograh + */ +export type DograhSttService = { + /** + * Provider + */ + provider?: 'dograh'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Dograh STT tier. + */ + model?: string; + /** + * Language + * + * Language code; use 'multi' for auto-detect. + */ + language?: string; +}; + +/** + * Dograh + */ +export type DograhTtsService = { + /** + * Provider + */ + provider?: 'dograh'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Dograh TTS tier. + */ + model?: string; + /** + * Voice + * + * Voice preset. + */ + voice?: string; + /** + * Speed + * + * Speed of the voice. + */ + speed?: number; +}; + /** * DuplicateTemplateRequest */ @@ -1522,6 +2192,44 @@ export type DuplicateTemplateRequest = { workflow_name: string; }; +/** + * ElevenLabs + */ +export type ElevenlabsTtsConfiguration = { + /** + * Provider + */ + provider?: 'elevenlabs'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Voice + * + * ElevenLabs voice ID from your Voice Library. + */ + voice?: string; + /** + * Speed + * + * Speed of the voice. + */ + speed?: number; + /** + * Model + * + * ElevenLabs TTS model. + */ + model?: string; + /** + * Base Url + * + * ElevenLabs API base URL. Override to use a Data Residency endpoint (e.g. https://api.eu.residency.elevenlabs.io) for GDPR / HIPAA / regional compliance. + */ + base_url?: string; +}; + /** * EmbedConfigResponse * @@ -1758,6 +2466,268 @@ export type FolderResponse = { created_at: string; }; +/** + * Gladia + */ +export type GladiaSttConfiguration = { + /** + * Provider + */ + provider?: 'gladia'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Gladia STT model. + */ + model?: string; + /** + * Language + * + * ISO 639-1 language code. + */ + language?: string; +}; + +/** + * Google + */ +export type GoogleLlmService = { + /** + * Provider + */ + provider?: 'google'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Gemini model on Google AI Studio (not Vertex). + */ + model?: string; +}; + +/** + * Google Realtime + */ +export type GoogleRealtimeLlmConfiguration = { + /** + * Provider + */ + provider?: 'google_realtime'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Gemini Live model on Google AI Studio (not Vertex). + */ + model?: string; + /** + * Voice + * + * Voice the model speaks in. + */ + voice?: string; + /** + * Language + * + * ISO 639-1 language code. + */ + language?: string; +}; + +/** + * Google Cloud + */ +export type GoogleSttConfiguration = { + /** + * Provider + */ + provider?: 'google'; + /** + * Api Key + * + * Not used for Google Cloud STT. Leave blank. + */ + api_key?: string | Array | null; + /** + * Model + * + * Google Cloud Speech-to-Text V2 recognition model. + */ + model?: string; + /** + * Language + * + * Primary BCP-47 language code for recognition. + */ + language?: string; + /** + * Location + * + * Google Cloud Speech-to-Text region (for example 'global' or 'us-central1'). + */ + location?: string; + /** + * Credentials + * + * Paste the entire Google Cloud service-account JSON. If omitted, the server falls back to Application Default Credentials (ADC). + */ + credentials?: string | null; +}; + +/** + * Google Cloud + */ +export type GoogleTtsConfiguration = { + /** + * Provider + */ + provider?: 'google'; + /** + * Api Key + * + * Not used for Google Cloud TTS. Leave blank. + */ + api_key?: string | Array | null; + /** + * Model + * + * Google Cloud low-latency TTS engine. Dograh maps this to Pipecat's streaming Google TTS service for Chirp 3 HD and Journey voices. + */ + model?: string; + /** + * Voice + * + * Google Cloud voice name. Use a Chirp 3 HD or Journey voice for streaming TTS. + */ + voice?: string; + /** + * Language + * + * BCP-47 language code for synthesis. + */ + language?: string; + /** + * Speed + * + * Speech speed multiplier for Google streaming TTS. + */ + speed?: number; + /** + * Location + * + * Optional Google Cloud regional Text-to-Speech endpoint (for example 'us-central1'). Leave blank to use the default endpoint. + */ + location?: string | null; + /** + * Credentials + * + * Paste the entire Google Cloud service-account JSON. If omitted, the server falls back to Application Default Credentials (ADC). + */ + credentials?: string | null; +}; + +/** + * Google Vertex + */ +export type GoogleVertexLlmConfiguration = { + /** + * Provider + */ + provider?: 'google_vertex'; + /** + * Api Key + * + * Not used for Vertex AI — authentication is via the service account in `credentials` (or ADC). Leave blank. + */ + api_key?: string | Array | null; + /** + * Model + * + * Gemini model on Vertex AI. + */ + model?: string; + /** + * Project Id + * + * Google Cloud project ID for Vertex AI. + */ + project_id: string; + /** + * Location + * + * GCP region for the Vertex AI endpoint (e.g. 'global'). + */ + location?: string; + /** + * Credentials + * + * Paste the entire service-account JSON file contents. If omitted, falls back to Application Default Credentials (ADC). + */ + credentials?: string | null; +}; + +/** + * Google Vertex Realtime + */ +export type GoogleVertexRealtimeLlmConfiguration = { + /** + * Provider + */ + provider?: 'google_vertex_realtime'; + /** + * Api Key + * + * Not used for Vertex AI — authentication is via the service account in `credentials` (or ADC). Leave blank. + */ + api_key?: string | Array | null; + /** + * Model + * + * Vertex AI publisher/model identifier. + */ + model?: string; + /** + * Voice + * + * Voice the model speaks in. + */ + voice?: string; + /** + * Language + * + * BCP-47 language code (e.g. 'en-US'). + */ + language?: string; + /** + * Project Id + * + * Google Cloud project ID for Vertex AI. + */ + project_id: string; + /** + * Location + * + * GCP region for the Vertex AI endpoint (e.g. 'global'). + */ + location?: string; + /** + * Credentials + * + * Paste the entire service-account JSON file contents. If omitted, falls back to Application Default Credentials (ADC). + */ + credentials?: string | null; +}; + /** * GraphConstraints * @@ -1782,6 +2752,52 @@ export type GraphConstraints = { max_outgoing?: number | null; }; +/** + * Grok Realtime + */ +export type GrokRealtimeLlmConfiguration = { + /** + * Provider + */ + provider?: 'grok_realtime'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Grok realtime voice-agent model. + */ + model?: string; + /** + * Voice + * + * Voice the model speaks in. + */ + voice?: string; +}; + +/** + * Groq + */ +export type GroqLlmService = { + /** + * Provider + */ + provider?: 'groq'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Groq-hosted model identifier. + */ + model?: string; +}; + /** * HTTPValidationError */ @@ -2205,6 +3221,82 @@ export type McpToolDefinition = { config: McpToolConfig; }; +/** + * MiniMaxLLMConfiguration + */ +export type MiniMaxLlmConfiguration = { + /** + * Provider + */ + provider?: 'minimax'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * MiniMax chat model. + */ + model?: string; + /** + * Base Url + * + * MiniMax OpenAI-compatible API endpoint. + */ + base_url?: string; + /** + * Temperature + * + * Sampling temperature. MiniMax requires > 0. + */ + temperature?: number; +}; + +/** + * MiniMaxTTSConfiguration + */ +export type MiniMaxTtsConfiguration = { + /** + * Provider + */ + provider?: 'minimax'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * MiniMax TTS model. + */ + model?: string; + /** + * Voice + * + * MiniMax voice ID. + */ + voice?: string; + /** + * Base Url + * + * 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). + */ + base_url?: string; + /** + * Speed + * + * Speech speed (0.5 to 2.0). + */ + speed?: number; + /** + * Group Id + * + * MiniMax Group ID (found in your MiniMax dashboard under Account → Group). + */ + group_id: string; +}; + /** * MoveWorkflowToFolderRequest * @@ -2306,6 +3398,240 @@ export type NodeTypesResponse = { node_types: Array; }; +/** + * OpenAI + */ +export type OpenAiEmbeddingsConfiguration = { + /** + * Provider + */ + provider?: 'openai'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * OpenAI embedding model. + */ + model?: string; +}; + +/** + * OpenAI + */ +export type OpenAillmService = { + /** + * Provider + */ + provider?: 'openai'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * OpenAI chat model to use. + */ + model?: string; + /** + * Base Url + * + * Override only if using an OpenAI-compatible API (e.g. local LLM, proxy). + */ + base_url?: string; +}; + +/** + * OpenAI Realtime + */ +export type OpenAiRealtimeLlmConfiguration = { + /** + * Provider + */ + provider?: 'openai_realtime'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * OpenAI realtime (speech-to-speech) model. + */ + model?: string; + /** + * Voice + * + * Voice the model speaks in. + */ + voice?: string; +}; + +/** + * OpenAI + */ +export type OpenAisttConfiguration = { + /** + * Provider + */ + provider?: 'openai'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * OpenAI transcription model. + */ + model?: string; + /** + * Base Url + * + * Override only if using an OpenAI-compatible API (e.g. local STT, proxy). + */ + base_url?: string; +}; + +/** + * OpenAI + */ +export type OpenAittsService = { + /** + * Provider + */ + provider?: 'openai'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * OpenAI TTS model. + */ + model?: string; + /** + * Voice + * + * OpenAI TTS voice name. + */ + voice?: string; + /** + * Base Url + * + * Override only if using an OpenAI-compatible API (e.g. local TTS, proxy). + */ + base_url?: string; +}; + +/** + * Open Router + */ +export type OpenRouterEmbeddingsConfiguration = { + /** + * Provider + */ + provider?: 'openrouter'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * OpenRouter-hosted embedding model slug. + */ + model?: string; + /** + * Base Url + * + * Override only if proxying OpenRouter through your own gateway. + */ + base_url?: string; +}; + +/** + * Open Router + */ +export type OpenRouterLlmConfiguration = { + /** + * Provider + */ + provider?: 'openrouter'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * OpenRouter model slug in 'vendor/model' form. + */ + model?: string; + /** + * Base Url + * + * Override only if proxying OpenRouter through your own gateway. + */ + base_url?: string; +}; + +/** + * OrganizationAIModelConfigurationResponse + */ +export type OrganizationAiModelConfigurationResponse = { + /** + * Configuration + */ + configuration: { + [key: string]: unknown; + } | null; + /** + * Effective Configuration + */ + effective_configuration: { + [key: string]: unknown; + }; + /** + * Source + */ + source: 'organization_v2' | 'legacy_user_v1' | 'empty'; +}; + +/** + * OrganizationAIModelConfigurationV2 + */ +export type OrganizationAiModelConfigurationV2 = { + /** + * Version + */ + version?: 2; + /** + * Mode + */ + mode: 'dograh' | 'byok'; + dograh?: DograhManagedAiModelConfiguration | null; + byok?: ByokaiModelConfiguration | null; +}; + +/** + * OrganizationPreferences + */ +export type OrganizationPreferences = { + /** + * Test Phone Number + */ + test_phone_number?: string | null; + /** + * Timezone + */ + timezone?: string | null; +}; + /** * PhoneNumberCreateRequest * @@ -3034,6 +4360,44 @@ export type RewindTextChatSessionRequest = { expected_revision?: number | null; }; +/** + * Rime + */ +export type RimeTtsConfiguration = { + /** + * Provider + */ + provider?: 'rime'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Rime TTS model. + */ + model?: string; + /** + * Voice + * + * Rime voice ID. + */ + voice?: string; + /** + * Speed + * + * Speech speed multiplier. + */ + speed?: number; + /** + * Language + * + * ISO 639-1 language code. + */ + language?: string; +}; + /** * S3SignedUrlResponse */ @@ -3048,6 +4412,90 @@ export type S3SignedUrlResponse = { expires_in: number; }; +/** + * Sarvam + */ +export type SarvamLlmConfiguration = { + /** + * Provider + */ + provider?: 'sarvam'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Sarvam chat model. Use sarvam-30b for low-latency voice agents; sarvam-105b for complex multi-step reasoning. + */ + model?: string; + /** + * Temperature + * + * Sampling temperature. Sarvam recommends 0.5 for balanced conversational responses. + */ + temperature?: number; +}; + +/** + * Sarvam + */ +export type SarvamSttConfiguration = { + /** + * Provider + */ + provider?: 'sarvam'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Sarvam STT model. saarika:v2.5 transcribes in the spoken language; saaras:v3 is the recommended model with flexible output modes. + */ + model?: string; + /** + * Language + * + * BCP-47 language code. Use unknown for automatic language detection. + */ + language?: string; +}; + +/** + * Sarvam + */ +export type SarvamTtsConfiguration = { + /** + * Provider + */ + provider?: 'sarvam'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Sarvam TTS model (voice list depends on this). + */ + model?: string; + /** + * Voice + * + * Sarvam voice name; must match the selected model's voice list. + */ + voice?: string; + /** + * Language + * + * BCP-47 Indian-language code (e.g. hi-IN, en-IN). + */ + language?: string; +}; + /** * ScheduleConfigRequest */ @@ -3144,6 +4592,140 @@ export type SignupRequest = { name?: string | null; }; +/** + * Local Models (Speaches) + * + * Self-hosted OpenAI-compatible local models. See the Speaches project for setup and supported backends. + */ +export type SpeachesLlmConfiguration = { + /** + * Provider + */ + provider?: 'speaches'; + /** + * Api Key + * + * Usually not required for self-hosted endpoints. Leave blank unless your server enforces one. + */ + api_key?: string | Array | null; + /** + * Model + * + * Model name as exposed by your OpenAI-compatible server. + */ + model?: string; + /** + * Base Url + * + * OpenAI-compatible endpoint (Ollama, vLLM, etc.). + */ + base_url?: string; +}; + +/** + * Local Models (Speaches) + * + * Self-hosted OpenAI-compatible local models. See the Speaches project for setup and supported backends. + */ +export type SpeachesSttConfiguration = { + /** + * Provider + */ + provider?: 'speaches'; + /** + * Api Key + * + * Usually not required for self-hosted STT. Leave blank unless enforced. + */ + api_key?: string | Array | null; + /** + * Model + * + * Whisper model identifier as served by your STT endpoint. + */ + model?: string; + /** + * Language + * + * ISO 639-1 language code. + */ + language?: string; + /** + * Base Url + * + * OpenAI-compatible STT endpoint (Speaches, etc.). + */ + base_url?: string; +}; + +/** + * Local Models (Speaches) + * + * Self-hosted OpenAI-compatible local models. See the Speaches project for setup and supported backends. + */ +export type SpeachesTtsConfiguration = { + /** + * Provider + */ + provider?: 'speaches'; + /** + * Api Key + * + * Usually not required for self-hosted TTS. Leave blank unless enforced. + */ + api_key?: string | Array | null; + /** + * Model + * + * Model name as served by your TTS endpoint (e.g. Kokoro-FastAPI). + */ + model?: string; + /** + * Voice + * + * Voice ID for the TTS engine. + */ + voice?: string; + /** + * Base Url + * + * OpenAI-compatible TTS endpoint (Kokoro-FastAPI, etc.). + */ + base_url?: string; + /** + * Speed + * + * Speech speed (0.25 to 4.0). + */ + speed?: number; +}; + +/** + * Speechmatics + */ +export type SpeechmaticsSttConfiguration = { + /** + * Provider + */ + provider?: 'speechmatics'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Speechmatics operating point: 'standard' or 'enhanced'. + */ + model?: string; + /** + * Language + * + * ISO 639-1 language code. + */ + language?: string; +}; + /** * SuperuserWorkflowRunResponse */ @@ -3877,6 +5459,32 @@ export type TwilioConfigurationResponse = { from_numbers: Array; }; +/** + * Ultravox Realtime + */ +export type UltravoxRealtimeLlmConfiguration = { + /** + * Provider + */ + provider?: 'ultravox_realtime'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Ultravox realtime voice-agent model. + */ + model?: string; + /** + * Voice + * + * Ultravox voice name or voice ID. + */ + voice?: string; +}; + /** * UpdateCampaignRequest */ @@ -5277,16 +6885,6 @@ export type HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflow export type HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostData = { body?: never; - headers?: { - /** - * X-Vobiz-Signature - */ - 'x-vobiz-signature'?: string | null; - /** - * X-Vobiz-Timestamp - */ - 'x-vobiz-timestamp'?: string | null; - }; path: { /** * Workflow Run Id @@ -5319,16 +6917,6 @@ export type HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRu export type HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostData = { body?: never; - headers?: { - /** - * X-Vobiz-Signature - */ - 'x-vobiz-signature'?: string | null; - /** - * X-Vobiz-Timestamp - */ - 'x-vobiz-timestamp'?: string | null; - }; path: { /** * Workflow Run Id @@ -5361,16 +6949,6 @@ export type HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdP export type HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData = { body?: never; - headers?: { - /** - * X-Vobiz-Signature - */ - 'x-vobiz-signature'?: string | null; - /** - * X-Vobiz-Timestamp - */ - 'x-vobiz-timestamp'?: string | null; - }; path: { /** * Workflow Id @@ -8250,6 +9828,280 @@ export type GetTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsG export type GetTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGetResponse = GetTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGetResponses[keyof GetTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGetResponses]; +export type GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/organizations/model-configurations/v2/defaults'; +}; + +export type GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetError = GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetErrors[keyof GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetErrors]; + +export type GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/organizations/model-configurations/v2'; +}; + +export type GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetError = GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetErrors[keyof GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetErrors]; + +export type GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetResponses = { + /** + * Successful Response + */ + 200: OrganizationAiModelConfigurationResponse; +}; + +export type GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetResponse = GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetResponses[keyof GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetResponses]; + +export type SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutData = { + body: OrganizationAiModelConfigurationV2; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/organizations/model-configurations/v2'; +}; + +export type SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutError = SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutErrors[keyof SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutErrors]; + +export type SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutResponses = { + /** + * Successful Response + */ + 200: OrganizationAiModelConfigurationResponse; +}; + +export type SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutResponse = SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutResponses[keyof SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutResponses]; + +export type PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/organizations/model-configurations/v2/migration-preview'; +}; + +export type PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetError = PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetErrors[keyof PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetErrors]; + +export type PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: { + /** + * Force + */ + force?: boolean; + }; + url: '/api/v1/organizations/model-configurations/v2/migrate'; +}; + +export type MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostError = MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostErrors[keyof MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostErrors]; + +export type MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostResponses = { + /** + * Successful Response + */ + 200: OrganizationAiModelConfigurationResponse; +}; + +export type MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostResponse = MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostResponses[keyof MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostResponses]; + +export type GetPreferencesApiV1OrganizationsPreferencesGetData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/organizations/preferences'; +}; + +export type GetPreferencesApiV1OrganizationsPreferencesGetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetPreferencesApiV1OrganizationsPreferencesGetError = GetPreferencesApiV1OrganizationsPreferencesGetErrors[keyof GetPreferencesApiV1OrganizationsPreferencesGetErrors]; + +export type GetPreferencesApiV1OrganizationsPreferencesGetResponses = { + /** + * Successful Response + */ + 200: OrganizationPreferences; +}; + +export type GetPreferencesApiV1OrganizationsPreferencesGetResponse = GetPreferencesApiV1OrganizationsPreferencesGetResponses[keyof GetPreferencesApiV1OrganizationsPreferencesGetResponses]; + +export type SavePreferencesApiV1OrganizationsPreferencesPutData = { + body: OrganizationPreferences; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/organizations/preferences'; +}; + +export type SavePreferencesApiV1OrganizationsPreferencesPutErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type SavePreferencesApiV1OrganizationsPreferencesPutError = SavePreferencesApiV1OrganizationsPreferencesPutErrors[keyof SavePreferencesApiV1OrganizationsPreferencesPutErrors]; + +export type SavePreferencesApiV1OrganizationsPreferencesPutResponses = { + /** + * Successful Response + */ + 200: OrganizationPreferences; +}; + +export type SavePreferencesApiV1OrganizationsPreferencesPutResponse = SavePreferencesApiV1OrganizationsPreferencesPutResponses[keyof SavePreferencesApiV1OrganizationsPreferencesPutResponses]; + export type ListTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGetData = { body?: never; headers?: { @@ -9909,7 +11761,7 @@ export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses = { export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponse = GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses[keyof GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses]; -export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsData = { +export type OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsData = { body?: never; path: { /** @@ -9921,7 +11773,7 @@ export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsData = { url: '/api/v1/public/embed/config/{token}'; }; -export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors = { +export type OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsErrors = { /** * Not found */ @@ -9932,9 +11784,9 @@ export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors = { 422: HttpValidationError; }; -export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsError = OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors[keyof OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors]; +export type OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsError = OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsErrors[keyof OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsErrors]; -export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsResponses = { +export type OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsResponses = { /** * Successful Response */ diff --git a/ui/src/components/AIModelConfigurationV2Editor.tsx b/ui/src/components/AIModelConfigurationV2Editor.tsx new file mode 100644 index 00000000..13ee2edd --- /dev/null +++ b/ui/src/components/AIModelConfigurationV2Editor.tsx @@ -0,0 +1,419 @@ +"use client"; + +import { KeyRound, Save } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import type { OrganizationAiModelConfigurationV2 } from "@/client/types.gen"; +import { + type ProviderSchema, + type ServiceConfigurationDefaults, + ServiceConfigurationForm, + type ServiceSegment, +} from "@/components/ServiceConfigurationForm"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages"; + +type ModelMode = "dograh" | "byok"; + +interface DograhDefaults { + voices: string[]; + speeds: number[]; + languages: string[]; + defaults: { + voice: string; + speed: number; + language: string; + }; +} + +export interface ModelConfigurationDefaultsV2 { + dograh: DograhDefaults; + byok: { + pipeline: ServiceConfigurationDefaults; + realtime: { + realtime: Record; + llm: Record; + embeddings: Record; + default_providers: ServiceConfigurationDefaults["default_providers"]; + }; + }; +} + +interface DograhFormState { + api_key: string; + voice: string; + speed: number; + language: string; +} + +interface AIModelConfigurationV2EditorProps { + defaults: ModelConfigurationDefaultsV2; + configuration?: OrganizationAiModelConfigurationV2 | Record | null; + effectiveConfiguration?: Record | null; + onSave: (configuration: OrganizationAiModelConfigurationV2) => Promise; + submitLabel?: string; +} + +function firstApiKey(value: unknown): string { + if (Array.isArray(value)) return String(value[0] || ""); + return typeof value === "string" ? value : ""; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function isDograhEffectiveConfig(config: Record | null | undefined): boolean { + if (!config || config.is_realtime) return false; + const llm = asRecord(config.llm); + const tts = asRecord(config.tts); + const stt = asRecord(config.stt); + return llm?.provider === "dograh" && tts?.provider === "dograh" && stt?.provider === "dograh"; +} + +function byokDefaults(defaults: ModelConfigurationDefaultsV2): ServiceConfigurationDefaults { + return { + llm: defaults.byok.pipeline.llm, + tts: defaults.byok.pipeline.tts, + stt: defaults.byok.pipeline.stt, + embeddings: defaults.byok.pipeline.embeddings, + realtime: defaults.byok.realtime.realtime, + default_providers: defaults.byok.pipeline.default_providers, + }; +} + +function byokConfigToLegacyShape(config: Record | null): Record | null { + if (!config || config.mode !== "byok") return null; + const byok = asRecord(config.byok); + if (!byok) return null; + + if (byok.mode === "realtime") { + const realtime = asRecord(byok.realtime); + return { + is_realtime: true, + realtime: realtime?.realtime, + llm: realtime?.llm, + embeddings: realtime?.embeddings, + }; + } + + const pipeline = asRecord(byok.pipeline); + return { + is_realtime: false, + llm: pipeline?.llm, + tts: pipeline?.tts, + stt: pipeline?.stt, + embeddings: pipeline?.embeddings, + }; +} + +function effectiveConfigToLegacyShape(config: Record | null): Record | null { + if (!config) return null; + return { + is_realtime: Boolean(config.is_realtime), + llm: config.llm, + tts: config.tts, + stt: config.stt, + realtime: config.realtime, + embeddings: config.embeddings, + }; +} + +function emptyByokInitialConfig(): Record { + return { + is_realtime: false, + }; +} + +function getByokInitialConfig( + configuration: Record | null, + effectiveConfiguration: Record | null, +): Record { + const byokConfiguration = byokConfigToLegacyShape(configuration); + if (byokConfiguration) return byokConfiguration; + + if (configuration?.mode === "dograh" || isDograhEffectiveConfig(effectiveConfiguration)) { + return emptyByokInitialConfig(); + } + + return effectiveConfigToLegacyShape(effectiveConfiguration) || emptyByokInitialConfig(); +} + +function buildDograhState( + defaults: ModelConfigurationDefaultsV2, + configuration: Record | null, + effectiveConfiguration: Record | null, +): DograhFormState { + const fallback = defaults.dograh.defaults; + const configuredDograh = configuration?.mode === "dograh" ? asRecord(configuration.dograh) : null; + if (configuredDograh) { + return { + api_key: String(configuredDograh.api_key || ""), + voice: String(configuredDograh.voice || fallback.voice), + speed: Number(configuredDograh.speed || fallback.speed), + language: String(configuredDograh.language || fallback.language), + }; + } + + if (isDograhEffectiveConfig(effectiveConfiguration)) { + const llm = asRecord(effectiveConfiguration?.llm); + const tts = asRecord(effectiveConfiguration?.tts); + const stt = asRecord(effectiveConfiguration?.stt); + return { + api_key: firstApiKey(llm?.api_key || tts?.api_key || stt?.api_key), + voice: String(tts?.voice || fallback.voice), + speed: Number(tts?.speed || fallback.speed), + language: String(stt?.language || fallback.language), + }; + } + + return { + api_key: "", + voice: fallback.voice, + speed: fallback.speed, + language: fallback.language, + }; +} + +function preferredMode( + configuration: Record | null, + effectiveConfiguration: Record | null, +): ModelMode { + if (configuration?.mode === "dograh" || configuration?.mode === "byok") { + return configuration.mode; + } + return isDograhEffectiveConfig(effectiveConfiguration) ? "dograh" : "byok"; +} + +function hasRequiredApiKey( + service: ServiceSegment, + serviceConfiguration: Record, + defaults: ServiceConfigurationDefaults, +): boolean { + const provider = serviceConfiguration.provider as string | undefined; + if (!provider) return false; + const providerSchema = service === "realtime" + ? defaults.realtime?.[provider] + : defaults[service as "llm" | "tts" | "stt" | "embeddings"]?.[provider]; + const requiresApiKey = providerSchema?.required?.includes("api_key") ?? false; + if (!requiresApiKey) return true; + + const apiKey = serviceConfiguration.api_key; + if (Array.isArray(apiKey)) { + return apiKey.some((key) => typeof key === "string" && key.trim().length > 0); + } + return typeof apiKey === "string" && apiKey.trim().length > 0; +} + +function requireByokService( + config: Record, + service: ServiceSegment, + defaults: ServiceConfigurationDefaults, +): Record { + const serviceConfiguration = asRecord(config[service]); + if ( + !serviceConfiguration + || !serviceConfiguration.provider + || serviceConfiguration.provider === "dograh" + || !hasRequiredApiKey(service, serviceConfiguration, defaults) + ) { + throw new Error(`${service} configuration is required`); + } + return serviceConfiguration; +} + +function optionalByokService(config: Record, service: ServiceSegment): Record | undefined { + const serviceConfiguration = asRecord(config[service]); + if (!serviceConfiguration?.provider || serviceConfiguration.provider === "dograh") return undefined; + return serviceConfiguration; +} + +export function AIModelConfigurationV2Editor({ + defaults, + configuration, + effectiveConfiguration, + onSave, + submitLabel = "Save Configuration", +}: AIModelConfigurationV2EditorProps) { + const defaultsForByok = useMemo(() => byokDefaults(defaults), [defaults]); + const [mode, setMode] = useState("dograh"); + const [dograh, setDograh] = useState(() => ({ + api_key: "", + voice: defaults.dograh.defaults.voice, + speed: defaults.dograh.defaults.speed, + language: defaults.dograh.defaults.language, + })); + const [byokInitialConfig, setByokInitialConfig] = useState | null>(null); + const [isSavingDograh, setIsSavingDograh] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const rawConfiguration = asRecord(configuration); + const rawEffectiveConfiguration = asRecord(effectiveConfiguration); + setMode(preferredMode(rawConfiguration, rawEffectiveConfiguration)); + setDograh(buildDograhState(defaults, rawConfiguration, rawEffectiveConfiguration)); + setByokInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration)); + }, [configuration, defaults, effectiveConfiguration]); + + const saveDograhConfiguration = async () => { + setIsSavingDograh(true); + setError(null); + try { + await onSave({ + version: 2, + mode: "dograh", + dograh: { + api_key: dograh.api_key.trim(), + voice: dograh.voice, + speed: dograh.speed, + language: dograh.language, + }, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save configuration"); + } finally { + setIsSavingDograh(false); + } + }; + + const saveByokConfiguration = async (config: Record) => { + setError(null); + const isRealtime = Boolean(config.is_realtime); + const llm = requireByokService(config, "llm", defaultsForByok); + const embeddings = optionalByokService(config, "embeddings"); + const body: OrganizationAiModelConfigurationV2 = { + version: 2, + mode: "byok", + byok: isRealtime + ? { + mode: "realtime", + realtime: { + realtime: requireByokService(config, "realtime", defaultsForByok) as never, + llm: llm as never, + ...(embeddings ? { embeddings: embeddings as never } : {}), + }, + } + : { + mode: "pipeline", + pipeline: { + llm: llm as never, + tts: requireByokService(config, "tts", defaultsForByok) as never, + stt: requireByokService(config, "stt", defaultsForByok) as never, + ...(embeddings ? { embeddings: embeddings as never } : {}), + }, + }, + }; + + await onSave(body); + }; + + return ( +
+ {error && ( +
+ {error} +
+ )} + + setMode(value as ModelMode)} className="space-y-6"> + + Dograh + BYOK + + + +
+
+
+ +
+ + setDograh({ ...dograh, api_key: event.target.value })} + placeholder="Enter API key" + /> +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+ + + + +
+
+ ); +} diff --git a/ui/src/components/ModelConfigurationV2.tsx b/ui/src/components/ModelConfigurationV2.tsx new file mode 100644 index 00000000..8f0a06de --- /dev/null +++ b/ui/src/components/ModelConfigurationV2.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { ExternalLink, RefreshCw } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { + getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get, + getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet, + migrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePost, + saveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Put, +} from "@/client/sdk.gen"; +import type { + OrganizationAiModelConfigurationResponse, + OrganizationAiModelConfigurationV2, +} from "@/client/types.gen"; +import { AIModelConfigurationV2Editor, type ModelConfigurationDefaultsV2 } from "@/components/AIModelConfigurationV2Editor"; +import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useUserConfig } from "@/context/UserConfigContext"; +import { detailFromError } from "@/lib/apiError"; +import { useAuth } from "@/lib/auth"; + +export default function ModelConfigurationV2({ + docsUrl, + initialAction, +}: { + docsUrl?: string; + initialAction?: string; +}) { + const auth = useAuth(); + const { refreshConfig, saveUserConfig } = useUserConfig(); + const hasFetched = useRef(false); + const hasAppliedInitialMigrationAction = useRef(false); + + const [defaults, setDefaults] = useState(null); + const [response, setResponse] = useState(null); + const [loading, setLoading] = useState(true); + const [migrating, setMigrating] = useState(false); + const [migrationDialogOpen, setMigrationDialogOpen] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + const applyResponse = (nextResponse: OrganizationAiModelConfigurationResponse) => { + setResponse(nextResponse); + }; + + useEffect(() => { + if (auth.loading || !auth.user || hasFetched.current) return; + hasFetched.current = true; + + const load = async () => { + setLoading(true); + setError(null); + const [defaultsResult, configResult] = await Promise.all([ + getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet(), + getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get(), + ]); + + if (defaultsResult.error) { + setError(detailFromError(defaultsResult.error, "Failed to load model configuration defaults")); + setLoading(false); + return; + } + if (configResult.error) { + setError(detailFromError(configResult.error, "Failed to load model configuration")); + setLoading(false); + return; + } + + const nextDefaults = defaultsResult.data as ModelConfigurationDefaultsV2; + if (!nextDefaults || !configResult.data) { + setError("Failed to load model configuration"); + setLoading(false); + return; + } + setDefaults(nextDefaults); + applyResponse(configResult.data); + setLoading(false); + }; + + load(); + + }, [auth.loading, auth.user]); + + useEffect(() => { + if (hasAppliedInitialMigrationAction.current) return; + if (initialAction !== "migrate_to_v2") return; + if (loading || response?.source !== "legacy_user_v1") return; + hasAppliedInitialMigrationAction.current = true; + setMigrationDialogOpen(true); + }, [initialAction, loading, response?.source]); + + const saveConfiguration = async (configuration: OrganizationAiModelConfigurationV2) => { + if (!defaults) return; + setError(null); + setNotice(null); + + const result = await saveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Put({ + body: configuration, + }); + + if (result.error) { + throw new Error(detailFromError(result.error, "Failed to save model configuration")); + } + if (!result.data) { + throw new Error("Failed to save model configuration"); + } + + applyResponse(result.data); + await refreshConfig(); + setNotice("Model configuration saved"); + }; + + const migrateConfiguration = async () => { + if (!defaults) return; + setMigrating(true); + setError(null); + setNotice(null); + + const result = await migrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePost(); + if (result.error) { + setError(detailFromError(result.error, "Failed to migrate model configuration")); + } else if (!result.data) { + setError("Failed to migrate model configuration"); + } else { + applyResponse(result.data); + await refreshConfig(); + setNotice("Configuration migrated to v2"); + setMigrationDialogOpen(false); + } + setMigrating(false); + }; + + const migrationWarningDialog = ( + + + + Migrate model configuration to v2? + + Your configurations will be migrated to v2. After migration, check your global configuration and workflow model overrides, then run a test call to make sure everything is working. + + + + Cancel + + + + + ); + + if (loading) { + return ( +
+ + + +
+ ); + } + + const source = response?.source || "empty"; + + if (source !== "organization_v2") { + return ( +
+
+
+
+

AI Models Configuration

+ + {source === "legacy_user_v1" ? "legacy" : "v1"} + +
+

+ Configure your AI model, voice, and transcription services.{" "} + {docsUrl && ( + + Learn more + + )} +

+
+ {source === "legacy_user_v1" && ( + + )} +
+ + {error && ( +
+ {error} +
+ )} + {notice && ( +
+ {notice} +
+ )} + + { + setError(null); + setNotice(null); + await saveUserConfig(config as Parameters[0]); + await refreshConfig(); + if (defaults) { + const configResult = await getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get(); + if (configResult.data) { + applyResponse(configResult.data); + } + } + setNotice("Configuration saved"); + }} + /> + {migrationWarningDialog} +
+ ); + } + + return ( +
+
+
+

AI Models Configuration

+

+ Organization-scoped model settings.{" "} + {docsUrl && ( + + Learn more + + )} +

+
+
+ + {error && ( +
+ {error} +
+ )} + {notice && ( +
+ {notice} +
+ )} + + {defaults && response && ( + + )} + {migrationWarningDialog} +
+ ); +} diff --git a/ui/src/components/OrganizationPreferencesSection.tsx b/ui/src/components/OrganizationPreferencesSection.tsx new file mode 100644 index 00000000..522b22c7 --- /dev/null +++ b/ui/src/components/OrganizationPreferencesSection.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { Save } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; +import TimezoneSelect, { type ITimezoneOption } from "react-timezone-select"; +import { toast } from "sonner"; + +import { + getPreferencesApiV1OrganizationsPreferencesGet, + savePreferencesApiV1OrganizationsPreferencesPut, +} from "@/client/sdk.gen"; +import type { OrganizationPreferences } from "@/client/types.gen"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useUserConfig } from "@/context/UserConfigContext"; +import { detailFromError } from "@/lib/apiError"; +import { useAuth } from "@/lib/auth"; + +const emptyPreferences: OrganizationPreferences = { + test_phone_number: "", + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", +}; + +const timezoneSelectStyles = { + control: (base: Record, state: { isFocused: boolean }) => ({ + ...base, + minHeight: "36px", + fontSize: "14px", + backgroundColor: "var(--background)", + borderColor: state.isFocused ? "var(--ring)" : "var(--border)", + boxShadow: state.isFocused + ? "0 0 0 2px color-mix(in srgb, var(--ring) 20%, transparent)" + : "none", + "&:hover": { borderColor: "var(--border)" }, + }), + menu: (base: Record) => ({ + ...base, + zIndex: 9999, + backgroundColor: "var(--popover)", + border: "1px solid var(--border)", + boxShadow: + "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", + }), + menuList: (base: Record) => ({ + ...base, + backgroundColor: "var(--popover)", + padding: 0, + }), + option: ( + base: Record, + state: { isFocused: boolean; isSelected: boolean }, + ) => ({ + ...base, + backgroundColor: state.isSelected + ? "var(--accent)" + : state.isFocused + ? "var(--accent)" + : "var(--popover)", + color: "var(--foreground)", + cursor: "pointer", + "&:active": { backgroundColor: "var(--accent)" }, + }), + singleValue: (base: Record) => ({ + ...base, + color: "var(--foreground)", + }), + input: (base: Record) => ({ + ...base, + color: "var(--foreground)", + }), + placeholder: (base: Record) => ({ + ...base, + color: "var(--muted-foreground)", + }), + indicatorSeparator: (base: Record) => ({ + ...base, + backgroundColor: "var(--border)", + }), + dropdownIndicator: (base: Record) => ({ + ...base, + color: "var(--muted-foreground)", + "&:hover": { color: "var(--foreground)" }, + }), +}; + +function getTimezoneValue(tz: ITimezoneOption | string): string { + return typeof tz === "string" ? tz : tz.value; +} + +export function OrganizationPreferencesSection() { + const { user, loading: authLoading } = useAuth(); + const { refreshConfig } = useUserConfig(); + const timezoneSelectId = useId(); + const hasFetched = useRef(false); + + const [preferences, setPreferences] = + useState(emptyPreferences); + const [timezone, setTimezone] = useState( + emptyPreferences.timezone || "UTC", + ); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (authLoading || !user || hasFetched.current) { + return; + } + hasFetched.current = true; + void fetchPreferences(); + }, [authLoading, user]); + + async function fetchPreferences() { + setLoading(true); + try { + const result = + await getPreferencesApiV1OrganizationsPreferencesGet(); + + if (result.error) { + toast.error( + detailFromError( + result.error, + "Failed to load organization preferences", + ), + ); + return; + } + + const nextPreferences = result.data || emptyPreferences; + setPreferences({ + test_phone_number: nextPreferences.test_phone_number || "", + timezone: nextPreferences.timezone || emptyPreferences.timezone, + }); + setTimezone( + nextPreferences.timezone || emptyPreferences.timezone || "UTC", + ); + } catch { + toast.error("Failed to load organization preferences"); + } finally { + setLoading(false); + } + } + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + try { + const result = + await savePreferencesApiV1OrganizationsPreferencesPut( + { + body: { + test_phone_number: preferences.test_phone_number || null, + timezone: getTimezoneValue(timezone), + }, + }, + ); + + if (result.error) { + toast.error(detailFromError(result.error, "Failed to save preferences")); + return; + } + if (!result.data) { + toast.error("Failed to save preferences"); + return; + } + + setPreferences({ + test_phone_number: result.data.test_phone_number || "", + timezone: result.data.timezone || emptyPreferences.timezone, + }); + setTimezone(result.data.timezone || emptyPreferences.timezone || "UTC"); + await refreshConfig(); + toast.success("Preferences saved"); + } catch { + toast.error("Failed to save preferences"); + } finally { + setSaving(false); + } + } + + if (loading) { + return

Loading...

; + } + + return ( +
+

+ Set organization-wide defaults used by testing and scheduling flows. +

+
+
+ + + setPreferences({ + ...preferences, + test_phone_number: event.target.value, + }) + } + placeholder="+15551234567" + /> +
+
+ + +
+
+ +
+ ); +} diff --git a/ui/src/components/ServiceConfigurationForm.tsx b/ui/src/components/ServiceConfigurationForm.tsx index 34e71407..d082ca24 100644 --- a/ui/src/components/ServiceConfigurationForm.tsx +++ b/ui/src/components/ServiceConfigurationForm.tsx @@ -19,7 +19,7 @@ import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages"; import { useUserConfig } from "@/context/UserConfigContext"; import type { ModelOverrides } from "@/types/workflow-configurations"; -type ServiceSegment = "llm" | "tts" | "stt" | "embeddings" | "realtime"; +export type ServiceSegment = "llm" | "tts" | "stt" | "embeddings" | "realtime"; interface SchemaProperty { type?: string; @@ -35,7 +35,7 @@ interface SchemaProperty { docs_url?: string; } -interface ProviderSchema { +export interface ProviderSchema { title?: string; description?: string; provider_docs_url?: string; @@ -49,6 +49,15 @@ interface FormValues { [key: string]: string | number | boolean; } +export interface ServiceConfigurationDefaults { + llm: Record; + tts: Record; + stt: Record; + embeddings: Record; + realtime?: Record; + default_providers: Partial>; +} + const STANDARD_TABS: { key: ServiceSegment; label: string }[] = [ { key: "llm", label: "LLM" }, { key: "tts", label: "Voice" }, @@ -90,6 +99,8 @@ export interface ServiceConfigurationFormProps { onSave: (config: Record) => Promise; /** Text for the submit button. Defaults to "Save Configuration". */ submitLabel?: string; + configurationDefaults?: ServiceConfigurationDefaults | null; + initialConfig?: Record | null; } function getProviderDisplayName( @@ -117,6 +128,8 @@ export function ServiceConfigurationForm({ currentOverrides, onSave, submitLabel, + configurationDefaults, + initialConfig, }: ServiceConfigurationFormProps) { const [apiError, setApiError] = useState(null); const [isSaving, setIsSaving] = useState(false); @@ -165,15 +178,16 @@ export function ServiceConfigurationForm({ // Build effective config source: overlay overrides onto global config const configSource = useMemo(() => { - if (mode === 'global' || !currentOverrides) return userConfig; + const baseConfig = initialConfig ?? userConfig; + if (mode === 'global' || !currentOverrides) return baseConfig; // Merge overrides onto global config for form initialization - const merged = { ...userConfig } as Record; + const merged = { ...baseConfig } as Record; const overrideServices: (keyof ModelOverrides)[] = ["llm", "tts", "stt", "realtime"]; for (const svc of overrideServices) { if (svc === "is_realtime") continue; const overrideVal = currentOverrides[svc]; if (overrideVal && typeof overrideVal === "object") { - const globalVal = (userConfig as Record | null)?.[svc] as Record | undefined; + const globalVal = (baseConfig as Record | null)?.[svc] as Record | undefined; merged[svc] = { ...globalVal, ...overrideVal }; } } @@ -181,24 +195,35 @@ export function ServiceConfigurationForm({ merged.is_realtime = currentOverrides.is_realtime; } return merged as typeof userConfig; - }, [mode, userConfig, currentOverrides]); + }, [mode, userConfig, currentOverrides, initialConfig]); useEffect(() => { const fetchConfigurations = async () => { - const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet(); - if (!response.data) { - console.error("Failed to fetch configurations"); - return; + let defaultsData = configurationDefaults; + if (!defaultsData) { + const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet(); + if (!response.data) { + console.error("Failed to fetch configurations"); + return; + } + defaultsData = response.data as ServiceConfigurationDefaults; } - const data = response.data as Record; - const realtimeSchemas = (data.realtime || {}) as Record; + const realtimeSchemas = (defaultsData.realtime || {}) as Record; + const pickDefaultProvider = ( + service: ServiceSegment, + schemaMap: Record, + ) => { + const preferred = defaultsData.default_providers?.[service]; + if (preferred && schemaMap[preferred]) return preferred; + return Object.keys(schemaMap)[0] || ""; + }; setSchemas({ - llm: response.data.llm as Record, - tts: response.data.tts as Record, - stt: response.data.stt as Record, - embeddings: response.data.embeddings as Record, + llm: defaultsData.llm, + tts: defaultsData.tts, + stt: defaultsData.stt, + embeddings: defaultsData.embeddings, realtime: realtimeSchemas, }); @@ -210,10 +235,10 @@ export function ServiceConfigurationForm({ const defaultValues: Record = {}; const selectedProviders: Record = { - llm: response.data.default_providers.llm, - tts: response.data.default_providers.tts, - stt: response.data.default_providers.stt, - embeddings: response.data.default_providers.embeddings, + llm: pickDefaultProvider("llm", defaultsData.llm), + tts: pickDefaultProvider("tts", defaultsData.tts), + stt: pickDefaultProvider("stt", defaultsData.stt), + embeddings: pickDefaultProvider("embeddings", defaultsData.embeddings), realtime: "", }; @@ -237,7 +262,7 @@ export function ServiceConfigurationForm({ const schemaSource = service === "realtime" ? realtimeSchemas - : response.data![service as "llm" | "tts" | "stt" | "embeddings"] as Record | undefined; + : defaultsData[service as "llm" | "tts" | "stt" | "embeddings"] as Record | undefined; if (src?.provider) { Object.entries(src).forEach(([field, value]) => { @@ -296,7 +321,7 @@ export function ServiceConfigurationForm({ // Detect custom inputs const detectedCustomInput: Record = {}; - const allSchemas = { ...response.data, realtime: realtimeSchemas } as unknown as Record>; + const allSchemas = { ...defaultsData, realtime: realtimeSchemas } as unknown as Record>; (["llm", "tts", "stt", "embeddings", "realtime"] as ServiceSegment[]).forEach(service => { const provider = selectedProviders[service]; const providerSchema = allSchemas[service]?.[provider]; @@ -337,7 +362,7 @@ export function ServiceConfigurationForm({ }; fetchConfigurations(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reset, configSource]); + }, [reset, configSource, configurationDefaults]); // Reset voice when TTS model changes if the provider has model-dependent voice options const ttsModel = watch("tts_model"); diff --git a/ui/src/types/workflow-configurations.ts b/ui/src/types/workflow-configurations.ts index 3f05c658..7a267bdb 100644 --- a/ui/src/types/workflow-configurations.ts +++ b/ui/src/types/workflow-configurations.ts @@ -1,3 +1,5 @@ +import type { OrganizationAiModelConfigurationV2 } from "@/client/types.gen"; + export interface AmbientNoiseConfiguration { enabled: boolean; volume: number; @@ -64,6 +66,7 @@ export interface WorkflowConfigurations { voicemail_detection?: VoicemailDetectionConfiguration; context_compaction_enabled?: boolean; // Summarize context on node transitions to remove stale tool calls model_overrides?: ModelOverrides; // Per-workflow model configuration overrides + model_configuration_v2_override?: OrganizationAiModelConfigurationV2; // Full v2 model configuration override [key: string]: unknown; // Allow additional properties for future configurations } From 91ac460799d586cbe7b4855f557220ad5b99dd0a Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Tue, 9 Jun 2026 16:30:03 +0530 Subject: [PATCH 08/14] chore: finish renaming UserConfiguration --- api/db/organization_usage_client.py | 4 ++-- api/db/user_client.py | 20 +++++++++++-------- api/schemas/user_configuration.py | 4 ---- api/services/auth/depends.py | 8 ++++---- api/services/configuration/check_validity.py | 4 ++-- api/services/configuration/masking.py | 8 ++++---- api/services/configuration/merge.py | 10 +++++----- api/services/configuration/resolve.py | 10 +++++----- api/services/workflow/qa/llm_config.py | 2 +- .../integrations/_run_pipeline_helpers.py | 4 ++-- api/tests/test_ai_model_configuration_v2.py | 12 +++++------ api/tests/test_grok_realtime_wrapper.py | 4 ++-- api/tests/test_masked_key_rejection.py | 8 ++++---- api/tests/test_resolve_effective_config.py | 16 +++++++-------- api/tests/test_ultravox_realtime_wrapper.py | 4 ++-- api/tests/test_workflow_text_chat.py | 6 +++--- 16 files changed, 62 insertions(+), 62 deletions(-) diff --git a/api/db/organization_usage_client.py b/api/db/organization_usage_client.py index f69f1957..f845fc75 100644 --- a/api/db/organization_usage_client.py +++ b/api/db/organization_usage_client.py @@ -19,7 +19,7 @@ from api.db.models import ( WorkflowRunModel, ) from api.enums import OrganizationConfigurationKey -from api.schemas.user_configuration import UserConfiguration +from api.schemas.user_configuration import EffectiveAIModelConfiguration class OrganizationUsageClient(BaseDBClient): @@ -473,7 +473,7 @@ class OrganizationUsageClient(BaseDBClient): ) config_obj = config_result.scalar_one_or_none() if config_obj and config_obj.configuration: - user_config = UserConfiguration.model_validate( + user_config = EffectiveAIModelConfiguration.model_validate( config_obj.configuration ) if user_config.timezone and user_timezone == "UTC": diff --git a/api/db/user_client.py b/api/db/user_client.py index 0983a38d..9c4476f2 100644 --- a/api/db/user_client.py +++ b/api/db/user_client.py @@ -8,7 +8,7 @@ from sqlalchemy.future import select from api.db.base_client import BaseDBClient from api.db.models import UserConfigurationModel, UserModel -from api.schemas.user_configuration import UserConfiguration +from api.schemas.user_configuration import EffectiveAIModelConfiguration class UserClient(BaseDBClient): @@ -65,7 +65,9 @@ class UserClient(BaseDBClient): ) return result.scalars().first() - async def get_user_configurations(self, user_id: int) -> UserConfiguration: + async def get_user_configurations( + self, user_id: int + ) -> EffectiveAIModelConfiguration: async with self.async_session() as session: result = await session.execute( select(UserConfigurationModel).where( @@ -74,10 +76,10 @@ class UserClient(BaseDBClient): ) configuration_obj = result.scalars().first() if not configuration_obj: - return UserConfiguration() + return EffectiveAIModelConfiguration() try: - return UserConfiguration.model_validate( + return EffectiveAIModelConfiguration.model_validate( { **configuration_obj.configuration, "last_validated_at": configuration_obj.last_validated_at, @@ -90,11 +92,11 @@ class UserClient(BaseDBClient): f"Failed to validate user configuration for user {user_id}: {e}. " "Returning default configuration." ) - return UserConfiguration() + return EffectiveAIModelConfiguration() async def update_user_configuration( - self, user_id: int, configuration: UserConfiguration - ) -> UserConfiguration: + self, user_id: int, configuration: EffectiveAIModelConfiguration + ) -> EffectiveAIModelConfiguration: async with self.async_session() as session: result = await session.execute( select(UserConfigurationModel).where( @@ -115,7 +117,9 @@ class UserClient(BaseDBClient): await session.rollback() raise e await session.refresh(configuration_obj) - return UserConfiguration.model_validate(configuration_obj.configuration) + return EffectiveAIModelConfiguration.model_validate( + configuration_obj.configuration + ) async def update_user_configuration_last_validated_at(self, user_id: int) -> None: async with self.async_session() as session: diff --git a/api/schemas/user_configuration.py b/api/schemas/user_configuration.py index 1f8d8ff2..fc958a5b 100644 --- a/api/schemas/user_configuration.py +++ b/api/schemas/user_configuration.py @@ -31,7 +31,3 @@ class EffectiveAIModelConfiguration(BaseModel): if isinstance(realtime, dict) and not realtime.get("api_key"): data.pop("realtime", None) return data - - -# Backward-compatible alias for legacy persistence and existing call sites. -UserConfiguration = EffectiveAIModelConfiguration diff --git a/api/services/auth/depends.py b/api/services/auth/depends.py index 7a284926..d9e24684 100644 --- a/api/services/auth/depends.py +++ b/api/services/auth/depends.py @@ -9,7 +9,7 @@ from api.constants import AUTH_PROVIDER, DOGRAH_MPS_SECRET_KEY, MPS_API_URL from api.db import db_client from api.db.models import UserModel from api.enums import PostHogEvent -from api.schemas.user_configuration import UserConfiguration +from api.schemas.user_configuration import EffectiveAIModelConfiguration from api.services.auth.stack_auth import stackauth from api.services.configuration.registry import ServiceProviders from api.services.posthog_client import capture_event @@ -213,7 +213,7 @@ async def _handle_api_key_auth(api_key: str) -> UserModel: async def create_user_configuration_with_mps_key( user_id: int, organization_id: int, user_provider_id: str -) -> Optional[UserConfiguration]: +) -> Optional[EffectiveAIModelConfiguration]: """Create user configuration using MPS service key. Args: @@ -222,7 +222,7 @@ async def create_user_configuration_with_mps_key( user_provider_id: The user's provider ID (for created_by field) Returns: - UserConfiguration with MPS-provided API keys or None if failed + EffectiveAIModelConfiguration with MPS-provided API keys or None if failed """ async with httpx.AsyncClient() as client: @@ -285,7 +285,7 @@ async def create_user_configuration_with_mps_key( "model": "default", }, } - user_config = UserConfiguration(**configuration) + user_config = EffectiveAIModelConfiguration(**configuration) return user_config else: logger.warning( diff --git a/api/services/configuration/check_validity.py b/api/services/configuration/check_validity.py index 0e4da863..e8f5bfa7 100644 --- a/api/services/configuration/check_validity.py +++ b/api/services/configuration/check_validity.py @@ -9,7 +9,7 @@ from groq import Groq # except ImportError: # Neuphonic = None from api.schemas.user_configuration import ( - UserConfiguration, + EffectiveAIModelConfiguration, ) from api.services.configuration.registry import ServiceConfig, ServiceProviders from api.services.mps_service_key_client import mps_service_key_client @@ -64,7 +64,7 @@ class UserConfigurationValidator: async def validate( self, - configuration: UserConfiguration, + configuration: EffectiveAIModelConfiguration, organization_id: Optional[int] = None, created_by: Optional[str] = None, ) -> APIKeyStatusResponse: diff --git a/api/services/configuration/masking.py b/api/services/configuration/masking.py index adbc6215..c3fa4bfc 100644 --- a/api/services/configuration/masking.py +++ b/api/services/configuration/masking.py @@ -12,7 +12,7 @@ The rules are simple: import copy from typing import Any, Dict, Optional -from api.schemas.user_configuration import UserConfiguration +from api.schemas.user_configuration import EffectiveAIModelConfiguration from api.services.configuration.registry import ServiceConfig from api.services.integrations import get_node_secret_fields @@ -31,7 +31,7 @@ def contains_masked_key(value: str | list[str] | None) -> bool: return any(MASK_MARKER in k for k in keys) -def check_for_masked_keys(config: "UserConfiguration") -> None: +def check_for_masked_keys(config: "EffectiveAIModelConfiguration") -> None: """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) @@ -111,7 +111,7 @@ def resolve_masked_api_keys( # --------------------------------------------------------------------------- -# High-level helpers for UserConfiguration objects +# High-level helpers for EffectiveAIModelConfiguration objects # --------------------------------------------------------------------------- @@ -129,7 +129,7 @@ def _mask_service(service_cfg: Optional[ServiceConfig]) -> Optional[Dict[str, An return data -def mask_user_config(config: UserConfiguration) -> Dict[str, Any]: +def mask_user_config(config: EffectiveAIModelConfiguration) -> Dict[str, Any]: """Return a JSON-serialisable dict of *config* with every api_key masked.""" return { diff --git a/api/services/configuration/merge.py b/api/services/configuration/merge.py index f421648f..1b174ee8 100644 --- a/api/services/configuration/merge.py +++ b/api/services/configuration/merge.py @@ -7,7 +7,7 @@ stored, while honouring masked API keys. import copy from typing import Dict -from api.schemas.user_configuration import UserConfiguration +from api.schemas.user_configuration import EffectiveAIModelConfiguration from api.services.configuration.masking import ( MODEL_OVERRIDE_FIELDS, SERVICE_SECRET_FIELDS, @@ -66,9 +66,9 @@ def _merge_service_secret_fields( def merge_user_configurations( - existing: UserConfiguration, incoming_partial: Dict[str, dict] -) -> UserConfiguration: - """Merge *incoming_partial* onto *existing* and return a new UserConfiguration. + existing: EffectiveAIModelConfiguration, incoming_partial: Dict[str, dict] +) -> EffectiveAIModelConfiguration: + """Merge *incoming_partial* onto *existing* and return a new EffectiveAIModelConfiguration. *incoming_partial* is the body of the PUT request (already `model_dump()`ed or extracted via Pydantic `model_dump`). @@ -113,7 +113,7 @@ def merge_user_configurations( if "timezone" in incoming_partial: merged["timezone"] = incoming_partial["timezone"] - return UserConfiguration.model_validate(merged) + return EffectiveAIModelConfiguration.model_validate(merged) def merge_workflow_configuration_secrets( diff --git a/api/services/configuration/resolve.py b/api/services/configuration/resolve.py index 742e46b8..a33f5c09 100644 --- a/api/services/configuration/resolve.py +++ b/api/services/configuration/resolve.py @@ -4,13 +4,13 @@ from __future__ import annotations import copy -from api.schemas.user_configuration import UserConfiguration +from api.schemas.user_configuration import EffectiveAIModelConfiguration from api.services.configuration.registry import ( REGISTRY, ServiceType, ) -# Maps override key → (UserConfiguration field, ServiceType for registry lookup) +# Maps override key → (EffectiveAIModelConfiguration field, ServiceType for registry lookup) _SECTION_MAP: dict[str, ServiceType] = { "llm": ServiceType.LLM, "tts": ServiceType.TTS, @@ -36,7 +36,7 @@ _SECRET_FIELDS = ("api_key", "credentials", "aws_access_key", "aws_secret_key") def enrich_overrides_with_api_keys( model_overrides: dict, - user_config: UserConfiguration, + user_config: EffectiveAIModelConfiguration, ) -> dict: """Copy API keys from the global config into model_overrides where missing. @@ -74,9 +74,9 @@ def enrich_overrides_with_api_keys( def resolve_effective_config( - user_config: UserConfiguration, + user_config: EffectiveAIModelConfiguration, model_overrides: dict | None, -) -> UserConfiguration: +) -> EffectiveAIModelConfiguration: """Deep-merge workflow model_overrides onto global user config. - If model_overrides is None or empty, returns a copy of user_config unchanged. diff --git a/api/services/workflow/qa/llm_config.py b/api/services/workflow/qa/llm_config.py index ec3ae41d..9f4d06f8 100644 --- a/api/services/workflow/qa/llm_config.py +++ b/api/services/workflow/qa/llm_config.py @@ -42,7 +42,7 @@ async def resolve_llm_config( async def resolve_user_llm_config( workflow_run: WorkflowRunModel, ) -> tuple[str, str, str, dict]: - """Resolve the user's configured LLM (from UserConfiguration). + """Resolve the user's configured LLM (from EffectiveAIModelConfiguration). Returns: (provider, model, api_key, service_kwargs) tuple diff --git a/api/tests/integrations/_run_pipeline_helpers.py b/api/tests/integrations/_run_pipeline_helpers.py index a1e19b02..1a3251a0 100644 --- a/api/tests/integrations/_run_pipeline_helpers.py +++ b/api/tests/integrations/_run_pipeline_helpers.py @@ -203,7 +203,7 @@ async def create_workflow_run_rows( Returns: Tuple of (workflow_run, user, workflow). """ - from api.schemas.user_configuration import UserConfiguration + from api.schemas.user_configuration import EffectiveAIModelConfiguration org = OrganizationModel(provider_id=f"test-org-{provider_id_suffix}") async_session.add(org) @@ -218,7 +218,7 @@ async def create_workflow_run_rows( await db_session.update_user_configuration( user_id=user.id, - configuration=UserConfiguration.model_validate(USER_CONFIGURATION), + configuration=EffectiveAIModelConfiguration.model_validate(USER_CONFIGURATION), ) workflow = await db_session.create_workflow( diff --git a/api/tests/test_ai_model_configuration_v2.py b/api/tests/test_ai_model_configuration_v2.py index 023a330e..98f431e8 100644 --- a/api/tests/test_ai_model_configuration_v2.py +++ b/api/tests/test_ai_model_configuration_v2.py @@ -6,7 +6,7 @@ from api.schemas.ai_model_configuration import ( OrganizationAIModelConfigurationV2, compile_ai_model_configuration_v2, ) -from api.schemas.user_configuration import UserConfiguration +from api.schemas.user_configuration import EffectiveAIModelConfiguration from api.services.configuration.ai_model_configuration import ( WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY, check_for_masked_keys_in_ai_model_configuration_v2, @@ -142,7 +142,7 @@ def test_masked_v2_configuration_masks_nested_service_keys(): def test_legacy_all_dograh_pipeline_converts_to_dograh_v2(): - legacy = UserConfiguration( + legacy = EffectiveAIModelConfiguration( llm=DograhLLMService( provider="dograh", api_key=["mps-secret"], @@ -170,7 +170,7 @@ def test_legacy_all_dograh_pipeline_converts_to_dograh_v2(): def test_legacy_mixed_dograh_pipeline_converts_to_dograh_v2(): - legacy = UserConfiguration( + legacy = EffectiveAIModelConfiguration( llm=OpenAILLMService( provider="openai", api_key="sk-llm", @@ -202,7 +202,7 @@ def test_legacy_mixed_dograh_pipeline_converts_to_dograh_v2(): def test_legacy_byok_pipeline_converts_to_byok_v2(): - legacy = UserConfiguration( + legacy = EffectiveAIModelConfiguration( llm=OpenAILLMService( provider="openai", api_key="sk-llm", @@ -235,7 +235,7 @@ def test_legacy_byok_pipeline_converts_to_byok_v2(): def test_workflow_model_override_migration_removes_v1_override_and_sets_v2(): - base = UserConfiguration( + base = EffectiveAIModelConfiguration( llm=OpenAILLMService( provider="openai", api_key="sk-llm", @@ -279,7 +279,7 @@ def test_workflow_model_override_migration_removes_v1_override_and_sets_v2(): def test_workflow_model_override_migration_removes_invalid_v1_override_marker(): - base = UserConfiguration() + base = EffectiveAIModelConfiguration() workflow_configurations = { "ambient_noise_configuration": {"enabled": False}, "model_overrides": None, diff --git a/api/tests/test_grok_realtime_wrapper.py b/api/tests/test_grok_realtime_wrapper.py index f3cfa1a7..7f7359dc 100644 --- a/api/tests/test_grok_realtime_wrapper.py +++ b/api/tests/test_grok_realtime_wrapper.py @@ -7,7 +7,7 @@ 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.schemas.user_configuration import EffectiveAIModelConfiguration from api.services.configuration.registry import GrokRealtimeLLMConfiguration from api.services.pipecat.realtime.grok_realtime import ( DograhGrokRealtimeLLMService, @@ -120,7 +120,7 @@ async def test_completed_input_transcription_is_broadcast_as_finalized(): def test_factory_creates_dograh_grok_realtime_service(): - user_config = UserConfiguration( + user_config = EffectiveAIModelConfiguration( is_realtime=True, realtime=GrokRealtimeLLMConfiguration( provider="grok_realtime", diff --git a/api/tests/test_masked_key_rejection.py b/api/tests/test_masked_key_rejection.py index 9bf1f549..2012c60b 100644 --- a/api/tests/test_masked_key_rejection.py +++ b/api/tests/test_masked_key_rejection.py @@ -5,7 +5,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from api.routes.user import router -from api.schemas.user_configuration import UserConfiguration +from api.schemas.user_configuration import EffectiveAIModelConfiguration from api.services.auth.depends import get_user from api.services.configuration.masking import mask_key from api.services.configuration.registry import ( @@ -33,7 +33,7 @@ MASKED_KEY = mask_key(REAL_KEY) # "**************************cdef" def _existing_openai_config(): - return UserConfiguration( + return EffectiveAIModelConfiguration( llm=OpenAILLMService( provider="openai", api_key=REAL_KEY, @@ -111,7 +111,7 @@ class TestMaskedKeyRejection: client = TestClient(app) new_key = "AIzaSyNewRealKey12345678" - updated = UserConfiguration( + updated = EffectiveAIModelConfiguration( llm=GoogleLLMService( provider="google", api_key=new_key, @@ -178,7 +178,7 @@ class TestMaskedKeyRejection: real_credentials = '{"type":"service_account","project_id":"demo-project"}' masked_credentials = mask_key(real_credentials) - existing = UserConfiguration( + existing = EffectiveAIModelConfiguration( llm=GoogleVertexLLMConfiguration( provider="google_vertex", api_key=None, diff --git a/api/tests/test_resolve_effective_config.py b/api/tests/test_resolve_effective_config.py index c539c7c6..85afe30f 100644 --- a/api/tests/test_resolve_effective_config.py +++ b/api/tests/test_resolve_effective_config.py @@ -2,14 +2,14 @@ TDD tests for resolve_effective_config(). This function deep-merges workflow-level model_overrides onto the global -UserConfiguration. Fields not overridden inherit from global. +EffectiveAIModelConfiguration. Fields not overridden inherit from global. Module under test: api.services.configuration.resolve """ import pytest -from api.schemas.user_configuration import UserConfiguration +from api.schemas.user_configuration import EffectiveAIModelConfiguration from api.services.configuration.masking import ( contains_masked_key, mask_workflow_configurations, @@ -35,9 +35,9 @@ from api.services.configuration.resolve import ( @pytest.fixture -def global_config() -> UserConfiguration: +def global_config() -> EffectiveAIModelConfiguration: """A realistic global user configuration.""" - return UserConfiguration( + return EffectiveAIModelConfiguration( llm=OpenAILLMService( provider="openai", api_key="sk-global-llm", model="gpt-4.1" ), @@ -59,9 +59,9 @@ def global_config() -> UserConfiguration: @pytest.fixture -def global_config_realtime() -> UserConfiguration: +def global_config_realtime() -> EffectiveAIModelConfiguration: """Global config with realtime enabled.""" - return UserConfiguration( + return EffectiveAIModelConfiguration( llm=OpenAILLMService( provider="openai", api_key="sk-global-llm", model="gpt-4.1" ), @@ -302,7 +302,7 @@ class TestRealtimeOverride: class TestOverrideOnNullGlobal: def test_override_stt_when_global_is_none(self): """When global has no STT config, override creates one from scratch.""" - config = UserConfiguration( + config = EffectiveAIModelConfiguration( llm=OpenAILLMService(provider="openai", api_key="sk-key", model="gpt-4.1"), stt=None, tts=None, @@ -325,7 +325,7 @@ class TestOverrideOnNullGlobal: def test_override_realtime_when_global_is_none(self): """Realtime section can be created from override even if global has none.""" - config = UserConfiguration( + config = EffectiveAIModelConfiguration( llm=OpenAILLMService(provider="openai", api_key="sk-key", model="gpt-4.1"), is_realtime=False, realtime=None, diff --git a/api/tests/test_ultravox_realtime_wrapper.py b/api/tests/test_ultravox_realtime_wrapper.py index 1034b8d4..65b062b6 100644 --- a/api/tests/test_ultravox_realtime_wrapper.py +++ b/api/tests/test_ultravox_realtime_wrapper.py @@ -10,7 +10,7 @@ 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.schemas.user_configuration import EffectiveAIModelConfiguration from api.services.configuration.registry import UltravoxRealtimeLLMConfiguration from api.services.pipecat.realtime.ultravox_realtime import ( _RESUMPTION_USER_MESSAGE, @@ -430,7 +430,7 @@ async def test_receive_messages_reports_unexpected_websocket_close(): def test_factory_creates_dograh_ultravox_realtime_service(): - user_config = UserConfiguration( + user_config = EffectiveAIModelConfiguration( is_realtime=True, realtime=UltravoxRealtimeLLMConfiguration( provider="ultravox_realtime", diff --git a/api/tests/test_workflow_text_chat.py b/api/tests/test_workflow_text_chat.py index 219f333f..e69e7c0a 100644 --- a/api/tests/test_workflow_text_chat.py +++ b/api/tests/test_workflow_text_chat.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from api.db.models import OrganizationModel, UserModel -from api.schemas.user_configuration import UserConfiguration +from api.schemas.user_configuration import EffectiveAIModelConfiguration from api.tests.integrations._run_pipeline_helpers import USER_CONFIGURATION from pipecat.tests import MockLLMService @@ -38,7 +38,7 @@ async def _create_user_and_workflow( await db_session.update_user_configuration( user_id=user.id, - configuration=UserConfiguration.model_validate(USER_CONFIGURATION), + configuration=EffectiveAIModelConfiguration.model_validate(USER_CONFIGURATION), ) workflow = await db_session.create_workflow( @@ -1041,7 +1041,7 @@ async def test_text_chat_session_creation_requires_selected_org_scope( await db_session.update_user_configuration( user_id=user.id, - configuration=UserConfiguration.model_validate(USER_CONFIGURATION), + configuration=EffectiveAIModelConfiguration.model_validate(USER_CONFIGURATION), ) workflow = await db_session.create_workflow( From e79c3e26f0f169df67982673b001d6e32f57ba95 Mon Sep 17 00:00:00 2001 From: Manasseh <112127696+manasseh-zw@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:48:13 +0200 Subject: [PATCH 09/14] feat: add Cartesia Sonic 3.5 TTS model (#423) --- api/services/configuration/registry.py | 4 +- .../test_cartesia_tts_service_factory.py | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 api/tests/test_cartesia_tts_service_factory.py diff --git a/api/services/configuration/registry.py b/api/services/configuration/registry.py index 9fa9ee3b..a0a74546 100644 --- a/api/services/configuration/registry.py +++ b/api/services/configuration/registry.py @@ -911,7 +911,7 @@ class DograhTTSService(BaseTTSConfiguration): speed: float = Field(default=1.0, ge=0.5, le=2.0, description="Speed of the voice.") -CARTESIA_TTS_MODELS = ["sonic-3"] +CARTESIA_TTS_MODELS = ["sonic-3.5", "sonic-3"] @register_tts @@ -919,7 +919,7 @@ class CartesiaTTSConfiguration(BaseTTSConfiguration): model_config = CARTESIA_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.CARTESIA] = ServiceProviders.CARTESIA model: str = Field( - default="sonic-3", + default="sonic-3.5", description="Cartesia TTS model.", json_schema_extra={"examples": CARTESIA_TTS_MODELS}, ) diff --git a/api/tests/test_cartesia_tts_service_factory.py b/api/tests/test_cartesia_tts_service_factory.py new file mode 100644 index 00000000..bcc12359 --- /dev/null +++ b/api/tests/test_cartesia_tts_service_factory.py @@ -0,0 +1,43 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from api.services.configuration.registry import ( + CARTESIA_TTS_MODELS, + CartesiaTTSConfiguration, + ServiceProviders, +) +from api.services.pipecat.service_factory import create_tts_service + + +def test_cartesia_tts_configuration_defaults_to_sonic_3_5(): + config = CartesiaTTSConfiguration(api_key="test-key") + + assert config.provider == ServiceProviders.CARTESIA + assert config.model == "sonic-3.5" + assert CARTESIA_TTS_MODELS == ["sonic-3.5", "sonic-3"] + + +def test_create_cartesia_tts_service_passes_selected_model(): + user_config = SimpleNamespace( + tts=SimpleNamespace( + provider=ServiceProviders.CARTESIA.value, + api_key="test-key", + model="sonic-3.5", + voice="test-voice-id", + speed=1.0, + volume=1.0, + ) + ) + audio_config = SimpleNamespace( + transport_out_sample_rate=24000, + transport_in_sample_rate=16000, + ) + + with patch("api.services.pipecat.service_factory.CartesiaTTSService") as mock_service: + create_tts_service(user_config, audio_config) + + assert mock_service.call_count == 1 + kwargs = mock_service.call_args.kwargs + assert kwargs["api_key"] == "test-key" + assert kwargs["settings"].model == "sonic-3.5" + assert kwargs["settings"].voice == "test-voice-id" From a81cccc68b3600666dcb003d4d946b60a7cf3c13 Mon Sep 17 00:00:00 2001 From: Mubashir R <112580905+Mubashirrrr@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:19:36 +0500 Subject: [PATCH 10/14] fix(telephony): handle Cloudonix CDR webhooks missing session/disposition (#407) * fix(telephony): handle Cloudonix CDR payloads missing session/disposition The /cloudonix/cdr webhook is a public, unauthenticated endpoint that parses arbitrary external JSON. It dereferenced cdr_data.get("session").get("token") unconditionally, so a partial or malformed CDR payload that omits "session" (or sends "session": null) raised AttributeError -> HTTP 500. The existing "Missing call_id field" guard right below it was unreachable because the crash happened first. StatusCallbackRequest.from_cloudonix_cdr had the same fragility plus a second one: data.get("disposition", "") returns None when the key is present-but-null, and None.upper() then crashed. Navigate both fields defensively so missing/null values fall through to the intended graceful error path instead of crashing. Adds regression tests covering missing session, null session, null disposition, and the well-formed mapping path. Co-Authored-By: Claude Opus 4.8 * fix: harden cloudonix cdr session validation * chore: renamed test path --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: Sabiha Khan --- .../telephony/providers/cloudonix/routes.py | 3 +- api/services/telephony/status_processor.py | 6 +- api/tests/telephony/cloudonix/test_routes.py | 119 ++++++++++++++++++ 3 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 api/tests/telephony/cloudonix/test_routes.py diff --git a/api/services/telephony/providers/cloudonix/routes.py b/api/services/telephony/providers/cloudonix/routes.py index cd4758a6..facf4bdb 100644 --- a/api/services/telephony/providers/cloudonix/routes.py +++ b/api/services/telephony/providers/cloudonix/routes.py @@ -103,7 +103,8 @@ async def handle_cloudonix_cdr(request: Request): return {"status": "error", "message": "Missing domain field"} # Extract call_id to find workflow run - call_id = cdr_data.get("session").get("token") + session = cdr_data.get("session") + call_id = session.get("token") if isinstance(session, dict) else None logger.info(f"Cloudonix CDR data for call id {call_id} - {cdr_data}") if not call_id: logger.warning("Cloudonix CDR missing call_id field") diff --git a/api/services/telephony/status_processor.py b/api/services/telephony/status_processor.py index f1f1f86a..b93a0d9e 100644 --- a/api/services/telephony/status_processor.py +++ b/api/services/telephony/status_processor.py @@ -114,11 +114,13 @@ class StatusCallbackRequest(BaseModel): "NOANSWER": "no-answer", } - disposition = data.get("disposition", "") + disposition = data.get("disposition") or "" status = disposition_map.get(disposition.upper(), disposition.lower()) + session = data.get("session") + call_id = session.get("token") if isinstance(session, dict) else "" return cls( - call_id=data.get("session").get("token"), + call_id=call_id or "", status=status, from_number=data.get("from"), to_number=data.get("to"), diff --git a/api/tests/telephony/cloudonix/test_routes.py b/api/tests/telephony/cloudonix/test_routes.py new file mode 100644 index 00000000..e22b0672 --- /dev/null +++ b/api/tests/telephony/cloudonix/test_routes.py @@ -0,0 +1,119 @@ +"""Regression tests for Cloudonix CDR webhook handling. + +A Cloudonix CDR webhook is a public, unauthenticated endpoint that parses +arbitrary external JSON. A partial / malformed payload (missing ``session``, +or a ``null`` ``session`` / ``disposition``) must produce a graceful error +response, not an unhandled ``AttributeError`` (HTTP 500). +""" + +from unittest.mock import AsyncMock, patch + +import pytest +from starlette.requests import Request + +from api.services.telephony.providers.cloudonix.routes import handle_cloudonix_cdr +from api.services.telephony.status_processor import StatusCallbackRequest + + +def _json_request(body: bytes) -> Request: + 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": "/api/v1/telephony/cloudonix/cdr", + "query_string": b"", + "headers": [(b"content-type", b"application/json")], + }, + receive, + ) + + +@pytest.mark.asyncio +async def test_cdr_route_handles_payload_without_session(): + """A CDR payload missing the ``session`` object returns a graceful error + instead of raising ``AttributeError`` on ``None.get("token")``.""" + request = _json_request(b'{"domain": "acme.cloudonix.io", "disposition": "ANSWER"}') + + with patch( + "api.services.telephony.providers.cloudonix.routes.db_client" + ) as db_client: + db_client.get_workflow_run_by_call_id = AsyncMock(return_value=None) + + result = await handle_cloudonix_cdr(request) + + assert result == {"status": "error", "message": "Missing call_id field"} + + +@pytest.mark.asyncio +async def test_cdr_route_handles_null_session(): + """A CDR payload with an explicit ``null`` session is handled gracefully.""" + request = _json_request(b'{"domain": "acme.cloudonix.io", "session": null}') + + with patch( + "api.services.telephony.providers.cloudonix.routes.db_client" + ) as db_client: + db_client.get_workflow_run_by_call_id = AsyncMock(return_value=None) + + result = await handle_cloudonix_cdr(request) + + assert result == {"status": "error", "message": "Missing call_id field"} + + +@pytest.mark.asyncio +async def test_cdr_route_handles_string_session(): + """A CDR payload with a non-object session is handled gracefully.""" + request = _json_request(b'{"domain": "acme.cloudonix.io", "session": "abc"}') + + with patch( + "api.services.telephony.providers.cloudonix.routes.db_client" + ) as db_client: + db_client.get_workflow_run_by_call_id = AsyncMock(return_value=None) + + result = await handle_cloudonix_cdr(request) + + assert result == {"status": "error", "message": "Missing call_id field"} + + +def test_from_cloudonix_cdr_tolerates_missing_session_and_disposition(): + """``from_cloudonix_cdr`` must not crash on a partial CDR payload.""" + # Missing both session and disposition. + req = StatusCallbackRequest.from_cloudonix_cdr({"domain": "acme.cloudonix.io"}) + assert req.call_id == "" + assert req.status == "" + + # Explicit null values. + req = StatusCallbackRequest.from_cloudonix_cdr( + {"session": None, "disposition": None} + ) + assert req.call_id == "" + assert req.status == "" + + +def test_from_cloudonix_cdr_tolerates_string_session(): + """``from_cloudonix_cdr`` treats a non-object session as missing call_id.""" + req = StatusCallbackRequest.from_cloudonix_cdr( + {"session": "abc", "disposition": "ANSWER"} + ) + assert req.call_id == "" + assert req.status == "completed" + + +def test_from_cloudonix_cdr_maps_disposition_and_session_token(): + """Normal, well-formed CDR payloads still map correctly.""" + req = StatusCallbackRequest.from_cloudonix_cdr( + { + "session": {"token": "abc123"}, + "disposition": "BUSY", + "from": "+15551230001", + "to": "+15551230002", + "billsec": 12, + } + ) + assert req.call_id == "abc123" + assert req.status == "busy" + assert req.duration == "12" From 97d71034808d3ed662f11ef6720197e2ff01c519 Mon Sep 17 00:00:00 2001 From: Abhishek Date: Thu, 11 Jun 2026 11:51:30 +0530 Subject: [PATCH 11/14] feat: add a start docker script (#426) Adds a start docker script so that we have required values like OSS_JWT_SECRET at runtime --- README.md | 4 +- docs/deployment/docker.mdx | 23 ++++-- docs/deployment/update.mdx | 14 +++- docs/getting-started/index.mdx | 17 ++-- docs/getting-started/prerequisites.mdx | 17 +++- scripts/start_docker.ps1 | 95 ++++++++++++++++++++++ scripts/start_docker.sh | 108 +++++++++++++++++++++++++ 7 files changed, 258 insertions(+), 20 deletions(-) create mode 100644 scripts/start_docker.ps1 create mode 100755 scripts/start_docker.sh diff --git a/README.md b/README.md index 5b3c90ba..be5f83b1 100644 --- a/README.md +++ b/README.md @@ -75,13 +75,13 @@ An honest comparison on the axes that matter most to teams evaluating voice AI p ##### Download and setup Dograh on your Local Machine > **Note** -> We collect anonymous usage data to improve the product. You can opt out by setting the `ENABLE_TELEMETRY` to `false` in the below command. +> We collect anonymous usage data to improve the product. You can opt out by setting `ENABLE_TELEMETRY=false` before running the startup script. > **Note** > If you wish to run the platform on a remote server instead, checkout our [Documentation](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 +curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml && curl -o start_docker.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.sh && chmod +x start_docker.sh && ./start_docker.sh ``` > **Note** diff --git a/docs/deployment/docker.mdx b/docs/deployment/docker.mdx index d0f29594..28a6a8ba 100644 --- a/docs/deployment/docker.mdx +++ b/docs/deployment/docker.mdx @@ -20,18 +20,27 @@ Watch the video tutorial below for a step-by-step walkthrough of setting up Dogr allowFullScreen > -For local development and testing, you can run Dograh AI directly on your machine using Docker with a single command. +For local development and testing, you can run Dograh AI directly on your machine using Docker with a small startup script. ### Quick Start -Run this single command to download and start Dograh AI: +Download the compose file and starter script, then confirm the prompt to start Dograh AI: -```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 + +```bash macOS/Linux +curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml && curl -o start_docker.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.sh && chmod +x start_docker.sh && ./start_docker.sh ``` +```powershell Windows +Invoke-WebRequest -OutFile docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml +Invoke-WebRequest -OutFile start_docker.ps1 https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.ps1 +.\start_docker.ps1 +``` + -This command: +This setup: - Downloads the latest docker-compose.yaml +- Creates `OSS_JWT_SECRET` in `.env` if one does not already exist +- Prompts before running Docker Compose - Starts all required services including PostgreSQL, Redis, MinIO, API, and UI - Pulls the latest images automatically @@ -43,7 +52,7 @@ http://localhost:3010 ``` -You can disable telemetry by setting `ENABLE_TELEMETRY=false` in the command above. +You can disable telemetry by setting `ENABLE_TELEMETRY=false` before running `./start_docker.sh` or `.\start_docker.ps1`. ### Troubleshooting WebRTC Connectivity @@ -72,7 +81,7 @@ The script will prompt you for: - 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`, 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: +It creates `docker-compose.yaml`, a `.env` file with JWT and TURN credentials, and the small helper bundle that `dograh-init` uses to render coturn config at startup. Start the stack with the `local-turn` profile so coturn comes up alongside the other services: ```bash docker compose --profile local-turn up --pull always diff --git a/docs/deployment/update.mdx b/docs/deployment/update.mdx index c307b89b..b2897788 100644 --- a/docs/deployment/update.mdx +++ b/docs/deployment/update.mdx @@ -67,12 +67,20 @@ The script overwrites `docker-compose.yaml` and the remote helper bundle (`remot ## Local deployment -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: +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 — stop the stack, then use the startup script to preserve `OSS_JWT_SECRET` and pull new images: -```bash + +```bash macOS/Linux +curl -o start_docker.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.sh && chmod +x start_docker.sh docker compose down -docker compose up --pull always +./start_docker.sh ``` +```powershell Windows +Invoke-WebRequest -OutFile start_docker.ps1 https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.ps1 +docker compose down +.\start_docker.ps1 +``` + To pin a specific version instead of `latest`, edit `docker-compose.yaml` and change both `image:` lines for `api` and `ui` to the same tag (e.g. `:1.28.0` — Docker image tags use bare semver, no `v` prefix), then run the commands above. diff --git a/docs/getting-started/index.mdx b/docs/getting-started/index.mdx index 7dfebff4..09252089 100644 --- a/docs/getting-started/index.mdx +++ b/docs/getting-started/index.mdx @@ -24,16 +24,23 @@ Watch the following video to learn about Dograh’s capabilities. ## Setting up -Get the platform up and running using Docker with a single command on your local computer. If you are looking to deploy the platform on a server different than your local machine, please check the [Deployment](deployment/introduction) section. +Get the platform up and running using Docker with a small startup script on your local computer. If you are looking to deploy the platform on a server different than your local machine, please check the [Deployment](deployment/introduction) section. -We collect anonymous usage data to improve the product. You can opt out by setting the `ENABLE_TELEMETRY` to `false` in the below command. +We collect anonymous usage data to improve the product. You can opt out by setting `ENABLE_TELEMETRY=false` before running the startup script. -```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 + +```bash macOS/Linux +curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml && curl -o start_docker.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.sh && chmod +x start_docker.sh && ./start_docker.sh ``` +```powershell Windows +Invoke-WebRequest -OutFile docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml +Invoke-WebRequest -OutFile start_docker.ps1 https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.ps1 +.\start_docker.ps1 +``` + Please check [Prerequisites](getting-started/prerequisites) for the system requirements and [Troubleshooting](getting-started/troubleshooting) for common issues. ## Next Steps -You can see how to configure the inference provider in [Inference Provider](/configurations/inference-providers). \ No newline at end of file +You can see how to configure the inference provider in [Inference Provider](/configurations/inference-providers). diff --git a/docs/getting-started/prerequisites.mdx b/docs/getting-started/prerequisites.mdx index 48e60cdf..aebd6aad 100644 --- a/docs/getting-started/prerequisites.mdx +++ b/docs/getting-started/prerequisites.mdx @@ -74,12 +74,23 @@ Dograh images are available from two registries: - **GitHub Container Registry (Default)**: `ghcr.io/dograh-hq` - Recommended for most users - **Docker Hub**: `dograhai` - Alternative registry -To use a specific registry, set the `REGISTRY` environment variable: +To use a specific registry, set the `REGISTRY` environment variable when running the startup script: + ```bash # Using GitHub Container Registry (recommended) -REGISTRY=ghcr.io/dograh-hq docker compose up --pull always +REGISTRY=ghcr.io/dograh-hq ./start_docker.sh # Using Docker Hub -REGISTRY=dograhai docker compose up --pull always +REGISTRY=dograhai ./start_docker.sh ``` +```powershell Windows +# Using GitHub Container Registry (recommended) +$env:REGISTRY = 'ghcr.io/dograh-hq' +.\start_docker.ps1 + +# Using Docker Hub +$env:REGISTRY = 'dograhai' +.\start_docker.ps1 +``` + diff --git a/scripts/start_docker.ps1 b/scripts/start_docker.ps1 new file mode 100644 index 00000000..e039bb9a --- /dev/null +++ b/scripts/start_docker.ps1 @@ -0,0 +1,95 @@ +$ErrorActionPreference = 'Stop' + +$EnvFile = '.env' +$Registry = if ([string]::IsNullOrEmpty($env:REGISTRY)) { 'ghcr.io/dograh-hq' } else { $env:REGISTRY } +$EnableTelemetry = if ([string]::IsNullOrEmpty($env:ENABLE_TELEMETRY)) { 'true' } else { $env:ENABLE_TELEMETRY } +$Utf8NoBom = [System.Text.UTF8Encoding]::new($false) + +function New-HexSecret { + $bytes = [byte[]]::new(32) + [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes) + return -join ($bytes | ForEach-Object { $_.ToString('x2') }) +} + +function Get-DotEnvValue { + param( + [string]$Path, + [string]$Key + ) + + if (-not (Test-Path $Path)) { + return $null + } + + $resolvedPath = (Resolve-Path $Path).Path + foreach ($line in [System.IO.File]::ReadLines($resolvedPath)) { + if ($line.StartsWith("$Key=")) { + return $line.Substring($Key.Length + 1) + } + } + + return $null +} + +function Set-DotEnvValue { + param( + [string]$Path, + [string]$Key, + [string]$Value + ) + + $lines = New-Object System.Collections.Generic.List[string] + $updated = $false + + if (Test-Path $Path) { + $resolvedPath = (Resolve-Path $Path).Path + foreach ($line in [System.IO.File]::ReadLines($resolvedPath)) { + if ($line.StartsWith("$Key=")) { + $lines.Add("$Key=$Value") + $updated = $true + } else { + $lines.Add($line) + } + } + } + + if (-not $updated) { + $lines.Add("$Key=$Value") + } + + [System.IO.File]::WriteAllLines((Join-Path (Get-Location) $Path), $lines, $Utf8NoBom) +} + +if (-not (Test-Path 'docker-compose.yaml')) { + Write-Error 'docker-compose.yaml not found. Download it first, then re-run this script.' + exit 1 +} + +$existingSecret = Get-DotEnvValue -Path $EnvFile -Key 'OSS_JWT_SECRET' +if ([string]::IsNullOrEmpty($existingSecret)) { + Set-DotEnvValue -Path $EnvFile -Key 'OSS_JWT_SECRET' -Value (New-HexSecret) + Write-Host "Created OSS_JWT_SECRET in $EnvFile." +} else { + Write-Host "OSS_JWT_SECRET is already set in $EnvFile." +} + +Write-Host '' +Write-Host "Docker registry: $Registry" +Write-Host "Telemetry enabled: $EnableTelemetry" +Write-Host '' +Write-Host 'This will run:' +Write-Host " `$env:REGISTRY = '$Registry'; `$env:ENABLE_TELEMETRY = '$EnableTelemetry'; docker compose up --pull always" +Write-Host '' + +$answer = Read-Host 'Start Dograh now? [Y/n]' +if ($answer -match '^[Nn]') { + Write-Host 'Dograh was not started.' + exit 0 +} + +$env:REGISTRY = $Registry +$env:ENABLE_TELEMETRY = $EnableTelemetry +docker compose up --pull always +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} diff --git a/scripts/start_docker.sh b/scripts/start_docker.sh new file mode 100755 index 00000000..9cb96750 --- /dev/null +++ b/scripts/start_docker.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -e + +ENV_FILE=".env" +REGISTRY="${REGISTRY:-ghcr.io/dograh-hq}" +ENABLE_TELEMETRY="${ENABLE_TELEMETRY:-true}" + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +generate_secret() { + if command -v python3 >/dev/null 2>&1 && python3 -c 'import secrets; print(secrets.token_hex(32))'; then + return + fi + + if command -v openssl >/dev/null 2>&1 && openssl rand -hex 32; then + return + fi + + if [[ -r /dev/urandom ]] && command -v od >/dev/null 2>&1 && command -v tr >/dev/null 2>&1 && od -An -N32 -tx1 /dev/urandom | tr -d ' \n'; then + return + fi + + fail "Could not generate OSS_JWT_SECRET. Install python3 or openssl, or set OSS_JWT_SECRET manually in .env." +} + +dotenv_value() { + local key=$1 + local line + + [[ -f "$ENV_FILE" ]] || return 1 + + while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + "$key"=*) + printf '%s\n' "${line#*=}" + return 0 + ;; + esac + done < "$ENV_FILE" + + return 1 +} + +set_dotenv_value() { + local key=$1 + local value=$2 + local tmp_file="${ENV_FILE}.tmp.$$" + local line + local updated=false + + if [[ -f "$ENV_FILE" ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + "$key"=*) + printf '%s=%s\n' "$key" "$value" + updated=true + ;; + *) + printf '%s\n' "$line" + ;; + esac + done < "$ENV_FILE" > "$tmp_file" + + if [[ "$updated" != "true" ]]; then + printf '%s=%s\n' "$key" "$value" >> "$tmp_file" + fi + + mv "$tmp_file" "$ENV_FILE" + else + printf '%s=%s\n' "$key" "$value" > "$ENV_FILE" + fi +} + +[[ -f docker-compose.yaml ]] || fail "docker-compose.yaml not found. Download it first, then re-run this script." + +existing_secret="$(dotenv_value OSS_JWT_SECRET || true)" +if [[ -z "$existing_secret" ]]; then + set_dotenv_value OSS_JWT_SECRET "$(generate_secret)" + echo "Created OSS_JWT_SECRET in $ENV_FILE." +else + echo "OSS_JWT_SECRET is already set in $ENV_FILE." +fi + +echo "" +echo "Docker registry: $REGISTRY" +echo "Telemetry enabled: $ENABLE_TELEMETRY" +echo "" +echo "This will run:" +echo " REGISTRY=$REGISTRY ENABLE_TELEMETRY=$ENABLE_TELEMETRY docker compose up --pull always" +echo "" + +if [[ ! -t 0 ]]; then + echo "Run the command above from an interactive shell to start Dograh." + exit 0 +fi + +read -r -p "Start Dograh now? [Y/n]: " answer +case "$answer" in + [Nn]*) + echo "Dograh was not started." + exit 0 + ;; +esac + +REGISTRY="$REGISTRY" ENABLE_TELEMETRY="$ENABLE_TELEMETRY" docker compose up --pull always From 1f1149f4d5b222c1098edf34a1dc7da82b4e32ed Mon Sep 17 00:00:00 2001 From: Abhishek Date: Fri, 12 Jun 2026 14:55:30 +0530 Subject: [PATCH 12/14] feat: billing and credit management v2 (#429) * feat: use mps generated correlation ID * chore: update pipecat submodule * feat: add credit purchase URL * feat: carve out billing page and show credit ledger * feat: deprecate dograh based quota tracking * fix: remove cost calculation from dograh codebase * fix: create mps account on migrate to v2 * chore: update pipecat --- .../2159d4ac431a_added_quota_tables.py | 27 +- ...c425d3445750_add_columns_in_usage_table.py | 10 +- api/db/campaign_client.py | 27 +- api/db/db_client.py | 2 +- api/db/filters.py | 6 +- api/db/models.py | 53 ++- api/db/organization_usage_client.py | 235 +--------- api/db/user_client.py | 2 +- api/db/workflow_run_client.py | 24 +- api/routes/knowledge_base.py | 16 +- api/routes/organization.py | 34 +- api/routes/organization_usage.py | 265 ++++++++++- api/routes/workflow.py | 30 +- api/schemas/ai_model_configuration.py | 26 +- api/schemas/user_configuration.py | 33 -- api/services/auth/depends.py | 24 +- .../configuration/ai_model_configuration.py | 2 +- api/services/configuration/check_validity.py | 20 +- api/services/configuration/masking.py | 2 +- api/services/configuration/merge.py | 2 +- api/services/configuration/resolve.py | 2 +- .../gen_ai/embedding/openai_service.py | 3 + api/services/managed_model_services.py | 98 +++++ api/services/mps_billing.py | 23 + api/services/mps_service_key_client.py | 229 ++++++++++ api/services/organization_context.py | 50 +++ api/services/pipecat/run_pipeline.py | 44 +- api/services/pipecat/service_factory.py | 23 +- api/services/pricing/README.md | 76 ---- api/services/pricing/__init__.py | 9 - api/services/pricing/cost_calculator.py | 228 ---------- api/services/pricing/embeddings.py | 44 -- api/services/pricing/llm.py | 143 ------ api/services/pricing/models.py | 89 ---- api/services/pricing/registry.py | 18 - api/services/pricing/run_usage_response.py | 13 - api/services/pricing/stt.py | 26 -- api/services/pricing/tts.py | 30 -- api/services/pricing/workflow_run_cost.py | 230 ---------- api/services/reports/run_report.py | 4 +- .../telephony/providers/vonage/routes.py | 28 -- api/services/workflow/pipecat_engine.py | 4 + api/services/workflow/run_usage_response.py | 41 ++ api/services/workflow/text_chat_runner.py | 22 +- .../workflow/text_chat_session_service.py | 20 - api/services/workflow/tools/knowledge_base.py | 15 + api/services/workflow_run_billing.py | 111 +++++ api/tasks/arq.py | 6 +- api/tasks/knowledge_base_processing.py | 20 +- api/tasks/s3_upload.py | 113 +---- api/tasks/workflow_completion.py | 121 +++++ .../integrations/_run_pipeline_helpers.py | 2 +- api/tests/test_ai_model_configuration_v2.py | 166 ++++++- api/tests/test_auth_depends.py | 68 +++ api/tests/test_cost_calculator.py | 31 -- api/tests/test_dograh_managed_correlation.py | 110 +++++ api/tests/test_grok_realtime_wrapper.py | 6 +- api/tests/test_masked_key_rejection.py | 2 +- api/tests/test_mps_service_key_client.py | 314 +++++++++++++ api/tests/test_organization_usage_billing.py | 99 +++++ api/tests/test_resolve_effective_config.py | 2 +- api/tests/test_run_usage_response.py | 2 +- api/tests/test_ultravox_realtime_wrapper.py | 6 +- api/tests/test_workflow_run_billing.py | 212 +++++++++ api/tests/test_workflow_run_cost.py | 181 -------- api/tests/test_workflow_text_chat.py | 14 +- pipecat | 2 +- ui/src/app/billing/page.tsx | 416 ++++++++++++++++++ ui/src/app/layout.tsx | 6 +- ui/src/app/reports/page.tsx | 6 +- ui/src/app/usage/page.tsx | 87 +--- ui/src/client/index.ts | 4 +- ui/src/client/sdk.gen.ts | 25 +- ui/src/client/types.gen.ts | 342 +++++++++++++- .../AIModelConfigurationV2Editor.tsx | 92 ++-- .../components/ServiceConfigurationForm.tsx | 46 +- ui/src/components/layout/AppSidebar.tsx | 5 + ui/src/context/OrgConfigContext.tsx | 192 ++++++++ ui/src/context/UserConfigContext.tsx | 204 +-------- ui/src/lib/apiError.ts | 27 +- 80 files changed, 3335 insertions(+), 2057 deletions(-) delete mode 100644 api/schemas/user_configuration.py create mode 100644 api/services/managed_model_services.py create mode 100644 api/services/mps_billing.py create mode 100644 api/services/organization_context.py delete mode 100644 api/services/pricing/README.md delete mode 100644 api/services/pricing/__init__.py delete mode 100644 api/services/pricing/cost_calculator.py delete mode 100644 api/services/pricing/embeddings.py delete mode 100644 api/services/pricing/llm.py delete mode 100644 api/services/pricing/models.py delete mode 100644 api/services/pricing/registry.py delete mode 100644 api/services/pricing/run_usage_response.py delete mode 100644 api/services/pricing/stt.py delete mode 100644 api/services/pricing/tts.py delete mode 100644 api/services/pricing/workflow_run_cost.py create mode 100644 api/services/workflow/run_usage_response.py create mode 100644 api/services/workflow_run_billing.py create mode 100644 api/tasks/workflow_completion.py create mode 100644 api/tests/test_auth_depends.py delete mode 100644 api/tests/test_cost_calculator.py create mode 100644 api/tests/test_dograh_managed_correlation.py create mode 100644 api/tests/test_organization_usage_billing.py create mode 100644 api/tests/test_workflow_run_billing.py delete mode 100644 api/tests/test_workflow_run_cost.py create mode 100644 ui/src/app/billing/page.tsx create mode 100644 ui/src/context/OrgConfigContext.tsx diff --git a/api/alembic/versions/2159d4ac431a_added_quota_tables.py b/api/alembic/versions/2159d4ac431a_added_quota_tables.py index 51efc4cc..24326e4b 100644 --- a/api/alembic/versions/2159d4ac431a_added_quota_tables.py +++ b/api/alembic/versions/2159d4ac431a_added_quota_tables.py @@ -18,6 +18,9 @@ branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +DEPRECATED_QUOTA_COMMENT = "Deprecated. MPS owns quota and credit ledger state." + + def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### # 1) Create the `quota_type` enum *before* we add the column that references it. @@ -34,7 +37,12 @@ def upgrade() -> None: sa.Column("organization_id", sa.Integer(), nullable=False), sa.Column("period_start", sa.DateTime(), nullable=False), sa.Column("period_end", sa.DateTime(), nullable=False), - sa.Column("quota_dograh_tokens", sa.Integer(), nullable=False), + sa.Column( + "quota_dograh_tokens", + sa.Integer(), + nullable=False, + comment=DEPRECATED_QUOTA_COMMENT, + ), sa.Column("used_dograh_tokens", sa.Integer(), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), @@ -63,7 +71,11 @@ def upgrade() -> None: op.add_column( "organizations", sa.Column( - "quota_type", quota_type_enum, nullable=False, server_default="monthly" + "quota_type", + quota_type_enum, + nullable=False, + server_default="monthly", + comment=DEPRECATED_QUOTA_COMMENT, ), ) op.add_column( @@ -73,6 +85,7 @@ def upgrade() -> None: sa.Integer(), nullable=False, server_default=sa.text("0"), + comment=DEPRECATED_QUOTA_COMMENT, ), ) op.add_column( @@ -82,10 +95,17 @@ def upgrade() -> None: sa.Integer(), nullable=False, server_default=sa.text("LEAST(EXTRACT(DAY FROM CURRENT_DATE)::int, 28)"), + comment=DEPRECATED_QUOTA_COMMENT, ), ) op.add_column( - "organizations", sa.Column("quota_start_date", sa.DateTime(), nullable=True) + "organizations", + sa.Column( + "quota_start_date", + sa.DateTime(), + nullable=True, + comment=DEPRECATED_QUOTA_COMMENT, + ), ) op.add_column( "organizations", @@ -94,6 +114,7 @@ def upgrade() -> None: sa.Boolean(), nullable=False, server_default=sa.text("false"), + comment=DEPRECATED_QUOTA_COMMENT, ), ) # ### end Alembic commands ### diff --git a/api/alembic/versions/c425d3445750_add_columns_in_usage_table.py b/api/alembic/versions/c425d3445750_add_columns_in_usage_table.py index 998e7123..cbd9c654 100644 --- a/api/alembic/versions/c425d3445750_add_columns_in_usage_table.py +++ b/api/alembic/versions/c425d3445750_add_columns_in_usage_table.py @@ -18,6 +18,9 @@ branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +DEPRECATED_QUOTA_COMMENT = "Deprecated. MPS owns quota and credit ledger state." + + def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.add_column( @@ -26,7 +29,12 @@ def upgrade() -> None: ) op.add_column( "organization_usage_cycles", - sa.Column("quota_amount_usd", sa.Float(), nullable=True), + sa.Column( + "quota_amount_usd", + sa.Float(), + nullable=True, + comment=DEPRECATED_QUOTA_COMMENT, + ), ) # ### end Alembic commands ### diff --git a/api/db/campaign_client.py b/api/db/campaign_client.py index d9ff2bae..7729a2ea 100644 --- a/api/db/campaign_client.py +++ b/api/db/campaign_client.py @@ -9,6 +9,7 @@ from api.db.base_client import BaseDBClient from api.db.filters import apply_workflow_run_filters, get_workflow_run_order_clause from api.db.models import CampaignModel, QueuedRunModel, WorkflowRunModel from api.schemas.workflow import WorkflowRunResponseSchema +from api.services.workflow.run_usage_response import format_public_cost_info class CampaignClient(BaseDBClient): @@ -215,26 +216,9 @@ class CampaignClient(BaseDBClient): "is_completed": run.is_completed, "recording_url": run.recording_url, "transcript_url": run.transcript_url, - "cost_info": { - "dograh_token_usage": ( - run.cost_info.get("dograh_token_usage") - if run.cost_info - and "dograh_token_usage" in run.cost_info - else round( - float(run.cost_info.get("total_cost_usd", 0)) * 100, - 2, - ) - if run.cost_info and "total_cost_usd" in run.cost_info - else 0 - ), - "call_duration_seconds": int( - round(run.cost_info.get("call_duration_seconds") or 0) - ) - if run.cost_info - else None, - } - if run.cost_info - else None, + "cost_info": format_public_cost_info( + run.cost_info, run.usage_info + ), "definition_id": run.definition_id, "initial_context": run.initial_context, "gathered_context": run.gathered_context, @@ -662,7 +646,7 @@ class CampaignClient(BaseDBClient): async with self.async_session() as session: conditions = [ WorkflowRunModel.is_completed.is_(True), - WorkflowRunModel.cost_info["call_duration_seconds"] + WorkflowRunModel.usage_info["call_duration_seconds"] .as_string() .isnot(None), ] @@ -685,6 +669,7 @@ class CampaignClient(BaseDBClient): WorkflowRunModel.initial_context, WorkflowRunModel.gathered_context, WorkflowRunModel.cost_info, + WorkflowRunModel.usage_info, WorkflowRunModel.public_access_token, ) .where(*conditions) diff --git a/api/db/db_client.py b/api/db/db_client.py index de98cf19..15d1c108 100644 --- a/api/db/db_client.py +++ b/api/db/db_client.py @@ -53,7 +53,7 @@ class DBClient( - UserClient: handles user and user configuration operations - OrganizationClient: handles organization operations - OrganizationConfigurationClient: handles organization configuration operations - - OrganizationUsageClient: handles organization usage and quota operations + - OrganizationUsageClient: handles organization usage reporting aggregates - IntegrationClient: handles integration operations - WorkflowTemplateClient: handles workflow template operations - CampaignClient: handles campaign operations diff --git a/api/db/filters.py b/api/db/filters.py index e960d724..cd30b144 100644 --- a/api/db/filters.py +++ b/api/db/filters.py @@ -25,7 +25,7 @@ def get_workflow_run_order_clause( """ # Determine sort column if sort_by == "duration": - sort_column = WorkflowRunModel.cost_info.op("->>")( + sort_column = WorkflowRunModel.usage_info.op("->>")( "call_duration_seconds" ).cast(Float) else: @@ -43,7 +43,7 @@ def get_workflow_run_order_clause( ATTRIBUTE_FIELD_MAPPING = { "dateRange": "created_at", "dispositionCode": "gathered_context.mapped_call_disposition", - "duration": "cost_info.call_duration_seconds", + "duration": "usage_info.call_duration_seconds", "status": "is_completed", "tokenUsage": "cost_info.total_cost_usd", "runId": "id", @@ -208,7 +208,7 @@ def apply_workflow_run_filters( min_val = value.get("min") max_val = value.get("max") - if field == "cost_info.call_duration_seconds": + if field == "usage_info.call_duration_seconds": # Use ->> operator for compatibility with all PostgreSQL versions # (subscript [] only works in PostgreSQL 14+) duration_text = cast(WorkflowRunModel.usage_info, JSONB).op("->>")( diff --git a/api/db/models.py b/api/db/models.py index c61cb03d..696cb6e6 100644 --- a/api/db/models.py +++ b/api/db/models.py @@ -97,22 +97,44 @@ class OrganizationModel(Base): provider_id = Column(String, unique=True, index=True, nullable=False) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) - # Quota fields + # Deprecated: MPS owns quota and credit ledger state. quota_type = Column( Enum("monthly", "annual", name="quota_type"), nullable=False, default="monthly", server_default=text("'monthly'::quota_type"), + comment="Deprecated. MPS owns quota and credit ledger state.", + info={"deprecated": True}, ) quota_dograh_tokens = Column( - Integer, nullable=False, default=0, server_default=text("0") + Integer, + nullable=False, + default=0, + server_default=text("0"), + comment="Deprecated. MPS owns quota and credit ledger state.", + info={"deprecated": True}, ) quota_reset_day = Column( - Integer, nullable=False, default=1, server_default=text("1") - ) # 1-28, only for monthly - quota_start_date = Column(DateTime(timezone=True), nullable=True) # Only for annual + Integer, + nullable=False, + default=1, + server_default=text("1"), + comment="Deprecated. MPS owns quota and credit ledger state.", + info={"deprecated": True}, + ) + quota_start_date = Column( + DateTime(timezone=True), + nullable=True, + comment="Deprecated. MPS owns quota and credit ledger state.", + info={"deprecated": True}, + ) quota_enabled = Column( - Boolean, nullable=False, default=False, server_default=text("false") + Boolean, + nullable=False, + default=False, + server_default=text("false"), + comment="Deprecated. MPS owns quota and credit ledger state.", + info={"deprecated": True}, ) price_per_second_usd = Column(Float, nullable=True) @@ -593,8 +615,9 @@ class WorkflowRunTextSessionModel(Base): class OrganizationUsageCycleModel(Base): """ - This model is used to track the usage of Dograh tokens for an organization for a given usage - cycle. + This model is used to track reporting aggregates for an organization for a given + usage cycle. Quota fields on this model are deprecated; MPS owns quota and + credit ledger state. """ __tablename__ = "organization_usage_cycles" @@ -603,14 +626,24 @@ class OrganizationUsageCycleModel(Base): organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False) period_start = Column(DateTime(timezone=True), nullable=False) period_end = Column(DateTime(timezone=True), nullable=False) - quota_dograh_tokens = Column(Integer, nullable=False) + quota_dograh_tokens = Column( + Integer, + nullable=False, + comment="Deprecated. MPS owns quota and credit ledger state.", + info={"deprecated": True}, + ) used_dograh_tokens = Column(Float, nullable=False, default=0) total_duration_seconds = Column( Integer, nullable=False, default=0, server_default=text("0") ) # New USD tracking fields used_amount_usd = Column(Float, nullable=True, default=0) - quota_amount_usd = Column(Float, nullable=True) + quota_amount_usd = Column( + Float, + nullable=True, + comment="Deprecated. MPS owns quota and credit ledger state.", + info={"deprecated": True}, + ) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) updated_at = Column( DateTime(timezone=True), diff --git a/api/db/organization_usage_client.py b/api/db/organization_usage_client.py index f845fc75..dfca0538 100644 --- a/api/db/organization_usage_client.py +++ b/api/db/organization_usage_client.py @@ -19,11 +19,11 @@ from api.db.models import ( WorkflowRunModel, ) from api.enums import OrganizationConfigurationKey -from api.schemas.user_configuration import EffectiveAIModelConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration class OrganizationUsageClient(BaseDBClient): - """Client for managing organization usage and quota operations.""" + """Client for managing organization usage reporting aggregates.""" async def get_or_create_current_cycle( self, organization_id: int, session=None @@ -49,14 +49,7 @@ class OrganizationUsageClient(BaseDBClient): self, organization_id: int, session, commit: bool ) -> OrganizationUsageCycleModel: """Internal implementation for get_or_create_current_cycle.""" - # Get organization to determine quota type - org_result = await session.execute( - select(OrganizationModel).where(OrganizationModel.id == organization_id) - ) - org = org_result.scalar_one() - - # Calculate current period - period_start, period_end = self._calculate_current_period(org) + period_start, period_end = self._calculate_current_period() # Try to get existing cycle cycle_result = await session.execute( @@ -78,7 +71,8 @@ class OrganizationUsageClient(BaseDBClient): organization_id=organization_id, period_start=period_start, period_end=period_end, - quota_dograh_tokens=org.quota_dograh_tokens, + # Deprecated non-null column retained for historical schema compatibility. + quota_dograh_tokens=0, ) # Handle concurrent inserts gracefully stmt = stmt.on_conflict_do_nothing( @@ -102,95 +96,9 @@ class OrganizationUsageClient(BaseDBClient): ) return cycle_result.scalar_one() - async def check_and_reserve_quota( - self, organization_id: int, estimated_tokens: int = 0 - ) -> bool: - """ - Check if organization has sufficient quota and optionally reserve tokens. - Returns True if quota is available, False otherwise. - - This method is fully atomic and safe for concurrent access from multiple processes. - """ - async with self.async_session() as session: - # Get organization - org_result = await session.execute( - select(OrganizationModel).where(OrganizationModel.id == organization_id) - ) - org = org_result.scalar_one_or_none() - - if not org or not org.quota_enabled: - # No quota enforcement if not enabled - return True - - # Get or create current cycle within the same session/transaction - cycle = await self._get_or_create_current_cycle_impl( - organization_id, session, commit=False - ) - - # Atomic check and update with row-level lock - result = await session.execute( - select(OrganizationUsageCycleModel) - .where( - and_( - OrganizationUsageCycleModel.id == cycle.id, - OrganizationUsageCycleModel.used_dograh_tokens - + estimated_tokens - <= OrganizationUsageCycleModel.quota_dograh_tokens, - ) - ) - .with_for_update(skip_locked=False) - ) - - cycle_locked = result.scalar_one_or_none() - if cycle_locked: - # Update the usage atomically - cycle_locked.used_dograh_tokens += estimated_tokens - await session.commit() - return True - - return False - - async def update_usage_after_run( - self, - organization_id: int, - 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. - - This method is fully atomic and safe for concurrent access from multiple processes. - """ - async with self.async_session() as session: - # Get or create current cycle within the same session/transaction - cycle = await self._get_or_create_current_cycle_impl( - organization_id, session, commit=False - ) - - # Acquire a row-level lock for atomic update - result = await session.execute( - select(OrganizationUsageCycleModel) - .where(OrganizationUsageCycleModel.id == cycle.id) - .with_for_update(skip_locked=False) - ) - cycle_locked = result.scalar_one() - - # Update usage atomically - cycle_locked.used_dograh_tokens += actual_tokens - cycle_locked.total_duration_seconds += int(round(duration_seconds)) - - # Update USD amount if provided - if charge_usd is not None: - if cycle_locked.used_amount_usd is None: - cycle_locked.used_amount_usd = 0 - cycle_locked.used_amount_usd += charge_usd - - await session.commit() - async def get_current_usage(self, organization_id: int) -> dict: - """Get current period usage information.""" + """Get current reporting-period usage information.""" async with self.async_session() as session: - # Get organization org_result = await session.execute( select(OrganizationModel).where(OrganizationModel.id == organization_id) ) @@ -201,42 +109,19 @@ class OrganizationUsageClient(BaseDBClient): organization_id, session, commit=False ) - # Calculate next refresh date - if org.quota_type == "monthly": - next_refresh = cycle.period_end + relativedelta(days=1) - else: # annual - next_refresh = cycle.period_end + relativedelta(days=1) - result = { "period_start": cycle.period_start.isoformat(), "period_end": cycle.period_end.isoformat(), "used_dograh_tokens": cycle.used_dograh_tokens, - "quota_dograh_tokens": cycle.quota_dograh_tokens, - "percentage_used": ( - round( - (cycle.used_dograh_tokens / cycle.quota_dograh_tokens) * 100, 2 - ) - if cycle.quota_dograh_tokens > 0 - else 0 - ), - "next_refresh_date": next_refresh.date().isoformat(), - "quota_enabled": org.quota_enabled, "total_duration_seconds": cycle.total_duration_seconds, } # Add USD fields if organization has pricing if org.price_per_second_usd is not None: result["used_amount_usd"] = cycle.used_amount_usd or 0 - result["quota_amount_usd"] = cycle.quota_amount_usd result["currency"] = "USD" result["price_per_second_usd"] = org.price_per_second_usd - # Calculate percentage based on USD if available - if cycle.quota_amount_usd and cycle.quota_amount_usd > 0: - result["percentage_used"] = round( - ((cycle.used_amount_usd or 0) / cycle.quota_amount_usd) * 100, 2 - ) - return result async def get_usage_history( @@ -256,7 +141,7 @@ class OrganizationUsageClient(BaseDBClient): .join(UserModel, WorkflowModel.user_id == UserModel.id) .where( UserModel.selected_organization_id == organization_id, - WorkflowRunModel.cost_info.isnot(None), + WorkflowRunModel.usage_info.isnot(None), ) .order_by(WorkflowRunModel.created_at.desc()) ) @@ -309,19 +194,8 @@ class OrganizationUsageClient(BaseDBClient): total_tokens = 0 total_duration_seconds = 0 for run in runs: - if run.cost_info: - # Try to get dograh_token_usage first (new format) - dograh_tokens = run.cost_info.get("dograh_token_usage", 0) - # If not present, calculate from total_cost_usd (old format) - if dograh_tokens == 0 and "total_cost_usd" in run.cost_info: - dograh_tokens = round( - float(run.cost_info["total_cost_usd"]) * 100, 2 - ) - # Get call duration - call_duration = run.cost_info.get("call_duration_seconds", 0) - else: - dograh_tokens = 0 - call_duration = 0 + dograh_tokens = 0 + call_duration = (run.usage_info or {}).get("call_duration_seconds", 0) total_tokens += dograh_tokens total_duration_seconds += int(round(call_duration)) @@ -395,13 +269,14 @@ class OrganizationUsageClient(BaseDBClient): WorkflowRunModel.initial_context, WorkflowRunModel.gathered_context, WorkflowRunModel.cost_info, + WorkflowRunModel.usage_info, WorkflowRunModel.public_access_token, ) .join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id) .join(UserModel, WorkflowModel.user_id == UserModel.id) .where( UserModel.selected_organization_id == organization_id, - WorkflowRunModel.cost_info.isnot(None), + WorkflowRunModel.usage_info.isnot(None), ) .order_by(WorkflowRunModel.created_at.desc()) ) @@ -473,11 +348,11 @@ class OrganizationUsageClient(BaseDBClient): ) config_obj = config_result.scalar_one_or_none() if config_obj and config_obj.configuration: - user_config = EffectiveAIModelConfiguration.model_validate( + effective_config = EffectiveAIModelConfiguration.model_validate( config_obj.configuration ) - if user_config.timezone and user_timezone == "UTC": - user_timezone = user_config.timezone + if effective_config.timezone and user_timezone == "UTC": + user_timezone = effective_config.timezone # Validate timezone string try: @@ -496,7 +371,7 @@ class OrganizationUsageClient(BaseDBClient): select( date_expr.label("date"), func.sum( - WorkflowRunModel.cost_info["call_duration_seconds"].as_float() + WorkflowRunModel.usage_info["call_duration_seconds"].as_float() ).label("total_seconds"), func.count(WorkflowRunModel.id).label("call_count"), ) @@ -545,83 +420,11 @@ class OrganizationUsageClient(BaseDBClient): "currency": "USD", } - async def update_organization_quota( - self, - organization_id: int, - quota_type: str, - quota_dograh_tokens: int, - quota_reset_day: Optional[int] = None, - quota_start_date: Optional[datetime] = None, - ) -> OrganizationModel: - """Update organization quota settings.""" - async with self.async_session() as session: - result = await session.execute( - select(OrganizationModel).where(OrganizationModel.id == organization_id) - ) - org = result.scalar_one() - - org.quota_type = quota_type - org.quota_dograh_tokens = quota_dograh_tokens - org.quota_enabled = True - - if quota_type == "monthly" and quota_reset_day: - org.quota_reset_day = quota_reset_day - elif quota_type == "annual" and quota_start_date: - org.quota_start_date = quota_start_date - - await session.commit() - await session.refresh(org) - return org - - def _calculate_current_period( - self, org: OrganizationModel - ) -> tuple[datetime, datetime]: - """Calculate the current billing period based on organization settings.""" + def _calculate_current_period(self) -> tuple[datetime, datetime]: + """Calculate the current calendar-month reporting period.""" now = datetime.now(timezone.utc) - if org.quota_type == "monthly": - # Find the start of the current billing month - reset_day = org.quota_reset_day - - # Handle month boundaries - if now.day >= reset_day: - period_start = now.replace( - day=reset_day, hour=0, minute=0, second=0, microsecond=0 - ) - else: - # Previous month - period_start = (now - relativedelta(months=1)).replace( - day=reset_day, hour=0, minute=0, second=0, microsecond=0 - ) - - # End is one month later minus 1 second - period_end = ( - period_start + relativedelta(months=1) - relativedelta(seconds=1) - ) - - else: # annual - if not org.quota_start_date: - # Default to calendar year - period_start = now.replace( - month=1, day=1, hour=0, minute=0, second=0, microsecond=0 - ) - period_end = ( - period_start + relativedelta(years=1) - relativedelta(seconds=1) - ) - else: - # Find current annual period - start_date = org.quota_start_date.replace(tzinfo=timezone.utc) - years_diff = now.year - start_date.year - - # Adjust for whether we've passed the anniversary - if now.month < start_date.month or ( - now.month == start_date.month and now.day < start_date.day - ): - years_diff -= 1 - - period_start = start_date + relativedelta(years=years_diff) - period_end = ( - period_start + relativedelta(years=1) - relativedelta(seconds=1) - ) + period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + period_end = period_start + relativedelta(months=1) - relativedelta(seconds=1) return period_start, period_end diff --git a/api/db/user_client.py b/api/db/user_client.py index 9c4476f2..4ea0bca9 100644 --- a/api/db/user_client.py +++ b/api/db/user_client.py @@ -8,7 +8,7 @@ from sqlalchemy.future import select from api.db.base_client import BaseDBClient from api.db.models import UserConfigurationModel, UserModel -from api.schemas.user_configuration import EffectiveAIModelConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration class UserClient(BaseDBClient): diff --git a/api/db/workflow_run_client.py b/api/db/workflow_run_client.py index 57c3e02b..497230ad 100644 --- a/api/db/workflow_run_client.py +++ b/api/db/workflow_run_client.py @@ -16,6 +16,7 @@ from api.db.models import ( ) from api.enums import CallType, StorageBackend from api.schemas.workflow import WorkflowRunResponseSchema +from api.services.workflow.run_usage_response import format_public_cost_info class WorkflowRunClient(BaseDBClient): @@ -312,26 +313,9 @@ class WorkflowRunClient(BaseDBClient): "is_completed": run.is_completed, "recording_url": run.recording_url, "transcript_url": run.transcript_url, - "cost_info": { - "dograh_token_usage": ( - run.cost_info.get("dograh_token_usage") - if run.cost_info - and "dograh_token_usage" in run.cost_info - else round( - float(run.cost_info.get("total_cost_usd", 0)) * 100, - 2, - ) - if run.cost_info and "total_cost_usd" in run.cost_info - else 0 - ), - "call_duration_seconds": int( - round(run.cost_info.get("call_duration_seconds") or 0) - ) - if run.cost_info - else None, - } - if run.cost_info - else None, + "cost_info": format_public_cost_info( + run.cost_info, run.usage_info + ), "definition_id": run.definition_id, "initial_context": run.initial_context, "gathered_context": run.gathered_context, diff --git a/api/routes/knowledge_base.py b/api/routes/knowledge_base.py index d9156871..bd0ba046 100644 --- a/api/routes/knowledge_base.py +++ b/api/routes/knowledge_base.py @@ -384,7 +384,7 @@ async def search_chunks( user_id=user.id, organization_id=user.selected_organization_id, ) - user_config = resolved_config.effective + effective_config = resolved_config.effective embeddings_api_key = None embeddings_model = None embeddings_provider = None @@ -392,17 +392,17 @@ async def search_chunks( embeddings_endpoint = None embeddings_api_version = None - if user_config.embeddings: - embeddings_api_key = user_config.embeddings.api_key - embeddings_model = user_config.embeddings.model - embeddings_provider = getattr(user_config.embeddings, "provider", None) - embeddings_endpoint = getattr(user_config.embeddings, "endpoint", None) + if effective_config.embeddings: + embeddings_api_key = effective_config.embeddings.api_key + embeddings_model = effective_config.embeddings.model + embeddings_provider = getattr(effective_config.embeddings, "provider", None) + embeddings_endpoint = getattr(effective_config.embeddings, "endpoint", None) embeddings_base_url = apply_managed_embeddings_base_url( provider=embeddings_provider, - base_url=getattr(user_config.embeddings, "base_url", None), + base_url=getattr(effective_config.embeddings, "base_url", None), ) embeddings_api_version = getattr( - user_config.embeddings, "api_version", None + effective_config.embeddings, "api_version", None ) # Initialize embedding service based on provider diff --git a/api/routes/organization.py b/api/routes/organization.py index 4fb8e850..8f8e4cbe 100644 --- a/api/routes/organization.py +++ b/api/routes/organization.py @@ -5,7 +5,11 @@ from loguru import logger from pydantic import BaseModel from sqlalchemy.exc import IntegrityError -from api.constants import DEFAULT_CAMPAIGN_RETRY_CONFIG, DEFAULT_ORG_CONCURRENCY_LIMIT +from api.constants import ( + DEFAULT_CAMPAIGN_RETRY_CONFIG, + DEFAULT_ORG_CONCURRENCY_LIMIT, + DEPLOYMENT_MODE, +) from api.db import db_client from api.db.models import UserModel from api.db.telephony_configuration_client import TelephonyConfigurationInUseError @@ -55,6 +59,11 @@ from api.services.configuration.registry import ( ServiceProviders, ServiceType, ) +from api.services.mps_billing import ensure_hosted_mps_billing_account_v2 +from api.services.organization_context import ( + OrganizationContextResponse, + get_organization_context, +) from api.services.organization_preferences import ( get_organization_preferences, upsert_organization_preferences, @@ -129,6 +138,12 @@ class TelephonyConfigWarningsResponse(BaseModel): telnyx_missing_webhook_public_key_count: int +@router.get("/context", response_model=OrganizationContextResponse) +async def get_current_organization_context(user: UserModel = Depends(get_user)): + """Return organization-scoped configuration signals owned by Dograh.""" + return await get_organization_context(user) + + @router.get( "/telephony-providers/metadata", response_model=TelephonyProvidersMetadataResponse, @@ -349,6 +364,23 @@ async def migrate_model_configuration_v2( except ValueError as exc: raise HTTPException(status_code=422, detail=exc.args[0]) + if DEPLOYMENT_MODE != "oss": + try: + await ensure_hosted_mps_billing_account_v2( + organization_id, + created_by=str(user.provider_id), + ) + except Exception as exc: + logger.error( + "Failed to initialize MPS billing v2 account for organization {}: {}", + organization_id, + exc, + ) + raise HTTPException( + status_code=502, + detail="Failed to initialize MPS billing v2 account", + ) + await upsert_organization_ai_model_configuration_v2( organization_id, configuration, diff --git a/api/routes/organization_usage.py b/api/routes/organization_usage.py index 8e75a2c8..3912745b 100644 --- a/api/routes/organization_usage.py +++ b/api/routes/organization_usage.py @@ -1,16 +1,16 @@ import json from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Literal, Optional from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from loguru import logger from pydantic import BaseModel, Field -from api.constants import DEPLOYMENT_MODE +from api.constants import DEPLOYMENT_MODE, UI_APP_URL from api.db import db_client from api.db.models import UserModel -from api.services.auth.depends import get_user +from api.services.auth.depends import get_user, get_user_with_selected_organization from api.services.mps_service_key_client import mps_service_key_client from api.services.reports import generate_usage_runs_report_csv from api.utils.artifacts import artifact_url @@ -22,14 +22,8 @@ class CurrentUsageResponse(BaseModel): period_start: str period_end: str used_dograh_tokens: float - quota_dograh_tokens: int - percentage_used: float - next_refresh_date: str - quota_enabled: bool total_duration_seconds: int - # New USD fields used_amount_usd: Optional[float] = None - quota_amount_usd: Optional[float] = None currency: Optional[str] = None price_per_second_usd: Optional[float] = None @@ -40,6 +34,61 @@ class MPSCreditsResponse(BaseModel): total_quota: float +class MPSCreditPurchaseUrlResponse(BaseModel): + checkout_url: str + + +class MPSBillingAccountResponse(BaseModel): + id: int + organization_id: int + billing_mode: str + cached_balance_credits: float + currency: str + + +class MPSCreditLedgerEntryResponse(BaseModel): + id: int + entry_type: str + origin: Optional[str] = None + credits_delta: float + balance_after: float + amount_minor: Optional[int] = None + amount_currency: Optional[str] = None + payment_order_id: Optional[int] = None + metric_code: Optional[str] = None + correlation_id: Optional[str] = None + aggregation_key: Optional[str] = None + usage_event_id: Optional[int] = None + workflow_run_id: Optional[int] = None + workflow_id: Optional[int] = None + billable_quantity: Optional[float] = None + quantity_unit: Optional[str] = None + metadata: Dict[str, Any] = Field(default_factory=dict) + created_at: str + + +class MPSBillingCreditsResponse(BaseModel): + billing_version: Literal["legacy", "v2"] + total_credits_used: float = 0.0 + remaining_credits: float = 0.0 + total_quota: float = 0.0 + account: Optional[MPSBillingAccountResponse] = None + ledger_entries: List[MPSCreditLedgerEntryResponse] = Field(default_factory=list) + total_count: int = 0 + page: int = 1 + limit: int = 50 + total_pages: int = 0 + + +def _optional_int(value: Any) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + class WorkflowRunUsageResponse(BaseModel): id: int workflow_id: int @@ -97,7 +146,7 @@ class DailyUsageBreakdownResponse(BaseModel): @router.get("/usage/current-period", response_model=CurrentUsageResponse) async def get_current_period_usage(user: UserModel = Depends(get_user)): - """Get current billing period usage for the user's organization.""" + """Get current reporting-period usage for the user's organization.""" if not user.selected_organization_id: raise HTTPException(status_code=400, detail="No organization selected") @@ -142,6 +191,202 @@ async def get_mps_credits(user: UserModel = Depends(get_user)): raise HTTPException(status_code=500, detail=str(e)) +async def _get_mps_billing_account_status( + user: UserModel, organization_id: int +) -> Optional[dict]: + return await mps_service_key_client.get_billing_account_status( + organization_id=organization_id, + created_by=str(user.provider_id), + ) + + +def _is_mps_billing_v2(account: Optional[dict]) -> bool: + return bool(account and account.get("billing_mode") == "v2") + + +async def _legacy_mps_credits_response(user: UserModel) -> MPSBillingCreditsResponse: + if DEPLOYMENT_MODE == "oss": + usage = await mps_service_key_client.get_usage_by_created_by( + str(user.provider_id) + ) + else: + if not user.selected_organization_id: + raise HTTPException(status_code=400, detail="No organization selected") + usage = await mps_service_key_client.get_usage_by_organization( + user.selected_organization_id + ) + + total_used = float(usage.get("total_credits_used", 0.0)) + total_remaining = float(usage.get("remaining_credits", 0.0)) + return MPSBillingCreditsResponse( + billing_version="legacy", + total_credits_used=total_used, + remaining_credits=total_remaining, + total_quota=total_used + total_remaining, + ) + + +@router.get("/billing/credits", response_model=MPSBillingCreditsResponse) +async def get_billing_credits( + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=100), + user: UserModel = Depends(get_user), +): + """Return legacy MPS credits or paginated v2 billing ledger details for the org.""" + try: + if DEPLOYMENT_MODE == "oss" or not user.selected_organization_id: + return await _legacy_mps_credits_response(user) + + organization_id = user.selected_organization_id + account_status = await _get_mps_billing_account_status(user, organization_id) + if not _is_mps_billing_v2(account_status): + return await _legacy_mps_credits_response(user) + + ledger = await mps_service_key_client.get_credit_ledger( + organization_id=organization_id, + page=page, + limit=limit, + created_by=str(user.provider_id), + ) + account = ledger.get("account") or {} + ledger_entries = ledger.get("ledger_entries") or [] + total_count = int(ledger.get("total_count") or len(ledger_entries)) + response_limit = int(ledger.get("limit") or limit) + total_pages = int( + ledger.get("total_pages") + or ((total_count + response_limit - 1) // response_limit) + ) + workflow_ids_by_run_id: dict[int, int] = {} + workflow_run_ids = { + workflow_run_id + for entry in ledger_entries + if (workflow_run_id := _optional_int(entry.get("workflow_run_id"))) + is not None + } + for workflow_run_id in workflow_run_ids: + workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) + if ( + workflow_run + and workflow_run.workflow + and workflow_run.workflow.organization_id == organization_id + ): + workflow_ids_by_run_id[workflow_run_id] = workflow_run.workflow_id + + balance = float(account.get("cached_balance_credits") or 0.0) + total_debits = sum( + abs(float(entry.get("credits_delta") or 0.0)) + for entry in ledger_entries + if float(entry.get("credits_delta") or 0.0) < 0 + ) + if ledger.get("total_debits_credits") is not None: + total_debits = float(ledger["total_debits_credits"]) + + return MPSBillingCreditsResponse( + billing_version="v2", + total_credits_used=total_debits, + remaining_credits=balance, + total_quota=balance + total_debits, + account=MPSBillingAccountResponse( + id=int(account["id"]), + organization_id=int(account["organization_id"]), + billing_mode=str(account["billing_mode"]), + cached_balance_credits=balance, + currency=str(account.get("currency") or "USD"), + ), + ledger_entries=[ + MPSCreditLedgerEntryResponse( + id=int(entry["id"]), + entry_type=str(entry["entry_type"]), + origin=entry.get("origin"), + credits_delta=float(entry.get("credits_delta") or 0.0), + balance_after=float(entry.get("balance_after") or 0.0), + amount_minor=entry.get("amount_minor"), + amount_currency=entry.get("amount_currency"), + payment_order_id=entry.get("payment_order_id"), + metric_code=entry.get("metric_code"), + correlation_id=entry.get("correlation_id"), + aggregation_key=entry.get("aggregation_key"), + usage_event_id=_optional_int(entry.get("usage_event_id")), + workflow_run_id=_optional_int(entry.get("workflow_run_id")), + workflow_id=workflow_ids_by_run_id.get( + _optional_int(entry.get("workflow_run_id")) + ) + if entry.get("workflow_run_id") is not None + else None, + billable_quantity=float(entry["billable_quantity"]) + if entry.get("billable_quantity") is not None + else None, + quantity_unit=entry.get("quantity_unit"), + metadata=entry.get("metadata") or {}, + created_at=str(entry["created_at"]), + ) + for entry in ledger_entries + ], + total_count=total_count, + page=int(ledger.get("page") or page), + limit=response_limit, + total_pages=total_pages, + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Failed to fetch billing credits: {exc}") + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.post( + "/usage/mps-credits/purchase-url", + response_model=MPSCreditPurchaseUrlResponse, +) +async def create_mps_credit_purchase_url( + user: UserModel = Depends(get_user_with_selected_organization), +): + """Create a checkout URL for organizations using Dograh-managed MPS v2.""" + if DEPLOYMENT_MODE == "oss": + raise HTTPException( + status_code=404, + detail="Credit purchases are not available in OSS mode", + ) + + organization_id = user.selected_organization_id + assert organization_id is not None + account_status = await _get_mps_billing_account_status(user, organization_id) + if not _is_mps_billing_v2(account_status): + raise HTTPException( + status_code=403, + detail=( + "Credit purchases are available only for organizations using billing v2" + ), + ) + + try: + session = await mps_service_key_client.create_credit_purchase_url( + organization_id=organization_id, + created_by=str(user.provider_id), + return_url=f"{UI_APP_URL.rstrip('/')}/billing", + billing_details={ + "source": "dograh_billing", + "dograh_user_id": str(user.id), + "dograh_provider_id": str(user.provider_id), + }, + ) + except Exception as exc: + logger.error(f"Failed to create MPS credit purchase URL: {exc}") + raise HTTPException( + status_code=502, + detail="Failed to create credit purchase URL", + ) + + checkout_url = session.get("checkout_url") + if not checkout_url: + logger.error(f"MPS checkout session response missing checkout_url: {session}") + raise HTTPException( + status_code=502, + detail="MPS checkout session response missing checkout_url", + ) + return MPSCreditPurchaseUrlResponse(checkout_url=checkout_url) + + FILTERS_DESCRIPTION = """\ JSON-encoded array of filter objects. Each object has the shape: diff --git a/api/routes/workflow.py b/api/routes/workflow.py index 9157c5cf..06e5fdf9 100644 --- a/api/routes/workflow.py +++ b/api/routes/workflow.py @@ -41,12 +41,15 @@ from api.services.configuration.resolve import ( ) from api.services.mps_service_key_client import mps_service_key_client from api.services.posthog_client import capture_event -from api.services.pricing.run_usage_response import format_public_usage_info from api.services.reports import generate_workflow_report_csv 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.run_usage_response import ( + format_public_cost_info, + format_public_usage_info, +) from api.services.workflow.trigger_paths import ( TriggerPathIssue, ensure_trigger_paths, @@ -1053,13 +1056,15 @@ async def update_workflow( user_id=user.id, organization_id=user.selected_organization_id, ) - user_config = resolved_config.effective + effective_config = resolved_config.effective try: enriched_overrides = enrich_overrides_with_api_keys( workflow_configurations["model_overrides"], - user_config, + effective_config, + ) + effective = resolve_effective_config( + effective_config, enriched_overrides ) - effective = resolve_effective_config(user_config, enriched_overrides) if resolved_config.source == "organization_v2": v2_override = convert_legacy_ai_model_configuration_to_v2(effective) await UserConfigurationValidator().validate( @@ -1264,22 +1269,7 @@ async def get_workflow_run( "transcript_public_url": artifact_url(public_access_token, "transcript"), "recording_public_url": artifact_url(public_access_token, "recording"), "public_access_token": public_access_token, - "cost_info": { - "dograh_token_usage": ( - run.cost_info.get("dograh_token_usage") - if run.cost_info and "dograh_token_usage" in run.cost_info - else round(float(run.cost_info.get("total_cost_usd", 0)) * 100, 2) - if run.cost_info and "total_cost_usd" in run.cost_info - else 0 - ), - "call_duration_seconds": int( - round(run.cost_info.get("call_duration_seconds")) - ) - if run.cost_info and run.cost_info.get("call_duration_seconds") is not None - else None, - } - if run.cost_info - else None, + "cost_info": format_public_cost_info(run.cost_info, run.usage_info), "usage_info": format_public_usage_info(run.usage_info), "created_at": run.created_at, "definition_id": run.definition_id, diff --git a/api/schemas/ai_model_configuration.py b/api/schemas/ai_model_configuration.py index dcc3a6e7..c5403b04 100644 --- a/api/schemas/ai_model_configuration.py +++ b/api/schemas/ai_model_configuration.py @@ -1,10 +1,10 @@ from __future__ import annotations +from datetime import datetime from typing import Literal from pydantic import BaseModel, Field, model_validator -from api.schemas.user_configuration import EffectiveAIModelConfiguration from api.services.configuration.registry import ( DograhEmbeddingsConfiguration, DograhLLMService, @@ -23,6 +23,29 @@ DOGRAH_DEFAULT_VOICE = "default" DOGRAH_DEFAULT_LANGUAGE = "multi" +class EffectiveAIModelConfiguration(BaseModel): + llm: LLMConfig | None = None + stt: STTConfig | None = None + tts: TTSConfig | None = None + embeddings: EmbeddingsConfig | None = None + realtime: RealtimeConfig | None = None + is_realtime: bool = False + managed_service_version: int | None = None + test_phone_number: str | None = None + timezone: str | None = None + last_validated_at: datetime | None = None + + @model_validator(mode="before") + @classmethod + def strip_incomplete_realtime_when_disabled(cls, data): + """Skip realtime validation when is_realtime is False and api_key is missing.""" + if isinstance(data, dict) and not data.get("is_realtime", False): + realtime = data.get("realtime") + if isinstance(realtime, dict) and not realtime.get("api_key"): + data.pop("realtime", None) + return data + + class DograhManagedAIModelConfiguration(BaseModel): api_key: str voice: str = DOGRAH_DEFAULT_VOICE @@ -160,6 +183,7 @@ def _compile_dograh_configuration( model="default", ), is_realtime=False, + managed_service_version=2, ) diff --git a/api/schemas/user_configuration.py b/api/schemas/user_configuration.py deleted file mode 100644 index fc958a5b..00000000 --- a/api/schemas/user_configuration.py +++ /dev/null @@ -1,33 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, model_validator - -from api.services.configuration.registry import ( - EmbeddingsConfig, - LLMConfig, - RealtimeConfig, - STTConfig, - TTSConfig, -) - - -class EffectiveAIModelConfiguration(BaseModel): - llm: LLMConfig | None = None - stt: STTConfig | None = None - tts: TTSConfig | None = None - embeddings: EmbeddingsConfig | None = None - realtime: RealtimeConfig | None = None - is_realtime: bool = False - test_phone_number: str | None = None - timezone: str | None = None - last_validated_at: datetime | None = None - - @model_validator(mode="before") - @classmethod - def strip_incomplete_realtime_when_disabled(cls, data): - """Skip realtime validation when is_realtime is False and api_key is missing.""" - if isinstance(data, dict) and not data.get("is_realtime", False): - realtime = data.get("realtime") - if isinstance(realtime, dict) and not realtime.get("api_key"): - data.pop("realtime", None) - return data diff --git a/api/services/auth/depends.py b/api/services/auth/depends.py index d9e24684..019dbc2f 100644 --- a/api/services/auth/depends.py +++ b/api/services/auth/depends.py @@ -9,9 +9,10 @@ from api.constants import AUTH_PROVIDER, DOGRAH_MPS_SECRET_KEY, MPS_API_URL from api.db import db_client from api.db.models import UserModel from api.enums import PostHogEvent -from api.schemas.user_configuration import EffectiveAIModelConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.services.auth.stack_auth import stackauth from api.services.configuration.registry import ServiceProviders +from api.services.mps_billing import ensure_hosted_mps_billing_account_v2 from api.services.posthog_client import capture_event from api.utils.auth import decode_jwt_token @@ -110,6 +111,19 @@ async def get_user( # This prevents race conditions where multiple concurrent requests # might try to create configurations if org_was_created: + try: + await ensure_hosted_mps_billing_account_v2( + organization.id, + created_by=str(stack_user["id"]), + ) + except Exception: + logger.warning( + "Failed to initialize hosted MPS billing account for " + "organization {}", + organization.id, + exc_info=True, + ) + existing_cfg = await db_client.get_user_configurations(user_model.id) if not (existing_cfg.llm or existing_cfg.tts or existing_cfg.stt): mps_config = await create_user_configuration_with_mps_key( @@ -232,7 +246,7 @@ async def create_user_configuration_with_mps_key( response = await client.post( f"{MPS_API_URL}/api/v1/service-keys/", json={ - "name": f"Default Dograh Model Service Key", + "name": "Default Dograh Model Service Key", "description": "Auto-generated key for OSS user", "expires_in_days": 7, # Short-lived for OSS "created_by": user_provider_id, @@ -250,7 +264,7 @@ async def create_user_configuration_with_mps_key( response = await client.post( f"{MPS_API_URL}/api/v1/service-keys/", json={ - "name": f"Default Dograh Model Service Key", + "name": "Default Dograh Model Service Key", "description": f"Auto-generated key for organization {organization_id}", "organization_id": organization_id, "expires_in_days": 90, # Longer-lived for authenticated users @@ -285,8 +299,8 @@ async def create_user_configuration_with_mps_key( "model": "default", }, } - user_config = EffectiveAIModelConfiguration(**configuration) - return user_config + effective_config = EffectiveAIModelConfiguration(**configuration) + return effective_config else: logger.warning( f"Failed to get MPS service key: {response.status_code} - {response.text}" diff --git a/api/services/configuration/ai_model_configuration.py b/api/services/configuration/ai_model_configuration.py index 1b9a00f6..c5331515 100644 --- a/api/services/configuration/ai_model_configuration.py +++ b/api/services/configuration/ai_model_configuration.py @@ -21,10 +21,10 @@ from api.schemas.ai_model_configuration import ( BYOKPipelineAIModelConfiguration, BYOKRealtimeAIModelConfiguration, DograhManagedAIModelConfiguration, + EffectiveAIModelConfiguration, OrganizationAIModelConfigurationV2, compile_ai_model_configuration_v2, ) -from api.schemas.user_configuration import EffectiveAIModelConfiguration from api.services.configuration.masking import ( SERVICE_SECRET_FIELDS, contains_masked_key, diff --git a/api/services/configuration/check_validity.py b/api/services/configuration/check_validity.py index e8f5bfa7..b1996879 100644 --- a/api/services/configuration/check_validity.py +++ b/api/services/configuration/check_validity.py @@ -8,7 +8,7 @@ from groq import Groq # from pyneuphonic import Neuphonic # except ImportError: # Neuphonic = None -from api.schemas.user_configuration import ( +from api.schemas.ai_model_configuration import ( EffectiveAIModelConfiguration, ) from api.services.configuration.registry import ServiceConfig, ServiceProviders @@ -75,21 +75,21 @@ class UserConfigurationValidator: status_list = [] status_list.extend(self._validate_service(configuration.llm, "llm")) - status_list.extend(self._validate_service(configuration.stt, "stt")) - status_list.extend(self._validate_service(configuration.tts, "tts")) - # Embeddings is optional - only validate if configured - status_list.extend( - self._validate_service( - configuration.embeddings, "embeddings", required=False - ) - ) - # Realtime is optional - only validate if is_realtime is enabled if configuration.is_realtime: status_list.extend( self._validate_service( configuration.realtime, "realtime", required=True ) ) + else: + status_list.extend(self._validate_service(configuration.stt, "stt")) + status_list.extend(self._validate_service(configuration.tts, "tts")) + # Embeddings is optional - only validate if configured + status_list.extend( + self._validate_service( + configuration.embeddings, "embeddings", required=False + ) + ) if status_list: raise ValueError(status_list) diff --git a/api/services/configuration/masking.py b/api/services/configuration/masking.py index c3fa4bfc..a7e1af6a 100644 --- a/api/services/configuration/masking.py +++ b/api/services/configuration/masking.py @@ -12,7 +12,7 @@ The rules are simple: import copy from typing import Any, Dict, Optional -from api.schemas.user_configuration import EffectiveAIModelConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.services.configuration.registry import ServiceConfig from api.services.integrations import get_node_secret_fields diff --git a/api/services/configuration/merge.py b/api/services/configuration/merge.py index 1b174ee8..3100fa45 100644 --- a/api/services/configuration/merge.py +++ b/api/services/configuration/merge.py @@ -7,7 +7,7 @@ stored, while honouring masked API keys. import copy from typing import Dict -from api.schemas.user_configuration import EffectiveAIModelConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.services.configuration.masking import ( MODEL_OVERRIDE_FIELDS, SERVICE_SECRET_FIELDS, diff --git a/api/services/configuration/resolve.py b/api/services/configuration/resolve.py index a33f5c09..5cbf11ef 100644 --- a/api/services/configuration/resolve.py +++ b/api/services/configuration/resolve.py @@ -4,7 +4,7 @@ from __future__ import annotations import copy -from api.schemas.user_configuration import EffectiveAIModelConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.services.configuration.registry import ( REGISTRY, ServiceType, diff --git a/api/services/gen_ai/embedding/openai_service.py b/api/services/gen_ai/embedding/openai_service.py index da5d3d4d..1081889e 100644 --- a/api/services/gen_ai/embedding/openai_service.py +++ b/api/services/gen_ai/embedding/openai_service.py @@ -38,6 +38,7 @@ class OpenAIEmbeddingService(BaseEmbeddingService): api_key: Optional[str] = None, model_id: str = DEFAULT_MODEL_ID, base_url: Optional[str] = None, + default_headers: Optional[Dict[str, str]] = None, ): """Initialize the OpenAI embedding service. @@ -60,6 +61,8 @@ class OpenAIEmbeddingService(BaseEmbeddingService): field_name="base_url", ) client_kwargs["base_url"] = base_url + if default_headers: + client_kwargs["default_headers"] = default_headers self.client = AsyncOpenAI(**client_kwargs) logger.info(f"OpenAI embedding service initialized with model: {model_id}") else: diff --git a/api/services/managed_model_services.py b/api/services/managed_model_services.py new file mode 100644 index 00000000..b6992aaf --- /dev/null +++ b/api/services/managed_model_services.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from typing import Any + +from loguru import logger + +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration +from api.services.configuration.registry import ServiceProviders +from api.services.mps_service_key_client import mps_service_key_client + +MPS_CORRELATION_ID_CONTEXT_KEY = "mps_correlation_id" + + +def uses_managed_model_services_v2( + ai_model_config: EffectiveAIModelConfiguration | None, +) -> bool: + if ( + ai_model_config is None + or getattr(ai_model_config, "managed_service_version", None) != 2 + ): + return False + + return any( + _is_dograh_service(getattr(ai_model_config, section_name, None)) + for section_name in ("llm", "tts", "stt", "embeddings") + ) + + +def get_mps_correlation_id(initial_context: dict[str, Any] | None) -> str | None: + if not initial_context: + return None + correlation_id = initial_context.get(MPS_CORRELATION_ID_CONTEXT_KEY) + if correlation_id is None: + return None + return str(correlation_id) + + +async def ensure_mps_correlation_id( + *, + ai_model_config: EffectiveAIModelConfiguration, + workflow_run_id: int, + initial_context: dict[str, Any] | None, +) -> str | None: + existing = get_mps_correlation_id(initial_context) + if existing: + return existing + + if not uses_managed_model_services_v2(ai_model_config): + return None + + service_key = _get_dograh_service_api_key(ai_model_config) + if not service_key: + raise ValueError( + "Managed model services v2 requires a Dograh service key before the run starts." + ) + + response = await mps_service_key_client.create_correlation_id( + service_key=service_key, + workflow_run_id=workflow_run_id, + ) + correlation_id = response.get("correlation_id") + if not correlation_id: + raise ValueError("MPS correlation-id response did not include correlation_id") + + correlation_id = str(correlation_id) + logger.info( + "Minted MPS correlation id {} for workflow run {}", + correlation_id, + workflow_run_id, + ) + return correlation_id + + +def _is_dograh_service(service: Any) -> bool: + provider = getattr(service, "provider", None) + return ( + provider == ServiceProviders.DOGRAH or provider == ServiceProviders.DOGRAH.value + ) + + +def _get_dograh_service_api_key( + ai_model_config: EffectiveAIModelConfiguration, +) -> str | None: + for section_name in ("llm", "tts", "stt", "embeddings"): + service = getattr(ai_model_config, section_name, None) + if not _is_dograh_service(service): + continue + + if hasattr(service, "get_all_api_keys"): + keys = service.get_all_api_keys() + if keys: + return keys[0] + + api_key = getattr(service, "api_key", None) + if isinstance(api_key, str) and api_key: + return api_key + + return None diff --git a/api/services/mps_billing.py b/api/services/mps_billing.py new file mode 100644 index 00000000..10a27c90 --- /dev/null +++ b/api/services/mps_billing.py @@ -0,0 +1,23 @@ +from typing import Optional + +from api.constants import DEPLOYMENT_MODE +from api.services.mps_service_key_client import mps_service_key_client + + +async def ensure_hosted_mps_billing_account_v2( + organization_id: int, + *, + created_by: Optional[str] = None, +) -> Optional[dict]: + """Ensure hosted orgs have an MPS billing v2 account. + + OSS deployments use legacy per-key quota accounting and do not create MPS + billing accounts. + """ + if DEPLOYMENT_MODE == "oss": + return None + + return await mps_service_key_client.ensure_billing_account_v2( + organization_id=organization_id, + created_by=created_by, + ) diff --git a/api/services/mps_service_key_client.py b/api/services/mps_service_key_client.py index 2c7fc56b..4f30341d 100644 --- a/api/services/mps_service_key_client.py +++ b/api/services/mps_service_key_client.py @@ -4,6 +4,7 @@ This client communicates with the Model Proxy Service (MPS) for service key mana Service keys are stored and managed entirely in MPS, not in the local database. """ +import asyncio from typing import List, Optional import httpx @@ -353,6 +354,234 @@ class MPSServiceKeyClient: response=response, ) + async def create_credit_purchase_url( + self, + organization_id: int, + created_by: Optional[str] = None, + return_url: Optional[str] = None, + billing_details: Optional[dict] = None, + ) -> dict: + """Create a short-lived MPS checkout URL for adding organization credits.""" + payload = { + "created_by": created_by, + "return_url": return_url, + "billing_details": billing_details or {}, + } + + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + f"{self.base_url}/api/v1/billing/accounts/{organization_id}/checkout-sessions", + json=payload, + headers=self._get_headers( + organization_id=organization_id, + created_by=created_by, + ), + ) + + if response.status_code == 200: + return response.json() + + logger.error( + "Failed to create MPS credit purchase URL: " + f"{response.status_code} - {response.text}" + ) + raise httpx.HTTPStatusError( + f"Failed to create MPS credit purchase URL: {response.text}", + request=response.request, + response=response, + ) + + async def get_credit_ledger( + self, + organization_id: int, + page: int = 1, + limit: int = 50, + created_by: Optional[str] = None, + ) -> dict: + """Get the MPS v2 billing account balance and recent credit ledger.""" + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get( + f"{self.base_url}/api/v1/billing/accounts/{organization_id}/ledger", + params={"page": page, "limit": limit}, + headers=self._get_headers( + organization_id=organization_id, + created_by=created_by, + ), + ) + + if response.status_code == 200: + return response.json() + + logger.error( + "Failed to get MPS credit ledger: " + f"{response.status_code} - {response.text}" + ) + raise httpx.HTTPStatusError( + f"Failed to get MPS credit ledger: {response.text}", + request=response.request, + response=response, + ) + + async def get_billing_account_status( + self, + organization_id: int, + created_by: Optional[str] = None, + ) -> Optional[dict]: + """Get an existing MPS v2 billing account without creating one.""" + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get( + f"{self.base_url}/api/v1/billing/accounts/{organization_id}/status", + headers=self._get_headers( + organization_id=organization_id, + created_by=created_by, + ), + ) + + if response.status_code == 200: + return response.json() + + logger.error( + "Failed to get MPS billing account status: " + f"{response.status_code} - {response.text}" + ) + raise httpx.HTTPStatusError( + f"Failed to get MPS billing account status: {response.text}", + request=response.request, + response=response, + ) + + async def ensure_billing_account_v2( + self, + organization_id: int, + created_by: Optional[str] = None, + ) -> dict: + """Create or return the MPS v2 billing account for an organization.""" + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get( + f"{self.base_url}/api/v1/billing/accounts/{organization_id}/balance", + headers=self._get_headers( + organization_id=organization_id, + created_by=created_by, + ), + ) + + if response.status_code == 200: + return response.json() + + logger.error( + "Failed to ensure MPS billing account v2: " + f"{response.status_code} - {response.text}" + ) + raise httpx.HTTPStatusError( + f"Failed to ensure MPS billing account v2: {response.text}", + request=response.request, + response=response, + ) + + async def create_correlation_id( + self, + *, + service_key: str, + workflow_run_id: int | None = None, + ) -> dict: + """Mint a server-generated correlation ID for managed model services.""" + payload: dict[str, int] = {} + if workflow_run_id is not None: + payload["workflow_run_id"] = workflow_run_id + + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + f"{self.base_url}/api/v1/service-keys/correlation-id/self", + json=payload, + headers={ + "Authorization": f"Bearer {service_key}", + "Content-Type": "application/json", + }, + ) + + if response.status_code == 200: + return response.json() + + logger.error( + "Failed to create correlation ID: " + f"{response.status_code} - {response.text}" + ) + raise httpx.HTTPStatusError( + f"Failed to create correlation ID: {response.text}", + request=response.request, + response=response, + ) + + async def report_platform_usage( + self, + *, + organization_id: int, + correlation_id: Optional[str] = None, + duration_seconds: Optional[float] = None, + workflow_run_id: int | None = None, + metadata: Optional[dict] = None, + max_attempts: int = 3, + ) -> dict: + """Report hosted Dograh platform usage for a completed workflow run.""" + if DEPLOYMENT_MODE == "oss": + raise ValueError("OSS deployments must not report platform usage to MPS") + if not correlation_id and duration_seconds is None: + raise ValueError( + "Platform usage reports require correlation_id or duration_seconds" + ) + + payload: dict = { + "metadata": metadata or {}, + } + if correlation_id: + payload["correlation_id"] = correlation_id + if duration_seconds is not None: + payload["duration_seconds"] = duration_seconds + if workflow_run_id is not None: + payload["workflow_run_id"] = workflow_run_id + + max_attempts = max(1, max_attempts) + last_response: httpx.Response | None = None + async with httpx.AsyncClient(timeout=self.timeout) as client: + for attempt in range(1, max_attempts + 1): + response = await client.post( + ( + f"{self.base_url}/api/v1/billing/accounts/" + f"{organization_id}/platform-usage" + ), + json=payload, + headers=self._get_headers(organization_id=organization_id), + ) + last_response = response + + if response.status_code == 200: + return response.json() + + should_retry = ( + response.status_code == 409 + and "usage_not_ready" in response.text + and attempt < max_attempts + ) + if should_retry: + await asyncio.sleep(attempt) + continue + + logger.error( + "Failed to report platform usage: " + f"{response.status_code} - {response.text}" + ) + raise httpx.HTTPStatusError( + f"Failed to report platform usage: {response.text}", + request=response.request, + response=response, + ) + + raise httpx.HTTPStatusError( + "Failed to report platform usage", + request=last_response.request, + response=last_response, + ) + async def transcribe_audio( self, audio_data: bytes, diff --git a/api/services/organization_context.py b/api/services/organization_context.py new file mode 100644 index 00000000..b17b8f4f --- /dev/null +++ b/api/services/organization_context.py @@ -0,0 +1,50 @@ +from typing import Literal, Optional + +from pydantic import BaseModel + +from api.db import db_client +from api.db.models import UserModel +from api.services.configuration.ai_model_configuration import ( + get_resolved_ai_model_configuration, +) + + +class OrganizationModelServicesContext(BaseModel): + config_source: Literal["organization_v2", "legacy_user_v1", "empty"] + has_model_configuration_v2: bool + managed_service_version: Optional[int] = None + uses_managed_service_v2: bool + + +class OrganizationContextResponse(BaseModel): + organization_id: Optional[int] = None + organization_provider_id: Optional[str] = None + model_services: OrganizationModelServicesContext + + +async def get_organization_context(user: UserModel) -> OrganizationContextResponse: + organization_id = user.selected_organization_id + organization = ( + await db_client.get_organization_by_id(organization_id) + if organization_id + else None + ) + + resolved = await get_resolved_ai_model_configuration( + user_id=user.id, + organization_id=organization_id, + ) + managed_service_version = resolved.effective.managed_service_version + + return OrganizationContextResponse( + organization_id=organization_id, + organization_provider_id=organization.provider_id if organization else None, + model_services=OrganizationModelServicesContext( + config_source=resolved.source, + has_model_configuration_v2=resolved.source == "organization_v2", + managed_service_version=managed_service_version, + uses_managed_service_v2=( + resolved.source == "organization_v2" and managed_service_version == 2 + ), + ), + ) diff --git a/api/services/pipecat/run_pipeline.py b/api/services/pipecat/run_pipeline.py index 63c11f53..07286901 100644 --- a/api/services/pipecat/run_pipeline.py +++ b/api/services/pipecat/run_pipeline.py @@ -162,15 +162,13 @@ async def run_pipeline_telephony( workflow_id: Workflow being executed. workflow_run_id: Workflow run row. user_id: Owner of the workflow. - call_id: Provider call identifier (stored in cost_info for billing). + call_id: Provider call identifier. transport_kwargs: Provider-specific kwargs forwarded to the transport factory (e.g. stream_sid + call_sid for Twilio). """ logger.debug(f"Running {provider_name} pipeline for workflow_run {workflow_run_id}") set_current_run_id(workflow_run_id) - await db_client.update_workflow_run(workflow_run_id, cost_info={"call_id": call_id}) - workflow = await db_client.get_workflow(workflow_id, user_id) if workflow: set_current_org_id(workflow.organization_id) @@ -340,7 +338,7 @@ async def _run_pipeline( if workflow_run.is_completed: raise HTTPException(status_code=400, detail="Workflow run already completed") - merged_call_context_vars = workflow_run.initial_context + merged_call_context_vars = dict(workflow_run.initial_context or {}) # If there is some extra call_context_vars, fold them in. Persistence # happens once below, after runtime_configuration is also resolved. if call_context_vars: @@ -398,6 +396,19 @@ async def _run_pipeline( else: user_config = resolved_user_config + from api.services.managed_model_services import ( + MPS_CORRELATION_ID_CONTEXT_KEY, + ensure_mps_correlation_id, + ) + + mps_correlation_id = await ensure_mps_correlation_id( + ai_model_config=user_config, + workflow_run_id=workflow_run_id, + initial_context=merged_call_context_vars, + ) + if mps_correlation_id: + merged_call_context_vars[MPS_CORRELATION_ID_CONTEXT_KEY] = mps_correlation_id + # 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 @@ -409,11 +420,23 @@ async def _run_pipeline( # Realtime services don't implement run_inference, so create a # separate text LLM for variable extraction and other out-of-band # inference calls. - inference_llm = create_llm_service(user_config) + inference_llm = create_llm_service( + user_config, + correlation_id=mps_correlation_id, + ) else: - stt = create_stt_service(user_config, audio_config, keyterms=keyterms) - tts = create_tts_service(user_config, audio_config) - llm = create_llm_service(user_config) + stt = create_stt_service( + user_config, + audio_config, + keyterms=keyterms, + correlation_id=mps_correlation_id, + ) + tts = create_tts_service( + user_config, + audio_config, + correlation_id=mps_correlation_id, + ) + llm = create_llm_service(user_config, correlation_id=mps_correlation_id) inference_llm = None # Stamp the providers/models actually resolved for this run onto @@ -695,7 +718,10 @@ async def _run_pipeline( # Create a separate LLM instance for the voicemail sub-pipeline # (can't share with main pipeline as it would mess up frame linking) if voicemail_config.get("use_workflow_llm", True): - voicemail_llm = create_llm_service(user_config) + voicemail_llm = create_llm_service( + user_config, + correlation_id=mps_correlation_id, + ) else: voicemail_llm = create_llm_service_from_provider( provider=voicemail_config.get("provider", "openai"), diff --git a/api/services/pipecat/service_factory.py b/api/services/pipecat/service_factory.py index 8ed96e40..ec5e9911 100644 --- a/api/services/pipecat/service_factory.py +++ b/api/services/pipecat/service_factory.py @@ -78,7 +78,10 @@ def _validate_runtime_service_url(url: str, field_name: str) -> None: def create_stt_service( - user_config, audio_config: "AudioConfig", keyterms: list[str] | None = None + user_config, + audio_config: "AudioConfig", + keyterms: list[str] | None = None, + correlation_id: str | None = None, ): """Create and return appropriate STT service based on user configuration @@ -160,6 +163,7 @@ def create_stt_service( return DograhSTTService( base_url=base_url, api_key=user_config.stt.api_key, + correlation_id=correlation_id, settings=DograhSTTSettings( model=user_config.stt.model, language=language, @@ -286,7 +290,9 @@ def create_stt_service( ) -def create_tts_service(user_config, audio_config: "AudioConfig"): +def create_tts_service( + user_config, audio_config: "AudioConfig", correlation_id: str | None = None +): """Create and return appropriate TTS service based on user configuration Args: @@ -404,6 +410,7 @@ def create_tts_service(user_config, audio_config: "AudioConfig"): return DograhTTSService( base_url=base_url, api_key=user_config.tts.api_key, + correlation_id=correlation_id, settings=DograhTTSSettings( model=user_config.tts.model, voice=user_config.tts.voice, @@ -564,6 +571,7 @@ def create_llm_service_from_provider( model: str, api_key: str | None, *, + correlation_id: str | None = None, base_url: str | None = None, endpoint: str | None = None, aws_access_key: str | None = None, @@ -637,6 +645,7 @@ def create_llm_service_from_provider( return DograhLLMService( base_url=f"{MPS_API_URL}/api/v1/llm", api_key=api_key, + correlation_id=correlation_id, settings=OpenAILLMSettings(model=model), ) elif provider == ServiceProviders.AWS_BEDROCK.value: @@ -851,7 +860,7 @@ def create_realtime_llm_service(user_config, audio_config: "AudioConfig"): ) -def create_llm_service(user_config): +def create_llm_service(user_config, correlation_id: str | None = None): """Create and return appropriate LLM service based on user configuration.""" provider = user_config.llm.provider model = user_config.llm.model @@ -880,4 +889,10 @@ def create_llm_service(user_config): elif provider == ServiceProviders.SARVAM.value: kwargs["temperature"] = user_config.llm.temperature - return create_llm_service_from_provider(provider, model, api_key, **kwargs) + return create_llm_service_from_provider( + provider, + model, + api_key, + correlation_id=correlation_id, + **kwargs, + ) diff --git a/api/services/pricing/README.md b/api/services/pricing/README.md deleted file mode 100644 index 4f834c28..00000000 --- a/api/services/pricing/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# Pricing Module - -This module contains pricing models and registries for different AI services used in workflow cost calculations. - -## Structure - -``` -pricing/ -├── __init__.py # Main module exports -├── models.py # Base pricing model classes -├── llm.py # LLM pricing configurations -├── tts.py # TTS pricing configurations -├── stt.py # STT pricing configurations -├── registry.py # Combined pricing registry -└── README.md # This file -``` - -## Pricing Models - -### TokenPricingModel -Used for LLM services that charge based on tokens: -- `prompt_token_price`: Cost per prompt token -- `completion_token_price`: Cost per completion token -- `cache_read_discount`: Discount for cache read tokens (default 50%) -- `cache_creation_multiplier`: Premium for cache creation tokens (default 25%) - -### CharacterPricingModel -Used for TTS services that charge based on character count: -- `character_price`: Cost per character - -### TimePricingModel -Used for STT services that charge based on time: -- `second_price`: Cost per second - -## Adding New Pricing - -### Adding a New LLM Model -Edit `llm.py` and add the model to the appropriate provider: - -```python -ServiceProviders.OPENAI: { - "new-model": TokenPricingModel( - prompt_token_price=Decimal("2.00") / 1000000, - completion_token_price=Decimal("8.00") / 1000000, - ), - # ... existing models -} -``` - -### Adding a New Provider -1. Add pricing configurations to the appropriate service file (llm.py, tts.py, stt.py) -2. The registry will automatically include them - -### Adding a New Service Type -1. Create a new pricing file (e.g., `image.py`) -2. Define the pricing models -3. Import and add to `registry.py` - -## Usage - -The pricing registry is automatically imported and used by the cost calculator: - -```python -from api.services.pricing import PRICING_REGISTRY -from api.services.workflow.cost_calculator import cost_calculator - -# The cost calculator uses the pricing registry automatically -result = cost_calculator.calculate_total_cost(usage_info) -``` - -## Maintenance - -- Update pricing when providers change their rates -- All prices should use `Decimal` for precision -- Include comments with current pricing from provider documentation -- Test changes with existing test suite \ No newline at end of file diff --git a/api/services/pricing/__init__.py b/api/services/pricing/__init__.py deleted file mode 100644 index 1fa0eedf..00000000 --- a/api/services/pricing/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -Pricing module for workflow cost calculation. - -This module contains pricing models and registries for different AI services. -""" - -from .registry import PRICING_REGISTRY - -__all__ = ["PRICING_REGISTRY"] diff --git a/api/services/pricing/cost_calculator.py b/api/services/pricing/cost_calculator.py deleted file mode 100644 index 14344752..00000000 --- a/api/services/pricing/cost_calculator.py +++ /dev/null @@ -1,228 +0,0 @@ -""" -Cost Calculator for Workflow Runs - -This module provides a comprehensive cost calculation system for workflow runs based on usage metrics -from different AI service providers (OpenAI, Groq, Deepgram, etc.). - -Features: -- Token-based pricing for LLM services with cache optimization support -- Character-based pricing for TTS services -- Time-based pricing for STT services -- Configurable pricing models that can be updated -- Support for multiple providers and models -- Automatic provider inference from model names -- JSON serialization support for database storage - -Usage: - from api.tasks.cost_calculator import cost_calculator - - usage_info = { - "llm": { - "processor_name|||gpt-4o": { - "prompt_tokens": 1000, - "completion_tokens": 500, - "total_tokens": 1500, - "cache_read_input_tokens": 0, - "cache_creation_input_tokens": 0 - } - }, - "tts": { - "processor_name|||aura-2-helena-en": 2000 # character count - } - } - - cost_breakdown = cost_calculator.calculate_total_cost(usage_info) - print(f"Total cost: ${cost_breakdown['total']:.6f}") -""" - -from decimal import Decimal -from typing import Any, Dict, Optional, Tuple - -from api.services.configuration.registry import ServiceProviders -from api.services.pricing import PRICING_REGISTRY -from api.services.pricing.models import ( - PricingModel, -) - - -class CostCalculator: - """Main cost calculator class""" - - def __init__(self, pricing_registry: Dict = None): - self.pricing_registry = pricing_registry or PRICING_REGISTRY - - def get_pricing_model( - self, service_type: str, provider: str, model: str - ) -> Optional[PricingModel]: - """Get pricing model for a specific service, provider, and model""" - try: - service_pricing = self.pricing_registry.get(service_type, {}) - - # Try to get pricing for the specific provider - provider_pricing = service_pricing.get(provider, {}) - pricing_model = provider_pricing.get(model) or provider_pricing.get( - "default" - ) - - if pricing_model: - return pricing_model - - # If not found, try the "default" provider for this service type - default_provider_pricing = service_pricing.get("default", {}) - return default_provider_pricing.get(model) or default_provider_pricing.get( - "default" - ) - - except (KeyError, AttributeError): - return None - - def calculate_llm_cost( - self, provider: str, model: str, usage: Dict[str, int] - ) -> Decimal: - """Calculate cost for LLM usage""" - pricing_model = self.get_pricing_model("llm", provider, model) - if not pricing_model: - return Decimal("0") - return pricing_model.calculate_cost(usage) - - def calculate_tts_cost( - self, provider: str, model: str, character_count: int - ) -> Decimal: - """Calculate cost for TTS usage""" - pricing_model = self.get_pricing_model("tts", provider, model) - if not pricing_model: - return Decimal("0") - return pricing_model.calculate_cost(character_count) - - def calculate_stt_cost(self, provider: str, model: str, seconds: float) -> Decimal: - """Calculate cost for STT usage""" - pricing_model = self.get_pricing_model("stt", provider, model) - if not pricing_model: - return Decimal("0") - return pricing_model.calculate_cost(seconds) - - def calculate_total_cost(self, usage_info: Dict) -> Dict[str, Any]: - llm_cost_total = Decimal("0") - tts_cost_total = Decimal("0") - stt_cost_total = Decimal("0") - - # Calculate LLM costs - llm_usage = usage_info.get("llm", {}) - for key, usage in llm_usage.items(): - processor, model = self._parse_key(key) - # Try to determine provider from processor name or model - provider = self._infer_provider_from_model(model, "llm") - cost = self.calculate_llm_cost(provider, model, usage) - llm_cost_total += cost - - # Calculate TTS costs - tts_usage = usage_info.get("tts", {}) - for key, character_count in tts_usage.items(): - processor, model = self._parse_key(key) - # Handle the case where model is "None" - infer from processor - if model.lower() in ["none", "null", ""]: - provider = self._infer_provider_from_processor(processor, "tts") - model = "default" # Use default model for the provider - else: - provider = self._infer_provider_from_model(model, "tts") - cost = self.calculate_tts_cost(provider, model, character_count) - tts_cost_total += cost - - # Calculate STT costs from explicit stt usage - stt_usage = usage_info.get("stt", {}) - for key, seconds in stt_usage.items(): - processor, model = self._parse_key(key) - provider = self._infer_provider_from_model(model, "stt") - cost = self.calculate_stt_cost(provider, model, seconds) - stt_cost_total += cost - - total_cost = llm_cost_total + tts_cost_total + stt_cost_total - - return { - "llm_cost": float(llm_cost_total), - "tts_cost": float(tts_cost_total), - "stt_cost": float(stt_cost_total), - "total": float(total_cost), - } - - def _parse_key(self, key) -> Tuple[str, str]: - """Parse key which is in format 'processor|||model'""" - if isinstance(key, str) and "|||" in key: - parts = key.split("|||", 1) - return parts[0], parts[1] - else: - # Fallback for backwards compatibility or malformed keys - return str(key), "unknown" - - def _infer_provider_from_model(self, model: str, service_type: str) -> str: - """Infer provider from model name""" - if not model: - return "unknown" - - model_lower = model.lower() - - # OpenAI models - if any(keyword in model_lower for keyword in ["gpt", "whisper", "openai"]): - return ServiceProviders.OPENAI - - # Groq models - if any(keyword in model_lower for keyword in ["groq"]): - return ServiceProviders.GROQ - - # Elevenlabs models - if any(keyword in model_lower for keyword in ["eleven"]): - return ServiceProviders.ELEVENLABS - - # Deepgram models - if any( - keyword in model_lower - for keyword in ["deepgram", "nova", "phonecall", "general"] - ): - return ServiceProviders.DEEPGRAM - - # Default to first available provider for the service type - service_providers = self.pricing_registry.get(service_type, {}) - if service_providers: - return list(service_providers.keys())[0] - - return "unknown" - - def _infer_provider_from_processor(self, processor: str, service_type: str) -> str: - """Infer provider from processor name""" - if not processor: - return "unknown" - - processor_lower = processor.lower() - - # OpenAI processors - if any(keyword in processor_lower for keyword in ["openai", "gpt"]): - return ServiceProviders.OPENAI - - # Groq processors - if any(keyword in processor_lower for keyword in ["groq"]): - return ServiceProviders.GROQ - - # Deepgram processors - if any(keyword in processor_lower for keyword in ["deepgram"]): - return ServiceProviders.DEEPGRAM - - # Default to first available provider for the service type - service_providers = self.pricing_registry.get(service_type, {}) - if service_providers: - return list(service_providers.keys())[0] - - return "unknown" - - def update_pricing( - self, service_type: str, provider: str, model: str, pricing_model: PricingModel - ): - """Update pricing for a specific service/provider/model combination""" - if service_type not in self.pricing_registry: - self.pricing_registry[service_type] = {} - if provider not in self.pricing_registry[service_type]: - self.pricing_registry[service_type][provider] = {} - self.pricing_registry[service_type][provider][model] = pricing_model - - -# Global cost calculator instance -cost_calculator = CostCalculator() diff --git a/api/services/pricing/embeddings.py b/api/services/pricing/embeddings.py deleted file mode 100644 index a58a8caa..00000000 --- a/api/services/pricing/embeddings.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -Embeddings pricing models for different providers. - -Prices are per token for embedding models. -""" - -from decimal import Decimal -from typing import Dict - -from api.services.configuration.registry import ServiceProviders - -from .models import PricingModel - - -class EmbeddingPricingModel(PricingModel): - """Pricing model for token-based embedding services.""" - - def __init__(self, token_price: Decimal): - """Initialize with price per token. - - Args: - token_price: Cost per token for embedding - """ - self.token_price = token_price - - def calculate_cost(self, token_count: int) -> Decimal: - """Calculate cost for embedding token usage.""" - return Decimal(token_count) * self.token_price - - -# Embeddings pricing registry -EMBEDDINGS_PRICING: Dict[str, Dict[str, EmbeddingPricingModel]] = { - ServiceProviders.OPENAI: { - "text-embedding-3-small": EmbeddingPricingModel( - token_price=Decimal("0.02") / 1_000_000, # $0.02 per 1M tokens - ), - "text-embedding-3-large": EmbeddingPricingModel( - token_price=Decimal("0.13") / 1_000_000, # $0.13 per 1M tokens - ), - "text-embedding-ada-002": EmbeddingPricingModel( - token_price=Decimal("0.10") / 1_000_000, # $0.10 per 1M tokens (legacy) - ), - }, -} diff --git a/api/services/pricing/llm.py b/api/services/pricing/llm.py deleted file mode 100644 index addb59bc..00000000 --- a/api/services/pricing/llm.py +++ /dev/null @@ -1,143 +0,0 @@ -""" -LLM pricing models for different providers. - -Prices are per 1000 tokens for most models, with some newer models priced per million tokens. -""" - -from decimal import Decimal -from typing import Dict - -from api.services.configuration.registry import ServiceProviders - -from .models import TokenPricingModel - -# LLM pricing registry -LLM_PRICING: Dict[str, Dict[str, TokenPricingModel]] = { - ServiceProviders.OPENAI: { - "gpt-3.5-turbo": TokenPricingModel( - prompt_token_price=Decimal("0.0015") / 1000, # $0.0015 per 1K tokens - completion_token_price=Decimal("0.002") / 1000, # $0.002 per 1K tokens - ), - "gpt-4": TokenPricingModel( - prompt_token_price=Decimal("0.03") / 1000, # $0.03 per 1K tokens - completion_token_price=Decimal("0.06") / 1000, # $0.06 per 1K tokens - ), - "gpt-4.1": TokenPricingModel( - prompt_token_price=Decimal("2.00") / 1000000, # $2.00 per 1M tokens - completion_token_price=Decimal("8.00") / 1000000, # $8.00 per 1M tokens - ), - "gpt-4.1-mini": TokenPricingModel( - prompt_token_price=Decimal("0.40") / 1000000, # $0.40 per 1M tokens - completion_token_price=Decimal("1.60") / 1000000, # $1.60 per 1M tokens - ), - "gpt-4.1-nano": TokenPricingModel( - prompt_token_price=Decimal("0.10") / 1000000, # $0.10 per 1M tokens - completion_token_price=Decimal("0.40") / 1000000, # $0.40 per 1M tokens - ), - "gpt-4.5-preview": TokenPricingModel( - prompt_token_price=Decimal("75.00") / 1000000, # $75.00 per 1M tokens - completion_token_price=Decimal("150.00") / 1000000, # $150.00 per 1M tokens - ), - "gpt-4o": TokenPricingModel( - prompt_token_price=Decimal("2.50") / 1000000, # $2.50 per 1M tokens - FIXED - completion_token_price=Decimal("10.00") - / 1000000, # $10.00 per 1M tokens - FIXED - ), - "gpt-4o-audio-preview": TokenPricingModel( - prompt_token_price=Decimal("2.50") / 1000000, # $2.50 per 1M tokens - completion_token_price=Decimal("10.00") / 1000000, # $10.00 per 1M tokens - ), - "gpt-4o-realtime-preview": TokenPricingModel( - prompt_token_price=Decimal("5.00") / 1000000, # $5.00 per 1M tokens - completion_token_price=Decimal("20.00") / 1000000, # $20.00 per 1M tokens - ), - "gpt-4o-mini": TokenPricingModel( - prompt_token_price=Decimal("0.15") / 1000000, # $0.15 per 1M tokens - completion_token_price=Decimal("0.60") / 1000000, # $0.60 per 1M tokens - ), - "gpt-4o-mini-audio-preview": TokenPricingModel( - prompt_token_price=Decimal("0.15") / 1000000, # $0.15 per 1M tokens - completion_token_price=Decimal("0.60") / 1000000, # $0.60 per 1M tokens - ), - "gpt-4o-mini-realtime-preview": TokenPricingModel( - prompt_token_price=Decimal("0.60") / 1000000, # $0.60 per 1M tokens - completion_token_price=Decimal("2.40") / 1000000, # $2.40 per 1M tokens - ), - "gpt-4o-search-preview": TokenPricingModel( - prompt_token_price=Decimal("2.50") / 1000000, # $2.50 per 1M tokens - completion_token_price=Decimal("10.00") / 1000000, # $10.00 per 1M tokens - ), - "gpt-4o-mini-search-preview": TokenPricingModel( - prompt_token_price=Decimal("0.15") / 1000000, # $0.15 per 1M tokens - completion_token_price=Decimal("0.60") / 1000000, # $0.60 per 1M tokens - ), - "o1": TokenPricingModel( - prompt_token_price=Decimal("15.00") / 1000000, # $15.00 per 1M tokens - completion_token_price=Decimal("60.00") / 1000000, # $60.00 per 1M tokens - ), - "o1-pro": TokenPricingModel( - prompt_token_price=Decimal("150.00") / 1000000, # $150.00 per 1M tokens - completion_token_price=Decimal("600.00") / 1000000, # $600.00 per 1M tokens - ), - "o1-mini": TokenPricingModel( - prompt_token_price=Decimal("1.10") / 1000000, # $1.10 per 1M tokens - completion_token_price=Decimal("4.40") / 1000000, # $4.40 per 1M tokens - ), - "o3": TokenPricingModel( - prompt_token_price=Decimal("10.00") / 1000000, # $10.00 per 1M tokens - completion_token_price=Decimal("40.00") / 1000000, # $40.00 per 1M tokens - ), - "o3-mini": TokenPricingModel( - prompt_token_price=Decimal("1.10") / 1000000, # $1.10 per 1M tokens - completion_token_price=Decimal("4.40") / 1000000, # $4.40 per 1M tokens - ), - "o4-mini": TokenPricingModel( - prompt_token_price=Decimal("1.10") / 1000000, # $1.10 per 1M tokens - completion_token_price=Decimal("4.40") / 1000000, # $4.40 per 1M tokens - ), - "computer-use-preview": TokenPricingModel( - prompt_token_price=Decimal("3.00") / 1000000, # $3.00 per 1M tokens - completion_token_price=Decimal("12.00") / 1000000, # $12.00 per 1M tokens - ), - "gpt-image-1": TokenPricingModel( - prompt_token_price=Decimal("5.00") / 1000000, # $5.00 per 1M tokens - completion_token_price=Decimal("0") / 1000000, # No output pricing shown - ), - "codex-mini-latest": TokenPricingModel( - prompt_token_price=Decimal("1.50") / 1000000, # $1.50 per 1M tokens - completion_token_price=Decimal("6.00") / 1000000, # $6.00 per 1M tokens - ), - # Transcription models - "gpt-4o-transcribe": TokenPricingModel( - prompt_token_price=Decimal("2.50") / 1000000, # $2.50 per 1M tokens - completion_token_price=Decimal("10.00") / 1000000, # $10.00 per 1M tokens - ), - "gpt-4o-mini-transcribe": TokenPricingModel( - prompt_token_price=Decimal("1.25") / 1000000, # $1.25 per 1M tokens - completion_token_price=Decimal("5.00") / 1000000, # $5.00 per 1M tokens - ), - # TTS models with token-based pricing - "gpt-4o-mini-tts": TokenPricingModel( - prompt_token_price=Decimal("0.60") / 1000000, # $0.60 per 1M tokens - completion_token_price=Decimal("0") - / 1000000, # No completion tokens for TTS - ), - }, - ServiceProviders.GROQ: { - "llama-3.3-70b-versatile": TokenPricingModel( - prompt_token_price=Decimal("0.00059") / 1000, # $0.00059 per 1K tokens - completion_token_price=Decimal("0.00079") / 1000, # $0.00079 per 1K tokens - ), - "deepseek-r1-distill-llama-70b": TokenPricingModel( - prompt_token_price=Decimal("0.00059") / 1000, # Assuming similar pricing - completion_token_price=Decimal("0.00079") / 1000, - ), - }, - ServiceProviders.AZURE: { - "gpt-4.1-mini": TokenPricingModel( - prompt_token_price=Decimal("0.44") / 1000000, # $0.40 per 1M tokens - completion_token_price=Decimal("8.80") - / 1000000, # $1.60 per 1M tokens if using data zone - ) - }, -} diff --git a/api/services/pricing/models.py b/api/services/pricing/models.py deleted file mode 100644 index 58e197ac..00000000 --- a/api/services/pricing/models.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -Base pricing models for different service types. -""" - -from decimal import Decimal -from enum import Enum -from typing import Any, Dict - - -class CostType(Enum): - LLM_TOKENS = "llm_tokens" - TTS_CHARACTERS = "tts_characters" - STT_SECONDS = "stt_seconds" - - -class PricingModel: - """Base class for pricing models""" - - def calculate_cost(self, usage: Any) -> Decimal: - """Calculate cost based on usage""" - raise NotImplementedError - - -class TokenPricingModel(PricingModel): - """Pricing model for token-based services (LLM)""" - - def __init__( - self, - prompt_token_price: Decimal, - completion_token_price: Decimal, - cache_read_discount: Decimal = Decimal("0.5"), # 50% discount for cache reads - cache_creation_multiplier: Decimal = Decimal( - "1.25" - ), # 25% premium for cache creation - ): - self.prompt_token_price = prompt_token_price - self.completion_token_price = completion_token_price - self.cache_read_discount = cache_read_discount - self.cache_creation_multiplier = cache_creation_multiplier - - def calculate_cost(self, usage: Dict[str, int]) -> Decimal: - """Calculate cost for LLM token usage""" - prompt_tokens = usage.get("prompt_tokens", 0) - completion_tokens = usage.get("completion_tokens", 0) - cache_read_tokens = usage.get("cache_read_input_tokens") or 0 - cache_creation_tokens = usage.get("cache_creation_input_tokens") or 0 - - # Base cost - prompt_cost = Decimal(prompt_tokens) * self.prompt_token_price - completion_cost = Decimal(completion_tokens) * self.completion_token_price - - # Cache adjustments - cache_read_savings = ( - Decimal(cache_read_tokens) - * self.prompt_token_price - * self.cache_read_discount - ) - cache_creation_premium = ( - Decimal(cache_creation_tokens) - * self.prompt_token_price - * (self.cache_creation_multiplier - 1) - ) - - total_cost = ( - prompt_cost + completion_cost - cache_read_savings + cache_creation_premium - ) - return max(total_cost, Decimal("0")) # Ensure non-negative - - -class CharacterPricingModel(PricingModel): - """Pricing model for character-based services (TTS)""" - - def __init__(self, character_price: Decimal): - self.character_price = character_price - - def calculate_cost(self, character_count: int) -> Decimal: - """Calculate cost for TTS character usage""" - return Decimal(character_count) * self.character_price - - -class TimePricingModel(PricingModel): - """Pricing model for time-based services (STT)""" - - def __init__(self, second_price: Decimal): - self.second_price = second_price - - def calculate_cost(self, seconds: float) -> Decimal: - """Calculate cost for STT time usage""" - return Decimal(str(seconds)) * self.second_price diff --git a/api/services/pricing/registry.py b/api/services/pricing/registry.py deleted file mode 100644 index 294a94a2..00000000 --- a/api/services/pricing/registry.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -Main pricing registry that combines all service type pricing models. -""" - -from typing import Dict - -from .embeddings import EMBEDDINGS_PRICING -from .llm import LLM_PRICING -from .stt import STT_PRICING -from .tts import TTS_PRICING - -# Combined pricing registry for all service types -PRICING_REGISTRY: Dict = { - "llm": LLM_PRICING, - "tts": TTS_PRICING, - "stt": STT_PRICING, - "embeddings": EMBEDDINGS_PRICING, -} diff --git a/api/services/pricing/run_usage_response.py b/api/services/pricing/run_usage_response.py deleted file mode 100644 index a1f85a47..00000000 --- a/api/services/pricing/run_usage_response.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Format workflow run usage for public API responses.""" - - -def format_public_usage_info(usage_info: dict | None) -> dict | None: - if not usage_info: - return None - - return { - "llm": usage_info.get("llm") or {}, - "tts": usage_info.get("tts") or {}, - "stt": usage_info.get("stt") or {}, - "call_duration_seconds": usage_info.get("call_duration_seconds"), - } diff --git a/api/services/pricing/stt.py b/api/services/pricing/stt.py deleted file mode 100644 index ca00ff4c..00000000 --- a/api/services/pricing/stt.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -STT (Speech-to-Text) pricing models for different providers. - -Prices are per second for STT services. -""" - -from decimal import Decimal -from typing import Dict - -from api.services.configuration.registry import ServiceProviders - -from .models import TimePricingModel - -# STT pricing registry -STT_PRICING: Dict[str, Dict[str, TimePricingModel]] = { - ServiceProviders.DEEPGRAM: { - "nova-3-general": TimePricingModel(Decimal("0.0077") / 60), - "nova-2": TimePricingModel(Decimal("0.0058") / 60), - "default": TimePricingModel(Decimal("0.0077") / 60), - }, - ServiceProviders.OPENAI: { - "gpt-4o-transcribe": TimePricingModel(Decimal("0.015") / 60), - "default": TimePricingModel(Decimal("0.015") / 60), - }, - "default": {"default": TimePricingModel(Decimal("0.0077") / 60)}, -} diff --git a/api/services/pricing/tts.py b/api/services/pricing/tts.py deleted file mode 100644 index 7485cc7f..00000000 --- a/api/services/pricing/tts.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -TTS (Text-to-Speech) pricing models for different providers. - -Prices are per character for TTS services. -""" - -from decimal import Decimal -from typing import Dict - -from api.services.configuration.registry import ServiceProviders - -from .models import CharacterPricingModel - -# TTS pricing registry -TTS_PRICING: Dict[str, Dict[str, CharacterPricingModel]] = { - ServiceProviders.OPENAI: { - "gpt-4o-mini-tts": CharacterPricingModel(Decimal("0.6") / 1_00_00_000), - "default": CharacterPricingModel(Decimal("0.6") / 1_00_00_000), - }, - ServiceProviders.DEEPGRAM: { - "aura-2": CharacterPricingModel(Decimal("0.030") / 1_000), - "aura-1": CharacterPricingModel(Decimal("0.015") / 1_000), - "default": CharacterPricingModel(Decimal("0.030") / 1_000), - }, - ServiceProviders.ELEVENLABS: { - # 6400 usd per 250*1e6 characters - "default": CharacterPricingModel(Decimal("0.0256") / 1_000) - }, - "default": {"default": CharacterPricingModel(Decimal("0.030") / 1_000)}, -} diff --git a/api/services/pricing/workflow_run_cost.py b/api/services/pricing/workflow_run_cost.py deleted file mode 100644 index 6d6010c3..00000000 --- a/api/services/pricing/workflow_run_cost.py +++ /dev/null @@ -1,230 +0,0 @@ -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_for_run - - -async def _fetch_telephony_cost(workflow_run) -> dict | None: - """Fetch telephony call cost. Returns a dict with cost_usd and provider_name, or None.""" - if ( - workflow_run.mode - not in [WorkflowRunMode.TWILIO.value, WorkflowRunMode.VONAGE.value] - or not workflow_run.cost_info - ): - return None - - call_id = workflow_run.cost_info.get("call_id") - if not call_id: - logger.warning(f"call_id not found in cost_info") - return None - - provider_name = workflow_run.mode.lower() if workflow_run.mode else "" - - workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) - if not workflow: - logger.warning("Workflow not found for workflow run") - raise Exception("Workflow not found") - - 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": - logger.error( - f"Failed to fetch {provider_name} call cost: {call_cost_info.get('error')}" - ) - return None - - cost_usd = call_cost_info.get("cost_usd", 0.0) - logger.info( - f"{provider_name.title()} call cost: ${cost_usd:.6f} USD for call {call_id}" - ) - return {"cost_usd": cost_usd, "provider_name": provider_name} - - -async def _update_organization_usage( - org, dograh_tokens: float, duration_seconds: float, charge_usd: float | None -) -> None: - """Update organization usage after a workflow run.""" - org_id = org.id - await db_client.update_usage_after_run( - org_id, dograh_tokens, duration_seconds, charge_usd - ) - if charge_usd is not None: - logger.info( - f"Updated organization usage with ${charge_usd:.2f} USD ({dograh_tokens} Dograh Tokens) and {duration_seconds}s duration for org {org_id}" - ) - else: - logger.info( - f"Updated organization usage with {dograh_tokens} Dograh Tokens and {duration_seconds}s duration for org {org_id}" - ) - - -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) - - -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 None - - cost_breakdown = cost_calculator.calculate_total_cost(usage_info) - - if include_telephony_cost and workflow_run is not None: - try: - telephony_cost = await _fetch_telephony_cost(workflow_run) - if telephony_cost: - telephony_cost_usd = telephony_cost["cost_usd"] - provider_name = telephony_cost["provider_name"] - cost_breakdown["telephony_call"] = telephony_cost_usd - cost_breakdown[f"{provider_name}_call"] = telephony_cost_usd - cost_breakdown["total"] = ( - float(cost_breakdown["total"]) + telephony_cost_usd - ) - except Exception as e: - logger.error(f"Failed to fetch telephony call cost: {e}") - # Don't fail the whole cost calculation if telephony API fails - - total_cost_usd = Decimal(str(cost_breakdown["total"])) - dograh_tokens = float(total_cost_usd * Decimal("100")) - - if organization is None and workflow_run is not None: - organization = await _get_pricing_organization(workflow_run) - - 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 = { - "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), - } - - if charge_usd is not None: - cost_info["charge_usd"] = charge_usd - cost_info["price_per_second_usd"] = organization.price_per_second_usd - - return cost_info - - -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}" - ) - 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_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/reports/run_report.py b/api/services/reports/run_report.py index b84a6f96..a5e64819 100644 --- a/api/services/reports/run_report.py +++ b/api/services/reports/run_report.py @@ -53,7 +53,7 @@ def build_run_report_csv(runs: List[Any]) -> io.StringIO: for run in runs: initial = run.initial_context or {} gathered = run.gathered_context or {} - cost = run.cost_info or {} + usage = run.usage_info or {} call_tags = gathered.get("call_tags", []) if isinstance(call_tags, list): @@ -67,7 +67,7 @@ def build_run_report_csv(runs: List[Any]) -> io.StringIO: run.created_at.isoformat() if run.created_at else "", initial.get("phone_number", ""), gathered.get("mapped_call_disposition", ""), - cost.get("call_duration_seconds", ""), + usage.get("call_duration_seconds", ""), ] extracted = gathered.get("extracted_variables", {}) diff --git a/api/services/telephony/providers/vonage/routes.py b/api/services/telephony/providers/vonage/routes.py index a4cca35d..c862e745 100644 --- a/api/services/telephony/providers/vonage/routes.py +++ b/api/services/telephony/providers/vonage/routes.py @@ -66,34 +66,6 @@ async def handle_vonage_events( logger.error(f"[run {workflow_run_id}] Workflow run not found") return {"status": "error", "message": "Workflow run not found"} - # For a completed call that includes cost info, capture it immediately - if event_data.get("status") == "completed": - # Vonage sometimes includes price info in the webhook - if "price" in event_data or "rate" in event_data: - try: - if workflow_run.cost_info: - # Store immediate cost info if available - cost_info = workflow_run.cost_info.copy() - if "price" in event_data: - cost_info["vonage_webhook_price"] = float(event_data["price"]) - if "rate" in event_data: - cost_info["vonage_webhook_rate"] = float(event_data["rate"]) - if "duration" in event_data: - cost_info["vonage_webhook_duration"] = int( - event_data["duration"] - ) - - await db_client.update_workflow_run( - run_id=workflow_run_id, cost_info=cost_info - ) - logger.info( - f"[run {workflow_run_id}] Captured Vonage cost info from webhook" - ) - except Exception as e: - logger.error( - f"[run {workflow_run_id}] Failed to capture Vonage cost from webhook: {e}" - ) - # Get workflow and provider workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) if not workflow: diff --git a/api/services/workflow/pipecat_engine.py b/api/services/workflow/pipecat_engine.py index cea1d21f..a0d67947 100644 --- a/api/services/workflow/pipecat_engine.py +++ b/api/services/workflow/pipecat_engine.py @@ -35,6 +35,7 @@ import asyncio from loguru import logger +from api.services.managed_model_services import MPS_CORRELATION_ID_CONTEXT_KEY 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 ( @@ -382,6 +383,9 @@ class PipecatEngine: embeddings_provider=self._embeddings_provider, embeddings_endpoint=self._embeddings_endpoint, embeddings_api_version=self._embeddings_api_version, + correlation_id=self._call_context_vars.get( + MPS_CORRELATION_ID_CONTEXT_KEY + ), tracing_context=self._get_otel_context(), ) diff --git a/api/services/workflow/run_usage_response.py b/api/services/workflow/run_usage_response.py new file mode 100644 index 00000000..c289e565 --- /dev/null +++ b/api/services/workflow/run_usage_response.py @@ -0,0 +1,41 @@ +"""Format workflow run usage for public API responses.""" + + +def format_public_usage_info(usage_info: dict | None) -> dict | None: + if not usage_info: + return None + + return { + "llm": usage_info.get("llm") or {}, + "tts": usage_info.get("tts") or {}, + "stt": usage_info.get("stt") or {}, + "call_duration_seconds": usage_info.get("call_duration_seconds"), + } + + +def format_public_cost_info( + cost_info: dict | None, usage_info: dict | None +) -> dict | None: + """Return the legacy response shape without doing local cost accounting.""" + duration = None + if usage_info and usage_info.get("call_duration_seconds") is not None: + duration = int(round(usage_info.get("call_duration_seconds") or 0)) + elif cost_info and cost_info.get("call_duration_seconds") is not None: + duration = int(round(cost_info.get("call_duration_seconds") or 0)) + + dograh_token_usage = 0 + if cost_info: + if "dograh_token_usage" in cost_info: + dograh_token_usage = cost_info.get("dograh_token_usage") or 0 + elif "total_cost_usd" in cost_info: + dograh_token_usage = round( + float(cost_info.get("total_cost_usd", 0)) * 100, 2 + ) + + if duration is None and dograh_token_usage == 0: + return None + + return { + "dograh_token_usage": dograh_token_usage, + "call_duration_seconds": duration, + } diff --git a/api/services/workflow/text_chat_runner.py b/api/services/workflow/text_chat_runner.py index 59073c80..7f6c5a0b 100644 --- a/api/services/workflow/text_chat_runner.py +++ b/api/services/workflow/text_chat_runner.py @@ -421,7 +421,19 @@ async def execute_text_chat_pending_turn( if user_config.llm is None: raise ValueError("Text chat requires an LLM configuration") - llm = create_llm_service(user_config) + from api.services.managed_model_services import ( + MPS_CORRELATION_ID_CONTEXT_KEY, + ensure_mps_correlation_id, + ) + + base_initial_context = dict(workflow_run.initial_context or {}) + mps_correlation_id = await ensure_mps_correlation_id( + ai_model_config=user_config, + workflow_run_id=workflow_run_id, + initial_context=base_initial_context, + ) + + llm = create_llm_service(user_config, correlation_id=mps_correlation_id) inference_llm = llm runtime_configuration = { @@ -429,9 +441,15 @@ async def execute_text_chat_pending_turn( "llm_model": user_config.llm.model, } initial_context = { - **(workflow_run.initial_context or {}), + **base_initial_context, "runtime_configuration": runtime_configuration, } + if mps_correlation_id: + initial_context[MPS_CORRELATION_ID_CONTEXT_KEY] = mps_correlation_id + await db_client.update_workflow_run( + workflow_run_id, + initial_context=initial_context, + ) workflow_graph = WorkflowGraph( ReactFlowDTO.model_validate(run_definition.workflow_json) diff --git a/api/services/workflow/text_chat_session_service.py b/api/services/workflow/text_chat_session_service.py index 53354d5f..81749960 100644 --- a/api/services/workflow/text_chat_session_service.py +++ b/api/services/workflow/text_chat_session_service.py @@ -4,17 +4,11 @@ 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, ) @@ -261,20 +255,6 @@ async def execute_pending_text_chat_turn( 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) diff --git a/api/services/workflow/tools/knowledge_base.py b/api/services/workflow/tools/knowledge_base.py index 6ce8f8c7..7b93aea7 100644 --- a/api/services/workflow/tools/knowledge_base.py +++ b/api/services/workflow/tools/knowledge_base.py @@ -29,6 +29,7 @@ async def retrieve_from_knowledge_base( embeddings_provider: Optional[str] = None, embeddings_endpoint: Optional[str] = None, embeddings_api_version: Optional[str] = None, + correlation_id: Optional[str] = None, tracing_context=None, ) -> Dict[str, Any]: """Retrieve relevant information from the knowledge base using vector similarity search. @@ -75,6 +76,7 @@ async def retrieve_from_knowledge_base( embeddings_provider, embeddings_endpoint, embeddings_api_version, + correlation_id, ) # Create span with parent context @@ -115,6 +117,7 @@ async def retrieve_from_knowledge_base( embeddings_provider, embeddings_endpoint, embeddings_api_version, + correlation_id, ) # Add result metadata to span @@ -192,6 +195,7 @@ async def retrieve_from_knowledge_base( embeddings_provider, embeddings_endpoint, embeddings_api_version, + correlation_id, ) else: # Tracing is disabled - perform retrieval without tracing @@ -206,6 +210,7 @@ async def retrieve_from_knowledge_base( embeddings_provider, embeddings_endpoint, embeddings_api_version, + correlation_id, ) @@ -220,6 +225,7 @@ async def _perform_retrieval( embeddings_provider: Optional[str] = None, embeddings_endpoint: Optional[str] = None, embeddings_api_version: Optional[str] = None, + correlation_id: Optional[str] = None, ) -> Dict[str, Any]: """Internal function to perform the actual retrieval operation. @@ -272,11 +278,20 @@ async def _perform_retrieval( api_version=embeddings_api_version or "2024-02-15-preview", ) else: + default_headers = None + if ( + embeddings_provider == ServiceProviders.DOGRAH.value + and correlation_id + ): + default_headers = { + "X-Dograh-Correlation-Id": correlation_id, + } embedding_service = OpenAIEmbeddingService( db_client=db_client, api_key=embeddings_api_key, model_id=embeddings_model or "text-embedding-3-small", base_url=embeddings_base_url, + default_headers=default_headers, ) results = await embedding_service.search_similar_chunks( diff --git a/api/services/workflow_run_billing.py b/api/services/workflow_run_billing.py new file mode 100644 index 00000000..ab8a3121 --- /dev/null +++ b/api/services/workflow_run_billing.py @@ -0,0 +1,111 @@ +"""Workflow-run billing hooks. + +Dograh does not rate or deduct credits locally. MPS owns credit accounting. +For hosted deployments, Dograh reports completed platform usage to MPS. +When a server-minted MPS correlation id exists, MPS uses model-service usage +as the canonical duration. Otherwise Dograh reports the completed run duration. +""" + +from typing import Any + +from loguru import logger + +from api.constants import DEPLOYMENT_MODE +from api.db import db_client +from api.services.managed_model_services import get_mps_correlation_id +from api.services.mps_service_key_client import mps_service_key_client + + +def _workflow_run_organization_id(workflow_run) -> int | None: + workflow = getattr(workflow_run, "workflow", None) + return getattr(workflow, "organization_id", None) + + +def _duration_seconds_from_usage_info(workflow_run) -> float | None: + usage_info: dict[str, Any] = getattr(workflow_run, "usage_info", None) or {} + duration = usage_info.get("call_duration_seconds") + try: + duration_seconds = float(duration) + except (TypeError, ValueError): + return None + + return duration_seconds if duration_seconds > 0 else None + + +async def _organization_uses_mps_billing_v2(organization_id: int) -> bool: + account = await mps_service_key_client.get_billing_account_status( + organization_id=organization_id + ) + return bool(account and account.get("billing_mode") == "v2") + + +async def report_workflow_run_platform_usage(workflow_run) -> None: + """Report hosted platform usage for a completed workflow run to MPS.""" + if DEPLOYMENT_MODE == "oss": + return + + if not getattr(workflow_run, "is_completed", False): + return + + organization_id = _workflow_run_organization_id(workflow_run) + if organization_id is None: + logger.warning( + "Skipping platform usage report for workflow run {}: no organization_id", + workflow_run.id, + ) + return + + correlation_id = get_mps_correlation_id( + getattr(workflow_run, "initial_context", None) + ) + duration_seconds = ( + None if correlation_id else _duration_seconds_from_usage_info(workflow_run) + ) + if not correlation_id and duration_seconds is None: + logger.warning( + "Skipping platform usage report for workflow run {}: no billable duration", + workflow_run.id, + ) + return + + try: + if not await _organization_uses_mps_billing_v2(organization_id): + return + + result = await mps_service_key_client.report_platform_usage( + organization_id=organization_id, + correlation_id=correlation_id, + duration_seconds=duration_seconds, + workflow_run_id=workflow_run.id, + metadata={ + "source": "workflow_run_completion", + "workflow_id": getattr(workflow_run, "workflow_id", None), + "duration_source": ( + "mps_correlation" if correlation_id else "dograh_usage_info" + ), + }, + ) + logger.info( + "Reported platform usage for workflow run {} to MPS: {}", + workflow_run.id, + result, + ) + except Exception as e: + logger.error( + "Failed to report platform usage for workflow run {}: {}", + workflow_run.id, + e, + ) + + +async def report_completed_workflow_run_platform_usage(workflow_run_id: int) -> None: + """Load a completed workflow run and report platform usage to MPS.""" + workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) + if not workflow_run: + logger.warning( + "Skipping platform usage report: workflow run {} not found", + workflow_run_id, + ) + return + + await report_workflow_run_platform_usage(workflow_run) diff --git a/api/tasks/arq.py b/api/tasks/arq.py index a948a578..442114e6 100644 --- a/api/tasks/arq.py +++ b/api/tasks/arq.py @@ -45,10 +45,8 @@ from api.tasks.campaign_tasks import ( ) from api.tasks.knowledge_base_processing import process_knowledge_base_document from api.tasks.run_integrations import run_integrations_post_workflow_run -from api.tasks.s3_upload import ( - process_workflow_completion, - upload_voicemail_audio_to_s3, -) +from api.tasks.s3_upload import upload_voicemail_audio_to_s3 +from api.tasks.workflow_completion import process_workflow_completion class WorkerSettings: diff --git a/api/tasks/knowledge_base_processing.py b/api/tasks/knowledge_base_processing.py index f496ac0e..a6ca0d6d 100644 --- a/api/tasks/knowledge_base_processing.py +++ b/api/tasks/knowledge_base_processing.py @@ -166,18 +166,22 @@ async def process_knowledge_base_document( user_id=document.created_by, organization_id=document.organization_id, ) - user_config = resolved_config.effective - if user_config.embeddings: - embeddings_provider = getattr(user_config.embeddings, "provider", None) - embeddings_api_key = user_config.embeddings.api_key - embeddings_model = user_config.embeddings.model + effective_config = resolved_config.effective + if effective_config.embeddings: + embeddings_provider = getattr( + effective_config.embeddings, "provider", None + ) + embeddings_api_key = effective_config.embeddings.api_key + embeddings_model = effective_config.embeddings.model embeddings_base_url = apply_managed_embeddings_base_url( provider=embeddings_provider, - base_url=getattr(user_config.embeddings, "base_url", None), + base_url=getattr(effective_config.embeddings, "base_url", None), + ) + embeddings_endpoint = getattr( + effective_config.embeddings, "endpoint", None ) - embeddings_endpoint = getattr(user_config.embeddings, "endpoint", None) embeddings_api_version = getattr( - user_config.embeddings, "api_version", None + effective_config.embeddings, "api_version", None ) logger.info( f"Using user embeddings config: provider={embeddings_provider}, " diff --git a/api/tasks/s3_upload.py b/api/tasks/s3_upload.py index b2086c09..bbbc8bf4 100644 --- a/api/tasks/s3_upload.py +++ b/api/tasks/s3_upload.py @@ -1,13 +1,9 @@ import os -from typing import Optional from loguru import logger from pipecat.utils.run_context import set_current_run_id -from api.db import db_client -from api.services.pricing.workflow_run_cost import calculate_workflow_run_cost -from api.services.storage import get_current_storage_backend, storage_fs -from api.tasks.run_integrations import run_integrations_post_workflow_run +from api.services.storage import storage_fs async def upload_voicemail_audio_to_s3( @@ -69,110 +65,3 @@ async def upload_voicemail_audio_to_s3( logger.warning( f"Failed to clean up temp voicemail audio file {temp_file_path}: {e}" ) - - -async def process_workflow_completion( - _ctx, - workflow_run_id: int, - audio_temp_path: Optional[str] = None, - transcript_temp_path: Optional[str] = None, -): - """Process workflow completion: upload artifacts and run integrations. - - This task combines audio upload, transcript upload, and webhook integrations - into a single sequential task to ensure integrations run after uploads complete. - - Args: - _ctx: ARQ context (unused) - workflow_run_id: The workflow run ID - audio_temp_path: Optional path to temp audio file - transcript_temp_path: Optional path to temp transcript file - """ - run_id = str(workflow_run_id) - set_current_run_id(run_id) - - logger.info(f"Processing workflow completion for run {workflow_run_id}") - - storage_backend = get_current_storage_backend() - - # Step 1: Upload audio if provided - if audio_temp_path: - try: - if os.path.exists(audio_temp_path): - file_size = os.path.getsize(audio_temp_path) - logger.debug(f"Audio file size: {file_size} bytes") - - recording_url = f"recordings/{workflow_run_id}.wav" - logger.info( - f"Uploading audio to {storage_backend.name} - workflow_run_id: {workflow_run_id}" - ) - - await storage_fs.aupload_file(audio_temp_path, recording_url) - await db_client.update_workflow_run( - run_id=workflow_run_id, - recording_url=recording_url, - storage_backend=storage_backend.value, - ) - logger.info(f"Successfully uploaded audio: {recording_url}") - else: - logger.warning(f"Audio temp file not found: {audio_temp_path}") - except Exception as e: - logger.error(f"Error uploading audio for workflow {workflow_run_id}: {e}") - finally: - if audio_temp_path and os.path.exists(audio_temp_path): - try: - os.remove(audio_temp_path) - logger.debug(f"Cleaned up temp audio file: {audio_temp_path}") - except Exception as e: - logger.warning(f"Failed to clean up temp audio file: {e}") - - # Step 2: Upload transcript if provided - if transcript_temp_path: - try: - if os.path.exists(transcript_temp_path): - file_size = os.path.getsize(transcript_temp_path) - logger.debug(f"Transcript file size: {file_size} bytes") - - transcript_url = f"transcripts/{workflow_run_id}.txt" - logger.info( - f"Uploading transcript to {storage_backend.name} - workflow_run_id: {workflow_run_id}" - ) - - await storage_fs.aupload_file(transcript_temp_path, transcript_url) - await db_client.update_workflow_run( - run_id=workflow_run_id, - transcript_url=transcript_url, - storage_backend=storage_backend.value, - ) - logger.info(f"Successfully uploaded transcript: {transcript_url}") - else: - logger.warning( - f"Transcript temp file not found: {transcript_temp_path}" - ) - except Exception as e: - logger.error( - f"Error uploading transcript for workflow {workflow_run_id}: {e}" - ) - finally: - if transcript_temp_path and os.path.exists(transcript_temp_path): - try: - os.remove(transcript_temp_path) - logger.debug( - f"Cleaned up temp transcript file: {transcript_temp_path}" - ) - except Exception as e: - logger.warning(f"Failed to clean up temp transcript file: {e}") - - # Step 3: Run integrations including QA analysis (after uploads are complete) - try: - await run_integrations_post_workflow_run(_ctx, workflow_run_id) - except Exception as e: - logger.error(f"Error running integrations for workflow {workflow_run_id}: {e}") - - # Step 4: Calculate cost after integrations (so QA token usage is included) - try: - await calculate_workflow_run_cost(workflow_run_id) - except Exception as e: - logger.error(f"Error calculating cost for workflow {workflow_run_id}: {e}") - - logger.info(f"Completed workflow completion processing for run {workflow_run_id}") diff --git a/api/tasks/workflow_completion.py b/api/tasks/workflow_completion.py new file mode 100644 index 00000000..ff0482d2 --- /dev/null +++ b/api/tasks/workflow_completion.py @@ -0,0 +1,121 @@ +import os +from typing import Optional + +from loguru import logger +from pipecat.utils.run_context import set_current_run_id + +from api.db import db_client +from api.services.storage import get_current_storage_backend, storage_fs +from api.services.workflow_run_billing import ( + report_completed_workflow_run_platform_usage, +) +from api.tasks.run_integrations import run_integrations_post_workflow_run + + +async def process_workflow_completion( + _ctx, + workflow_run_id: int, + audio_temp_path: Optional[str] = None, + transcript_temp_path: Optional[str] = None, +): + """Process workflow completion: upload artifacts and run integrations. + + This task combines audio upload, transcript upload, and webhook integrations + into a single sequential task to ensure integrations run after uploads complete. + + Args: + _ctx: ARQ context (unused) + workflow_run_id: The workflow run ID + audio_temp_path: Optional path to temp audio file + transcript_temp_path: Optional path to temp transcript file + """ + run_id = str(workflow_run_id) + set_current_run_id(run_id) + + logger.info(f"Processing workflow completion for run {workflow_run_id}") + + storage_backend = get_current_storage_backend() + + # Step 1: Upload audio if provided + if audio_temp_path: + try: + if os.path.exists(audio_temp_path): + file_size = os.path.getsize(audio_temp_path) + logger.debug(f"Audio file size: {file_size} bytes") + + recording_url = f"recordings/{workflow_run_id}.wav" + logger.info( + f"Uploading audio to {storage_backend.name} - workflow_run_id: {workflow_run_id}" + ) + + await storage_fs.aupload_file(audio_temp_path, recording_url) + await db_client.update_workflow_run( + run_id=workflow_run_id, + recording_url=recording_url, + storage_backend=storage_backend.value, + ) + logger.info(f"Successfully uploaded audio: {recording_url}") + else: + logger.warning(f"Audio temp file not found: {audio_temp_path}") + except Exception as e: + logger.error(f"Error uploading audio for workflow {workflow_run_id}: {e}") + finally: + if audio_temp_path and os.path.exists(audio_temp_path): + try: + os.remove(audio_temp_path) + logger.debug(f"Cleaned up temp audio file: {audio_temp_path}") + except Exception as e: + logger.warning(f"Failed to clean up temp audio file: {e}") + + # Step 2: Upload transcript if provided + if transcript_temp_path: + try: + if os.path.exists(transcript_temp_path): + file_size = os.path.getsize(transcript_temp_path) + logger.debug(f"Transcript file size: {file_size} bytes") + + transcript_url = f"transcripts/{workflow_run_id}.txt" + logger.info( + f"Uploading transcript to {storage_backend.name} - workflow_run_id: {workflow_run_id}" + ) + + await storage_fs.aupload_file(transcript_temp_path, transcript_url) + await db_client.update_workflow_run( + run_id=workflow_run_id, + transcript_url=transcript_url, + storage_backend=storage_backend.value, + ) + logger.info(f"Successfully uploaded transcript: {transcript_url}") + else: + logger.warning( + f"Transcript temp file not found: {transcript_temp_path}" + ) + except Exception as e: + logger.error( + f"Error uploading transcript for workflow {workflow_run_id}: {e}" + ) + finally: + if transcript_temp_path and os.path.exists(transcript_temp_path): + try: + os.remove(transcript_temp_path) + logger.debug( + f"Cleaned up temp transcript file: {transcript_temp_path}" + ) + except Exception as e: + logger.warning(f"Failed to clean up temp transcript file: {e}") + + # Step 3: Run integrations including QA analysis (after uploads are complete) + try: + await run_integrations_post_workflow_run(_ctx, workflow_run_id) + except Exception as e: + logger.error(f"Error running integrations for workflow {workflow_run_id}: {e}") + + # Step 4: Notify MPS after completion. MPS owns credit accounting. + try: + await report_completed_workflow_run_platform_usage(workflow_run_id) + except Exception as e: + logger.error( + f"Error reporting platform usage for workflow {workflow_run_id}: {e}" + ) + + logger.info(f"Completed workflow completion processing for run {workflow_run_id}") diff --git a/api/tests/integrations/_run_pipeline_helpers.py b/api/tests/integrations/_run_pipeline_helpers.py index 1a3251a0..58b4ffd2 100644 --- a/api/tests/integrations/_run_pipeline_helpers.py +++ b/api/tests/integrations/_run_pipeline_helpers.py @@ -203,7 +203,7 @@ async def create_workflow_run_rows( Returns: Tuple of (workflow_run, user, workflow). """ - from api.schemas.user_configuration import EffectiveAIModelConfiguration + from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration org = OrganizationModel(provider_id=f"test-org-{provider_id_suffix}") async_session.add(org) diff --git a/api/tests/test_ai_model_configuration_v2.py b/api/tests/test_ai_model_configuration_v2.py index 98f431e8..57f7cf83 100644 --- a/api/tests/test_ai_model_configuration_v2.py +++ b/api/tests/test_ai_model_configuration_v2.py @@ -1,12 +1,16 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + import pytest from pydantic import ValidationError from api.schemas.ai_model_configuration import ( DograhManagedAIModelConfiguration, + EffectiveAIModelConfiguration, + OrganizationAIModelConfigurationResponse, OrganizationAIModelConfigurationV2, compile_ai_model_configuration_v2, ) -from api.schemas.user_configuration import EffectiveAIModelConfiguration from api.services.configuration.ai_model_configuration import ( WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY, check_for_masked_keys_in_ai_model_configuration_v2, @@ -15,6 +19,7 @@ from api.services.configuration.ai_model_configuration import ( merge_ai_model_configuration_v2_secrets, migrate_workflow_configuration_model_override_to_v2, ) +from api.services.configuration.check_validity import UserConfigurationValidator from api.services.configuration.masking import mask_key from api.services.configuration.registry import ( DeepgramSTTConfiguration, @@ -22,6 +27,8 @@ from api.services.configuration.registry import ( DograhSTTService, DograhTTSService, ElevenlabsTTSConfiguration, + GoogleLLMService, + GoogleRealtimeLLMConfiguration, OpenAIEmbeddingsConfiguration, OpenAILLMService, ) @@ -49,6 +56,7 @@ def test_dograh_v2_compiles_to_effective_managed_pipeline_with_embeddings(): assert effective.stt.language == "multi" assert effective.embeddings.provider == "dograh" assert effective.embeddings.model == "default" + assert effective.managed_service_version == 2 def test_dograh_v2_rejects_non_predefined_speed(): @@ -92,6 +100,67 @@ def test_byok_v2_rejects_dograh_provider(): ) +@pytest.mark.asyncio +async def test_byok_realtime_validator_does_not_require_stt_or_tts(): + config = OrganizationAIModelConfigurationV2.model_validate( + { + "mode": "byok", + "byok": { + "mode": "realtime", + "realtime": { + "realtime": { + "provider": "google_realtime", + "api_key": "google-realtime-key", + "model": "gemini-3.1-flash-live-preview", + "voice": "Puck", + "language": "en", + }, + "llm": { + "provider": "google", + "api_key": "google-llm-key", + "model": "gemini-2.0-flash", + }, + }, + }, + } + ) + effective = compile_ai_model_configuration_v2(config) + + assert effective.is_realtime is True + assert effective.stt is None + assert effective.tts is None + assert await UserConfigurationValidator().validate(effective) == { + "status": [{"model": "all", "message": "ok"}] + } + + +@pytest.mark.asyncio +async def test_pipeline_validator_requires_stt_and_tts_when_not_realtime(): + effective = EffectiveAIModelConfiguration( + llm=GoogleLLMService( + provider="google", + api_key="google-llm-key", + model="gemini-2.0-flash", + ), + realtime=GoogleRealtimeLLMConfiguration( + provider="google_realtime", + api_key="google-realtime-key", + model="gemini-3.1-flash-live-preview", + voice="Puck", + language="en", + ), + is_realtime=False, + ) + + with pytest.raises(ValueError) as exc_info: + await UserConfigurationValidator().validate(effective) + + assert exc_info.value.args[0] == [ + {"model": "stt", "message": "API key is missing"}, + {"model": "tts", "message": "API key is missing"}, + ] + + def test_masked_dograh_key_is_preserved_when_saving_same_mode(): existing = OrganizationAIModelConfigurationV2( mode="dograh", @@ -293,3 +362,98 @@ def test_workflow_model_override_migration_removes_invalid_v1_override_marker(): assert changed is True assert "model_overrides" not in migrated assert migrated["ambient_noise_configuration"] == {"enabled": False} + + +@pytest.mark.asyncio +async def test_migrate_model_configuration_v2_initializes_hosted_mps_billing( + monkeypatch, +): + from api.routes import organization as organization_routes + + legacy = EffectiveAIModelConfiguration( + llm=DograhLLMService( + provider="dograh", + api_key=["mps-secret"], + model="default", + ), + tts=DograhTTSService( + provider="dograh", + api_key=["mps-secret"], + model="default", + voice="default", + ), + stt=DograhSTTService( + provider="dograh", + api_key=["mps-secret"], + model="default", + ), + ) + expected_response = OrganizationAIModelConfigurationResponse( + configuration={"version": 2, "mode": "dograh"}, + effective_configuration={}, + source="organization_v2", + ) + + class FakeValidator: + async def validate(self, *args, **kwargs): + return {"status": [{"model": "all", "message": "ok"}]} + + ensure_billing = AsyncMock(return_value={"billing_mode": "v2"}) + upsert = AsyncMock() + migrate_workflows = AsyncMock() + + monkeypatch.setattr(organization_routes, "DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + organization_routes, + "get_organization_ai_model_configuration_v2", + AsyncMock(return_value=None), + ) + monkeypatch.setattr( + organization_routes.db_client, + "get_user_configurations", + AsyncMock(return_value=legacy), + ) + monkeypatch.setattr( + organization_routes, + "UserConfigurationValidator", + lambda: FakeValidator(), + ) + monkeypatch.setattr( + organization_routes, + "ensure_hosted_mps_billing_account_v2", + ensure_billing, + ) + monkeypatch.setattr( + organization_routes, + "upsert_organization_ai_model_configuration_v2", + upsert, + ) + monkeypatch.setattr( + organization_routes, + "migrate_workflow_model_configurations_to_v2", + migrate_workflows, + ) + monkeypatch.setattr( + organization_routes, + "_model_configuration_v2_response", + AsyncMock(return_value=expected_response), + ) + + user = SimpleNamespace( + id=7, + provider_id="provider-123", + selected_organization_id=42, + ) + + response = await organization_routes.migrate_model_configuration_v2( + force=False, + user=user, + ) + + ensure_billing.assert_awaited_once_with(42, created_by="provider-123") + upsert.assert_awaited_once() + migrate_workflows.assert_awaited_once_with( + organization_id=42, + fallback_user_config=legacy, + ) + assert response == expected_response diff --git a/api/tests/test_auth_depends.py b/api/tests/test_auth_depends.py new file mode 100644 index 00000000..2f33ff58 --- /dev/null +++ b/api/tests/test_auth_depends.py @@ -0,0 +1,68 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from api.services.auth import depends as auth_depends + + +@pytest.mark.asyncio +async def test_get_user_initializes_hosted_mps_billing_for_new_org(monkeypatch): + stack_user = { + "id": "stack-user-1", + "selected_team_id": "team-1", + "primary_email_verified": False, + } + user = SimpleNamespace( + id=7, + email=None, + provider_id="stack-user-1", + selected_organization_id=None, + ) + organization = SimpleNamespace(id=42) + existing_config = SimpleNamespace(llm=object(), tts=None, stt=None) + + ensure_billing = AsyncMock(return_value={"billing_mode": "v2"}) + + monkeypatch.setattr(auth_depends, "AUTH_PROVIDER", "stack") + monkeypatch.setattr( + auth_depends.stackauth, + "get_user", + AsyncMock(return_value=stack_user), + ) + monkeypatch.setattr( + auth_depends.db_client, + "get_or_create_user_by_provider_id", + AsyncMock(return_value=(user, False)), + ) + monkeypatch.setattr( + auth_depends.db_client, + "get_or_create_organization_by_provider_id", + AsyncMock(return_value=(organization, True)), + ) + monkeypatch.setattr( + auth_depends.db_client, + "add_user_to_organization", + AsyncMock(), + ) + monkeypatch.setattr( + auth_depends.db_client, + "update_user_selected_organization", + AsyncMock(), + ) + monkeypatch.setattr( + auth_depends.db_client, + "get_user_configurations", + AsyncMock(return_value=existing_config), + ) + monkeypatch.setattr( + auth_depends, + "ensure_hosted_mps_billing_account_v2", + ensure_billing, + ) + + result = await auth_depends.get_user(authorization="Bearer token") + + assert result is user + assert result.selected_organization_id == 42 + ensure_billing.assert_awaited_once_with(42, created_by="stack-user-1") diff --git a/api/tests/test_cost_calculator.py b/api/tests/test_cost_calculator.py deleted file mode 100644 index 940ac582..00000000 --- a/api/tests/test_cost_calculator.py +++ /dev/null @@ -1,31 +0,0 @@ -from api.services.pricing.cost_calculator import cost_calculator - - -def test_cost_calculator(): - """Test function to verify cost calculation works""" - sample_usage = { - "llm": { - "OpenAILLMService#0|||gpt-4.1-mini": { - "prompt_tokens": 45380, - "completion_tokens": 496, - "total_tokens": 45876, - "cache_read_input_tokens": 0, - "cache_creation_input_tokens": 0, - } - }, - "tts": {"ElevenLabsTTSService#0|||eleven_flash_v2_5": 2399}, - "stt": {"DeepgramSTTService#0|||nova-3-general": 177.21536946296692}, - "call_duration_seconds": 179, - } - - result = cost_calculator.calculate_total_cost(sample_usage) - assert result["llm_cost"] == 45380 * 0.40 / 1_000_000 + 496 * 1.60 / 1_000_000 - assert result["tts_cost"] == 2399 * 0.0256 / 1_000 - assert result["stt_cost"] == 177.21536946296692 / 60 * 0.0077 - assert ( - abs( - result["total"] - - (result["llm_cost"] + result["tts_cost"] + result["stt_cost"]) - ) - < 1e-10 - ) diff --git a/api/tests/test_dograh_managed_correlation.py b/api/tests/test_dograh_managed_correlation.py new file mode 100644 index 00000000..b0cb52c0 --- /dev/null +++ b/api/tests/test_dograh_managed_correlation.py @@ -0,0 +1,110 @@ +import json + +import pytest +from openai._types import NOT_GIVEN as OPENAI_NOT_GIVEN +from pipecat.frames.frames import TTSStartedFrame +from pipecat.services.dograh.llm import DograhLLMService +from pipecat.services.dograh.stt import DograhSTTService +from pipecat.services.dograh.tts import DograhTTSService +from pipecat.services.openai.base_llm import OpenAILLMSettings +from websockets.protocol import State + + +class _FakeWebSocket: + def __init__(self): + self.state = State.OPEN + self.messages: list[dict] = [] + + async def send(self, message: str) -> None: + self.messages.append(json.loads(message)) + + async def close(self, *args, **kwargs) -> None: + self.state = State.CLOSED + + +def test_dograh_llm_uses_explicit_mps_correlation_id(): + service = DograhLLMService( + api_key="mps-secret", + correlation_id="mps-corr-123", + settings=OpenAILLMSettings(model="default"), + ) + service._start_metadata = {"workflow_run_id": 99} + + params = service.build_chat_completion_params( + { + "messages": [], + "tools": OPENAI_NOT_GIVEN, + "tool_choice": OPENAI_NOT_GIVEN, + } + ) + + assert params["metadata"]["correlation_id"] == "mps-corr-123" + assert params["metadata"]["mps_billing_version"] == "2" + + +@pytest.mark.asyncio +async def test_dograh_stt_config_uses_explicit_mps_correlation_id(monkeypatch): + fake_ws = _FakeWebSocket() + + async def fake_connect(url, additional_headers): + return fake_ws + + monkeypatch.setattr( + "pipecat.services.dograh.stt.websocket_connect", + fake_connect, + ) + + service = DograhSTTService( + api_key="mps-secret", + correlation_id="mps-corr-123", + sample_rate=16000, + ) + service._start_metadata = {"workflow_run_id": 99} + + await service._connect_websocket() + + assert fake_ws.messages[0]["type"] == "config" + assert fake_ws.messages[0]["correlation_id"] == "mps-corr-123" + assert fake_ws.messages[0]["mps_billing_version"] == "2" + + +@pytest.mark.asyncio +async def test_dograh_tts_messages_use_explicit_mps_correlation_id(monkeypatch): + fake_ws = _FakeWebSocket() + + async def fake_connect(url, additional_headers): + return fake_ws + + monkeypatch.setattr( + "pipecat.services.dograh.tts.websocket_connect", + fake_connect, + ) + + service = DograhTTSService( + api_key="mps-secret", + correlation_id="mps-corr-123", + sample_rate=24000, + ) + service._start_metadata = {"workflow_run_id": 99} + + await service._connect_websocket() + assert fake_ws.messages[0]["type"] == "config" + assert fake_ws.messages[0]["correlation_id"] == "mps-corr-123" + assert fake_ws.messages[0]["mps_billing_version"] == "2" + + async def _noop(*args, **kwargs): + return None + + service.audio_context_available = lambda context_id: False + service.create_audio_context = _noop + service.start_ttfb_metrics = _noop + service.start_tts_usage_metrics = _noop + + frames = [] + async for frame in service.run_tts("hello", "ctx-1"): + frames.append(frame) + + assert isinstance(frames[0], TTSStartedFrame) + assert fake_ws.messages[1]["type"] == "create_context" + assert fake_ws.messages[1]["correlation_id"] == "mps-corr-123" + assert fake_ws.messages[1]["mps_billing_version"] == "2" diff --git a/api/tests/test_grok_realtime_wrapper.py b/api/tests/test_grok_realtime_wrapper.py index 7f7359dc..19cae657 100644 --- a/api/tests/test_grok_realtime_wrapper.py +++ b/api/tests/test_grok_realtime_wrapper.py @@ -7,7 +7,7 @@ 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 EffectiveAIModelConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.services.configuration.registry import GrokRealtimeLLMConfiguration from api.services.pipecat.realtime.grok_realtime import ( DograhGrokRealtimeLLMService, @@ -120,7 +120,7 @@ async def test_completed_input_transcription_is_broadcast_as_finalized(): def test_factory_creates_dograh_grok_realtime_service(): - user_config = EffectiveAIModelConfiguration( + effective_config = EffectiveAIModelConfiguration( is_realtime=True, realtime=GrokRealtimeLLMConfiguration( provider="grok_realtime", @@ -131,7 +131,7 @@ def test_factory_creates_dograh_grok_realtime_service(): ) service = create_realtime_llm_service( - user_config, + effective_config, audio_config=SimpleNamespace(), ) diff --git a/api/tests/test_masked_key_rejection.py b/api/tests/test_masked_key_rejection.py index 2012c60b..45782335 100644 --- a/api/tests/test_masked_key_rejection.py +++ b/api/tests/test_masked_key_rejection.py @@ -5,7 +5,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from api.routes.user import router -from api.schemas.user_configuration import EffectiveAIModelConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.services.auth.depends import get_user from api.services.configuration.masking import mask_key from api.services.configuration.registry import ( diff --git a/api/tests/test_mps_service_key_client.py b/api/tests/test_mps_service_key_client.py index 9cd629e3..032f07bf 100644 --- a/api/tests/test_mps_service_key_client.py +++ b/api/tests/test_mps_service_key_client.py @@ -87,3 +87,317 @@ async def test_check_service_key_usage_uses_bearer_self_usage(monkeypatch): "Content-Type": "application/json", }, ) + + +@pytest.mark.asyncio +async def test_create_correlation_id_uses_bearer_auth(monkeypatch): + calls = [] + + class FakeAsyncClient: + def __init__(self, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url, json, headers): + calls.append(("POST", url, json, headers)) + return _Response(200, {"correlation_id": "mps-corr-123"}) + + monkeypatch.setattr( + "api.services.mps_service_key_client.httpx.AsyncClient", FakeAsyncClient + ) + + client = MPSServiceKeyClient() + + assert await client.create_correlation_id( + service_key="mps_sk_paid", + workflow_run_id=42, + ) == {"correlation_id": "mps-corr-123"} + assert calls == [ + ( + "POST", + f"{client.base_url}/api/v1/service-keys/correlation-id/self", + {"workflow_run_id": 42}, + { + "Authorization": "Bearer mps_sk_paid", + "Content-Type": "application/json", + }, + ) + ] + + +@pytest.mark.asyncio +async def test_get_billing_account_status_uses_hosted_org_auth(monkeypatch): + calls = [] + + class FakeAsyncClient: + def __init__(self, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def get(self, url, headers): + calls.append(("GET", url, headers)) + return _Response(200, {"organization_id": 42, "billing_mode": "v2"}) + + monkeypatch.setattr( + "api.services.mps_service_key_client.httpx.AsyncClient", FakeAsyncClient + ) + monkeypatch.setattr("api.services.mps_service_key_client.DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + "api.services.mps_service_key_client.DOGRAH_MPS_SECRET_KEY", "mps-secret" + ) + + client = MPSServiceKeyClient() + + assert await client.get_billing_account_status(organization_id=42) == { + "organization_id": 42, + "billing_mode": "v2", + } + assert calls == [ + ( + "GET", + f"{client.base_url}/api/v1/billing/accounts/42/status", + { + "Content-Type": "application/json", + "X-Secret-Key": "mps-secret", + "X-Organization-Id": "42", + }, + ) + ] + + +@pytest.mark.asyncio +async def test_ensure_billing_account_v2_uses_balance_endpoint(monkeypatch): + calls = [] + + class FakeAsyncClient: + def __init__(self, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def get(self, url, headers): + calls.append(("GET", url, headers)) + return _Response( + 200, + { + "id": 7, + "organization_id": 42, + "billing_mode": "v2", + "cached_balance_credits": "0.0000", + "currency": "USD", + }, + ) + + monkeypatch.setattr( + "api.services.mps_service_key_client.httpx.AsyncClient", FakeAsyncClient + ) + monkeypatch.setattr("api.services.mps_service_key_client.DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + "api.services.mps_service_key_client.DOGRAH_MPS_SECRET_KEY", "mps-secret" + ) + + client = MPSServiceKeyClient() + + assert await client.ensure_billing_account_v2( + organization_id=42, + created_by="provider-123", + ) == { + "id": 7, + "organization_id": 42, + "billing_mode": "v2", + "cached_balance_credits": "0.0000", + "currency": "USD", + } + assert calls == [ + ( + "GET", + f"{client.base_url}/api/v1/billing/accounts/42/balance", + { + "Content-Type": "application/json", + "X-Secret-Key": "mps-secret", + "X-Organization-Id": "42", + }, + ) + ] + + +@pytest.mark.asyncio +async def test_get_credit_ledger_sends_page_and_limit(monkeypatch): + calls = [] + + class FakeAsyncClient: + def __init__(self, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def get(self, url, params, headers): + calls.append(("GET", url, params, headers)) + return _Response( + 200, + { + "account": {"organization_id": 42}, + "ledger_entries": [], + "total_count": 0, + "page": 3, + "limit": 25, + "total_pages": 0, + }, + ) + + monkeypatch.setattr( + "api.services.mps_service_key_client.httpx.AsyncClient", FakeAsyncClient + ) + monkeypatch.setattr("api.services.mps_service_key_client.DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + "api.services.mps_service_key_client.DOGRAH_MPS_SECRET_KEY", "mps-secret" + ) + + client = MPSServiceKeyClient() + + assert await client.get_credit_ledger( + organization_id=42, + page=3, + limit=25, + ) == { + "account": {"organization_id": 42}, + "ledger_entries": [], + "total_count": 0, + "page": 3, + "limit": 25, + "total_pages": 0, + } + assert calls == [ + ( + "GET", + f"{client.base_url}/api/v1/billing/accounts/42/ledger", + {"page": 3, "limit": 25}, + { + "Content-Type": "application/json", + "X-Secret-Key": "mps-secret", + "X-Organization-Id": "42", + }, + ) + ] + + +@pytest.mark.asyncio +async def test_report_platform_usage_uses_hosted_secret_auth(monkeypatch): + calls = [] + + class FakeAsyncClient: + def __init__(self, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url, json, headers): + calls.append(("POST", url, json, headers)) + return _Response(200, {"metered": True}) + + monkeypatch.setattr( + "api.services.mps_service_key_client.httpx.AsyncClient", FakeAsyncClient + ) + monkeypatch.setattr("api.services.mps_service_key_client.DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + "api.services.mps_service_key_client.DOGRAH_MPS_SECRET_KEY", "mps-secret" + ) + + client = MPSServiceKeyClient() + + assert await client.report_platform_usage( + organization_id=42, + correlation_id="mps-corr-123", + workflow_run_id=123, + metadata={"source": "workflow_run_completion"}, + ) == {"metered": True} + assert calls == [ + ( + "POST", + f"{client.base_url}/api/v1/billing/accounts/42/platform-usage", + { + "correlation_id": "mps-corr-123", + "workflow_run_id": 123, + "metadata": {"source": "workflow_run_completion"}, + }, + { + "Content-Type": "application/json", + "X-Secret-Key": "mps-secret", + "X-Organization-Id": "42", + }, + ) + ] + + +@pytest.mark.asyncio +async def test_report_platform_usage_sends_duration_without_correlation(monkeypatch): + calls = [] + + class FakeAsyncClient: + def __init__(self, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url, json, headers): + calls.append(("POST", url, json, headers)) + return _Response(200, {"metered": True}) + + monkeypatch.setattr( + "api.services.mps_service_key_client.httpx.AsyncClient", FakeAsyncClient + ) + monkeypatch.setattr("api.services.mps_service_key_client.DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + "api.services.mps_service_key_client.DOGRAH_MPS_SECRET_KEY", "mps-secret" + ) + + client = MPSServiceKeyClient() + + assert await client.report_platform_usage( + organization_id=42, + duration_seconds=87.0, + workflow_run_id=123, + metadata={"source": "workflow_run_completion"}, + ) == {"metered": True} + assert calls == [ + ( + "POST", + f"{client.base_url}/api/v1/billing/accounts/42/platform-usage", + { + "duration_seconds": 87.0, + "workflow_run_id": 123, + "metadata": {"source": "workflow_run_completion"}, + }, + { + "Content-Type": "application/json", + "X-Secret-Key": "mps-secret", + "X-Organization-Id": "42", + }, + ) + ] diff --git a/api/tests/test_organization_usage_billing.py b/api/tests/test_organization_usage_billing.py new file mode 100644 index 00000000..2f813eac --- /dev/null +++ b/api/tests/test_organization_usage_billing.py @@ -0,0 +1,99 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from api.routes import organization_usage + + +def test_is_mps_billing_v2_depends_only_on_account_mode(): + assert organization_usage._is_mps_billing_v2({"billing_mode": "v2"}) is True + assert organization_usage._is_mps_billing_v2({"billing_mode": "v1"}) is False + assert organization_usage._is_mps_billing_v2({"billing_mode": "shadow"}) is False + assert organization_usage._is_mps_billing_v2(None) is False + + +@pytest.mark.asyncio +async def test_get_mps_billing_account_status_uses_user_provider_id(monkeypatch): + get_status = AsyncMock(return_value={"billing_mode": "v2"}) + monkeypatch.setattr( + organization_usage.mps_service_key_client, + "get_billing_account_status", + get_status, + ) + + user = SimpleNamespace(provider_id="provider-123") + + assert await organization_usage._get_mps_billing_account_status(user, 42) == { + "billing_mode": "v2" + } + get_status.assert_awaited_once_with( + organization_id=42, + created_by="provider-123", + ) + + +@pytest.mark.asyncio +async def test_get_billing_credits_pages_v2_ledger(monkeypatch): + monkeypatch.setattr(organization_usage, "DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + organization_usage, + "_get_mps_billing_account_status", + AsyncMock(return_value={"billing_mode": "v2"}), + ) + get_ledger = AsyncMock( + return_value={ + "account": { + "id": 7, + "organization_id": 42, + "billing_mode": "v2", + "cached_balance_credits": 250, + "currency": "USD", + }, + "ledger_entries": [ + { + "id": 99, + "entry_type": "grant", + "origin": "account_creation", + "credits_delta": 250, + "balance_after": 250, + "created_at": "2026-06-12T00:00:00Z", + } + ], + "total_debits_credits": 75, + "total_count": 101, + "page": 3, + "limit": 25, + "total_pages": 5, + } + ) + monkeypatch.setattr( + organization_usage.mps_service_key_client, + "get_credit_ledger", + get_ledger, + ) + + user = SimpleNamespace( + provider_id="provider-123", + selected_organization_id=42, + ) + + response = await organization_usage.get_billing_credits( + page=3, + limit=25, + user=user, + ) + + get_ledger.assert_awaited_once_with( + organization_id=42, + page=3, + limit=25, + created_by="provider-123", + ) + assert response.billing_version == "v2" + assert response.total_credits_used == 75 + assert response.total_count == 101 + assert response.page == 3 + assert response.limit == 25 + assert response.total_pages == 5 + assert response.ledger_entries[0].id == 99 diff --git a/api/tests/test_resolve_effective_config.py b/api/tests/test_resolve_effective_config.py index 85afe30f..1b9ad8c6 100644 --- a/api/tests/test_resolve_effective_config.py +++ b/api/tests/test_resolve_effective_config.py @@ -9,7 +9,7 @@ Module under test: api.services.configuration.resolve import pytest -from api.schemas.user_configuration import EffectiveAIModelConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.services.configuration.masking import ( contains_masked_key, mask_workflow_configurations, diff --git a/api/tests/test_run_usage_response.py b/api/tests/test_run_usage_response.py index c17d4a9f..044c6563 100644 --- a/api/tests/test_run_usage_response.py +++ b/api/tests/test_run_usage_response.py @@ -1,4 +1,4 @@ -from api.services.pricing.run_usage_response import format_public_usage_info +from api.services.workflow.run_usage_response import format_public_usage_info def test_format_public_usage_info(): diff --git a/api/tests/test_ultravox_realtime_wrapper.py b/api/tests/test_ultravox_realtime_wrapper.py index 65b062b6..32888439 100644 --- a/api/tests/test_ultravox_realtime_wrapper.py +++ b/api/tests/test_ultravox_realtime_wrapper.py @@ -10,7 +10,7 @@ from pipecat.processors.frame_processor import FrameDirection from websockets.exceptions import ConnectionClosedError from websockets.frames import Close -from api.schemas.user_configuration import EffectiveAIModelConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.services.configuration.registry import UltravoxRealtimeLLMConfiguration from api.services.pipecat.realtime.ultravox_realtime import ( _RESUMPTION_USER_MESSAGE, @@ -430,7 +430,7 @@ async def test_receive_messages_reports_unexpected_websocket_close(): def test_factory_creates_dograh_ultravox_realtime_service(): - user_config = EffectiveAIModelConfiguration( + effective_config = EffectiveAIModelConfiguration( is_realtime=True, realtime=UltravoxRealtimeLLMConfiguration( provider="ultravox_realtime", @@ -441,7 +441,7 @@ def test_factory_creates_dograh_ultravox_realtime_service(): ) service = create_realtime_llm_service( - user_config, + effective_config, audio_config=SimpleNamespace(), ) diff --git a/api/tests/test_workflow_run_billing.py b/api/tests/test_workflow_run_billing.py new file mode 100644 index 00000000..2837317f --- /dev/null +++ b/api/tests/test_workflow_run_billing.py @@ -0,0 +1,212 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from api.services import workflow_run_billing as workflow_run_billing_mod +from api.services.workflow_run_billing import ( + report_completed_workflow_run_platform_usage, + report_workflow_run_platform_usage, +) + + +def _make_workflow_run(): + return SimpleNamespace( + id=123, + workflow_id=456, + is_completed=True, + initial_context={"mps_correlation_id": "mps-corr-123"}, + usage_info={"call_duration_seconds": 87}, + workflow=SimpleNamespace( + organization_id=42, + user=SimpleNamespace(selected_organization_id=42), + ), + ) + + +@pytest.mark.asyncio +async def test_report_workflow_run_platform_usage_reports_hosted_completion( + monkeypatch, +): + workflow_run = _make_workflow_run() + get_status = AsyncMock(return_value={"billing_mode": "v2"}) + report_usage = AsyncMock(return_value={"metered": True}) + + monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "get_billing_account_status", + get_status, + ) + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "report_platform_usage", + report_usage, + ) + + await report_workflow_run_platform_usage(workflow_run) + + report_usage.assert_awaited_once_with( + organization_id=42, + correlation_id="mps-corr-123", + duration_seconds=None, + workflow_run_id=workflow_run.id, + metadata={ + "source": "workflow_run_completion", + "workflow_id": workflow_run.workflow_id, + "duration_source": "mps_correlation", + }, + ) + + +@pytest.mark.asyncio +async def test_report_workflow_run_platform_usage_reports_duration_without_correlation( + monkeypatch, +): + workflow_run = _make_workflow_run() + workflow_run.initial_context = {} + get_status = AsyncMock(return_value={"billing_mode": "v2"}) + report_usage = AsyncMock(return_value={"metered": True}) + + monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "get_billing_account_status", + get_status, + ) + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "report_platform_usage", + report_usage, + ) + + await report_workflow_run_platform_usage(workflow_run) + + report_usage.assert_awaited_once_with( + organization_id=42, + correlation_id=None, + duration_seconds=87.0, + workflow_run_id=workflow_run.id, + metadata={ + "source": "workflow_run_completion", + "workflow_id": workflow_run.workflow_id, + "duration_source": "dograh_usage_info", + }, + ) + + +@pytest.mark.asyncio +async def test_report_workflow_run_platform_usage_skips_non_v2_account(monkeypatch): + workflow_run = _make_workflow_run() + get_status = AsyncMock(return_value={"billing_mode": "v1"}) + report_usage = AsyncMock() + + monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "get_billing_account_status", + get_status, + ) + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "report_platform_usage", + report_usage, + ) + + await report_workflow_run_platform_usage(workflow_run) + + get_status.assert_awaited_once_with(organization_id=42) + report_usage.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_report_workflow_run_platform_usage_skips_missing_duration_without_correlation( + monkeypatch, +): + workflow_run = _make_workflow_run() + workflow_run.initial_context = {} + workflow_run.usage_info = {} + get_status = AsyncMock(return_value={"billing_mode": "v2"}) + report_usage = AsyncMock() + + monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "get_billing_account_status", + get_status, + ) + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "report_platform_usage", + report_usage, + ) + + await report_workflow_run_platform_usage(workflow_run) + + get_status.assert_not_awaited() + report_usage.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_report_workflow_run_platform_usage_skips_oss(monkeypatch): + workflow_run = _make_workflow_run() + report_usage = AsyncMock() + + monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "oss") + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "report_platform_usage", + report_usage, + ) + + await report_workflow_run_platform_usage(workflow_run) + + report_usage.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_report_workflow_run_platform_usage_skips_incomplete(monkeypatch): + workflow_run = _make_workflow_run() + workflow_run.is_completed = False + report_usage = AsyncMock() + + monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "report_platform_usage", + report_usage, + ) + + await report_workflow_run_platform_usage(workflow_run) + + report_usage.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_report_completed_workflow_run_platform_usage_loads_run(monkeypatch): + workflow_run = _make_workflow_run() + get_run = AsyncMock(return_value=workflow_run) + get_status = AsyncMock(return_value={"billing_mode": "v2"}) + report_usage = AsyncMock(return_value={"metered": True}) + + monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + workflow_run_billing_mod.db_client, + "get_workflow_run_by_id", + get_run, + ) + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "get_billing_account_status", + get_status, + ) + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "report_platform_usage", + report_usage, + ) + + await report_completed_workflow_run_platform_usage(workflow_run.id) + + get_run.assert_awaited_once_with(workflow_run.id) + report_usage.assert_awaited_once() diff --git a/api/tests/test_workflow_run_cost.py b/api/tests/test_workflow_run_cost.py deleted file mode 100644 index c77424c8..00000000 --- a/api/tests/test_workflow_run_cost.py +++ /dev/null @@ -1,181 +0,0 @@ -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 index e69e7c0a..3be8a613 100644 --- a/api/tests/test_workflow_text_chat.py +++ b/api/tests/test_workflow_text_chat.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from api.db.models import OrganizationModel, UserModel -from api.schemas.user_configuration import EffectiveAIModelConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.tests.integrations._run_pipeline_helpers import USER_CONFIGURATION from pipecat.tests import MockLLMService @@ -176,11 +176,7 @@ async def test_text_chat_session_creation_executes_initial_assistant_turn( 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 "call_duration_seconds" in workflow_run.usage_info assert _log_texts(run_payload["logs"], "rtf-bot-text") == [ "Hello from the workflow tester." ] @@ -296,11 +292,7 @@ async def test_text_chat_message_executes_assistant_turn( 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 "call_duration_seconds" in workflow_run.usage_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.", diff --git a/pipecat b/pipecat index 228324a1..0d64dc6e 160000 --- a/pipecat +++ b/pipecat @@ -1 +1 @@ -Subproject commit 228324a146a6765c6b8d610963bc80d7bc8cb9f7 +Subproject commit 0d64dc6e0e3e6b3c46cc66373e34b4f54f980268 diff --git a/ui/src/app/billing/page.tsx b/ui/src/app/billing/page.tsx new file mode 100644 index 00000000..0a9732c9 --- /dev/null +++ b/ui/src/app/billing/page.tsx @@ -0,0 +1,416 @@ +"use client"; + +import { + ChevronLeft, + ChevronRight, + CircleDollarSign, + CreditCard, + RefreshCw, +} from "lucide-react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { createMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPost, getBillingCreditsApiV1OrganizationsBillingCreditsGet } from "@/client/sdk.gen"; +import type { MpsBillingCreditsResponse, MpsCreditLedgerEntryResponse } from "@/client/types.gen"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useAppConfig } from "@/context/AppConfigContext"; +import { useAuth } from "@/lib/auth"; + +const LEDGER_PAGE_SIZE = 50; + +const formatCredits = (value: number | null | undefined) => ( + (value ?? 0).toLocaleString(undefined, { + maximumFractionDigits: 2, + minimumFractionDigits: 0, + }) +); + +const formatAmount = (amountMinor?: number | null, currency?: string | null) => { + if (amountMinor == null) { + return "-"; + } + + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: currency || "USD", + }).format(amountMinor / 100); +}; + +const formatDate = (value: string) => ( + new Date(value).toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) +); + +const metricLabels: Record = { + voice_minutes: "Voice usage", + platform_usage: "Platform usage", +}; + +const formatTitleCase = (value: string | null | undefined) => ( + value ? value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()) : "-" +); + +const getLedgerEntryLabel = (entry: MpsCreditLedgerEntryResponse) => { + if (entry.metric_code) { + return metricLabels[entry.metric_code] ?? formatTitleCase(entry.metric_code); + } + + if (entry.entry_type === "grant") { + return "Credit grant"; + } + + if (entry.entry_type === "purchase") { + return "Credit purchase"; + } + + return formatTitleCase(entry.entry_type); +}; + +const formatBillableQuantity = (entry: MpsCreditLedgerEntryResponse) => { + if (entry.billable_quantity == null || !entry.quantity_unit) { + return null; + } + + const unit = entry.quantity_unit === "minute" ? "min" : entry.quantity_unit; + return `${formatCredits(entry.billable_quantity)} ${unit}`; +}; + +const getRunHref = (entry: MpsCreditLedgerEntryResponse) => { + if (!entry.workflow_id || !entry.workflow_run_id) { + return null; + } + + return `/workflow/${entry.workflow_id}/run/${entry.workflow_run_id}`; +}; + +const getPageFromSearchParams = ( + searchParams: { get: (name: string) => string | null }, +) => { + const pageParam = searchParams.get("page"); + const page = pageParam ? Number.parseInt(pageParam, 10) : 1; + return Number.isFinite(page) && page > 0 ? page : 1; +}; + +export default function BillingPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const auth = useAuth(); + const { config } = useAppConfig(); + const [credits, setCredits] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [purchasing, setPurchasing] = useState(false); + const [currentPage, setCurrentPage] = useState( + () => getPageFromSearchParams(searchParams), + ); + + const isBillingV2 = credits?.billing_version === "v2"; + const canPurchaseCredits = isBillingV2 && config?.deploymentMode !== "oss"; + const totalQuota = credits?.total_quota ?? 0; + const remainingCredits = credits?.remaining_credits ?? 0; + const usedCredits = credits?.total_credits_used ?? 0; + const usagePercent = totalQuota > 0 ? Math.min(100, Math.round((usedCredits / totalQuota) * 100)) : 0; + + const ledgerEntries = useMemo(() => credits?.ledger_entries ?? [], [credits?.ledger_entries]); + const ledgerPage = credits?.page ?? currentPage; + const ledgerTotalCount = credits?.total_count ?? ledgerEntries.length; + const ledgerTotalPages = credits?.total_pages ?? 0; + + const fetchCredits = useCallback(async ( + page: number, + { silent = false }: { silent?: boolean } = {}, + ) => { + if (auth.loading) { + return; + } + + if (!auth.isAuthenticated) { + setLoading(false); + return; + } + + if (silent) { + setRefreshing(true); + } else { + setLoading(true); + } + + try { + const response = await getBillingCreditsApiV1OrganizationsBillingCreditsGet({ + query: { page, limit: LEDGER_PAGE_SIZE }, + }); + + if (response.error) { + throw new Error("Failed to fetch billing credits"); + } + + setCredits(response.data ?? null); + } catch (error) { + console.error("Failed to fetch billing credits:", error); + toast.error("Failed to fetch billing credits"); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [auth.isAuthenticated, auth.loading]); + + useEffect(() => { + const nextPage = getPageFromSearchParams(searchParams); + setCurrentPage((previousPage) => ( + previousPage === nextPage ? previousPage : nextPage + )); + }, [searchParams]); + + useEffect(() => { + fetchCredits(currentPage); + }, [currentPage, fetchCredits]); + + const handleRefresh = () => { + fetchCredits(currentPage, { silent: true }); + }; + + const updateUrlPage = useCallback((page: number) => { + const newParams = new URLSearchParams(searchParams.toString()); + if (page > 1) { + newParams.set("page", page.toString()); + } else { + newParams.delete("page"); + } + + const queryString = newParams.toString(); + router.push(queryString ? `/billing?${queryString}` : "/billing"); + }, [router, searchParams]); + + const handlePageChange = (page: number) => { + const nextPage = Math.max(1, page); + setCurrentPage(nextPage); + updateUrlPage(nextPage); + }; + + const handlePurchaseCredits = async () => { + if (!canPurchaseCredits) { + return; + } + + setPurchasing(true); + try { + const response = await createMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPost(); + const checkoutUrl = response.data?.checkout_url; + if (!checkoutUrl) { + throw new Error("Missing checkout URL"); + } + window.location.href = checkoutUrl; + } catch (error) { + console.error("Failed to create credit purchase URL:", error); + toast.error("Failed to open checkout"); + setPurchasing(false); + } + }; + + if (loading) { + return ( +
+
+ + +
+
+ + +
+ +
+ ); + } + + return ( +
+
+
+

Billing

+

+ Credits, balance, and account usage for your organization. +

+
+
+ + {canPurchaseCredits && ( + + )} +
+
+ +
+ + + {isBillingV2 ? "Credit balance" : "Credits remaining"} + + + {formatCredits(remainingCredits)} + + + +

1 credit = 1 cent

+
+
+ + + + Credits used + {formatCredits(usedCredits)} + + +

+ {isBillingV2 ? "Total ledger debits" : "Current allocation usage"} +

+
+
+
+ + {isBillingV2 ? ( + + + Credit Ledger + Recent grants, purchases, and usage debits. + + + {ledgerEntries.length > 0 ? ( +
+ + + + Date + Activity + Origin + Run + Delta + Balance + Amount + + + + {ledgerEntries.map((entry) => { + const delta = entry.credits_delta ?? 0; + const runHref = getRunHref(entry); + const billableQuantity = formatBillableQuantity(entry); + return ( + + {formatDate(entry.created_at)} + +
+ {getLedgerEntryLabel(entry)} + {billableQuantity && ( + {billableQuantity} + )} +
+
+ + {entry.origin ? ( + {formatTitleCase(entry.origin)} + ) : ( + "-" + )} + + + {entry.workflow_run_id ? ( + runHref ? ( + + #{entry.workflow_run_id} + + ) : ( + #{entry.workflow_run_id} + ) + ) : ( + "-" + )} + + = 0 ? "text-green-600" : "text-destructive"}`}> + {delta >= 0 ? "+" : ""} + {formatCredits(delta)} + + {formatCredits(entry.balance_after)} + + {formatAmount(entry.amount_minor, entry.amount_currency)} + +
+ ); + })} +
+
+
+ ) : ( +
+ No ledger entries yet +
+ )} + {ledgerTotalPages > 1 && ( +
+

+ Page {ledgerPage} of {ledgerTotalPages} ({ledgerTotalCount} total entries) +

+
+ + +
+
+ )} +
+
+ ) : ( + + + Credit Usage + + + +
+ {usagePercent}% used + {formatCredits(remainingCredits)} of {formatCredits(totalQuota)} remaining +
+
+
+ )} +
+ ); +} diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx index b7961425..9b346354 100644 --- a/ui/src/app/layout.tsx +++ b/ui/src/app/layout.tsx @@ -12,8 +12,8 @@ import SpinLoader from "@/components/SpinLoader"; import { Toaster } from "@/components/ui/sonner"; import { AppConfigProvider } from "@/context/AppConfigContext"; import { OnboardingProvider } from "@/context/OnboardingContext"; +import { OrgConfigProvider } from "@/context/OrgConfigContext"; import { TelephonyConfigWarningsProvider } from "@/context/TelephonyConfigWarningsContext"; -import { UserConfigProvider } from "@/context/UserConfigContext"; import { AuthProvider } from "@/lib/auth"; @@ -65,7 +65,7 @@ export default function RootLayout({ }> - + @@ -76,7 +76,7 @@ export default function RootLayout({ - + diff --git a/ui/src/app/reports/page.tsx b/ui/src/app/reports/page.tsx index 770e1724..3f703bdc 100644 --- a/ui/src/app/reports/page.tsx +++ b/ui/src/app/reports/page.tsx @@ -2,7 +2,7 @@ import { addDays, format, subDays } from 'date-fns'; import { Calendar, ChevronLeft, ChevronRight, Download } from 'lucide-react'; -import { useEffect,useState } from 'react'; +import { useEffect, useState } from 'react'; import { getDailyReportApiV1OrganizationsReportsDailyGet, @@ -201,7 +201,9 @@ export default function ReportsPage() {
{/* Header */}
-

Daily Reports

+
+

Daily Reports

+
{/* Date Navigation & Workflow Selector */}
diff --git a/ui/src/app/usage/page.tsx b/ui/src/app/usage/page.tsx index 181d1791..a5d69839 100644 --- a/ui/src/app/usage/page.tsx +++ b/ui/src/app/usage/page.tsx @@ -6,8 +6,8 @@ import { useCallback, useEffect, useId, useState } from 'react'; import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select'; import { toast } from 'sonner'; -import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getPreferencesApiV1OrganizationsPreferencesGet, getUsageHistoryApiV1OrganizationsUsageRunsGet, savePreferencesApiV1OrganizationsPreferencesPut } from '@/client/sdk.gen'; -import type { DailyUsageBreakdownResponse, MpsCreditsResponse, OrganizationPreferences, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen'; +import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getPreferencesApiV1OrganizationsPreferencesGet, getUsageHistoryApiV1OrganizationsUsageRunsGet, savePreferencesApiV1OrganizationsPreferencesPut } from '@/client/sdk.gen'; +import type { DailyUsageBreakdownResponse, OrganizationPreferences, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen'; import { CallTypeCell } from '@/components/CallTypeCell'; import { DailyUsageTable } from '@/components/DailyUsageTable'; import { FilterBuilder } from '@/components/filters/FilterBuilder'; @@ -15,7 +15,6 @@ import { MediaPreviewButton, MediaPreviewDialog } from '@/components/MediaPrevie import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Progress } from '@/components/ui/progress'; import { Table, TableBody, @@ -39,10 +38,6 @@ export default function UsagePage() { const { organizationPricing } = useUserConfig(); const auth = useAuth(); - // MPS credits state - const [mpsCredits, setMpsCredits] = useState(null); - const [isLoadingCredits, setIsLoadingCredits] = useState(true); - // Usage history state const [usageHistory, setUsageHistory] = useState(null); const [isLoadingHistory, setIsLoadingHistory] = useState(false); @@ -78,21 +73,6 @@ export default function UsagePage() { const [preferencesLoading, setPreferencesLoading] = useState(true); const timezoneSelectId = useId(); // Stable ID for react-select to prevent hydration mismatch - // Fetch MPS credits - const fetchMpsCredits = useCallback(async () => { - if (!auth.isAuthenticated) return; - try { - const response = await getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet(); - if (response.data) { - setMpsCredits(response.data); - } - } catch (error) { - console.error('Failed to fetch MPS credits:', error); - } finally { - setIsLoadingCredits(false); - } - }, [auth.isAuthenticated]); - // Translate the FilterBuilder state into the query-param shape the // backend expects. Shared between the listing fetch and the CSV export // so they stay in lockstep. @@ -251,10 +231,9 @@ export default function UsagePage() { // Initial load - fetch when auth becomes available useEffect(() => { if (auth.isAuthenticated) { - fetchMpsCredits(); fetchUsageHistory(currentPage, appliedFilters); } - }, [auth.isAuthenticated, currentPage, appliedFilters, fetchUsageHistory, fetchMpsCredits]); + }, [auth.isAuthenticated, currentPage, appliedFilters, fetchUsageHistory]); // Fetch daily usage when organizationPricing becomes available useEffect(() => { @@ -428,46 +407,6 @@ export default function UsagePage() {
- {/* MPS Credits Card */} - - - Dograh Model Credits - - These track usage of Dograh models using Dograh Service Keys. - - - - {isLoadingCredits ? ( -
-
-
-
-
- ) : mpsCredits ? ( -
-
-
-

- {mpsCredits.total_credits_used.toFixed(2)} / {mpsCredits.total_quota.toFixed(2)} -

-

Credits Used

-
-
-

{mpsCredits.remaining_credits.toFixed(2)}

-

Remaining

-
-
- - {mpsCredits.total_quota > 0 && ( - - )} -
- ) : ( -

No Dograh service keys configured. Set up a service key in your model configuration to see usage.

- )} -
-
- {/* Daily Usage Table - Only for paid organizations */} {organizationPricing?.price_per_second_usd && (
@@ -535,9 +474,9 @@ export default function UsagePage() { Disposition Date Duration - - {organizationPricing?.price_per_second_usd ? 'Cost (USD)' : 'Tokens'} - + {organizationPricing?.price_per_second_usd && ( + Cost (USD) + )} Actions @@ -574,12 +513,14 @@ export default function UsagePage() { {formatDuration(run.call_duration_seconds)} - - {organizationPricing?.price_per_second_usd && run.charge_usd !== undefined && run.charge_usd !== null - ? `$${run.charge_usd.toFixed(2)}` - : run.dograh_token_usage.toLocaleString() - } - + {organizationPricing?.price_per_second_usd && ( + + {run.charge_usd !== undefined && run.charge_usd !== null + ? `$${run.charge_usd.toFixed(2)}` + : '-' + } + + )} = Options2 & { /** @@ -915,6 +915,13 @@ export const refreshMcpToolsApiV1ToolsToolUuidMcpRefreshPost = (options: Options) => (options.client ?? client).post({ url: '/api/v1/tools/{tool_uuid}/unarchive', ...options }); +/** + * Get Current Organization Context + * + * Return organization-scoped configuration signals owned by Dograh. + */ +export const getCurrentOrganizationContextApiV1OrganizationsContextGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/context', ...options }); + /** * Get Telephony Providers Metadata * @@ -1232,7 +1239,7 @@ export const reactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePut = /** * Get Current Period Usage * - * Get current billing period usage for the user's organization. + * Get current reporting-period usage for the user's organization. */ export const getCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/usage/current-period', ...options }); @@ -1246,6 +1253,20 @@ export const getCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/usage/mps-credits', ...options }); +/** + * Get Billing Credits + * + * Return legacy MPS credits or paginated v2 billing ledger details for the org. + */ +export const getBillingCreditsApiV1OrganizationsBillingCreditsGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/billing/credits', ...options }); + +/** + * Create Mps Credit Purchase Url + * + * Create a checkout URL for organizations using Dograh-managed MPS v2. + */ +export const createMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPost = (options?: Options) => (options?.client ?? client).post({ url: '/api/v1/organizations/usage/mps-credits/purchase-url', ...options }); + /** * Get Usage History * diff --git a/ui/src/client/types.gen.ts b/ui/src/client/types.gen.ts index a44e7565..4c6afb36 100644 --- a/ui/src/client/types.gen.ts +++ b/ui/src/client/types.gen.ts @@ -1642,22 +1642,6 @@ export type CurrentUsageResponse = { * Used Dograh Tokens */ used_dograh_tokens: number; - /** - * Quota Dograh Tokens - */ - quota_dograh_tokens: number; - /** - * Percentage Used - */ - percentage_used: number; - /** - * Next Refresh Date - */ - next_refresh_date: string; - /** - * Quota Enabled - */ - quota_enabled: boolean; /** * Total Duration Seconds */ @@ -1666,10 +1650,6 @@ export type CurrentUsageResponse = { * Used Amount Usd */ used_amount_usd?: number | null; - /** - * Quota Amount Usd - */ - quota_amount_usd?: number | null; /** * Currency */ @@ -3107,6 +3087,165 @@ export type LoginRequest = { password: string; }; +/** + * MPSBillingAccountResponse + */ +export type MpsBillingAccountResponse = { + /** + * Id + */ + id: number; + /** + * Organization Id + */ + organization_id: number; + /** + * Billing Mode + */ + billing_mode: string; + /** + * Cached Balance Credits + */ + cached_balance_credits: number; + /** + * Currency + */ + currency: string; +}; + +/** + * MPSBillingCreditsResponse + */ +export type MpsBillingCreditsResponse = { + /** + * Billing Version + */ + billing_version: 'legacy' | 'v2'; + /** + * Total Credits Used + */ + total_credits_used?: number; + /** + * Remaining Credits + */ + remaining_credits?: number; + /** + * Total Quota + */ + total_quota?: number; + account?: MpsBillingAccountResponse | null; + /** + * Ledger Entries + */ + ledger_entries?: Array; + /** + * Total Count + */ + total_count?: number; + /** + * Page + */ + page?: number; + /** + * Limit + */ + limit?: number; + /** + * Total Pages + */ + total_pages?: number; +}; + +/** + * MPSCreditLedgerEntryResponse + */ +export type MpsCreditLedgerEntryResponse = { + /** + * Id + */ + id: number; + /** + * Entry Type + */ + entry_type: string; + /** + * Origin + */ + origin?: string | null; + /** + * Credits Delta + */ + credits_delta: number; + /** + * Balance After + */ + balance_after: number; + /** + * Amount Minor + */ + amount_minor?: number | null; + /** + * Amount Currency + */ + amount_currency?: string | null; + /** + * Payment Order Id + */ + payment_order_id?: number | null; + /** + * Metric Code + */ + metric_code?: string | null; + /** + * Correlation Id + */ + correlation_id?: string | null; + /** + * Aggregation Key + */ + aggregation_key?: string | null; + /** + * Usage Event Id + */ + usage_event_id?: number | null; + /** + * Workflow Run Id + */ + workflow_run_id?: number | null; + /** + * Workflow Id + */ + workflow_id?: number | null; + /** + * Billable Quantity + */ + billable_quantity?: number | null; + /** + * Quantity Unit + */ + quantity_unit?: string | null; + /** + * Metadata + */ + metadata?: { + [key: string]: unknown; + }; + /** + * Created At + */ + created_at: string; +}; + +/** + * MPSCreditPurchaseUrlResponse + */ +export type MpsCreditPurchaseUrlResponse = { + /** + * Checkout Url + */ + checkout_url: string; +}; + /** * MPSCreditsResponse */ @@ -3618,6 +3757,43 @@ export type OrganizationAiModelConfigurationV2 = { byok?: ByokaiModelConfiguration | null; }; +/** + * OrganizationContextResponse + */ +export type OrganizationContextResponse = { + /** + * Organization Id + */ + organization_id?: number | null; + /** + * Organization Provider Id + */ + organization_provider_id?: string | null; + model_services: OrganizationModelServicesContext; +}; + +/** + * OrganizationModelServicesContext + */ +export type OrganizationModelServicesContext = { + /** + * Config Source + */ + config_source: 'organization_v2' | 'legacy_user_v1' | 'empty'; + /** + * Has Model Configuration V2 + */ + has_model_configuration_v2: boolean; + /** + * Managed Service Version + */ + managed_service_version?: number | null; + /** + * Uses Managed Service V2 + */ + uses_managed_service_v2: boolean; +}; + /** * OrganizationPreferences */ @@ -9750,6 +9926,45 @@ export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses = { export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse = UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses[keyof UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses]; +export type GetCurrentOrganizationContextApiV1OrganizationsContextGetData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/organizations/context'; +}; + +export type GetCurrentOrganizationContextApiV1OrganizationsContextGetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetCurrentOrganizationContextApiV1OrganizationsContextGetError = GetCurrentOrganizationContextApiV1OrganizationsContextGetErrors[keyof GetCurrentOrganizationContextApiV1OrganizationsContextGetErrors]; + +export type GetCurrentOrganizationContextApiV1OrganizationsContextGetResponses = { + /** + * Successful Response + */ + 200: OrganizationContextResponse; +}; + +export type GetCurrentOrganizationContextApiV1OrganizationsContextGetResponse = GetCurrentOrganizationContextApiV1OrganizationsContextGetResponses[keyof GetCurrentOrganizationContextApiV1OrganizationsContextGetResponses]; + export type GetTelephonyProvidersMetadataApiV1OrganizationsTelephonyProvidersMetadataGetData = { body?: never; headers?: { @@ -11269,6 +11484,93 @@ export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses = { export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponse = GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses[keyof GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses]; +export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: { + /** + * Page + */ + page?: number; + /** + * Limit + */ + limit?: number; + }; + url: '/api/v1/organizations/billing/credits'; +}; + +export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetError = GetBillingCreditsApiV1OrganizationsBillingCreditsGetErrors[keyof GetBillingCreditsApiV1OrganizationsBillingCreditsGetErrors]; + +export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponses = { + /** + * Successful Response + */ + 200: MpsBillingCreditsResponse; +}; + +export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponse = GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponses[keyof GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponses]; + +export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/organizations/usage/mps-credits/purchase-url'; +}; + +export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostError = CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostErrors[keyof CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostErrors]; + +export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponses = { + /** + * Successful Response + */ + 200: MpsCreditPurchaseUrlResponse; +}; + +export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponse = CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponses[keyof CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponses]; + export type GetUsageHistoryApiV1OrganizationsUsageRunsGetData = { body?: never; headers?: { diff --git a/ui/src/components/AIModelConfigurationV2Editor.tsx b/ui/src/components/AIModelConfigurationV2Editor.tsx index 13ee2edd..bbe66658 100644 --- a/ui/src/components/AIModelConfigurationV2Editor.tsx +++ b/ui/src/components/AIModelConfigurationV2Editor.tsx @@ -17,7 +17,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages"; -type ModelMode = "dograh" | "byok"; +type ModelMode = "realtime" | "dograh" | "byok"; interface DograhDefaults { voices: string[]; @@ -125,24 +125,35 @@ function effectiveConfigToLegacyShape(config: Record | null): R }; } -function emptyByokInitialConfig(): Record { +function emptyByokInitialConfig(isRealtime: boolean): Record { return { - is_realtime: false, + is_realtime: isRealtime, }; } +// The v2 editor surfaces realtime ("Speech to Speech") and pipeline (BYOK) as +// separate tabs, so each tab gets its own initial config. A tab is pre-filled +// only when the saved (or effective) configuration matches that tab's mode; +// otherwise it starts empty so the other tab's data does not leak across. function getByokInitialConfig( configuration: Record | null, effectiveConfiguration: Record | null, + wantRealtime: boolean, ): Record { - const byokConfiguration = byokConfigToLegacyShape(configuration); - if (byokConfiguration) return byokConfiguration; + const matchesTab = (config: Record | null) => + config ? Boolean(config.is_realtime) === wantRealtime : false; - if (configuration?.mode === "dograh" || isDograhEffectiveConfig(effectiveConfiguration)) { - return emptyByokInitialConfig(); + const byokConfiguration = byokConfigToLegacyShape(configuration); + if (byokConfiguration) { + return matchesTab(byokConfiguration) ? byokConfiguration : emptyByokInitialConfig(wantRealtime); } - return effectiveConfigToLegacyShape(effectiveConfiguration) || emptyByokInitialConfig(); + if (configuration?.mode === "dograh" || isDograhEffectiveConfig(effectiveConfiguration)) { + return emptyByokInitialConfig(wantRealtime); + } + + const effective = effectiveConfigToLegacyShape(effectiveConfiguration); + return matchesTab(effective) ? (effective as Record) : emptyByokInitialConfig(wantRealtime); } function buildDograhState( @@ -185,10 +196,12 @@ function preferredMode( configuration: Record | null, effectiveConfiguration: Record | null, ): ModelMode { - if (configuration?.mode === "dograh" || configuration?.mode === "byok") { - return configuration.mode; + if (configuration?.mode === "dograh") return "dograh"; + if (configuration?.mode === "byok") { + return asRecord(configuration.byok)?.mode === "realtime" ? "realtime" : "byok"; } - return isDograhEffectiveConfig(effectiveConfiguration) ? "dograh" : "byok"; + if (isDograhEffectiveConfig(effectiveConfiguration)) return "dograh"; + return Boolean(effectiveConfiguration?.is_realtime) ? "realtime" : "byok"; } function hasRequiredApiKey( @@ -249,7 +262,8 @@ export function AIModelConfigurationV2Editor({ speed: defaults.dograh.defaults.speed, language: defaults.dograh.defaults.language, })); - const [byokInitialConfig, setByokInitialConfig] = useState | null>(null); + const [realtimeInitialConfig, setRealtimeInitialConfig] = useState | null>(null); + const [pipelineInitialConfig, setPipelineInitialConfig] = useState | null>(null); const [isSavingDograh, setIsSavingDograh] = useState(false); const [error, setError] = useState(null); @@ -258,7 +272,8 @@ export function AIModelConfigurationV2Editor({ const rawEffectiveConfiguration = asRecord(effectiveConfiguration); setMode(preferredMode(rawConfiguration, rawEffectiveConfiguration)); setDograh(buildDograhState(defaults, rawConfiguration, rawEffectiveConfiguration)); - setByokInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration)); + setRealtimeInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, true)); + setPipelineInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, false)); }, [configuration, defaults, effectiveConfiguration]); const saveDograhConfiguration = async () => { @@ -322,28 +337,30 @@ export function AIModelConfigurationV2Editor({ )} setMode(value as ModelMode)} className="space-y-6"> - + + Speech to Speech Dograh BYOK + +

+ A single speech-to-speech model handles the conversation in realtime (no separate transcriber or voice). An LLM is still required for variable extraction and QA. +

+ +
+
-
- -
- - setDograh({ ...dograh, api_key: event.target.value })} - placeholder="Enter API key" - /> -
-
-
+ +
+ +
+ + setDograh({ ...dograh, api_key: event.target.value })} + placeholder="Enter API key" + /> +
+