feat: add google stt and tts. add folders to organize agents

This commit is contained in:
Abhishek Kumar 2026-05-22 14:36:50 +05:30
parent 21951eca18
commit ad2fa07058
52 changed files with 3412 additions and 621 deletions

View file

@ -0,0 +1,61 @@
"""add folders and workflow folder_id
Revision ID: 6bd9f67ec994
Revises: 2f638891cbb6
Create Date: 2026-05-22 12:52:30.737380
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "6bd9f67ec994"
down_revision: Union[str, None] = "2f638891cbb6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"folders",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("organization_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["organization_id"],
["organizations.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("organization_id", "name", name="uq_folder_org_name"),
)
op.create_index(
op.f("ix_folders_organization_id"), "folders", ["organization_id"], unique=False
)
op.add_column("workflows", sa.Column("folder_id", sa.Integer(), nullable=True))
op.create_index(
op.f("ix_workflows_folder_id"), "workflows", ["folder_id"], unique=False
)
op.create_foreign_key(
"fk_workflows_folder_id",
"workflows",
"folders",
["folder_id"],
["id"],
ondelete="SET NULL",
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint("fk_workflows_folder_id", "workflows", type_="foreignkey")
op.drop_index(op.f("ix_workflows_folder_id"), table_name="workflows")
op.drop_column("workflows", "folder_id")
op.drop_index(op.f("ix_folders_organization_id"), table_name="folders")
op.drop_table("folders")
# ### end Alembic commands ###

View file

@ -2,6 +2,7 @@ from api.db.agent_trigger_client import AgentTriggerClient
from api.db.api_key_client import APIKeyClient
from api.db.campaign_client import CampaignClient
from api.db.embed_token_client import EmbedTokenClient
from api.db.folder_client import FolderClient
from api.db.integration_client import IntegrationClient
from api.db.knowledge_base_client import KnowledgeBaseClient
from api.db.organization_client import OrganizationClient
@ -41,6 +42,7 @@ class DBClient(
WorkflowRecordingClient,
TelephonyConfigurationClient,
TelephonyPhoneNumberClient,
FolderClient,
):
"""
Unified database client that combines all specialized database operations.
@ -62,6 +64,7 @@ class DBClient(
- WebhookCredentialClient: handles webhook credential operations
- ToolClient: handles tool operations for reusable HTTP API tools
- KnowledgeBaseClient: handles knowledge base document and vector search operations
- FolderClient: handles folder operations for grouping workflows (agents)
"""
pass

115
api/db/folder_client.py Normal file
View file

@ -0,0 +1,115 @@
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.future import select
from api.db.base_client import BaseDBClient
from api.db.models import FolderModel, WorkflowModel
from api.enums import WorkflowStatus
class FolderNameConflictError(Exception):
"""Raised when a folder name already exists within the organization."""
class FolderClient(BaseDBClient):
async def create_folder(self, name: str, organization_id: int) -> FolderModel:
async with self.async_session() as session:
folder = FolderModel(name=name, organization_id=organization_id)
session.add(folder)
try:
await session.commit()
except IntegrityError:
await session.rollback()
raise FolderNameConflictError(
f"A folder named '{name}' already exists."
)
await session.refresh(folder)
return folder
async def get_folder(
self, folder_id: int, organization_id: int
) -> FolderModel | None:
"""Fetch a single folder scoped to the organization (tenant isolation)."""
async with self.async_session() as session:
result = await session.execute(
select(FolderModel).where(
FolderModel.id == folder_id,
FolderModel.organization_id == organization_id,
)
)
return result.scalar_one_or_none()
async def list_folders(self, organization_id: int) -> list[FolderModel]:
async with self.async_session() as session:
result = await session.execute(
select(FolderModel)
.where(FolderModel.organization_id == organization_id)
.order_by(FolderModel.name.asc())
)
return result.scalars().all()
async def rename_folder(
self, folder_id: int, name: str, organization_id: int
) -> FolderModel:
async with self.async_session() as session:
result = await session.execute(
select(FolderModel).where(
FolderModel.id == folder_id,
FolderModel.organization_id == organization_id,
)
)
folder = result.scalar_one_or_none()
if folder is None:
raise ValueError(f"Folder with id {folder_id} not found")
folder.name = name
try:
await session.commit()
except IntegrityError:
await session.rollback()
raise FolderNameConflictError(
f"A folder named '{name}' already exists."
)
await session.refresh(folder)
return folder
async def delete_folder(self, folder_id: int, organization_id: int) -> bool:
"""Delete a folder. Member workflows are un-filed (folder_id -> NULL)
via the ON DELETE SET NULL foreign key, never deleted.
"""
async with self.async_session() as session:
result = await session.execute(
select(FolderModel).where(
FolderModel.id == folder_id,
FolderModel.organization_id == organization_id,
)
)
folder = result.scalar_one_or_none()
if folder is None:
return False
await session.delete(folder)
await session.commit()
return True
async def get_active_workflow_counts_by_folder(
self, organization_id: int
) -> dict[int, int]:
"""Return {folder_id: active_workflow_count} for the organization.
Only counts active (non-archived) workflows with a non-NULL folder_id.
"""
async with self.async_session() as session:
result = await session.execute(
select(
WorkflowModel.folder_id,
func.count(WorkflowModel.id).label("count"),
)
.where(
WorkflowModel.organization_id == organization_id,
WorkflowModel.folder_id.is_not(None),
WorkflowModel.status == WorkflowStatus.ACTIVE.value,
)
.group_by(WorkflowModel.folder_id)
)
return {folder_id: count for folder_id, count in result.all()}

View file

@ -352,6 +352,32 @@ class WorkflowDefinitionModel(Base):
workflow_runs = relationship("WorkflowRunModel", back_populates="definition")
class FolderModel(Base):
"""A folder for grouping workflows (agents) within an organization.
Folders are flat (no nesting) and org-scoped. A workflow belongs to at
most one folder via ``WorkflowModel.folder_id``; a NULL folder_id means
the workflow is "Uncategorized".
"""
__tablename__ = "folders"
id = Column(Integer, primary_key=True, index=True)
organization_id = Column(
Integer, ForeignKey("organizations.id"), nullable=False, index=True
)
organization = relationship("OrganizationModel")
name = Column(String, nullable=False)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
workflows = relationship("WorkflowModel", back_populates="folder")
# Folder names must be unique within an organization.
__table_args__ = (
UniqueConstraint("organization_id", "name", name="uq_folder_org_name"),
)
class WorkflowModel(Base):
__tablename__ = "workflows"
id = Column(Integer, primary_key=True, index=True)
@ -366,6 +392,15 @@ class WorkflowModel(Base):
user = relationship("UserModel", back_populates="workflows")
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=True)
organization = relationship("OrganizationModel")
# Optional folder for grouping in the agents list. NULL = "Uncategorized".
# ON DELETE SET NULL: deleting a folder un-files its agents, never deletes them.
folder_id = Column(
Integer,
ForeignKey("folders.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
folder = relationship("FolderModel", back_populates="workflows")
name = Column(String, index=True, nullable=False)
status = Column(
Enum(*[status.value for status in WorkflowStatus], name="workflow_status"),

View file

@ -372,6 +372,8 @@ class WorkflowClient(BaseDBClient):
WorkflowModel.name,
WorkflowModel.status,
WorkflowModel.created_at,
WorkflowModel.folder_id,
WorkflowModel.workflow_uuid,
)
)
@ -425,8 +427,26 @@ class WorkflowClient(BaseDBClient):
return result.scalar_one_or_none()
async def get_workflow(
self, workflow_id: int, user_id: int = None, organization_id: int = None
self,
workflow_id: int,
user_id: int | None = None,
organization_id: int | None = None,
) -> WorkflowModel | None:
"""Fetch a workflow by id, scoped to a tenant.
Scoping is mandatory: pass ``organization_id`` (preferred) or
``user_id``. A fully unscoped lookup would let a request-supplied id
reach another tenant's workflow. System/runtime paths that only have a
``workflow_id`` and derive the org from the workflow itself (e.g.
inbound telephony routing) must call ``get_workflow_by_id`` instead
the explicit unscoped variant.
"""
if user_id is None and organization_id is None:
raise ValueError(
"get_workflow requires organization_id (preferred) or user_id "
"for tenant scoping; use get_workflow_by_id for unscoped "
"system lookups."
)
async with self.async_session() as session:
query = (
select(WorkflowModel)
@ -448,6 +468,13 @@ class WorkflowClient(BaseDBClient):
return result.scalars().first()
async def get_workflow_by_id(self, workflow_id: int) -> WorkflowModel | None:
"""Fetch a workflow by id WITHOUT tenant scoping.
Explicit unscoped variant of ``get_workflow``. Only for system/runtime
contexts that legitimately have just a workflow_id and derive the org
from the workflow itself (e.g. inbound telephony). Never call this with
a request-supplied id on a user-facing path.
"""
async with self.async_session() as session:
result = await session.execute(
select(WorkflowModel)
@ -609,7 +636,7 @@ class WorkflowClient(BaseDBClient):
self,
workflow_id: int,
status: str,
organization_id: int = None,
organization_id: int,
) -> WorkflowModel:
"""
Update the status of a workflow.
@ -617,7 +644,9 @@ class WorkflowClient(BaseDBClient):
Args:
workflow_id: The ID of the workflow to update
status: The new status (active/archived)
organization_id: The organization ID
organization_id: The organization ID. Required and always filtered
on: this is a mutation, so an unscoped query would let a caller
archive another org's workflow (tenant-isolation bypass).
Returns:
The updated WorkflowModel
@ -632,12 +661,12 @@ class WorkflowClient(BaseDBClient):
selectinload(WorkflowModel.current_definition),
selectinload(WorkflowModel.released_definition),
)
.where(WorkflowModel.id == workflow_id)
.where(
WorkflowModel.id == workflow_id,
WorkflowModel.organization_id == organization_id,
)
)
if organization_id:
query = query.where(WorkflowModel.organization_id == organization_id)
result = await session.execute(query)
workflow = result.scalars().first()
@ -654,6 +683,47 @@ class WorkflowClient(BaseDBClient):
await session.refresh(workflow)
return workflow
async def move_workflow_to_folder(
self,
workflow_id: int,
folder_id: int | None,
organization_id: int,
) -> WorkflowModel:
"""Set (or clear) a workflow's folder.
Pass ``folder_id=None`` to move the workflow to "Uncategorized". The
caller must validate that ``folder_id`` belongs to ``organization_id``
before calling (the FK only proves the folder exists, not ownership).
``organization_id`` is required and always filtered on: this is a
mutation, so an unscoped query would let a caller move another org's
workflow (tenant-isolation bypass).
Raises:
ValueError: If the workflow is not found within the organization.
"""
async with self.async_session() as session:
query = select(WorkflowModel).where(
WorkflowModel.id == workflow_id,
WorkflowModel.organization_id == organization_id,
)
result = await session.execute(query)
workflow = result.scalars().first()
if not workflow:
raise ValueError(f"Workflow with ID {workflow_id} not found")
workflow.folder_id = folder_id
try:
await session.commit()
except Exception as e:
await session.rollback()
raise e
await session.refresh(workflow)
return workflow
async def get_workflow_run_count(self, workflow_id: int) -> int:
"""Get the count of runs for a workflow."""
async with self.async_session() as session:

View file

@ -34,6 +34,10 @@ from api.mcp_server.ts_bridge import TsBridgeError, parse_code
from api.services.posthog_client import capture_event
from api.services.workflow.dto import ReactFlowDTO
from api.services.workflow.layout import reconcile_positions
from api.services.workflow.trigger_paths import (
extract_trigger_paths,
validate_trigger_paths,
)
from api.services.workflow.workflow_graph import WorkflowGraph
@ -53,20 +57,6 @@ def _format_errors(errors: list[dict[str, Any]]) -> str:
return "\n".join(parts)
def _extract_trigger_paths(workflow_definition: dict) -> list[str]:
"""Mirror of `routes.workflow.extract_trigger_paths` — kept local so the
MCP layer doesn't depend on the route module."""
if not workflow_definition:
return []
paths: list[str] = []
for node in workflow_definition.get("nodes") or []:
if node.get("type") == "trigger":
trigger_path = (node.get("data") or {}).get("trigger_path")
if trigger_path:
paths.append(trigger_path)
return paths
@traced_tool
async def create_workflow(code: str) -> dict[str, Any]:
"""Parse SDK TypeScript and create a new published workflow.
@ -129,6 +119,12 @@ async def create_workflow(code: str) -> dict[str, Any]:
# 1b. New workflow — no prior version to reconcile against; layout
# places new nodes adjacent to their first incoming neighbor.
payload = reconcile_positions(payload, None)
trigger_path_issues = validate_trigger_paths(payload)
if trigger_path_issues:
return _error_result(
"validation_error",
"\n".join(issue.message for issue in trigger_path_issues),
)
# 2. Pydantic shape check (defence in depth — parser is spec-driven).
try:
@ -144,7 +140,7 @@ async def create_workflow(code: str) -> dict[str, Any]:
# 4. Reject upfront if any trigger path collides with another workflow's
# trigger in this org so we don't leave an orphan workflow record.
trigger_paths = _extract_trigger_paths(payload)
trigger_paths = extract_trigger_paths(payload)
if trigger_paths:
try:
await db_client.assert_trigger_paths_available(

View file

@ -32,6 +32,7 @@ from api.mcp_server.tracing import traced_tool
from api.mcp_server.ts_bridge import TsBridgeError, parse_code
from api.services.workflow.dto import ReactFlowDTO
from api.services.workflow.layout import reconcile_positions
from api.services.workflow.trigger_paths import validate_trigger_paths
from api.services.workflow.workflow_graph import WorkflowGraph
@ -129,6 +130,12 @@ async def save_workflow(workflow_id: int, code: str) -> dict[str, Any]:
# here we fill them back in from what was there before, and pick
# approximate placements for newly-introduced nodes.
payload = reconcile_positions(payload, await _previous_workflow_json(workflow))
trigger_path_issues = validate_trigger_paths(payload)
if trigger_path_issues:
return _error_result(
"validation_error",
"\n".join(issue.message for issue in trigger_path_issues),
)
# 2. Pydantic shape check (defence in depth — parser is spec-driven).
try:

99
api/routes/folder.py Normal file
View file

@ -0,0 +1,99 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field, field_validator
from api.db import db_client
from api.db.folder_client import FolderNameConflictError
from api.db.models import UserModel
from api.services.auth.depends import get_user
router = APIRouter(prefix="/folder")
class FolderResponse(BaseModel):
id: int
name: str
created_at: datetime
class CreateFolderRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
@field_validator("name")
@classmethod
def strip_name(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Folder name cannot be empty")
return v
class UpdateFolderRequest(CreateFolderRequest):
pass
@router.get("/")
async def list_folders(
user: UserModel = Depends(get_user),
) -> list[FolderResponse]:
"""List all folders in the authenticated user's organization."""
folders = await db_client.list_folders(
organization_id=user.selected_organization_id
)
return [
FolderResponse(id=f.id, name=f.name, created_at=f.created_at) for f in folders
]
@router.post("/")
async def create_folder(
request: CreateFolderRequest,
user: UserModel = Depends(get_user),
) -> FolderResponse:
"""Create a new folder in the authenticated user's organization."""
try:
folder = await db_client.create_folder(
name=request.name,
organization_id=user.selected_organization_id,
)
except FolderNameConflictError as e:
raise HTTPException(status_code=409, detail=str(e))
return FolderResponse(id=folder.id, name=folder.name, created_at=folder.created_at)
@router.put("/{folder_id}")
async def rename_folder(
folder_id: int,
request: UpdateFolderRequest,
user: UserModel = Depends(get_user),
) -> FolderResponse:
"""Rename a folder owned by the authenticated user's organization."""
try:
folder = await db_client.rename_folder(
folder_id=folder_id,
name=request.name,
organization_id=user.selected_organization_id,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except FolderNameConflictError as e:
raise HTTPException(status_code=409, detail=str(e))
return FolderResponse(id=folder.id, name=folder.name, created_at=folder.created_at)
@router.delete("/{folder_id}")
async def delete_folder(
folder_id: int,
user: UserModel = Depends(get_user),
) -> dict[str, bool]:
"""Delete a folder. Member agents are moved to "Uncategorized", not deleted."""
deleted = await db_client.delete_folder(
folder_id=folder_id,
organization_id=user.selected_organization_id,
)
if not deleted:
raise HTTPException(
status_code=404, detail=f"Folder with id {folder_id} not found"
)
return {"success": True}

View file

@ -6,6 +6,7 @@ from api.routes.agent_stream import router as agent_stream_router
from api.routes.auth import router as auth_router
from api.routes.campaign import router as campaign_router
from api.routes.credentials import router as credentials_router
from api.routes.folder import router as folder_router
from api.routes.knowledge_base import router as knowledge_base_router
from api.routes.node_types import router as node_types_router
from api.routes.organization import router as organization_router
@ -54,6 +55,7 @@ router.include_router(public_download_router)
router.include_router(workflow_embed_router)
router.include_router(knowledge_base_router)
router.include_router(workflow_recording_router)
router.include_router(folder_router)
router.include_router(auth_router)
router.include_router(node_types_router)
router.include_router(agent_stream_router)

View file

@ -1,18 +1,19 @@
"""Public API endpoints for agent triggers.
"""Public API endpoints for public agent execution.
These endpoints are accessible with API key authentication and allow
external systems to programmatically trigger phone calls.
"""
import random
from typing import Optional
from dataclasses import dataclass
from typing import Awaitable, Callable, Optional
from fastapi import APIRouter, Header, HTTPException
from loguru import logger
from pydantic import BaseModel
from api.db import db_client
from api.enums import TriggerState
from api.enums import TriggerState, WorkflowStatus
from api.services.quota_service import check_dograh_quota_by_user_id
from api.services.telephony.factory import (
get_default_telephony_provider,
@ -39,6 +40,14 @@ class TriggerCallResponse(BaseModel):
workflow_run_name: str
@dataclass
class ResolvedAgentTarget:
workflow: object
organization_id: int
identifier_type: str
identifier_value: str
def trigger_exists_in_workflow(workflow_definition: dict, trigger_path: str) -> bool:
"""Check if trigger node exists in workflow definition.
@ -57,72 +66,133 @@ def trigger_exists_in_workflow(workflow_definition: dict, trigger_path: str) ->
return False
async def _initiate_call(
uuid: str,
request: TriggerCallRequest,
x_api_key: str,
*,
use_draft: bool,
) -> TriggerCallResponse:
"""Shared core for production and test trigger endpoints.
When ``use_draft`` is True the latest draft definition is executed;
otherwise the published (released) definition is used.
"""
# 1. Validate API key
async def _validate_api_key(x_api_key: str):
"""Validate the org API key used to invoke a public agent endpoint."""
api_key = await db_client.validate_api_key(x_api_key)
if not api_key:
raise HTTPException(status_code=401, detail="Invalid API key")
return api_key
# 2. Lookup agent trigger by UUID
trigger = await db_client.get_agent_trigger_by_path(uuid)
def _ensure_workflow_is_active(workflow) -> None:
if workflow.status != WorkflowStatus.ACTIVE.value:
raise HTTPException(status_code=404, detail="Workflow is not active")
def _get_execution_user_id(workflow) -> int:
if workflow.user_id is None:
raise HTTPException(
status_code=409,
detail="Workflow has no execution owner",
)
return workflow.user_id
async def _get_workflow_definition_for_execution(workflow, *, use_draft: bool) -> dict:
"""Return the definition that would execute for this public agent request."""
if use_draft:
draft = await db_client.get_draft_version(workflow.id)
if draft:
return draft.workflow_json
if workflow.released_definition is None:
raise HTTPException(
status_code=404, detail="Workflow has no published definition"
)
return workflow.released_definition.workflow_json
async def _resolve_trigger_target(
trigger_path: str,
organization_id: int,
*,
use_draft: bool,
) -> ResolvedAgentTarget:
"""Resolve a trigger UUID to a workflow, scoped to the API key's org."""
trigger = await db_client.get_agent_trigger_by_path(trigger_path)
if not trigger:
raise HTTPException(status_code=404, detail="Agent trigger not found")
# 3. Validate organization match (API key org must match trigger org)
if api_key.organization_id != trigger.organization_id:
if organization_id != trigger.organization_id:
raise HTTPException(status_code=403, detail="Access denied")
# 4. Validate trigger is active
if trigger.state != TriggerState.ACTIVE.value:
raise HTTPException(status_code=404, detail="Agent trigger is not active")
# 4.5 Check Dograh quota before initiating the call (apply the trigger's
# workflow's model_overrides so we evaluate the keys this run will use).
workflow = await db_client.get_workflow(
trigger.workflow_id,
organization_id=organization_id,
)
if not workflow:
raise HTTPException(status_code=404, detail="Workflow not found")
_ensure_workflow_is_active(workflow)
workflow_definition = await _get_workflow_definition_for_execution(
workflow,
use_draft=use_draft,
)
if not trigger_exists_in_workflow(workflow_definition, trigger_path):
raise HTTPException(
status_code=404,
detail="Trigger not found in the selected Agent",
)
return ResolvedAgentTarget(
workflow=workflow,
organization_id=organization_id,
identifier_type="trigger_path",
identifier_value=trigger_path,
)
async def _resolve_workflow_uuid_target(
workflow_uuid: str,
organization_id: int,
*,
use_draft: bool,
) -> ResolvedAgentTarget:
"""Resolve a workflow UUID directly, scoped to the API key's org."""
workflow = await db_client.get_workflow_by_uuid(workflow_uuid, organization_id)
if not workflow:
raise HTTPException(status_code=404, detail="Workflow not found")
_ensure_workflow_is_active(workflow)
await _get_workflow_definition_for_execution(workflow, use_draft=use_draft)
return ResolvedAgentTarget(
workflow=workflow,
organization_id=organization_id,
identifier_type="workflow_uuid",
identifier_value=workflow_uuid,
)
async def _execute_resolved_target(
target: ResolvedAgentTarget,
request: TriggerCallRequest,
*,
use_draft: bool,
api_key_id: int | None,
api_key_created_by: int | None,
) -> TriggerCallResponse:
"""Shared execution path once the target workflow has been resolved."""
execution_user_id = _get_execution_user_id(target.workflow)
# Check Dograh quota using the workflow owner's config and model overrides.
quota_result = await check_dograh_quota_by_user_id(
api_key.created_by, workflow_id=trigger.workflow_id
execution_user_id,
workflow_id=target.workflow.id,
)
if not quota_result.has_quota:
raise HTTPException(status_code=402, detail=quota_result.error_message)
# 5. Get workflow and resolve the definition (published vs draft)
workflow = await db_client.get_workflow_by_id(trigger.workflow_id)
if not workflow:
raise HTTPException(status_code=404, detail="Workflow not found")
if use_draft:
draft = await db_client.get_draft_version(trigger.workflow_id)
# Fall back to the published definition when no draft exists, so the
# test URL always runs *something* — typically the same agent the
# production URL would run.
workflow_definition = (
draft.workflow_json if draft else workflow.released_definition.workflow_json
)
else:
workflow_definition = workflow.released_definition.workflow_json
# Validate trigger node still exists in the resolved definition
if not trigger_exists_in_workflow(workflow_definition, uuid):
raise HTTPException(
status_code=404,
detail="Trigger not found in the published Agent",
)
# 6. Get telephony provider — either the caller-specified config (validated
# against the trigger's org) or the org's default config.
# Get telephony provider — either the caller-specified config (validated
# against the workflow's org) or the org's default config.
if request.telephony_configuration_id is not None:
cfg = await db_client.get_telephony_configuration_for_org(
request.telephony_configuration_id, trigger.organization_id
request.telephony_configuration_id,
target.organization_id,
)
if not cfg:
raise HTTPException(
@ -130,7 +200,7 @@ async def _initiate_call(
)
try:
provider = await get_telephony_provider_by_id(
cfg.id, trigger.organization_id
cfg.id, target.organization_id
)
except ValueError:
raise HTTPException(
@ -140,14 +210,14 @@ async def _initiate_call(
resolved_cfg_id = cfg.id
else:
try:
provider = await get_default_telephony_provider(trigger.organization_id)
provider = await get_default_telephony_provider(target.organization_id)
except ValueError:
raise HTTPException(
status_code=400,
detail="Telephony provider not configured for this organization",
)
default_cfg = await db_client.get_default_telephony_configuration(
trigger.organization_id
target.organization_id
)
resolved_cfg_id = default_cfg.id if default_cfg else None
@ -164,24 +234,36 @@ async def _initiate_call(
# 8. Create workflow run
mode_label = "TEST" if use_draft else "API"
workflow_run_name = f"WR-{mode_label}-{random.randint(1000, 9999)}"
initial_context = {
"provider": provider.PROVIDER_NAME,
"phone_number": request.phone_number,
"trigger_mode": "test" if use_draft else "production",
"telephony_configuration_id": resolved_cfg_id,
"agent_identifier": target.identifier_value,
"agent_identifier_type": target.identifier_type,
"workflow_uuid": target.workflow.workflow_uuid,
}
if target.identifier_type == "trigger_path":
initial_context["agent_uuid"] = target.identifier_value
if api_key_id is not None:
initial_context["api_key_id"] = api_key_id
if api_key_created_by is not None:
initial_context["api_key_created_by"] = api_key_created_by
initial_context.update(request.initial_context or {})
workflow_run = await db_client.create_workflow_run(
name=workflow_run_name,
workflow_id=trigger.workflow_id,
workflow_id=target.workflow.id,
mode=workflow_run_mode,
initial_context={
"provider": provider.PROVIDER_NAME,
"phone_number": request.phone_number,
"agent_uuid": uuid,
"trigger_mode": "test" if use_draft else "production",
"telephony_configuration_id": resolved_cfg_id,
**(request.initial_context or {}),
},
user_id=api_key.created_by,
initial_context=initial_context,
user_id=execution_user_id,
use_draft=use_draft,
organization_id=target.organization_id,
)
logger.info(
f"Created workflow run {workflow_run.id} for API trigger {uuid} "
f"Created workflow run {workflow_run.id} for public agent "
f"{target.identifier_type}={target.identifier_value} "
f"(mode={'test' if use_draft else 'production'}) "
f"to phone number {request.phone_number}"
)
@ -192,10 +274,10 @@ async def _initiate_call(
webhook_url = (
f"{backend_endpoint}/api/v1/telephony/{webhook_endpoint}"
f"?workflow_id={trigger.workflow_id}"
f"&user_id={api_key.created_by}"
f"?workflow_id={target.workflow.id}"
f"&user_id={execution_user_id}"
f"&workflow_run_id={workflow_run.id}"
f"&organization_id={trigger.organization_id}"
f"&organization_id={target.organization_id}"
)
# 10. Initiate call via telephony provider. workflow_id and user_id are
@ -207,8 +289,8 @@ async def _initiate_call(
to_number=request.phone_number,
webhook_url=webhook_url,
workflow_run_id=workflow_run.id,
workflow_id=trigger.workflow_id,
user_id=api_key.created_by,
workflow_id=target.workflow.id,
user_id=execution_user_id,
)
except Exception as e:
logger.warning(
@ -221,7 +303,7 @@ async def _initiate_call(
logger.info(
f"Call initiated successfully for workflow run {workflow_run.id} "
f"via trigger {uuid}"
f"via {target.identifier_type}={target.identifier_value}"
)
return TriggerCallResponse(
@ -231,6 +313,30 @@ async def _initiate_call(
)
async def _initiate_call(
identifier: str,
request: TriggerCallRequest,
x_api_key: str,
*,
use_draft: bool,
target_resolver: Callable[..., Awaitable[ResolvedAgentTarget]],
) -> TriggerCallResponse:
"""Resolve the requested public target, then execute the common call flow."""
api_key = await _validate_api_key(x_api_key)
target = await target_resolver(
identifier,
api_key.organization_id,
use_draft=use_draft,
)
return await _execute_resolved_target(
target,
request,
use_draft=use_draft,
api_key_id=api_key.id,
api_key_created_by=api_key.created_by,
)
@router.post("/{uuid}", response_model=TriggerCallResponse)
async def initiate_call(
uuid: str,
@ -241,7 +347,13 @@ async def initiate_call(
Executes the workflow's currently released definition.
"""
return await _initiate_call(uuid, request, x_api_key, use_draft=False)
return await _initiate_call(
uuid,
request,
x_api_key,
use_draft=False,
target_resolver=_resolve_trigger_target,
)
@router.post("/test/{uuid}", response_model=TriggerCallResponse)
@ -255,4 +367,42 @@ async def initiate_call_test(
Useful for verifying changes before publishing. Falls back to the
published definition when no draft exists.
"""
return await _initiate_call(uuid, request, x_api_key, use_draft=True)
return await _initiate_call(
uuid,
request,
x_api_key,
use_draft=True,
target_resolver=_resolve_trigger_target,
)
@router.post("/workflow/{workflow_uuid}", response_model=TriggerCallResponse)
async def initiate_call_by_workflow_uuid(
workflow_uuid: str,
request: TriggerCallRequest,
x_api_key: str = Header(..., alias="X-API-Key"),
):
"""Initiate a phone call against the published workflow identified by UUID."""
return await _initiate_call(
workflow_uuid,
request,
x_api_key,
use_draft=False,
target_resolver=_resolve_workflow_uuid_target,
)
@router.post("/test/workflow/{workflow_uuid}", response_model=TriggerCallResponse)
async def initiate_call_test_by_workflow_uuid(
workflow_uuid: str,
request: TriggerCallRequest,
x_api_key: str = Header(..., alias="X-API-Key"),
):
"""Initiate a phone call against the latest draft of the workflow by UUID."""
return await _initiate_call(
workflow_uuid,
request,
x_api_key,
use_draft=True,
target_resolver=_resolve_workflow_uuid_target,
)

View file

@ -305,7 +305,9 @@ async def _validate_inbound_request(
"""
from api.services.telephony import registry as telephony_registry
workflow = await db_client.get_workflow(workflow_id)
# System lookup: inbound routing only has the workflow_id and derives the
# org/user from the workflow itself, so use the explicit unscoped variant.
workflow = await db_client.get_workflow_by_id(workflow_id)
if not workflow:
return False, TelephonyError.WORKFLOW_NOT_FOUND, {}, None
@ -528,8 +530,9 @@ async def _handle_telephony_websocket(
await websocket.close(code=4404, reason="Workflow run not found")
return
# Get workflow for organization info
workflow = await db_client.get_workflow(workflow_id)
# Get workflow for organization info. System lookup keyed only on the
# workflow_id (org is derived below) — use the explicit unscoped variant.
workflow = await db_client.get_workflow_by_id(workflow_id)
if not workflow:
logger.error(f"Workflow {workflow_id} not found")
await websocket.close(code=4404, reason="Workflow not found")

View file

@ -32,99 +32,16 @@ from api.services.storage import storage_fs
from api.services.workflow.dto import ReactFlowDTO, sanitize_workflow_definition
from api.services.workflow.duplicate import duplicate_workflow
from api.services.workflow.errors import ItemKind, WorkflowError
from api.services.workflow.trigger_paths import (
TriggerPathIssue,
ensure_trigger_paths,
extract_trigger_paths,
regenerate_trigger_uuids,
trigger_path_to_node_id,
validate_trigger_paths,
)
from api.services.workflow.workflow_graph import WorkflowGraph
def extract_trigger_paths(workflow_definition: dict) -> List[str]:
"""Extract trigger UUIDs from workflow definition.
Args:
workflow_definition: The workflow definition JSON
Returns:
List of trigger UUIDs found in the workflow
"""
if not workflow_definition:
return []
nodes = workflow_definition.get("nodes", [])
trigger_paths = []
for node in nodes:
if node.get("type") == "trigger":
trigger_path = node.get("data", {}).get("trigger_path")
if trigger_path:
trigger_paths.append(trigger_path)
return trigger_paths
def _trigger_path_to_node_id(workflow_definition: dict) -> dict[str, str]:
"""Map each trigger node's trigger_path to its node id."""
if not workflow_definition:
return {}
out: dict[str, str] = {}
for node in workflow_definition.get("nodes", []):
if node.get("type") == "trigger":
tp = node.get("data", {}).get("trigger_path")
if tp:
out[tp] = node.get("id")
return out
def regenerate_trigger_uuids(workflow_definition: dict) -> dict:
"""Regenerate UUIDs for all trigger nodes in a workflow definition.
This should be called when creating a new workflow from a template or
duplicating a workflow to avoid trigger UUID conflicts.
Args:
workflow_definition: The workflow definition JSON
Returns:
Updated workflow definition with new trigger UUIDs
"""
if not workflow_definition:
return workflow_definition
# Deep copy to avoid modifying the original
import copy
updated_definition = copy.deepcopy(workflow_definition)
nodes = updated_definition.get("nodes", [])
for node in nodes:
if node.get("type") == "trigger":
# Generate a new UUID for this trigger
if "data" not in node:
node["data"] = {}
node["data"]["trigger_path"] = str(uuid.uuid4())
return updated_definition
def ensure_trigger_paths(workflow_definition: Optional[dict]) -> Optional[dict]:
"""Mint a UUID for any trigger node that's missing ``data.trigger_path``.
Trigger nodes that already carry a non-empty trigger_path are left
untouched so stable IDs survive edits. The input is not mutated; the
returned dict is what should be persisted and echoed in the response.
"""
if not workflow_definition:
return workflow_definition
import copy
out = copy.deepcopy(workflow_definition)
for node in out.get("nodes") or []:
if node.get("type") != "trigger":
continue
data = node.setdefault("data", {})
if not data.get("trigger_path"):
data["trigger_path"] = str(uuid.uuid4())
return out
router = APIRouter(prefix="/workflow")
@ -139,7 +56,7 @@ def _trigger_conflict_http_exception(
"""Build a 409 with the same detail shape as validate's 422 so the editor
can highlight the offending trigger node(s) using the same code path."""
path_to_node = (
_trigger_path_to_node_id(workflow_definition) if workflow_definition else {}
trigger_path_to_node_id(workflow_definition) if workflow_definition else {}
)
errors: list[WorkflowError] = [
WorkflowError(
@ -159,6 +76,24 @@ def _trigger_conflict_http_exception(
)
def _trigger_path_validation_http_exception(
issues: list[TriggerPathIssue],
) -> HTTPException:
errors = [
WorkflowError(
kind=ItemKind.node,
id=issue.node_id,
field="data.trigger_path",
message=issue.message,
)
for issue in issues
]
return HTTPException(
status_code=422,
detail=ValidateWorkflowResponse(is_valid=False, errors=errors).model_dump(),
)
async def _validate_workflow_definition(
workflow_definition: Optional[dict],
exclude_workflow_id: Optional[int] = None,
@ -187,6 +122,17 @@ async def _validate_workflow_definition(
except ValueError as e:
errors.extend(e.args[0])
# ----------- Trigger Path Format Check ------------
for issue in validate_trigger_paths(workflow_definition):
errors.append(
WorkflowError(
kind=ItemKind.node,
id=issue.node_id,
field="data.trigger_path",
message=issue.message,
)
)
# ----------- Trigger Path Conflict Check ------------
trigger_paths = extract_trigger_paths(workflow_definition)
if trigger_paths:
@ -195,7 +141,7 @@ async def _validate_workflow_definition(
exclude_workflow_id=exclude_workflow_id,
)
if conflicts:
path_to_node = _trigger_path_to_node_id(workflow_definition)
path_to_node = trigger_path_to_node_id(workflow_definition)
for conflicting_path in conflicts:
errors.append(
WorkflowError(
@ -251,6 +197,14 @@ class WorkflowListResponse(BaseModel):
status: str
created_at: datetime
total_runs: int
folder_id: int | None = None
workflow_uuid: str | None = None
class MoveWorkflowToFolderRequest(BaseModel):
"""Move a workflow into a folder, or to "Uncategorized" when null."""
folder_id: int | None = None
class WorkflowCountResponse(BaseModel):
@ -404,6 +358,9 @@ async def create_workflow(
# Auto-mint trigger_path for any trigger node that didn't ship one so
# clients don't need to generate UUIDs themselves.
workflow_definition = ensure_trigger_paths(request.workflow_definition)
trigger_path_issues = validate_trigger_paths(workflow_definition)
if trigger_path_issues:
raise _trigger_path_validation_http_exception(trigger_path_issues)
# Validate trigger path uniqueness BEFORE creating the workflow so we
# don't leave an orphaned workflow record when the trigger conflicts.
@ -641,6 +598,8 @@ async def get_workflows(
status=workflow.status,
created_at=workflow.created_at,
total_runs=run_counts.get(workflow.id, 0),
folder_id=workflow.folder_id,
workflow_uuid=workflow.workflow_uuid,
)
for workflow in workflows
]
@ -883,6 +842,48 @@ async def update_workflow_status(
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{workflow_id}/folder")
async def move_workflow_to_folder(
workflow_id: int,
request: MoveWorkflowToFolderRequest,
user: UserModel = Depends(get_user),
) -> WorkflowListResponse:
"""Move a workflow into a folder, or to "Uncategorized" (folder_id=null).
Validates that the target folder belongs to the caller's organization —
the FK alone proves the folder exists, not that the caller may use it.
"""
# Validate target folder ownership (tenant isolation) unless un-filing.
if request.folder_id is not None:
folder = await db_client.get_folder(
request.folder_id, organization_id=user.selected_organization_id
)
if folder is None:
raise HTTPException(
status_code=404,
detail=f"Folder with id {request.folder_id} not found",
)
try:
workflow = await db_client.move_workflow_to_folder(
workflow_id=workflow_id,
folder_id=request.folder_id,
organization_id=user.selected_organization_id,
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
run_count = await db_client.get_workflow_run_count(workflow.id)
return WorkflowListResponse(
id=workflow.id,
name=workflow.name,
status=workflow.status,
created_at=workflow.created_at,
total_runs=run_count,
folder_id=workflow.folder_id,
)
@router.put(
"/{workflow_id}",
**sdk_expose(
@ -917,6 +918,9 @@ async def update_workflow(
# response echoes workflow_definition so the client picks up the new
# UUID without a refetch.
workflow_definition = ensure_trigger_paths(workflow_definition)
trigger_path_issues = validate_trigger_paths(workflow_definition)
if trigger_path_issues:
raise _trigger_path_validation_http_exception(trigger_path_issues)
if workflow_definition:
existing_workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id

View file

@ -0,0 +1,49 @@
from .deepgram import DEEPGRAM_LANGUAGES, DEEPGRAM_STT_MODELS
from .gladia import GLADIA_STT_LANGUAGES, GLADIA_STT_MODELS
from .google import (
GOOGLE_MODELS,
GOOGLE_REALTIME_LANGUAGES,
GOOGLE_REALTIME_MODELS,
GOOGLE_REALTIME_VOICES,
GOOGLE_STT_LANGUAGES,
GOOGLE_STT_MODELS,
GOOGLE_TTS_LANGUAGES,
GOOGLE_TTS_MODELS,
GOOGLE_TTS_VOICES,
GOOGLE_VERTEX_REALTIME_LANGUAGES,
GOOGLE_VERTEX_REALTIME_MODELS,
GOOGLE_VERTEX_REALTIME_VOICES,
)
from .sarvam import (
SARVAM_LANGUAGES,
SARVAM_STT_MODELS,
SARVAM_TTS_MODELS,
SARVAM_V2_VOICES,
SARVAM_V3_VOICES,
)
from .speechmatics import SPEECHMATICS_STT_LANGUAGES
__all__ = [
"DEEPGRAM_LANGUAGES",
"DEEPGRAM_STT_MODELS",
"GLADIA_STT_LANGUAGES",
"GLADIA_STT_MODELS",
"GOOGLE_MODELS",
"GOOGLE_REALTIME_LANGUAGES",
"GOOGLE_REALTIME_MODELS",
"GOOGLE_REALTIME_VOICES",
"GOOGLE_STT_LANGUAGES",
"GOOGLE_STT_MODELS",
"GOOGLE_TTS_LANGUAGES",
"GOOGLE_TTS_MODELS",
"GOOGLE_TTS_VOICES",
"GOOGLE_VERTEX_REALTIME_LANGUAGES",
"GOOGLE_VERTEX_REALTIME_MODELS",
"GOOGLE_VERTEX_REALTIME_VOICES",
"SARVAM_LANGUAGES",
"SARVAM_STT_MODELS",
"SARVAM_TTS_MODELS",
"SARVAM_V2_VOICES",
"SARVAM_V3_VOICES",
"SPEECHMATICS_STT_LANGUAGES",
]

View file

@ -0,0 +1,84 @@
DEEPGRAM_STT_MODELS = ("nova-3-general", "flux-general-en", "flux-general-multi")
DEEPGRAM_LANGUAGES = (
"multi",
"ar",
"ar-AE",
"ar-SA",
"ar-QA",
"ar-KW",
"ar-SY",
"ar-LB",
"ar-PS",
"ar-JO",
"ar-EG",
"ar-SD",
"ar-TD",
"ar-MA",
"ar-DZ",
"ar-TN",
"ar-IQ",
"ar-IR",
"be",
"bn",
"bs",
"bg",
"ca",
"cs",
"da",
"da-DK",
"de",
"de-CH",
"el",
"en",
"en-US",
"en-AU",
"en-GB",
"en-IN",
"en-NZ",
"es",
"es-419",
"et",
"fa",
"fi",
"fr",
"fr-CA",
"he",
"hi",
"hr",
"hu",
"id",
"it",
"ja",
"kn",
"ko",
"ko-KR",
"lt",
"lv",
"mk",
"mr",
"ms",
"nl",
"nl-BE",
"no",
"pl",
"pt",
"pt-BR",
"pt-PT",
"ro",
"ru",
"sk",
"sl",
"sr",
"sv",
"sv-SE",
"ta",
"te",
"th",
"tl",
"tr",
"uk",
"ur",
"vi",
"zh-CN",
"zh-TW",
)

View file

@ -0,0 +1,103 @@
GLADIA_STT_MODELS = ("solaria-1",)
GLADIA_STT_LANGUAGES = (
"af",
"am",
"ar",
"as",
"az",
"ba",
"be",
"bg",
"bn",
"bo",
"br",
"bs",
"ca",
"cs",
"cy",
"da",
"de",
"el",
"en",
"es",
"et",
"eu",
"fa",
"fi",
"fo",
"fr",
"gl",
"gu",
"ha",
"haw",
"he",
"hi",
"hr",
"ht",
"hu",
"hy",
"id",
"is",
"it",
"ja",
"jw",
"ka",
"kk",
"km",
"kn",
"ko",
"la",
"lb",
"ln",
"lo",
"lt",
"lv",
"mg",
"mi",
"mk",
"ml",
"mn",
"mr",
"ms",
"mt",
"my",
"ne",
"nl",
"nn",
"no",
"oc",
"pa",
"pl",
"ps",
"pt",
"ro",
"ru",
"sa",
"sd",
"si",
"sk",
"sl",
"sn",
"so",
"sq",
"sr",
"su",
"sv",
"sw",
"ta",
"te",
"tg",
"th",
"tk",
"tl",
"tr",
"tt",
"uk",
"ur",
"uz",
"vi",
"wo",
"yi",
"yo",
"zh",
)

View file

@ -0,0 +1,273 @@
GOOGLE_MODELS = (
"gemini-2.0-flash",
"gemini-2.0-flash-lite",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
)
GOOGLE_REALTIME_MODELS = ("gemini-3.1-flash-live-preview",)
GOOGLE_REALTIME_VOICES = ("Puck", "Charon", "Kore", "Fenrir", "Aoede")
GOOGLE_REALTIME_LANGUAGES = (
"ar",
"bn",
"de",
"en",
"es",
"fr",
"gu",
"hi",
"id",
"it",
"ja",
"kn",
"ko",
"ml",
"mr",
"nl",
"pl",
"pt",
"ru",
"ta",
"te",
"th",
"tr",
"vi",
"zh",
)
GOOGLE_VERTEX_REALTIME_MODELS = ("google/gemini-live-2.5-flash-native-audio",)
GOOGLE_VERTEX_REALTIME_VOICES = GOOGLE_REALTIME_VOICES
GOOGLE_VERTEX_REALTIME_LANGUAGES = GOOGLE_REALTIME_LANGUAGES
GOOGLE_STT_MODELS = ("latest_long", "latest_short", "chirp_3")
# Docs-derived from Google Cloud Speech-to-Text V2 supported languages.
GOOGLE_STT_LANGUAGES = (
"af-ZA",
"am-ET",
"ar-AE",
"ar-BH",
"ar-DZ",
"ar-EG",
"ar-IL",
"ar-IQ",
"ar-JO",
"ar-KW",
"ar-LB",
"ar-MA",
"ar-MR",
"ar-OM",
"ar-PS",
"ar-QA",
"ar-SA",
"ar-SY",
"ar-TN",
"ar-XA",
"ar-YE",
"as-IN",
"ast-ES",
"az-AZ",
"be-BY",
"bg-BG",
"bn-BD",
"bn-IN",
"bs-BA",
"ca-ES",
"ceb-PH",
"ckb-IQ",
"cmn-Hans-CN",
"cmn-Hant-TW",
"cs-CZ",
"cy-GB",
"da-DK",
"de-AT",
"de-CH",
"de-DE",
"el-GR",
"en-AU",
"en-GB",
"en-HK",
"en-IE",
"en-IN",
"en-NZ",
"en-PH",
"en-PK",
"en-SG",
"en-US",
"es-419",
"es-AR",
"es-BO",
"es-CL",
"es-CO",
"es-CR",
"es-DO",
"es-EC",
"es-ES",
"es-GT",
"es-HN",
"es-MX",
"es-NI",
"es-PA",
"es-PE",
"es-PR",
"es-SV",
"es-US",
"es-UY",
"es-VE",
"et-EE",
"eu-ES",
"fa-IR",
"ff-SN",
"fi-FI",
"fil-PH",
"fr-BE",
"fr-CA",
"fr-CH",
"fr-FR",
"ga-IE",
"gl-ES",
"gu-IN",
"ha-NG",
"hi-IN",
"hr-HR",
"hu-HU",
"hy-AM",
"id-ID",
"ig-NG",
"is-IS",
"it-CH",
"it-IT",
"iw-IL",
"ja-JP",
"jv-ID",
"ka-GE",
"kam-KE",
"kea-CV",
"kk-KZ",
"km-KH",
"kn-IN",
"ko-KR",
"ky-KG",
"lb-LU",
"lg-UG",
"ln-CD",
"lo-LA",
"lt-LT",
"luo-KE",
"lv-LV",
"mi-NZ",
"mk-MK",
"ml-IN",
"mn-MN",
"mr-IN",
"ms-MY",
"mt-MT",
"my-MM",
"ne-NP",
"nl-BE",
"nl-NL",
"no-NO",
"nso-ZA",
"ny-MW",
"oc-FR",
"om-ET",
"or-IN",
"pa-Guru-IN",
"pl-PL",
"ps-AF",
"pt-BR",
"pt-PT",
"ro-RO",
"ru-RU",
"rup-BG",
"rw-RW",
"sd-IN",
"si-LK",
"sk-SK",
"sl-SI",
"sn-ZW",
"so-SO",
"sq-AL",
"sr-RS",
"ss-Latn-ZA",
"st-ZA",
"su-ID",
"sv-SE",
"sw",
"sw-KE",
"ta-IN",
"te-IN",
"tg-TJ",
"th-TH",
"tn-Latn-ZA",
"tr-TR",
"ts-ZA",
"uk-UA",
"umb-AO",
"ur-PK",
"uz-UZ",
"ve-ZA",
"vi-VN",
"wo-SN",
"xh-ZA",
"yo-NG",
"yue-Hant-HK",
"zu-ZA",
)
GOOGLE_TTS_MODELS = ("chirp_3_hd",)
GOOGLE_TTS_VOICES = ("en-US-Chirp3-HD-Charon",)
GOOGLE_TTS_LANGUAGES = (
"ar-XA",
"bn-IN",
"bg-BG",
"yue-HK",
"hr-HR",
"cs-CZ",
"da-DK",
"nl-BE",
"nl-NL",
"en-AU",
"en-IN",
"en-GB",
"en-US",
"et-EE",
"fi-FI",
"fr-CA",
"fr-FR",
"de-DE",
"el-GR",
"gu-IN",
"he-IL",
"hi-IN",
"hu-HU",
"id-ID",
"it-IT",
"ja-JP",
"kn-IN",
"ko-KR",
"lv-LV",
"lt-LT",
"ml-IN",
"cmn-CN",
"mr-IN",
"nb-NO",
"pl-PL",
"pt-BR",
"pa-IN",
"ro-RO",
"ru-RU",
"sr-RS",
"sk-SK",
"sl-SI",
"es-ES",
"es-US",
"sw-KE",
"sv-SE",
"ta-IN",
"te-IN",
"th-TH",
"tr-TR",
"uk-UA",
"ur-IN",
"vi-VN",
)

View file

@ -0,0 +1,66 @@
SARVAM_TTS_MODELS = ("bulbul:v2", "bulbul:v3")
SARVAM_V2_VOICES = (
"anushka",
"manisha",
"vidya",
"arya",
"abhilash",
"karun",
"hitesh",
)
SARVAM_V3_VOICES = (
"shubh",
"aditya",
"ritu",
"priya",
"neha",
"rahul",
"pooja",
"rohan",
"simran",
"kavya",
"amit",
"dev",
"ishita",
"shreya",
"ratan",
"varun",
"manan",
"sumit",
"roopa",
"kabir",
"aayan",
"ashutosh",
"advait",
"amelia",
"sophia",
"anand",
"tanya",
"tarun",
"sunny",
"mani",
"gokul",
"vijay",
"shruti",
"suhani",
"mohit",
"kavitha",
"rehan",
"soham",
"rupali",
)
SARVAM_LANGUAGES = (
"bn-IN",
"en-IN",
"gu-IN",
"hi-IN",
"kn-IN",
"ml-IN",
"mr-IN",
"od-IN",
"pa-IN",
"ta-IN",
"te-IN",
"as-IN",
)
SARVAM_STT_MODELS = ("saarika:v2.5", "saaras:v2")

View file

@ -0,0 +1,63 @@
SPEECHMATICS_STT_LANGUAGES = (
"ar",
"ar_en",
"ba",
"eu",
"be",
"bn",
"bg",
"yue",
"ca",
"hr",
"cs",
"da",
"nl",
"en",
"eo",
"et",
"fi",
"fr",
"gl",
"de",
"el",
"he",
"hi",
"hu",
"id",
"ia",
"ga",
"it",
"ja",
"ko",
"lv",
"lt",
"ms",
"en_ms",
"mt",
"cmn",
"cmn_en",
"cmn_en_ms_ta",
"mr",
"mn",
"no",
"fa",
"pl",
"pt",
"ro",
"ru",
"sk",
"sl",
"es",
"sw",
"sv",
"tl",
"ta",
"en_ta",
"th",
"tr",
"uk",
"ur",
"ug",
"vi",
"cy",
)

View file

@ -2,7 +2,32 @@ import random
from enum import Enum, auto
from typing import Annotated, Dict, Literal, Type, TypeVar, Union
from pydantic import BaseModel, Field, computed_field, field_validator
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator
from api.services.configuration.options import (
DEEPGRAM_LANGUAGES,
DEEPGRAM_STT_MODELS,
GLADIA_STT_LANGUAGES,
GLADIA_STT_MODELS,
GOOGLE_MODELS,
GOOGLE_REALTIME_LANGUAGES,
GOOGLE_REALTIME_MODELS,
GOOGLE_REALTIME_VOICES,
GOOGLE_STT_LANGUAGES,
GOOGLE_STT_MODELS,
GOOGLE_TTS_LANGUAGES,
GOOGLE_TTS_MODELS,
GOOGLE_TTS_VOICES,
GOOGLE_VERTEX_REALTIME_LANGUAGES,
GOOGLE_VERTEX_REALTIME_MODELS,
GOOGLE_VERTEX_REALTIME_VOICES,
SARVAM_LANGUAGES,
SARVAM_STT_MODELS,
SARVAM_TTS_MODELS,
SARVAM_V2_VOICES,
SARVAM_V3_VOICES,
SPEECHMATICS_STT_LANGUAGES,
)
class ServiceType(Enum):
@ -153,9 +178,56 @@ def register_embeddings(cls: Type[BaseEmbeddingsConfiguration]):
return register_service(ServiceType.EMBEDDINGS)(cls)
def provider_model_config(
title: str,
*,
description: str | None = None,
provider_docs_url: str | None = None,
) -> ConfigDict:
json_schema_extra: dict[str, str] = {}
if description is not None:
json_schema_extra["description"] = description
if provider_docs_url is not None:
json_schema_extra["provider_docs_url"] = provider_docs_url
if json_schema_extra:
return ConfigDict(title=title, json_schema_extra=json_schema_extra)
return ConfigDict(title=title)
###################################################### LLM ########################################################################
# Suggested models for each provider (used for UI dropdown)
OPENAI_PROVIDER_MODEL_CONFIG = provider_model_config("OpenAI")
GOOGLE_PROVIDER_MODEL_CONFIG = provider_model_config("Google")
GROQ_PROVIDER_MODEL_CONFIG = provider_model_config("Groq")
OPENROUTER_PROVIDER_MODEL_CONFIG = provider_model_config("Open Router")
AZURE_OPENAI_PROVIDER_MODEL_CONFIG = provider_model_config("Azure OpenAI")
DOGRAH_PROVIDER_MODEL_CONFIG = provider_model_config("Dograh")
AWS_BEDROCK_PROVIDER_MODEL_CONFIG = provider_model_config("AWS Bedrock")
OPENAI_REALTIME_PROVIDER_MODEL_CONFIG = provider_model_config("OpenAI Realtime")
GOOGLE_REALTIME_PROVIDER_MODEL_CONFIG = provider_model_config("Google Realtime")
GOOGLE_VERTEX_REALTIME_PROVIDER_MODEL_CONFIG = provider_model_config(
"Google Vertex Realtime"
)
DEEPGRAM_PROVIDER_MODEL_CONFIG = provider_model_config("Deepgram")
ELEVENLABS_PROVIDER_MODEL_CONFIG = provider_model_config("ElevenLabs")
CARTESIA_PROVIDER_MODEL_CONFIG = provider_model_config("Cartesia")
SARVAM_PROVIDER_MODEL_CONFIG = provider_model_config("Sarvam")
CAMB_PROVIDER_MODEL_CONFIG = provider_model_config("Camb.ai")
RIME_PROVIDER_MODEL_CONFIG = provider_model_config("Rime")
GOOGLE_CLOUD_PROVIDER_MODEL_CONFIG = provider_model_config("Google Cloud")
SPEECHMATICS_PROVIDER_MODEL_CONFIG = provider_model_config("Speechmatics")
ASSEMBLYAI_PROVIDER_MODEL_CONFIG = provider_model_config("AssemblyAI")
GLADIA_PROVIDER_MODEL_CONFIG = provider_model_config("Gladia")
SPEACHES_PROVIDER_MODEL_CONFIG = provider_model_config(
"Local Models (Speaches)",
description=(
"Self-hosted OpenAI-compatible local models. See the Speaches project "
"for setup and supported backends."
),
provider_docs_url="https://github.com/speaches-ai/speaches",
)
OPENAI_MODELS = [
"gpt-4.1",
"gpt-4.1-mini",
@ -165,12 +237,6 @@ OPENAI_MODELS = [
"gpt-5-nano",
"gpt-3.5-turbo",
]
GOOGLE_MODELS = [
"gemini-2.0-flash",
"gemini-2.0-flash-lite",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
]
GROQ_MODELS = [
"llama-3.3-70b-versatile",
"deepseek-r1-distill-llama-70b",
@ -204,6 +270,7 @@ AWS_BEDROCK_MODELS = [
@register_llm
class OpenAILLMService(BaseLLMConfiguration):
model_config = OPENAI_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.OPENAI] = ServiceProviders.OPENAI
model: str = Field(
default="gpt-4.1",
@ -214,6 +281,7 @@ class OpenAILLMService(BaseLLMConfiguration):
@register_llm
class GoogleLLMService(BaseLLMConfiguration):
model_config = GOOGLE_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.GOOGLE] = ServiceProviders.GOOGLE
model: str = Field(
default="gemini-2.0-flash",
@ -224,6 +292,7 @@ class GoogleLLMService(BaseLLMConfiguration):
@register_llm
class GroqLLMService(BaseLLMConfiguration):
model_config = GROQ_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.GROQ] = ServiceProviders.GROQ
model: str = Field(
default="llama-3.3-70b-versatile",
@ -234,6 +303,7 @@ class GroqLLMService(BaseLLMConfiguration):
@register_llm
class OpenRouterLLMConfiguration(BaseLLMConfiguration):
model_config = OPENROUTER_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.OPENROUTER] = ServiceProviders.OPENROUTER
model: str = Field(
default="openai/gpt-4.1",
@ -249,6 +319,7 @@ class OpenRouterLLMConfiguration(BaseLLMConfiguration):
@register_llm
class AzureLLMService(BaseLLMConfiguration):
model_config = AZURE_OPENAI_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.AZURE] = ServiceProviders.AZURE
model: str = Field(
default="gpt-4.1-mini",
@ -263,6 +334,7 @@ class AzureLLMService(BaseLLMConfiguration):
@register_llm
class DograhLLMService(BaseLLMConfiguration):
model_config = DOGRAH_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.DOGRAH] = ServiceProviders.DOGRAH
model: str = Field(
default="default",
@ -273,6 +345,7 @@ class DograhLLMService(BaseLLMConfiguration):
@register_llm
class AWSBedrockLLMConfiguration(BaseLLMConfiguration):
model_config = AWS_BEDROCK_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.AWS_BEDROCK] = ServiceProviders.AWS_BEDROCK
model: str = Field(
default="us.amazon.nova-pro-v1:0",
@ -302,6 +375,7 @@ SPEACHES_LLM_MODELS = ["llama3", "mistral", "phi3", "qwen2", "gemma2", "deepseek
@register_llm
class SpeachesLLMConfiguration(BaseLLMConfiguration):
model_config = SPEACHES_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.SPEACHES] = ServiceProviders.SPEACHES
model: str = Field(
default="llama3",
@ -336,6 +410,7 @@ OPENAI_REALTIME_VOICES = [
@register_service(ServiceType.REALTIME)
class OpenAIRealtimeLLMConfiguration(BaseLLMConfiguration):
model_config = OPENAI_REALTIME_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.OPENAI_REALTIME] = (
ServiceProviders.OPENAI_REALTIME
)
@ -357,39 +432,9 @@ class OpenAIRealtimeLLMConfiguration(BaseLLMConfiguration):
)
GOOGLE_REALTIME_MODELS = ["gemini-3.1-flash-live-preview"]
GOOGLE_REALTIME_VOICES = ["Puck", "Charon", "Kore", "Fenrir", "Aoede"]
GOOGLE_REALTIME_LANGUAGES = [
"ar",
"bn",
"de",
"en",
"es",
"fr",
"gu",
"hi",
"id",
"it",
"ja",
"kn",
"ko",
"ml",
"mr",
"nl",
"pl",
"pt",
"ru",
"ta",
"te",
"th",
"tr",
"vi",
"zh",
]
@register_service(ServiceType.REALTIME)
class GoogleRealtimeLLMConfiguration(BaseLLMConfiguration):
model_config = GOOGLE_REALTIME_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.GOOGLE_REALTIME] = (
ServiceProviders.GOOGLE_REALTIME
)
@ -419,15 +464,9 @@ class GoogleRealtimeLLMConfiguration(BaseLLMConfiguration):
)
GOOGLE_VERTEX_REALTIME_MODELS = [
"google/gemini-live-2.5-flash-native-audio",
]
GOOGLE_VERTEX_REALTIME_VOICES = GOOGLE_REALTIME_VOICES
GOOGLE_VERTEX_REALTIME_LANGUAGES = GOOGLE_REALTIME_LANGUAGES
@register_service(ServiceType.REALTIME)
class GoogleVertexRealtimeLLMConfiguration(BaseLLMConfiguration):
model_config = GOOGLE_VERTEX_REALTIME_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.GOOGLE_VERTEX_REALTIME] = (
ServiceProviders.GOOGLE_VERTEX_REALTIME
)
@ -512,6 +551,7 @@ RealtimeConfig = Annotated[
@register_tts
class DeepgramTTSConfiguration(BaseServiceConfiguration):
model_config = DEEPGRAM_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.DEEPGRAM] = ServiceProviders.DEEPGRAM
voice: str = Field(
default="aura-2-helena-en",
@ -537,6 +577,7 @@ ELEVENLABS_TTS_MODELS = ["eleven_flash_v2_5"]
@register_tts
class ElevenlabsTTSConfiguration(BaseServiceConfiguration):
model_config = ELEVENLABS_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.ELEVENLABS] = ServiceProviders.ELEVENLABS
voice: str = Field(
default="21m00Tcm4TlvDq8ikWAM",
@ -558,11 +599,70 @@ class ElevenlabsTTSConfiguration(BaseServiceConfiguration):
)
@register_tts
class GoogleTTSConfiguration(BaseTTSConfiguration):
model_config = GOOGLE_CLOUD_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.GOOGLE] = ServiceProviders.GOOGLE
model: str = Field(
default="chirp_3_hd",
description=(
"Google Cloud low-latency TTS engine. Dograh maps this to Pipecat's "
"streaming Google TTS service for Chirp 3 HD and Journey voices."
),
json_schema_extra={
"examples": GOOGLE_TTS_MODELS,
"allow_custom_input": True,
},
)
voice: str = Field(
default="en-US-Chirp3-HD-Charon",
description="Google Cloud voice name. Use a Chirp 3 HD or Journey voice for streaming TTS.",
json_schema_extra={
"examples": GOOGLE_TTS_VOICES,
"allow_custom_input": True,
},
)
language: str = Field(
default="en-US",
description="BCP-47 language code for synthesis.",
json_schema_extra={
"examples": GOOGLE_TTS_LANGUAGES,
"allow_custom_input": True,
},
)
speed: float = Field(
default=1.0,
ge=0.25,
le=2.0,
description="Speech speed multiplier for Google streaming TTS.",
)
location: str | None = Field(
default=None,
description=(
"Optional Google Cloud regional Text-to-Speech endpoint (for example "
"'us-central1'). Leave blank to use the default endpoint."
),
)
credentials: str | None = Field(
default=None,
description=(
"Paste the entire Google Cloud service-account JSON. If omitted, "
"the server falls back to Application Default Credentials (ADC)."
),
json_schema_extra={"multiline": True},
)
api_key: str | list[str] | None = Field(
default=None,
description="Not used for Google Cloud TTS. Leave blank.",
)
OPENAI_TTS_MODELS = ["gpt-4o-mini-tts"]
@register_tts
class OpenAITTSService(BaseTTSConfiguration):
model_config = OPENAI_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.OPENAI] = ServiceProviders.OPENAI
model: str = Field(
default="gpt-4o-mini-tts",
@ -580,6 +680,7 @@ DOGRAH_TTS_MODELS = ["default"]
@register_tts
class DograhTTSService(BaseTTSConfiguration):
model_config = DOGRAH_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.DOGRAH] = ServiceProviders.DOGRAH
model: str = Field(
default="default",
@ -598,6 +699,7 @@ CARTESIA_TTS_MODELS = ["sonic-3"]
@register_tts
class CartesiaTTSConfiguration(BaseTTSConfiguration):
model_config = CARTESIA_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.CARTESIA] = ServiceProviders.CARTESIA
model: str = Field(
default="sonic-3",
@ -617,75 +719,9 @@ class CartesiaTTSConfiguration(BaseTTSConfiguration):
)
SARVAM_TTS_MODELS = ["bulbul:v2", "bulbul:v3"]
SARVAM_V2_VOICES = [
"anushka",
"manisha",
"vidya",
"arya",
"abhilash",
"karun",
"hitesh",
]
SARVAM_V3_VOICES = [
"shubh",
"aditya",
"ritu",
"priya",
"neha",
"rahul",
"pooja",
"rohan",
"simran",
"kavya",
"amit",
"dev",
"ishita",
"shreya",
"ratan",
"varun",
"manan",
"sumit",
"roopa",
"kabir",
"aayan",
"ashutosh",
"advait",
"amelia",
"sophia",
"anand",
"tanya",
"tarun",
"sunny",
"mani",
"gokul",
"vijay",
"shruti",
"suhani",
"mohit",
"kavitha",
"rehan",
"soham",
"rupali",
]
SARVAM_LANGUAGES = [
"bn-IN",
"en-IN",
"gu-IN",
"hi-IN",
"kn-IN",
"ml-IN",
"mr-IN",
"od-IN",
"pa-IN",
"ta-IN",
"te-IN",
"as-IN",
]
@register_tts
class SarvamTTSConfiguration(BaseTTSConfiguration):
model_config = SARVAM_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.SARVAM] = ServiceProviders.SARVAM
model: str = Field(
default="bulbul:v2",
@ -715,6 +751,7 @@ CAMB_TTS_MODELS = ["mars-flash", "mars-pro", "mars-instruct"]
@register_tts
class CambTTSConfiguration(BaseTTSConfiguration):
model_config = CAMB_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.CAMB] = ServiceProviders.CAMB
model: str = Field(
default="mars-flash",
@ -731,6 +768,7 @@ RIME_TTS_LANGUAGES = ["en", "de", "fr", "es", "hi"]
@register_tts
class RimeTTSConfiguration(BaseTTSConfiguration):
model_config = RIME_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.RIME] = ServiceProviders.RIME
model: str = Field(
default="arcana",
@ -756,6 +794,7 @@ SPEACHES_TTS_MODELS = ["hexgrad/Kokoro-82M"]
@register_tts
class SpeachesTTSConfiguration(BaseTTSConfiguration):
model_config = SPEACHES_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.SPEACHES] = ServiceProviders.SPEACHES
model: str = Field(
default="kokoro",
@ -786,6 +825,7 @@ class SpeachesTTSConfiguration(BaseTTSConfiguration):
TTSConfig = Annotated[
Union[
DeepgramTTSConfiguration,
GoogleTTSConfiguration,
OpenAITTSService,
ElevenlabsTTSConfiguration,
CartesiaTTSConfiguration,
@ -801,94 +841,9 @@ TTSConfig = Annotated[
###################################################### STT ########################################################################
DEEPGRAM_STT_MODELS = ["nova-3-general", "flux-general-en", "flux-general-multi"]
DEEPGRAM_LANGUAGES = [
"multi",
"ar",
"ar-AE",
"ar-SA",
"ar-QA",
"ar-KW",
"ar-SY",
"ar-LB",
"ar-PS",
"ar-JO",
"ar-EG",
"ar-SD",
"ar-TD",
"ar-MA",
"ar-DZ",
"ar-TN",
"ar-IQ",
"ar-IR",
"be",
"bn",
"bs",
"bg",
"ca",
"cs",
"da",
"da-DK",
"de",
"de-CH",
"el",
"en",
"en-US",
"en-AU",
"en-GB",
"en-IN",
"en-NZ",
"es",
"es-419",
"et",
"fa",
"fi",
"fr",
"fr-CA",
"he",
"hi",
"hr",
"hu",
"id",
"it",
"ja",
"kn",
"ko",
"ko-KR",
"lt",
"lv",
"mk",
"mr",
"ms",
"nl",
"nl-BE",
"no",
"pl",
"pt",
"pt-BR",
"pt-PT",
"ro",
"ru",
"sk",
"sl",
"sr",
"sv",
"sv-SE",
"ta",
"te",
"th",
"tl",
"tr",
"uk",
"ur",
"vi",
"zh-CN",
"zh-TW",
]
@register_stt
class DeepgramSTTConfiguration(BaseSTTConfiguration):
model_config = DEEPGRAM_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.DEEPGRAM] = ServiceProviders.DEEPGRAM
model: str = Field(
default="nova-3-general",
@ -902,7 +857,7 @@ class DeepgramSTTConfiguration(BaseSTTConfiguration):
"examples": DEEPGRAM_LANGUAGES,
"model_options": {
"nova-3-general": DEEPGRAM_LANGUAGES,
"flux-general-en": ["en"],
"flux-general-en": ("en",),
},
},
)
@ -913,6 +868,7 @@ CARTESIA_STT_MODELS = ["ink-whisper"]
@register_stt
class CartesiaSTTConfiguration(BaseSTTConfiguration):
model_config = CARTESIA_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.CARTESIA] = ServiceProviders.CARTESIA
model: str = Field(
default="ink-whisper",
@ -926,6 +882,7 @@ OPENAI_STT_MODELS = ["gpt-4o-transcribe"]
@register_stt
class OpenAISTTConfiguration(BaseSTTConfiguration):
model_config = OPENAI_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.OPENAI] = ServiceProviders.OPENAI
model: str = Field(
default="gpt-4o-transcribe",
@ -934,6 +891,45 @@ class OpenAISTTConfiguration(BaseSTTConfiguration):
)
@register_stt
class GoogleSTTConfiguration(BaseSTTConfiguration):
model_config = GOOGLE_CLOUD_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.GOOGLE] = ServiceProviders.GOOGLE
model: str = Field(
default="latest_long",
description="Google Cloud Speech-to-Text V2 recognition model.",
json_schema_extra={
"examples": GOOGLE_STT_MODELS,
"allow_custom_input": True,
},
)
language: str = Field(
default="en-US",
description="Primary BCP-47 language code for recognition.",
json_schema_extra={
"examples": GOOGLE_STT_LANGUAGES,
"allow_custom_input": True,
"docs_url": "https://docs.cloud.google.com/speech-to-text/docs/speech-to-text-supported-languages",
},
)
location: str = Field(
default="global",
description="Google Cloud Speech-to-Text region (for example 'global' or 'us-central1').",
)
credentials: str | None = Field(
default=None,
description=(
"Paste the entire Google Cloud service-account JSON. If omitted, "
"the server falls back to Application Default Credentials (ADC)."
),
json_schema_extra={"multiline": True},
)
api_key: str | list[str] | None = Field(
default=None,
description="Not used for Google Cloud STT. Leave blank.",
)
# Dograh STT Service
DOGRAH_STT_MODELS = ["default"]
DOGRAH_STT_LANGUAGES = DEEPGRAM_LANGUAGES
@ -941,6 +937,7 @@ DOGRAH_STT_LANGUAGES = DEEPGRAM_LANGUAGES
@register_stt
class DograhSTTService(BaseSTTConfiguration):
model_config = DOGRAH_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.DOGRAH] = ServiceProviders.DOGRAH
model: str = Field(
default="default",
@ -954,12 +951,9 @@ class DograhSTTService(BaseSTTConfiguration):
)
# Sarvam STT Service
SARVAM_STT_MODELS = ["saarika:v2.5", "saaras:v2"]
@register_stt
class SarvamSTTConfiguration(BaseSTTConfiguration):
model_config = SARVAM_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.SARVAM] = ServiceProviders.SARVAM
model: str = Field(
default="saarika:v2.5",
@ -973,74 +967,9 @@ class SarvamSTTConfiguration(BaseSTTConfiguration):
)
# Speechmatics STT Service
SPEECHMATICS_STT_LANGUAGES = [
"ar",
"ar_en",
"ba",
"eu",
"be",
"bn",
"bg",
"yue",
"ca",
"hr",
"cs",
"da",
"nl",
"en",
"eo",
"et",
"fi",
"fr",
"gl",
"de",
"el",
"he",
"hi",
"hu",
"id",
"ia",
"ga",
"it",
"ja",
"ko",
"lv",
"lt",
"ms",
"en_ms",
"mt",
"cmn",
"cmn_en",
"cmn_en_ms_ta",
"mr",
"mn",
"no",
"fa",
"pl",
"pt",
"ro",
"ru",
"sk",
"sl",
"es",
"sw",
"sv",
"tl",
"ta",
"en_ta",
"th",
"tr",
"uk",
"ur",
"ug",
"vi",
"cy",
]
@register_stt
class SpeechmaticsSTTConfiguration(BaseSTTConfiguration):
model_config = SPEECHMATICS_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.SPEECHMATICS] = ServiceProviders.SPEECHMATICS
model: str = Field(
default="enhanced",
@ -1062,6 +991,7 @@ SPEACHES_STT_LANGUAGES = ["en", "ar", "nl", "fr", "de", "hi", "it", "pt", "es"]
@register_stt
class SpeachesSTTConfiguration(BaseSTTConfiguration):
model_config = SPEACHES_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.SPEACHES] = ServiceProviders.SPEACHES
model: str = Field(
default="Systran/faster-distil-whisper-small.en",
@ -1095,6 +1025,7 @@ ASSEMBLYAI_STT_LANGUAGES = ["en", "es", "de", "fr", "pt", "it"]
@register_stt
class AssemblyAISTTConfiguration(BaseSTTConfiguration):
model_config = ASSEMBLYAI_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.ASSEMBLYAI] = ServiceProviders.ASSEMBLYAI
model: str = Field(
default="u3-rt-pro",
@ -1108,113 +1039,9 @@ class AssemblyAISTTConfiguration(BaseSTTConfiguration):
)
GLADIA_STT_MODELS = ["solaria-1"]
GLADIA_STT_LANGUAGES = [
"af",
"am",
"ar",
"as",
"az",
"ba",
"be",
"bg",
"bn",
"bo",
"br",
"bs",
"ca",
"cs",
"cy",
"da",
"de",
"el",
"en",
"es",
"et",
"eu",
"fa",
"fi",
"fo",
"fr",
"gl",
"gu",
"ha",
"haw",
"he",
"hi",
"hr",
"ht",
"hu",
"hy",
"id",
"is",
"it",
"ja",
"jw",
"ka",
"kk",
"km",
"kn",
"ko",
"la",
"lb",
"ln",
"lo",
"lt",
"lv",
"mg",
"mi",
"mk",
"ml",
"mn",
"mr",
"ms",
"mt",
"my",
"ne",
"nl",
"nn",
"no",
"oc",
"pa",
"pl",
"ps",
"pt",
"ro",
"ru",
"sa",
"sd",
"si",
"sk",
"sl",
"sn",
"so",
"sq",
"sr",
"su",
"sv",
"sw",
"ta",
"te",
"tg",
"th",
"tk",
"tl",
"tr",
"tt",
"uk",
"ur",
"uz",
"vi",
"wo",
"yi",
"yo",
"zh",
]
@register_stt
class GladiaSTTConfiguration(BaseSTTConfiguration):
model_config = GLADIA_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.GLADIA] = ServiceProviders.GLADIA
model: str = Field(
default="solaria-1",
@ -1233,6 +1060,7 @@ STTConfig = Annotated[
DeepgramSTTConfiguration,
CartesiaSTTConfiguration,
OpenAISTTConfiguration,
GoogleSTTConfiguration,
DograhSTTService,
SpeechmaticsSTTConfiguration,
SarvamSTTConfiguration,
@ -1250,6 +1078,7 @@ OPENAI_EMBEDDING_MODELS = ["text-embedding-3-small"]
@register_embeddings
class OpenAIEmbeddingsConfiguration(BaseEmbeddingsConfiguration):
model_config = OPENAI_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.OPENAI] = ServiceProviders.OPENAI
model: str = Field(
default="text-embedding-3-small",
@ -1263,6 +1092,7 @@ OPENROUTER_EMBEDDING_MODELS = ["openai/text-embedding-3-small"]
@register_embeddings
class OpenRouterEmbeddingsConfiguration(BaseEmbeddingsConfiguration):
model_config = OPENROUTER_PROVIDER_MODEL_CONFIG
provider: Literal[ServiceProviders.OPENROUTER] = ServiceProviders.OPENROUTER
model: str = Field(
default="openai/text-embedding-3-small",

View file

@ -26,6 +26,8 @@ from pipecat.services.dograh.tts import DograhTTSService, DograhTTSSettings
from pipecat.services.elevenlabs.tts import ElevenLabsTTSService, ElevenLabsTTSSettings
from pipecat.services.gladia.stt import GladiaSTTService, GladiaSTTSettings
from pipecat.services.google.llm import GoogleLLMService, GoogleLLMSettings
from pipecat.services.google.stt import GoogleSTTService, GoogleSTTSettings
from pipecat.services.google.tts import GoogleTTSService, GoogleTTSSettings
from pipecat.services.groq.llm import GroqLLMService, GroqLLMSettings
from pipecat.services.openai.base_llm import OpenAILLMSettings
from pipecat.services.openai.llm import OpenAILLMService
@ -101,6 +103,23 @@ def create_stt_service(
api_key=user_config.stt.api_key,
settings=OpenAISTTSettings(model=user_config.stt.model),
)
elif user_config.stt.provider == ServiceProviders.GOOGLE.value:
language = getattr(user_config.stt, "language", None) or "en-US"
location = getattr(user_config.stt, "location", None) or "global"
credentials = getattr(user_config.stt, "credentials", None)
settings_kwargs = {"model": user_config.stt.model}
try:
settings_kwargs["languages"] = [Language(language)]
except ValueError:
settings_kwargs["language_codes"] = [language]
return GoogleSTTService(
credentials=credentials,
location=location,
settings=GoogleSTTSettings(**settings_kwargs),
sample_rate=audio_config.transport_in_sample_rate,
)
elif user_config.stt.provider == ServiceProviders.CARTESIA.value:
return CartesiaSTTService(
api_key=user_config.stt.api_key,
@ -241,6 +260,30 @@ def create_tts_service(user_config, audio_config: "AudioConfig"):
skip_aggregator_types=["recording_router", "recording"],
silence_time_s=1.0,
)
elif user_config.tts.provider == ServiceProviders.GOOGLE.value:
model = getattr(user_config.tts, "model", None) or "chirp_3_hd"
language = getattr(user_config.tts, "language", None) or "en-US"
voice = getattr(user_config.tts, "voice", None) or "en-US-Chirp3-HD-Charon"
speed = getattr(user_config.tts, "speed", None)
location = getattr(user_config.tts, "location", None) or None
credentials = getattr(user_config.tts, "credentials", None)
settings_kwargs = {
"model": model,
"voice": voice,
"language": language,
}
if speed is not None and speed != 1.0:
settings_kwargs["speaking_rate"] = speed
return GoogleTTSService(
credentials=credentials,
location=location,
settings=GoogleTTSSettings(**settings_kwargs),
text_filters=[xml_function_tag_filter],
skip_aggregator_types=["recording_router", "recording"],
silence_time_s=1.0,
)
elif user_config.tts.provider == ServiceProviders.ELEVENLABS.value:
# Backward compatible with older configuration "Name - voice_id"
try:

View file

@ -610,13 +610,14 @@ class GlobalNodeData(BaseNodeData, _PromptedNodeDataMixin):
"trigger_path": {
"display_name": "Trigger Path",
"description": (
"Auto-generated UUID-style path segment that uniquely identifies "
"Path segment that uniquely identifies "
"this trigger. Used in both URLs:\n"
" • Production: `/api/v1/public/agent/<trigger_path>` — executes "
"the published agent.\n"
" • Test: `/api/v1/public/agent/test/<trigger_path>` — executes "
"the latest draft.\n"
"Do not edit manually."
"Can be customized to a descriptive value up to 36 characters "
"using letters, numbers, hyphens, or underscores."
),
},
},

View file

@ -0,0 +1,142 @@
import copy
import re
import uuid
from dataclasses import dataclass
from typing import Optional
TRIGGER_PATH_MAX_LENGTH = 36
TRIGGER_PATH_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
@dataclass(frozen=True)
class TriggerPathIssue:
node_id: str | None
trigger_path: str
message: str
def extract_trigger_paths(workflow_definition: Optional[dict]) -> list[str]:
"""Extract trigger paths from a workflow definition."""
if not workflow_definition:
return []
trigger_paths: list[str] = []
for node in workflow_definition.get("nodes") or []:
if node.get("type") != "trigger":
continue
trigger_path = (node.get("data") or {}).get("trigger_path")
if isinstance(trigger_path, str) and trigger_path:
trigger_paths.append(trigger_path)
return trigger_paths
def trigger_path_to_node_id(workflow_definition: Optional[dict]) -> dict[str, str]:
"""Map each trigger node's trigger_path to its node id."""
if not workflow_definition:
return {}
out: dict[str, str] = {}
for node in workflow_definition.get("nodes") or []:
if node.get("type") != "trigger":
continue
trigger_path = (node.get("data") or {}).get("trigger_path")
if isinstance(trigger_path, str) and trigger_path:
out[trigger_path] = node.get("id")
return out
def regenerate_trigger_uuids(workflow_definition: Optional[dict]) -> Optional[dict]:
"""Regenerate UUIDs for all trigger nodes in a workflow definition."""
if not workflow_definition:
return workflow_definition
updated_definition = copy.deepcopy(workflow_definition)
for node in updated_definition.get("nodes") or []:
if node.get("type") != "trigger":
continue
data = node.setdefault("data", {})
data["trigger_path"] = str(uuid.uuid4())
return updated_definition
def ensure_trigger_paths(workflow_definition: Optional[dict]) -> Optional[dict]:
"""Mint UUIDs for trigger nodes that do not already have a path."""
if not workflow_definition:
return workflow_definition
out = copy.deepcopy(workflow_definition)
for node in out.get("nodes") or []:
if node.get("type") != "trigger":
continue
data = node.setdefault("data", {})
if not data.get("trigger_path"):
data["trigger_path"] = str(uuid.uuid4())
return out
def validate_trigger_paths(
workflow_definition: Optional[dict],
) -> list[TriggerPathIssue]:
"""Validate custom trigger paths before they reach persistence/runtime."""
if not workflow_definition:
return []
issues: list[TriggerPathIssue] = []
seen_paths: dict[str, str | None] = {}
for node in workflow_definition.get("nodes") or []:
if node.get("type") != "trigger":
continue
node_id = node.get("id")
trigger_path = (node.get("data") or {}).get("trigger_path")
if not trigger_path:
continue
if not isinstance(trigger_path, str):
issues.append(
TriggerPathIssue(
node_id=node_id,
trigger_path=repr(trigger_path),
message="Trigger path must be a string.",
)
)
continue
if len(trigger_path) > TRIGGER_PATH_MAX_LENGTH:
issues.append(
TriggerPathIssue(
node_id=node_id,
trigger_path=trigger_path,
message=(
f"Trigger path must be {TRIGGER_PATH_MAX_LENGTH} "
"characters or fewer."
),
)
)
if not TRIGGER_PATH_PATTERN.fullmatch(trigger_path):
issues.append(
TriggerPathIssue(
node_id=node_id,
trigger_path=trigger_path,
message=(
"Trigger path must be a single URL path segment using "
"only letters, numbers, hyphens, and underscores."
),
)
)
first_node_id = seen_paths.get(trigger_path)
if first_node_id is None:
seen_paths[trigger_path] = node_id
else:
issues.append(
TriggerPathIssue(
node_id=node_id,
trigger_path=trigger_path,
message="Trigger path is duplicated in this workflow.",
)
)
return issues

View file

@ -0,0 +1,55 @@
from types import SimpleNamespace
from unittest.mock import patch
from pipecat.transcriptions.language import Language
from api.services.configuration.registry import ServiceProviders
from api.services.pipecat.service_factory import create_stt_service
def test_create_google_stt_service_uses_credentials_location_and_language():
user_config = SimpleNamespace(
stt=SimpleNamespace(
provider=ServiceProviders.GOOGLE.value,
credentials='{"project_id":"demo-project"}',
api_key=None,
model="latest_long",
language="en-US",
location="us-central1",
)
)
audio_config = SimpleNamespace(transport_in_sample_rate=16000)
with patch("api.services.pipecat.service_factory.GoogleSTTService") as mock_service:
create_stt_service(user_config, audio_config)
assert mock_service.call_count == 1
kwargs = mock_service.call_args.kwargs
assert kwargs["credentials"] == '{"project_id":"demo-project"}'
assert kwargs["location"] == "us-central1"
assert kwargs["sample_rate"] == 16000
assert kwargs["settings"].model == "latest_long"
assert kwargs["settings"].languages == [Language.EN_US]
def test_create_google_stt_service_falls_back_to_raw_language_codes():
user_config = SimpleNamespace(
stt=SimpleNamespace(
provider=ServiceProviders.GOOGLE.value,
credentials=None,
api_key=None,
model="chirp_3",
language="cmn-Hans-CN",
location="global",
)
)
audio_config = SimpleNamespace(transport_in_sample_rate=24000)
with patch("api.services.pipecat.service_factory.GoogleSTTService") as mock_service:
create_stt_service(user_config, audio_config)
assert mock_service.call_count == 1
kwargs = mock_service.call_args.kwargs
assert kwargs["sample_rate"] == 24000
assert kwargs["settings"].model == "chirp_3"
assert kwargs["settings"].language_codes == ["cmn-Hans-CN"]

View file

@ -0,0 +1,67 @@
from types import SimpleNamespace
from unittest.mock import patch
from pipecat.services.settings import NOT_GIVEN
from api.services.configuration.registry import ServiceProviders
from api.services.pipecat.service_factory import create_tts_service
def test_create_google_tts_service_uses_credentials_location_and_settings():
user_config = SimpleNamespace(
tts=SimpleNamespace(
provider=ServiceProviders.GOOGLE.value,
credentials='{"project_id":"demo-project"}',
api_key=None,
model="chirp_3_hd",
voice="en-US-Chirp3-HD-Charon",
language="en-US",
speed=1.15,
location="us-central1",
)
)
audio_config = SimpleNamespace(
transport_out_sample_rate=24000,
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.GoogleTTSService") as mock_service:
create_tts_service(user_config, audio_config)
assert mock_service.call_count == 1
kwargs = mock_service.call_args.kwargs
assert kwargs["credentials"] == '{"project_id":"demo-project"}'
assert kwargs["location"] == "us-central1"
assert kwargs["settings"].model == "chirp_3_hd"
assert kwargs["settings"].voice == "en-US-Chirp3-HD-Charon"
assert kwargs["settings"].language == "en-US"
assert kwargs["settings"].speaking_rate == 1.15
def test_create_google_tts_service_omits_default_speed():
user_config = SimpleNamespace(
tts=SimpleNamespace(
provider=ServiceProviders.GOOGLE.value,
credentials=None,
api_key=None,
model="chirp_3_hd",
voice="en-US-Chirp3-HD-Charon",
language="sw-KE",
speed=1.0,
location=None,
)
)
audio_config = SimpleNamespace(
transport_out_sample_rate=24000,
transport_in_sample_rate=16000,
)
with patch("api.services.pipecat.service_factory.GoogleTTSService") as mock_service:
create_tts_service(user_config, audio_config)
assert mock_service.call_count == 1
kwargs = mock_service.call_args.kwargs
assert kwargs["location"] is None
assert kwargs["settings"].model == "chirp_3_hd"
assert kwargs["settings"].language == "sw-KE"
assert kwargs["settings"].speaking_rate is NOT_GIVEN

View file

@ -186,6 +186,45 @@ const n = wf.addTyped(startCall({ name: "g", prompt: "hi", promt: "typo" }));
update_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_invalid_trigger_path_surfaces_validation_error(mock_backends):
save_mock, update_mock = mock_backends
payload = {
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {"trigger_path": "support/west"},
}
],
"edges": [],
}
with (
patch(
"api.mcp_server.tools.save_workflow.parse_code",
AsyncMock(
return_value={
"ok": True,
"workflowName": _FakeWorkflowModel.name,
"workflow": payload,
}
),
),
patch(
"api.mcp_server.tools.save_workflow.reconcile_positions",
return_value=payload,
),
):
result = await save_workflow(workflow_id=1, code="ignored")
assert result["saved"] is False
assert result["error_code"] == "validation_error"
assert "single URL path segment" in result["error"]
save_mock.assert_not_awaited()
update_mock.assert_not_awaited()
# ─── Graph-stage rejections ──────────────────────────────────────────────

View file

@ -0,0 +1,191 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.routes.public_agent import router
def _make_test_app() -> FastAPI:
app = FastAPI()
app.include_router(router)
return app
def _active_workflow(*, trigger_path: str | None = None):
nodes = []
if trigger_path is not None:
nodes.append(
{
"type": "trigger",
"data": {"trigger_path": trigger_path},
}
)
return SimpleNamespace(
id=33,
user_id=99,
organization_id=11,
status="active",
workflow_uuid="workflow-uuid-123",
released_definition=SimpleNamespace(
workflow_json={"nodes": nodes, "edges": []}
),
)
def _provider():
return SimpleNamespace(
PROVIDER_NAME="twilio",
WEBHOOK_ENDPOINT="outbound",
validate_config=Mock(return_value=True),
initiate_call=AsyncMock(),
)
def test_trigger_route_executes_as_workflow_owner():
app = _make_test_app()
client = TestClient(app)
workflow = _active_workflow(trigger_path="trigger-uuid-123")
provider = _provider()
quota_mock = AsyncMock(
return_value=SimpleNamespace(has_quota=True, error_message="")
)
with (
patch("api.routes.public_agent.db_client") as mock_db,
patch(
"api.routes.public_agent.check_dograh_quota_by_user_id",
new=quota_mock,
),
patch(
"api.routes.public_agent.get_default_telephony_provider",
new=AsyncMock(return_value=provider),
),
patch(
"api.routes.public_agent.get_backend_endpoints",
new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")),
),
):
mock_db.validate_api_key = AsyncMock(
return_value=SimpleNamespace(id=7, organization_id=11, created_by=22)
)
mock_db.get_agent_trigger_by_path = AsyncMock(
return_value=SimpleNamespace(
workflow_id=workflow.id,
organization_id=11,
state="active",
)
)
mock_db.get_workflow = AsyncMock(return_value=workflow)
mock_db.get_default_telephony_configuration = AsyncMock(
return_value=SimpleNamespace(id=55)
)
mock_db.create_workflow_run = AsyncMock(return_value=SimpleNamespace(id=501))
response = client.post(
"/public/agent/trigger-uuid-123",
headers={"X-API-Key": "test-api-key"},
json={"phone_number": "+15551234567"},
)
assert response.status_code == 200
quota_mock.assert_awaited_once_with(workflow.user_id, workflow_id=workflow.id)
mock_db.get_workflow.assert_awaited_once_with(workflow.id, organization_id=11)
create_kwargs = mock_db.create_workflow_run.await_args.kwargs
assert create_kwargs["workflow_id"] == workflow.id
assert create_kwargs["user_id"] == workflow.user_id
assert create_kwargs["organization_id"] == workflow.organization_id
assert create_kwargs["initial_context"]["agent_uuid"] == "trigger-uuid-123"
assert create_kwargs["initial_context"]["agent_identifier"] == "trigger-uuid-123"
assert create_kwargs["initial_context"]["agent_identifier_type"] == "trigger_path"
assert create_kwargs["initial_context"]["workflow_uuid"] == workflow.workflow_uuid
assert create_kwargs["initial_context"]["api_key_id"] == 7
assert create_kwargs["initial_context"]["api_key_created_by"] == 22
initiate_kwargs = provider.initiate_call.await_args.kwargs
assert initiate_kwargs["workflow_id"] == workflow.id
assert initiate_kwargs["user_id"] == workflow.user_id
def test_workflow_uuid_route_uses_scoped_lookup_and_shared_execution():
app = _make_test_app()
client = TestClient(app)
workflow = _active_workflow()
provider = _provider()
quota_mock = AsyncMock(
return_value=SimpleNamespace(has_quota=True, error_message="")
)
with (
patch("api.routes.public_agent.db_client") as mock_db,
patch(
"api.routes.public_agent.check_dograh_quota_by_user_id",
new=quota_mock,
),
patch(
"api.routes.public_agent.get_default_telephony_provider",
new=AsyncMock(return_value=provider),
),
patch(
"api.routes.public_agent.get_backend_endpoints",
new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")),
),
):
mock_db.validate_api_key = AsyncMock(
return_value=SimpleNamespace(id=8, organization_id=11, created_by=22)
)
mock_db.get_workflow_by_uuid = AsyncMock(return_value=workflow)
mock_db.get_default_telephony_configuration = AsyncMock(
return_value=SimpleNamespace(id=55)
)
mock_db.create_workflow_run = AsyncMock(return_value=SimpleNamespace(id=601))
response = client.post(
f"/public/agent/workflow/{workflow.workflow_uuid}",
headers={"X-API-Key": "test-api-key"},
json={"phone_number": "+15551234567"},
)
assert response.status_code == 200
mock_db.get_workflow_by_uuid.assert_awaited_once_with(
workflow.workflow_uuid,
11,
)
assert not mock_db.get_agent_trigger_by_path.called
create_kwargs = mock_db.create_workflow_run.await_args.kwargs
assert create_kwargs["user_id"] == workflow.user_id
assert (
create_kwargs["initial_context"]["agent_identifier"] == workflow.workflow_uuid
)
assert create_kwargs["initial_context"]["agent_identifier_type"] == "workflow_uuid"
assert "agent_uuid" not in create_kwargs["initial_context"]
def test_workflow_uuid_route_rejects_archived_workflows():
app = _make_test_app()
client = TestClient(app)
workflow = _active_workflow()
workflow.status = "archived"
with patch("api.routes.public_agent.db_client") as mock_db:
mock_db.validate_api_key = AsyncMock(
return_value=SimpleNamespace(id=9, organization_id=11, created_by=22)
)
mock_db.get_workflow_by_uuid = AsyncMock(return_value=workflow)
response = client.post(
f"/public/agent/workflow/{workflow.workflow_uuid}",
headers={"X-API-Key": "test-api-key"},
json={"phone_number": "+15551234567"},
)
assert response.status_code == 404
assert response.json()["detail"] == "Workflow is not active"
assert not mock_db.create_workflow_run.called

View file

@ -0,0 +1,56 @@
from api.services.workflow.trigger_paths import (
TRIGGER_PATH_MAX_LENGTH,
validate_trigger_paths,
)
def test_validate_trigger_paths_rejects_invalid_path_segments():
workflow_definition = {
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {"trigger_path": "support/west"},
}
],
"edges": [],
}
issues = validate_trigger_paths(workflow_definition)
assert len(issues) == 1
assert issues[0].node_id == "trigger-1"
assert "single URL path segment" in issues[0].message
def test_validate_trigger_paths_rejects_long_and_duplicate_paths():
long_path = "a" * (TRIGGER_PATH_MAX_LENGTH + 1)
workflow_definition = {
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {"trigger_path": long_path},
},
{
"id": "trigger-2",
"type": "trigger",
"data": {"trigger_path": "sales_agent"},
},
{
"id": "trigger-3",
"type": "trigger",
"data": {"trigger_path": "sales_agent"},
},
],
"edges": [],
}
issues = validate_trigger_paths(workflow_definition)
messages = [issue.message for issue in issues]
assert (
f"Trigger path must be {TRIGGER_PATH_MAX_LENGTH} characters or fewer."
in messages
)
assert "Trigger path is duplicated in this workflow." in messages

View file

@ -0,0 +1,49 @@
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.routes.workflow import router
from api.services.auth.depends import get_user
def _make_test_app() -> FastAPI:
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_user] = lambda: SimpleNamespace(
id=1,
provider_id="provider-1",
selected_organization_id=11,
)
return app
def test_create_workflow_rejects_invalid_trigger_path_before_db_write():
app = _make_test_app()
client = TestClient(app)
with patch("api.routes.workflow.db_client") as mock_db:
response = client.post(
"/workflow/create/definition",
json={
"name": "Support Agent",
"workflow_definition": {
"nodes": [
{
"id": "trigger-1",
"type": "trigger",
"data": {"trigger_path": "support/west"},
}
],
"edges": [],
},
},
)
assert response.status_code == 422
detail = response.json()["detail"]
assert detail["is_valid"] is False
assert detail["errors"][0]["field"] == "data.trigger_path"
assert "single URL path segment" in detail["errors"][0]["message"]
assert mock_db.mock_calls == []

View file

@ -0,0 +1,52 @@
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.routes.workflow import router
from api.services.auth.depends import get_user
def _make_test_app() -> FastAPI:
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_user] = lambda: SimpleNamespace(
id=1,
selected_organization_id=11,
)
return app
def test_workflow_fetch_list_includes_workflow_uuid():
app = _make_test_app()
client = TestClient(app)
workflow = SimpleNamespace(
id=5,
name="Sales Agent",
status="active",
created_at=datetime(2026, 5, 22, 10, 30, tzinfo=timezone.utc),
folder_id=3,
workflow_uuid="workflow-uuid-123",
)
with patch("api.routes.workflow.db_client") as mock_db:
mock_db.get_all_workflows_for_listing = AsyncMock(return_value=[workflow])
mock_db.get_workflow_run_counts = AsyncMock(return_value={workflow.id: 9})
response = client.get("/workflow/fetch")
assert response.status_code == 200
assert response.json() == [
{
"id": workflow.id,
"name": workflow.name,
"status": workflow.status,
"created_at": "2026-05-22T10:30:00Z",
"total_runs": 9,
"folder_id": workflow.folder_id,
"workflow_uuid": workflow.workflow_uuid,
}
]

View file

@ -182,7 +182,7 @@ class TestSaveDraft:
workflow_definition=GRAPH_V2,
)
refreshed = await db_session.get_workflow(workflow.id)
refreshed = await db_session.get_workflow_by_id(workflow.id)
assert refreshed.released_definition_id == original_released_id
async def test_save_draft_twice_updates_in_place(
@ -264,7 +264,7 @@ class TestPublishDraft:
await db_session.publish_workflow_draft(workflow.id)
refreshed = await db_session.get_workflow(workflow.id)
refreshed = await db_session.get_workflow_by_id(workflow.id)
assert refreshed.released_definition_id == draft.id
async def test_publish_sets_published_at(self, db_session, workflow_with_v1):
@ -346,7 +346,7 @@ class TestDiscardDraft:
)
await db_session.discard_workflow_draft(workflow.id)
refreshed = await db_session.get_workflow(workflow.id)
refreshed = await db_session.get_workflow_by_id(workflow.id)
assert refreshed.released_definition_id == original_released_id
async def test_discard_when_no_draft_raises(self, db_session, workflow_with_v1):
@ -464,7 +464,7 @@ class TestRevert:
await db_session.revert_to_version(workflow.id, v1_id)
refreshed = await db_session.get_workflow(workflow.id)
refreshed = await db_session.get_workflow_by_id(workflow.id)
assert refreshed.released_definition_id == v2.id # still V2

View file

@ -5,11 +5,37 @@ description: "Initiate outbound calls and trigger agents via the API"
| Method | Endpoint | Quick Link |
|---|---|---|
| `POST` | `/public/agent/{uuid}` | [Trigger an outbound call](/api-reference/calls/trigger) |
| `POST` | `/public/agent/{uuid}` | [Trigger an outbound call by API Trigger node](/api-reference/calls/trigger) |
| `POST` | `/public/agent/workflow/{workflow_uuid}` | [Trigger an outbound call by workflow UUID](/api-reference/calls/trigger-workflow) |
| `GET` | `/workflow/{workflow_id}/runs/{run_id}` | [Retrieve call details](/api-reference/calls/get-run) |
| `GET` | `/public/download/workflow/{token}/{artifact_type}` | [Download recordings and transcripts](/api-reference/calls/download) |
| `POST` | `/telephony/inbound/{workflow_id}` | [Inbound call webhook](/api-reference/calls/inbound) |
## Choose the right public call route
Dograh exposes two public outbound call route families. They are **not**
interchangeable, even though both path parameters look like UUIDs.
| Use this when | Production route | Test route | Identifier you pass |
|---|---|---|---|
| You added an **[API Trigger node](/voice-agent/api-trigger)** to the workflow and want to call that trigger | `/public/agent/{uuid}` | `/public/agent/test/{uuid}` | The trigger UUID (`trigger_path`) from the API Trigger node |
| You want to execute the workflow by its stable **Agent UUID** instead of a trigger node | `/public/agent/workflow/{workflow_uuid}` | `/public/agent/test/workflow/{workflow_uuid}` | The workflow UUID from the agent's **[Agent UUID](/configurations/agent-uuid)** field |
<Note>
Do not pass a workflow UUID to `/public/agent/{uuid}` and do not pass a trigger UUID
to `/public/agent/workflow/{workflow_uuid}`. Dograh treats these as different
identifier types and the request will fail if you mix them up.
</Note>
Once Dograh resolves the target agent, both route families behave the same:
- They accept the same request body
- They return the same response shape
- They validate the same `X-API-Key` organization boundary
- They use the same telephony configuration selection rules
If you specifically need the API Trigger route, see [Trigger an outbound call by API Trigger node](/api-reference/calls/trigger). To execute by workflow UUID, see [Trigger an outbound call by workflow UUID](/api-reference/calls/trigger-workflow).
## Using initial context
`initial_context` passes runtime data into the agent at call time. Values are available as template variables in your agent's prompt using double-brace syntax.

View file

@ -0,0 +1,25 @@
---
title: "Trigger an Outbound Call by Workflow UUID"
description: "Initiate an outbound call using a workflow's stable Agent UUID"
openapi: "POST /api/v1/public/agent/workflow/{workflow_uuid}"
---
Use this endpoint when you want to execute a workflow directly by its stable Agent UUID instead of through an API Trigger node.
The `workflow_uuid` is the workflow's Agent UUID. It is different from an API Trigger node's `trigger_path`.
To find and copy the Agent UUID in the UI, see [Agent UUID](/configurations/agent-uuid).
Use `workflow_run_id` from the response to later [retrieve call details](/api-reference/calls/get-run), recordings, and transcripts.
Pass `initial_context` to inject runtime data as template variables into the agent's prompt. See [Using initial context](/api-reference/calls#using-initial-context).
Pass `telephony_configuration_id` to route the call through a specific telephony configuration instead of your organization's default. The id is shown on each row in **Telephony configurations** (`https://app.dograh.com/telephony-configurations` for hosted or `http://localhost:3010/telephony-configurations` for local).
<Note>
This route expects a workflow UUID. Do not pass an API Trigger node UUID here. If you want to execute via an API Trigger node, use [Trigger an outbound call](/api-reference/calls/trigger) instead.
</Note>
<Note>
Your telephony provider must be configured before outbound calls will connect. See [Telephony](/integrations/telephony/overview) for setup instructions.
</Note>

View file

@ -1,10 +1,12 @@
---
title: "Trigger an Outbound Call"
description: "Initiate an outbound call using an agent's public UUID"
title: "Trigger an Outbound Call by API Trigger Node"
description: "Initiate an outbound call using an API Trigger node UUID"
openapi: "POST /api/v1/public/agent/{uuid}"
---
The simplest way to initiate a call programmatically. The `uuid` comes from the [API Trigger node](/voice-agent/api-trigger) in your agent — add the node to your workflow and copy its auto-generated `trigger_path`.
Use this endpoint when you want to execute a workflow through an [API Trigger node](/voice-agent/api-trigger).
The `uuid` comes from the API Trigger node in your agent. Add the node to your workflow and copy its auto-generated `trigger_path`.
Use `workflow_run_id` from the response to later [retrieve call details](/api-reference/calls/get-run), recordings, and transcripts.
@ -12,6 +14,10 @@ Pass `initial_context` to inject runtime data as template variables into the age
Pass `telephony_configuration_id` to route the call through a specific telephony configuration instead of your organization's default. The id is shown on each row in **Telephony configurations** (`https://app.dograh.com/telephony-configurations` for hosted or `http://localhost:3010/telephony-configurations` for local).
<Note>
This route expects an API Trigger node UUID (`trigger_path`). Do not pass a workflow UUID here. If you want to execute by workflow UUID, use [Trigger an outbound call by workflow UUID](/api-reference/calls/trigger-workflow) instead.
</Note>
<Note>
Your telephony provider must be configured before outbound calls will connect. See [Telephony](/integrations/telephony/overview) for setup instructions.
</Note>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,43 @@
---
title: "Agent UUID"
description: "Find and use your workflow's stable Agent UUID"
---
The **Agent UUID** is the workflow's stable identifier. In API routes and payloads you may also see it called `workflow_uuid`.
Unlike numeric workflow IDs, the Agent UUID is portable across environments and does not change when you publish new versions of the same workflow.
## Where to find it
You can copy the Agent UUID from two places in the dashboard.
### From the workflow editor
1. Open your agent in the workflow editor
2. Click the **⋮** menu in the top-right of the header
3. Click **Copy Agent UUID**
### From the agent's Settings page
1. Open the agent and go to **Settings**
2. Scroll to the **Agent UUID** section
3. Click the UUID code block, or use **Copy UUID**
## Where you use it
Use the Agent UUID for workflow-level routes such as:
- `POST /api/v1/public/agent/workflow/{workflow_uuid}`
- `POST /api/v1/public/agent/test/workflow/{workflow_uuid}`
- `wss://<your-host>/api/v1/agent-stream/{agent_uuid}`
<Note>
Do not confuse the Agent UUID with an API Trigger node UUID (`trigger_path`).
- Use the Agent UUID for workflow-level routes like `/public/agent/workflow/{workflow_uuid}`
- Use the API Trigger node UUID for trigger routes like `/public/agent/{uuid}`
- The API Trigger node path can be edited if you want something more descriptive
- The Agent UUID is generated by Dograh and cannot be changed
See [API Trigger](/voice-agent/api-trigger) for trigger-node URLs.
</Note>

View file

@ -45,6 +45,7 @@
"configurations/voice",
"configurations/transcriber",
"configurations/api-keys",
"configurations/agent-uuid",
"configurations/interruption",
"configurations/tracing"
]
@ -204,6 +205,7 @@
"pages": [
"api-reference/calls",
"api-reference/calls/trigger",
"api-reference/calls/trigger-workflow",
"api-reference/calls/get-run",
"api-reference/calls/list-runs",
"api-reference/calls/download",

View file

@ -35,19 +35,9 @@ wss://app.dograh.com/api/v1/agent-stream/{agent_uuid}
## Get the Agent UUID
The Agent UUID is the workflow's stable identifier — it doesn't change when versions are published. You can copy it from two places in the dashboard:
The Agent UUID is the workflow's stable identifier — it doesn't change when versions are published.
**From the workflow editor**
1. Open your agent in the workflow editor
2. Click the **⋮** (more options) menu in the top-right of the header
3. Click **Copy Agent UUID** — the toast confirms the copy
**From the agent's Settings page**
1. Open the agent and go to **Settings**
2. Scroll to the **Agent UUID** section (also linked in the right-side nav)
3. Click the UUID code block, or use the **Copy UUID** button
To find and copy it in the UI, see [Agent UUID](/configurations/agent-uuid).
## Connect to the WebSocket

View file

@ -14,7 +14,12 @@ This is useful when you want to trigger calls from your own backend, a CRM, or w
## Finding your trigger URL
When you add an API Trigger node to your workflow, Dograh assigns it a unique UUID. The trigger node exposes two URLs that share this UUID — one for the published agent and one for the latest draft. You can copy either URL from the trigger node's settings dialog.
When you add an API Trigger node to your workflow, Dograh assigns it a unique UUID. The trigger node exposes two URLs that share this UUID:
- A **production URL** for the published workflow
- A **test URL** for the latest draft
You can copy either URL from the trigger node's settings dialog.
```
POST https://your-dograh-instance/api/v1/public/agent/{uuid} # Production
@ -30,7 +35,15 @@ If you are using the hosted version, replace `your-dograh-instance` with `api.do
| Production | `/api/v1/public/agent/{uuid}` | The published version of the agent. |
| Test | `/api/v1/public/agent/test/{uuid}` | The latest draft. Falls back to the published version if no draft exists. |
Use the test URL while iterating on changes so production traffic continues to hit the published version. Once you publish your draft, both URLs run the same definition.
Use the test URL while iterating on changes so production traffic continues to hit the published version.
The production URL only executes a **published** workflow. If you update the workflow but do not publish it, the production trigger will continue to run the older published version.
Once you publish your draft, both URLs run the same definition.
<Warning>
A common pitfall is editing the workflow, saving the draft, and then calling the production trigger URL expecting the new behavior. That will not work until you publish the workflow. Use the test URL to verify draft changes before publishing.
</Warning>
The request body, headers, and response shape are identical for both URLs.
@ -38,7 +51,8 @@ The request body, headers, and response shape are identical for both URLs.
Authenticate by passing your API key in the `X-API-Key` header. The request body requires a `phone_number` and accepts optional `initial_context` and `telephony_configuration_id` fields.
```bash
<CodeGroup>
```bash Production URL
curl -X POST https://your-dograh-instance/api/v1/public/agent/{uuid} \
-H "Content-Type: application/json" \
-H "X-API-Key: dg_your_api_key" \
@ -51,6 +65,22 @@ curl -X POST https://your-dograh-instance/api/v1/public/agent/{uuid} \
}'
```
```bash Test URL
curl -X POST https://your-dograh-instance/api/v1/public/agent/test/{uuid} \
-H "Content-Type: application/json" \
-H "X-API-Key: dg_your_api_key" \
-d '{
"phone_number": "+14155550100",
"initial_context": {
"customer_name": "Jane",
"appointment_date": "March 15"
}
}'
```
</CodeGroup>
Use the test URL when you want to verify draft changes before publishing.
### Response
A successful request returns a `workflow_run_id` that you can use to [retrieve call details](/api-reference/calls/get-run), recordings, and transcripts.
@ -107,4 +137,4 @@ The id is shown on each row in **Telephony configurations** (`https://app.dograh
<Note>
For full endpoint details including all parameters and response fields, see the [API reference](/api-reference/calls/trigger).
</Note>
</Note>

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.kmLQzhjuKC
# timestamp: 2026-05-21T11:50:06+00:00
# filename: dograh-openapi-XXXXXX.json.SafScGt2nh
# timestamp: 2026-05-22T09:06:50+00:00
from __future__ import annotations
@ -247,6 +247,8 @@ class WorkflowListResponse(BaseModel):
status: Annotated[str, Field(title='Status')]
created_at: Annotated[AwareDatetime, Field(title='Created At')]
total_runs: Annotated[int, Field(title='Total Runs')]
folder_id: Annotated[int | None, Field(title='Folder Id')] = None
workflow_uuid: Annotated[str | None, Field(title='Workflow Uuid')] = None
class WorkflowResponse(BaseModel):

View file

@ -46,10 +46,10 @@ class Trigger(TypedNode):
trigger_path: Optional[str] = None
"""
Auto-generated UUID-style path segment that uniquely identifies this
trigger. Used in both URLs: Production:
`/api/v1/public/agent/<trigger_path>` executes the published agent.
Test: `/api/v1/public/agent/test/<trigger_path>` executes the latest
draft. Do not edit manually.
Path segment that uniquely identifies this trigger. Used in both URLs:
Production: `/api/v1/public/agent/<trigger_path>` executes the
published agent. Test: `/api/v1/public/agent/test/<trigger_path>`
executes the latest draft. Can be customized to a descriptive value up
to 36 characters using letters, numbers, hyphens, or underscores.
"""

View file

@ -707,6 +707,10 @@ export interface components {
created_at: string;
/** Total Runs */
total_runs: number;
/** Folder Id */
folder_id?: number | null;
/** Workflow Uuid */
workflow_uuid?: string | null;
};
/** WorkflowResponse */
WorkflowResponse: {

View file

@ -28,10 +28,10 @@ export interface Trigger {
*/
enabled?: boolean;
/**
* Auto-generated UUID-style path segment that uniquely identifies this trigger. Used in both URLs:
* Path segment that uniquely identifies this trigger. Used in both URLs:
* Production: `/api/v1/public/agent/<trigger_path>` executes the published agent.
* Test: `/api/v1/public/agent/test/<trigger_path>` executes the latest draft.
* Do not edit manually.
* Can be customized to a descriptive value up to 36 characters using letters, numbers, hyphens, or underscores.
*/
trigger_path?: string;
}

View file

@ -1,10 +1,12 @@
import { Suspense } from 'react';
import { getWorkflowsApiV1WorkflowFetchGet } from '@/client/sdk.gen';
import type { WorkflowListResponse } from '@/client/types.gen';
import { getWorkflowsApiV1WorkflowFetchGet, listFoldersApiV1FolderGet } from '@/client/sdk.gen';
import type { FolderResponse, WorkflowListResponse } from '@/client/types.gen';
import { CreateWorkflowButton } from "@/components/workflow/CreateWorkflowButton";
import { AgentFolderView } from '@/components/workflow/folders/AgentFolderView';
import { CreateFolderButton } from '@/components/workflow/folders/CreateFolderButton';
import { FolderSection } from '@/components/workflow/folders/FolderSection';
import { UploadWorkflowButton } from '@/components/workflow/UploadWorkflowButton';
import { WorkflowTable } from "@/components/workflow/WorkflowTable";
import { getServerAccessToken, getServerAuthProvider } from '@/lib/auth/server';
import logger from '@/lib/logger';
@ -54,13 +56,27 @@ async function WorkflowList() {
.filter((w: WorkflowListResponse) => w.status === 'archived')
.sort((a: WorkflowListResponse, b: WorkflowListResponse) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
// Fetch folders for grouping active agents. A failure here shouldn't
// break the page — fall back to an empty list (flat, ungrouped view).
let folders: FolderResponse[] = [];
try {
const foldersResponse = await listFoldersApiV1FolderGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
folders = foldersResponse.data ?? [];
} catch (folderErr) {
logger.error(`Error fetching folders: ${folderErr}`);
}
return (
<>
{/* Active Workflows Section */}
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4">Active Agents</h2>
{activeWorkflows.length > 0 ? (
<WorkflowTable workflows={activeWorkflows} showArchived={false} />
{activeWorkflows.length > 0 || folders.length > 0 ? (
<AgentFolderView workflows={activeWorkflows} folders={folders} />
) : (
<div className="text-muted-foreground bg-muted rounded-lg p-8 text-center">
No active workflows found. Create your first workflow to get started.
@ -68,11 +84,10 @@ async function WorkflowList() {
)}
</div>
{/* Archived Workflows Section */}
{/* Archived Section — collapsible, same design as the folder/Uncategorized sections */}
{archivedWorkflows.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-semibold mb-4 text-muted-foreground">Archived Workflows</h2>
<WorkflowTable workflows={archivedWorkflows} showArchived={true} />
<FolderSection kind="archived" workflows={archivedWorkflows} />
</div>
)}
</>
@ -99,6 +114,7 @@ async function PageContent() {
<h1 className="text-2xl font-bold">Your Agents</h1>
<div className="flex gap-2">
<UploadWorkflowButton />
<CreateFolderButton />
<CreateWorkflowButton />
</div>
</div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -864,6 +864,16 @@ export type CreateCredentialRequest = {
};
};
/**
* CreateFolderRequest
*/
export type CreateFolderRequest = {
/**
* Name
*/
name: string;
};
/**
* CreateServiceKeyRequest
*/
@ -1718,6 +1728,24 @@ export type FileMetadataResponse = {
} | null;
};
/**
* FolderResponse
*/
export type FolderResponse = {
/**
* Id
*/
id: number;
/**
* Name
*/
name: string;
/**
* Created At
*/
created_at: string;
};
/**
* GraphConstraints
*
@ -2165,6 +2193,18 @@ export type McpToolDefinition = {
config: McpToolConfig;
};
/**
* MoveWorkflowToFolderRequest
*
* Move a workflow into a folder, or to "Uncategorized" when null.
*/
export type MoveWorkflowToFolderRequest = {
/**
* Folder Id
*/
folder_id?: number | null;
};
/**
* NodeCategory
*
@ -3865,6 +3905,16 @@ export type UpdateCredentialRequest = {
} | null;
};
/**
* UpdateFolderRequest
*/
export type UpdateFolderRequest = {
/**
* Name
*/
name: string;
};
/**
* UpdateToolRequest
*
@ -4359,6 +4409,10 @@ export type WorkflowListResponse = {
* Total Runs
*/
total_runs: number;
/**
* Folder Id
*/
folder_id?: number | null;
};
/**
@ -5962,6 +6016,50 @@ export type UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponses = {
export type UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse = UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponses[keyof UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponses];
export type MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutData = {
body: MoveWorkflowToFolderRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Workflow Id
*/
workflow_id: number;
};
query?: never;
url: '/api/v1/workflow/{workflow_id}/folder';
};
export type MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutError = MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutErrors[keyof MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutErrors];
export type MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutResponses = {
/**
* Successful Response
*/
200: WorkflowListResponse;
};
export type MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutResponse = MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutResponses[keyof MoveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPutResponses];
export type UpdateWorkflowApiV1WorkflowWorkflowIdPutData = {
body: UpdateWorkflowRequest;
headers?: {
@ -10680,6 +10778,178 @@ export type TranscribeAudioApiV1WorkflowRecordingsTranscribePostResponses = {
200: unknown;
};
export type ListFoldersApiV1FolderGetData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/folder/';
};
export type ListFoldersApiV1FolderGetErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type ListFoldersApiV1FolderGetError = ListFoldersApiV1FolderGetErrors[keyof ListFoldersApiV1FolderGetErrors];
export type ListFoldersApiV1FolderGetResponses = {
/**
* Response List Folders Api V1 Folder Get
*
* Successful Response
*/
200: Array<FolderResponse>;
};
export type ListFoldersApiV1FolderGetResponse = ListFoldersApiV1FolderGetResponses[keyof ListFoldersApiV1FolderGetResponses];
export type CreateFolderApiV1FolderPostData = {
body: CreateFolderRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path?: never;
query?: never;
url: '/api/v1/folder/';
};
export type CreateFolderApiV1FolderPostErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CreateFolderApiV1FolderPostError = CreateFolderApiV1FolderPostErrors[keyof CreateFolderApiV1FolderPostErrors];
export type CreateFolderApiV1FolderPostResponses = {
/**
* Successful Response
*/
200: FolderResponse;
};
export type CreateFolderApiV1FolderPostResponse = CreateFolderApiV1FolderPostResponses[keyof CreateFolderApiV1FolderPostResponses];
export type DeleteFolderApiV1FolderFolderIdDeleteData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Folder Id
*/
folder_id: number;
};
query?: never;
url: '/api/v1/folder/{folder_id}';
};
export type DeleteFolderApiV1FolderFolderIdDeleteErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type DeleteFolderApiV1FolderFolderIdDeleteError = DeleteFolderApiV1FolderFolderIdDeleteErrors[keyof DeleteFolderApiV1FolderFolderIdDeleteErrors];
export type DeleteFolderApiV1FolderFolderIdDeleteResponses = {
/**
* Response Delete Folder Api V1 Folder Folder Id Delete
*
* Successful Response
*/
200: {
[key: string]: boolean;
};
};
export type DeleteFolderApiV1FolderFolderIdDeleteResponse = DeleteFolderApiV1FolderFolderIdDeleteResponses[keyof DeleteFolderApiV1FolderFolderIdDeleteResponses];
export type RenameFolderApiV1FolderFolderIdPutData = {
body: UpdateFolderRequest;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
/**
* X-Api-Key
*/
'X-API-Key'?: string | null;
};
path: {
/**
* Folder Id
*/
folder_id: number;
};
query?: never;
url: '/api/v1/folder/{folder_id}';
};
export type RenameFolderApiV1FolderFolderIdPutErrors = {
/**
* Not found
*/
404: unknown;
/**
* Validation Error
*/
422: HttpValidationError;
};
export type RenameFolderApiV1FolderFolderIdPutError = RenameFolderApiV1FolderFolderIdPutErrors[keyof RenameFolderApiV1FolderFolderIdPutErrors];
export type RenameFolderApiV1FolderFolderIdPutResponses = {
/**
* Successful Response
*/
200: FolderResponse;
};
export type RenameFolderApiV1FolderFolderIdPutResponse = RenameFolderApiV1FolderFolderIdPutResponses[keyof RenameFolderApiV1FolderFolderIdPutResponses];
export type SignupApiV1AuthSignupPostData = {
body: SignupRequest;
path?: never;

View file

@ -1,6 +1,6 @@
"use client";
import { Plus, X } from "lucide-react";
import { ExternalLink, Plus, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
@ -32,9 +32,13 @@ interface SchemaProperty {
description?: string;
format?: string;
multiline?: boolean;
docs_url?: string;
}
interface ProviderSchema {
title?: string;
description?: string;
provider_docs_url?: string;
properties: Record<string, SchemaProperty>;
required?: string[];
$defs?: Record<string, SchemaProperty>;
@ -88,12 +92,24 @@ export interface ServiceConfigurationFormProps {
submitLabel?: string;
}
function getGlobalSummary(config: Record<string, unknown> | null | undefined): string {
function getProviderDisplayName(
provider: string | undefined,
providerSchema: ProviderSchema | undefined,
): string | undefined {
if (!provider) return provider;
return providerSchema?.title || provider;
}
function getGlobalSummary(
config: Record<string, unknown> | null | undefined,
providerSchema: ProviderSchema | undefined,
): string {
if (!config) return "Not configured";
const provider = config.provider as string | undefined;
const model = config.model as string | undefined;
if (!provider) return "Not configured";
return model ? `${provider} / ${model}` : provider;
const providerLabel = getProviderDisplayName(provider, providerSchema);
return model ? `${providerLabel} / ${model}` : providerLabel || provider;
}
export function ServiceConfigurationForm({
@ -486,11 +502,26 @@ export function ServiceConfigurationForm({
<SelectContent>
{availableProviders.map((provider) => (
<SelectItem key={provider} value={provider}>
{provider}
{getProviderDisplayName(provider, schemas?.[service]?.[provider])}
</SelectItem>
))}
</SelectContent>
</Select>
{(providerSchema?.description || providerSchema?.provider_docs_url) && (
<p className="text-xs text-muted-foreground">
{providerSchema?.description}{" "}
{providerSchema?.provider_docs_url && (
<a
href={providerSchema.provider_docs_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline"
>
Learn more <ExternalLink className="h-3 w-3" />
</a>
)}
</p>
)}
</div>
{currentProvider && providerSchema && configFields[0] && (
@ -580,9 +611,21 @@ export function ServiceConfigurationForm({
const actualSchema = schema.$ref && providerSchema.$defs
? providerSchema.$defs[schema.$ref.split('/').pop() || '']
: schema;
if (!actualSchema?.description) return null;
if (!actualSchema?.description && !actualSchema?.docs_url) return null;
return (
<p className="text-xs text-muted-foreground">{actualSchema.description}</p>
<p className="text-xs text-muted-foreground">
{actualSchema?.description}{" "}
{actualSchema?.docs_url && (
<a
href={actualSchema.docs_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline"
>
Supported languages <ExternalLink className="h-3 w-3" />
</a>
)}
</p>
);
};
@ -763,6 +806,8 @@ export function ServiceConfigurationForm({
const renderOverrideToggle = (service: ServiceSegment, label: string) => {
const globalVal = (userConfig as Record<string, unknown> | null)?.[service] as Record<string, unknown> | null | undefined;
const isEnabled = enabledOverrides[service];
const globalProvider = globalVal?.provider as string | undefined;
const globalProviderSchema = globalProvider ? schemas?.[service]?.[globalProvider] : undefined;
return (
<div className="flex items-center justify-between p-3 border rounded-md bg-muted/20 mb-4">
@ -772,7 +817,7 @@ export function ServiceConfigurationForm({
</Label>
{!isEnabled && (
<p className="text-xs text-muted-foreground">
Using global: {getGlobalSummary(globalVal)}
Using global: {getGlobalSummary(globalVal, globalProviderSchema)}
</p>
)}
</div>

View file

@ -1,12 +1,32 @@
'use client';
import { Archive, Pencil, RotateCcw } from 'lucide-react';
import {
Archive,
Check,
Folder as FolderIcon,
FolderInput,
Inbox,
Pencil,
RotateCcw,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { toast } from 'sonner';
import { updateWorkflowStatusApiV1WorkflowWorkflowIdStatusPut } from '@/client/sdk.gen';
import {
moveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPut,
updateWorkflowStatusApiV1WorkflowWorkflowIdStatusPut,
} from '@/client/sdk.gen';
import type { FolderResponse } from '@/client/types.gen';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Table,
TableBody,
@ -21,17 +41,31 @@ interface Workflow {
status: string;
created_at: string;
total_runs?: number | null;
folder_id?: number | null;
}
interface WorkflowTableProps {
workflows: Workflow[];
showArchived: boolean;
/**
* When provided, each row gets a "Move to folder" action listing these
* folders. Omit it (e.g. for the archived list) to hide the control.
*/
folders?: FolderResponse[];
/** The folder this table is rendered under; null means "Uncategorized". */
currentFolderId?: number | null;
}
export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
export function WorkflowTable({
workflows,
showArchived,
folders,
currentFolderId = null,
}: WorkflowTableProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [loadingWorkflowId, setLoadingWorkflowId] = useState<number | null>(null);
const [movingWorkflowId, setMovingWorkflowId] = useState<number | null>(null);
const handleEdit = (id: number) => {
router.push(`/workflow/${id}`);
@ -67,6 +101,30 @@ export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
}
};
const handleMove = async (id: number, folderId: number | null) => {
setMovingWorkflowId(id);
try {
const response = await moveWorkflowToFolderApiV1WorkflowWorkflowIdFolderPut({
path: { workflow_id: id },
body: { folder_id: folderId },
});
if (response.error) {
throw new Error('Failed to move agent');
}
toast.success(
folderId === null ? 'Moved to Uncategorized' : 'Agent moved',
);
startTransition(() => {
router.refresh();
});
} catch (error) {
console.error('Error moving workflow:', error);
toast.error('Failed to move agent');
} finally {
setMovingWorkflowId(null);
}
};
return (
<div className="bg-card border rounded-lg overflow-hidden shadow-sm">
<Table>
@ -114,6 +172,52 @@ export function WorkflowTable({ workflows, showArchived }: WorkflowTableProps) {
<Pencil size={16} />
Edit
</Button>
{folders && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={movingWorkflowId === workflow.id || isPending}
className="flex items-center gap-2"
>
{movingWorkflowId === workflow.id ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<FolderInput size={16} />
)}
Move
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>Move to folder</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={currentFolderId === null}
onClick={() => handleMove(workflow.id, null)}
>
<Inbox size={14} className="mr-2" />
Uncategorized
{currentFolderId === null && (
<Check size={14} className="ml-auto" />
)}
</DropdownMenuItem>
{folders.map((folder) => (
<DropdownMenuItem
key={folder.id}
disabled={folder.id === currentFolderId}
onClick={() => handleMove(workflow.id, folder.id)}
>
<FolderIcon size={14} className="mr-2" />
<span className="truncate">{folder.name}</span>
{folder.id === currentFolderId && (
<Check size={14} className="ml-auto shrink-0" />
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
<Button
variant={showArchived ? "default" : "outline"}
size="sm"

View file

@ -0,0 +1,63 @@
'use client';
import type { FolderResponse, WorkflowListResponse } from '@/client/types.gen';
import { WorkflowTable } from '../WorkflowTable';
import { FolderSection } from './FolderSection';
interface AgentFolderViewProps {
/** Active (non-archived) agents only. */
workflows: WorkflowListResponse[];
folders: FolderResponse[];
}
/**
* Renders active agents grouped into collapsible folder sections.
*
* When the organization has no folders yet, this falls back to the original
* flat table so the feature stays invisible until someone creates a folder.
*/
export function AgentFolderView({ workflows, folders }: AgentFolderViewProps) {
// No folders → keep the original flat list (no folder chrome, nowhere to move to).
if (folders.length === 0) {
return <WorkflowTable workflows={workflows} showArchived={false} />;
}
// Group agents by folder. Agents whose folder_id is null — or points at a
// folder we didn't get back — fall into "Uncategorized".
const folderIds = new Set(folders.map((f) => f.id));
const byFolder = new Map<number, WorkflowListResponse[]>();
const uncategorized: WorkflowListResponse[] = [];
for (const wf of workflows) {
if (wf.folder_id != null && folderIds.has(wf.folder_id)) {
const bucket = byFolder.get(wf.folder_id) ?? [];
bucket.push(wf);
byFolder.set(wf.folder_id, bucket);
} else {
uncategorized.push(wf);
}
}
return (
<div className="space-y-1">
{folders.map((folder) => (
<FolderSection
key={folder.id}
kind="folder"
folder={folder}
workflows={byFolder.get(folder.id) ?? []}
allFolders={folders}
defaultOpen={false}
/>
))}
{uncategorized.length > 0 && (
<FolderSection
kind="uncategorized"
workflows={uncategorized}
allFolders={folders}
/>
)}
</div>
);
}

View file

@ -0,0 +1,46 @@
'use client';
import { FolderPlus } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import { createFolderApiV1FolderPost } from '@/client/sdk.gen';
import { Button } from '@/components/ui/button';
import { FolderFormDialog } from './FolderFormDialog';
export function CreateFolderButton() {
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const handleCreate = async (name: string) => {
const response = await createFolderApiV1FolderPost({ body: { name } });
if (response.error) {
// 409 = duplicate name; surface the server's message when present.
const detail =
(response.error as { detail?: string })?.detail ??
'Failed to create folder';
toast.error(detail);
throw new Error(detail);
}
toast.success(`Folder "${name}" created`);
router.refresh();
};
return (
<>
<Button variant="outline" onClick={() => setIsOpen(true)}>
<FolderPlus className="w-4 h-4 mr-2" />
New Folder
</Button>
<FolderFormDialog
open={isOpen}
onOpenChange={setIsOpen}
title="Create folder"
submitLabel="Create"
onSubmit={handleCreate}
/>
</>
);
}

View file

@ -0,0 +1,97 @@
'use client';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface FolderFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
/** Pre-fill the input (used when renaming). */
initialName?: string;
submitLabel: string;
/** Resolve to close the dialog; reject/throw to keep it open (e.g. on error). */
onSubmit: (name: string) => Promise<void>;
}
/**
* Shared single-field dialog used for both creating and renaming a folder.
* Keeps name validation and the pending state in one place.
*/
export function FolderFormDialog({
open,
onOpenChange,
title,
initialName = '',
submitLabel,
onSubmit,
}: FolderFormDialogProps) {
const [name, setName] = useState(initialName);
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset the field whenever the dialog (re)opens.
useEffect(() => {
if (open) setName(initialName);
}, [open, initialName]);
const trimmed = name.trim();
const canSubmit = trimmed.length > 0 && trimmed !== initialName.trim() && !isSubmitting;
const handleSubmit = async () => {
if (!canSubmit) return;
setIsSubmitting(true);
try {
await onSubmit(trimmed);
onOpenChange(false);
} catch {
// onSubmit surfaces its own error toast; keep the dialog open.
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="folder-name">Folder name</Label>
<Input
id="folder-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Sales, Support, Onboarding"
maxLength={100}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
}}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!canSubmit}>
{isSubmitting ? 'Saving...' : submitLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,255 @@
'use client';
import {
Archive,
ChevronRight,
Folder as FolderIcon,
FolderOpen,
Inbox,
MoreVertical,
Pencil,
Trash2,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import {
deleteFolderApiV1FolderFolderIdDelete,
renameFolderApiV1FolderFolderIdPut,
} from '@/client/sdk.gen';
import type { FolderResponse, WorkflowListResponse } from '@/client/types.gen';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import logger from '@/lib/logger';
import { cn } from '@/lib/utils';
import { WorkflowTable } from '../WorkflowTable';
import { FolderFormDialog } from './FolderFormDialog';
/**
* - `folder` a real, renameable/deletable folder of active agents
* - `uncategorized` active agents with no folder
* - `archived` archived agents (restore-only; not a move target)
*/
type SectionKind = 'folder' | 'uncategorized' | 'archived';
interface FolderSectionProps {
kind: SectionKind;
/** Required when kind === 'folder'; ignored otherwise. */
folder?: FolderResponse | null;
workflows: WorkflowListResponse[];
/** All folders, passed through so each row's "Move to folder" menu has targets. */
allFolders?: FolderResponse[];
/** Defaults to open only for Uncategorized; folders and Archived start collapsed. */
defaultOpen?: boolean;
}
export function FolderSection({
kind,
folder = null,
workflows,
allFolders = [],
defaultOpen,
}: FolderSectionProps) {
const router = useRouter();
const [open, setOpen] = useState(defaultOpen ?? kind === 'uncategorized');
const [isRenaming, setIsRenaming] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isFolder = kind === 'folder';
const isArchived = kind === 'archived';
const count = workflows.length;
const title = isFolder ? (folder?.name ?? '') : isArchived ? 'Archived' : 'Uncategorized';
const handleRename = async (name: string) => {
if (!folder) return;
const response = await renameFolderApiV1FolderFolderIdPut({
path: { folder_id: folder.id },
body: { name },
});
if (response.error) {
const detail =
(response.error as { detail?: string })?.detail ??
'Failed to rename folder';
toast.error(detail);
throw new Error(detail);
}
toast.success('Folder renamed');
router.refresh();
};
const handleDelete = async () => {
if (!folder) return;
setIsDeleting(true);
try {
const response = await deleteFolderApiV1FolderFolderIdDelete({
path: { folder_id: folder.id },
});
if (response.error) {
throw new Error('Failed to delete folder');
}
toast.success(`Folder "${folder.name}" deleted`);
setConfirmDelete(false);
router.refresh();
} catch (err) {
logger.error(`Error deleting folder: ${err}`);
toast.error('Failed to delete folder');
} finally {
setIsDeleting(false);
}
};
return (
<div className="mb-3">
<Collapsible open={open} onOpenChange={setOpen}>
<div className="flex items-center gap-1">
<CollapsibleTrigger asChild>
<button
className="group flex flex-1 items-center gap-2.5 rounded-md px-2 py-2 text-left transition-colors hover:bg-accent"
aria-label={`Toggle ${title}`}
>
<ChevronRight
size={16}
className={cn(
'shrink-0 text-muted-foreground transition-transform duration-200',
open && 'rotate-90',
)}
/>
{isFolder ? (
open ? (
<FolderOpen size={17} className="shrink-0 text-amber-500" />
) : (
<FolderIcon size={17} className="shrink-0 text-amber-500" />
)
) : isArchived ? (
<Archive size={16} className="shrink-0 text-muted-foreground" />
) : (
<Inbox size={17} className="shrink-0 text-muted-foreground" />
)}
<span
className={cn('font-medium', !isFolder && 'text-muted-foreground')}
>
{title}
</span>
<Badge variant="secondary" className="ml-1 font-normal">
{count}
</Badge>
</button>
</CollapsibleTrigger>
{isFolder && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground"
aria-label="Folder actions"
>
<MoreVertical size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setIsRenaming(true)}>
<Pencil size={14} className="mr-2" />
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setConfirmDelete(true)}
className="text-destructive focus:text-destructive"
>
<Trash2 size={14} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<CollapsibleContent className="data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1">
<div className="pl-7 pt-2">
{count > 0 ? (
<WorkflowTable
workflows={workflows}
showArchived={isArchived}
// Archived agents are restore-only — not a move target.
folders={isArchived ? undefined : allFolders}
currentFolderId={folder?.id ?? null}
/>
) : (
<div className="rounded-lg border border-dashed bg-muted/30 p-6 text-center text-sm text-muted-foreground">
{isArchived
? 'No archived agents.'
: isFolder
? 'This folder is empty. Use “Move to folder” on an agent to add it here.'
: 'No uncategorized agents.'}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
{isFolder && folder && (
<>
<FolderFormDialog
open={isRenaming}
onOpenChange={setIsRenaming}
title="Rename folder"
initialName={folder.name}
submitLabel="Rename"
onSubmit={handleRename}
/>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {folder.name}?</AlertDialogTitle>
<AlertDialogDescription>
The {count} agent{count === 1 ? '' : 's'} in this folder
wont be deleted theyll move to Uncategorized.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? 'Deleting...' : 'Delete folder'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div>
);
}