diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0fef5f8a..dc38bc3d 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.33.0" + ".": "1.34.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8842b706..894aa1f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 1.34.0 (2026-06-03) + + + +## What's Changed +### Features +* feat: add mcp guides for various topic and stages for bot building by @a6kme in https://github.com/dograh-hq/dograh/pull/380 +* feat: allow overriding base URL of OpenAI STT and TTS by @developer603 in https://github.com/dograh-hq/dograh/pull/377 +* feat: add Azure AI multi-provider support (TTS, STT, Embeddings, Realtime) by @vishaldhateria in https://github.com/dograh-hq/dograh/pull/381 +### Bug Fixes +* fix: support object and array parameters in custom HTTP tools by @mvanhorn in https://github.com/dograh-hq/dograh/pull/373 +* fix(telephony): resolve transfer context via call-sid index instead of KEYS scan by @shiminshen in https://github.com/dograh-hq/dograh/pull/387 +* fix(webrtc): enforce embed allowed-domain policy on public signaling websocket by @shiminshen in https://github.com/dograh-hq/dograh/pull/388 +* fix: use runtime BACKEND_URL for proxying by @a6kme in https://github.com/dograh-hq/dograh/pull/411 +* fix: add CORS preflight handler and ACAO header for embed config endpoint by @nuthalapativarun in https://github.com/dograh-hq/dograh/pull/403 +### Other Changes +* Add Sarvam LLM, update Sarvam STT models, expose usage_info on run detail by @abhaybabbar in https://github.com/dograh-hq/dograh/pull/351 +* fix: make email lookup case-insensitive in get_user_by_email by @developer603 in https://github.com/dograh-hq/dograh/pull/397 + +## New Contributors +* @abhaybabbar made their first contribution in https://github.com/dograh-hq/dograh/pull/351 +* @mvanhorn made their first contribution in https://github.com/dograh-hq/dograh/pull/373 +* @developer603 made their first contribution in https://github.com/dograh-hq/dograh/pull/377 +* @vishaldhateria made their first contribution in https://github.com/dograh-hq/dograh/pull/381 +* @shiminshen made their first contribution in https://github.com/dograh-hq/dograh/pull/387 + +**Full Changelog**: https://github.com/dograh-hq/dograh/compare/dograh-v1.33.0...dograh-v1.34.0 + ## [1.33.0](https://github.com/dograh-hq/dograh/compare/dograh-v1.32.0...dograh-v1.33.0) (2026-05-31) diff --git a/README.md b/README.md index 5b3c90ba..be5f83b1 100644 --- a/README.md +++ b/README.md @@ -75,13 +75,13 @@ An honest comparison on the axes that matter most to teams evaluating voice AI p ##### Download and setup Dograh on your Local Machine > **Note** -> We collect anonymous usage data to improve the product. You can opt out by setting the `ENABLE_TELEMETRY` to `false` in the below command. +> We collect anonymous usage data to improve the product. You can opt out by setting `ENABLE_TELEMETRY=false` before running the startup script. > **Note** > If you wish to run the platform on a remote server instead, checkout our [Documentation](https://docs.dograh.com/deployment/docker#option-2:-remote-server-deployment) ```bash -curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml && REGISTRY=ghcr.io/dograh-hq ENABLE_TELEMETRY=true docker compose up --pull always +curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml && curl -o start_docker.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.sh && chmod +x start_docker.sh && ./start_docker.sh ``` > **Note** diff --git a/api/alembic/versions/2159d4ac431a_added_quota_tables.py b/api/alembic/versions/2159d4ac431a_added_quota_tables.py index 51efc4cc..24326e4b 100644 --- a/api/alembic/versions/2159d4ac431a_added_quota_tables.py +++ b/api/alembic/versions/2159d4ac431a_added_quota_tables.py @@ -18,6 +18,9 @@ branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +DEPRECATED_QUOTA_COMMENT = "Deprecated. MPS owns quota and credit ledger state." + + def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### # 1) Create the `quota_type` enum *before* we add the column that references it. @@ -34,7 +37,12 @@ def upgrade() -> None: sa.Column("organization_id", sa.Integer(), nullable=False), sa.Column("period_start", sa.DateTime(), nullable=False), sa.Column("period_end", sa.DateTime(), nullable=False), - sa.Column("quota_dograh_tokens", sa.Integer(), nullable=False), + sa.Column( + "quota_dograh_tokens", + sa.Integer(), + nullable=False, + comment=DEPRECATED_QUOTA_COMMENT, + ), sa.Column("used_dograh_tokens", sa.Integer(), nullable=False), sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), @@ -63,7 +71,11 @@ def upgrade() -> None: op.add_column( "organizations", sa.Column( - "quota_type", quota_type_enum, nullable=False, server_default="monthly" + "quota_type", + quota_type_enum, + nullable=False, + server_default="monthly", + comment=DEPRECATED_QUOTA_COMMENT, ), ) op.add_column( @@ -73,6 +85,7 @@ def upgrade() -> None: sa.Integer(), nullable=False, server_default=sa.text("0"), + comment=DEPRECATED_QUOTA_COMMENT, ), ) op.add_column( @@ -82,10 +95,17 @@ def upgrade() -> None: sa.Integer(), nullable=False, server_default=sa.text("LEAST(EXTRACT(DAY FROM CURRENT_DATE)::int, 28)"), + comment=DEPRECATED_QUOTA_COMMENT, ), ) op.add_column( - "organizations", sa.Column("quota_start_date", sa.DateTime(), nullable=True) + "organizations", + sa.Column( + "quota_start_date", + sa.DateTime(), + nullable=True, + comment=DEPRECATED_QUOTA_COMMENT, + ), ) op.add_column( "organizations", @@ -94,6 +114,7 @@ def upgrade() -> None: sa.Boolean(), nullable=False, server_default=sa.text("false"), + comment=DEPRECATED_QUOTA_COMMENT, ), ) # ### end Alembic commands ### diff --git a/api/alembic/versions/384be6596b36_make_email_case_insensitive.py b/api/alembic/versions/384be6596b36_make_email_case_insensitive.py index a300f477..11357c98 100644 --- a/api/alembic/versions/384be6596b36_make_email_case_insensitive.py +++ b/api/alembic/versions/384be6596b36_make_email_case_insensitive.py @@ -5,28 +5,38 @@ Revises: 6bd9f67ec994 Create Date: 2026-06-02 07:58:00.002359 """ + from typing import Sequence, Union -from alembic import op import sqlalchemy as sa - +from alembic import op # revision identifiers, used by Alembic. -revision: str = '384be6596b36' -down_revision: Union[str, None] = '6bd9f67ec994' +revision: str = "384be6596b36" +down_revision: Union[str, None] = "6bd9f67ec994" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_users_email'), table_name='users') - op.create_index('ix_users_email_lower', 'users', [sa.literal_column('lower(email)')], unique=True, postgresql_where=sa.text('email IS NOT NULL')) + op.drop_index(op.f("ix_users_email"), table_name="users") + op.create_index( + "ix_users_email_lower", + "users", + [sa.literal_column("lower(email)")], + unique=True, + postgresql_where=sa.text("email IS NOT NULL"), + ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_index('ix_users_email_lower', table_name='users', postgresql_where=sa.text('email IS NOT NULL')) - op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.drop_index( + "ix_users_email_lower", + table_name="users", + postgresql_where=sa.text("email IS NOT NULL"), + ) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) # ### end Alembic commands ### diff --git a/api/alembic/versions/c425d3445750_add_columns_in_usage_table.py b/api/alembic/versions/c425d3445750_add_columns_in_usage_table.py index 998e7123..cbd9c654 100644 --- a/api/alembic/versions/c425d3445750_add_columns_in_usage_table.py +++ b/api/alembic/versions/c425d3445750_add_columns_in_usage_table.py @@ -18,6 +18,9 @@ branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None +DEPRECATED_QUOTA_COMMENT = "Deprecated. MPS owns quota and credit ledger state." + + def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### op.add_column( @@ -26,7 +29,12 @@ def upgrade() -> None: ) op.add_column( "organization_usage_cycles", - sa.Column("quota_amount_usd", sa.Float(), nullable=True), + sa.Column( + "quota_amount_usd", + sa.Float(), + nullable=True, + comment=DEPRECATED_QUOTA_COMMENT, + ), ) # ### end Alembic commands ### diff --git a/api/app.py b/api/app.py index b2b28111..1dd9413f 100644 --- a/api/app.py +++ b/api/app.py @@ -117,6 +117,15 @@ app.add_middleware( allow_headers=["*"], ) + +def _add_public_embed_cors_middleware() -> None: + from api.routes.public_embed import PublicEmbedCORSMiddleware + + app.add_middleware(PublicEmbedCORSMiddleware, api_prefix=API_PREFIX) + + +_add_public_embed_cors_middleware() + api_router = APIRouter() # include subrouters here diff --git a/api/db/campaign_client.py b/api/db/campaign_client.py index d9ff2bae..7729a2ea 100644 --- a/api/db/campaign_client.py +++ b/api/db/campaign_client.py @@ -9,6 +9,7 @@ from api.db.base_client import BaseDBClient from api.db.filters import apply_workflow_run_filters, get_workflow_run_order_clause from api.db.models import CampaignModel, QueuedRunModel, WorkflowRunModel from api.schemas.workflow import WorkflowRunResponseSchema +from api.services.workflow.run_usage_response import format_public_cost_info class CampaignClient(BaseDBClient): @@ -215,26 +216,9 @@ class CampaignClient(BaseDBClient): "is_completed": run.is_completed, "recording_url": run.recording_url, "transcript_url": run.transcript_url, - "cost_info": { - "dograh_token_usage": ( - run.cost_info.get("dograh_token_usage") - if run.cost_info - and "dograh_token_usage" in run.cost_info - else round( - float(run.cost_info.get("total_cost_usd", 0)) * 100, - 2, - ) - if run.cost_info and "total_cost_usd" in run.cost_info - else 0 - ), - "call_duration_seconds": int( - round(run.cost_info.get("call_duration_seconds") or 0) - ) - if run.cost_info - else None, - } - if run.cost_info - else None, + "cost_info": format_public_cost_info( + run.cost_info, run.usage_info + ), "definition_id": run.definition_id, "initial_context": run.initial_context, "gathered_context": run.gathered_context, @@ -662,7 +646,7 @@ class CampaignClient(BaseDBClient): async with self.async_session() as session: conditions = [ WorkflowRunModel.is_completed.is_(True), - WorkflowRunModel.cost_info["call_duration_seconds"] + WorkflowRunModel.usage_info["call_duration_seconds"] .as_string() .isnot(None), ] @@ -685,6 +669,7 @@ class CampaignClient(BaseDBClient): WorkflowRunModel.initial_context, WorkflowRunModel.gathered_context, WorkflowRunModel.cost_info, + WorkflowRunModel.usage_info, WorkflowRunModel.public_access_token, ) .where(*conditions) diff --git a/api/db/db_client.py b/api/db/db_client.py index de98cf19..15d1c108 100644 --- a/api/db/db_client.py +++ b/api/db/db_client.py @@ -53,7 +53,7 @@ class DBClient( - UserClient: handles user and user configuration operations - OrganizationClient: handles organization operations - OrganizationConfigurationClient: handles organization configuration operations - - OrganizationUsageClient: handles organization usage and quota operations + - OrganizationUsageClient: handles organization usage reporting aggregates - IntegrationClient: handles integration operations - WorkflowTemplateClient: handles workflow template operations - CampaignClient: handles campaign operations diff --git a/api/db/filters.py b/api/db/filters.py index e960d724..cd30b144 100644 --- a/api/db/filters.py +++ b/api/db/filters.py @@ -25,7 +25,7 @@ def get_workflow_run_order_clause( """ # Determine sort column if sort_by == "duration": - sort_column = WorkflowRunModel.cost_info.op("->>")( + sort_column = WorkflowRunModel.usage_info.op("->>")( "call_duration_seconds" ).cast(Float) else: @@ -43,7 +43,7 @@ def get_workflow_run_order_clause( ATTRIBUTE_FIELD_MAPPING = { "dateRange": "created_at", "dispositionCode": "gathered_context.mapped_call_disposition", - "duration": "cost_info.call_duration_seconds", + "duration": "usage_info.call_duration_seconds", "status": "is_completed", "tokenUsage": "cost_info.total_cost_usd", "runId": "id", @@ -208,7 +208,7 @@ def apply_workflow_run_filters( min_val = value.get("min") max_val = value.get("max") - if field == "cost_info.call_duration_seconds": + if field == "usage_info.call_duration_seconds": # Use ->> operator for compatibility with all PostgreSQL versions # (subscript [] only works in PostgreSQL 14+) duration_text = cast(WorkflowRunModel.usage_info, JSONB).op("->>")( diff --git a/api/db/models.py b/api/db/models.py index c61cb03d..696cb6e6 100644 --- a/api/db/models.py +++ b/api/db/models.py @@ -97,22 +97,44 @@ class OrganizationModel(Base): provider_id = Column(String, unique=True, index=True, nullable=False) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) - # Quota fields + # Deprecated: MPS owns quota and credit ledger state. quota_type = Column( Enum("monthly", "annual", name="quota_type"), nullable=False, default="monthly", server_default=text("'monthly'::quota_type"), + comment="Deprecated. MPS owns quota and credit ledger state.", + info={"deprecated": True}, ) quota_dograh_tokens = Column( - Integer, nullable=False, default=0, server_default=text("0") + Integer, + nullable=False, + default=0, + server_default=text("0"), + comment="Deprecated. MPS owns quota and credit ledger state.", + info={"deprecated": True}, ) quota_reset_day = Column( - Integer, nullable=False, default=1, server_default=text("1") - ) # 1-28, only for monthly - quota_start_date = Column(DateTime(timezone=True), nullable=True) # Only for annual + Integer, + nullable=False, + default=1, + server_default=text("1"), + comment="Deprecated. MPS owns quota and credit ledger state.", + info={"deprecated": True}, + ) + quota_start_date = Column( + DateTime(timezone=True), + nullable=True, + comment="Deprecated. MPS owns quota and credit ledger state.", + info={"deprecated": True}, + ) quota_enabled = Column( - Boolean, nullable=False, default=False, server_default=text("false") + Boolean, + nullable=False, + default=False, + server_default=text("false"), + comment="Deprecated. MPS owns quota and credit ledger state.", + info={"deprecated": True}, ) price_per_second_usd = Column(Float, nullable=True) @@ -593,8 +615,9 @@ class WorkflowRunTextSessionModel(Base): class OrganizationUsageCycleModel(Base): """ - This model is used to track the usage of Dograh tokens for an organization for a given usage - cycle. + This model is used to track reporting aggregates for an organization for a given + usage cycle. Quota fields on this model are deprecated; MPS owns quota and + credit ledger state. """ __tablename__ = "organization_usage_cycles" @@ -603,14 +626,24 @@ class OrganizationUsageCycleModel(Base): organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False) period_start = Column(DateTime(timezone=True), nullable=False) period_end = Column(DateTime(timezone=True), nullable=False) - quota_dograh_tokens = Column(Integer, nullable=False) + quota_dograh_tokens = Column( + Integer, + nullable=False, + comment="Deprecated. MPS owns quota and credit ledger state.", + info={"deprecated": True}, + ) used_dograh_tokens = Column(Float, nullable=False, default=0) total_duration_seconds = Column( Integer, nullable=False, default=0, server_default=text("0") ) # New USD tracking fields used_amount_usd = Column(Float, nullable=True, default=0) - quota_amount_usd = Column(Float, nullable=True) + quota_amount_usd = Column( + Float, + nullable=True, + comment="Deprecated. MPS owns quota and credit ledger state.", + info={"deprecated": True}, + ) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) updated_at = Column( DateTime(timezone=True), diff --git a/api/db/organization_usage_client.py b/api/db/organization_usage_client.py index 928bf8be..dfca0538 100644 --- a/api/db/organization_usage_client.py +++ b/api/db/organization_usage_client.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import joinedload from api.db.base_client import BaseDBClient from api.db.filters import apply_workflow_run_filters from api.db.models import ( + OrganizationConfigurationModel, OrganizationModel, OrganizationUsageCycleModel, UserConfigurationModel, @@ -17,11 +18,12 @@ from api.db.models import ( WorkflowModel, WorkflowRunModel, ) -from api.schemas.user_configuration import UserConfiguration +from api.enums import OrganizationConfigurationKey +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration class OrganizationUsageClient(BaseDBClient): - """Client for managing organization usage and quota operations.""" + """Client for managing organization usage reporting aggregates.""" async def get_or_create_current_cycle( self, organization_id: int, session=None @@ -47,14 +49,7 @@ class OrganizationUsageClient(BaseDBClient): self, organization_id: int, session, commit: bool ) -> OrganizationUsageCycleModel: """Internal implementation for get_or_create_current_cycle.""" - # Get organization to determine quota type - org_result = await session.execute( - select(OrganizationModel).where(OrganizationModel.id == organization_id) - ) - org = org_result.scalar_one() - - # Calculate current period - period_start, period_end = self._calculate_current_period(org) + period_start, period_end = self._calculate_current_period() # Try to get existing cycle cycle_result = await session.execute( @@ -76,7 +71,8 @@ class OrganizationUsageClient(BaseDBClient): organization_id=organization_id, period_start=period_start, period_end=period_end, - quota_dograh_tokens=org.quota_dograh_tokens, + # Deprecated non-null column retained for historical schema compatibility. + quota_dograh_tokens=0, ) # Handle concurrent inserts gracefully stmt = stmt.on_conflict_do_nothing( @@ -100,95 +96,9 @@ class OrganizationUsageClient(BaseDBClient): ) return cycle_result.scalar_one() - async def check_and_reserve_quota( - self, organization_id: int, estimated_tokens: int = 0 - ) -> bool: - """ - Check if organization has sufficient quota and optionally reserve tokens. - Returns True if quota is available, False otherwise. - - This method is fully atomic and safe for concurrent access from multiple processes. - """ - async with self.async_session() as session: - # Get organization - org_result = await session.execute( - select(OrganizationModel).where(OrganizationModel.id == organization_id) - ) - org = org_result.scalar_one_or_none() - - if not org or not org.quota_enabled: - # No quota enforcement if not enabled - return True - - # Get or create current cycle within the same session/transaction - cycle = await self._get_or_create_current_cycle_impl( - organization_id, session, commit=False - ) - - # Atomic check and update with row-level lock - result = await session.execute( - select(OrganizationUsageCycleModel) - .where( - and_( - OrganizationUsageCycleModel.id == cycle.id, - OrganizationUsageCycleModel.used_dograh_tokens - + estimated_tokens - <= OrganizationUsageCycleModel.quota_dograh_tokens, - ) - ) - .with_for_update(skip_locked=False) - ) - - cycle_locked = result.scalar_one_or_none() - if cycle_locked: - # Update the usage atomically - cycle_locked.used_dograh_tokens += estimated_tokens - await session.commit() - return True - - return False - - async def update_usage_after_run( - self, - organization_id: int, - actual_tokens: float, - duration_seconds: float = 0, - charge_usd: float | None = None, - ) -> None: - """Update usage after a workflow run completes with actual token count and duration. - - This method is fully atomic and safe for concurrent access from multiple processes. - """ - async with self.async_session() as session: - # Get or create current cycle within the same session/transaction - cycle = await self._get_or_create_current_cycle_impl( - organization_id, session, commit=False - ) - - # Acquire a row-level lock for atomic update - result = await session.execute( - select(OrganizationUsageCycleModel) - .where(OrganizationUsageCycleModel.id == cycle.id) - .with_for_update(skip_locked=False) - ) - cycle_locked = result.scalar_one() - - # Update usage atomically - cycle_locked.used_dograh_tokens += actual_tokens - cycle_locked.total_duration_seconds += int(round(duration_seconds)) - - # Update USD amount if provided - if charge_usd is not None: - if cycle_locked.used_amount_usd is None: - cycle_locked.used_amount_usd = 0 - cycle_locked.used_amount_usd += charge_usd - - await session.commit() - async def get_current_usage(self, organization_id: int) -> dict: - """Get current period usage information.""" + """Get current reporting-period usage information.""" async with self.async_session() as session: - # Get organization org_result = await session.execute( select(OrganizationModel).where(OrganizationModel.id == organization_id) ) @@ -199,42 +109,19 @@ class OrganizationUsageClient(BaseDBClient): organization_id, session, commit=False ) - # Calculate next refresh date - if org.quota_type == "monthly": - next_refresh = cycle.period_end + relativedelta(days=1) - else: # annual - next_refresh = cycle.period_end + relativedelta(days=1) - result = { "period_start": cycle.period_start.isoformat(), "period_end": cycle.period_end.isoformat(), "used_dograh_tokens": cycle.used_dograh_tokens, - "quota_dograh_tokens": cycle.quota_dograh_tokens, - "percentage_used": ( - round( - (cycle.used_dograh_tokens / cycle.quota_dograh_tokens) * 100, 2 - ) - if cycle.quota_dograh_tokens > 0 - else 0 - ), - "next_refresh_date": next_refresh.date().isoformat(), - "quota_enabled": org.quota_enabled, "total_duration_seconds": cycle.total_duration_seconds, } # Add USD fields if organization has pricing if org.price_per_second_usd is not None: result["used_amount_usd"] = cycle.used_amount_usd or 0 - result["quota_amount_usd"] = cycle.quota_amount_usd result["currency"] = "USD" result["price_per_second_usd"] = org.price_per_second_usd - # Calculate percentage based on USD if available - if cycle.quota_amount_usd and cycle.quota_amount_usd > 0: - result["percentage_used"] = round( - ((cycle.used_amount_usd or 0) / cycle.quota_amount_usd) * 100, 2 - ) - return result async def get_usage_history( @@ -254,7 +141,7 @@ class OrganizationUsageClient(BaseDBClient): .join(UserModel, WorkflowModel.user_id == UserModel.id) .where( UserModel.selected_organization_id == organization_id, - WorkflowRunModel.cost_info.isnot(None), + WorkflowRunModel.usage_info.isnot(None), ) .order_by(WorkflowRunModel.created_at.desc()) ) @@ -307,19 +194,8 @@ class OrganizationUsageClient(BaseDBClient): total_tokens = 0 total_duration_seconds = 0 for run in runs: - if run.cost_info: - # Try to get dograh_token_usage first (new format) - dograh_tokens = run.cost_info.get("dograh_token_usage", 0) - # If not present, calculate from total_cost_usd (old format) - if dograh_tokens == 0 and "total_cost_usd" in run.cost_info: - dograh_tokens = round( - float(run.cost_info["total_cost_usd"]) * 100, 2 - ) - # Get call duration - call_duration = run.cost_info.get("call_duration_seconds", 0) - else: - dograh_tokens = 0 - call_duration = 0 + dograh_tokens = 0 + call_duration = (run.usage_info or {}).get("call_duration_seconds", 0) total_tokens += dograh_tokens total_duration_seconds += int(round(call_duration)) @@ -393,13 +269,14 @@ class OrganizationUsageClient(BaseDBClient): WorkflowRunModel.initial_context, WorkflowRunModel.gathered_context, WorkflowRunModel.cost_info, + WorkflowRunModel.usage_info, WorkflowRunModel.public_access_token, ) .join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id) .join(UserModel, WorkflowModel.user_id == UserModel.id) .where( UserModel.selected_organization_id == organization_id, - WorkflowRunModel.cost_info.isnot(None), + WorkflowRunModel.usage_info.isnot(None), ) .order_by(WorkflowRunModel.created_at.desc()) ) @@ -440,8 +317,29 @@ class OrganizationUsageClient(BaseDBClient): """Get daily usage breakdown for an organization with pricing.""" async with self.async_session() as session: - # Get user timezone if user_id is provided + # Get org timezone preference first, then fall back to legacy user config. user_timezone = "UTC" # Default timezone + pref_result = await session.execute( + select(OrganizationConfigurationModel).where( + OrganizationConfigurationModel.organization_id == organization_id, + OrganizationConfigurationModel.key.in_( + [ + OrganizationConfigurationKey.ORGANIZATION_PREFERENCES.value, + OrganizationConfigurationKey.MODEL_CONFIGURATION_PREFERENCES.value, + ] + ), + ) + ) + pref_rows = pref_result.scalars().all() + pref_by_key = {pref.key: pref for pref in pref_rows} + pref_obj = pref_by_key.get( + OrganizationConfigurationKey.ORGANIZATION_PREFERENCES.value + ) or pref_by_key.get( + OrganizationConfigurationKey.MODEL_CONFIGURATION_PREFERENCES.value + ) + if pref_obj and pref_obj.value: + user_timezone = pref_obj.value.get("timezone") or user_timezone + if user_id: config_result = await session.execute( select(UserConfigurationModel).where( @@ -450,11 +348,11 @@ class OrganizationUsageClient(BaseDBClient): ) config_obj = config_result.scalar_one_or_none() if config_obj and config_obj.configuration: - user_config = UserConfiguration.model_validate( + effective_config = EffectiveAIModelConfiguration.model_validate( config_obj.configuration ) - if user_config.timezone: - user_timezone = user_config.timezone + if effective_config.timezone and user_timezone == "UTC": + user_timezone = effective_config.timezone # Validate timezone string try: @@ -473,7 +371,7 @@ class OrganizationUsageClient(BaseDBClient): select( date_expr.label("date"), func.sum( - WorkflowRunModel.cost_info["call_duration_seconds"].as_float() + WorkflowRunModel.usage_info["call_duration_seconds"].as_float() ).label("total_seconds"), func.count(WorkflowRunModel.id).label("call_count"), ) @@ -522,83 +420,11 @@ class OrganizationUsageClient(BaseDBClient): "currency": "USD", } - async def update_organization_quota( - self, - organization_id: int, - quota_type: str, - quota_dograh_tokens: int, - quota_reset_day: Optional[int] = None, - quota_start_date: Optional[datetime] = None, - ) -> OrganizationModel: - """Update organization quota settings.""" - async with self.async_session() as session: - result = await session.execute( - select(OrganizationModel).where(OrganizationModel.id == organization_id) - ) - org = result.scalar_one() - - org.quota_type = quota_type - org.quota_dograh_tokens = quota_dograh_tokens - org.quota_enabled = True - - if quota_type == "monthly" and quota_reset_day: - org.quota_reset_day = quota_reset_day - elif quota_type == "annual" and quota_start_date: - org.quota_start_date = quota_start_date - - await session.commit() - await session.refresh(org) - return org - - def _calculate_current_period( - self, org: OrganizationModel - ) -> tuple[datetime, datetime]: - """Calculate the current billing period based on organization settings.""" + def _calculate_current_period(self) -> tuple[datetime, datetime]: + """Calculate the current calendar-month reporting period.""" now = datetime.now(timezone.utc) - if org.quota_type == "monthly": - # Find the start of the current billing month - reset_day = org.quota_reset_day - - # Handle month boundaries - if now.day >= reset_day: - period_start = now.replace( - day=reset_day, hour=0, minute=0, second=0, microsecond=0 - ) - else: - # Previous month - period_start = (now - relativedelta(months=1)).replace( - day=reset_day, hour=0, minute=0, second=0, microsecond=0 - ) - - # End is one month later minus 1 second - period_end = ( - period_start + relativedelta(months=1) - relativedelta(seconds=1) - ) - - else: # annual - if not org.quota_start_date: - # Default to calendar year - period_start = now.replace( - month=1, day=1, hour=0, minute=0, second=0, microsecond=0 - ) - period_end = ( - period_start + relativedelta(years=1) - relativedelta(seconds=1) - ) - else: - # Find current annual period - start_date = org.quota_start_date.replace(tzinfo=timezone.utc) - years_diff = now.year - start_date.year - - # Adjust for whether we've passed the anniversary - if now.month < start_date.month or ( - now.month == start_date.month and now.day < start_date.day - ): - years_diff -= 1 - - period_start = start_date + relativedelta(years=years_diff) - period_end = ( - period_start + relativedelta(years=1) - relativedelta(seconds=1) - ) + period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + period_end = period_start + relativedelta(months=1) - relativedelta(seconds=1) return period_start, period_end diff --git a/api/db/user_client.py b/api/db/user_client.py index 0983a38d..4ea0bca9 100644 --- a/api/db/user_client.py +++ b/api/db/user_client.py @@ -8,7 +8,7 @@ from sqlalchemy.future import select from api.db.base_client import BaseDBClient from api.db.models import UserConfigurationModel, UserModel -from api.schemas.user_configuration import UserConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration class UserClient(BaseDBClient): @@ -65,7 +65,9 @@ class UserClient(BaseDBClient): ) return result.scalars().first() - async def get_user_configurations(self, user_id: int) -> UserConfiguration: + async def get_user_configurations( + self, user_id: int + ) -> EffectiveAIModelConfiguration: async with self.async_session() as session: result = await session.execute( select(UserConfigurationModel).where( @@ -74,10 +76,10 @@ class UserClient(BaseDBClient): ) configuration_obj = result.scalars().first() if not configuration_obj: - return UserConfiguration() + return EffectiveAIModelConfiguration() try: - return UserConfiguration.model_validate( + return EffectiveAIModelConfiguration.model_validate( { **configuration_obj.configuration, "last_validated_at": configuration_obj.last_validated_at, @@ -90,11 +92,11 @@ class UserClient(BaseDBClient): f"Failed to validate user configuration for user {user_id}: {e}. " "Returning default configuration." ) - return UserConfiguration() + return EffectiveAIModelConfiguration() async def update_user_configuration( - self, user_id: int, configuration: UserConfiguration - ) -> UserConfiguration: + self, user_id: int, configuration: EffectiveAIModelConfiguration + ) -> EffectiveAIModelConfiguration: async with self.async_session() as session: result = await session.execute( select(UserConfigurationModel).where( @@ -115,7 +117,9 @@ class UserClient(BaseDBClient): await session.rollback() raise e await session.refresh(configuration_obj) - return UserConfiguration.model_validate(configuration_obj.configuration) + return EffectiveAIModelConfiguration.model_validate( + configuration_obj.configuration + ) async def update_user_configuration_last_validated_at(self, user_id: int) -> None: async with self.async_session() as session: diff --git a/api/db/workflow_run_client.py b/api/db/workflow_run_client.py index 57c3e02b..497230ad 100644 --- a/api/db/workflow_run_client.py +++ b/api/db/workflow_run_client.py @@ -16,6 +16,7 @@ from api.db.models import ( ) from api.enums import CallType, StorageBackend from api.schemas.workflow import WorkflowRunResponseSchema +from api.services.workflow.run_usage_response import format_public_cost_info class WorkflowRunClient(BaseDBClient): @@ -312,26 +313,9 @@ class WorkflowRunClient(BaseDBClient): "is_completed": run.is_completed, "recording_url": run.recording_url, "transcript_url": run.transcript_url, - "cost_info": { - "dograh_token_usage": ( - run.cost_info.get("dograh_token_usage") - if run.cost_info - and "dograh_token_usage" in run.cost_info - else round( - float(run.cost_info.get("total_cost_usd", 0)) * 100, - 2, - ) - if run.cost_info and "total_cost_usd" in run.cost_info - else 0 - ), - "call_duration_seconds": int( - round(run.cost_info.get("call_duration_seconds") or 0) - ) - if run.cost_info - else None, - } - if run.cost_info - else None, + "cost_info": format_public_cost_info( + run.cost_info, run.usage_info + ), "definition_id": run.definition_id, "initial_context": run.initial_context, "gathered_context": run.gathered_context, diff --git a/api/enums.py b/api/enums.py index 12557057..6ca1085f 100644 --- a/api/enums.py +++ b/api/enums.py @@ -89,6 +89,11 @@ class OrganizationConfigurationKey(Enum): LANGFUSE_CREDENTIALS = ( "LANGFUSE_CREDENTIALS" # Org-level Langfuse tracing credentials ) + MODEL_CONFIGURATION_V2 = ( + "MODEL_CONFIGURATION_V2" # Org-level v2 AI model configuration + ) + ORGANIZATION_PREFERENCES = "ORGANIZATION_PREFERENCES" # Org-level defaults such as timezone/test call number + MODEL_CONFIGURATION_PREFERENCES = "MODEL_CONFIGURATION_PREFERENCES" # Deprecated; read fallback for old org preferences class WorkflowStatus(Enum): diff --git a/api/pyproject.toml b/api/pyproject.toml index e5c764bc..b0368db3 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,5 +1,5 @@ [project] name = "dograh-api" -version = "1.33.0" +version = "1.34.0" description = "Backend API for Dograh voice AI platform" requires-python = ">=3.13,<3.14" diff --git a/api/routes/agent_stream.py b/api/routes/agent_stream.py index b593a318..32bf5743 100644 --- a/api/routes/agent_stream.py +++ b/api/routes/agent_stream.py @@ -22,7 +22,7 @@ from starlette.websockets import WebSocketDisconnect from api.db import db_client from api.enums import CallType, WorkflowRunState -from api.services.quota_service import check_dograh_quota_by_user_id +from api.services.quota_service import authorize_workflow_run_start from api.services.telephony import registry as telephony_registry router = APIRouter(prefix="/agent-stream") @@ -67,19 +67,6 @@ async def agent_stream_websocket( await websocket.close(code=1008, reason="Workflow not found") return - quota_result = await check_dograh_quota_by_user_id( - workflow.user_id, workflow_id=workflow.id - ) - if not quota_result.has_quota: - logger.warning( - f"agent-stream quota exceeded for user {workflow.user_id}: " - f"{quota_result.error_message}" - ) - await websocket.close( - code=1008, reason=quota_result.error_message or "Quota exceeded" - ) - return - numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000 workflow_run_name = f"WR-AGS-{numeric_suffix:08d}" call_id = params.get("callId") or params.get("CallSid") @@ -108,6 +95,20 @@ async def agent_stream_websocket( set_current_run_id(workflow_run.id) set_current_org_id(workflow.organization_id) + quota_result = await authorize_workflow_run_start( + workflow_id=workflow.id, + workflow_run_id=workflow_run.id, + ) + if not quota_result.has_quota: + logger.warning( + f"agent-stream quota exceeded for user {workflow.user_id}: " + f"{quota_result.error_message}" + ) + await websocket.close( + code=1008, reason=quota_result.error_message or "Quota exceeded" + ) + return + await db_client.update_workflow_run( run_id=workflow_run.id, state=WorkflowRunState.RUNNING.value ) diff --git a/api/routes/auth.py b/api/routes/auth.py index b6773a69..6083b875 100644 --- a/api/routes/auth.py +++ b/api/routes/auth.py @@ -3,9 +3,12 @@ from loguru import logger from api.db import db_client from api.db.models import UserModel -from api.enums import PostHogEvent +from api.enums import OrganizationConfigurationKey, PostHogEvent from api.schemas.auth import AuthResponse, LoginRequest, SignupRequest, UserResponse from api.services.auth.depends import create_user_configuration_with_mps_key, get_user +from api.services.configuration.ai_model_configuration import ( + convert_legacy_ai_model_configuration_to_v2, +) from api.services.posthog_client import capture_event from api.utils.auth import create_jwt_token, hash_password, verify_password @@ -47,6 +50,12 @@ async def signup(request: SignupRequest): ) if mps_config: await db_client.update_user_configuration(user.id, mps_config) + model_config_v2 = convert_legacy_ai_model_configuration_to_v2(mps_config) + await db_client.upsert_configuration( + organization.id, + OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value, + model_config_v2.model_dump(mode="json", exclude_none=True), + ) except Exception: logger.warning( "Failed to create default configuration for OSS user", exc_info=True diff --git a/api/routes/campaign.py b/api/routes/campaign.py index cb5f541c..90697f91 100644 --- a/api/routes/campaign.py +++ b/api/routes/campaign.py @@ -18,7 +18,7 @@ from api.services.auth.depends import get_user from api.services.campaign.runner import campaign_runner_service from api.services.campaign.source_sync import CampaignSourceSyncService from api.services.campaign.source_sync_factory import get_sync_service -from api.services.quota_service import check_dograh_quota +from api.services.quota_service import authorize_workflow_run_start from api.services.reports import generate_campaign_report_csv from api.services.storage import storage_fs @@ -550,7 +550,10 @@ async def start_campaign( # Check Dograh quota before starting campaign (apply per-workflow # model_overrides so we evaluate the keys this campaign will use). - quota_result = await check_dograh_quota(user, workflow_id=campaign.workflow_id) + quota_result = await authorize_workflow_run_start( + workflow_id=campaign.workflow_id, + actor_user=user, + ) if not quota_result.has_quota: raise HTTPException(status_code=402, detail=quota_result.error_message) @@ -872,7 +875,10 @@ async def resume_campaign( # Check Dograh quota before resuming campaign (apply per-workflow # model_overrides so we evaluate the keys this campaign will use). - quota_result = await check_dograh_quota(user, workflow_id=campaign.workflow_id) + quota_result = await authorize_workflow_run_start( + workflow_id=campaign.workflow_id, + actor_user=user, + ) if not quota_result.has_quota: raise HTTPException(status_code=402, detail=quota_result.error_message) diff --git a/api/routes/knowledge_base.py b/api/routes/knowledge_base.py index 5bf4b0ae..bd0ba046 100644 --- a/api/routes/knowledge_base.py +++ b/api/routes/knowledge_base.py @@ -369,6 +369,10 @@ async def search_chunks( try: # Import here to avoid circular dependency + from api.services.configuration.ai_model_configuration import ( + apply_managed_embeddings_base_url, + get_resolved_ai_model_configuration, + ) from api.services.configuration.registry import ServiceProviders from api.services.gen_ai import ( AzureOpenAIEmbeddingService, @@ -376,20 +380,29 @@ async def search_chunks( ) # Try to get user's embeddings configuration - user_config = await db_client.get_user_configurations(user.id) + resolved_config = await get_resolved_ai_model_configuration( + user_id=user.id, + organization_id=user.selected_organization_id, + ) + effective_config = resolved_config.effective embeddings_api_key = None embeddings_model = None embeddings_provider = None + embeddings_base_url = None embeddings_endpoint = None embeddings_api_version = None - if user_config.embeddings: - embeddings_api_key = user_config.embeddings.api_key - embeddings_model = user_config.embeddings.model - embeddings_provider = getattr(user_config.embeddings, "provider", None) - embeddings_endpoint = getattr(user_config.embeddings, "endpoint", None) + if effective_config.embeddings: + embeddings_api_key = effective_config.embeddings.api_key + embeddings_model = effective_config.embeddings.model + embeddings_provider = getattr(effective_config.embeddings, "provider", None) + embeddings_endpoint = getattr(effective_config.embeddings, "endpoint", None) + embeddings_base_url = apply_managed_embeddings_base_url( + provider=embeddings_provider, + base_url=getattr(effective_config.embeddings, "base_url", None), + ) embeddings_api_version = getattr( - user_config.embeddings, "api_version", None + effective_config.embeddings, "api_version", None ) # Initialize embedding service based on provider @@ -406,9 +419,7 @@ async def search_chunks( db_client=db_client, api_key=embeddings_api_key, model_id=embeddings_model or "text-embedding-3-small", - base_url=getattr(user_config.embeddings, "base_url", None) - if user_config.embeddings - else None, + base_url=embeddings_base_url, ) # Perform search diff --git a/api/routes/organization.py b/api/routes/organization.py index f60a4133..8f8e4cbe 100644 --- a/api/routes/organization.py +++ b/api/routes/organization.py @@ -1,15 +1,27 @@ from typing import List, Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from loguru import logger from pydantic import BaseModel from sqlalchemy.exc import IntegrityError -from api.constants import DEFAULT_CAMPAIGN_RETRY_CONFIG, DEFAULT_ORG_CONCURRENCY_LIMIT +from api.constants import ( + DEFAULT_CAMPAIGN_RETRY_CONFIG, + DEFAULT_ORG_CONCURRENCY_LIMIT, + DEPLOYMENT_MODE, +) from api.db import db_client from api.db.models import UserModel from api.db.telephony_configuration_client import TelephonyConfigurationInUseError from api.enums import OrganizationConfigurationKey, PostHogEvent +from api.schemas.ai_model_configuration import ( + DOGRAH_DEFAULT_LANGUAGE, + DOGRAH_DEFAULT_VOICE, + DOGRAH_SPEED_OPTIONS, + OrganizationAIModelConfigurationResponse, + OrganizationAIModelConfigurationV2, +) +from api.schemas.organization_preferences import OrganizationPreferences from api.schemas.telephony_config import ( TelephonyConfigRequest, TelephonyConfigurationCreateRequest, @@ -26,8 +38,36 @@ from api.schemas.telephony_phone_number import ( PhoneNumberUpdateRequest, ProviderSyncStatus, ) -from api.services.auth.depends import get_user -from api.services.configuration.masking import is_mask_of, mask_key +from api.services.auth.depends import get_user, get_user_with_selected_organization +from api.services.configuration.ai_model_configuration import ( + check_for_masked_keys_in_ai_model_configuration_v2, + compile_ai_model_configuration_v2, + convert_legacy_ai_model_configuration_to_v2, + get_organization_ai_model_configuration_v2, + get_resolved_ai_model_configuration, + mask_ai_model_configuration_v2, + merge_ai_model_configuration_v2_secrets, + migrate_workflow_model_configurations_to_v2, + upsert_organization_ai_model_configuration_v2, +) +from api.services.configuration.check_validity import UserConfigurationValidator +from api.services.configuration.defaults import DEFAULT_SERVICE_PROVIDERS +from api.services.configuration.masking import is_mask_of, mask_key, mask_user_config +from api.services.configuration.registry import ( + DOGRAH_STT_LANGUAGES, + REGISTRY, + ServiceProviders, + ServiceType, +) +from api.services.mps_billing import ensure_hosted_mps_billing_account_v2 +from api.services.organization_context import ( + OrganizationContextResponse, + get_organization_context, +) +from api.services.organization_preferences import ( + get_organization_preferences, + upsert_organization_preferences, +) from api.services.posthog_client import capture_event from api.services.telephony import registry as telephony_registry from api.services.telephony.factory import get_telephony_provider_by_id @@ -98,6 +138,12 @@ class TelephonyConfigWarningsResponse(BaseModel): telnyx_missing_webhook_public_key_count: int +@router.get("/context", response_model=OrganizationContextResponse) +async def get_current_organization_context(user: UserModel = Depends(get_user)): + """Return organization-scoped configuration signals owned by Dograh.""" + return await get_organization_context(user) + + @router.get( "/telephony-providers/metadata", response_model=TelephonyProvidersMetadataResponse, @@ -159,6 +205,239 @@ async def get_telephony_config_warnings(user: UserModel = Depends(get_user)): ) +# --------------------------------------------------------------------------- +# AI model configurations v2 +# --------------------------------------------------------------------------- + + +def _byok_provider_schemas(service_type: ServiceType) -> dict[str, dict]: + return { + provider: model_cls.model_json_schema() + for provider, model_cls in REGISTRY[service_type].items() + if provider != ServiceProviders.DOGRAH.value + } + + +async def _model_configuration_v2_response( + *, + user: UserModel, + configuration: OrganizationAIModelConfigurationV2 | None = None, +) -> OrganizationAIModelConfigurationResponse: + resolved = await get_resolved_ai_model_configuration( + user_id=user.id, + organization_id=user.selected_organization_id, + ) + raw_configuration = ( + configuration + if configuration is not None + else resolved.organization_configuration + ) + return OrganizationAIModelConfigurationResponse( + configuration=mask_ai_model_configuration_v2(raw_configuration), + effective_configuration=mask_user_config(resolved.effective), + source=resolved.source, + ) + + +@router.get("/model-configurations/v2/defaults") +async def get_model_configuration_v2_defaults( + user: UserModel = Depends(get_user_with_selected_organization), +): + byok_default_providers = { + service: provider + for service, provider in DEFAULT_SERVICE_PROVIDERS.items() + if provider != ServiceProviders.DOGRAH.value + } + return { + "dograh": { + "voices": [DOGRAH_DEFAULT_VOICE], + "speeds": list(DOGRAH_SPEED_OPTIONS), + "languages": DOGRAH_STT_LANGUAGES, + "defaults": { + "voice": DOGRAH_DEFAULT_VOICE, + "speed": 1.0, + "language": DOGRAH_DEFAULT_LANGUAGE, + }, + }, + "byok": { + "pipeline": { + "llm": _byok_provider_schemas(ServiceType.LLM), + "tts": _byok_provider_schemas(ServiceType.TTS), + "stt": _byok_provider_schemas(ServiceType.STT), + "embeddings": _byok_provider_schemas(ServiceType.EMBEDDINGS), + "default_providers": byok_default_providers, + }, + "realtime": { + "realtime": _byok_provider_schemas(ServiceType.REALTIME), + "llm": _byok_provider_schemas(ServiceType.LLM), + "embeddings": _byok_provider_schemas(ServiceType.EMBEDDINGS), + "default_providers": byok_default_providers, + }, + }, + } + + +@router.get( + "/model-configurations/v2", + response_model=OrganizationAIModelConfigurationResponse, +) +async def get_model_configuration_v2( + user: UserModel = Depends(get_user_with_selected_organization), +): + return await _model_configuration_v2_response(user=user) + + +@router.put( + "/model-configurations/v2", + response_model=OrganizationAIModelConfigurationResponse, +) +async def save_model_configuration_v2( + request: OrganizationAIModelConfigurationV2, + user: UserModel = Depends(get_user_with_selected_organization), +): + organization_id = user.selected_organization_id + existing = await get_organization_ai_model_configuration_v2(organization_id) + configuration = merge_ai_model_configuration_v2_secrets(request, existing) + try: + check_for_masked_keys_in_ai_model_configuration_v2(configuration) + effective = compile_ai_model_configuration_v2(configuration) + await UserConfigurationValidator().validate( + effective, + organization_id=organization_id, + created_by=user.provider_id, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=exc.args[0]) + + await upsert_organization_ai_model_configuration_v2( + organization_id, + configuration, + ) + return await _model_configuration_v2_response( + user=user, + configuration=configuration, + ) + + +@router.get("/model-configurations/v2/migration-preview") +async def preview_model_configuration_v2_migration( + user: UserModel = Depends(get_user_with_selected_organization), +): + legacy = await db_client.get_user_configurations(user.id) + try: + configuration = convert_legacy_ai_model_configuration_to_v2(legacy) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + return { + "configuration": mask_ai_model_configuration_v2(configuration), + "effective_configuration": mask_user_config( + compile_ai_model_configuration_v2(configuration) + ), + } + + +@router.post( + "/model-configurations/v2/migrate", + response_model=OrganizationAIModelConfigurationResponse, +) +async def migrate_model_configuration_v2( + force: bool = Query(default=False), + user: UserModel = Depends(get_user_with_selected_organization), +): + organization_id = user.selected_organization_id + existing = await get_organization_ai_model_configuration_v2(organization_id) + if existing is not None and not force: + raise HTTPException( + status_code=409, + detail="Organization already has a v2 model configuration", + ) + + legacy = await db_client.get_user_configurations(user.id) + try: + configuration = convert_legacy_ai_model_configuration_to_v2(legacy) + effective = compile_ai_model_configuration_v2(configuration) + await UserConfigurationValidator().validate( + effective, + organization_id=organization_id, + created_by=user.provider_id, + ) + except ValueError as exc: + raise HTTPException(status_code=422, detail=exc.args[0]) + + if DEPLOYMENT_MODE != "oss": + try: + await ensure_hosted_mps_billing_account_v2( + organization_id, + created_by=str(user.provider_id), + ) + except Exception as exc: + logger.error( + "Failed to initialize MPS billing v2 account for organization {}: {}", + organization_id, + exc, + ) + raise HTTPException( + status_code=502, + detail="Failed to initialize MPS billing v2 account", + ) + + await upsert_organization_ai_model_configuration_v2( + organization_id, + configuration, + ) + await migrate_workflow_model_configurations_to_v2( + organization_id=organization_id, + fallback_user_config=legacy, + ) + return await _model_configuration_v2_response( + user=user, + configuration=configuration, + ) + + +@router.get("/preferences", response_model=OrganizationPreferences) +async def get_preferences( + user: UserModel = Depends(get_user_with_selected_organization), +): + organization_id = user.selected_organization_id + return await get_organization_preferences(organization_id) + + +@router.put("/preferences", response_model=OrganizationPreferences) +async def save_preferences( + request: OrganizationPreferences, + user: UserModel = Depends(get_user_with_selected_organization), +): + organization_id = user.selected_organization_id + return await upsert_organization_preferences( + organization_id, + request, + ) + + +@router.get( + "/model-configurations/preferences", + response_model=OrganizationPreferences, + include_in_schema=False, +) +async def get_model_configuration_preferences_legacy( + user: UserModel = Depends(get_user_with_selected_organization), +): + return await get_preferences(user=user) + + +@router.put( + "/model-configurations/preferences", + response_model=OrganizationPreferences, + include_in_schema=False, +) +async def save_model_configuration_preferences_legacy( + request: OrganizationPreferences, + user: UserModel = Depends(get_user_with_selected_organization), +): + return await save_preferences(request=request, user=user) + + def preserve_masked_fields(provider: str, request_dict: dict, existing: dict): """If the client re-submitted a masked sensitive field, restore the original.""" for field_name in _sensitive_fields(provider): diff --git a/api/routes/organization_usage.py b/api/routes/organization_usage.py index 8e75a2c8..3912745b 100644 --- a/api/routes/organization_usage.py +++ b/api/routes/organization_usage.py @@ -1,16 +1,16 @@ import json from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Literal, Optional from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from loguru import logger from pydantic import BaseModel, Field -from api.constants import DEPLOYMENT_MODE +from api.constants import DEPLOYMENT_MODE, UI_APP_URL from api.db import db_client from api.db.models import UserModel -from api.services.auth.depends import get_user +from api.services.auth.depends import get_user, get_user_with_selected_organization from api.services.mps_service_key_client import mps_service_key_client from api.services.reports import generate_usage_runs_report_csv from api.utils.artifacts import artifact_url @@ -22,14 +22,8 @@ class CurrentUsageResponse(BaseModel): period_start: str period_end: str used_dograh_tokens: float - quota_dograh_tokens: int - percentage_used: float - next_refresh_date: str - quota_enabled: bool total_duration_seconds: int - # New USD fields used_amount_usd: Optional[float] = None - quota_amount_usd: Optional[float] = None currency: Optional[str] = None price_per_second_usd: Optional[float] = None @@ -40,6 +34,61 @@ class MPSCreditsResponse(BaseModel): total_quota: float +class MPSCreditPurchaseUrlResponse(BaseModel): + checkout_url: str + + +class MPSBillingAccountResponse(BaseModel): + id: int + organization_id: int + billing_mode: str + cached_balance_credits: float + currency: str + + +class MPSCreditLedgerEntryResponse(BaseModel): + id: int + entry_type: str + origin: Optional[str] = None + credits_delta: float + balance_after: float + amount_minor: Optional[int] = None + amount_currency: Optional[str] = None + payment_order_id: Optional[int] = None + metric_code: Optional[str] = None + correlation_id: Optional[str] = None + aggregation_key: Optional[str] = None + usage_event_id: Optional[int] = None + workflow_run_id: Optional[int] = None + workflow_id: Optional[int] = None + billable_quantity: Optional[float] = None + quantity_unit: Optional[str] = None + metadata: Dict[str, Any] = Field(default_factory=dict) + created_at: str + + +class MPSBillingCreditsResponse(BaseModel): + billing_version: Literal["legacy", "v2"] + total_credits_used: float = 0.0 + remaining_credits: float = 0.0 + total_quota: float = 0.0 + account: Optional[MPSBillingAccountResponse] = None + ledger_entries: List[MPSCreditLedgerEntryResponse] = Field(default_factory=list) + total_count: int = 0 + page: int = 1 + limit: int = 50 + total_pages: int = 0 + + +def _optional_int(value: Any) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + class WorkflowRunUsageResponse(BaseModel): id: int workflow_id: int @@ -97,7 +146,7 @@ class DailyUsageBreakdownResponse(BaseModel): @router.get("/usage/current-period", response_model=CurrentUsageResponse) async def get_current_period_usage(user: UserModel = Depends(get_user)): - """Get current billing period usage for the user's organization.""" + """Get current reporting-period usage for the user's organization.""" if not user.selected_organization_id: raise HTTPException(status_code=400, detail="No organization selected") @@ -142,6 +191,202 @@ async def get_mps_credits(user: UserModel = Depends(get_user)): raise HTTPException(status_code=500, detail=str(e)) +async def _get_mps_billing_account_status( + user: UserModel, organization_id: int +) -> Optional[dict]: + return await mps_service_key_client.get_billing_account_status( + organization_id=organization_id, + created_by=str(user.provider_id), + ) + + +def _is_mps_billing_v2(account: Optional[dict]) -> bool: + return bool(account and account.get("billing_mode") == "v2") + + +async def _legacy_mps_credits_response(user: UserModel) -> MPSBillingCreditsResponse: + if DEPLOYMENT_MODE == "oss": + usage = await mps_service_key_client.get_usage_by_created_by( + str(user.provider_id) + ) + else: + if not user.selected_organization_id: + raise HTTPException(status_code=400, detail="No organization selected") + usage = await mps_service_key_client.get_usage_by_organization( + user.selected_organization_id + ) + + total_used = float(usage.get("total_credits_used", 0.0)) + total_remaining = float(usage.get("remaining_credits", 0.0)) + return MPSBillingCreditsResponse( + billing_version="legacy", + total_credits_used=total_used, + remaining_credits=total_remaining, + total_quota=total_used + total_remaining, + ) + + +@router.get("/billing/credits", response_model=MPSBillingCreditsResponse) +async def get_billing_credits( + page: int = Query(1, ge=1), + limit: int = Query(50, ge=1, le=100), + user: UserModel = Depends(get_user), +): + """Return legacy MPS credits or paginated v2 billing ledger details for the org.""" + try: + if DEPLOYMENT_MODE == "oss" or not user.selected_organization_id: + return await _legacy_mps_credits_response(user) + + organization_id = user.selected_organization_id + account_status = await _get_mps_billing_account_status(user, organization_id) + if not _is_mps_billing_v2(account_status): + return await _legacy_mps_credits_response(user) + + ledger = await mps_service_key_client.get_credit_ledger( + organization_id=organization_id, + page=page, + limit=limit, + created_by=str(user.provider_id), + ) + account = ledger.get("account") or {} + ledger_entries = ledger.get("ledger_entries") or [] + total_count = int(ledger.get("total_count") or len(ledger_entries)) + response_limit = int(ledger.get("limit") or limit) + total_pages = int( + ledger.get("total_pages") + or ((total_count + response_limit - 1) // response_limit) + ) + workflow_ids_by_run_id: dict[int, int] = {} + workflow_run_ids = { + workflow_run_id + for entry in ledger_entries + if (workflow_run_id := _optional_int(entry.get("workflow_run_id"))) + is not None + } + for workflow_run_id in workflow_run_ids: + workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) + if ( + workflow_run + and workflow_run.workflow + and workflow_run.workflow.organization_id == organization_id + ): + workflow_ids_by_run_id[workflow_run_id] = workflow_run.workflow_id + + balance = float(account.get("cached_balance_credits") or 0.0) + total_debits = sum( + abs(float(entry.get("credits_delta") or 0.0)) + for entry in ledger_entries + if float(entry.get("credits_delta") or 0.0) < 0 + ) + if ledger.get("total_debits_credits") is not None: + total_debits = float(ledger["total_debits_credits"]) + + return MPSBillingCreditsResponse( + billing_version="v2", + total_credits_used=total_debits, + remaining_credits=balance, + total_quota=balance + total_debits, + account=MPSBillingAccountResponse( + id=int(account["id"]), + organization_id=int(account["organization_id"]), + billing_mode=str(account["billing_mode"]), + cached_balance_credits=balance, + currency=str(account.get("currency") or "USD"), + ), + ledger_entries=[ + MPSCreditLedgerEntryResponse( + id=int(entry["id"]), + entry_type=str(entry["entry_type"]), + origin=entry.get("origin"), + credits_delta=float(entry.get("credits_delta") or 0.0), + balance_after=float(entry.get("balance_after") or 0.0), + amount_minor=entry.get("amount_minor"), + amount_currency=entry.get("amount_currency"), + payment_order_id=entry.get("payment_order_id"), + metric_code=entry.get("metric_code"), + correlation_id=entry.get("correlation_id"), + aggregation_key=entry.get("aggregation_key"), + usage_event_id=_optional_int(entry.get("usage_event_id")), + workflow_run_id=_optional_int(entry.get("workflow_run_id")), + workflow_id=workflow_ids_by_run_id.get( + _optional_int(entry.get("workflow_run_id")) + ) + if entry.get("workflow_run_id") is not None + else None, + billable_quantity=float(entry["billable_quantity"]) + if entry.get("billable_quantity") is not None + else None, + quantity_unit=entry.get("quantity_unit"), + metadata=entry.get("metadata") or {}, + created_at=str(entry["created_at"]), + ) + for entry in ledger_entries + ], + total_count=total_count, + page=int(ledger.get("page") or page), + limit=response_limit, + total_pages=total_pages, + ) + except HTTPException: + raise + except Exception as exc: + logger.error(f"Failed to fetch billing credits: {exc}") + raise HTTPException(status_code=500, detail=str(exc)) + + +@router.post( + "/usage/mps-credits/purchase-url", + response_model=MPSCreditPurchaseUrlResponse, +) +async def create_mps_credit_purchase_url( + user: UserModel = Depends(get_user_with_selected_organization), +): + """Create a checkout URL for organizations using Dograh-managed MPS v2.""" + if DEPLOYMENT_MODE == "oss": + raise HTTPException( + status_code=404, + detail="Credit purchases are not available in OSS mode", + ) + + organization_id = user.selected_organization_id + assert organization_id is not None + account_status = await _get_mps_billing_account_status(user, organization_id) + if not _is_mps_billing_v2(account_status): + raise HTTPException( + status_code=403, + detail=( + "Credit purchases are available only for organizations using billing v2" + ), + ) + + try: + session = await mps_service_key_client.create_credit_purchase_url( + organization_id=organization_id, + created_by=str(user.provider_id), + return_url=f"{UI_APP_URL.rstrip('/')}/billing", + billing_details={ + "source": "dograh_billing", + "dograh_user_id": str(user.id), + "dograh_provider_id": str(user.provider_id), + }, + ) + except Exception as exc: + logger.error(f"Failed to create MPS credit purchase URL: {exc}") + raise HTTPException( + status_code=502, + detail="Failed to create credit purchase URL", + ) + + checkout_url = session.get("checkout_url") + if not checkout_url: + logger.error(f"MPS checkout session response missing checkout_url: {session}") + raise HTTPException( + status_code=502, + detail="MPS checkout session response missing checkout_url", + ) + return MPSCreditPurchaseUrlResponse(checkout_url=checkout_url) + + FILTERS_DESCRIPTION = """\ JSON-encoded array of filter objects. Each object has the shape: diff --git a/api/routes/public_agent.py b/api/routes/public_agent.py index 93d3f1e8..64706fb5 100644 --- a/api/routes/public_agent.py +++ b/api/routes/public_agent.py @@ -14,7 +14,7 @@ from pydantic import BaseModel from api.db import db_client from api.enums import TriggerState, WorkflowStatus -from api.services.quota_service import check_dograh_quota_by_user_id +from api.services.quota_service import authorize_workflow_run_start from api.services.telephony.factory import ( get_default_telephony_provider, get_telephony_provider_by_id, @@ -179,14 +179,6 @@ async def _execute_resolved_target( """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( - execution_user_id, - workflow_id=target.workflow.id, - ) - if not quota_result.has_quota: - raise HTTPException(status_code=402, detail=quota_result.error_message) - # 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: @@ -268,6 +260,15 @@ async def _execute_resolved_target( f"to phone number {request.phone_number}" ) + # Check Dograh quota after the run exists so hosted v2 can mint and store + # the MPS correlation id before the provider starts the call. + quota_result = await authorize_workflow_run_start( + workflow_id=target.workflow.id, + workflow_run_id=workflow_run.id, + ) + if not quota_result.has_quota: + raise HTTPException(status_code=402, detail=quota_result.error_message) + # 9. Construct webhook URL for telephony provider callback backend_endpoint, _ = await get_backend_endpoints() webhook_endpoint = provider.WEBHOOK_ENDPOINT diff --git a/api/routes/public_embed.py b/api/routes/public_embed.py index 058def54..e8a699a7 100644 --- a/api/routes/public_embed.py +++ b/api/routes/public_embed.py @@ -7,6 +7,7 @@ They handle CORS, domain validation, and session management for embedded workflo import secrets from datetime import UTC, datetime, timedelta from typing import Optional +from urllib.parse import urlsplit from fastapi import ( APIRouter, @@ -16,6 +17,8 @@ from fastapi import ( ) from loguru import logger from pydantic import BaseModel +from starlette.datastructures import Headers +from starlette.types import ASGIApp, Receive, Scope, Send from api.db import db_client from api.enums import WorkflowRunMode @@ -27,6 +30,9 @@ from api.routes.turn_credentials import ( router = APIRouter(prefix="/public/embed") +EMBED_CORS_ALLOW_HEADERS = "Content-Type, Origin" +EMBED_CORS_MAX_AGE = "86400" + class InitEmbedRequest(BaseModel): """Request model for initializing an embed session""" @@ -70,11 +76,9 @@ def validate_origin(origin: str, allowed_domains: list) -> bool: # If no domains specified, allow all origins return True - # Extract domain from origin (remove protocol) - if "://" in origin: - domain = origin.split("://")[1].split("/")[0].split(":")[0] - else: - domain = origin + domain, origin_port = _parse_origin_host_port(origin) + if not domain: + return False # Normalize domain for www matching def normalize_www(d: str) -> tuple[str, str]: @@ -87,16 +91,23 @@ def validate_origin(origin: str, allowed_domains: list) -> bool: domain_variants = normalize_www(domain) for allowed in allowed_domains: + allowed = str(allowed).strip().lower() if allowed == "*": return True - elif allowed.startswith("*."): + allowed_domain, allowed_port = _parse_origin_host_port(allowed) + if not allowed_domain: + continue + if allowed_port is not None and allowed_port != origin_port: + continue + + if allowed_domain.startswith("*."): # Wildcard subdomain matching - base_domain = allowed[2:] + base_domain = allowed_domain[2:] if domain == base_domain or domain.endswith("." + base_domain): return True else: # Check both www and non-www versions - allowed_variants = normalize_www(allowed) + allowed_variants = normalize_www(allowed_domain) # If any variant of domain matches any variant of allowed, it's valid if any( dv in allowed_variants or av in domain_variants @@ -108,6 +119,24 @@ def validate_origin(origin: str, allowed_domains: list) -> bool: return False +def _parse_origin_host_port(value: str) -> tuple[str, str | None]: + candidate = value.strip().lower() + if not candidate: + return "", None + + if "://" not in candidate and not candidate.startswith("//"): + candidate = f"//{candidate}" + + parsed = urlsplit(candidate) + try: + parsed_port = parsed.port + except ValueError: + parsed_port = None + + port = str(parsed_port) if parsed_port is not None else None + return (parsed.hostname or "").rstrip("."), port + + def generate_session_token() -> str: """Generate a cryptographically secure session token""" return f"emb_session_{secrets.token_urlsafe(32)}" @@ -121,8 +150,120 @@ def get_request_origin(request: Request) -> str: return origin +def _cors_response(origin: str, methods: str) -> Response: + return Response( + headers={ + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": methods, + "Access-Control-Allow-Headers": EMBED_CORS_ALLOW_HEADERS, + "Access-Control-Max-Age": EMBED_CORS_MAX_AGE, + "Vary": "Origin", + } + ) + + +def _allow_embed_origin(response: Response, origin: str) -> None: + response.headers["Access-Control-Allow-Origin"] = origin + vary = response.headers.get("Vary") + if not vary: + response.headers["Vary"] = "Origin" + return + + vary_values = {value.strip().lower() for value in vary.split(",")} + if "origin" not in vary_values: + response.headers["Vary"] = f"{vary}, Origin" + + +async def _config_preflight_response(token: str, origin: str) -> Response: + embed_token = await db_client.get_embed_token_by_token(token) + if not embed_token or not embed_token.is_active: + return Response(status_code=403) + + if not validate_origin(origin, embed_token.allowed_domains or []): + return Response(status_code=403) + + return _cors_response(origin, "GET, OPTIONS") + + +async def _turn_credentials_preflight_response( + session_token: str, origin: str +) -> Response: + embed_session = await db_client.get_embed_session_by_token(session_token) + if not embed_session: + return Response(status_code=403) + + if embed_session.expires_at and embed_session.expires_at < datetime.now(UTC): + return Response(status_code=403) + + embed_token = await db_client.get_embed_token_by_id(embed_session.embed_token_id) + if not embed_token: + return Response(status_code=403) + + if not validate_origin(origin, embed_token.allowed_domains or []): + return Response(status_code=403) + + return _cors_response(origin, "GET, OPTIONS") + + +async def build_public_embed_preflight_response( + path: str, origin: str, requested_method: str, api_prefix: str = "/api/v1" +) -> Response | None: + """Handle embed preflights before global CORSMiddleware rejects external sites.""" + public_embed_prefix = f"{api_prefix.rstrip('/')}/public/embed" + + if path == f"{public_embed_prefix}/init": + if requested_method.upper() != "POST": + return Response(status_code=405) + return _cors_response(origin, "POST, OPTIONS") + + config_prefix = f"{public_embed_prefix}/config/" + if path.startswith(config_prefix): + if requested_method.upper() != "GET": + return Response(status_code=405) + token = path[len(config_prefix) :].split("/", 1)[0] + return await _config_preflight_response(token, origin) + + turn_credentials_prefix = f"{public_embed_prefix}/turn-credentials/" + if path.startswith(turn_credentials_prefix): + if requested_method.upper() != "GET": + return Response(status_code=405) + session_token = path[len(turn_credentials_prefix) :].split("/", 1)[0] + return await _turn_credentials_preflight_response(session_token, origin) + + return None + + +class PublicEmbedCORSMiddleware: + """Allow token-gated embed CORS before global SaaS CORS rejects preflights.""" + + def __init__(self, app: ASGIApp, api_prefix: str = "/api/v1"): + self.app = app + self.api_prefix = api_prefix + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or scope.get("method") != "OPTIONS": + await self.app(scope, receive, send) + return + + headers = Headers(scope=scope) + origin = headers.get("origin") + requested_method = headers.get("access-control-request-method") + + if origin and requested_method: + response = await build_public_embed_preflight_response( + scope.get("path", ""), origin, requested_method, self.api_prefix + ) + if response is not None: + await response(scope, receive, send) + return + + await self.app(scope, receive, send) + + @router.post("/init", response_model=InitEmbedResponse) -async def initialize_embed_session(request: Request, init_request: InitEmbedRequest): +async def initialize_embed_session( + request: Request, init_request: InitEmbedRequest, response: Response +): """Initialize an embed session with token validation and domain checking. This endpoint: @@ -158,6 +299,9 @@ async def initialize_embed_session(request: Request, init_request: InitEmbedRequ ) raise HTTPException(status_code=403, detail=f"Domain not allowed: {origin}") + if origin: + _allow_embed_origin(response, origin) + # Create workflow run try: workflow_run = await db_client.create_workflow_run( @@ -204,8 +348,19 @@ async def initialize_embed_session(request: Request, init_request: InitEmbedRequ ) +@router.options("/config/{token}") +async def options_embed_config(token: str, request: Request): + """Fallback OPTIONS handler for the embed config endpoint. + + Browser preflights include Access-Control-Request-Method and are handled by + PublicEmbedCORSMiddleware before global CORS. This keeps non-conformant + OPTIONS requests on the same validation path. + """ + return await _config_preflight_response(token, request.headers.get("origin", "")) + + @router.get("/config/{token}", response_model=EmbedConfigResponse) -async def get_embed_config(token: str, request: Request): +async def get_embed_config(token: str, request: Request, response: Response): """Get embed configuration without creating a session. This endpoint is used to fetch widget configuration for display purposes @@ -226,6 +381,11 @@ async def get_embed_config(token: str, request: Request): if not validate_origin(origin, embed_token.allowed_domains or []): raise HTTPException(status_code=403, detail=f"Domain not allowed: {origin}") + # Set CORS header explicitly; the global CORSMiddleware covers only + # first-party origins; this endpoint is fetched by external embed sites. + if origin: + _allow_embed_origin(response, origin) + # Extract settings with defaults settings = embed_token.settings or {} @@ -243,24 +403,20 @@ async def get_embed_config(token: str, request: Request): @router.options("/init") async def options_init(request: Request): - """Handle CORS preflight for init endpoint""" + """Fallback OPTIONS handler for init endpoint.""" + # Browser preflights are handled by PublicEmbedCORSMiddleware before global CORS. # For init endpoint, we need to check the token in the request body # But OPTIONS requests don't have body, so we'll be permissive # The actual validation happens in the POST request origin = request.headers.get("origin", "*") - return Response( - headers={ - "Access-Control-Allow-Origin": origin, - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Origin", - "Access-Control-Max-Age": "86400", - } - ) + return _cors_response(origin, "POST, OPTIONS") @router.get("/turn-credentials/{session_token}", response_model=TurnCredentialsResponse) -async def get_public_turn_credentials(session_token: str, request: Request): +async def get_public_turn_credentials( + session_token: str, request: Request, response: Response +): """Get TURN credentials for an embed session. This endpoint allows embedded widgets to obtain TURN server credentials @@ -295,6 +451,9 @@ async def get_public_turn_credentials(session_token: str, request: Request): ) raise HTTPException(status_code=403, detail=f"Domain not allowed: {origin}") + if origin: + _allow_embed_origin(response, origin) + # Check if TURN is configured if not TURN_SECRET: raise HTTPException( @@ -316,63 +475,8 @@ async def get_public_turn_credentials(session_token: str, request: Request): @router.options("/turn-credentials/{session_token}") async def options_turn_credentials(request: Request, session_token: str): - """Handle CORS preflight for TURN credentials endpoint""" - origin = request.headers.get("origin", "*") - - # Try to validate the session token and get allowed domains - allowed_origin = origin - try: - embed_session = await db_client.get_embed_session_by_token(session_token) - if embed_session: - embed_token = await db_client.get_embed_token_by_id( - embed_session.embed_token_id - ) - if embed_token: - # Check if origin is in allowed domains (empty means allow all) - if validate_origin(origin, embed_token.allowed_domains or []): - allowed_origin = origin - else: - allowed_origin = "" - except Exception: - # On error, be permissive for OPTIONS - pass - - return Response( - headers={ - "Access-Control-Allow-Origin": allowed_origin, - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - "Access-Control-Max-Age": "86400", - } - ) - - -@router.options("/config/{token}") -async def options_config(request: Request, token: str): - """Handle CORS preflight for config endpoint""" - # Get origin header - origin = request.headers.get("origin", "*") - - # Try to validate the token and get allowed domains - allowed_origin = origin - try: - embed_token = await db_client.get_embed_token_by_token(token) - if embed_token and embed_token.is_active: - # Check if origin is in allowed domains - if validate_origin(origin, embed_token.allowed_domains or []): - allowed_origin = origin - else: - # If not allowed, don't include the origin - allowed_origin = "" - except Exception: - # On error, be permissive for OPTIONS - pass - - return Response( - headers={ - "Access-Control-Allow-Origin": allowed_origin, - "Access-Control-Allow-Methods": "GET, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", - "Access-Control-Max-Age": "86400", - } + """Fallback OPTIONS handler for TURN credentials endpoint.""" + # Browser preflights are handled by PublicEmbedCORSMiddleware before global CORS. + return await _turn_credentials_preflight_response( + session_token, request.headers.get("origin", "") ) diff --git a/api/routes/telephony.py b/api/routes/telephony.py index 86bbbc02..c9ffd0df 100644 --- a/api/routes/telephony.py +++ b/api/routes/telephony.py @@ -25,7 +25,7 @@ from api.enums import CallType, WorkflowRunState from api.errors.telephony_errors import TelephonyError from api.sdk_expose import sdk_expose from api.services.auth.depends import get_user -from api.services.quota_service import check_dograh_quota_by_user_id +from api.services.quota_service import authorize_workflow_run_start from api.services.telephony.call_transfer_manager import get_call_transfer_manager from api.services.telephony.factory import ( get_all_telephony_providers, @@ -53,7 +53,7 @@ class InitiateCallRequest(BaseModel): workflow_run_id: int | None = None phone_number: str | None = None # Optional explicit telephony config to use for the test call. If omitted, - # falls back to the user's per-user default (when set), then the org default. + # falls back to the org default. telephony_configuration_id: int | None = None # Optional caller-ID phone number to dial out from. Must belong to the # resolved telephony configuration; otherwise the provider picks one. @@ -82,7 +82,12 @@ async def initiate_call( """Initiate a call using the configured telephony provider from web browser. This is supposed to be a test call method for the draft version of the agent.""" - user_configuration = await db_client.get_user_configurations(user.id) + from api.services.organization_preferences import get_organization_preferences + + preferences = await get_organization_preferences( + user.selected_organization_id, + db=db_client, + ) # Resolve which telephony config to use: explicit request value, otherwise # the org's default outbound config. @@ -116,13 +121,12 @@ async def initiate_call( detail="telephony_not_configured", ) - phone_number = request.phone_number or user_configuration.test_phone_number + phone_number = request.phone_number or preferences.test_phone_number if not phone_number: raise HTTPException( status_code=400, - detail="Phone number must be provided in request or set in user " - "configuration", + detail="Phone number must be provided in request or set in organization preferences", ) workflow = await db_client.get_workflow( @@ -132,14 +136,6 @@ async def initiate_call( raise HTTPException(status_code=404, detail="Workflow not found") execution_user_id = _get_execution_user_id(workflow) - # Check Dograh quota before initiating the call (apply per-workflow - # model_overrides so the keys we will actually use are the ones checked). - quota_result = await check_dograh_quota_by_user_id( - execution_user_id, workflow_id=workflow.id - ) - if not quota_result.has_quota: - raise HTTPException(status_code=402, detail=quota_result.error_message) - # Determine the workflow run mode based on provider type workflow_run_mode = provider.PROVIDER_NAME @@ -182,6 +178,16 @@ async def initiate_call( ) workflow_run_name = workflow_run.name + # Check Dograh quota after the run exists so hosted v2 can mint and store + # the MPS correlation id before initiating the call. + quota_result = await authorize_workflow_run_start( + workflow_id=workflow.id, + workflow_run_id=workflow_run_id, + actor_user=user, + ) + if not quota_result.has_quota: + raise HTTPException(status_code=402, detail=quota_result.error_message) + # Construct webhook URL based on provider type backend_endpoint, _ = await get_backend_endpoints() @@ -735,19 +741,8 @@ async def handle_inbound_run(request: Request): TelephonyError.SIGNATURE_VALIDATION_FAILED ) - # 4. Quota check (use the workflow's model_overrides if set). - quota_result = await check_dograh_quota_by_user_id( - user_id, workflow_id=workflow_id - ) - if not quota_result.has_quota: - logger.warning( - f"User {user_id} has exceeded quota: {quota_result.error_message}" - ) - return provider_class.generate_validation_error_response( - TelephonyError.QUOTA_EXCEEDED - ) - - # 5. Create workflow run + return provider-shaped response. + # 5. Create workflow run + authorize quota before returning provider + # stream instructions. workflow_run_id = await _create_inbound_workflow_run( workflow_id, user_id, @@ -756,6 +751,17 @@ async def handle_inbound_run(request: Request): telephony_configuration_id=telephony_configuration_id, from_phone_number_id=phone_row.id, ) + quota_result = await authorize_workflow_run_start( + workflow_id=workflow_id, + workflow_run_id=workflow_run_id, + ) + if not quota_result.has_quota: + logger.warning( + f"User {user_id} has exceeded quota: {quota_result.error_message}" + ) + return provider_class.generate_validation_error_response( + TelephonyError.QUOTA_EXCEEDED + ) backend_endpoint, wss_backend_endpoint = await get_backend_endpoints() websocket_url = ( @@ -870,20 +876,8 @@ async def handle_inbound_telephony( logger.error(f"Request validation failed: {error_type}") return provider_class.generate_validation_error_response(error_type) - # Check quota before processing (apply per-workflow model_overrides). + # Create workflow run. user_id = workflow_context["user_id"] - quota_result = await check_dograh_quota_by_user_id( - user_id, workflow_id=workflow_id - ) - if not quota_result.has_quota: - logger.warning( - f"User {user_id} has exceeded quota for inbound calls: {quota_result.error_message}" - ) - return provider_class.generate_validation_error_response( - TelephonyError.QUOTA_EXCEEDED - ) - - # Create workflow run workflow_run_id = await _create_inbound_workflow_run( workflow_id, workflow_context["user_id"], @@ -892,6 +886,17 @@ async def handle_inbound_telephony( telephony_configuration_id=workflow_context["telephony_configuration_id"], from_phone_number_id=workflow_context.get("from_phone_number_id"), ) + quota_result = await authorize_workflow_run_start( + workflow_id=workflow_id, + workflow_run_id=workflow_run_id, + ) + if not quota_result.has_quota: + logger.warning( + f"User {user_id} has exceeded quota for inbound calls: {quota_result.error_message}" + ) + return provider_class.generate_validation_error_response( + TelephonyError.QUOTA_EXCEEDED + ) # Generate response URLs backend_endpoint, wss_backend_endpoint = await get_backend_endpoints() diff --git a/api/routes/user.py b/api/routes/user.py index a3c25da2..5ed8b45c 100644 --- a/api/routes/user.py +++ b/api/routes/user.py @@ -10,6 +10,9 @@ from api.db.models import ( UserModel, ) from api.services.auth.depends import get_user +from api.services.configuration.ai_model_configuration import ( + get_resolved_ai_model_configuration, +) from api.services.configuration.check_validity import ( APIKeyStatusResponse, UserConfigurationValidator, @@ -19,6 +22,10 @@ from api.services.configuration.masking import check_for_masked_keys, mask_user_ from api.services.configuration.merge import merge_user_configurations from api.services.configuration.registry import REGISTRY, ServiceType from api.services.mps_service_key_client import mps_service_key_client +from api.services.organization_preferences import ( + get_organization_preferences, + upsert_organization_preferences, +) router = APIRouter(prefix="/user") @@ -94,8 +101,17 @@ class UserConfigurationRequestResponseSchema(BaseModel): async def get_user_configurations( user: UserModel = Depends(get_user), ) -> UserConfigurationRequestResponseSchema: - user_configurations = await db_client.get_user_configurations(user.id) - masked_config = mask_user_config(user_configurations) + resolved_config = await get_resolved_ai_model_configuration( + user_id=user.id, + organization_id=user.selected_organization_id, + ) + masked_config = mask_user_config(resolved_config.effective) + if user.selected_organization_id: + preferences = await get_organization_preferences(user.selected_organization_id) + if preferences.test_phone_number is not None: + masked_config["test_phone_number"] = preferences.test_phone_number + if preferences.timezone is not None: + masked_config["timezone"] = preferences.timezone # Add organization pricing info if available if user.selected_organization_id: @@ -121,34 +137,61 @@ async def update_user_configurations( # Remove organization_pricing from incoming dict as it's read-only incoming_dict.pop("organization_pricing", None) + preferences_update = { + key: incoming_dict.pop(key) + for key in ("test_phone_number", "timezone") + if key in incoming_dict + } - # Merge via helper - try: - user_configurations = merge_user_configurations(existing_config, incoming_dict) - except ValidationError as e: - raise HTTPException(status_code=422, detail=str(e)) + if incoming_dict: + # Merge via helper + try: + user_configurations = merge_user_configurations( + existing_config, incoming_dict + ) + except ValidationError as e: + raise HTTPException(status_code=422, detail=str(e)) - try: - check_for_masked_keys(user_configurations) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) + try: + check_for_masked_keys(user_configurations) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) - try: - validator = UserConfigurationValidator() - await validator.validate( - user_configurations, - organization_id=user.selected_organization_id, - created_by=user.provider_id, + try: + validator = UserConfigurationValidator() + await validator.validate( + user_configurations, + organization_id=user.selected_organization_id, + created_by=user.provider_id, + ) + except ValueError as e: + raise HTTPException(status_code=422, detail=e.args[0]) + + user_configurations = await db_client.update_user_configuration( + user.id, user_configurations ) - except ValueError as e: - raise HTTPException(status_code=422, detail=e.args[0]) + else: + user_configurations = existing_config - user_configurations = await db_client.update_user_configuration( - user.id, user_configurations - ) + if user.selected_organization_id and preferences_update: + preferences = await get_organization_preferences(user.selected_organization_id) + if "test_phone_number" in preferences_update: + preferences.test_phone_number = preferences_update["test_phone_number"] + if "timezone" in preferences_update: + preferences.timezone = preferences_update["timezone"] + await upsert_organization_preferences( + user.selected_organization_id, + preferences, + ) # Return masked version of updated config masked_config = mask_user_config(user_configurations) + if user.selected_organization_id: + preferences = await get_organization_preferences(user.selected_organization_id) + if preferences.test_phone_number is not None: + masked_config["test_phone_number"] = preferences.test_phone_number + if preferences.timezone is not None: + masked_config["timezone"] = preferences.timezone # Add organization pricing info if available if user.selected_organization_id: @@ -168,7 +211,11 @@ async def validate_user_configurations( validity_ttl_seconds: int = Query(default=60, ge=0, le=86400), user: UserModel = Depends(get_user), ) -> APIKeyStatusResponse: - configurations = await db_client.get_user_configurations(user.id) + resolved_config = await get_resolved_ai_model_configuration( + user_id=user.id, + organization_id=user.selected_organization_id, + ) + configurations = resolved_config.effective if ( configurations.last_validated_at diff --git a/api/routes/webrtc_signaling.py b/api/routes/webrtc_signaling.py index f7b4eeb3..ca8d3038 100644 --- a/api/routes/webrtc_signaling.py +++ b/api/routes/webrtc_signaling.py @@ -45,7 +45,7 @@ from api.services.pipecat.ws_sender_registry import ( register_ws_sender, unregister_ws_sender, ) -from api.services.quota_service import check_dograh_quota +from api.services.quota_service import authorize_workflow_run_start router = APIRouter(prefix="/ws") @@ -329,7 +329,11 @@ class SignalingManager: # Check Dograh quota before initiating the call (apply per-workflow # model_overrides so we evaluate the keys this workflow will use). - quota_result = await check_dograh_quota(user, workflow_id=workflow_id) + quota_result = await authorize_workflow_run_start( + workflow_id=workflow_id, + workflow_run_id=workflow_run_id, + actor_user=user, + ) if not quota_result.has_quota: # Send error response for quota issues await ws.send_json( diff --git a/api/routes/workflow.py b/api/routes/workflow.py index 7adf0864..06e5fdf9 100644 --- a/api/routes/workflow.py +++ b/api/routes/workflow.py @@ -16,9 +16,18 @@ from api.db.agent_trigger_client import TriggerPathConflictError from api.db.models import UserModel from api.db.workflow_template_client import WorkflowTemplateClient from api.enums import CallType, PostHogEvent, StorageBackend +from api.schemas.ai_model_configuration import OrganizationAIModelConfigurationV2 from api.schemas.workflow import WorkflowRunResponseSchema from api.sdk_expose import sdk_expose from api.services.auth.depends import get_user +from api.services.configuration.ai_model_configuration import ( + WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY, + check_for_masked_keys_in_ai_model_configuration_v2, + compile_ai_model_configuration_v2, + convert_legacy_ai_model_configuration_to_v2, + get_resolved_ai_model_configuration, + merge_ai_model_configuration_v2_secrets, +) from api.services.configuration.check_validity import UserConfigurationValidator from api.services.configuration.masking import ( mask_workflow_configurations, @@ -32,12 +41,15 @@ from api.services.configuration.resolve import ( ) from api.services.mps_service_key_client import mps_service_key_client from api.services.posthog_client import capture_event -from api.services.pricing.run_usage_response import format_public_usage_info from api.services.reports import generate_workflow_report_csv from api.services.storage import storage_fs from api.services.workflow.dto import ReactFlowDTO, sanitize_workflow_definition from api.services.workflow.duplicate import duplicate_workflow from api.services.workflow.errors import ItemKind, WorkflowError +from api.services.workflow.run_usage_response import ( + format_public_cost_info, + format_public_usage_info, +) from api.services.workflow.trigger_paths import ( TriggerPathIssue, ensure_trigger_paths, @@ -955,12 +967,74 @@ async def update_workflow( existing_def, ) - # Validate model_overrides: resolve onto global config, then - # run the same validator used by the user-configurations endpoint. - # Also stamp the current global API key into the override so the override - # remains functional if the global config later switches to a different provider. + # Validate model overrides. v2 uses a complete workflow-level model + # configuration; legacy v1 uses partial service overlays. workflow_configurations = request.workflow_configurations - if workflow_configurations and workflow_configurations.get("model_overrides"): + if workflow_configurations and workflow_configurations.get( + WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY + ): + existing_workflow = await db_client.get_workflow( + workflow_id, organization_id=user.selected_organization_id + ) + if existing_workflow is None: + raise HTTPException( + status_code=404, detail=f"Workflow with id {workflow_id} not found" + ) + existing_draft = await db_client.get_draft_version(workflow_id) + existing_configs = ( + existing_draft.workflow_configurations + if existing_draft + else existing_workflow.released_definition.workflow_configurations + ) + existing_v2_override = (existing_configs or {}).get( + WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY + ) + try: + incoming_v2_override = ( + OrganizationAIModelConfigurationV2.model_validate( + workflow_configurations[ + WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY + ] + ) + ) + existing_v2_override_config = ( + OrganizationAIModelConfigurationV2.model_validate( + existing_v2_override + ) + if existing_v2_override + else None + ) + v2_override = merge_ai_model_configuration_v2_secrets( + incoming_v2_override, + existing_v2_override_config, + ) + if existing_v2_override_config is None: + resolved_config = await get_resolved_ai_model_configuration( + user_id=user.id, + organization_id=user.selected_organization_id, + ) + v2_override = merge_ai_model_configuration_v2_secrets( + v2_override, + resolved_config.organization_configuration, + ) + check_for_masked_keys_in_ai_model_configuration_v2(v2_override) + effective = compile_ai_model_configuration_v2(v2_override) + await UserConfigurationValidator().validate( + effective, + organization_id=user.selected_organization_id, + created_by=user.provider_id, + ) + except (ValidationError, ValueError) as e: + raise HTTPException(status_code=422, detail=str(e)) + workflow_configurations = { + **workflow_configurations, + WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY: v2_override.model_dump( + mode="json", + exclude_none=True, + ), + } + workflow_configurations.pop("model_overrides", None) + elif workflow_configurations and workflow_configurations.get("model_overrides"): existing_workflow = await db_client.get_workflow( workflow_id, organization_id=user.selected_organization_id ) @@ -978,24 +1052,48 @@ async def update_workflow( workflow_configurations, existing_configs, ) - user_config = await db_client.get_user_configurations(user.id) + resolved_config = await get_resolved_ai_model_configuration( + user_id=user.id, + organization_id=user.selected_organization_id, + ) + effective_config = resolved_config.effective try: enriched_overrides = enrich_overrides_with_api_keys( workflow_configurations["model_overrides"], - user_config, + effective_config, ) - effective = resolve_effective_config(user_config, enriched_overrides) - await UserConfigurationValidator().validate( - effective, - organization_id=user.selected_organization_id, - created_by=user.provider_id, + effective = resolve_effective_config( + effective_config, enriched_overrides ) + if resolved_config.source == "organization_v2": + v2_override = convert_legacy_ai_model_configuration_to_v2(effective) + await UserConfigurationValidator().validate( + compile_ai_model_configuration_v2(v2_override), + organization_id=user.selected_organization_id, + created_by=user.provider_id, + ) + else: + await UserConfigurationValidator().validate( + effective, + organization_id=user.selected_organization_id, + created_by=user.provider_id, + ) except ValueError as e: raise HTTPException(status_code=422, detail=str(e)) - workflow_configurations = { - **workflow_configurations, - "model_overrides": enriched_overrides, - } + if resolved_config.source == "organization_v2": + workflow_configurations = { + **workflow_configurations, + WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY: v2_override.model_dump( + mode="json", + exclude_none=True, + ), + } + workflow_configurations.pop("model_overrides", None) + else: + workflow_configurations = { + **workflow_configurations, + "model_overrides": enriched_overrides, + } # Reject upfront if any new trigger path collides with another # workflow's trigger — keeps the workflow record from @@ -1171,22 +1269,7 @@ async def get_workflow_run( "transcript_public_url": artifact_url(public_access_token, "transcript"), "recording_public_url": artifact_url(public_access_token, "recording"), "public_access_token": public_access_token, - "cost_info": { - "dograh_token_usage": ( - run.cost_info.get("dograh_token_usage") - if run.cost_info and "dograh_token_usage" in run.cost_info - else round(float(run.cost_info.get("total_cost_usd", 0)) * 100, 2) - if run.cost_info and "total_cost_usd" in run.cost_info - else 0 - ), - "call_duration_seconds": int( - round(run.cost_info.get("call_duration_seconds")) - ) - if run.cost_info and run.cost_info.get("call_duration_seconds") is not None - else None, - } - if run.cost_info - else None, + "cost_info": format_public_cost_info(run.cost_info, run.usage_info), "usage_info": format_public_usage_info(run.usage_info), "created_at": run.created_at, "definition_id": run.definition_id, diff --git a/api/routes/workflow_text_chat.py b/api/routes/workflow_text_chat.py index 71d1b909..47254330 100644 --- a/api/routes/workflow_text_chat.py +++ b/api/routes/workflow_text_chat.py @@ -9,8 +9,8 @@ from pydantic import BaseModel, Field from api.db import db_client from api.db.models import UserModel, WorkflowRunTextSessionModel from api.enums import WorkflowRunMode -from api.services.auth.depends import get_user -from api.services.quota_service import check_dograh_quota +from api.services.auth.depends import get_user_with_selected_organization +from api.services.quota_service import authorize_workflow_run_start from api.services.workflow.text_chat_session_service import ( TextChatPendingTurnLostError, TextChatSessionExecutionError, @@ -96,14 +96,16 @@ def _revision_conflict_detail(e: Any) -> dict[str, Any]: } -def _require_selected_organization_id(user: UserModel) -> int: - if user.selected_organization_id is None: - raise HTTPException(status_code=403, detail="Organization context is required") - return user.selected_organization_id - - -async def _ensure_text_chat_quota(user: UserModel, workflow_id: int) -> None: - quota_result = await check_dograh_quota(user, workflow_id=workflow_id) +async def _ensure_text_chat_quota( + user: UserModel, + workflow_id: int, + workflow_run_id: int, +) -> None: + quota_result = await authorize_workflow_run_start( + workflow_id=workflow_id, + workflow_run_id=workflow_run_id, + actor_user=user, + ) if not quota_result.has_quota: raise HTTPException(status_code=402, detail=quota_result.error_message) @@ -114,9 +116,8 @@ async def _load_text_session_or_404( user: UserModel, ) -> WorkflowRunTextSessionModel: set_current_run_id(run_id) - organization_id = _require_selected_organization_id(user) text_session = await db_client.get_workflow_run_text_session( - run_id, organization_id=organization_id + run_id, organization_id=user.selected_organization_id ) if not text_session or not text_session.workflow_run: raise HTTPException(status_code=404, detail="Text chat session not found") @@ -158,11 +159,8 @@ async def _execute_pending_turn_response( async def create_text_chat_session( workflow_id: int, request: CreateTextChatSessionRequest, - user: UserModel = Depends(get_user), + user: UserModel = Depends(get_user_with_selected_organization), ) -> WorkflowRunTextSessionResponse: - organization_id = _require_selected_organization_id(user) - await _ensure_text_chat_quota(user, workflow_id) - session_name = request.name or f"WR-TEXT-{uuid4().hex[:6].upper()}" try: workflow_run = await db_client.create_workflow_run( @@ -172,12 +170,13 @@ async def create_text_chat_session( user_id=user.id, initial_context=request.initial_context, use_draft=True, - organization_id=organization_id, + organization_id=user.selected_organization_id, ) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) set_current_run_id(workflow_run.id) + await _ensure_text_chat_quota(user, workflow_id, workflow_run.id) annotations = { "tester": { @@ -220,7 +219,7 @@ async def create_text_chat_session( async def get_text_chat_session( workflow_id: int, run_id: int, - user: UserModel = Depends(get_user), + user: UserModel = Depends(get_user_with_selected_organization), ) -> WorkflowRunTextSessionResponse: text_session = await _load_text_session_or_404(workflow_id, run_id, user) return _build_response(text_session) @@ -234,10 +233,10 @@ async def append_text_chat_message( workflow_id: int, run_id: int, request: AppendTextChatMessageRequest, - user: UserModel = Depends(get_user), + user: UserModel = Depends(get_user_with_selected_organization), ) -> WorkflowRunTextSessionResponse: text_session = await _load_text_session_or_404(workflow_id, run_id, user) - await _ensure_text_chat_quota(user, workflow_id) + await _ensure_text_chat_quota(user, workflow_id, run_id) try: text_session = await append_text_chat_user_message( @@ -264,7 +263,7 @@ async def rewind_text_chat_session( workflow_id: int, run_id: int, request: RewindTextChatSessionRequest, - user: UserModel = Depends(get_user), + user: UserModel = Depends(get_user_with_selected_organization), ) -> WorkflowRunTextSessionResponse: text_session = await _load_text_session_or_404(workflow_id, run_id, user) try: diff --git a/api/schemas/ai_model_configuration.py b/api/schemas/ai_model_configuration.py new file mode 100644 index 00000000..594c3288 --- /dev/null +++ b/api/schemas/ai_model_configuration.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field, model_validator + +from api.services.configuration.registry import ( + DograhEmbeddingsConfiguration, + DograhLLMService, + DograhSTTService, + DograhTTSService, + EmbeddingsConfig, + LLMConfig, + RealtimeConfig, + ServiceProviders, + STTConfig, + TTSConfig, +) + +DOGRAH_SPEED_OPTIONS: tuple[float, ...] = (0.8, 1.0, 1.2) +DOGRAH_DEFAULT_VOICE = "default" +DOGRAH_DEFAULT_LANGUAGE = "multi" + + +class EffectiveAIModelConfiguration(BaseModel): + llm: LLMConfig | None = None + stt: STTConfig | None = None + tts: TTSConfig | None = None + embeddings: EmbeddingsConfig | None = None + realtime: RealtimeConfig | None = None + is_realtime: bool = False + managed_service_version: int | None = None + test_phone_number: str | None = None + timezone: str | None = None + last_validated_at: datetime | None = None + # Post-signup onboarding gate: set once the user submits or skips the + # onboarding form, so it shows only once per user. + onboarding_completed_at: datetime | None = None + onboarding_skipped: bool = False + + @model_validator(mode="before") + @classmethod + def strip_incomplete_realtime_when_disabled(cls, data): + """Skip realtime validation when is_realtime is False and api_key is missing.""" + if isinstance(data, dict) and not data.get("is_realtime", False): + realtime = data.get("realtime") + if isinstance(realtime, dict) and not realtime.get("api_key"): + data.pop("realtime", None) + return data + + +class DograhManagedAIModelConfiguration(BaseModel): + api_key: str + voice: str = DOGRAH_DEFAULT_VOICE + speed: float = Field(default=1.0) + language: str = DOGRAH_DEFAULT_LANGUAGE + + @model_validator(mode="after") + def validate_speed(self): + if self.speed not in DOGRAH_SPEED_OPTIONS: + allowed = ", ".join(str(speed) for speed in DOGRAH_SPEED_OPTIONS) + raise ValueError(f"Dograh speed must be one of: {allowed}") + return self + + +class BYOKPipelineAIModelConfiguration(BaseModel): + llm: LLMConfig + tts: TTSConfig + stt: STTConfig + embeddings: EmbeddingsConfig | None = None + + @model_validator(mode="after") + def reject_dograh_providers(self): + _reject_dograh_provider("llm", self.llm) + _reject_dograh_provider("tts", self.tts) + _reject_dograh_provider("stt", self.stt) + _reject_dograh_provider("embeddings", self.embeddings) + return self + + +class BYOKRealtimeAIModelConfiguration(BaseModel): + realtime: RealtimeConfig + llm: LLMConfig + embeddings: EmbeddingsConfig | None = None + + @model_validator(mode="after") + def reject_dograh_providers(self): + _reject_dograh_provider("llm", self.llm) + _reject_dograh_provider("embeddings", self.embeddings) + return self + + +class BYOKAIModelConfiguration(BaseModel): + mode: Literal["pipeline", "realtime"] + pipeline: BYOKPipelineAIModelConfiguration | None = None + realtime: BYOKRealtimeAIModelConfiguration | None = None + + @model_validator(mode="after") + def validate_selected_mode(self): + if self.mode == "pipeline" and self.pipeline is None: + raise ValueError("byok.pipeline is required when byok.mode is pipeline") + if self.mode == "realtime" and self.realtime is None: + raise ValueError("byok.realtime is required when byok.mode is realtime") + return self + + +class OrganizationAIModelConfigurationV2(BaseModel): + version: Literal[2] = 2 + mode: Literal["dograh", "byok"] + dograh: DograhManagedAIModelConfiguration | None = None + byok: BYOKAIModelConfiguration | None = None + + @model_validator(mode="after") + def validate_selected_mode(self): + if self.mode == "dograh" and self.dograh is None: + raise ValueError("dograh configuration is required when mode is dograh") + if self.mode == "byok" and self.byok is None: + raise ValueError("byok configuration is required when mode is byok") + return self + + +class OrganizationAIModelConfigurationResponse(BaseModel): + configuration: dict | None + effective_configuration: dict + source: Literal["organization_v2", "legacy_user_v1", "empty"] + + +def compile_ai_model_configuration_v2( + configuration: OrganizationAIModelConfigurationV2, +) -> EffectiveAIModelConfiguration: + if configuration.mode == "dograh": + if configuration.dograh is None: + raise ValueError("dograh configuration is required") + return _compile_dograh_configuration(configuration.dograh) + + if configuration.byok is None: + raise ValueError("byok configuration is required") + if configuration.byok.mode == "pipeline": + if configuration.byok.pipeline is None: + raise ValueError("byok.pipeline is required") + pipeline = configuration.byok.pipeline + return EffectiveAIModelConfiguration( + llm=pipeline.llm, + tts=pipeline.tts, + stt=pipeline.stt, + embeddings=pipeline.embeddings, + is_realtime=False, + ) + + if configuration.byok.realtime is None: + raise ValueError("byok.realtime is required") + realtime = configuration.byok.realtime + return EffectiveAIModelConfiguration( + llm=realtime.llm, + realtime=realtime.realtime, + embeddings=realtime.embeddings, + is_realtime=True, + ) + + +def _compile_dograh_configuration( + configuration: DograhManagedAIModelConfiguration, +) -> EffectiveAIModelConfiguration: + return EffectiveAIModelConfiguration( + llm=DograhLLMService( + provider=ServiceProviders.DOGRAH, + api_key=configuration.api_key, + model="default", + ), + tts=DograhTTSService( + provider=ServiceProviders.DOGRAH, + api_key=configuration.api_key, + model="default", + voice=configuration.voice, + speed=configuration.speed, + ), + stt=DograhSTTService( + provider=ServiceProviders.DOGRAH, + api_key=configuration.api_key, + model="default", + language=configuration.language, + ), + embeddings=DograhEmbeddingsConfiguration( + provider=ServiceProviders.DOGRAH, + api_key=configuration.api_key, + model="default", + ), + is_realtime=False, + managed_service_version=2, + ) + + +def _reject_dograh_provider(section: str, service) -> None: + if service is None: + return + if getattr(service, "provider", None) == ServiceProviders.DOGRAH: + raise ValueError(f"BYOK {section} cannot use Dograh provider") diff --git a/api/schemas/organization_preferences.py b/api/schemas/organization_preferences.py new file mode 100644 index 00000000..ffc98404 --- /dev/null +++ b/api/schemas/organization_preferences.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class OrganizationPreferences(BaseModel): + test_phone_number: str | None = None + timezone: str | None = None diff --git a/api/schemas/user_configuration.py b/api/schemas/user_configuration.py deleted file mode 100644 index 15608760..00000000 --- a/api/schemas/user_configuration.py +++ /dev/null @@ -1,37 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, model_validator - -from api.services.configuration.registry import ( - EmbeddingsConfig, - LLMConfig, - RealtimeConfig, - STTConfig, - TTSConfig, -) - - -class UserConfiguration(BaseModel): - llm: LLMConfig | None = None - stt: STTConfig | None = None - tts: TTSConfig | None = None - embeddings: EmbeddingsConfig | None = None - realtime: RealtimeConfig | None = None - is_realtime: bool = False - test_phone_number: str | None = None - timezone: str | None = None - last_validated_at: datetime | None = None - # Post-signup onboarding gate: set once the user submits or skips the - # onboarding form, so it shows only once per user (server-side, cross-device). - onboarding_completed_at: datetime | None = None - onboarding_skipped: bool = False - - @model_validator(mode="before") - @classmethod - def strip_incomplete_realtime_when_disabled(cls, data): - """Skip realtime validation when is_realtime is False and api_key is missing.""" - if isinstance(data, dict) and not data.get("is_realtime", False): - realtime = data.get("realtime") - if isinstance(realtime, dict) and not realtime.get("api_key"): - data.pop("realtime", None) - return data diff --git a/api/services/auth/depends.py b/api/services/auth/depends.py index 7ffabfb7..019dbc2f 100644 --- a/api/services/auth/depends.py +++ b/api/services/auth/depends.py @@ -1,7 +1,7 @@ from typing import Annotated, Optional import httpx -from fastapi import Header, HTTPException, Query, WebSocket +from fastapi import Depends, Header, HTTPException, Query, WebSocket from loguru import logger from pydantic import ValidationError @@ -9,9 +9,10 @@ from api.constants import AUTH_PROVIDER, DOGRAH_MPS_SECRET_KEY, MPS_API_URL from api.db import db_client from api.db.models import UserModel from api.enums import PostHogEvent -from api.schemas.user_configuration import UserConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.services.auth.stack_auth import stackauth from api.services.configuration.registry import ServiceProviders +from api.services.mps_billing import ensure_hosted_mps_billing_account_v2 from api.services.posthog_client import capture_event from api.utils.auth import decode_jwt_token @@ -110,6 +111,19 @@ async def get_user( # This prevents race conditions where multiple concurrent requests # might try to create configurations if org_was_created: + try: + await ensure_hosted_mps_billing_account_v2( + organization.id, + created_by=str(stack_user["id"]), + ) + except Exception: + logger.warning( + "Failed to initialize hosted MPS billing account for " + "organization {}", + organization.id, + exc_info=True, + ) + existing_cfg = await db_client.get_user_configurations(user_model.id) if not (existing_cfg.llm or existing_cfg.tts or existing_cfg.stt): mps_config = await create_user_configuration_with_mps_key( @@ -119,6 +133,19 @@ async def get_user( await db_client.update_user_configuration( user_model.id, mps_config ) + from api.enums import OrganizationConfigurationKey + from api.services.configuration.ai_model_configuration import ( + convert_legacy_ai_model_configuration_to_v2, + ) + + model_config_v2 = convert_legacy_ai_model_configuration_to_v2( + mps_config + ) + await db_client.upsert_configuration( + organization.id, + OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value, + model_config_v2.model_dump(mode="json", exclude_none=True), + ) except Exception as exc: raise HTTPException( @@ -129,6 +156,14 @@ async def get_user( return user_model +async def get_user_with_selected_organization( + user: Annotated[UserModel, Depends(get_user)], +) -> UserModel: + if not user.selected_organization_id: + raise HTTPException(status_code=400, detail="No organization selected") + return user + + async def _handle_oss_auth(authorization: str | None) -> UserModel: """ Handle authentication for OSS deployment mode. @@ -192,7 +227,7 @@ async def _handle_api_key_auth(api_key: str) -> UserModel: async def create_user_configuration_with_mps_key( user_id: int, organization_id: int, user_provider_id: str -) -> Optional[UserConfiguration]: +) -> Optional[EffectiveAIModelConfiguration]: """Create user configuration using MPS service key. Args: @@ -201,7 +236,7 @@ async def create_user_configuration_with_mps_key( user_provider_id: The user's provider ID (for created_by field) Returns: - UserConfiguration with MPS-provided API keys or None if failed + EffectiveAIModelConfiguration with MPS-provided API keys or None if failed """ async with httpx.AsyncClient() as client: @@ -211,7 +246,7 @@ async def create_user_configuration_with_mps_key( response = await client.post( f"{MPS_API_URL}/api/v1/service-keys/", json={ - "name": f"Default Dograh Model Service Key", + "name": "Default Dograh Model Service Key", "description": "Auto-generated key for OSS user", "expires_in_days": 7, # Short-lived for OSS "created_by": user_provider_id, @@ -229,7 +264,7 @@ async def create_user_configuration_with_mps_key( response = await client.post( f"{MPS_API_URL}/api/v1/service-keys/", json={ - "name": f"Default Dograh Model Service Key", + "name": "Default Dograh Model Service Key", "description": f"Auto-generated key for organization {organization_id}", "organization_id": organization_id, "expires_in_days": 90, # Longer-lived for authenticated users @@ -264,8 +299,8 @@ async def create_user_configuration_with_mps_key( "model": "default", }, } - user_config = UserConfiguration(**configuration) - return user_config + effective_config = EffectiveAIModelConfiguration(**configuration) + return effective_config else: logger.warning( f"Failed to get MPS service key: {response.status_code} - {response.text}" diff --git a/api/services/campaign/campaign_call_dispatcher.py b/api/services/campaign/campaign_call_dispatcher.py index 27fc2355..84a419be 100644 --- a/api/services/campaign/campaign_call_dispatcher.py +++ b/api/services/campaign/campaign_call_dispatcher.py @@ -15,6 +15,7 @@ from api.services.campaign.errors import ( PhoneNumberPoolExhaustedError, ) from api.services.campaign.rate_limiter import rate_limiter +from api.services.quota_service import authorize_workflow_run_start from api.utils.common import get_backend_endpoints if TYPE_CHECKING: @@ -339,6 +340,41 @@ class CampaignCallDispatcher: }, ) + quota_result = await authorize_workflow_run_start( + workflow_id=campaign.workflow_id, + workflow_run_id=workflow_run.id, + ) + if not quota_result.has_quota: + error_message = quota_result.error_message or "Quota exceeded" + logger.warning( + f"Campaign {campaign.id} quota check failed for workflow run " + f"{workflow_run.id}: {error_message}" + ) + await db_client.update_workflow_run( + run_id=workflow_run.id, + is_completed=True, + state=WorkflowRunState.COMPLETED.value, + gathered_context={"error": error_message}, + ) + + mapping = await rate_limiter.get_workflow_slot_mapping(workflow_run.id) + if mapping: + org_id, mapped_slot_id = mapping + await rate_limiter.release_concurrent_slot(org_id, mapped_slot_id) + await rate_limiter.delete_workflow_slot_mapping(workflow_run.id) + + from_number_mapping = await rate_limiter.get_workflow_from_number_mapping( + workflow_run.id + ) + if from_number_mapping: + fn_org_id, fn_number, fn_tcid = from_number_mapping + await rate_limiter.release_from_number( + fn_org_id, fn_number, telephony_configuration_id=fn_tcid + ) + await rate_limiter.delete_workflow_from_number_mapping(workflow_run.id) + + raise ValueError(error_message) + # Initiate call via telephony provider try: # Construct webhook URL with parameters diff --git a/api/services/configuration/ai_model_configuration.py b/api/services/configuration/ai_model_configuration.py new file mode 100644 index 00000000..c5331515 --- /dev/null +++ b/api/services/configuration/ai_model_configuration.py @@ -0,0 +1,484 @@ +from __future__ import annotations + +import copy +from dataclasses import dataclass +from typing import Literal + +from loguru import logger +from pydantic import ValidationError +from sqlalchemy import select, update +from sqlalchemy.orm import selectinload + +from api.constants import MPS_API_URL +from api.db import db_client +from api.db.models import WorkflowDefinitionModel, WorkflowModel +from api.enums import OrganizationConfigurationKey +from api.schemas.ai_model_configuration import ( + DOGRAH_DEFAULT_LANGUAGE, + DOGRAH_DEFAULT_VOICE, + DOGRAH_SPEED_OPTIONS, + BYOKAIModelConfiguration, + BYOKPipelineAIModelConfiguration, + BYOKRealtimeAIModelConfiguration, + DograhManagedAIModelConfiguration, + EffectiveAIModelConfiguration, + OrganizationAIModelConfigurationV2, + compile_ai_model_configuration_v2, +) +from api.services.configuration.masking import ( + SERVICE_SECRET_FIELDS, + contains_masked_key, + mask_key, + resolve_masked_api_keys, +) +from api.services.configuration.registry import ServiceProviders +from api.services.configuration.resolve import resolve_effective_config + +AIModelConfigurationSource = Literal["organization_v2", "legacy_user_v1", "empty"] +WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY = "model_configuration_v2_override" + + +@dataclass +class ResolvedAIModelConfiguration: + effective: EffectiveAIModelConfiguration + source: AIModelConfigurationSource + organization_configuration: OrganizationAIModelConfigurationV2 | None = None + + +@dataclass +class WorkflowAIModelConfigurationMigrationResult: + workflow_count: int = 0 + definition_count: int = 0 + workflow_ids: list[int] | None = None + + +async def get_resolved_ai_model_configuration( + *, + user_id: int | None, + organization_id: int | None, +) -> ResolvedAIModelConfiguration: + organization_configuration = await get_organization_ai_model_configuration_v2( + organization_id + ) + if organization_configuration is not None: + return ResolvedAIModelConfiguration( + effective=compile_ai_model_configuration_v2(organization_configuration), + source="organization_v2", + organization_configuration=organization_configuration, + ) + + if user_id is None: + return ResolvedAIModelConfiguration( + effective=EffectiveAIModelConfiguration(), + source="empty", + ) + + legacy = await db_client.get_user_configurations(user_id) + return ResolvedAIModelConfiguration( + effective=legacy, + source="legacy_user_v1" if _has_model_services(legacy) else "empty", + ) + + +async def get_effective_ai_model_configuration_for_workflow( + *, + user_id: int | None, + organization_id: int | None, + workflow_configurations: dict | None, +) -> EffectiveAIModelConfiguration: + workflow_configurations = workflow_configurations or {} + v2_override = workflow_configurations.get( + WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY + ) + if v2_override: + return compile_ai_model_configuration_v2( + OrganizationAIModelConfigurationV2.model_validate(v2_override) + ) + + resolved_config = await get_resolved_ai_model_configuration( + user_id=user_id, + organization_id=organization_id, + ) + return resolve_effective_config( + resolved_config.effective, + workflow_configurations.get("model_overrides"), + ) + + +async def get_organization_ai_model_configuration_v2( + organization_id: int | None, +) -> OrganizationAIModelConfigurationV2 | None: + if organization_id is None: + return None + row = await db_client.get_configuration( + organization_id, + OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value, + ) + if row is None or not row.value: + return None + try: + return OrganizationAIModelConfigurationV2.model_validate(row.value) + except ValidationError as exc: + logger.warning( + "Invalid org AI model configuration v2 for organization " + f"{organization_id}: {exc}. Falling back to legacy configuration." + ) + return None + + +async def upsert_organization_ai_model_configuration_v2( + organization_id: int, + configuration: OrganizationAIModelConfigurationV2, +) -> OrganizationAIModelConfigurationV2: + await db_client.upsert_configuration( + organization_id, + OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value, + configuration.model_dump(mode="json", exclude_none=True), + ) + return configuration + + +async def migrate_workflow_model_configurations_to_v2( + *, + organization_id: int, + fallback_user_config: EffectiveAIModelConfiguration, +) -> WorkflowAIModelConfigurationMigrationResult: + workflows = await _list_workflows_for_model_configuration_migration(organization_id) + owner_configs: dict[int, EffectiveAIModelConfiguration] = {} + workflow_updates: list[tuple[int, dict]] = [] + definition_updates: list[tuple[int, dict]] = [] + migrated_workflow_ids: set[int] = set() + + for workflow in workflows: + base_config = fallback_user_config + if workflow.user_id is not None: + if workflow.user_id not in owner_configs: + owner_configs[ + workflow.user_id + ] = await db_client.get_user_configurations(workflow.user_id) + base_config = owner_configs[workflow.user_id] + + workflow_configs, workflow_changed = ( + migrate_workflow_configuration_model_override_to_v2( + workflow.workflow_configurations, + base_config, + ) + ) + if workflow_changed: + workflow_updates.append((workflow.id, workflow_configs)) + migrated_workflow_ids.add(workflow.id) + + for definition in workflow.definitions: + definition_configs, definition_changed = ( + migrate_workflow_configuration_model_override_to_v2( + definition.workflow_configurations, + base_config, + ) + ) + if definition_changed: + definition_updates.append((definition.id, definition_configs)) + migrated_workflow_ids.add(workflow.id) + + if workflow_updates or definition_updates: + async with db_client.async_session() as session: + for workflow_id, workflow_configs in workflow_updates: + await session.execute( + update(WorkflowModel) + .where(WorkflowModel.id == workflow_id) + .values(workflow_configurations=workflow_configs) + ) + for definition_id, definition_configs in definition_updates: + await session.execute( + update(WorkflowDefinitionModel) + .where(WorkflowDefinitionModel.id == definition_id) + .values(workflow_configurations=definition_configs) + ) + await session.commit() + + return WorkflowAIModelConfigurationMigrationResult( + workflow_count=len(migrated_workflow_ids), + definition_count=len(definition_updates), + workflow_ids=sorted(migrated_workflow_ids), + ) + + +def migrate_workflow_configuration_model_override_to_v2( + workflow_configurations: dict | None, + base_config: EffectiveAIModelConfiguration, +) -> tuple[dict, bool]: + if not isinstance(workflow_configurations, dict): + return {}, False + + migrated = copy.deepcopy(workflow_configurations) + model_overrides = migrated.get("model_overrides") + existing_v2_override = migrated.get(WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY) + if not isinstance(model_overrides, dict): + if "model_overrides" in migrated: + migrated.pop("model_overrides", None) + return migrated, True + return migrated, False + + if not existing_v2_override: + effective = resolve_effective_config(base_config, model_overrides) + v2_override = convert_legacy_ai_model_configuration_to_v2(effective) + migrated[WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY] = v2_override.model_dump( + mode="json", exclude_none=True + ) + migrated.pop("model_overrides", None) + return migrated, True + + +def merge_ai_model_configuration_v2_secrets( + incoming: OrganizationAIModelConfigurationV2, + existing: OrganizationAIModelConfigurationV2 | None, +) -> OrganizationAIModelConfigurationV2: + if existing is None: + return incoming + + incoming_dict = incoming.model_dump(mode="json", exclude_none=True) + existing_dict = existing.model_dump(mode="json", exclude_none=True) + + if incoming_dict.get("mode") == "dograh" and existing_dict.get("mode") == "dograh": + incoming_dograh = incoming_dict.get("dograh") or {} + existing_dograh = existing_dict.get("dograh") or {} + incoming_key = incoming_dograh.get("api_key") + existing_key = existing_dograh.get("api_key") + if incoming_key and existing_key and contains_masked_key(incoming_key): + incoming_dograh["api_key"] = resolve_masked_api_keys( + incoming_key, + existing_key, + ) + + if incoming_dict.get("mode") == "byok" and existing_dict.get("mode") == "byok": + _merge_byok_secret_fields(incoming_dict.get("byok"), existing_dict.get("byok")) + + return OrganizationAIModelConfigurationV2.model_validate(incoming_dict) + + +def check_for_masked_keys_in_ai_model_configuration_v2( + configuration: OrganizationAIModelConfigurationV2, +) -> None: + data = configuration.model_dump(mode="json", exclude_none=True) + _raise_if_masked_secret(data) + + +def mask_ai_model_configuration_v2( + configuration: OrganizationAIModelConfigurationV2 | None, +) -> dict | None: + if configuration is None: + return None + data = configuration.model_dump(mode="json", exclude_none=True) + _mask_secret_fields(data) + return data + + +def convert_legacy_ai_model_configuration_to_v2( + configuration: EffectiveAIModelConfiguration, +) -> OrganizationAIModelConfigurationV2: + dograh_key = _first_dograh_api_key(configuration) + if dograh_key: + return _convert_any_dograh_legacy_configuration(configuration, dograh_key) + + if configuration.is_realtime: + if configuration.realtime is None or configuration.llm is None: + raise ValueError("Realtime legacy configuration is incomplete") + return OrganizationAIModelConfigurationV2( + mode="byok", + byok=BYOKAIModelConfiguration( + mode="realtime", + realtime=BYOKRealtimeAIModelConfiguration( + realtime=configuration.realtime, + llm=configuration.llm, + embeddings=configuration.embeddings, + ), + ), + ) + + if ( + configuration.llm is None + or configuration.tts is None + or configuration.stt is None + ): + raise ValueError("Pipeline legacy configuration is incomplete") + return OrganizationAIModelConfigurationV2( + mode="byok", + byok=BYOKAIModelConfiguration( + mode="pipeline", + pipeline=BYOKPipelineAIModelConfiguration( + llm=configuration.llm, + tts=configuration.tts, + stt=configuration.stt, + embeddings=configuration.embeddings, + ), + ), + ) + + +def dograh_embeddings_base_url() -> str: + return f"{MPS_API_URL}/api/v1/llm" + + +def apply_managed_embeddings_base_url( + *, + provider: str | None, + base_url: str | None, +) -> str | None: + if provider == ServiceProviders.DOGRAH.value or provider == ServiceProviders.DOGRAH: + return dograh_embeddings_base_url() + return base_url + + +def _merge_byok_secret_fields(incoming_byok: dict | None, existing_byok: dict | None): + if not isinstance(incoming_byok, dict) or not isinstance(existing_byok, dict): + return + incoming_mode = incoming_byok.get("mode") + existing_mode = existing_byok.get("mode") + if incoming_mode != existing_mode: + return + section_names = ( + ("llm", "tts", "stt", "embeddings") + if incoming_mode == "pipeline" + else ("realtime", "llm", "embeddings") + ) + incoming_container = incoming_byok.get(incoming_mode) + existing_container = existing_byok.get(existing_mode) + if not isinstance(incoming_container, dict) or not isinstance( + existing_container, dict + ): + return + for section_name in section_names: + incoming_section = incoming_container.get(section_name) + existing_section = existing_container.get(section_name) + if isinstance(incoming_section, dict) and isinstance(existing_section, dict): + _merge_service_secret_fields(incoming_section, existing_section) + + +async def _list_workflows_for_model_configuration_migration( + organization_id: int, +) -> list[WorkflowModel]: + async with db_client.async_session() as session: + result = await session.execute( + select(WorkflowModel) + .options(selectinload(WorkflowModel.definitions)) + .where(WorkflowModel.organization_id == organization_id) + ) + return list(result.scalars().unique().all()) + + +def _merge_service_secret_fields(incoming: dict, existing: dict): + if ( + incoming.get("provider") is not None + and existing.get("provider") is not None + and incoming.get("provider") != existing.get("provider") + ): + return + for secret_field in SERVICE_SECRET_FIELDS: + if secret_field not in existing: + continue + incoming_secret = incoming.get(secret_field) + existing_secret = existing[secret_field] + if incoming_secret is None: + incoming[secret_field] = existing_secret + elif contains_masked_key(incoming_secret): + incoming[secret_field] = resolve_masked_api_keys( + incoming_secret, + existing_secret, + ) + + +def _raise_if_masked_secret(value): + if isinstance(value, dict): + for key, nested in value.items(): + if key in SERVICE_SECRET_FIELDS and contains_masked_key(nested): + raise ValueError( + f"The {key} appears to be masked. Please provide the actual " + "value, not the masked value." + ) + _raise_if_masked_secret(nested) + elif isinstance(value, list): + for item in value: + _raise_if_masked_secret(item) + + +def _mask_secret_fields(value): + if isinstance(value, dict): + for key, nested in list(value.items()): + if key in SERVICE_SECRET_FIELDS and nested: + value[key] = _mask_secret_value(nested) + else: + _mask_secret_fields(nested) + elif isinstance(value, list): + for item in value: + _mask_secret_fields(item) + + +def _mask_secret_value(value): + if isinstance(value, list): + return [mask_key(item) for item in value] + return mask_key(value) + + +def _has_model_services(configuration: EffectiveAIModelConfiguration) -> bool: + return any( + service is not None + for service in ( + configuration.llm, + configuration.tts, + configuration.stt, + configuration.embeddings, + configuration.realtime, + ) + ) + + +def _convert_any_dograh_legacy_configuration( + configuration: EffectiveAIModelConfiguration, + dograh_key: str, +) -> OrganizationAIModelConfigurationV2: + speed = getattr(configuration.tts, "speed", 1.0) + if speed not in DOGRAH_SPEED_OPTIONS: + speed = 1.0 + return OrganizationAIModelConfigurationV2( + mode="dograh", + dograh=DograhManagedAIModelConfiguration( + api_key=dograh_key, + voice=getattr(configuration.tts, "voice", DOGRAH_DEFAULT_VOICE) + or DOGRAH_DEFAULT_VOICE, + speed=speed, + language=getattr(configuration.stt, "language", DOGRAH_DEFAULT_LANGUAGE) + or DOGRAH_DEFAULT_LANGUAGE, + ), + ) + + +def _first_dograh_api_key(configuration: EffectiveAIModelConfiguration) -> str | None: + for service in ( + configuration.llm, + configuration.tts, + configuration.stt, + configuration.embeddings, + configuration.realtime, + ): + if service is None or _provider(service) != ServiceProviders.DOGRAH: + continue + try: + return _single_api_key(service) + except ValueError: + continue + return None + + +def _provider(service): + return getattr(service, "provider", None) + + +def _single_api_key(service) -> str: + if hasattr(service, "get_all_api_keys"): + keys = service.get_all_api_keys() + if len(keys) != 1: + raise ValueError("Expected exactly one API key") + return keys[0] + key = getattr(service, "api_key", None) + if not key: + raise ValueError("Expected an API key") + return key diff --git a/api/services/configuration/check_validity.py b/api/services/configuration/check_validity.py index 0e4da863..b1996879 100644 --- a/api/services/configuration/check_validity.py +++ b/api/services/configuration/check_validity.py @@ -8,8 +8,8 @@ from groq import Groq # from pyneuphonic import Neuphonic # except ImportError: # Neuphonic = None -from api.schemas.user_configuration import ( - UserConfiguration, +from api.schemas.ai_model_configuration import ( + EffectiveAIModelConfiguration, ) from api.services.configuration.registry import ServiceConfig, ServiceProviders from api.services.mps_service_key_client import mps_service_key_client @@ -64,7 +64,7 @@ class UserConfigurationValidator: async def validate( self, - configuration: UserConfiguration, + configuration: EffectiveAIModelConfiguration, organization_id: Optional[int] = None, created_by: Optional[str] = None, ) -> APIKeyStatusResponse: @@ -75,21 +75,21 @@ class UserConfigurationValidator: status_list = [] status_list.extend(self._validate_service(configuration.llm, "llm")) - status_list.extend(self._validate_service(configuration.stt, "stt")) - status_list.extend(self._validate_service(configuration.tts, "tts")) - # Embeddings is optional - only validate if configured - status_list.extend( - self._validate_service( - configuration.embeddings, "embeddings", required=False - ) - ) - # Realtime is optional - only validate if is_realtime is enabled if configuration.is_realtime: status_list.extend( self._validate_service( configuration.realtime, "realtime", required=True ) ) + else: + status_list.extend(self._validate_service(configuration.stt, "stt")) + status_list.extend(self._validate_service(configuration.tts, "tts")) + # Embeddings is optional - only validate if configured + status_list.extend( + self._validate_service( + configuration.embeddings, "embeddings", required=False + ) + ) if status_list: raise ValueError(status_list) diff --git a/api/services/configuration/masking.py b/api/services/configuration/masking.py index a16950c1..dc023343 100644 --- a/api/services/configuration/masking.py +++ b/api/services/configuration/masking.py @@ -12,7 +12,7 @@ The rules are simple: import copy from typing import Any, Dict, Optional -from api.schemas.user_configuration import UserConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.services.configuration.registry import ServiceConfig from api.services.integrations import get_node_secret_fields @@ -31,7 +31,7 @@ def contains_masked_key(value: str | list[str] | None) -> bool: return any(MASK_MARKER in k for k in keys) -def check_for_masked_keys(config: "UserConfiguration") -> None: +def check_for_masked_keys(config: "EffectiveAIModelConfiguration") -> None: """Raise ValueError if any service in *config* still has a masked secret.""" for field in ("llm", "tts", "stt", "embeddings", "realtime"): service = getattr(config, field, None) @@ -111,7 +111,7 @@ def resolve_masked_api_keys( # --------------------------------------------------------------------------- -# High-level helpers for UserConfiguration objects +# High-level helpers for EffectiveAIModelConfiguration objects # --------------------------------------------------------------------------- @@ -129,7 +129,7 @@ def _mask_service(service_cfg: Optional[ServiceConfig]) -> Optional[Dict[str, An return data -def mask_user_config(config: UserConfiguration) -> Dict[str, Any]: +def mask_user_config(config: EffectiveAIModelConfiguration) -> Dict[str, Any]: """Return a JSON-serialisable dict of *config* with every api_key masked.""" return { @@ -155,21 +155,35 @@ def mask_workflow_configurations(config: Optional[Dict]) -> Optional[Dict]: masked = copy.deepcopy(config) model_overrides = masked.get("model_overrides") - if not isinstance(model_overrides, dict): - return masked + if isinstance(model_overrides, dict): + for section in MODEL_OVERRIDE_FIELDS: + override = model_overrides.get(section) + if not isinstance(override, dict): + continue + for secret_field in SERVICE_SECRET_FIELDS: + raw = override.get(secret_field) + if raw: + override[secret_field] = _mask_secret_value(raw) - for section in MODEL_OVERRIDE_FIELDS: - override = model_overrides.get(section) - if not isinstance(override, dict): - continue - for secret_field in SERVICE_SECRET_FIELDS: - raw = override.get(secret_field) - if raw: - override[secret_field] = _mask_secret_value(raw) + v2_override = masked.get("model_configuration_v2_override") + if isinstance(v2_override, dict): + _mask_nested_service_secrets(v2_override) return masked +def _mask_nested_service_secrets(value): + if isinstance(value, dict): + for key, nested in list(value.items()): + if key in SERVICE_SECRET_FIELDS and nested: + value[key] = _mask_secret_value(nested) + else: + _mask_nested_service_secrets(nested) + elif isinstance(value, list): + for item in value: + _mask_nested_service_secrets(item) + + # --------------------------------------------------------------------------- # Workflow definition helpers – mask / merge node API keys # --------------------------------------------------------------------------- diff --git a/api/services/configuration/merge.py b/api/services/configuration/merge.py index 094800d3..d3df9f1e 100644 --- a/api/services/configuration/merge.py +++ b/api/services/configuration/merge.py @@ -7,7 +7,7 @@ stored, while honouring masked API keys. import copy from typing import Dict -from api.schemas.user_configuration import UserConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.services.configuration.masking import ( MODEL_OVERRIDE_FIELDS, SERVICE_SECRET_FIELDS, @@ -66,9 +66,9 @@ def _merge_service_secret_fields( def merge_user_configurations( - existing: UserConfiguration, incoming_partial: Dict[str, dict] -) -> UserConfiguration: - """Merge *incoming_partial* onto *existing* and return a new UserConfiguration. + existing: EffectiveAIModelConfiguration, incoming_partial: Dict[str, dict] +) -> EffectiveAIModelConfiguration: + """Merge *incoming_partial* onto *existing* and return a new EffectiveAIModelConfiguration. *incoming_partial* is the body of the PUT request (already `model_dump()`ed or extracted via Pydantic `model_dump`). @@ -113,14 +113,14 @@ def merge_user_configurations( if "timezone" in incoming_partial: merged["timezone"] = incoming_partial["timezone"] - # Onboarding gate flags — overwrite only when supplied (set once on submit/skip). + # Onboarding gate flags: overwrite only when supplied. if "onboarding_completed_at" in incoming_partial: merged["onboarding_completed_at"] = incoming_partial["onboarding_completed_at"] if "onboarding_skipped" in incoming_partial: merged["onboarding_skipped"] = incoming_partial["onboarding_skipped"] - return UserConfiguration.model_validate(merged) + return EffectiveAIModelConfiguration.model_validate(merged) def merge_workflow_configuration_secrets( diff --git a/api/services/configuration/registry.py b/api/services/configuration/registry.py index 7f831cf9..5e3d050d 100644 --- a/api/services/configuration/registry.py +++ b/api/services/configuration/registry.py @@ -911,7 +911,7 @@ class DograhTTSService(BaseTTSConfiguration): speed: float = Field(default=1.0, ge=0.5, le=2.0, description="Speed of the voice.") -CARTESIA_TTS_MODELS = ["sonic-3"] +CARTESIA_TTS_MODELS = ["sonic-3.5", "sonic-3"] @register_tts @@ -919,7 +919,7 @@ class CartesiaTTSConfiguration(BaseTTSConfiguration): model_config = CARTESIA_PROVIDER_MODEL_CONFIG provider: Literal[ServiceProviders.CARTESIA] = ServiceProviders.CARTESIA model: str = Field( - default="sonic-3", + default="sonic-3.5", description="Cartesia TTS model.", json_schema_extra={"examples": CARTESIA_TTS_MODELS}, ) @@ -1472,11 +1472,26 @@ class AzureOpenAIEmbeddingsConfiguration(BaseEmbeddingsConfiguration): ) +DOGRAH_EMBEDDING_MODELS = ["default"] + + +@register_embeddings +class DograhEmbeddingsConfiguration(BaseEmbeddingsConfiguration): + model_config = DOGRAH_PROVIDER_MODEL_CONFIG + provider: Literal[ServiceProviders.DOGRAH] = ServiceProviders.DOGRAH + model: str = Field( + default="default", + description="Dograh-managed embedding model.", + json_schema_extra={"examples": DOGRAH_EMBEDDING_MODELS}, + ) + + EmbeddingsConfig = Annotated[ Union[ OpenAIEmbeddingsConfiguration, OpenRouterEmbeddingsConfiguration, AzureOpenAIEmbeddingsConfiguration, + DograhEmbeddingsConfiguration, ], Field(discriminator="provider"), ] diff --git a/api/services/configuration/resolve.py b/api/services/configuration/resolve.py index 742e46b8..5cbf11ef 100644 --- a/api/services/configuration/resolve.py +++ b/api/services/configuration/resolve.py @@ -4,13 +4,13 @@ from __future__ import annotations import copy -from api.schemas.user_configuration import UserConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.services.configuration.registry import ( REGISTRY, ServiceType, ) -# Maps override key → (UserConfiguration field, ServiceType for registry lookup) +# Maps override key → (EffectiveAIModelConfiguration field, ServiceType for registry lookup) _SECTION_MAP: dict[str, ServiceType] = { "llm": ServiceType.LLM, "tts": ServiceType.TTS, @@ -36,7 +36,7 @@ _SECRET_FIELDS = ("api_key", "credentials", "aws_access_key", "aws_secret_key") def enrich_overrides_with_api_keys( model_overrides: dict, - user_config: UserConfiguration, + user_config: EffectiveAIModelConfiguration, ) -> dict: """Copy API keys from the global config into model_overrides where missing. @@ -74,9 +74,9 @@ def enrich_overrides_with_api_keys( def resolve_effective_config( - user_config: UserConfiguration, + user_config: EffectiveAIModelConfiguration, model_overrides: dict | None, -) -> UserConfiguration: +) -> EffectiveAIModelConfiguration: """Deep-merge workflow model_overrides onto global user config. - If model_overrides is None or empty, returns a copy of user_config unchanged. diff --git a/api/services/gen_ai/embedding/openai_service.py b/api/services/gen_ai/embedding/openai_service.py index da5d3d4d..1081889e 100644 --- a/api/services/gen_ai/embedding/openai_service.py +++ b/api/services/gen_ai/embedding/openai_service.py @@ -38,6 +38,7 @@ class OpenAIEmbeddingService(BaseEmbeddingService): api_key: Optional[str] = None, model_id: str = DEFAULT_MODEL_ID, base_url: Optional[str] = None, + default_headers: Optional[Dict[str, str]] = None, ): """Initialize the OpenAI embedding service. @@ -60,6 +61,8 @@ class OpenAIEmbeddingService(BaseEmbeddingService): field_name="base_url", ) client_kwargs["base_url"] = base_url + if default_headers: + client_kwargs["default_headers"] = default_headers self.client = AsyncOpenAI(**client_kwargs) logger.info(f"OpenAI embedding service initialized with model: {model_id}") else: diff --git a/api/services/managed_model_services.py b/api/services/managed_model_services.py new file mode 100644 index 00000000..00c776ff --- /dev/null +++ b/api/services/managed_model_services.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Any + +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration +from api.services.configuration.registry import ServiceProviders + +MPS_CORRELATION_ID_CONTEXT_KEY = "mps_correlation_id" + + +def uses_managed_model_services_v2( + ai_model_config: EffectiveAIModelConfiguration | None, +) -> bool: + if ( + ai_model_config is None + or getattr(ai_model_config, "managed_service_version", None) != 2 + ): + return False + + return any( + _is_dograh_service(getattr(ai_model_config, section_name, None)) + for section_name in ("llm", "tts", "stt", "embeddings") + ) + + +def get_mps_correlation_id(initial_context: dict[str, Any] | None) -> str | None: + if not initial_context: + return None + correlation_id = initial_context.get(MPS_CORRELATION_ID_CONTEXT_KEY) + if correlation_id is None: + return None + return str(correlation_id) + + +async def ensure_mps_correlation_id( + *, + ai_model_config: EffectiveAIModelConfiguration, + workflow_run_id: int, + initial_context: dict[str, Any] | None, +) -> str | None: + existing = get_mps_correlation_id(initial_context) + if existing: + return existing + + if not uses_managed_model_services_v2(ai_model_config): + return None + + raise ValueError( + "Managed model services v2 requires workflow run authorization before " + f"the run starts. Missing correlation id for workflow_run_id={workflow_run_id}." + ) + + +def _is_dograh_service(service: Any) -> bool: + provider = getattr(service, "provider", None) + return ( + provider == ServiceProviders.DOGRAH or provider == ServiceProviders.DOGRAH.value + ) + + +def get_dograh_service_api_key( + ai_model_config: EffectiveAIModelConfiguration, +) -> str | None: + for section_name in ("llm", "tts", "stt", "embeddings"): + service = getattr(ai_model_config, section_name, None) + if not _is_dograh_service(service): + continue + + if hasattr(service, "get_all_api_keys"): + keys = service.get_all_api_keys() + if keys: + return keys[0] + + api_key = getattr(service, "api_key", None) + if isinstance(api_key, str) and api_key: + return api_key + + return None diff --git a/api/services/mps_billing.py b/api/services/mps_billing.py new file mode 100644 index 00000000..10a27c90 --- /dev/null +++ b/api/services/mps_billing.py @@ -0,0 +1,23 @@ +from typing import Optional + +from api.constants import DEPLOYMENT_MODE +from api.services.mps_service_key_client import mps_service_key_client + + +async def ensure_hosted_mps_billing_account_v2( + organization_id: int, + *, + created_by: Optional[str] = None, +) -> Optional[dict]: + """Ensure hosted orgs have an MPS billing v2 account. + + OSS deployments use legacy per-key quota accounting and do not create MPS + billing accounts. + """ + if DEPLOYMENT_MODE == "oss": + return None + + return await mps_service_key_client.ensure_billing_account_v2( + organization_id=organization_id, + created_by=created_by, + ) diff --git a/api/services/mps_service_key_client.py b/api/services/mps_service_key_client.py index 2c7fc56b..5f90380f 100644 --- a/api/services/mps_service_key_client.py +++ b/api/services/mps_service_key_client.py @@ -4,6 +4,7 @@ This client communicates with the Model Proxy Service (MPS) for service key mana Service keys are stored and managed entirely in MPS, not in the local database. """ +import asyncio from typing import List, Optional import httpx @@ -353,6 +354,278 @@ class MPSServiceKeyClient: response=response, ) + async def create_credit_purchase_url( + self, + organization_id: int, + created_by: Optional[str] = None, + return_url: Optional[str] = None, + billing_details: Optional[dict] = None, + ) -> dict: + """Create a short-lived MPS checkout URL for adding organization credits.""" + payload = { + "created_by": created_by, + "return_url": return_url, + "billing_details": billing_details or {}, + } + + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + f"{self.base_url}/api/v1/billing/accounts/{organization_id}/checkout-sessions", + json=payload, + headers=self._get_headers( + organization_id=organization_id, + created_by=created_by, + ), + ) + + if response.status_code == 200: + return response.json() + + logger.error( + "Failed to create MPS credit purchase URL: " + f"{response.status_code} - {response.text}" + ) + raise httpx.HTTPStatusError( + f"Failed to create MPS credit purchase URL: {response.text}", + request=response.request, + response=response, + ) + + async def get_credit_ledger( + self, + organization_id: int, + page: int = 1, + limit: int = 50, + created_by: Optional[str] = None, + ) -> dict: + """Get the MPS v2 billing account balance and recent credit ledger.""" + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get( + f"{self.base_url}/api/v1/billing/accounts/{organization_id}/ledger", + params={"page": page, "limit": limit}, + headers=self._get_headers( + organization_id=organization_id, + created_by=created_by, + ), + ) + + if response.status_code == 200: + return response.json() + + logger.error( + "Failed to get MPS credit ledger: " + f"{response.status_code} - {response.text}" + ) + raise httpx.HTTPStatusError( + f"Failed to get MPS credit ledger: {response.text}", + request=response.request, + response=response, + ) + + async def get_billing_account_status( + self, + organization_id: int, + created_by: Optional[str] = None, + ) -> Optional[dict]: + """Get an existing MPS v2 billing account without creating one.""" + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get( + f"{self.base_url}/api/v1/billing/accounts/{organization_id}/status", + headers=self._get_headers( + organization_id=organization_id, + created_by=created_by, + ), + ) + + if response.status_code == 200: + return response.json() + + logger.error( + "Failed to get MPS billing account status: " + f"{response.status_code} - {response.text}" + ) + raise httpx.HTTPStatusError( + f"Failed to get MPS billing account status: {response.text}", + request=response.request, + response=response, + ) + + async def ensure_billing_account_v2( + self, + organization_id: int, + created_by: Optional[str] = None, + ) -> dict: + """Create or return the MPS v2 billing account for an organization.""" + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.get( + f"{self.base_url}/api/v1/billing/accounts/{organization_id}/balance", + headers=self._get_headers( + organization_id=organization_id, + created_by=created_by, + ), + ) + + if response.status_code == 200: + return response.json() + + logger.error( + "Failed to ensure MPS billing account v2: " + f"{response.status_code} - {response.text}" + ) + raise httpx.HTTPStatusError( + f"Failed to ensure MPS billing account v2: {response.text}", + request=response.request, + response=response, + ) + + async def authorize_workflow_run_start( + self, + *, + organization_id: int, + workflow_run_id: int | None = None, + service_key: Optional[str] = None, + require_correlation_id: bool = False, + minimum_credits: float | None = None, + metadata: Optional[dict] = None, + created_by: Optional[str] = None, + ) -> dict: + """Authorize a hosted workflow run and optionally mint its MPS correlation.""" + payload = { + "workflow_run_id": workflow_run_id, + "service_key": service_key, + "require_correlation_id": require_correlation_id, + "metadata": metadata or {}, + } + if minimum_credits is not None: + payload["minimum_credits"] = minimum_credits + + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + f"{self.base_url}/api/v1/billing/accounts/{organization_id}/run-authorization", + json=payload, + headers=self._get_headers( + organization_id=organization_id, + created_by=created_by, + ), + ) + + if response.status_code == 200: + return response.json() + + logger.error( + "Failed to authorize MPS workflow run start: " + f"{response.status_code} - {response.text}" + ) + raise httpx.HTTPStatusError( + f"Failed to authorize MPS workflow run start: {response.text}", + request=response.request, + response=response, + ) + + async def create_correlation_id( + self, + *, + service_key: str, + workflow_run_id: int | None = None, + ) -> dict: + """Mint a server-generated correlation ID for managed model services.""" + payload: dict[str, int] = {} + if workflow_run_id is not None: + payload["workflow_run_id"] = workflow_run_id + + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + f"{self.base_url}/api/v1/service-keys/correlation-id/self", + json=payload, + headers={ + "Authorization": f"Bearer {service_key}", + "Content-Type": "application/json", + }, + ) + + if response.status_code == 200: + return response.json() + + logger.error( + "Failed to create correlation ID: " + f"{response.status_code} - {response.text}" + ) + raise httpx.HTTPStatusError( + f"Failed to create correlation ID: {response.text}", + request=response.request, + response=response, + ) + + async def report_platform_usage( + self, + *, + organization_id: int, + correlation_id: Optional[str] = None, + duration_seconds: Optional[float] = None, + workflow_run_id: int | None = None, + metadata: Optional[dict] = None, + max_attempts: int = 3, + ) -> dict: + """Report hosted Dograh platform usage for a completed workflow run.""" + if DEPLOYMENT_MODE == "oss": + raise ValueError("OSS deployments must not report platform usage to MPS") + if not correlation_id and duration_seconds is None: + raise ValueError( + "Platform usage reports require correlation_id or duration_seconds" + ) + + payload: dict = { + "metadata": metadata or {}, + } + if correlation_id: + payload["correlation_id"] = correlation_id + if duration_seconds is not None: + payload["duration_seconds"] = duration_seconds + if workflow_run_id is not None: + payload["workflow_run_id"] = workflow_run_id + + max_attempts = max(1, max_attempts) + last_response: httpx.Response | None = None + async with httpx.AsyncClient(timeout=self.timeout) as client: + for attempt in range(1, max_attempts + 1): + response = await client.post( + ( + f"{self.base_url}/api/v1/billing/accounts/" + f"{organization_id}/platform-usage" + ), + json=payload, + headers=self._get_headers(organization_id=organization_id), + ) + last_response = response + + if response.status_code == 200: + return response.json() + + should_retry = ( + response.status_code == 409 + and "usage_not_ready" in response.text + and attempt < max_attempts + ) + if should_retry: + await asyncio.sleep(attempt) + continue + + logger.error( + "Failed to report platform usage: " + f"{response.status_code} - {response.text}" + ) + raise httpx.HTTPStatusError( + f"Failed to report platform usage: {response.text}", + request=response.request, + response=response, + ) + + raise httpx.HTTPStatusError( + "Failed to report platform usage", + request=last_response.request, + response=last_response, + ) + async def transcribe_audio( self, audio_data: bytes, diff --git a/api/services/organization_context.py b/api/services/organization_context.py new file mode 100644 index 00000000..b17b8f4f --- /dev/null +++ b/api/services/organization_context.py @@ -0,0 +1,50 @@ +from typing import Literal, Optional + +from pydantic import BaseModel + +from api.db import db_client +from api.db.models import UserModel +from api.services.configuration.ai_model_configuration import ( + get_resolved_ai_model_configuration, +) + + +class OrganizationModelServicesContext(BaseModel): + config_source: Literal["organization_v2", "legacy_user_v1", "empty"] + has_model_configuration_v2: bool + managed_service_version: Optional[int] = None + uses_managed_service_v2: bool + + +class OrganizationContextResponse(BaseModel): + organization_id: Optional[int] = None + organization_provider_id: Optional[str] = None + model_services: OrganizationModelServicesContext + + +async def get_organization_context(user: UserModel) -> OrganizationContextResponse: + organization_id = user.selected_organization_id + organization = ( + await db_client.get_organization_by_id(organization_id) + if organization_id + else None + ) + + resolved = await get_resolved_ai_model_configuration( + user_id=user.id, + organization_id=organization_id, + ) + managed_service_version = resolved.effective.managed_service_version + + return OrganizationContextResponse( + organization_id=organization_id, + organization_provider_id=organization.provider_id if organization else None, + model_services=OrganizationModelServicesContext( + config_source=resolved.source, + has_model_configuration_v2=resolved.source == "organization_v2", + managed_service_version=managed_service_version, + uses_managed_service_v2=( + resolved.source == "organization_v2" and managed_service_version == 2 + ), + ), + ) diff --git a/api/services/organization_preferences.py b/api/services/organization_preferences.py new file mode 100644 index 00000000..82204ea0 --- /dev/null +++ b/api/services/organization_preferences.py @@ -0,0 +1,62 @@ +from inspect import isawaitable + +from loguru import logger +from pydantic import ValidationError + +from api.db import db_client +from api.enums import OrganizationConfigurationKey +from api.schemas.organization_preferences import OrganizationPreferences + + +async def get_organization_preferences( + organization_id: int | None, + db=None, +) -> OrganizationPreferences: + if organization_id is None: + return OrganizationPreferences() + + db = db or db_client + row = await _get_configuration( + db, + organization_id, + OrganizationConfigurationKey.ORGANIZATION_PREFERENCES.value, + ) + if row is None: + row = await _get_configuration( + db, + organization_id, + OrganizationConfigurationKey.MODEL_CONFIGURATION_PREFERENCES.value, + ) + return _parse_preferences(row.value if row is not None else None, organization_id) + + +async def upsert_organization_preferences( + organization_id: int, + preferences: OrganizationPreferences, +) -> OrganizationPreferences: + await db_client.upsert_configuration( + organization_id, + OrganizationConfigurationKey.ORGANIZATION_PREFERENCES.value, + preferences.model_dump(mode="json", exclude_none=True), + ) + return preferences + + +async def _get_configuration(db, organization_id: int, key: str): + row = db.get_configuration(organization_id, key) + if isawaitable(row): + row = await row + return row + + +def _parse_preferences(value, organization_id: int) -> OrganizationPreferences: + if not value or not isinstance(value, dict): + return OrganizationPreferences() + try: + return OrganizationPreferences.model_validate(value) + except ValidationError as exc: + logger.warning( + "Invalid organization preferences for organization " + f"{organization_id}: {exc}. Returning defaults." + ) + return OrganizationPreferences() diff --git a/api/services/pipecat/pre_call_fetch.py b/api/services/pipecat/pre_call_fetch.py index 77761117..8a2025bb 100644 --- a/api/services/pipecat/pre_call_fetch.py +++ b/api/services/pipecat/pre_call_fetch.py @@ -15,6 +15,29 @@ from api.utils.credential_auth import build_auth_header PRE_CALL_FETCH_TIMEOUT_SECONDS = 10 +def _extract_initial_context(response_data: Dict[str, Any]) -> Dict[str, Any]: + """Pull the context variables out of a pre-call fetch response. + + The canonical key is ``initial_context``. The legacy ``dynamic_variables`` + key is still accepted for backward compatibility, so existing endpoints + keep working; ``initial_context`` takes precedence when both are present. + + Either key may appear at the top level or nested under ``call_inbound``: + {"call_inbound": {"initial_context": {...}}} | {"initial_context": {...}} + {"call_inbound": {"dynamic_variables": {...}}} | {"dynamic_variables": {...}} + """ + container = response_data.get("call_inbound") + if not isinstance(container, dict): + container = response_data + + for key in ("initial_context", "dynamic_variables"): + value = container.get(key) + if isinstance(value, dict): + return value + + return {} + + async def execute_pre_call_fetch( *, url: str, @@ -77,24 +100,16 @@ async def execute_pre_call_fetch( ) return {} - # Extract dynamic_variables from Retell-compatible response - # Supports: {call_inbound: {dynamic_variables: {...}}} - # or: {dynamic_variables: {...}} - dynamic_vars = {} - call_inbound = response_data.get("call_inbound") - if isinstance(call_inbound, dict): - dynamic_vars = call_inbound.get("dynamic_variables", {}) - elif "dynamic_variables" in response_data: - dynamic_vars = response_data["dynamic_variables"] - - if not isinstance(dynamic_vars, dict): - dynamic_vars = {} + # Extract the variables to merge into initial_context. Prefers + # the canonical `initial_context` key, falling back to the + # legacy `dynamic_variables` key for backward compatibility. + initial_context_vars = _extract_initial_context(response_data) logger.info( f"Pre-call fetch: success ({response.status_code}), " - f"dynamic_variables keys: {list(dynamic_vars.keys())}" + f"initial_context keys: {list(initial_context_vars.keys())}" ) - return dynamic_vars + return initial_context_vars else: logger.warning( f"Pre-call fetch: HTTP {response.status_code} - " diff --git a/api/services/pipecat/run_pipeline.py b/api/services/pipecat/run_pipeline.py index 7ce41d87..07286901 100644 --- a/api/services/pipecat/run_pipeline.py +++ b/api/services/pipecat/run_pipeline.py @@ -162,15 +162,13 @@ async def run_pipeline_telephony( workflow_id: Workflow being executed. workflow_run_id: Workflow run row. user_id: Owner of the workflow. - call_id: Provider call identifier (stored in cost_info for billing). + call_id: Provider call identifier. transport_kwargs: Provider-specific kwargs forwarded to the transport factory (e.g. stream_sid + call_sid for Twilio). """ logger.debug(f"Running {provider_name} pipeline for workflow_run {workflow_run_id}") set_current_run_id(workflow_run_id) - await db_client.update_workflow_run(workflow_run_id, cost_info={"call_id": call_id}) - workflow = await db_client.get_workflow(workflow_id, user_id) if workflow: set_current_org_id(workflow.organization_id) @@ -195,14 +193,17 @@ async def run_pipeline_telephony( # Resolve effective user config here so the transport can tune its # bot-stopped-speaking fallback based on is_realtime; pass the resolved # values into _run_pipeline so it doesn't fetch them again. - from api.services.configuration.resolve import resolve_effective_config + from api.services.configuration.ai_model_configuration import ( + get_effective_ai_model_configuration_for_workflow, + ) - user_config = await db_client.get_user_configurations(user_id) run_configs = ( (workflow_run.definition.workflow_configurations or {}) if workflow_run else {} ) - user_config = resolve_effective_config( - user_config, run_configs.get("model_overrides") + user_config = await get_effective_ai_model_configuration_for_workflow( + user_id=user_id, + organization_id=workflow.organization_id if workflow else None, + workflow_configurations=run_configs, ) is_realtime = bool(user_config.is_realtime and user_config.realtime is not None) @@ -272,15 +273,18 @@ async def run_pipeline_smallwebrtc( # Resolve workflow_run + effective user_config here so the transport can # tune its bot-stopped-speaking fallback based on is_realtime. _run_pipeline # reuses these via kwargs so we don't fetch twice. - from api.services.configuration.resolve import resolve_effective_config + from api.services.configuration.ai_model_configuration import ( + get_effective_ai_model_configuration_for_workflow, + ) workflow_run = await db_client.get_workflow_run(workflow_run_id, user_id) - user_config = await db_client.get_user_configurations(user_id) run_configs = ( (workflow_run.definition.workflow_configurations or {}) if workflow_run else {} ) - user_config = resolve_effective_config( - user_config, run_configs.get("model_overrides") + user_config = await get_effective_ai_model_configuration_for_workflow( + user_id=user_id, + organization_id=workflow.organization_id if workflow else None, + workflow_configurations=run_configs, ) is_realtime = bool(user_config.is_realtime and user_config.realtime is not None) @@ -334,7 +338,7 @@ async def _run_pipeline( if workflow_run.is_completed: raise HTTPException(status_code=400, detail="Workflow run already completed") - merged_call_context_vars = workflow_run.initial_context + merged_call_context_vars = dict(workflow_run.initial_context or {}) # If there is some extra call_context_vars, fold them in. Persistence # happens once below, after runtime_configuration is also resolved. if call_context_vars: @@ -380,15 +384,31 @@ async def _run_pipeline( # Resolve model overrides from the version onto global user config (skip # when the caller already resolved it). if resolved_user_config is None: - from api.services.configuration.resolve import resolve_effective_config + from api.services.configuration.ai_model_configuration import ( + get_effective_ai_model_configuration_for_workflow, + ) - user_config = await db_client.get_user_configurations(user_id) - user_config = resolve_effective_config( - user_config, run_configs.get("model_overrides") + user_config = await get_effective_ai_model_configuration_for_workflow( + user_id=user_id, + organization_id=workflow.organization_id, + workflow_configurations=run_configs, ) else: user_config = resolved_user_config + from api.services.managed_model_services import ( + MPS_CORRELATION_ID_CONTEXT_KEY, + ensure_mps_correlation_id, + ) + + mps_correlation_id = await ensure_mps_correlation_id( + ai_model_config=user_config, + workflow_run_id=workflow_run_id, + initial_context=merged_call_context_vars, + ) + if mps_correlation_id: + merged_call_context_vars[MPS_CORRELATION_ID_CONTEXT_KEY] = mps_correlation_id + # Detect realtime mode (speech-to-speech services like OpenAI Realtime, Gemini Live) is_realtime = user_config.is_realtime and user_config.realtime is not None @@ -400,11 +420,23 @@ async def _run_pipeline( # Realtime services don't implement run_inference, so create a # separate text LLM for variable extraction and other out-of-band # inference calls. - inference_llm = create_llm_service(user_config) + inference_llm = create_llm_service( + user_config, + correlation_id=mps_correlation_id, + ) else: - stt = create_stt_service(user_config, audio_config, keyterms=keyterms) - tts = create_tts_service(user_config, audio_config) - llm = create_llm_service(user_config) + stt = create_stt_service( + user_config, + audio_config, + keyterms=keyterms, + correlation_id=mps_correlation_id, + ) + tts = create_tts_service( + user_config, + audio_config, + correlation_id=mps_correlation_id, + ) + llm = create_llm_service(user_config, correlation_id=mps_correlation_id) inference_llm = None # Stamp the providers/models actually resolved for this run onto @@ -508,10 +540,17 @@ async def _run_pipeline( embeddings_endpoint = None embeddings_api_version = None if user_config and user_config.embeddings: + from api.services.configuration.ai_model_configuration import ( + apply_managed_embeddings_base_url, + ) + embeddings_api_key = user_config.embeddings.api_key embeddings_model = user_config.embeddings.model embeddings_provider = getattr(user_config.embeddings, "provider", None) - embeddings_base_url = getattr(user_config.embeddings, "base_url", None) + embeddings_base_url = apply_managed_embeddings_base_url( + provider=embeddings_provider, + base_url=getattr(user_config.embeddings, "base_url", None), + ) embeddings_endpoint = getattr(user_config.embeddings, "endpoint", None) embeddings_api_version = getattr(user_config.embeddings, "api_version", None) @@ -679,7 +718,10 @@ async def _run_pipeline( # Create a separate LLM instance for the voicemail sub-pipeline # (can't share with main pipeline as it would mess up frame linking) if voicemail_config.get("use_workflow_llm", True): - voicemail_llm = create_llm_service(user_config) + voicemail_llm = create_llm_service( + user_config, + correlation_id=mps_correlation_id, + ) else: voicemail_llm = create_llm_service_from_provider( provider=voicemail_config.get("provider", "openai"), diff --git a/api/services/pipecat/service_factory.py b/api/services/pipecat/service_factory.py index 8ed96e40..ec5e9911 100644 --- a/api/services/pipecat/service_factory.py +++ b/api/services/pipecat/service_factory.py @@ -78,7 +78,10 @@ def _validate_runtime_service_url(url: str, field_name: str) -> None: def create_stt_service( - user_config, audio_config: "AudioConfig", keyterms: list[str] | None = None + user_config, + audio_config: "AudioConfig", + keyterms: list[str] | None = None, + correlation_id: str | None = None, ): """Create and return appropriate STT service based on user configuration @@ -160,6 +163,7 @@ def create_stt_service( return DograhSTTService( base_url=base_url, api_key=user_config.stt.api_key, + correlation_id=correlation_id, settings=DograhSTTSettings( model=user_config.stt.model, language=language, @@ -286,7 +290,9 @@ def create_stt_service( ) -def create_tts_service(user_config, audio_config: "AudioConfig"): +def create_tts_service( + user_config, audio_config: "AudioConfig", correlation_id: str | None = None +): """Create and return appropriate TTS service based on user configuration Args: @@ -404,6 +410,7 @@ def create_tts_service(user_config, audio_config: "AudioConfig"): return DograhTTSService( base_url=base_url, api_key=user_config.tts.api_key, + correlation_id=correlation_id, settings=DograhTTSSettings( model=user_config.tts.model, voice=user_config.tts.voice, @@ -564,6 +571,7 @@ def create_llm_service_from_provider( model: str, api_key: str | None, *, + correlation_id: str | None = None, base_url: str | None = None, endpoint: str | None = None, aws_access_key: str | None = None, @@ -637,6 +645,7 @@ def create_llm_service_from_provider( return DograhLLMService( base_url=f"{MPS_API_URL}/api/v1/llm", api_key=api_key, + correlation_id=correlation_id, settings=OpenAILLMSettings(model=model), ) elif provider == ServiceProviders.AWS_BEDROCK.value: @@ -851,7 +860,7 @@ def create_realtime_llm_service(user_config, audio_config: "AudioConfig"): ) -def create_llm_service(user_config): +def create_llm_service(user_config, correlation_id: str | None = None): """Create and return appropriate LLM service based on user configuration.""" provider = user_config.llm.provider model = user_config.llm.model @@ -880,4 +889,10 @@ def create_llm_service(user_config): elif provider == ServiceProviders.SARVAM.value: kwargs["temperature"] = user_config.llm.temperature - return create_llm_service_from_provider(provider, model, api_key, **kwargs) + return create_llm_service_from_provider( + provider, + model, + api_key, + correlation_id=correlation_id, + **kwargs, + ) diff --git a/api/services/pricing/README.md b/api/services/pricing/README.md deleted file mode 100644 index 4f834c28..00000000 --- a/api/services/pricing/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# Pricing Module - -This module contains pricing models and registries for different AI services used in workflow cost calculations. - -## Structure - -``` -pricing/ -├── __init__.py # Main module exports -├── models.py # Base pricing model classes -├── llm.py # LLM pricing configurations -├── tts.py # TTS pricing configurations -├── stt.py # STT pricing configurations -├── registry.py # Combined pricing registry -└── README.md # This file -``` - -## Pricing Models - -### TokenPricingModel -Used for LLM services that charge based on tokens: -- `prompt_token_price`: Cost per prompt token -- `completion_token_price`: Cost per completion token -- `cache_read_discount`: Discount for cache read tokens (default 50%) -- `cache_creation_multiplier`: Premium for cache creation tokens (default 25%) - -### CharacterPricingModel -Used for TTS services that charge based on character count: -- `character_price`: Cost per character - -### TimePricingModel -Used for STT services that charge based on time: -- `second_price`: Cost per second - -## Adding New Pricing - -### Adding a New LLM Model -Edit `llm.py` and add the model to the appropriate provider: - -```python -ServiceProviders.OPENAI: { - "new-model": TokenPricingModel( - prompt_token_price=Decimal("2.00") / 1000000, - completion_token_price=Decimal("8.00") / 1000000, - ), - # ... existing models -} -``` - -### Adding a New Provider -1. Add pricing configurations to the appropriate service file (llm.py, tts.py, stt.py) -2. The registry will automatically include them - -### Adding a New Service Type -1. Create a new pricing file (e.g., `image.py`) -2. Define the pricing models -3. Import and add to `registry.py` - -## Usage - -The pricing registry is automatically imported and used by the cost calculator: - -```python -from api.services.pricing import PRICING_REGISTRY -from api.services.workflow.cost_calculator import cost_calculator - -# The cost calculator uses the pricing registry automatically -result = cost_calculator.calculate_total_cost(usage_info) -``` - -## Maintenance - -- Update pricing when providers change their rates -- All prices should use `Decimal` for precision -- Include comments with current pricing from provider documentation -- Test changes with existing test suite \ No newline at end of file diff --git a/api/services/pricing/__init__.py b/api/services/pricing/__init__.py deleted file mode 100644 index 1fa0eedf..00000000 --- a/api/services/pricing/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -Pricing module for workflow cost calculation. - -This module contains pricing models and registries for different AI services. -""" - -from .registry import PRICING_REGISTRY - -__all__ = ["PRICING_REGISTRY"] diff --git a/api/services/pricing/cost_calculator.py b/api/services/pricing/cost_calculator.py deleted file mode 100644 index 14344752..00000000 --- a/api/services/pricing/cost_calculator.py +++ /dev/null @@ -1,228 +0,0 @@ -""" -Cost Calculator for Workflow Runs - -This module provides a comprehensive cost calculation system for workflow runs based on usage metrics -from different AI service providers (OpenAI, Groq, Deepgram, etc.). - -Features: -- Token-based pricing for LLM services with cache optimization support -- Character-based pricing for TTS services -- Time-based pricing for STT services -- Configurable pricing models that can be updated -- Support for multiple providers and models -- Automatic provider inference from model names -- JSON serialization support for database storage - -Usage: - from api.tasks.cost_calculator import cost_calculator - - usage_info = { - "llm": { - "processor_name|||gpt-4o": { - "prompt_tokens": 1000, - "completion_tokens": 500, - "total_tokens": 1500, - "cache_read_input_tokens": 0, - "cache_creation_input_tokens": 0 - } - }, - "tts": { - "processor_name|||aura-2-helena-en": 2000 # character count - } - } - - cost_breakdown = cost_calculator.calculate_total_cost(usage_info) - print(f"Total cost: ${cost_breakdown['total']:.6f}") -""" - -from decimal import Decimal -from typing import Any, Dict, Optional, Tuple - -from api.services.configuration.registry import ServiceProviders -from api.services.pricing import PRICING_REGISTRY -from api.services.pricing.models import ( - PricingModel, -) - - -class CostCalculator: - """Main cost calculator class""" - - def __init__(self, pricing_registry: Dict = None): - self.pricing_registry = pricing_registry or PRICING_REGISTRY - - def get_pricing_model( - self, service_type: str, provider: str, model: str - ) -> Optional[PricingModel]: - """Get pricing model for a specific service, provider, and model""" - try: - service_pricing = self.pricing_registry.get(service_type, {}) - - # Try to get pricing for the specific provider - provider_pricing = service_pricing.get(provider, {}) - pricing_model = provider_pricing.get(model) or provider_pricing.get( - "default" - ) - - if pricing_model: - return pricing_model - - # If not found, try the "default" provider for this service type - default_provider_pricing = service_pricing.get("default", {}) - return default_provider_pricing.get(model) or default_provider_pricing.get( - "default" - ) - - except (KeyError, AttributeError): - return None - - def calculate_llm_cost( - self, provider: str, model: str, usage: Dict[str, int] - ) -> Decimal: - """Calculate cost for LLM usage""" - pricing_model = self.get_pricing_model("llm", provider, model) - if not pricing_model: - return Decimal("0") - return pricing_model.calculate_cost(usage) - - def calculate_tts_cost( - self, provider: str, model: str, character_count: int - ) -> Decimal: - """Calculate cost for TTS usage""" - pricing_model = self.get_pricing_model("tts", provider, model) - if not pricing_model: - return Decimal("0") - return pricing_model.calculate_cost(character_count) - - def calculate_stt_cost(self, provider: str, model: str, seconds: float) -> Decimal: - """Calculate cost for STT usage""" - pricing_model = self.get_pricing_model("stt", provider, model) - if not pricing_model: - return Decimal("0") - return pricing_model.calculate_cost(seconds) - - def calculate_total_cost(self, usage_info: Dict) -> Dict[str, Any]: - llm_cost_total = Decimal("0") - tts_cost_total = Decimal("0") - stt_cost_total = Decimal("0") - - # Calculate LLM costs - llm_usage = usage_info.get("llm", {}) - for key, usage in llm_usage.items(): - processor, model = self._parse_key(key) - # Try to determine provider from processor name or model - provider = self._infer_provider_from_model(model, "llm") - cost = self.calculate_llm_cost(provider, model, usage) - llm_cost_total += cost - - # Calculate TTS costs - tts_usage = usage_info.get("tts", {}) - for key, character_count in tts_usage.items(): - processor, model = self._parse_key(key) - # Handle the case where model is "None" - infer from processor - if model.lower() in ["none", "null", ""]: - provider = self._infer_provider_from_processor(processor, "tts") - model = "default" # Use default model for the provider - else: - provider = self._infer_provider_from_model(model, "tts") - cost = self.calculate_tts_cost(provider, model, character_count) - tts_cost_total += cost - - # Calculate STT costs from explicit stt usage - stt_usage = usage_info.get("stt", {}) - for key, seconds in stt_usage.items(): - processor, model = self._parse_key(key) - provider = self._infer_provider_from_model(model, "stt") - cost = self.calculate_stt_cost(provider, model, seconds) - stt_cost_total += cost - - total_cost = llm_cost_total + tts_cost_total + stt_cost_total - - return { - "llm_cost": float(llm_cost_total), - "tts_cost": float(tts_cost_total), - "stt_cost": float(stt_cost_total), - "total": float(total_cost), - } - - def _parse_key(self, key) -> Tuple[str, str]: - """Parse key which is in format 'processor|||model'""" - if isinstance(key, str) and "|||" in key: - parts = key.split("|||", 1) - return parts[0], parts[1] - else: - # Fallback for backwards compatibility or malformed keys - return str(key), "unknown" - - def _infer_provider_from_model(self, model: str, service_type: str) -> str: - """Infer provider from model name""" - if not model: - return "unknown" - - model_lower = model.lower() - - # OpenAI models - if any(keyword in model_lower for keyword in ["gpt", "whisper", "openai"]): - return ServiceProviders.OPENAI - - # Groq models - if any(keyword in model_lower for keyword in ["groq"]): - return ServiceProviders.GROQ - - # Elevenlabs models - if any(keyword in model_lower for keyword in ["eleven"]): - return ServiceProviders.ELEVENLABS - - # Deepgram models - if any( - keyword in model_lower - for keyword in ["deepgram", "nova", "phonecall", "general"] - ): - return ServiceProviders.DEEPGRAM - - # Default to first available provider for the service type - service_providers = self.pricing_registry.get(service_type, {}) - if service_providers: - return list(service_providers.keys())[0] - - return "unknown" - - def _infer_provider_from_processor(self, processor: str, service_type: str) -> str: - """Infer provider from processor name""" - if not processor: - return "unknown" - - processor_lower = processor.lower() - - # OpenAI processors - if any(keyword in processor_lower for keyword in ["openai", "gpt"]): - return ServiceProviders.OPENAI - - # Groq processors - if any(keyword in processor_lower for keyword in ["groq"]): - return ServiceProviders.GROQ - - # Deepgram processors - if any(keyword in processor_lower for keyword in ["deepgram"]): - return ServiceProviders.DEEPGRAM - - # Default to first available provider for the service type - service_providers = self.pricing_registry.get(service_type, {}) - if service_providers: - return list(service_providers.keys())[0] - - return "unknown" - - def update_pricing( - self, service_type: str, provider: str, model: str, pricing_model: PricingModel - ): - """Update pricing for a specific service/provider/model combination""" - if service_type not in self.pricing_registry: - self.pricing_registry[service_type] = {} - if provider not in self.pricing_registry[service_type]: - self.pricing_registry[service_type][provider] = {} - self.pricing_registry[service_type][provider][model] = pricing_model - - -# Global cost calculator instance -cost_calculator = CostCalculator() diff --git a/api/services/pricing/embeddings.py b/api/services/pricing/embeddings.py deleted file mode 100644 index a58a8caa..00000000 --- a/api/services/pricing/embeddings.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -Embeddings pricing models for different providers. - -Prices are per token for embedding models. -""" - -from decimal import Decimal -from typing import Dict - -from api.services.configuration.registry import ServiceProviders - -from .models import PricingModel - - -class EmbeddingPricingModel(PricingModel): - """Pricing model for token-based embedding services.""" - - def __init__(self, token_price: Decimal): - """Initialize with price per token. - - Args: - token_price: Cost per token for embedding - """ - self.token_price = token_price - - def calculate_cost(self, token_count: int) -> Decimal: - """Calculate cost for embedding token usage.""" - return Decimal(token_count) * self.token_price - - -# Embeddings pricing registry -EMBEDDINGS_PRICING: Dict[str, Dict[str, EmbeddingPricingModel]] = { - ServiceProviders.OPENAI: { - "text-embedding-3-small": EmbeddingPricingModel( - token_price=Decimal("0.02") / 1_000_000, # $0.02 per 1M tokens - ), - "text-embedding-3-large": EmbeddingPricingModel( - token_price=Decimal("0.13") / 1_000_000, # $0.13 per 1M tokens - ), - "text-embedding-ada-002": EmbeddingPricingModel( - token_price=Decimal("0.10") / 1_000_000, # $0.10 per 1M tokens (legacy) - ), - }, -} diff --git a/api/services/pricing/llm.py b/api/services/pricing/llm.py deleted file mode 100644 index addb59bc..00000000 --- a/api/services/pricing/llm.py +++ /dev/null @@ -1,143 +0,0 @@ -""" -LLM pricing models for different providers. - -Prices are per 1000 tokens for most models, with some newer models priced per million tokens. -""" - -from decimal import Decimal -from typing import Dict - -from api.services.configuration.registry import ServiceProviders - -from .models import TokenPricingModel - -# LLM pricing registry -LLM_PRICING: Dict[str, Dict[str, TokenPricingModel]] = { - ServiceProviders.OPENAI: { - "gpt-3.5-turbo": TokenPricingModel( - prompt_token_price=Decimal("0.0015") / 1000, # $0.0015 per 1K tokens - completion_token_price=Decimal("0.002") / 1000, # $0.002 per 1K tokens - ), - "gpt-4": TokenPricingModel( - prompt_token_price=Decimal("0.03") / 1000, # $0.03 per 1K tokens - completion_token_price=Decimal("0.06") / 1000, # $0.06 per 1K tokens - ), - "gpt-4.1": TokenPricingModel( - prompt_token_price=Decimal("2.00") / 1000000, # $2.00 per 1M tokens - completion_token_price=Decimal("8.00") / 1000000, # $8.00 per 1M tokens - ), - "gpt-4.1-mini": TokenPricingModel( - prompt_token_price=Decimal("0.40") / 1000000, # $0.40 per 1M tokens - completion_token_price=Decimal("1.60") / 1000000, # $1.60 per 1M tokens - ), - "gpt-4.1-nano": TokenPricingModel( - prompt_token_price=Decimal("0.10") / 1000000, # $0.10 per 1M tokens - completion_token_price=Decimal("0.40") / 1000000, # $0.40 per 1M tokens - ), - "gpt-4.5-preview": TokenPricingModel( - prompt_token_price=Decimal("75.00") / 1000000, # $75.00 per 1M tokens - completion_token_price=Decimal("150.00") / 1000000, # $150.00 per 1M tokens - ), - "gpt-4o": TokenPricingModel( - prompt_token_price=Decimal("2.50") / 1000000, # $2.50 per 1M tokens - FIXED - completion_token_price=Decimal("10.00") - / 1000000, # $10.00 per 1M tokens - FIXED - ), - "gpt-4o-audio-preview": TokenPricingModel( - prompt_token_price=Decimal("2.50") / 1000000, # $2.50 per 1M tokens - completion_token_price=Decimal("10.00") / 1000000, # $10.00 per 1M tokens - ), - "gpt-4o-realtime-preview": TokenPricingModel( - prompt_token_price=Decimal("5.00") / 1000000, # $5.00 per 1M tokens - completion_token_price=Decimal("20.00") / 1000000, # $20.00 per 1M tokens - ), - "gpt-4o-mini": TokenPricingModel( - prompt_token_price=Decimal("0.15") / 1000000, # $0.15 per 1M tokens - completion_token_price=Decimal("0.60") / 1000000, # $0.60 per 1M tokens - ), - "gpt-4o-mini-audio-preview": TokenPricingModel( - prompt_token_price=Decimal("0.15") / 1000000, # $0.15 per 1M tokens - completion_token_price=Decimal("0.60") / 1000000, # $0.60 per 1M tokens - ), - "gpt-4o-mini-realtime-preview": TokenPricingModel( - prompt_token_price=Decimal("0.60") / 1000000, # $0.60 per 1M tokens - completion_token_price=Decimal("2.40") / 1000000, # $2.40 per 1M tokens - ), - "gpt-4o-search-preview": TokenPricingModel( - prompt_token_price=Decimal("2.50") / 1000000, # $2.50 per 1M tokens - completion_token_price=Decimal("10.00") / 1000000, # $10.00 per 1M tokens - ), - "gpt-4o-mini-search-preview": TokenPricingModel( - prompt_token_price=Decimal("0.15") / 1000000, # $0.15 per 1M tokens - completion_token_price=Decimal("0.60") / 1000000, # $0.60 per 1M tokens - ), - "o1": TokenPricingModel( - prompt_token_price=Decimal("15.00") / 1000000, # $15.00 per 1M tokens - completion_token_price=Decimal("60.00") / 1000000, # $60.00 per 1M tokens - ), - "o1-pro": TokenPricingModel( - prompt_token_price=Decimal("150.00") / 1000000, # $150.00 per 1M tokens - completion_token_price=Decimal("600.00") / 1000000, # $600.00 per 1M tokens - ), - "o1-mini": TokenPricingModel( - prompt_token_price=Decimal("1.10") / 1000000, # $1.10 per 1M tokens - completion_token_price=Decimal("4.40") / 1000000, # $4.40 per 1M tokens - ), - "o3": TokenPricingModel( - prompt_token_price=Decimal("10.00") / 1000000, # $10.00 per 1M tokens - completion_token_price=Decimal("40.00") / 1000000, # $40.00 per 1M tokens - ), - "o3-mini": TokenPricingModel( - prompt_token_price=Decimal("1.10") / 1000000, # $1.10 per 1M tokens - completion_token_price=Decimal("4.40") / 1000000, # $4.40 per 1M tokens - ), - "o4-mini": TokenPricingModel( - prompt_token_price=Decimal("1.10") / 1000000, # $1.10 per 1M tokens - completion_token_price=Decimal("4.40") / 1000000, # $4.40 per 1M tokens - ), - "computer-use-preview": TokenPricingModel( - prompt_token_price=Decimal("3.00") / 1000000, # $3.00 per 1M tokens - completion_token_price=Decimal("12.00") / 1000000, # $12.00 per 1M tokens - ), - "gpt-image-1": TokenPricingModel( - prompt_token_price=Decimal("5.00") / 1000000, # $5.00 per 1M tokens - completion_token_price=Decimal("0") / 1000000, # No output pricing shown - ), - "codex-mini-latest": TokenPricingModel( - prompt_token_price=Decimal("1.50") / 1000000, # $1.50 per 1M tokens - completion_token_price=Decimal("6.00") / 1000000, # $6.00 per 1M tokens - ), - # Transcription models - "gpt-4o-transcribe": TokenPricingModel( - prompt_token_price=Decimal("2.50") / 1000000, # $2.50 per 1M tokens - completion_token_price=Decimal("10.00") / 1000000, # $10.00 per 1M tokens - ), - "gpt-4o-mini-transcribe": TokenPricingModel( - prompt_token_price=Decimal("1.25") / 1000000, # $1.25 per 1M tokens - completion_token_price=Decimal("5.00") / 1000000, # $5.00 per 1M tokens - ), - # TTS models with token-based pricing - "gpt-4o-mini-tts": TokenPricingModel( - prompt_token_price=Decimal("0.60") / 1000000, # $0.60 per 1M tokens - completion_token_price=Decimal("0") - / 1000000, # No completion tokens for TTS - ), - }, - ServiceProviders.GROQ: { - "llama-3.3-70b-versatile": TokenPricingModel( - prompt_token_price=Decimal("0.00059") / 1000, # $0.00059 per 1K tokens - completion_token_price=Decimal("0.00079") / 1000, # $0.00079 per 1K tokens - ), - "deepseek-r1-distill-llama-70b": TokenPricingModel( - prompt_token_price=Decimal("0.00059") / 1000, # Assuming similar pricing - completion_token_price=Decimal("0.00079") / 1000, - ), - }, - ServiceProviders.AZURE: { - "gpt-4.1-mini": TokenPricingModel( - prompt_token_price=Decimal("0.44") / 1000000, # $0.40 per 1M tokens - completion_token_price=Decimal("8.80") - / 1000000, # $1.60 per 1M tokens if using data zone - ) - }, -} diff --git a/api/services/pricing/models.py b/api/services/pricing/models.py deleted file mode 100644 index 58e197ac..00000000 --- a/api/services/pricing/models.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -Base pricing models for different service types. -""" - -from decimal import Decimal -from enum import Enum -from typing import Any, Dict - - -class CostType(Enum): - LLM_TOKENS = "llm_tokens" - TTS_CHARACTERS = "tts_characters" - STT_SECONDS = "stt_seconds" - - -class PricingModel: - """Base class for pricing models""" - - def calculate_cost(self, usage: Any) -> Decimal: - """Calculate cost based on usage""" - raise NotImplementedError - - -class TokenPricingModel(PricingModel): - """Pricing model for token-based services (LLM)""" - - def __init__( - self, - prompt_token_price: Decimal, - completion_token_price: Decimal, - cache_read_discount: Decimal = Decimal("0.5"), # 50% discount for cache reads - cache_creation_multiplier: Decimal = Decimal( - "1.25" - ), # 25% premium for cache creation - ): - self.prompt_token_price = prompt_token_price - self.completion_token_price = completion_token_price - self.cache_read_discount = cache_read_discount - self.cache_creation_multiplier = cache_creation_multiplier - - def calculate_cost(self, usage: Dict[str, int]) -> Decimal: - """Calculate cost for LLM token usage""" - prompt_tokens = usage.get("prompt_tokens", 0) - completion_tokens = usage.get("completion_tokens", 0) - cache_read_tokens = usage.get("cache_read_input_tokens") or 0 - cache_creation_tokens = usage.get("cache_creation_input_tokens") or 0 - - # Base cost - prompt_cost = Decimal(prompt_tokens) * self.prompt_token_price - completion_cost = Decimal(completion_tokens) * self.completion_token_price - - # Cache adjustments - cache_read_savings = ( - Decimal(cache_read_tokens) - * self.prompt_token_price - * self.cache_read_discount - ) - cache_creation_premium = ( - Decimal(cache_creation_tokens) - * self.prompt_token_price - * (self.cache_creation_multiplier - 1) - ) - - total_cost = ( - prompt_cost + completion_cost - cache_read_savings + cache_creation_premium - ) - return max(total_cost, Decimal("0")) # Ensure non-negative - - -class CharacterPricingModel(PricingModel): - """Pricing model for character-based services (TTS)""" - - def __init__(self, character_price: Decimal): - self.character_price = character_price - - def calculate_cost(self, character_count: int) -> Decimal: - """Calculate cost for TTS character usage""" - return Decimal(character_count) * self.character_price - - -class TimePricingModel(PricingModel): - """Pricing model for time-based services (STT)""" - - def __init__(self, second_price: Decimal): - self.second_price = second_price - - def calculate_cost(self, seconds: float) -> Decimal: - """Calculate cost for STT time usage""" - return Decimal(str(seconds)) * self.second_price diff --git a/api/services/pricing/registry.py b/api/services/pricing/registry.py deleted file mode 100644 index 294a94a2..00000000 --- a/api/services/pricing/registry.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -Main pricing registry that combines all service type pricing models. -""" - -from typing import Dict - -from .embeddings import EMBEDDINGS_PRICING -from .llm import LLM_PRICING -from .stt import STT_PRICING -from .tts import TTS_PRICING - -# Combined pricing registry for all service types -PRICING_REGISTRY: Dict = { - "llm": LLM_PRICING, - "tts": TTS_PRICING, - "stt": STT_PRICING, - "embeddings": EMBEDDINGS_PRICING, -} diff --git a/api/services/pricing/run_usage_response.py b/api/services/pricing/run_usage_response.py deleted file mode 100644 index a1f85a47..00000000 --- a/api/services/pricing/run_usage_response.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Format workflow run usage for public API responses.""" - - -def format_public_usage_info(usage_info: dict | None) -> dict | None: - if not usage_info: - return None - - return { - "llm": usage_info.get("llm") or {}, - "tts": usage_info.get("tts") or {}, - "stt": usage_info.get("stt") or {}, - "call_duration_seconds": usage_info.get("call_duration_seconds"), - } diff --git a/api/services/pricing/stt.py b/api/services/pricing/stt.py deleted file mode 100644 index ca00ff4c..00000000 --- a/api/services/pricing/stt.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -STT (Speech-to-Text) pricing models for different providers. - -Prices are per second for STT services. -""" - -from decimal import Decimal -from typing import Dict - -from api.services.configuration.registry import ServiceProviders - -from .models import TimePricingModel - -# STT pricing registry -STT_PRICING: Dict[str, Dict[str, TimePricingModel]] = { - ServiceProviders.DEEPGRAM: { - "nova-3-general": TimePricingModel(Decimal("0.0077") / 60), - "nova-2": TimePricingModel(Decimal("0.0058") / 60), - "default": TimePricingModel(Decimal("0.0077") / 60), - }, - ServiceProviders.OPENAI: { - "gpt-4o-transcribe": TimePricingModel(Decimal("0.015") / 60), - "default": TimePricingModel(Decimal("0.015") / 60), - }, - "default": {"default": TimePricingModel(Decimal("0.0077") / 60)}, -} diff --git a/api/services/pricing/tts.py b/api/services/pricing/tts.py deleted file mode 100644 index 7485cc7f..00000000 --- a/api/services/pricing/tts.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -TTS (Text-to-Speech) pricing models for different providers. - -Prices are per character for TTS services. -""" - -from decimal import Decimal -from typing import Dict - -from api.services.configuration.registry import ServiceProviders - -from .models import CharacterPricingModel - -# TTS pricing registry -TTS_PRICING: Dict[str, Dict[str, CharacterPricingModel]] = { - ServiceProviders.OPENAI: { - "gpt-4o-mini-tts": CharacterPricingModel(Decimal("0.6") / 1_00_00_000), - "default": CharacterPricingModel(Decimal("0.6") / 1_00_00_000), - }, - ServiceProviders.DEEPGRAM: { - "aura-2": CharacterPricingModel(Decimal("0.030") / 1_000), - "aura-1": CharacterPricingModel(Decimal("0.015") / 1_000), - "default": CharacterPricingModel(Decimal("0.030") / 1_000), - }, - ServiceProviders.ELEVENLABS: { - # 6400 usd per 250*1e6 characters - "default": CharacterPricingModel(Decimal("0.0256") / 1_000) - }, - "default": {"default": CharacterPricingModel(Decimal("0.030") / 1_000)}, -} diff --git a/api/services/pricing/workflow_run_cost.py b/api/services/pricing/workflow_run_cost.py deleted file mode 100644 index 6d6010c3..00000000 --- a/api/services/pricing/workflow_run_cost.py +++ /dev/null @@ -1,230 +0,0 @@ -from decimal import Decimal - -from loguru import logger - -from api.db import db_client -from api.enums import WorkflowRunMode -from api.services.pricing.cost_calculator import cost_calculator -from api.services.telephony.factory import get_telephony_provider_for_run - - -async def _fetch_telephony_cost(workflow_run) -> dict | None: - """Fetch telephony call cost. Returns a dict with cost_usd and provider_name, or None.""" - if ( - workflow_run.mode - not in [WorkflowRunMode.TWILIO.value, WorkflowRunMode.VONAGE.value] - or not workflow_run.cost_info - ): - return None - - call_id = workflow_run.cost_info.get("call_id") - if not call_id: - logger.warning(f"call_id not found in cost_info") - return None - - provider_name = workflow_run.mode.lower() if workflow_run.mode else "" - - workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) - if not workflow: - logger.warning("Workflow not found for workflow run") - raise Exception("Workflow not found") - - provider = await get_telephony_provider_for_run( - workflow_run, workflow.organization_id - ) - call_cost_info = await provider.get_call_cost(call_id) - - if call_cost_info.get("status") == "error": - logger.error( - f"Failed to fetch {provider_name} call cost: {call_cost_info.get('error')}" - ) - return None - - cost_usd = call_cost_info.get("cost_usd", 0.0) - logger.info( - f"{provider_name.title()} call cost: ${cost_usd:.6f} USD for call {call_id}" - ) - return {"cost_usd": cost_usd, "provider_name": provider_name} - - -async def _update_organization_usage( - org, dograh_tokens: float, duration_seconds: float, charge_usd: float | None -) -> None: - """Update organization usage after a workflow run.""" - org_id = org.id - await db_client.update_usage_after_run( - org_id, dograh_tokens, duration_seconds, charge_usd - ) - if charge_usd is not None: - logger.info( - f"Updated organization usage with ${charge_usd:.2f} USD ({dograh_tokens} Dograh Tokens) and {duration_seconds}s duration for org {org_id}" - ) - else: - logger.info( - f"Updated organization usage with {dograh_tokens} Dograh Tokens and {duration_seconds}s duration for org {org_id}" - ) - - -async def _get_pricing_organization(workflow_run): - workflow = getattr(workflow_run, "workflow", None) - organization_id = getattr(workflow, "organization_id", None) - if organization_id is None and workflow and workflow.user: - organization_id = workflow.user.selected_organization_id - if organization_id is None: - return None - return await db_client.get_organization_by_id(organization_id) - - -async def _build_usage_cost_snapshot( - usage_info: dict | None, - *, - workflow_run=None, - include_telephony_cost: bool = False, - organization=None, - calculated_at: str | None = None, -) -> dict | None: - if not usage_info: - logger.warning("No usage info available for workflow run") - return None - - cost_breakdown = cost_calculator.calculate_total_cost(usage_info) - - if include_telephony_cost and workflow_run is not None: - try: - telephony_cost = await _fetch_telephony_cost(workflow_run) - if telephony_cost: - telephony_cost_usd = telephony_cost["cost_usd"] - provider_name = telephony_cost["provider_name"] - cost_breakdown["telephony_call"] = telephony_cost_usd - cost_breakdown[f"{provider_name}_call"] = telephony_cost_usd - cost_breakdown["total"] = ( - float(cost_breakdown["total"]) + telephony_cost_usd - ) - except Exception as e: - logger.error(f"Failed to fetch telephony call cost: {e}") - # Don't fail the whole cost calculation if telephony API fails - - total_cost_usd = Decimal(str(cost_breakdown["total"])) - dograh_tokens = float(total_cost_usd * Decimal("100")) - - if organization is None and workflow_run is not None: - organization = await _get_pricing_organization(workflow_run) - - charge_usd = None - if organization and organization.price_per_second_usd: - duration_seconds = usage_info.get("call_duration_seconds", 0) - charge_usd = float( - Decimal(str(duration_seconds)) - * Decimal(str(organization.price_per_second_usd)) - ) - - cost_info = { - "cost_breakdown": cost_breakdown, - "total_cost_usd": float(total_cost_usd), - "dograh_token_usage": dograh_tokens, - "calculated_at": calculated_at - or (workflow_run.created_at.isoformat() if workflow_run is not None else None), - "call_duration_seconds": usage_info.get("call_duration_seconds", 0), - } - - if charge_usd is not None: - cost_info["charge_usd"] = charge_usd - cost_info["price_per_second_usd"] = organization.price_per_second_usd - - return cost_info - - -async def build_workflow_run_cost_info(workflow_run) -> dict | None: - cost_info = await _build_usage_cost_snapshot( - workflow_run.usage_info, - workflow_run=workflow_run, - include_telephony_cost=True, - calculated_at=workflow_run.created_at.isoformat(), - ) - if cost_info is None: - return None - return { - **(workflow_run.cost_info or {}), - **cost_info, - } - - -async def save_workflow_run_cost_info( - workflow_run_id: int, cost_info: dict | None -) -> None: - if cost_info is None: - return - await db_client.update_workflow_run(run_id=workflow_run_id, cost_info=cost_info) - - -async def apply_workflow_run_usage_to_organization( - workflow_run, cost_info: dict | None -) -> None: - if cost_info is None: - return - - org = await _get_pricing_organization(workflow_run) - if not org: - return - - await _update_organization_usage( - org, - float(cost_info.get("dograh_token_usage") or 0), - float(cost_info.get("call_duration_seconds") or 0), - cost_info.get("charge_usd"), - ) - - -async def apply_usage_delta_to_organization( - workflow_run, usage_info: dict | None -) -> dict | None: - org = await _get_pricing_organization(workflow_run) - if not org: - return None - - cost_info = await _build_usage_cost_snapshot(usage_info, organization=org) - if cost_info is None: - return None - - await _update_organization_usage( - org, - float(cost_info.get("dograh_token_usage") or 0), - float(cost_info.get("call_duration_seconds") or 0), - cost_info.get("charge_usd"), - ) - return cost_info - - -async def calculate_workflow_run_cost(workflow_run_id: int): - logger.debug("Calculating cost for workflow run") - - workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) - if not workflow_run: - logger.warning("Workflow run not found") - return - - try: - cost_info = await build_workflow_run_cost_info(workflow_run) - if cost_info is None: - return - - await save_workflow_run_cost_info(workflow_run_id, cost_info) - - try: - await apply_workflow_run_usage_to_organization(workflow_run, cost_info) - except Exception as e: - org = await _get_pricing_organization(workflow_run) - if org: - logger.error( - f"Failed to update organization usage for org {org.id}: {e}" - ) - else: - logger.error(f"Failed to update organization usage: {e}") - # Don't fail the whole cost calculation if usage update fails - - logger.info( - f"Calculated cost for workflow run: ${cost_info['total_cost_usd']:.6f} USD ({cost_info['dograh_token_usage']} Dograh Tokens)" - ) - except Exception as e: - logger.error(f"Error calculating cost for workflow run: {e}") - raise diff --git a/api/services/quota_service.py b/api/services/quota_service.py index 23c7120d..6633736e 100644 --- a/api/services/quota_service.py +++ b/api/services/quota_service.py @@ -5,15 +5,38 @@ across different endpoints (WebRTC signaling, telephony, public API triggers). """ from dataclasses import dataclass +from typing import Any from loguru import logger +from api.constants import DEPLOYMENT_MODE from api.db import db_client from api.db.models import UserModel +from api.services.configuration.ai_model_configuration import ( + get_effective_ai_model_configuration_for_workflow, +) from api.services.configuration.registry import ServiceProviders -from api.services.configuration.resolve import resolve_effective_config +from api.services.managed_model_services import ( + MPS_CORRELATION_ID_CONTEXT_KEY, + get_dograh_service_api_key, + uses_managed_model_services_v2, +) from api.services.mps_service_key_client import mps_service_key_client +MINIMUM_DOGRAH_CREDITS_FOR_CALL = 0.10 + +LEGACY_QUOTA_EXCEEDED_MESSAGE = ( + "You have exhausted your trial credits. " + "Please email founders@dograh.com for additional Dograh credits " + "or change providers in Models configurations." +) + +BILLING_V2_QUOTA_EXCEEDED_MESSAGE = ( + "You have exhausted your Dograh credits. " + "Please purchase more credits from /billing " + "or change providers in Models configurations." +) + @dataclass class QuotaCheckResult: @@ -24,104 +47,359 @@ class QuotaCheckResult: error_code: str = "" -async def check_dograh_quota( - user: UserModel, workflow_id: int | None = None -) -> QuotaCheckResult: - """Check if user has sufficient Dograh quota for making a call. - - This function checks if the user is using any Dograh services (LLM, STT, TTS) - and validates that they have sufficient credits remaining. - - When ``workflow_id`` is provided, the workflow's per-workflow - ``model_overrides`` are merged onto the user's global config so the quota - check runs against the credentials that will actually be used for the call - (rather than always falling back to the user's defaults). - - Args: - user: The user to check quota for - workflow_id: Optional workflow whose ``model_overrides`` should be - applied when resolving the effective service config. - - Returns: - QuotaCheckResult with has_quota=True if user has sufficient quota or - is not using Dograh services, or has_quota=False with error_message - if quota is insufficient. - """ +def _safe_float(value: Any, default: float = 0.0) -> float: try: - # Get user configurations - user_config = await db_client.get_user_configurations(user.id) + return float(value) + except (TypeError, ValueError): + return default - if workflow_id is not None: - workflow = await db_client.get_workflow_by_id(workflow_id) - if workflow: - model_overrides = (workflow.workflow_configurations or {}).get( - "model_overrides" + +def _insufficient_billing_v2_quota_result() -> QuotaCheckResult: + return QuotaCheckResult( + has_quota=False, + error_code="insufficient_credits", + error_message=BILLING_V2_QUOTA_EXCEEDED_MESSAGE, + ) + + +def _insufficient_legacy_quota_result() -> QuotaCheckResult: + return QuotaCheckResult( + has_quota=False, + error_code="quota_exceeded", + error_message=LEGACY_QUOTA_EXCEEDED_MESSAGE, + ) + + +def _service_uses_dograh(service: Any) -> bool: + provider = getattr(service, "provider", None) + return ( + provider == ServiceProviders.DOGRAH or provider == ServiceProviders.DOGRAH.value + ) + + +def _dograh_api_keys(user_config: Any) -> set[str]: + api_keys: set[str] = set() + for section_name in ("llm", "stt", "tts", "embeddings"): + service = getattr(user_config, section_name, None) + if not _service_uses_dograh(service): + continue + if hasattr(service, "get_all_api_keys"): + all_api_keys = [ + api_key + for api_key in service.get_all_api_keys() + if isinstance(api_key, str) and api_key + ] + if all_api_keys: + api_keys.update(all_api_keys) + continue + api_key = getattr(service, "api_key", None) + if api_key: + api_keys.add(api_key) + return api_keys + + +async def _store_run_correlation_id( + workflow_run_id: int | None, + correlation_id: str | None, +) -> None: + if not workflow_run_id or not correlation_id: + return + + workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) + if not workflow_run: + logger.warning( + "Could not store MPS correlation id for missing workflow run {}", + workflow_run_id, + ) + return + + initial_context = dict(workflow_run.initial_context or {}) + if initial_context.get(MPS_CORRELATION_ID_CONTEXT_KEY) == correlation_id: + return + + initial_context[MPS_CORRELATION_ID_CONTEXT_KEY] = correlation_id + await db_client.update_workflow_run( + workflow_run_id, + initial_context=initial_context, + ) + + +async def _authorize_hosted_workflow_run_start( + *, + workflow_owner: UserModel, + organization_id: int | None, + workflow_id: int | None, + workflow_run_id: int | None, + user_config: Any, +) -> tuple[QuotaCheckResult, bool]: + """Authorize hosted v2 billing and return whether MPS handled enforcement.""" + if DEPLOYMENT_MODE == "oss" or organization_id is None: + return QuotaCheckResult(has_quota=True), False + + requires_correlation = bool( + workflow_run_id and uses_managed_model_services_v2(user_config) + ) + service_key = ( + get_dograh_service_api_key(user_config) if requires_correlation else None + ) + if requires_correlation and not service_key: + return ( + QuotaCheckResult( + has_quota=False, + error_code="invalid_service_key", + error_message=( + "You have invalid keys in your model configuration. " + "Please validate the service keys." + ), + ), + True, + ) + + try: + authorization = await mps_service_key_client.authorize_workflow_run_start( + organization_id=organization_id, + workflow_run_id=workflow_run_id, + service_key=service_key, + require_correlation_id=requires_correlation, + minimum_credits=MINIMUM_DOGRAH_CREDITS_FOR_CALL, + created_by=( + str(workflow_owner.provider_id) + if workflow_owner.provider_id is not None + else None + ), + metadata={ + "dograh_user_id": str(workflow_owner.id), + "workflow_id": workflow_id, + }, + ) + except Exception as e: + logger.error( + "Failed to authorize workflow start with MPS for org {}: {}", + organization_id, + e, + ) + return ( + QuotaCheckResult( + has_quota=False, + error_code="quota_check_failed", + error_message="Could not verify Dograh credits. Please try again.", + ), + True, + ) + + billing_mode = authorization.get("billing_mode") + if billing_mode != "v2": + return QuotaCheckResult(has_quota=True), False + + remaining = _safe_float(authorization.get("remaining_credits")) + if ( + not authorization.get("allowed", False) + or remaining < MINIMUM_DOGRAH_CREDITS_FOR_CALL + ): + logger.warning( + "Insufficient Dograh billing v2 credits for org {}: {:.2f} credits remaining", + organization_id, + remaining, + ) + return _insufficient_billing_v2_quota_result(), True + + try: + await _store_run_correlation_id( + workflow_run_id, + authorization.get("correlation_id"), + ) + except Exception as e: + logger.error( + "Failed to store MPS correlation id for workflow_run_id {}: {}", + workflow_run_id, + e, + ) + return ( + QuotaCheckResult( + has_quota=False, + error_code="quota_check_failed", + error_message="Could not verify Dograh credits. Please try again.", + ), + True, + ) + logger.info( + "Dograh billing v2 run authorization passed for org {}: {:.2f} credits remaining", + organization_id, + remaining, + ) + return QuotaCheckResult(has_quota=True), True + + +async def _authorize_legacy_dograh_keys( + *, + dograh_api_keys: set[str], + organization_id: int | None, + workflow_owner: UserModel, +) -> QuotaCheckResult: + for api_key in dograh_api_keys: + try: + usage = await mps_service_key_client.check_service_key_usage( + api_key, + organization_id=organization_id, + created_by=workflow_owner.provider_id, + ) + remaining = usage.get("remaining_credits", 0.0) + + # Require at least $0.10 for a short call + if remaining < MINIMUM_DOGRAH_CREDITS_FOR_CALL: + logger.warning( + f"Insufficient Dograh credits for key ...{api_key[-8:]}: " + f"${remaining:.2f} remaining" ) - if model_overrides: - user_config = resolve_effective_config(user_config, model_overrides) + return _insufficient_legacy_quota_result() - # Check if user is using any Dograh service - using_dograh = False - dograh_api_keys = set() - - if user_config.llm and user_config.llm.provider == ServiceProviders.DOGRAH: - using_dograh = True - dograh_api_keys.add(user_config.llm.api_key) - - if user_config.stt and user_config.stt.provider == ServiceProviders.DOGRAH: - using_dograh = True - dograh_api_keys.add(user_config.stt.api_key) - - if user_config.tts and user_config.tts.provider == ServiceProviders.DOGRAH: - using_dograh = True - dograh_api_keys.add(user_config.tts.api_key) - - # If not using Dograh, quota check passes - if not using_dograh: - return QuotaCheckResult(has_quota=True) - - # Check quota for ALL Dograh keys - for api_key in dograh_api_keys: - try: - usage = await mps_service_key_client.check_service_key_usage( - api_key, created_by=user.provider_id - ) - remaining = usage.get("remaining_credits", 0.0) - - # Require at least $0.10 for a short call - if remaining < 0.10: - logger.warning( - f"Insufficient Dograh credits for key ...{api_key[-8:]}: " - f"${remaining:.2f} remaining" - ) - return QuotaCheckResult( - has_quota=False, - error_code="quota_exceeded", - error_message=( - "You have exhausted your trial credits. " - "Please email founders@dograh.com for additional Dograh credits " - "or change providers in Models configurations." - ), - ) - - logger.info( - f"Dograh quota check passed for key ...{api_key[-8:]}: " - f"{remaining:.2f} credits remaining" - ) - except Exception as e: - logger.error(f"Failed to check quota for Dograh key: {str(e)}") - error_str = str(e) - if "404" in error_str or "not found" in error_str.lower(): - return QuotaCheckResult( - has_quota=False, - error_code="invalid_service_key", - error_message="You have invalid keys in your model configuration. Please validate the service keys.", - ) + logger.info( + f"Dograh quota check passed for key ...{api_key[-8:]}: " + f"{remaining:.2f} credits remaining" + ) + except Exception as e: + logger.error(f"Failed to check quota for Dograh key: {str(e)}") + error_str = str(e) + if "404" in error_str or "not found" in error_str.lower(): return QuotaCheckResult( has_quota=False, - error_code="quota_check_failed", - error_message="Could not verify Dograh credits. Please try again.", + error_code="invalid_service_key", + error_message="You have invalid keys in your model configuration. Please validate the service keys.", ) + return QuotaCheckResult( + has_quota=False, + error_code="quota_check_failed", + error_message="Could not verify Dograh credits. Please try again.", + ) + + return QuotaCheckResult(has_quota=True) + + +async def _authorize_oss_managed_v2_correlation( + *, + workflow_id: int, + workflow_run_id: int | None, + user_config: Any, +) -> QuotaCheckResult: + if not workflow_run_id or not uses_managed_model_services_v2(user_config): + return QuotaCheckResult(has_quota=True) + + service_key = get_dograh_service_api_key(user_config) + if not service_key: + return QuotaCheckResult( + has_quota=False, + error_code="invalid_service_key", + error_message=( + "You have invalid keys in your model configuration. " + "Please validate the service keys." + ), + ) + + try: + response = await mps_service_key_client.create_correlation_id( + service_key=service_key, + workflow_run_id=workflow_run_id, + ) + await _store_run_correlation_id( + workflow_run_id, + response.get("correlation_id"), + ) + except Exception as e: + logger.error( + "Failed to authorize OSS managed v2 workflow start for workflow {} run {}: {}", + workflow_id, + workflow_run_id, + e, + ) + return QuotaCheckResult( + has_quota=False, + error_code="quota_check_failed", + error_message="Could not verify Dograh credits. Please try again.", + ) + + return QuotaCheckResult(has_quota=True) + + +async def authorize_workflow_run_start( + *, + workflow_id: int, + workflow_run_id: int | None = None, + actor_user: UserModel | None = None, +) -> QuotaCheckResult: + """Authorize a workflow run before any billable call/text runtime starts. + + The workflow organization is the billing subject for hosted v2. The workflow + owner is used only to resolve the effective model configuration and legacy + service-key metadata. + """ + try: + workflow = await db_client.get_workflow_by_id(workflow_id) + if not workflow: + return QuotaCheckResult( + has_quota=False, + error_code="workflow_not_found", + error_message="Workflow not found", + ) + + actor_org_id = getattr(actor_user, "selected_organization_id", None) + if actor_org_id is not None and actor_org_id != workflow.organization_id: + logger.warning( + "Workflow start authorization denied: actor org {} does not match workflow {} org {}", + actor_org_id, + workflow_id, + workflow.organization_id, + ) + return QuotaCheckResult( + has_quota=False, + error_code="workflow_not_found", + error_message="Workflow not found", + ) + + workflow_owner = await db_client.get_user_by_id(workflow.user_id) + if not workflow_owner: + return QuotaCheckResult( + has_quota=False, + error_code="user_not_found", + error_message="User not found", + ) + + user_config = await get_effective_ai_model_configuration_for_workflow( + user_id=workflow_owner.id, + organization_id=workflow.organization_id, + workflow_configurations=workflow.workflow_configurations, + ) + + if DEPLOYMENT_MODE != "oss": + hosted_result, hosted_enforced = await _authorize_hosted_workflow_run_start( + workflow_owner=workflow_owner, + organization_id=workflow.organization_id, + workflow_id=workflow.id, + workflow_run_id=workflow_run_id, + user_config=user_config, + ) + if hosted_enforced or not hosted_result.has_quota: + return hosted_result + + dograh_api_keys = _dograh_api_keys(user_config) + if not dograh_api_keys: + return QuotaCheckResult(has_quota=True) + + legacy_result = await _authorize_legacy_dograh_keys( + dograh_api_keys=dograh_api_keys, + organization_id=( + None if DEPLOYMENT_MODE == "oss" else workflow.organization_id + ), + workflow_owner=workflow_owner, + ) + if not legacy_result.has_quota: + return legacy_result + + if DEPLOYMENT_MODE == "oss": + return await _authorize_oss_managed_v2_correlation( + workflow_id=workflow.id, + workflow_run_id=workflow_run_id, + user_config=user_config, + ) return QuotaCheckResult(has_quota=True) @@ -129,30 +407,3 @@ async def check_dograh_quota( logger.error(f"Error during quota check: {str(e)}") # On unexpected error, allow the call to proceed return QuotaCheckResult(has_quota=True) - - -async def check_dograh_quota_by_user_id( - user_id: int, workflow_id: int | None = None -) -> QuotaCheckResult: - """Check Dograh quota by user ID. - - Convenience function that fetches the user and then checks quota. When - ``workflow_id`` is provided, the workflow's ``model_overrides`` are - applied so the quota check evaluates the credentials that will actually - be used for the call. - - Args: - user_id: The ID of the user to check quota for - workflow_id: Optional workflow whose per-workflow overrides should - be applied to the user's config before checking quota. - - Returns: - QuotaCheckResult with quota status - """ - user = await db_client.get_user_by_id(user_id) - if not user: - return QuotaCheckResult( - has_quota=False, - error_message="User not found", - ) - return await check_dograh_quota(user, workflow_id=workflow_id) diff --git a/api/services/reports/run_report.py b/api/services/reports/run_report.py index b84a6f96..a5e64819 100644 --- a/api/services/reports/run_report.py +++ b/api/services/reports/run_report.py @@ -53,7 +53,7 @@ def build_run_report_csv(runs: List[Any]) -> io.StringIO: for run in runs: initial = run.initial_context or {} gathered = run.gathered_context or {} - cost = run.cost_info or {} + usage = run.usage_info or {} call_tags = gathered.get("call_tags", []) if isinstance(call_tags, list): @@ -67,7 +67,7 @@ def build_run_report_csv(runs: List[Any]) -> io.StringIO: run.created_at.isoformat() if run.created_at else "", initial.get("phone_number", ""), gathered.get("mapped_call_disposition", ""), - cost.get("call_duration_seconds", ""), + usage.get("call_duration_seconds", ""), ] extracted = gathered.get("extracted_variables", {}) diff --git a/api/services/telephony/ari_manager.py b/api/services/telephony/ari_manager.py index a10c05dc..2648affd 100644 --- a/api/services/telephony/ari_manager.py +++ b/api/services/telephony/ari_manager.py @@ -26,7 +26,7 @@ from loguru import logger from api.constants import REDIS_URL from api.db import db_client from api.enums import CallType, WorkflowRunMode -from api.services.quota_service import check_dograh_quota_by_user_id +from api.services.quota_service import authorize_workflow_run_start from api.services.telephony.call_transfer_manager import get_call_transfer_manager from api.services.telephony.transfer_event_protocol import ( TransferEvent, @@ -564,19 +564,7 @@ class ARIConnection: user_id = workflow.user_id - # 3. Check quota (apply per-workflow model_overrides). - quota_result = await check_dograh_quota_by_user_id( - user_id, workflow_id=inbound_workflow_id - ) - if not quota_result.has_quota: - logger.warning( - f"[ARI org={self.organization_id}] Quota exceeded for user {user_id} " - f"— hanging up inbound call {channel_id}" - ) - await self._delete_channel(channel_id) - return - - # 4. Create workflow run + # 3. Create workflow run call_id = channel_id workflow_run = await db_client.create_workflow_run( name=f"ARI Inbound {caller_number}", @@ -602,6 +590,20 @@ class ARIConnection: f"(caller={caller_number}, called={called_number})" ) + # 4. Check quota after the run exists so hosted v2 can mint and + # store the MPS correlation id before the pipeline starts. + quota_result = await authorize_workflow_run_start( + workflow_id=inbound_workflow_id, + workflow_run_id=workflow_run.id, + ) + if not quota_result.has_quota: + logger.warning( + f"[ARI org={self.organization_id}] Quota exceeded for user {user_id} " + f"— hanging up inbound call {channel_id}" + ) + await self._delete_channel(channel_id) + return + # 5. Answer the inbound channel await self._answer_channel(channel_id) diff --git a/api/services/telephony/providers/cloudonix/routes.py b/api/services/telephony/providers/cloudonix/routes.py index cd4758a6..facf4bdb 100644 --- a/api/services/telephony/providers/cloudonix/routes.py +++ b/api/services/telephony/providers/cloudonix/routes.py @@ -103,7 +103,8 @@ async def handle_cloudonix_cdr(request: Request): return {"status": "error", "message": "Missing domain field"} # Extract call_id to find workflow run - call_id = cdr_data.get("session").get("token") + session = cdr_data.get("session") + call_id = session.get("token") if isinstance(session, dict) else None logger.info(f"Cloudonix CDR data for call id {call_id} - {cdr_data}") if not call_id: logger.warning("Cloudonix CDR missing call_id field") diff --git a/api/services/telephony/providers/vobiz/routes.py b/api/services/telephony/providers/vobiz/routes.py index 15c2def9..6e8e1317 100644 --- a/api/services/telephony/providers/vobiz/routes.py +++ b/api/services/telephony/providers/vobiz/routes.py @@ -6,9 +6,8 @@ provider registry — see ProviderSpec.router. import json from datetime import UTC, datetime -from typing import Optional -from fastapi import APIRouter, Header, Request +from fastapi import APIRouter, HTTPException, Request from loguru import logger from pipecat.utils.run_context import set_current_run_id from starlette.responses import HTMLResponse @@ -29,6 +28,30 @@ from api.utils.telephony_helper import ( router = APIRouter() +async def _verify_vobiz_callback( + provider, + webhook_url: str, + callback_data: dict, + headers: dict, + raw_body: str, + *, + log_prefix: str, +) -> None: + """Verify a Vobiz callback signature, failing closed. + + Vobiz signs every callback, so a missing signature header is an invalid + request — ``provider.verify_inbound_signature`` returns ``False`` for both + missing and forged signatures. Reject with HTTP 403 (per Vobiz's + callback-validation docs) so the caller never reaches status processing. + """ + is_valid = await provider.verify_inbound_signature( + webhook_url, callback_data, headers, raw_body + ) + if not is_valid: + logger.warning(f"{log_prefix} Invalid or missing Vobiz callback signature") + raise HTTPException(status_code=403, detail="Invalid webhook signature") + + @router.post("/vobiz-xml", include_in_schema=False) async def handle_vobiz_xml_webhook( workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int @@ -65,8 +88,6 @@ async def handle_vobiz_xml_webhook( async def handle_vobiz_hangup_callback( workflow_run_id: int, request: Request, - x_vobiz_signature: Optional[str] = Header(None), - x_vobiz_timestamp: Optional[str] = Header(None), ): """Handle Vobiz hangup callback (sent when call ends). @@ -75,82 +96,23 @@ async def handle_vobiz_hangup_callback( """ set_current_run_id(workflow_run_id) - # Logging all headers and body to understand what Vobiz actually sends all_headers = dict(request.headers) - logger.info( - f"[run {workflow_run_id}] Vobiz hangup callback - Headers: {json.dumps(all_headers)}" - ) # Parse the callback data from the raw body so signed webhooks can verify # the exact bytes Vobiz sent without draining the request stream first. callback_data, raw_body = await parse_webhook_request(request) - # TODO: Remove this debug logging after Vobiz team clarifies webhook authentication - logger.info( - f"[run {workflow_run_id}] Vobiz hangup callback - Body: {json.dumps(callback_data)}" - ) logger.info( f"[run {workflow_run_id}] Received Vobiz hangup callback {json.dumps(callback_data)}" ) - # Verify signature if Vobiz provided any supported signature header. - has_vobiz_signature = any( - header in all_headers - for header in ( - "x-vobiz-signature-v3", - "x-vobiz-signature-ma-v3", - "x-vobiz-signature-v2", - "x-vobiz-signature-ma-v2", - ) - ) - if has_vobiz_signature: - # We need the workflow run to get organization for provider credentials - workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) - if not workflow_run: - logger.warning( - f"[run {workflow_run_id}] Workflow run not found for signature verification" - ) - return {"status": "error", "reason": "workflow_run_not_found"} - - workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) - if not workflow: - logger.warning( - f"[run {workflow_run_id}] Workflow not found for signature verification" - ) - return {"status": "error", "reason": "workflow_not_found"} - - provider = await get_telephony_provider_for_run( - workflow_run, workflow.organization_id - ) - - # Verify signature - backend_endpoint, _ = await get_backend_endpoints() - webhook_url = f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/{workflow_run_id}" - - is_valid = await provider.verify_inbound_signature( - webhook_url, - callback_data, - all_headers, - raw_body, - ) - - if not is_valid: - logger.warning( - f"[run {workflow_run_id}] Invalid Vobiz hangup callback signature" - ) - return {"status": "error", "reason": "invalid_signature"} - - logger.info(f"[run {workflow_run_id}] Vobiz hangup callback signature verified") - else: - # Get workflow run for processing (signature verification already got it if needed) - workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) + workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) if not workflow_run: logger.warning( f"[run {workflow_run_id}] Workflow run not found for Vobiz hangup callback" ) return {"status": "ignored", "reason": "workflow_run_not_found"} - # Get workflow and provider workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) if not workflow: logger.warning(f"[run {workflow_run_id}] Workflow not found") @@ -160,6 +122,21 @@ async def handle_vobiz_hangup_callback( workflow_run, workflow.organization_id ) + # Fail closed: Vobiz signs every callback, so reject unsigned/forged ones + # before they can mutate call state. + backend_endpoint, _ = await get_backend_endpoints() + webhook_url = ( + f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/{workflow_run_id}" + ) + await _verify_vobiz_callback( + provider, + webhook_url, + callback_data, + all_headers, + raw_body, + log_prefix=f"[run {workflow_run_id}]", + ) + logger.debug( f"[run {workflow_run_id}] Processing Vobiz hangup with provider: {provider.PROVIDER_NAME}" ) @@ -167,10 +144,6 @@ async def handle_vobiz_hangup_callback( # Parse the callback data into generic format parsed_data = provider.parse_status_callback(callback_data) - logger.debug( - f"[run {workflow_run_id}] Parsed Vobiz callback data: {json.dumps(parsed_data)}" - ) - # Create StatusCallbackRequest from parsed data status_update = StatusCallbackRequest( call_id=parsed_data["call_id"], @@ -194,8 +167,6 @@ async def handle_vobiz_hangup_callback( async def handle_vobiz_ring_callback( workflow_run_id: int, request: Request, - x_vobiz_signature: Optional[str] = Header(None), - x_vobiz_timestamp: Optional[str] = Header(None), ): """Handle Vobiz ring callback (sent when call starts ringing). @@ -204,84 +175,46 @@ async def handle_vobiz_ring_callback( """ set_current_run_id(workflow_run_id) - # Logging all headers and body to understand what Vobiz actually sends all_headers = dict(request.headers) - logger.info( - f"[run {workflow_run_id}] Vobiz ring callback - Headers: {json.dumps(all_headers)}" - ) # Parse the callback data from the raw body so signed webhooks can verify # the exact bytes Vobiz sent without draining the request stream first. callback_data, raw_body = await parse_webhook_request(request) - # TODO: Remove this debug logging after Vobiz team clarifies webhook authentication - logger.info( - f"[run {workflow_run_id}] Vobiz ring callback - Body: {json.dumps(callback_data)}" - ) - logger.info( f"[run {workflow_run_id}] Received Vobiz ring callback {json.dumps(callback_data)}" ) - # Verify signature if Vobiz provided any supported signature header. - has_vobiz_signature = any( - header in all_headers - for header in ( - "x-vobiz-signature-v3", - "x-vobiz-signature-ma-v3", - "x-vobiz-signature-v2", - "x-vobiz-signature-ma-v2", - ) - ) - if has_vobiz_signature: - # We need the workflow run to get organization for provider credentials - workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) - if not workflow_run: - logger.warning( - f"[run {workflow_run_id}] Workflow run not found for signature verification" - ) - return {"status": "error", "reason": "workflow_run_not_found"} - - workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) - if not workflow: - logger.warning( - f"[run {workflow_run_id}] Workflow not found for signature verification" - ) - return {"status": "error", "reason": "workflow_not_found"} - - provider = await get_telephony_provider_for_run( - workflow_run, workflow.organization_id - ) - - # Verify signature - backend_endpoint, _ = await get_backend_endpoints() - webhook_url = ( - f"{backend_endpoint}/api/v1/telephony/vobiz/ring-callback/{workflow_run_id}" - ) - - is_valid = await provider.verify_inbound_signature( - webhook_url, - callback_data, - all_headers, - raw_body, - ) - - if not is_valid: - logger.warning( - f"[run {workflow_run_id}] Invalid Vobiz ring callback signature" - ) - return {"status": "error", "reason": "invalid_signature"} - - logger.info(f"[run {workflow_run_id}] Vobiz ring callback signature verified") - else: - # Get workflow run for processing (signature verification already got it if needed) - workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) + workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) if not workflow_run: logger.warning( f"[run {workflow_run_id}] Workflow run not found for Vobiz ring callback" ) return {"status": "ignored", "reason": "workflow_run_not_found"} + workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) + if not workflow: + logger.warning(f"[run {workflow_run_id}] Workflow not found") + return {"status": "ignored", "reason": "workflow_not_found"} + + provider = await get_telephony_provider_for_run( + workflow_run, workflow.organization_id + ) + + # Fail closed: reject unsigned/forged ring callbacks before logging them. + backend_endpoint, _ = await get_backend_endpoints() + webhook_url = ( + f"{backend_endpoint}/api/v1/telephony/vobiz/ring-callback/{workflow_run_id}" + ) + await _verify_vobiz_callback( + provider, + webhook_url, + callback_data, + all_headers, + raw_body, + log_prefix=f"[run {workflow_run_id}]", + ) + # Log the ringing event telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", []) ring_log = { @@ -308,15 +241,10 @@ async def handle_vobiz_ring_callback( async def handle_vobiz_hangup_callback_by_workflow( workflow_id: int, request: Request, - x_vobiz_signature: Optional[str] = Header(None), - x_vobiz_timestamp: Optional[str] = Header(None), ): """Handle Vobiz hangup callback with workflow_id - finds workflow run by call_id.""" all_headers = dict(request.headers) - logger.info( - f"[workflow {workflow_id}] Vobiz hangup callback - Headers: {json.dumps(all_headers)}" - ) try: callback_data, raw_body = await parse_webhook_request(request) @@ -364,35 +292,18 @@ async def handle_vobiz_hangup_callback_by_workflow( workflow_run, workflow.organization_id ) - has_vobiz_signature = any( - header in all_headers - for header in ( - "x-vobiz-signature-v3", - "x-vobiz-signature-ma-v3", - "x-vobiz-signature-v2", - "x-vobiz-signature-ma-v2", - ) + # Fail closed: Vobiz signs every callback, so reject unsigned/forged ones + # before they can mutate call state. + backend_endpoint, _ = await get_backend_endpoints() + webhook_url = f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}" + await _verify_vobiz_callback( + provider, + webhook_url, + callback_data, + all_headers, + raw_body, + log_prefix=f"[workflow {workflow_id}]", ) - if has_vobiz_signature: - backend_endpoint, _ = await get_backend_endpoints() - webhook_url = f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}" - - is_valid = await provider.verify_inbound_signature( - webhook_url, - callback_data, - all_headers, - raw_body, - ) - - if not is_valid: - logger.warning( - f"[workflow {workflow_id}] Invalid Vobiz hangup callback signature" - ) - return {"status": "error", "message": "invalid_signature"} - - logger.info( - f"[workflow {workflow_id}] Vobiz hangup callback signature verified" - ) try: parsed_data = provider.parse_status_callback(callback_data) diff --git a/api/services/telephony/providers/vonage/routes.py b/api/services/telephony/providers/vonage/routes.py index a4cca35d..c862e745 100644 --- a/api/services/telephony/providers/vonage/routes.py +++ b/api/services/telephony/providers/vonage/routes.py @@ -66,34 +66,6 @@ async def handle_vonage_events( logger.error(f"[run {workflow_run_id}] Workflow run not found") return {"status": "error", "message": "Workflow run not found"} - # For a completed call that includes cost info, capture it immediately - if event_data.get("status") == "completed": - # Vonage sometimes includes price info in the webhook - if "price" in event_data or "rate" in event_data: - try: - if workflow_run.cost_info: - # Store immediate cost info if available - cost_info = workflow_run.cost_info.copy() - if "price" in event_data: - cost_info["vonage_webhook_price"] = float(event_data["price"]) - if "rate" in event_data: - cost_info["vonage_webhook_rate"] = float(event_data["rate"]) - if "duration" in event_data: - cost_info["vonage_webhook_duration"] = int( - event_data["duration"] - ) - - await db_client.update_workflow_run( - run_id=workflow_run_id, cost_info=cost_info - ) - logger.info( - f"[run {workflow_run_id}] Captured Vonage cost info from webhook" - ) - except Exception as e: - logger.error( - f"[run {workflow_run_id}] Failed to capture Vonage cost from webhook: {e}" - ) - # Get workflow and provider workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id) if not workflow: diff --git a/api/services/telephony/status_processor.py b/api/services/telephony/status_processor.py index f1f1f86a..b93a0d9e 100644 --- a/api/services/telephony/status_processor.py +++ b/api/services/telephony/status_processor.py @@ -114,11 +114,13 @@ class StatusCallbackRequest(BaseModel): "NOANSWER": "no-answer", } - disposition = data.get("disposition", "") + disposition = data.get("disposition") or "" status = disposition_map.get(disposition.upper(), disposition.lower()) + session = data.get("session") + call_id = session.get("token") if isinstance(session, dict) else "" return cls( - call_id=data.get("session").get("token"), + call_id=call_id or "", status=status, from_number=data.get("from"), to_number=data.get("to"), diff --git a/api/services/workflow/pipecat_engine.py b/api/services/workflow/pipecat_engine.py index cea1d21f..a0d67947 100644 --- a/api/services/workflow/pipecat_engine.py +++ b/api/services/workflow/pipecat_engine.py @@ -35,6 +35,7 @@ import asyncio from loguru import logger +from api.services.managed_model_services import MPS_CORRELATION_ID_CONTEXT_KEY from api.services.workflow import pipecat_engine_callbacks as engine_callbacks from api.services.workflow.mcp_tool_session import McpToolSession from api.services.workflow.pipecat_engine_context_composer import ( @@ -382,6 +383,9 @@ class PipecatEngine: embeddings_provider=self._embeddings_provider, embeddings_endpoint=self._embeddings_endpoint, embeddings_api_version=self._embeddings_api_version, + correlation_id=self._call_context_vars.get( + MPS_CORRELATION_ID_CONTEXT_KEY + ), tracing_context=self._get_otel_context(), ) diff --git a/api/services/workflow/qa/llm_config.py b/api/services/workflow/qa/llm_config.py index 9c1159a6..9f4d06f8 100644 --- a/api/services/workflow/qa/llm_config.py +++ b/api/services/workflow/qa/llm_config.py @@ -2,7 +2,6 @@ import random -from api.db import db_client from api.db.models import WorkflowRunModel from api.services.workflow.dto import QANodeData @@ -43,7 +42,7 @@ async def resolve_llm_config( async def resolve_user_llm_config( workflow_run: WorkflowRunModel, ) -> tuple[str, str, str, dict]: - """Resolve the user's configured LLM (from UserConfiguration). + """Resolve the user's configured LLM (from EffectiveAIModelConfiguration). Returns: (provider, model, api_key, service_kwargs) tuple @@ -54,7 +53,27 @@ async def resolve_user_llm_config( llm_config: dict = {} if user_id: - user_configuration = await db_client.get_user_configurations(user_id) + from api.services.configuration.ai_model_configuration import ( + get_effective_ai_model_configuration_for_workflow, + ) + + workflow_configurations = {} + if workflow_run.definition: + workflow_configurations = ( + workflow_run.definition.workflow_configurations or {} + ) + elif workflow_run.workflow: + workflow_configurations = ( + workflow_run.workflow.workflow_configurations or {} + ) + + user_configuration = await get_effective_ai_model_configuration_for_workflow( + user_id=user_id, + organization_id=workflow_run.workflow.organization_id + if workflow_run.workflow + else None, + workflow_configurations=workflow_configurations, + ) llm_config = user_configuration.model_dump(exclude_none=True).get("llm", {}) provider = llm_config.get("provider", "openai") diff --git a/api/services/workflow/run_usage_response.py b/api/services/workflow/run_usage_response.py new file mode 100644 index 00000000..c289e565 --- /dev/null +++ b/api/services/workflow/run_usage_response.py @@ -0,0 +1,41 @@ +"""Format workflow run usage for public API responses.""" + + +def format_public_usage_info(usage_info: dict | None) -> dict | None: + if not usage_info: + return None + + return { + "llm": usage_info.get("llm") or {}, + "tts": usage_info.get("tts") or {}, + "stt": usage_info.get("stt") or {}, + "call_duration_seconds": usage_info.get("call_duration_seconds"), + } + + +def format_public_cost_info( + cost_info: dict | None, usage_info: dict | None +) -> dict | None: + """Return the legacy response shape without doing local cost accounting.""" + duration = None + if usage_info and usage_info.get("call_duration_seconds") is not None: + duration = int(round(usage_info.get("call_duration_seconds") or 0)) + elif cost_info and cost_info.get("call_duration_seconds") is not None: + duration = int(round(cost_info.get("call_duration_seconds") or 0)) + + dograh_token_usage = 0 + if cost_info: + if "dograh_token_usage" in cost_info: + dograh_token_usage = cost_info.get("dograh_token_usage") or 0 + elif "total_cost_usd" in cost_info: + dograh_token_usage = round( + float(cost_info.get("total_cost_usd", 0)) * 100, 2 + ) + + if duration is None and dograh_token_usage == 0: + return None + + return { + "dograh_token_usage": dograh_token_usage, + "call_duration_seconds": duration, + } diff --git a/api/services/workflow/text_chat_runner.py b/api/services/workflow/text_chat_runner.py index 83a4ad15..6cc4615a 100644 --- a/api/services/workflow/text_chat_runner.py +++ b/api/services/workflow/text_chat_runner.py @@ -32,7 +32,6 @@ from pipecat.utils.run_context import set_current_org_id from api.db import db_client from api.enums import WorkflowRunMode, WorkflowRunState -from api.services.configuration.resolve import resolve_effective_config from api.services.pipecat.audio_config import create_audio_config from api.services.pipecat.pipeline_builder import create_pipeline_task from api.services.pipecat.pipeline_metrics_aggregator import ( @@ -410,14 +409,31 @@ async def execute_text_chat_pending_turn( run_definition = workflow_run.definition run_configs = run_definition.workflow_configurations or {} - user_config = await db_client.get_user_configurations(workflow_run.workflow.user.id) - user_config = resolve_effective_config( - user_config, run_configs.get("model_overrides") + from api.services.configuration.ai_model_configuration import ( + get_effective_ai_model_configuration_for_workflow, + ) + + user_config = await get_effective_ai_model_configuration_for_workflow( + user_id=workflow_run.workflow.user.id, + organization_id=workflow.organization_id, + workflow_configurations=run_configs, ) if user_config.llm is None: raise ValueError("Text chat requires an LLM configuration") - llm = create_llm_service(user_config) + from api.services.managed_model_services import ( + MPS_CORRELATION_ID_CONTEXT_KEY, + ensure_mps_correlation_id, + ) + + base_initial_context = dict(workflow_run.initial_context or {}) + mps_correlation_id = await ensure_mps_correlation_id( + ai_model_config=user_config, + workflow_run_id=workflow_run_id, + initial_context=base_initial_context, + ) + + llm = create_llm_service(user_config, correlation_id=mps_correlation_id) inference_llm = llm runtime_configuration = { @@ -425,9 +441,15 @@ async def execute_text_chat_pending_turn( "llm_model": user_config.llm.model, } initial_context = { - **(workflow_run.initial_context or {}), + **base_initial_context, "runtime_configuration": runtime_configuration, } + if mps_correlation_id: + initial_context[MPS_CORRELATION_ID_CONTEXT_KEY] = mps_correlation_id + await db_client.update_workflow_run( + workflow_run_id, + initial_context=initial_context, + ) workflow_graph = WorkflowGraph( ReactFlowDTO.model_validate(run_definition.workflow_json) @@ -466,9 +488,17 @@ async def execute_text_chat_pending_turn( embeddings_model = None embeddings_base_url = None if user_config.embeddings: + from api.services.configuration.ai_model_configuration import ( + apply_managed_embeddings_base_url, + ) + embeddings_api_key = user_config.embeddings.api_key embeddings_model = user_config.embeddings.model - embeddings_base_url = getattr(user_config.embeddings, "base_url", None) + embeddings_provider = getattr(user_config.embeddings, "provider", None) + embeddings_base_url = apply_managed_embeddings_base_url( + provider=embeddings_provider, + base_url=getattr(user_config.embeddings, "base_url", None), + ) has_recordings = await db_client.has_active_recordings(workflow.organization_id) context_compaction_enabled = (workflow.workflow_configurations or {}).get( @@ -606,8 +636,10 @@ async def execute_text_chat_pending_turn( "Transportless text chat pipeline failed while closing run {}", workflow_run_id, ) + await engine.close_mcp_sessions() await engine.cleanup() raise + await engine.close_mcp_sessions() await engine.cleanup() gathered_context = await engine.get_gathered_context() diff --git a/api/services/workflow/text_chat_session_service.py b/api/services/workflow/text_chat_session_service.py index 53354d5f..81749960 100644 --- a/api/services/workflow/text_chat_session_service.py +++ b/api/services/workflow/text_chat_session_service.py @@ -4,17 +4,11 @@ from datetime import UTC, datetime from typing import Any from uuid import uuid4 -from loguru import logger - from api.db import db_client from api.db.models import WorkflowRunTextSessionModel from api.db.workflow_run_text_session_client import ( WorkflowRunTextSessionRevisionConflictError, ) -from api.services.pricing.workflow_run_cost import ( - apply_usage_delta_to_organization, - build_workflow_run_cost_info, -) from api.services.workflow.text_chat_logs import ( build_text_chat_realtime_feedback_events, ) @@ -261,20 +255,6 @@ async def execute_pending_text_chat_turn( state=execution.state, is_completed=execution.is_completed, ) - workflow_run = await db_client.get_workflow_run_by_id(run_id) - if workflow_run: - try: - # Apply the per-turn delta so org usage tracks cumulative run cost - # without replaying the full session totals on every turn. - await apply_usage_delta_to_organization(workflow_run, execution.usage) - except Exception as e: - logger.error( - f"Failed to update organization usage for text chat run {run_id}: {e}" - ) - - cost_info = await build_workflow_run_cost_info(workflow_run) - if cost_info is not None: - await db_client.update_workflow_run(run_id, cost_info=cost_info) return await _reload_text_chat_session(run_id) diff --git a/api/services/workflow/tools/knowledge_base.py b/api/services/workflow/tools/knowledge_base.py index 6ce8f8c7..7b93aea7 100644 --- a/api/services/workflow/tools/knowledge_base.py +++ b/api/services/workflow/tools/knowledge_base.py @@ -29,6 +29,7 @@ async def retrieve_from_knowledge_base( embeddings_provider: Optional[str] = None, embeddings_endpoint: Optional[str] = None, embeddings_api_version: Optional[str] = None, + correlation_id: Optional[str] = None, tracing_context=None, ) -> Dict[str, Any]: """Retrieve relevant information from the knowledge base using vector similarity search. @@ -75,6 +76,7 @@ async def retrieve_from_knowledge_base( embeddings_provider, embeddings_endpoint, embeddings_api_version, + correlation_id, ) # Create span with parent context @@ -115,6 +117,7 @@ async def retrieve_from_knowledge_base( embeddings_provider, embeddings_endpoint, embeddings_api_version, + correlation_id, ) # Add result metadata to span @@ -192,6 +195,7 @@ async def retrieve_from_knowledge_base( embeddings_provider, embeddings_endpoint, embeddings_api_version, + correlation_id, ) else: # Tracing is disabled - perform retrieval without tracing @@ -206,6 +210,7 @@ async def retrieve_from_knowledge_base( embeddings_provider, embeddings_endpoint, embeddings_api_version, + correlation_id, ) @@ -220,6 +225,7 @@ async def _perform_retrieval( embeddings_provider: Optional[str] = None, embeddings_endpoint: Optional[str] = None, embeddings_api_version: Optional[str] = None, + correlation_id: Optional[str] = None, ) -> Dict[str, Any]: """Internal function to perform the actual retrieval operation. @@ -272,11 +278,20 @@ async def _perform_retrieval( api_version=embeddings_api_version or "2024-02-15-preview", ) else: + default_headers = None + if ( + embeddings_provider == ServiceProviders.DOGRAH.value + and correlation_id + ): + default_headers = { + "X-Dograh-Correlation-Id": correlation_id, + } embedding_service = OpenAIEmbeddingService( db_client=db_client, api_key=embeddings_api_key, model_id=embeddings_model or "text-embedding-3-small", base_url=embeddings_base_url, + default_headers=default_headers, ) results = await embedding_service.search_similar_chunks( diff --git a/api/services/workflow_run_billing.py b/api/services/workflow_run_billing.py new file mode 100644 index 00000000..ab8a3121 --- /dev/null +++ b/api/services/workflow_run_billing.py @@ -0,0 +1,111 @@ +"""Workflow-run billing hooks. + +Dograh does not rate or deduct credits locally. MPS owns credit accounting. +For hosted deployments, Dograh reports completed platform usage to MPS. +When a server-minted MPS correlation id exists, MPS uses model-service usage +as the canonical duration. Otherwise Dograh reports the completed run duration. +""" + +from typing import Any + +from loguru import logger + +from api.constants import DEPLOYMENT_MODE +from api.db import db_client +from api.services.managed_model_services import get_mps_correlation_id +from api.services.mps_service_key_client import mps_service_key_client + + +def _workflow_run_organization_id(workflow_run) -> int | None: + workflow = getattr(workflow_run, "workflow", None) + return getattr(workflow, "organization_id", None) + + +def _duration_seconds_from_usage_info(workflow_run) -> float | None: + usage_info: dict[str, Any] = getattr(workflow_run, "usage_info", None) or {} + duration = usage_info.get("call_duration_seconds") + try: + duration_seconds = float(duration) + except (TypeError, ValueError): + return None + + return duration_seconds if duration_seconds > 0 else None + + +async def _organization_uses_mps_billing_v2(organization_id: int) -> bool: + account = await mps_service_key_client.get_billing_account_status( + organization_id=organization_id + ) + return bool(account and account.get("billing_mode") == "v2") + + +async def report_workflow_run_platform_usage(workflow_run) -> None: + """Report hosted platform usage for a completed workflow run to MPS.""" + if DEPLOYMENT_MODE == "oss": + return + + if not getattr(workflow_run, "is_completed", False): + return + + organization_id = _workflow_run_organization_id(workflow_run) + if organization_id is None: + logger.warning( + "Skipping platform usage report for workflow run {}: no organization_id", + workflow_run.id, + ) + return + + correlation_id = get_mps_correlation_id( + getattr(workflow_run, "initial_context", None) + ) + duration_seconds = ( + None if correlation_id else _duration_seconds_from_usage_info(workflow_run) + ) + if not correlation_id and duration_seconds is None: + logger.warning( + "Skipping platform usage report for workflow run {}: no billable duration", + workflow_run.id, + ) + return + + try: + if not await _organization_uses_mps_billing_v2(organization_id): + return + + result = await mps_service_key_client.report_platform_usage( + organization_id=organization_id, + correlation_id=correlation_id, + duration_seconds=duration_seconds, + workflow_run_id=workflow_run.id, + metadata={ + "source": "workflow_run_completion", + "workflow_id": getattr(workflow_run, "workflow_id", None), + "duration_source": ( + "mps_correlation" if correlation_id else "dograh_usage_info" + ), + }, + ) + logger.info( + "Reported platform usage for workflow run {} to MPS: {}", + workflow_run.id, + result, + ) + except Exception as e: + logger.error( + "Failed to report platform usage for workflow run {}: {}", + workflow_run.id, + e, + ) + + +async def report_completed_workflow_run_platform_usage(workflow_run_id: int) -> None: + """Load a completed workflow run and report platform usage to MPS.""" + workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id) + if not workflow_run: + logger.warning( + "Skipping platform usage report: workflow run {} not found", + workflow_run_id, + ) + return + + await report_workflow_run_platform_usage(workflow_run) diff --git a/api/tasks/arq.py b/api/tasks/arq.py index a948a578..442114e6 100644 --- a/api/tasks/arq.py +++ b/api/tasks/arq.py @@ -45,10 +45,8 @@ from api.tasks.campaign_tasks import ( ) from api.tasks.knowledge_base_processing import process_knowledge_base_document from api.tasks.run_integrations import run_integrations_post_workflow_run -from api.tasks.s3_upload import ( - process_workflow_completion, - upload_voicemail_audio_to_s3, -) +from api.tasks.s3_upload import upload_voicemail_audio_to_s3 +from api.tasks.workflow_completion import process_workflow_completion class WorkerSettings: diff --git a/api/tasks/knowledge_base_processing.py b/api/tasks/knowledge_base_processing.py index 4e943299..a6ca0d6d 100644 --- a/api/tasks/knowledge_base_processing.py +++ b/api/tasks/knowledge_base_processing.py @@ -157,15 +157,31 @@ async def process_knowledge_base_document( embeddings_endpoint = None embeddings_api_version = None if document.created_by: - user_config = await db_client.get_user_configurations(document.created_by) - if user_config.embeddings: - embeddings_provider = getattr(user_config.embeddings, "provider", None) - embeddings_api_key = user_config.embeddings.api_key - embeddings_model = user_config.embeddings.model - embeddings_base_url = getattr(user_config.embeddings, "base_url", None) - embeddings_endpoint = getattr(user_config.embeddings, "endpoint", None) + from api.services.configuration.ai_model_configuration import ( + apply_managed_embeddings_base_url, + get_resolved_ai_model_configuration, + ) + + resolved_config = await get_resolved_ai_model_configuration( + user_id=document.created_by, + organization_id=document.organization_id, + ) + effective_config = resolved_config.effective + if effective_config.embeddings: + embeddings_provider = getattr( + effective_config.embeddings, "provider", None + ) + embeddings_api_key = effective_config.embeddings.api_key + embeddings_model = effective_config.embeddings.model + embeddings_base_url = apply_managed_embeddings_base_url( + provider=embeddings_provider, + base_url=getattr(effective_config.embeddings, "base_url", None), + ) + embeddings_endpoint = getattr( + effective_config.embeddings, "endpoint", None + ) embeddings_api_version = getattr( - user_config.embeddings, "api_version", None + effective_config.embeddings, "api_version", None ) logger.info( f"Using user embeddings config: provider={embeddings_provider}, " diff --git a/api/tasks/s3_upload.py b/api/tasks/s3_upload.py index b2086c09..bbbc8bf4 100644 --- a/api/tasks/s3_upload.py +++ b/api/tasks/s3_upload.py @@ -1,13 +1,9 @@ import os -from typing import Optional from loguru import logger from pipecat.utils.run_context import set_current_run_id -from api.db import db_client -from api.services.pricing.workflow_run_cost import calculate_workflow_run_cost -from api.services.storage import get_current_storage_backend, storage_fs -from api.tasks.run_integrations import run_integrations_post_workflow_run +from api.services.storage import storage_fs async def upload_voicemail_audio_to_s3( @@ -69,110 +65,3 @@ async def upload_voicemail_audio_to_s3( logger.warning( f"Failed to clean up temp voicemail audio file {temp_file_path}: {e}" ) - - -async def process_workflow_completion( - _ctx, - workflow_run_id: int, - audio_temp_path: Optional[str] = None, - transcript_temp_path: Optional[str] = None, -): - """Process workflow completion: upload artifacts and run integrations. - - This task combines audio upload, transcript upload, and webhook integrations - into a single sequential task to ensure integrations run after uploads complete. - - Args: - _ctx: ARQ context (unused) - workflow_run_id: The workflow run ID - audio_temp_path: Optional path to temp audio file - transcript_temp_path: Optional path to temp transcript file - """ - run_id = str(workflow_run_id) - set_current_run_id(run_id) - - logger.info(f"Processing workflow completion for run {workflow_run_id}") - - storage_backend = get_current_storage_backend() - - # Step 1: Upload audio if provided - if audio_temp_path: - try: - if os.path.exists(audio_temp_path): - file_size = os.path.getsize(audio_temp_path) - logger.debug(f"Audio file size: {file_size} bytes") - - recording_url = f"recordings/{workflow_run_id}.wav" - logger.info( - f"Uploading audio to {storage_backend.name} - workflow_run_id: {workflow_run_id}" - ) - - await storage_fs.aupload_file(audio_temp_path, recording_url) - await db_client.update_workflow_run( - run_id=workflow_run_id, - recording_url=recording_url, - storage_backend=storage_backend.value, - ) - logger.info(f"Successfully uploaded audio: {recording_url}") - else: - logger.warning(f"Audio temp file not found: {audio_temp_path}") - except Exception as e: - logger.error(f"Error uploading audio for workflow {workflow_run_id}: {e}") - finally: - if audio_temp_path and os.path.exists(audio_temp_path): - try: - os.remove(audio_temp_path) - logger.debug(f"Cleaned up temp audio file: {audio_temp_path}") - except Exception as e: - logger.warning(f"Failed to clean up temp audio file: {e}") - - # Step 2: Upload transcript if provided - if transcript_temp_path: - try: - if os.path.exists(transcript_temp_path): - file_size = os.path.getsize(transcript_temp_path) - logger.debug(f"Transcript file size: {file_size} bytes") - - transcript_url = f"transcripts/{workflow_run_id}.txt" - logger.info( - f"Uploading transcript to {storage_backend.name} - workflow_run_id: {workflow_run_id}" - ) - - await storage_fs.aupload_file(transcript_temp_path, transcript_url) - await db_client.update_workflow_run( - run_id=workflow_run_id, - transcript_url=transcript_url, - storage_backend=storage_backend.value, - ) - logger.info(f"Successfully uploaded transcript: {transcript_url}") - else: - logger.warning( - f"Transcript temp file not found: {transcript_temp_path}" - ) - except Exception as e: - logger.error( - f"Error uploading transcript for workflow {workflow_run_id}: {e}" - ) - finally: - if transcript_temp_path and os.path.exists(transcript_temp_path): - try: - os.remove(transcript_temp_path) - logger.debug( - f"Cleaned up temp transcript file: {transcript_temp_path}" - ) - except Exception as e: - logger.warning(f"Failed to clean up temp transcript file: {e}") - - # Step 3: Run integrations including QA analysis (after uploads are complete) - try: - await run_integrations_post_workflow_run(_ctx, workflow_run_id) - except Exception as e: - logger.error(f"Error running integrations for workflow {workflow_run_id}: {e}") - - # Step 4: Calculate cost after integrations (so QA token usage is included) - try: - await calculate_workflow_run_cost(workflow_run_id) - except Exception as e: - logger.error(f"Error calculating cost for workflow {workflow_run_id}: {e}") - - logger.info(f"Completed workflow completion processing for run {workflow_run_id}") diff --git a/api/tasks/workflow_completion.py b/api/tasks/workflow_completion.py new file mode 100644 index 00000000..ff0482d2 --- /dev/null +++ b/api/tasks/workflow_completion.py @@ -0,0 +1,121 @@ +import os +from typing import Optional + +from loguru import logger +from pipecat.utils.run_context import set_current_run_id + +from api.db import db_client +from api.services.storage import get_current_storage_backend, storage_fs +from api.services.workflow_run_billing import ( + report_completed_workflow_run_platform_usage, +) +from api.tasks.run_integrations import run_integrations_post_workflow_run + + +async def process_workflow_completion( + _ctx, + workflow_run_id: int, + audio_temp_path: Optional[str] = None, + transcript_temp_path: Optional[str] = None, +): + """Process workflow completion: upload artifacts and run integrations. + + This task combines audio upload, transcript upload, and webhook integrations + into a single sequential task to ensure integrations run after uploads complete. + + Args: + _ctx: ARQ context (unused) + workflow_run_id: The workflow run ID + audio_temp_path: Optional path to temp audio file + transcript_temp_path: Optional path to temp transcript file + """ + run_id = str(workflow_run_id) + set_current_run_id(run_id) + + logger.info(f"Processing workflow completion for run {workflow_run_id}") + + storage_backend = get_current_storage_backend() + + # Step 1: Upload audio if provided + if audio_temp_path: + try: + if os.path.exists(audio_temp_path): + file_size = os.path.getsize(audio_temp_path) + logger.debug(f"Audio file size: {file_size} bytes") + + recording_url = f"recordings/{workflow_run_id}.wav" + logger.info( + f"Uploading audio to {storage_backend.name} - workflow_run_id: {workflow_run_id}" + ) + + await storage_fs.aupload_file(audio_temp_path, recording_url) + await db_client.update_workflow_run( + run_id=workflow_run_id, + recording_url=recording_url, + storage_backend=storage_backend.value, + ) + logger.info(f"Successfully uploaded audio: {recording_url}") + else: + logger.warning(f"Audio temp file not found: {audio_temp_path}") + except Exception as e: + logger.error(f"Error uploading audio for workflow {workflow_run_id}: {e}") + finally: + if audio_temp_path and os.path.exists(audio_temp_path): + try: + os.remove(audio_temp_path) + logger.debug(f"Cleaned up temp audio file: {audio_temp_path}") + except Exception as e: + logger.warning(f"Failed to clean up temp audio file: {e}") + + # Step 2: Upload transcript if provided + if transcript_temp_path: + try: + if os.path.exists(transcript_temp_path): + file_size = os.path.getsize(transcript_temp_path) + logger.debug(f"Transcript file size: {file_size} bytes") + + transcript_url = f"transcripts/{workflow_run_id}.txt" + logger.info( + f"Uploading transcript to {storage_backend.name} - workflow_run_id: {workflow_run_id}" + ) + + await storage_fs.aupload_file(transcript_temp_path, transcript_url) + await db_client.update_workflow_run( + run_id=workflow_run_id, + transcript_url=transcript_url, + storage_backend=storage_backend.value, + ) + logger.info(f"Successfully uploaded transcript: {transcript_url}") + else: + logger.warning( + f"Transcript temp file not found: {transcript_temp_path}" + ) + except Exception as e: + logger.error( + f"Error uploading transcript for workflow {workflow_run_id}: {e}" + ) + finally: + if transcript_temp_path and os.path.exists(transcript_temp_path): + try: + os.remove(transcript_temp_path) + logger.debug( + f"Cleaned up temp transcript file: {transcript_temp_path}" + ) + except Exception as e: + logger.warning(f"Failed to clean up temp transcript file: {e}") + + # Step 3: Run integrations including QA analysis (after uploads are complete) + try: + await run_integrations_post_workflow_run(_ctx, workflow_run_id) + except Exception as e: + logger.error(f"Error running integrations for workflow {workflow_run_id}: {e}") + + # Step 4: Notify MPS after completion. MPS owns credit accounting. + try: + await report_completed_workflow_run_platform_usage(workflow_run_id) + except Exception as e: + logger.error( + f"Error reporting platform usage for workflow {workflow_run_id}: {e}" + ) + + logger.info(f"Completed workflow completion processing for run {workflow_run_id}") diff --git a/api/tests/integrations/_run_pipeline_helpers.py b/api/tests/integrations/_run_pipeline_helpers.py index a1e19b02..58b4ffd2 100644 --- a/api/tests/integrations/_run_pipeline_helpers.py +++ b/api/tests/integrations/_run_pipeline_helpers.py @@ -203,7 +203,7 @@ async def create_workflow_run_rows( Returns: Tuple of (workflow_run, user, workflow). """ - from api.schemas.user_configuration import UserConfiguration + from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration org = OrganizationModel(provider_id=f"test-org-{provider_id_suffix}") async_session.add(org) @@ -218,7 +218,7 @@ async def create_workflow_run_rows( await db_session.update_user_configuration( user_id=user.id, - configuration=UserConfiguration.model_validate(USER_CONFIGURATION), + configuration=EffectiveAIModelConfiguration.model_validate(USER_CONFIGURATION), ) workflow = await db_session.create_workflow( diff --git a/api/tests/telephony/cloudonix/test_routes.py b/api/tests/telephony/cloudonix/test_routes.py new file mode 100644 index 00000000..e22b0672 --- /dev/null +++ b/api/tests/telephony/cloudonix/test_routes.py @@ -0,0 +1,119 @@ +"""Regression tests for Cloudonix CDR webhook handling. + +A Cloudonix CDR webhook is a public, unauthenticated endpoint that parses +arbitrary external JSON. A partial / malformed payload (missing ``session``, +or a ``null`` ``session`` / ``disposition``) must produce a graceful error +response, not an unhandled ``AttributeError`` (HTTP 500). +""" + +from unittest.mock import AsyncMock, patch + +import pytest +from starlette.requests import Request + +from api.services.telephony.providers.cloudonix.routes import handle_cloudonix_cdr +from api.services.telephony.status_processor import StatusCallbackRequest + + +def _json_request(body: bytes) -> Request: + async def receive(): + return {"type": "http.request", "body": body, "more_body": False} + + return Request( + { + "type": "http", + "method": "POST", + "scheme": "https", + "server": ("example.test", 443), + "path": "/api/v1/telephony/cloudonix/cdr", + "query_string": b"", + "headers": [(b"content-type", b"application/json")], + }, + receive, + ) + + +@pytest.mark.asyncio +async def test_cdr_route_handles_payload_without_session(): + """A CDR payload missing the ``session`` object returns a graceful error + instead of raising ``AttributeError`` on ``None.get("token")``.""" + request = _json_request(b'{"domain": "acme.cloudonix.io", "disposition": "ANSWER"}') + + with patch( + "api.services.telephony.providers.cloudonix.routes.db_client" + ) as db_client: + db_client.get_workflow_run_by_call_id = AsyncMock(return_value=None) + + result = await handle_cloudonix_cdr(request) + + assert result == {"status": "error", "message": "Missing call_id field"} + + +@pytest.mark.asyncio +async def test_cdr_route_handles_null_session(): + """A CDR payload with an explicit ``null`` session is handled gracefully.""" + request = _json_request(b'{"domain": "acme.cloudonix.io", "session": null}') + + with patch( + "api.services.telephony.providers.cloudonix.routes.db_client" + ) as db_client: + db_client.get_workflow_run_by_call_id = AsyncMock(return_value=None) + + result = await handle_cloudonix_cdr(request) + + assert result == {"status": "error", "message": "Missing call_id field"} + + +@pytest.mark.asyncio +async def test_cdr_route_handles_string_session(): + """A CDR payload with a non-object session is handled gracefully.""" + request = _json_request(b'{"domain": "acme.cloudonix.io", "session": "abc"}') + + with patch( + "api.services.telephony.providers.cloudonix.routes.db_client" + ) as db_client: + db_client.get_workflow_run_by_call_id = AsyncMock(return_value=None) + + result = await handle_cloudonix_cdr(request) + + assert result == {"status": "error", "message": "Missing call_id field"} + + +def test_from_cloudonix_cdr_tolerates_missing_session_and_disposition(): + """``from_cloudonix_cdr`` must not crash on a partial CDR payload.""" + # Missing both session and disposition. + req = StatusCallbackRequest.from_cloudonix_cdr({"domain": "acme.cloudonix.io"}) + assert req.call_id == "" + assert req.status == "" + + # Explicit null values. + req = StatusCallbackRequest.from_cloudonix_cdr( + {"session": None, "disposition": None} + ) + assert req.call_id == "" + assert req.status == "" + + +def test_from_cloudonix_cdr_tolerates_string_session(): + """``from_cloudonix_cdr`` treats a non-object session as missing call_id.""" + req = StatusCallbackRequest.from_cloudonix_cdr( + {"session": "abc", "disposition": "ANSWER"} + ) + assert req.call_id == "" + assert req.status == "completed" + + +def test_from_cloudonix_cdr_maps_disposition_and_session_token(): + """Normal, well-formed CDR payloads still map correctly.""" + req = StatusCallbackRequest.from_cloudonix_cdr( + { + "session": {"token": "abc123"}, + "disposition": "BUSY", + "from": "+15551230001", + "to": "+15551230002", + "billsec": 12, + } + ) + assert req.call_id == "abc123" + assert req.status == "busy" + assert req.duration == "12" diff --git a/api/tests/telephony/vobiz/test_routes.py b/api/tests/telephony/vobiz/test_routes.py index 7b80d468..cfccfc90 100644 --- a/api/tests/telephony/vobiz/test_routes.py +++ b/api/tests/telephony/vobiz/test_routes.py @@ -6,11 +6,13 @@ from unittest.mock import AsyncMock, patch from urllib.parse import urlencode import pytest +from fastapi import HTTPException from starlette.requests import Request from api.services.telephony.providers.vobiz.provider import VobizProvider from api.services.telephony.providers.vobiz.routes import ( handle_vobiz_hangup_callback, + handle_vobiz_hangup_callback_by_workflow, handle_vobiz_ring_callback, ) @@ -225,3 +227,154 @@ async def test_vobiz_verify_inbound_signature_rejects_missing_signature(): {}, {}, ) + + +@pytest.mark.asyncio +async def test_vobiz_hangup_callback_rejects_missing_signature(): + """An unsigned hangup callback must be rejected before status processing.""" + provider = _provider() + form_data = { + "CallUUID": "call-123", + "CallStatus": "completed", + "From": "15551230001", + "To": "15551230002", + "Direction": "outbound", + "Duration": "12", + } + # No x-vobiz-signature-* headers — the callback is unsigned. + request = _request( + path="/api/v1/telephony/vobiz/hangup-callback/123", + form_data=form_data, + ) + + with ( + patch("api.services.telephony.providers.vobiz.routes.db_client") as db_client, + patch( + "api.services.telephony.providers.vobiz.routes.get_telephony_provider_for_run", + new_callable=AsyncMock, + return_value=provider, + ), + patch( + "api.services.telephony.providers.vobiz.routes.get_backend_endpoints", + new_callable=AsyncMock, + return_value=("https://example.test", "wss://example.test"), + ), + patch( + "api.services.telephony.providers.vobiz.routes._process_status_update", + new_callable=AsyncMock, + ) as process_status, + ): + db_client.get_workflow_run_by_id = AsyncMock( + return_value=SimpleNamespace(workflow_id=7) + ) + db_client.get_workflow_by_id = AsyncMock( + return_value=SimpleNamespace(organization_id=11) + ) + + with pytest.raises(HTTPException) as exc_info: + await handle_vobiz_hangup_callback( + workflow_run_id=123, + request=request, + ) + + assert exc_info.value.status_code == 403 + process_status.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_vobiz_ring_callback_rejects_missing_signature(): + """An unsigned ring callback must be rejected before it is logged.""" + provider = _provider() + form_data = { + "CallUUID": "call-123", + "CallStatus": "ringing", + "From": "15551230001", + "To": "15551230002", + } + # No x-vobiz-signature-* headers — the callback is unsigned. + request = _request( + path="/api/v1/telephony/vobiz/ring-callback/123", + form_data=form_data, + ) + + workflow_run = SimpleNamespace(workflow_id=7, logs={}) + + with ( + patch("api.services.telephony.providers.vobiz.routes.db_client") as db_client, + patch( + "api.services.telephony.providers.vobiz.routes.get_telephony_provider_for_run", + new_callable=AsyncMock, + return_value=provider, + ), + patch( + "api.services.telephony.providers.vobiz.routes.get_backend_endpoints", + new_callable=AsyncMock, + return_value=("https://example.test", "wss://example.test"), + ), + ): + db_client.get_workflow_run_by_id = AsyncMock(return_value=workflow_run) + db_client.get_workflow_by_id = AsyncMock( + return_value=SimpleNamespace(organization_id=11) + ) + db_client.update_workflow_run = AsyncMock() + + with pytest.raises(HTTPException) as exc_info: + await handle_vobiz_ring_callback( + workflow_run_id=123, + request=request, + ) + + assert exc_info.value.status_code == 403 + db_client.update_workflow_run.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_vobiz_hangup_callback_by_workflow_rejects_missing_signature(): + """An unsigned by-workflow hangup callback must be rejected before processing.""" + provider = _provider() + form_data = { + "CallUUID": "call-123", + "CallStatus": "completed", + "From": "15551230001", + "To": "15551230002", + "Direction": "outbound", + "Duration": "12", + } + # No x-vobiz-signature-* headers — the callback is unsigned. + request = _request( + path="/api/v1/telephony/vobiz/hangup-callback/workflow/7", + form_data=form_data, + ) + + with ( + patch("api.services.telephony.providers.vobiz.routes.db_client") as db_client, + patch( + "api.services.telephony.providers.vobiz.routes.get_telephony_provider_for_run", + new_callable=AsyncMock, + return_value=provider, + ), + patch( + "api.services.telephony.providers.vobiz.routes.get_backend_endpoints", + new_callable=AsyncMock, + return_value=("https://example.test", "wss://example.test"), + ), + patch( + "api.services.telephony.providers.vobiz.routes._process_status_update", + new_callable=AsyncMock, + ) as process_status, + ): + db_client.get_workflow_by_id = AsyncMock( + return_value=SimpleNamespace(organization_id=11) + ) + db_client.get_workflow_run_by_call_id = AsyncMock( + return_value=SimpleNamespace(id=123, workflow_id=7) + ) + + with pytest.raises(HTTPException) as exc_info: + await handle_vobiz_hangup_callback_by_workflow( + workflow_id=7, + request=request, + ) + + assert exc_info.value.status_code == 403 + process_status.assert_not_awaited() diff --git a/api/tests/test_ai_model_configuration_v2.py b/api/tests/test_ai_model_configuration_v2.py new file mode 100644 index 00000000..57f7cf83 --- /dev/null +++ b/api/tests/test_ai_model_configuration_v2.py @@ -0,0 +1,459 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from pydantic import ValidationError + +from api.schemas.ai_model_configuration import ( + DograhManagedAIModelConfiguration, + EffectiveAIModelConfiguration, + OrganizationAIModelConfigurationResponse, + OrganizationAIModelConfigurationV2, + compile_ai_model_configuration_v2, +) +from api.services.configuration.ai_model_configuration import ( + WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY, + check_for_masked_keys_in_ai_model_configuration_v2, + convert_legacy_ai_model_configuration_to_v2, + mask_ai_model_configuration_v2, + merge_ai_model_configuration_v2_secrets, + migrate_workflow_configuration_model_override_to_v2, +) +from api.services.configuration.check_validity import UserConfigurationValidator +from api.services.configuration.masking import mask_key +from api.services.configuration.registry import ( + DeepgramSTTConfiguration, + DograhLLMService, + DograhSTTService, + DograhTTSService, + ElevenlabsTTSConfiguration, + GoogleLLMService, + GoogleRealtimeLLMConfiguration, + OpenAIEmbeddingsConfiguration, + OpenAILLMService, +) + + +def test_dograh_v2_compiles_to_effective_managed_pipeline_with_embeddings(): + config = OrganizationAIModelConfigurationV2( + mode="dograh", + dograh=DograhManagedAIModelConfiguration( + api_key="mps-secret", + voice="default", + speed=1.2, + language="multi", + ), + ) + + effective = compile_ai_model_configuration_v2(config) + + assert effective.is_realtime is False + assert effective.llm.provider == "dograh" + assert effective.llm.model == "default" + assert effective.tts.provider == "dograh" + assert effective.tts.speed == 1.2 + assert effective.stt.provider == "dograh" + assert effective.stt.language == "multi" + assert effective.embeddings.provider == "dograh" + assert effective.embeddings.model == "default" + assert effective.managed_service_version == 2 + + +def test_dograh_v2_rejects_non_predefined_speed(): + with pytest.raises(ValidationError): + OrganizationAIModelConfigurationV2( + mode="dograh", + dograh=DograhManagedAIModelConfiguration( + api_key="mps-secret", + speed=1.5, + ), + ) + + +def test_byok_v2_rejects_dograh_provider(): + with pytest.raises(ValidationError): + OrganizationAIModelConfigurationV2.model_validate( + { + "mode": "byok", + "byok": { + "mode": "pipeline", + "pipeline": { + "llm": { + "provider": "dograh", + "api_key": "mps-secret", + "model": "default", + }, + "tts": { + "provider": "dograh", + "api_key": "mps-secret", + "model": "default", + "voice": "default", + }, + "stt": { + "provider": "dograh", + "api_key": "mps-secret", + "model": "default", + }, + }, + }, + } + ) + + +@pytest.mark.asyncio +async def test_byok_realtime_validator_does_not_require_stt_or_tts(): + config = OrganizationAIModelConfigurationV2.model_validate( + { + "mode": "byok", + "byok": { + "mode": "realtime", + "realtime": { + "realtime": { + "provider": "google_realtime", + "api_key": "google-realtime-key", + "model": "gemini-3.1-flash-live-preview", + "voice": "Puck", + "language": "en", + }, + "llm": { + "provider": "google", + "api_key": "google-llm-key", + "model": "gemini-2.0-flash", + }, + }, + }, + } + ) + effective = compile_ai_model_configuration_v2(config) + + assert effective.is_realtime is True + assert effective.stt is None + assert effective.tts is None + assert await UserConfigurationValidator().validate(effective) == { + "status": [{"model": "all", "message": "ok"}] + } + + +@pytest.mark.asyncio +async def test_pipeline_validator_requires_stt_and_tts_when_not_realtime(): + effective = EffectiveAIModelConfiguration( + llm=GoogleLLMService( + provider="google", + api_key="google-llm-key", + model="gemini-2.0-flash", + ), + realtime=GoogleRealtimeLLMConfiguration( + provider="google_realtime", + api_key="google-realtime-key", + model="gemini-3.1-flash-live-preview", + voice="Puck", + language="en", + ), + is_realtime=False, + ) + + with pytest.raises(ValueError) as exc_info: + await UserConfigurationValidator().validate(effective) + + assert exc_info.value.args[0] == [ + {"model": "stt", "message": "API key is missing"}, + {"model": "tts", "message": "API key is missing"}, + ] + + +def test_masked_dograh_key_is_preserved_when_saving_same_mode(): + existing = OrganizationAIModelConfigurationV2( + mode="dograh", + dograh=DograhManagedAIModelConfiguration(api_key="mps-real-secret"), + ) + incoming = OrganizationAIModelConfigurationV2( + mode="dograh", + dograh=DograhManagedAIModelConfiguration(api_key=mask_key("mps-real-secret")), + ) + + merged = merge_ai_model_configuration_v2_secrets(incoming, existing) + + assert merged.dograh.api_key == "mps-real-secret" + check_for_masked_keys_in_ai_model_configuration_v2(merged) + + +def test_masked_v2_configuration_masks_nested_service_keys(): + config = OrganizationAIModelConfigurationV2( + mode="byok", + byok={ + "mode": "pipeline", + "pipeline": { + "llm": { + "provider": "openai", + "api_key": "sk-real-secret", + "model": "gpt-4.1", + }, + "tts": { + "provider": "elevenlabs", + "api_key": "el-real-secret", + "model": "eleven_flash_v2_5", + "voice": "Rachel", + }, + "stt": { + "provider": "deepgram", + "api_key": "dg-real-secret", + "model": "nova-3-general", + }, + }, + }, + ) + + masked = mask_ai_model_configuration_v2(config) + + assert masked["byok"]["pipeline"]["llm"]["api_key"] == mask_key("sk-real-secret") + assert masked["byok"]["pipeline"]["tts"]["api_key"] == mask_key("el-real-secret") + assert masked["byok"]["pipeline"]["stt"]["api_key"] == mask_key("dg-real-secret") + + +def test_legacy_all_dograh_pipeline_converts_to_dograh_v2(): + legacy = EffectiveAIModelConfiguration( + llm=DograhLLMService( + provider="dograh", + api_key=["mps-secret"], + model="default", + ), + tts=DograhTTSService( + provider="dograh", + api_key=["mps-secret"], + model="default", + voice="default", + speed=1.0, + ), + stt=DograhSTTService( + provider="dograh", + api_key=["mps-secret"], + model="default", + language="multi", + ), + ) + + config = convert_legacy_ai_model_configuration_to_v2(legacy) + + assert config.mode == "dograh" + assert config.dograh.api_key == "mps-secret" + + +def test_legacy_mixed_dograh_pipeline_converts_to_dograh_v2(): + legacy = EffectiveAIModelConfiguration( + llm=OpenAILLMService( + provider="openai", + api_key="sk-llm", + model="gpt-4.1", + ), + tts=DograhTTSService( + provider="dograh", + api_key="mps-tts", + model="default", + voice="default", + ), + stt=DograhSTTService( + provider="dograh", + api_key="mps-stt", + model="default", + ), + embeddings=OpenAIEmbeddingsConfiguration( + provider="openai", + api_key="sk-emb", + model="text-embedding-3-small", + ), + ) + + config = convert_legacy_ai_model_configuration_to_v2(legacy) + + assert config.mode == "dograh" + assert config.dograh.api_key == "mps-tts" + assert config.dograh.voice == "default" + + +def test_legacy_byok_pipeline_converts_to_byok_v2(): + legacy = EffectiveAIModelConfiguration( + llm=OpenAILLMService( + provider="openai", + api_key="sk-llm", + model="gpt-4.1", + ), + tts=ElevenlabsTTSConfiguration( + provider="elevenlabs", + api_key="el-tts", + model="eleven_flash_v2_5", + voice="Rachel", + ), + stt=DeepgramSTTConfiguration( + provider="deepgram", + api_key="dg-stt", + model="nova-3-general", + ), + embeddings=OpenAIEmbeddingsConfiguration( + provider="openai", + api_key="sk-emb", + model="text-embedding-3-small", + ), + ) + + config = convert_legacy_ai_model_configuration_to_v2(legacy) + + assert config.mode == "byok" + assert config.byok.mode == "pipeline" + assert config.byok.pipeline.llm.provider == "openai" + assert config.byok.pipeline.tts.provider == "elevenlabs" + + +def test_workflow_model_override_migration_removes_v1_override_and_sets_v2(): + base = EffectiveAIModelConfiguration( + llm=OpenAILLMService( + provider="openai", + api_key="sk-llm", + model="gpt-4.1", + ), + tts=ElevenlabsTTSConfiguration( + provider="elevenlabs", + api_key="el-tts", + model="eleven_flash_v2_5", + voice="Rachel", + ), + stt=DeepgramSTTConfiguration( + provider="deepgram", + api_key="dg-stt", + model="nova-3-general", + ), + ) + workflow_configurations = { + "ambient_noise_configuration": {"enabled": False}, + "model_overrides": { + "tts": { + "provider": "dograh", + "api_key": "mps-workflow", + "model": "default", + "voice": "default", + } + }, + } + + migrated, changed = migrate_workflow_configuration_model_override_to_v2( + workflow_configurations, + base, + ) + + assert changed is True + assert "model_overrides" not in migrated + assert migrated["ambient_noise_configuration"] == {"enabled": False} + v2_override = migrated[WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY] + assert v2_override["mode"] == "dograh" + assert v2_override["dograh"]["api_key"] == "mps-workflow" + + +def test_workflow_model_override_migration_removes_invalid_v1_override_marker(): + base = EffectiveAIModelConfiguration() + workflow_configurations = { + "ambient_noise_configuration": {"enabled": False}, + "model_overrides": None, + } + + migrated, changed = migrate_workflow_configuration_model_override_to_v2( + workflow_configurations, + base, + ) + + assert changed is True + assert "model_overrides" not in migrated + assert migrated["ambient_noise_configuration"] == {"enabled": False} + + +@pytest.mark.asyncio +async def test_migrate_model_configuration_v2_initializes_hosted_mps_billing( + monkeypatch, +): + from api.routes import organization as organization_routes + + legacy = EffectiveAIModelConfiguration( + llm=DograhLLMService( + provider="dograh", + api_key=["mps-secret"], + model="default", + ), + tts=DograhTTSService( + provider="dograh", + api_key=["mps-secret"], + model="default", + voice="default", + ), + stt=DograhSTTService( + provider="dograh", + api_key=["mps-secret"], + model="default", + ), + ) + expected_response = OrganizationAIModelConfigurationResponse( + configuration={"version": 2, "mode": "dograh"}, + effective_configuration={}, + source="organization_v2", + ) + + class FakeValidator: + async def validate(self, *args, **kwargs): + return {"status": [{"model": "all", "message": "ok"}]} + + ensure_billing = AsyncMock(return_value={"billing_mode": "v2"}) + upsert = AsyncMock() + migrate_workflows = AsyncMock() + + monkeypatch.setattr(organization_routes, "DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + organization_routes, + "get_organization_ai_model_configuration_v2", + AsyncMock(return_value=None), + ) + monkeypatch.setattr( + organization_routes.db_client, + "get_user_configurations", + AsyncMock(return_value=legacy), + ) + monkeypatch.setattr( + organization_routes, + "UserConfigurationValidator", + lambda: FakeValidator(), + ) + monkeypatch.setattr( + organization_routes, + "ensure_hosted_mps_billing_account_v2", + ensure_billing, + ) + monkeypatch.setattr( + organization_routes, + "upsert_organization_ai_model_configuration_v2", + upsert, + ) + monkeypatch.setattr( + organization_routes, + "migrate_workflow_model_configurations_to_v2", + migrate_workflows, + ) + monkeypatch.setattr( + organization_routes, + "_model_configuration_v2_response", + AsyncMock(return_value=expected_response), + ) + + user = SimpleNamespace( + id=7, + provider_id="provider-123", + selected_organization_id=42, + ) + + response = await organization_routes.migrate_model_configuration_v2( + force=False, + user=user, + ) + + ensure_billing.assert_awaited_once_with(42, created_by="provider-123") + upsert.assert_awaited_once() + migrate_workflows.assert_awaited_once_with( + organization_id=42, + fallback_user_config=legacy, + ) + assert response == expected_response diff --git a/api/tests/test_auth_depends.py b/api/tests/test_auth_depends.py new file mode 100644 index 00000000..2f33ff58 --- /dev/null +++ b/api/tests/test_auth_depends.py @@ -0,0 +1,68 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from api.services.auth import depends as auth_depends + + +@pytest.mark.asyncio +async def test_get_user_initializes_hosted_mps_billing_for_new_org(monkeypatch): + stack_user = { + "id": "stack-user-1", + "selected_team_id": "team-1", + "primary_email_verified": False, + } + user = SimpleNamespace( + id=7, + email=None, + provider_id="stack-user-1", + selected_organization_id=None, + ) + organization = SimpleNamespace(id=42) + existing_config = SimpleNamespace(llm=object(), tts=None, stt=None) + + ensure_billing = AsyncMock(return_value={"billing_mode": "v2"}) + + monkeypatch.setattr(auth_depends, "AUTH_PROVIDER", "stack") + monkeypatch.setattr( + auth_depends.stackauth, + "get_user", + AsyncMock(return_value=stack_user), + ) + monkeypatch.setattr( + auth_depends.db_client, + "get_or_create_user_by_provider_id", + AsyncMock(return_value=(user, False)), + ) + monkeypatch.setattr( + auth_depends.db_client, + "get_or_create_organization_by_provider_id", + AsyncMock(return_value=(organization, True)), + ) + monkeypatch.setattr( + auth_depends.db_client, + "add_user_to_organization", + AsyncMock(), + ) + monkeypatch.setattr( + auth_depends.db_client, + "update_user_selected_organization", + AsyncMock(), + ) + monkeypatch.setattr( + auth_depends.db_client, + "get_user_configurations", + AsyncMock(return_value=existing_config), + ) + monkeypatch.setattr( + auth_depends, + "ensure_hosted_mps_billing_account_v2", + ensure_billing, + ) + + result = await auth_depends.get_user(authorization="Bearer token") + + assert result is user + assert result.selected_organization_id == 42 + ensure_billing.assert_awaited_once_with(42, created_by="stack-user-1") diff --git a/api/tests/test_cartesia_tts_service_factory.py b/api/tests/test_cartesia_tts_service_factory.py new file mode 100644 index 00000000..71e2acab --- /dev/null +++ b/api/tests/test_cartesia_tts_service_factory.py @@ -0,0 +1,45 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from api.services.configuration.registry import ( + CARTESIA_TTS_MODELS, + CartesiaTTSConfiguration, + ServiceProviders, +) +from api.services.pipecat.service_factory import create_tts_service + + +def test_cartesia_tts_configuration_defaults_to_sonic_3_5(): + config = CartesiaTTSConfiguration(api_key="test-key") + + assert config.provider == ServiceProviders.CARTESIA + assert config.model == "sonic-3.5" + assert CARTESIA_TTS_MODELS == ["sonic-3.5", "sonic-3"] + + +def test_create_cartesia_tts_service_passes_selected_model(): + user_config = SimpleNamespace( + tts=SimpleNamespace( + provider=ServiceProviders.CARTESIA.value, + api_key="test-key", + model="sonic-3.5", + voice="test-voice-id", + speed=1.0, + volume=1.0, + ) + ) + audio_config = SimpleNamespace( + transport_out_sample_rate=24000, + transport_in_sample_rate=16000, + ) + + with patch( + "api.services.pipecat.service_factory.CartesiaTTSService" + ) as mock_service: + create_tts_service(user_config, audio_config) + + assert mock_service.call_count == 1 + kwargs = mock_service.call_args.kwargs + assert kwargs["api_key"] == "test-key" + assert kwargs["settings"].model == "sonic-3.5" + assert kwargs["settings"].voice == "test-voice-id" diff --git a/api/tests/test_cost_calculator.py b/api/tests/test_cost_calculator.py deleted file mode 100644 index 940ac582..00000000 --- a/api/tests/test_cost_calculator.py +++ /dev/null @@ -1,31 +0,0 @@ -from api.services.pricing.cost_calculator import cost_calculator - - -def test_cost_calculator(): - """Test function to verify cost calculation works""" - sample_usage = { - "llm": { - "OpenAILLMService#0|||gpt-4.1-mini": { - "prompt_tokens": 45380, - "completion_tokens": 496, - "total_tokens": 45876, - "cache_read_input_tokens": 0, - "cache_creation_input_tokens": 0, - } - }, - "tts": {"ElevenLabsTTSService#0|||eleven_flash_v2_5": 2399}, - "stt": {"DeepgramSTTService#0|||nova-3-general": 177.21536946296692}, - "call_duration_seconds": 179, - } - - result = cost_calculator.calculate_total_cost(sample_usage) - assert result["llm_cost"] == 45380 * 0.40 / 1_000_000 + 496 * 1.60 / 1_000_000 - assert result["tts_cost"] == 2399 * 0.0256 / 1_000 - assert result["stt_cost"] == 177.21536946296692 / 60 * 0.0077 - assert ( - abs( - result["total"] - - (result["llm_cost"] + result["tts_cost"] + result["stt_cost"]) - ) - < 1e-10 - ) diff --git a/api/tests/test_dograh_managed_correlation.py b/api/tests/test_dograh_managed_correlation.py new file mode 100644 index 00000000..b0cb52c0 --- /dev/null +++ b/api/tests/test_dograh_managed_correlation.py @@ -0,0 +1,110 @@ +import json + +import pytest +from openai._types import NOT_GIVEN as OPENAI_NOT_GIVEN +from pipecat.frames.frames import TTSStartedFrame +from pipecat.services.dograh.llm import DograhLLMService +from pipecat.services.dograh.stt import DograhSTTService +from pipecat.services.dograh.tts import DograhTTSService +from pipecat.services.openai.base_llm import OpenAILLMSettings +from websockets.protocol import State + + +class _FakeWebSocket: + def __init__(self): + self.state = State.OPEN + self.messages: list[dict] = [] + + async def send(self, message: str) -> None: + self.messages.append(json.loads(message)) + + async def close(self, *args, **kwargs) -> None: + self.state = State.CLOSED + + +def test_dograh_llm_uses_explicit_mps_correlation_id(): + service = DograhLLMService( + api_key="mps-secret", + correlation_id="mps-corr-123", + settings=OpenAILLMSettings(model="default"), + ) + service._start_metadata = {"workflow_run_id": 99} + + params = service.build_chat_completion_params( + { + "messages": [], + "tools": OPENAI_NOT_GIVEN, + "tool_choice": OPENAI_NOT_GIVEN, + } + ) + + assert params["metadata"]["correlation_id"] == "mps-corr-123" + assert params["metadata"]["mps_billing_version"] == "2" + + +@pytest.mark.asyncio +async def test_dograh_stt_config_uses_explicit_mps_correlation_id(monkeypatch): + fake_ws = _FakeWebSocket() + + async def fake_connect(url, additional_headers): + return fake_ws + + monkeypatch.setattr( + "pipecat.services.dograh.stt.websocket_connect", + fake_connect, + ) + + service = DograhSTTService( + api_key="mps-secret", + correlation_id="mps-corr-123", + sample_rate=16000, + ) + service._start_metadata = {"workflow_run_id": 99} + + await service._connect_websocket() + + assert fake_ws.messages[0]["type"] == "config" + assert fake_ws.messages[0]["correlation_id"] == "mps-corr-123" + assert fake_ws.messages[0]["mps_billing_version"] == "2" + + +@pytest.mark.asyncio +async def test_dograh_tts_messages_use_explicit_mps_correlation_id(monkeypatch): + fake_ws = _FakeWebSocket() + + async def fake_connect(url, additional_headers): + return fake_ws + + monkeypatch.setattr( + "pipecat.services.dograh.tts.websocket_connect", + fake_connect, + ) + + service = DograhTTSService( + api_key="mps-secret", + correlation_id="mps-corr-123", + sample_rate=24000, + ) + service._start_metadata = {"workflow_run_id": 99} + + await service._connect_websocket() + assert fake_ws.messages[0]["type"] == "config" + assert fake_ws.messages[0]["correlation_id"] == "mps-corr-123" + assert fake_ws.messages[0]["mps_billing_version"] == "2" + + async def _noop(*args, **kwargs): + return None + + service.audio_context_available = lambda context_id: False + service.create_audio_context = _noop + service.start_ttfb_metrics = _noop + service.start_tts_usage_metrics = _noop + + frames = [] + async for frame in service.run_tts("hello", "ctx-1"): + frames.append(frame) + + assert isinstance(frames[0], TTSStartedFrame) + assert fake_ws.messages[1]["type"] == "create_context" + assert fake_ws.messages[1]["correlation_id"] == "mps-corr-123" + assert fake_ws.messages[1]["mps_billing_version"] == "2" diff --git a/api/tests/test_from_number_pool_isolation.py b/api/tests/test_from_number_pool_isolation.py index 3c65d10f..ae3dffbc 100644 --- a/api/tests/test_from_number_pool_isolation.py +++ b/api/tests/test_from_number_pool_isolation.py @@ -270,6 +270,12 @@ class TestDispatcherThreadsTelephonyConfig: "api.services.campaign.campaign_call_dispatcher.get_backend_endpoints", AsyncMock(return_value=("https://example.com", None)), ), + patch( + "api.services.campaign.campaign_call_dispatcher.authorize_workflow_run_start", + AsyncMock( + return_value=SimpleNamespace(has_quota=True, error_message="") + ), + ), ): mock_db.get_workflow_by_id = AsyncMock(return_value=SimpleNamespace(id=1)) mock_db.create_workflow_run = AsyncMock(return_value=workflow_run) diff --git a/api/tests/test_grok_realtime_wrapper.py b/api/tests/test_grok_realtime_wrapper.py index f3cfa1a7..19cae657 100644 --- a/api/tests/test_grok_realtime_wrapper.py +++ b/api/tests/test_grok_realtime_wrapper.py @@ -7,7 +7,7 @@ from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.frame_processor import FrameDirection from pipecat.services.xai.realtime import events -from api.schemas.user_configuration import UserConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.services.configuration.registry import GrokRealtimeLLMConfiguration from api.services.pipecat.realtime.grok_realtime import ( DograhGrokRealtimeLLMService, @@ -120,7 +120,7 @@ async def test_completed_input_transcription_is_broadcast_as_finalized(): def test_factory_creates_dograh_grok_realtime_service(): - user_config = UserConfiguration( + effective_config = EffectiveAIModelConfiguration( is_realtime=True, realtime=GrokRealtimeLLMConfiguration( provider="grok_realtime", @@ -131,7 +131,7 @@ def test_factory_creates_dograh_grok_realtime_service(): ) service = create_realtime_llm_service( - user_config, + effective_config, audio_config=SimpleNamespace(), ) diff --git a/api/tests/test_masked_key_rejection.py b/api/tests/test_masked_key_rejection.py index c6fdb51b..45782335 100644 --- a/api/tests/test_masked_key_rejection.py +++ b/api/tests/test_masked_key_rejection.py @@ -1,10 +1,11 @@ +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from fastapi import FastAPI from fastapi.testclient import TestClient from api.routes.user import router -from api.schemas.user_configuration import UserConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.services.auth.depends import get_user from api.services.configuration.masking import mask_key from api.services.configuration.registry import ( @@ -14,14 +15,14 @@ from api.services.configuration.registry import ( ) -def _make_test_app(): +def _make_test_app(selected_organization_id=None): app = FastAPI() app.include_router(router) mock_user = MagicMock() mock_user.id = 1 mock_user.is_superuser = False - mock_user.selected_organization_id = None + mock_user.selected_organization_id = selected_organization_id app.dependency_overrides[get_user] = lambda: mock_user return app @@ -32,7 +33,7 @@ MASKED_KEY = mask_key(REAL_KEY) # "**************************cdef" def _existing_openai_config(): - return UserConfiguration( + return EffectiveAIModelConfiguration( llm=OpenAILLMService( provider="openai", api_key=REAL_KEY, @@ -110,7 +111,7 @@ class TestMaskedKeyRejection: client = TestClient(app) new_key = "AIzaSyNewRealKey12345678" - updated = UserConfiguration( + updated = EffectiveAIModelConfiguration( llm=GoogleLLMService( provider="google", api_key=new_key, @@ -177,7 +178,7 @@ class TestMaskedKeyRejection: real_credentials = '{"type":"service_account","project_id":"demo-project"}' masked_credentials = mask_key(real_credentials) - existing = UserConfiguration( + existing = EffectiveAIModelConfiguration( llm=GoogleVertexLLMConfiguration( provider="google_vertex", api_key=None, @@ -210,3 +211,38 @@ class TestMaskedKeyRejection: ) assert response.status_code == 200 + + def test_preference_only_update_does_not_validate_or_save_model_config(self): + """Saving a test phone number through the legacy endpoint must not touch models.""" + app = _make_test_app(selected_organization_id=11) + client = TestClient(app) + preferences = SimpleNamespace(test_phone_number=None, timezone=None) + + with ( + patch("api.routes.user.db_client") as mock_db, + patch("api.routes.user.UserConfigurationValidator") as mock_validator, + patch( + "api.routes.user.get_organization_preferences", + new=AsyncMock(return_value=preferences), + ), + patch( + "api.routes.user.upsert_organization_preferences", + new=AsyncMock(return_value=preferences), + ) as upsert_preferences, + ): + existing = _existing_openai_config() + mock_db.get_user_configurations = AsyncMock(return_value=existing) + mock_db.update_user_configuration = AsyncMock() + mock_db.get_organization_by_id = AsyncMock(return_value=None) + mock_validator.return_value.validate = AsyncMock() + + response = client.put( + "/user/configurations/user", + json={"test_phone_number": "+15551234567"}, + ) + + assert response.status_code == 200 + assert response.json()["test_phone_number"] == "+15551234567" + mock_db.update_user_configuration.assert_not_called() + mock_validator.return_value.validate.assert_not_called() + upsert_preferences.assert_awaited_once() diff --git a/api/tests/test_mps_service_key_client.py b/api/tests/test_mps_service_key_client.py index 9cd629e3..f51f2aa9 100644 --- a/api/tests/test_mps_service_key_client.py +++ b/api/tests/test_mps_service_key_client.py @@ -87,3 +87,387 @@ async def test_check_service_key_usage_uses_bearer_self_usage(monkeypatch): "Content-Type": "application/json", }, ) + + +@pytest.mark.asyncio +async def test_create_correlation_id_uses_bearer_auth(monkeypatch): + calls = [] + + class FakeAsyncClient: + def __init__(self, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url, json, headers): + calls.append(("POST", url, json, headers)) + return _Response(200, {"correlation_id": "mps-corr-123"}) + + monkeypatch.setattr( + "api.services.mps_service_key_client.httpx.AsyncClient", FakeAsyncClient + ) + + client = MPSServiceKeyClient() + + assert await client.create_correlation_id( + service_key="mps_sk_paid", + workflow_run_id=42, + ) == {"correlation_id": "mps-corr-123"} + assert calls == [ + ( + "POST", + f"{client.base_url}/api/v1/service-keys/correlation-id/self", + {"workflow_run_id": 42}, + { + "Authorization": "Bearer mps_sk_paid", + "Content-Type": "application/json", + }, + ) + ] + + +@pytest.mark.asyncio +async def test_get_billing_account_status_uses_hosted_org_auth(monkeypatch): + calls = [] + + class FakeAsyncClient: + def __init__(self, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def get(self, url, headers): + calls.append(("GET", url, headers)) + return _Response(200, {"organization_id": 42, "billing_mode": "v2"}) + + monkeypatch.setattr( + "api.services.mps_service_key_client.httpx.AsyncClient", FakeAsyncClient + ) + monkeypatch.setattr("api.services.mps_service_key_client.DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + "api.services.mps_service_key_client.DOGRAH_MPS_SECRET_KEY", "mps-secret" + ) + + client = MPSServiceKeyClient() + + assert await client.get_billing_account_status(organization_id=42) == { + "organization_id": 42, + "billing_mode": "v2", + } + assert calls == [ + ( + "GET", + f"{client.base_url}/api/v1/billing/accounts/42/status", + { + "Content-Type": "application/json", + "X-Secret-Key": "mps-secret", + "X-Organization-Id": "42", + }, + ) + ] + + +@pytest.mark.asyncio +async def test_authorize_workflow_run_start_uses_hosted_org_auth(monkeypatch): + calls = [] + + class FakeAsyncClient: + def __init__(self, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url, json, headers): + calls.append(("POST", url, json, headers)) + return _Response( + 200, + { + "allowed": True, + "billing_mode": "v2", + "remaining_credits": "25.0000", + "correlation_id": "mps-corr-123", + }, + ) + + monkeypatch.setattr( + "api.services.mps_service_key_client.httpx.AsyncClient", FakeAsyncClient + ) + monkeypatch.setattr("api.services.mps_service_key_client.DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + "api.services.mps_service_key_client.DOGRAH_MPS_SECRET_KEY", "mps-secret" + ) + + client = MPSServiceKeyClient() + + assert await client.authorize_workflow_run_start( + organization_id=42, + workflow_run_id=88, + service_key="mps_sk_paid", + require_correlation_id=True, + minimum_credits=0.1, + metadata={"workflow_id": 7}, + created_by="provider-123", + ) == { + "allowed": True, + "billing_mode": "v2", + "remaining_credits": "25.0000", + "correlation_id": "mps-corr-123", + } + assert calls == [ + ( + "POST", + f"{client.base_url}/api/v1/billing/accounts/42/run-authorization", + { + "workflow_run_id": 88, + "service_key": "mps_sk_paid", + "require_correlation_id": True, + "minimum_credits": 0.1, + "metadata": {"workflow_id": 7}, + }, + { + "Content-Type": "application/json", + "X-Secret-Key": "mps-secret", + "X-Organization-Id": "42", + }, + ) + ] + + +@pytest.mark.asyncio +async def test_ensure_billing_account_v2_uses_balance_endpoint(monkeypatch): + calls = [] + + class FakeAsyncClient: + def __init__(self, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def get(self, url, headers): + calls.append(("GET", url, headers)) + return _Response( + 200, + { + "id": 7, + "organization_id": 42, + "billing_mode": "v2", + "cached_balance_credits": "0.0000", + "currency": "USD", + }, + ) + + monkeypatch.setattr( + "api.services.mps_service_key_client.httpx.AsyncClient", FakeAsyncClient + ) + monkeypatch.setattr("api.services.mps_service_key_client.DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + "api.services.mps_service_key_client.DOGRAH_MPS_SECRET_KEY", "mps-secret" + ) + + client = MPSServiceKeyClient() + + assert await client.ensure_billing_account_v2( + organization_id=42, + created_by="provider-123", + ) == { + "id": 7, + "organization_id": 42, + "billing_mode": "v2", + "cached_balance_credits": "0.0000", + "currency": "USD", + } + assert calls == [ + ( + "GET", + f"{client.base_url}/api/v1/billing/accounts/42/balance", + { + "Content-Type": "application/json", + "X-Secret-Key": "mps-secret", + "X-Organization-Id": "42", + }, + ) + ] + + +@pytest.mark.asyncio +async def test_get_credit_ledger_sends_page_and_limit(monkeypatch): + calls = [] + + class FakeAsyncClient: + def __init__(self, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def get(self, url, params, headers): + calls.append(("GET", url, params, headers)) + return _Response( + 200, + { + "account": {"organization_id": 42}, + "ledger_entries": [], + "total_count": 0, + "page": 3, + "limit": 25, + "total_pages": 0, + }, + ) + + monkeypatch.setattr( + "api.services.mps_service_key_client.httpx.AsyncClient", FakeAsyncClient + ) + monkeypatch.setattr("api.services.mps_service_key_client.DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + "api.services.mps_service_key_client.DOGRAH_MPS_SECRET_KEY", "mps-secret" + ) + + client = MPSServiceKeyClient() + + assert await client.get_credit_ledger( + organization_id=42, + page=3, + limit=25, + ) == { + "account": {"organization_id": 42}, + "ledger_entries": [], + "total_count": 0, + "page": 3, + "limit": 25, + "total_pages": 0, + } + assert calls == [ + ( + "GET", + f"{client.base_url}/api/v1/billing/accounts/42/ledger", + {"page": 3, "limit": 25}, + { + "Content-Type": "application/json", + "X-Secret-Key": "mps-secret", + "X-Organization-Id": "42", + }, + ) + ] + + +@pytest.mark.asyncio +async def test_report_platform_usage_uses_hosted_secret_auth(monkeypatch): + calls = [] + + class FakeAsyncClient: + def __init__(self, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url, json, headers): + calls.append(("POST", url, json, headers)) + return _Response(200, {"metered": True}) + + monkeypatch.setattr( + "api.services.mps_service_key_client.httpx.AsyncClient", FakeAsyncClient + ) + monkeypatch.setattr("api.services.mps_service_key_client.DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + "api.services.mps_service_key_client.DOGRAH_MPS_SECRET_KEY", "mps-secret" + ) + + client = MPSServiceKeyClient() + + assert await client.report_platform_usage( + organization_id=42, + correlation_id="mps-corr-123", + workflow_run_id=123, + metadata={"source": "workflow_run_completion"}, + ) == {"metered": True} + assert calls == [ + ( + "POST", + f"{client.base_url}/api/v1/billing/accounts/42/platform-usage", + { + "correlation_id": "mps-corr-123", + "workflow_run_id": 123, + "metadata": {"source": "workflow_run_completion"}, + }, + { + "Content-Type": "application/json", + "X-Secret-Key": "mps-secret", + "X-Organization-Id": "42", + }, + ) + ] + + +@pytest.mark.asyncio +async def test_report_platform_usage_sends_duration_without_correlation(monkeypatch): + calls = [] + + class FakeAsyncClient: + def __init__(self, timeout): + self.timeout = timeout + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + async def post(self, url, json, headers): + calls.append(("POST", url, json, headers)) + return _Response(200, {"metered": True}) + + monkeypatch.setattr( + "api.services.mps_service_key_client.httpx.AsyncClient", FakeAsyncClient + ) + monkeypatch.setattr("api.services.mps_service_key_client.DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + "api.services.mps_service_key_client.DOGRAH_MPS_SECRET_KEY", "mps-secret" + ) + + client = MPSServiceKeyClient() + + assert await client.report_platform_usage( + organization_id=42, + duration_seconds=87.0, + workflow_run_id=123, + metadata={"source": "workflow_run_completion"}, + ) == {"metered": True} + assert calls == [ + ( + "POST", + f"{client.base_url}/api/v1/billing/accounts/42/platform-usage", + { + "duration_seconds": 87.0, + "workflow_run_id": 123, + "metadata": {"source": "workflow_run_completion"}, + }, + { + "Content-Type": "application/json", + "X-Secret-Key": "mps-secret", + "X-Organization-Id": "42", + }, + ) + ] diff --git a/api/tests/test_organization_usage_billing.py b/api/tests/test_organization_usage_billing.py new file mode 100644 index 00000000..2f813eac --- /dev/null +++ b/api/tests/test_organization_usage_billing.py @@ -0,0 +1,99 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from api.routes import organization_usage + + +def test_is_mps_billing_v2_depends_only_on_account_mode(): + assert organization_usage._is_mps_billing_v2({"billing_mode": "v2"}) is True + assert organization_usage._is_mps_billing_v2({"billing_mode": "v1"}) is False + assert organization_usage._is_mps_billing_v2({"billing_mode": "shadow"}) is False + assert organization_usage._is_mps_billing_v2(None) is False + + +@pytest.mark.asyncio +async def test_get_mps_billing_account_status_uses_user_provider_id(monkeypatch): + get_status = AsyncMock(return_value={"billing_mode": "v2"}) + monkeypatch.setattr( + organization_usage.mps_service_key_client, + "get_billing_account_status", + get_status, + ) + + user = SimpleNamespace(provider_id="provider-123") + + assert await organization_usage._get_mps_billing_account_status(user, 42) == { + "billing_mode": "v2" + } + get_status.assert_awaited_once_with( + organization_id=42, + created_by="provider-123", + ) + + +@pytest.mark.asyncio +async def test_get_billing_credits_pages_v2_ledger(monkeypatch): + monkeypatch.setattr(organization_usage, "DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + organization_usage, + "_get_mps_billing_account_status", + AsyncMock(return_value={"billing_mode": "v2"}), + ) + get_ledger = AsyncMock( + return_value={ + "account": { + "id": 7, + "organization_id": 42, + "billing_mode": "v2", + "cached_balance_credits": 250, + "currency": "USD", + }, + "ledger_entries": [ + { + "id": 99, + "entry_type": "grant", + "origin": "account_creation", + "credits_delta": 250, + "balance_after": 250, + "created_at": "2026-06-12T00:00:00Z", + } + ], + "total_debits_credits": 75, + "total_count": 101, + "page": 3, + "limit": 25, + "total_pages": 5, + } + ) + monkeypatch.setattr( + organization_usage.mps_service_key_client, + "get_credit_ledger", + get_ledger, + ) + + user = SimpleNamespace( + provider_id="provider-123", + selected_organization_id=42, + ) + + response = await organization_usage.get_billing_credits( + page=3, + limit=25, + user=user, + ) + + get_ledger.assert_awaited_once_with( + organization_id=42, + page=3, + limit=25, + created_by="provider-123", + ) + assert response.billing_version == "v2" + assert response.total_credits_used == 75 + assert response.total_count == 101 + assert response.page == 3 + assert response.limit == 25 + assert response.total_pages == 5 + assert response.ledger_entries[0].id == 99 diff --git a/api/tests/test_pre_call_fetch.py b/api/tests/test_pre_call_fetch.py new file mode 100644 index 00000000..8016da21 --- /dev/null +++ b/api/tests/test_pre_call_fetch.py @@ -0,0 +1,66 @@ +from api.services.pipecat.pre_call_fetch import _extract_initial_context + + +class TestExtractInitialContext: + """Tests for _extract_initial_context, the pre-call fetch response parser.""" + + def test_initial_context_nested_under_call_inbound(self): + """The canonical `initial_context` key nested under `call_inbound`.""" + response = {"call_inbound": {"initial_context": {"customer_name": "Jane"}}} + assert _extract_initial_context(response) == {"customer_name": "Jane"} + + def test_initial_context_at_top_level(self): + """The canonical `initial_context` key at the top level.""" + response = {"initial_context": {"customer_name": "Jane"}} + assert _extract_initial_context(response) == {"customer_name": "Jane"} + + def test_legacy_dynamic_variables_nested(self): + """The legacy `dynamic_variables` key still works nested under `call_inbound`.""" + response = {"call_inbound": {"dynamic_variables": {"customer_name": "Jane"}}} + assert _extract_initial_context(response) == {"customer_name": "Jane"} + + def test_legacy_dynamic_variables_at_top_level(self): + """The legacy `dynamic_variables` key still works at the top level.""" + response = {"dynamic_variables": {"customer_name": "Jane"}} + assert _extract_initial_context(response) == {"customer_name": "Jane"} + + def test_initial_context_takes_precedence_over_legacy(self): + """When both keys are present, `initial_context` wins.""" + response = { + "call_inbound": { + "initial_context": {"source": "new"}, + "dynamic_variables": {"source": "legacy"}, + } + } + assert _extract_initial_context(response) == {"source": "new"} + + def test_falls_back_to_legacy_when_initial_context_not_a_dict(self): + """A non-dict `initial_context` falls back to `dynamic_variables`.""" + response = { + "initial_context": None, + "dynamic_variables": {"customer_name": "Jane"}, + } + assert _extract_initial_context(response) == {"customer_name": "Jane"} + + def test_nested_values_preserved(self): + """Nested objects pass through untouched for dot-notation access.""" + response = { + "call_inbound": { + "initial_context": {"customer": {"address": {"city": "LA"}}} + } + } + assert _extract_initial_context(response) == { + "customer": {"address": {"city": "LA"}} + } + + def test_empty_when_no_known_keys(self): + """A response with neither key yields an empty dict.""" + assert _extract_initial_context({"call_inbound": {"agent_id": 1}}) == {} + + def test_empty_when_call_inbound_missing(self): + """No `call_inbound` and no top-level keys yields an empty dict.""" + assert _extract_initial_context({}) == {} + + def test_non_dict_vars_yield_empty(self): + """A non-dict value under a known key yields an empty dict.""" + assert _extract_initial_context({"initial_context": "nope"}) == {} diff --git a/api/tests/test_public_agent_routes.py b/api/tests/test_public_agent_routes.py index a7849fbe..3b7ea409 100644 --- a/api/tests/test_public_agent_routes.py +++ b/api/tests/test_public_agent_routes.py @@ -57,7 +57,7 @@ def test_trigger_route_executes_as_workflow_owner(): with ( patch("api.routes.public_agent.db_client") as mock_db, patch( - "api.routes.public_agent.check_dograh_quota_by_user_id", + "api.routes.public_agent.authorize_workflow_run_start", new=quota_mock, ), patch( @@ -92,7 +92,10 @@ def test_trigger_route_executes_as_workflow_owner(): ) assert response.status_code == 200 - quota_mock.assert_awaited_once_with(workflow.user_id, workflow_id=workflow.id) + quota_mock.assert_awaited_once_with( + workflow_id=workflow.id, + workflow_run_id=501, + ) mock_db.get_workflow.assert_awaited_once_with(workflow.id, organization_id=11) create_kwargs = mock_db.create_workflow_run.await_args.kwargs @@ -124,7 +127,7 @@ def test_workflow_uuid_route_uses_scoped_lookup_and_shared_execution(): with ( patch("api.routes.public_agent.db_client") as mock_db, patch( - "api.routes.public_agent.check_dograh_quota_by_user_id", + "api.routes.public_agent.authorize_workflow_run_start", new=quota_mock, ), patch( diff --git a/api/tests/test_public_embed_cors.py b/api/tests/test_public_embed_cors.py new file mode 100644 index 00000000..5683f38c --- /dev/null +++ b/api/tests/test_public_embed_cors.py @@ -0,0 +1,274 @@ +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.testclient import TestClient + +from api.routes.public_embed import PublicEmbedCORSMiddleware, router + +app = FastAPI() +app.add_middleware( + CORSMiddleware, + allow_origins=["https://app.dograh.com"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.add_middleware(PublicEmbedCORSMiddleware, api_prefix="/api/v1") +app.include_router(router, prefix="/api/v1") +client = TestClient(app, raise_server_exceptions=False) + +_ACTIVE_TOKEN = SimpleNamespace( + id=10, + is_active=True, + expires_at=None, + allowed_domains=[], + workflow_id=1, + created_by=7, + usage_limit=None, + usage_count=0, + settings={}, +) + +_RESTRICTED_TOKEN = SimpleNamespace( + id=20, + is_active=True, + expires_at=None, + allowed_domains=["allowed.example.com"], + workflow_id=2, + created_by=7, + usage_limit=None, + usage_count=0, + settings={}, +) + +_LOCALHOST_TOKEN = SimpleNamespace( + id=30, + is_active=True, + expires_at=None, + allowed_domains=["localhost:3000", "localhost:3020"], + workflow_id=3, + created_by=7, + usage_limit=None, + usage_count=0, + settings={}, +) + + +@pytest.fixture(autouse=True) +def _patch_db(monkeypatch): + async def _get_token(token): + if token == "valid": + return _ACTIVE_TOKEN + if token == "restricted": + return _RESTRICTED_TOKEN + if token == "localhost": + return _LOCALHOST_TOKEN + return None + + async def _get_token_by_id(token_id): + if token_id == _ACTIVE_TOKEN.id: + return _ACTIVE_TOKEN + if token_id == _RESTRICTED_TOKEN.id: + return _RESTRICTED_TOKEN + if token_id == _LOCALHOST_TOKEN.id: + return _LOCALHOST_TOKEN + return None + + async def _get_session(session_token): + if session_token == "session-valid": + return SimpleNamespace(embed_token_id=_ACTIVE_TOKEN.id, expires_at=None) + if session_token == "session-restricted": + return SimpleNamespace(embed_token_id=_RESTRICTED_TOKEN.id, expires_at=None) + return None + + async def _create_workflow_run(**_kwargs): + return SimpleNamespace(id=123) + + async def _noop(*_args, **_kwargs): + return None + + monkeypatch.setattr( + "api.routes.public_embed.db_client.get_embed_token_by_token", + _get_token, + ) + monkeypatch.setattr( + "api.routes.public_embed.db_client.get_embed_token_by_id", + _get_token_by_id, + ) + monkeypatch.setattr( + "api.routes.public_embed.db_client.get_embed_session_by_token", + _get_session, + ) + monkeypatch.setattr( + "api.routes.public_embed.db_client.create_workflow_run", + _create_workflow_run, + ) + monkeypatch.setattr( + "api.routes.public_embed.db_client.create_embed_session", + _noop, + ) + monkeypatch.setattr( + "api.routes.public_embed.db_client.increment_embed_token_usage", + _noop, + ) + monkeypatch.setattr("api.routes.public_embed.TURN_SECRET", "test-secret") + monkeypatch.setattr( + "api.routes.public_embed.generate_turn_credentials", + lambda _user_id: { + "username": "turn-user", + "password": "turn-password", + "ttl": 3600, + "uris": ["turn:example.com:3478"], + }, + ) + + +def _assert_embed_cors(resp, origin: str): + assert resp.headers.get("access-control-allow-origin") == origin + assert "origin" in { + value.strip().lower() for value in resp.headers.get("vary", "").split(",") + } + + +def test_options_config_returns_acao_for_allowed_origin(): + origin = "https://mysite.vercel.app" + resp = client.options( + "/api/v1/public/embed/config/valid", + headers={ + "Origin": origin, + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_options_config_accepts_allowed_localhost_port(): + origin = "http://localhost:3020" + resp = client.options( + "/api/v1/public/embed/config/localhost", + headers={ + "Origin": origin, + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_options_config_rejects_unknown_token(): + resp = client.options( + "/api/v1/public/embed/config/unknown", + headers={ + "Origin": "https://mysite.vercel.app", + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 403 + + +def test_options_config_rejects_disallowed_origin(): + resp = client.options( + "/api/v1/public/embed/config/restricted", + headers={ + "Origin": "https://notallowed.example.com", + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 403 + + +def test_get_config_includes_acao_header(): + origin = "https://mysite.vercel.app" + resp = client.get( + "/api/v1/public/embed/config/valid", + headers={"Origin": origin}, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_get_config_accepts_allowed_localhost_port(): + origin = "http://localhost:3020" + resp = client.get( + "/api/v1/public/embed/config/localhost", + headers={"Origin": origin}, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_get_config_rejects_unlisted_localhost_port(): + resp = client.get( + "/api/v1/public/embed/config/localhost", + headers={"Origin": "http://localhost:3021"}, + ) + assert resp.status_code == 403 + + +def test_get_config_rejects_disallowed_origin(): + resp = client.get( + "/api/v1/public/embed/config/restricted", + headers={"Origin": "https://notallowed.example.com"}, + ) + assert resp.status_code == 403 + + +def test_init_includes_acao_header(): + origin = "https://mysite.vercel.app" + resp = client.post( + "/api/v1/public/embed/init", + headers={"Origin": origin}, + json={"token": "valid"}, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_turn_credentials_includes_acao_header(): + origin = "https://mysite.vercel.app" + resp = client.get( + "/api/v1/public/embed/turn-credentials/session-valid", + headers={"Origin": origin}, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_options_init_returns_acao_for_allowed_origin(): + origin = "https://mysite.vercel.app" + resp = client.options( + "/api/v1/public/embed/init", + headers={ + "Origin": origin, + "Access-Control-Request-Method": "POST", + }, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_options_turn_credentials_returns_acao_for_allowed_origin(): + origin = "https://mysite.vercel.app" + resp = client.options( + "/api/v1/public/embed/turn-credentials/session-valid", + headers={ + "Origin": origin, + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 200 + _assert_embed_cors(resp, origin) + + +def test_options_turn_credentials_rejects_disallowed_origin(): + resp = client.options( + "/api/v1/public/embed/turn-credentials/session-restricted", + headers={ + "Origin": "https://notallowed.example.com", + "Access-Control-Request-Method": "GET", + }, + ) + assert resp.status_code == 403 diff --git a/api/tests/test_quota_service.py b/api/tests/test_quota_service.py new file mode 100644 index 00000000..8e2ee6f5 --- /dev/null +++ b/api/tests/test_quota_service.py @@ -0,0 +1,369 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from api.services import quota_service +from api.services.configuration.registry import ServiceProviders +from api.services.managed_model_services import MPS_CORRELATION_ID_CONTEXT_KEY + + +def _dograh_config( + api_key: str = "mps_sk_12345678", + *, + managed_service_version: int = 1, +): + return SimpleNamespace( + managed_service_version=managed_service_version, + llm=SimpleNamespace(provider=ServiceProviders.DOGRAH, api_key=api_key), + stt=None, + tts=None, + embeddings=None, + ) + + +def _byok_config(): + return SimpleNamespace( + managed_service_version=2, + llm=SimpleNamespace(provider="openai", api_key="sk-openai"), + stt=None, + tts=None, + embeddings=None, + ) + + +def _workflow(): + return SimpleNamespace( + id=7, + user_id=123, + organization_id=42, + workflow_configurations={"model_overrides": {}}, + ) + + +def _workflow_owner(): + return SimpleNamespace( + id=123, + provider_id="provider-123", + ) + + +def _actor(): + return SimpleNamespace( + id=456, + provider_id="actor-456", + selected_organization_id=42, + ) + + +def _patch_workflow_context(monkeypatch, *, workflow=None, owner=None): + monkeypatch.setattr( + quota_service.db_client, + "get_workflow_by_id", + AsyncMock(return_value=workflow or _workflow()), + ) + monkeypatch.setattr( + quota_service.db_client, + "get_user_by_id", + AsyncMock(return_value=owner or _workflow_owner()), + ) + + +@pytest.mark.asyncio +async def test_authorize_workflow_run_uses_workflow_org_for_hosted_v2( + monkeypatch, +): + get_config = AsyncMock(return_value=_dograh_config()) + authorize = AsyncMock( + return_value={ + "allowed": True, + "billing_mode": "v2", + "remaining_credits": "25.0000", + } + ) + check_usage = AsyncMock() + + monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas") + _patch_workflow_context(monkeypatch) + monkeypatch.setattr( + quota_service, + "get_effective_ai_model_configuration_for_workflow", + get_config, + ) + monkeypatch.setattr( + quota_service.mps_service_key_client, + "authorize_workflow_run_start", + authorize, + ) + monkeypatch.setattr( + quota_service.mps_service_key_client, + "check_service_key_usage", + check_usage, + ) + + result = await quota_service.authorize_workflow_run_start(workflow_id=7) + + assert result.has_quota is True + get_config.assert_awaited_once_with( + user_id=123, + organization_id=42, + workflow_configurations={"model_overrides": {}}, + ) + authorize.assert_awaited_once_with( + organization_id=42, + workflow_run_id=None, + service_key=None, + require_correlation_id=False, + minimum_credits=quota_service.MINIMUM_DOGRAH_CREDITS_FOR_CALL, + created_by="provider-123", + metadata={"dograh_user_id": "123", "workflow_id": 7}, + ) + check_usage.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_authorize_workflow_run_v2_insufficient_credits_prompts_billing( + monkeypatch, +): + get_config = AsyncMock(return_value=_byok_config()) + authorize = AsyncMock( + return_value={ + "allowed": False, + "billing_mode": "v2", + "remaining_credits": "0.0000", + "error": "insufficient_credits", + } + ) + check_usage = AsyncMock() + + monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas") + _patch_workflow_context(monkeypatch) + monkeypatch.setattr( + quota_service, + "get_effective_ai_model_configuration_for_workflow", + get_config, + ) + monkeypatch.setattr( + quota_service.mps_service_key_client, + "authorize_workflow_run_start", + authorize, + ) + monkeypatch.setattr( + quota_service.mps_service_key_client, + "check_service_key_usage", + check_usage, + ) + + result = await quota_service.authorize_workflow_run_start(workflow_id=7) + + assert result.has_quota is False + assert result.error_code == "insufficient_credits" + assert "/billing" in result.error_message + assert "founders@dograh.com" not in result.error_message + authorize.assert_awaited_once() + check_usage.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_authorize_workflow_run_v1_uses_legacy_key_usage( + monkeypatch, +): + api_key = "mps_sk_12345678" + get_config = AsyncMock(return_value=_dograh_config(api_key)) + authorize = AsyncMock( + return_value={ + "allowed": True, + "billing_mode": "v1", + "remaining_credits": "0.0000", + } + ) + check_usage = AsyncMock( + return_value={"total_credits_used": 500.0, "remaining_credits": 0.0} + ) + + monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas") + _patch_workflow_context(monkeypatch) + monkeypatch.setattr( + quota_service, + "get_effective_ai_model_configuration_for_workflow", + get_config, + ) + monkeypatch.setattr( + quota_service.mps_service_key_client, + "authorize_workflow_run_start", + authorize, + ) + monkeypatch.setattr( + quota_service.mps_service_key_client, + "check_service_key_usage", + check_usage, + ) + + result = await quota_service.authorize_workflow_run_start(workflow_id=7) + + assert result.has_quota is False + assert result.error_code == "quota_exceeded" + assert "founders@dograh.com" in result.error_message + assert "/billing" not in result.error_message + authorize.assert_awaited_once() + check_usage.assert_awaited_once_with( + api_key, + organization_id=42, + created_by="provider-123", + ) + + +@pytest.mark.asyncio +async def test_authorize_workflow_run_managed_v2_stores_hosted_correlation( + monkeypatch, +): + api_key = "mps_sk_12345678" + workflow_run = SimpleNamespace(initial_context={"existing": "value"}) + get_config = AsyncMock( + return_value=_dograh_config(api_key, managed_service_version=2) + ) + authorize = AsyncMock( + return_value={ + "allowed": True, + "billing_mode": "v2", + "remaining_credits": "25.0000", + "correlation_id": "mps-corr-123", + } + ) + update_workflow_run = AsyncMock() + + monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas") + _patch_workflow_context(monkeypatch) + monkeypatch.setattr( + quota_service.db_client, + "get_workflow_run_by_id", + AsyncMock(return_value=workflow_run), + ) + monkeypatch.setattr( + quota_service.db_client, + "update_workflow_run", + update_workflow_run, + ) + monkeypatch.setattr( + quota_service, + "get_effective_ai_model_configuration_for_workflow", + get_config, + ) + monkeypatch.setattr( + quota_service.mps_service_key_client, + "authorize_workflow_run_start", + authorize, + ) + monkeypatch.setattr( + quota_service.mps_service_key_client, + "check_service_key_usage", + AsyncMock(), + ) + + result = await quota_service.authorize_workflow_run_start( + workflow_id=7, + workflow_run_id=88, + ) + + assert result.has_quota is True + authorize.assert_awaited_once_with( + organization_id=42, + workflow_run_id=88, + service_key=api_key, + require_correlation_id=True, + minimum_credits=quota_service.MINIMUM_DOGRAH_CREDITS_FOR_CALL, + created_by="provider-123", + metadata={"dograh_user_id": "123", "workflow_id": 7}, + ) + update_workflow_run.assert_awaited_once_with( + 88, + initial_context={ + "existing": "value", + MPS_CORRELATION_ID_CONTEXT_KEY: "mps-corr-123", + }, + ) + + +@pytest.mark.asyncio +async def test_authorize_workflow_run_oss_uses_key_paths_not_workflow_org( + monkeypatch, +): + api_key = "mps_sk_12345678" + workflow_run = SimpleNamespace(initial_context={}) + get_config = AsyncMock( + return_value=_dograh_config(api_key, managed_service_version=2) + ) + hosted_authorize = AsyncMock() + check_usage = AsyncMock( + return_value={"total_credits_used": 1.0, "remaining_credits": 499.0} + ) + create_correlation = AsyncMock(return_value={"correlation_id": "oss-corr-123"}) + update_workflow_run = AsyncMock() + + monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "oss") + _patch_workflow_context(monkeypatch) + monkeypatch.setattr( + quota_service.db_client, + "get_workflow_run_by_id", + AsyncMock(return_value=workflow_run), + ) + monkeypatch.setattr( + quota_service.db_client, + "update_workflow_run", + update_workflow_run, + ) + monkeypatch.setattr( + quota_service, + "get_effective_ai_model_configuration_for_workflow", + get_config, + ) + monkeypatch.setattr( + quota_service.mps_service_key_client, + "authorize_workflow_run_start", + hosted_authorize, + ) + monkeypatch.setattr( + quota_service.mps_service_key_client, + "check_service_key_usage", + check_usage, + ) + monkeypatch.setattr( + quota_service.mps_service_key_client, + "create_correlation_id", + create_correlation, + ) + + result = await quota_service.authorize_workflow_run_start( + workflow_id=7, + workflow_run_id=88, + ) + + assert result.has_quota is True + hosted_authorize.assert_not_awaited() + check_usage.assert_awaited_once_with( + api_key, + organization_id=None, + created_by="provider-123", + ) + create_correlation.assert_awaited_once_with( + service_key=api_key, + workflow_run_id=88, + ) + update_workflow_run.assert_awaited_once_with( + 88, + initial_context={MPS_CORRELATION_ID_CONTEXT_KEY: "oss-corr-123"}, + ) + + +@pytest.mark.asyncio +async def test_authorize_workflow_run_rejects_actor_from_another_org(monkeypatch): + monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas") + _patch_workflow_context(monkeypatch) + + result = await quota_service.authorize_workflow_run_start( + workflow_id=7, + actor_user=SimpleNamespace(selected_organization_id=999), + ) + + assert result.has_quota is False + assert result.error_code == "workflow_not_found" diff --git a/api/tests/test_resolve_effective_config.py b/api/tests/test_resolve_effective_config.py index c539c7c6..1b9ad8c6 100644 --- a/api/tests/test_resolve_effective_config.py +++ b/api/tests/test_resolve_effective_config.py @@ -2,14 +2,14 @@ TDD tests for resolve_effective_config(). This function deep-merges workflow-level model_overrides onto the global -UserConfiguration. Fields not overridden inherit from global. +EffectiveAIModelConfiguration. Fields not overridden inherit from global. Module under test: api.services.configuration.resolve """ import pytest -from api.schemas.user_configuration import UserConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.services.configuration.masking import ( contains_masked_key, mask_workflow_configurations, @@ -35,9 +35,9 @@ from api.services.configuration.resolve import ( @pytest.fixture -def global_config() -> UserConfiguration: +def global_config() -> EffectiveAIModelConfiguration: """A realistic global user configuration.""" - return UserConfiguration( + return EffectiveAIModelConfiguration( llm=OpenAILLMService( provider="openai", api_key="sk-global-llm", model="gpt-4.1" ), @@ -59,9 +59,9 @@ def global_config() -> UserConfiguration: @pytest.fixture -def global_config_realtime() -> UserConfiguration: +def global_config_realtime() -> EffectiveAIModelConfiguration: """Global config with realtime enabled.""" - return UserConfiguration( + return EffectiveAIModelConfiguration( llm=OpenAILLMService( provider="openai", api_key="sk-global-llm", model="gpt-4.1" ), @@ -302,7 +302,7 @@ class TestRealtimeOverride: class TestOverrideOnNullGlobal: def test_override_stt_when_global_is_none(self): """When global has no STT config, override creates one from scratch.""" - config = UserConfiguration( + config = EffectiveAIModelConfiguration( llm=OpenAILLMService(provider="openai", api_key="sk-key", model="gpt-4.1"), stt=None, tts=None, @@ -325,7 +325,7 @@ class TestOverrideOnNullGlobal: def test_override_realtime_when_global_is_none(self): """Realtime section can be created from override even if global has none.""" - config = UserConfiguration( + config = EffectiveAIModelConfiguration( llm=OpenAILLMService(provider="openai", api_key="sk-key", model="gpt-4.1"), is_realtime=False, realtime=None, diff --git a/api/tests/test_run_usage_response.py b/api/tests/test_run_usage_response.py index c17d4a9f..044c6563 100644 --- a/api/tests/test_run_usage_response.py +++ b/api/tests/test_run_usage_response.py @@ -1,4 +1,4 @@ -from api.services.pricing.run_usage_response import format_public_usage_info +from api.services.workflow.run_usage_response import format_public_usage_info def test_format_public_usage_info(): diff --git a/api/tests/test_telephony_routes.py b/api/tests/test_telephony_routes.py index 49c2f8d4..03a4cc48 100644 --- a/api/tests/test_telephony_routes.py +++ b/api/tests/test_telephony_routes.py @@ -1,5 +1,5 @@ from types import SimpleNamespace -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch from fastapi import FastAPI from fastapi.testclient import TestClient @@ -54,7 +54,7 @@ def test_initiate_call_executes_as_workflow_owner_for_shared_org_workflow(): with ( patch("api.routes.telephony.db_client") as mock_db, patch( - "api.routes.telephony.check_dograh_quota_by_user_id", + "api.routes.telephony.authorize_workflow_run_start", new=quota_mock, ), patch( @@ -88,7 +88,11 @@ def test_initiate_call_executes_as_workflow_owner_for_shared_org_workflow(): ) assert response.status_code == 200 - quota_mock.assert_awaited_once_with(workflow.user_id, workflow_id=workflow.id) + quota_mock.assert_awaited_once_with( + workflow_id=workflow.id, + workflow_run_id=501, + actor_user=ANY, + ) mock_db.get_workflow.assert_awaited_once_with(workflow.id, organization_id=11) create_call = mock_db.create_workflow_run.await_args @@ -103,6 +107,61 @@ def test_initiate_call_executes_as_workflow_owner_for_shared_org_workflow(): assert initiate_kwargs["workflow_id"] == workflow.id assert initiate_kwargs["user_id"] == workflow.user_id assert "user_id=99" in initiate_kwargs["webhook_url"] + mock_db.get_user_configurations.assert_not_called() + + +def test_initiate_call_uses_organization_preference_phone_number(): + app = _make_test_app() + client = TestClient(app) + + workflow = _workflow() + provider = _provider() + quota_mock = AsyncMock( + return_value=SimpleNamespace(has_quota=True, error_message="") + ) + + with ( + patch("api.routes.telephony.db_client") as mock_db, + patch( + "api.routes.telephony.authorize_workflow_run_start", + new=quota_mock, + ), + patch( + "api.routes.telephony.get_default_telephony_provider", + new=AsyncMock(return_value=provider), + ), + patch( + "api.routes.telephony.get_backend_endpoints", + new=AsyncMock(return_value=("https://api.example.com", "wss://ignored")), + ), + ): + mock_db.get_user_configurations = AsyncMock( + return_value=SimpleNamespace(test_phone_number="+15550000000") + ) + mock_db.get_configuration = Mock( + return_value=SimpleNamespace(value={"test_phone_number": "+15557654321"}) + ) + mock_db.get_default_telephony_configuration = AsyncMock( + return_value=SimpleNamespace(id=55) + ) + mock_db.get_workflow = AsyncMock(return_value=workflow) + mock_db.create_workflow_run = AsyncMock( + return_value=SimpleNamespace( + id=501, + name="WR-TEL-OUT-00000001", + initial_context={}, + ) + ) + mock_db.update_workflow_run = AsyncMock() + + response = client.post( + "/telephony/initiate-call", + json={"workflow_id": workflow.id}, + ) + + assert response.status_code == 200 + assert provider.initiate_call.await_args.kwargs["to_number"] == "+15557654321" + mock_db.get_user_configurations.assert_not_called() def test_initiate_call_rejects_existing_run_for_different_workflow(): @@ -118,7 +177,7 @@ def test_initiate_call_rejects_existing_run_for_different_workflow(): with ( patch("api.routes.telephony.db_client") as mock_db, patch( - "api.routes.telephony.check_dograh_quota_by_user_id", + "api.routes.telephony.authorize_workflow_run_start", new=quota_mock, ), patch( diff --git a/api/tests/test_ultravox_realtime_wrapper.py b/api/tests/test_ultravox_realtime_wrapper.py index 1034b8d4..32888439 100644 --- a/api/tests/test_ultravox_realtime_wrapper.py +++ b/api/tests/test_ultravox_realtime_wrapper.py @@ -10,7 +10,7 @@ from pipecat.processors.frame_processor import FrameDirection from websockets.exceptions import ConnectionClosedError from websockets.frames import Close -from api.schemas.user_configuration import UserConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.services.configuration.registry import UltravoxRealtimeLLMConfiguration from api.services.pipecat.realtime.ultravox_realtime import ( _RESUMPTION_USER_MESSAGE, @@ -430,7 +430,7 @@ async def test_receive_messages_reports_unexpected_websocket_close(): def test_factory_creates_dograh_ultravox_realtime_service(): - user_config = UserConfiguration( + effective_config = EffectiveAIModelConfiguration( is_realtime=True, realtime=UltravoxRealtimeLLMConfiguration( provider="ultravox_realtime", @@ -441,7 +441,7 @@ def test_factory_creates_dograh_ultravox_realtime_service(): ) service = create_realtime_llm_service( - user_config, + effective_config, audio_config=SimpleNamespace(), ) diff --git a/api/tests/test_workflow_run_billing.py b/api/tests/test_workflow_run_billing.py new file mode 100644 index 00000000..2837317f --- /dev/null +++ b/api/tests/test_workflow_run_billing.py @@ -0,0 +1,212 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from api.services import workflow_run_billing as workflow_run_billing_mod +from api.services.workflow_run_billing import ( + report_completed_workflow_run_platform_usage, + report_workflow_run_platform_usage, +) + + +def _make_workflow_run(): + return SimpleNamespace( + id=123, + workflow_id=456, + is_completed=True, + initial_context={"mps_correlation_id": "mps-corr-123"}, + usage_info={"call_duration_seconds": 87}, + workflow=SimpleNamespace( + organization_id=42, + user=SimpleNamespace(selected_organization_id=42), + ), + ) + + +@pytest.mark.asyncio +async def test_report_workflow_run_platform_usage_reports_hosted_completion( + monkeypatch, +): + workflow_run = _make_workflow_run() + get_status = AsyncMock(return_value={"billing_mode": "v2"}) + report_usage = AsyncMock(return_value={"metered": True}) + + monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "get_billing_account_status", + get_status, + ) + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "report_platform_usage", + report_usage, + ) + + await report_workflow_run_platform_usage(workflow_run) + + report_usage.assert_awaited_once_with( + organization_id=42, + correlation_id="mps-corr-123", + duration_seconds=None, + workflow_run_id=workflow_run.id, + metadata={ + "source": "workflow_run_completion", + "workflow_id": workflow_run.workflow_id, + "duration_source": "mps_correlation", + }, + ) + + +@pytest.mark.asyncio +async def test_report_workflow_run_platform_usage_reports_duration_without_correlation( + monkeypatch, +): + workflow_run = _make_workflow_run() + workflow_run.initial_context = {} + get_status = AsyncMock(return_value={"billing_mode": "v2"}) + report_usage = AsyncMock(return_value={"metered": True}) + + monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "get_billing_account_status", + get_status, + ) + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "report_platform_usage", + report_usage, + ) + + await report_workflow_run_platform_usage(workflow_run) + + report_usage.assert_awaited_once_with( + organization_id=42, + correlation_id=None, + duration_seconds=87.0, + workflow_run_id=workflow_run.id, + metadata={ + "source": "workflow_run_completion", + "workflow_id": workflow_run.workflow_id, + "duration_source": "dograh_usage_info", + }, + ) + + +@pytest.mark.asyncio +async def test_report_workflow_run_platform_usage_skips_non_v2_account(monkeypatch): + workflow_run = _make_workflow_run() + get_status = AsyncMock(return_value={"billing_mode": "v1"}) + report_usage = AsyncMock() + + monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "get_billing_account_status", + get_status, + ) + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "report_platform_usage", + report_usage, + ) + + await report_workflow_run_platform_usage(workflow_run) + + get_status.assert_awaited_once_with(organization_id=42) + report_usage.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_report_workflow_run_platform_usage_skips_missing_duration_without_correlation( + monkeypatch, +): + workflow_run = _make_workflow_run() + workflow_run.initial_context = {} + workflow_run.usage_info = {} + get_status = AsyncMock(return_value={"billing_mode": "v2"}) + report_usage = AsyncMock() + + monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "get_billing_account_status", + get_status, + ) + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "report_platform_usage", + report_usage, + ) + + await report_workflow_run_platform_usage(workflow_run) + + get_status.assert_not_awaited() + report_usage.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_report_workflow_run_platform_usage_skips_oss(monkeypatch): + workflow_run = _make_workflow_run() + report_usage = AsyncMock() + + monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "oss") + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "report_platform_usage", + report_usage, + ) + + await report_workflow_run_platform_usage(workflow_run) + + report_usage.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_report_workflow_run_platform_usage_skips_incomplete(monkeypatch): + workflow_run = _make_workflow_run() + workflow_run.is_completed = False + report_usage = AsyncMock() + + monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "report_platform_usage", + report_usage, + ) + + await report_workflow_run_platform_usage(workflow_run) + + report_usage.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_report_completed_workflow_run_platform_usage_loads_run(monkeypatch): + workflow_run = _make_workflow_run() + get_run = AsyncMock(return_value=workflow_run) + get_status = AsyncMock(return_value={"billing_mode": "v2"}) + report_usage = AsyncMock(return_value={"metered": True}) + + monkeypatch.setattr(workflow_run_billing_mod, "DEPLOYMENT_MODE", "saas") + monkeypatch.setattr( + workflow_run_billing_mod.db_client, + "get_workflow_run_by_id", + get_run, + ) + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "get_billing_account_status", + get_status, + ) + monkeypatch.setattr( + workflow_run_billing_mod.mps_service_key_client, + "report_platform_usage", + report_usage, + ) + + await report_completed_workflow_run_platform_usage(workflow_run.id) + + get_run.assert_awaited_once_with(workflow_run.id) + report_usage.assert_awaited_once() diff --git a/api/tests/test_workflow_run_cost.py b/api/tests/test_workflow_run_cost.py deleted file mode 100644 index c77424c8..00000000 --- a/api/tests/test_workflow_run_cost.py +++ /dev/null @@ -1,181 +0,0 @@ -from datetime import UTC, datetime -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest - -from api.services.pricing import workflow_run_cost as workflow_run_cost_mod -from api.services.pricing.workflow_run_cost import ( - apply_usage_delta_to_organization, - build_workflow_run_cost_info, - calculate_workflow_run_cost, -) - - -def _make_workflow_run(): - return SimpleNamespace( - id=123, - workflow_id=456, - mode="textchat", - created_at=datetime.now(UTC), - usage_info={ - "llm": {}, - "tts": {}, - "stt": {}, - "call_duration_seconds": 7, - }, - cost_info={}, - workflow=SimpleNamespace( - organization_id=42, - user=SimpleNamespace(selected_organization_id=42), - ), - ) - - -@pytest.mark.asyncio -async def test_build_workflow_run_cost_info_does_not_update_org_usage(monkeypatch): - workflow_run = _make_workflow_run() - get_org = AsyncMock(return_value=SimpleNamespace(id=42, price_per_second_usd=1.5)) - update_usage = AsyncMock() - - monkeypatch.setattr( - workflow_run_cost_mod.db_client, "get_organization_by_id", get_org - ) - monkeypatch.setattr( - workflow_run_cost_mod.db_client, "update_usage_after_run", update_usage - ) - - cost_info = await build_workflow_run_cost_info(workflow_run) - - assert cost_info is not None - assert cost_info["call_duration_seconds"] == 7 - assert "cost_breakdown" in cost_info - assert "dograh_token_usage" in cost_info - assert cost_info["charge_usd"] == 10.5 - update_usage.assert_not_called() - - -@pytest.mark.asyncio -async def test_calculate_workflow_run_cost_keeps_org_usage_side_effect_in_wrapper( - monkeypatch, -): - workflow_run = _make_workflow_run() - get_org = AsyncMock(return_value=SimpleNamespace(id=42, price_per_second_usd=None)) - update_run = AsyncMock() - update_usage = AsyncMock() - - monkeypatch.setattr( - workflow_run_cost_mod.db_client, - "get_workflow_run_by_id", - AsyncMock(return_value=workflow_run), - ) - monkeypatch.setattr( - workflow_run_cost_mod.db_client, "get_organization_by_id", get_org - ) - monkeypatch.setattr( - workflow_run_cost_mod.db_client, "update_workflow_run", update_run - ) - monkeypatch.setattr( - workflow_run_cost_mod.db_client, "update_usage_after_run", update_usage - ) - - await calculate_workflow_run_cost(workflow_run.id) - - update_run.assert_awaited_once() - saved_kwargs = update_run.await_args.kwargs - assert saved_kwargs["run_id"] == workflow_run.id - assert "cost_breakdown" in saved_kwargs["cost_info"] - update_usage.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_apply_usage_delta_to_organization_uses_incremental_costs( - monkeypatch, -): - workflow_run = _make_workflow_run() - workflow_run.cost_info = {"call_id": "preserve-me"} - - usage_delta_one = { - "llm": { - "OpenAILLMService#0|||gpt-4.1-mini": { - "prompt_tokens": 1_000, - "completion_tokens": 100, - "total_tokens": 1_100, - "cache_read_input_tokens": 0, - "cache_creation_input_tokens": 0, - } - }, - "tts": {}, - "stt": {}, - "call_duration_seconds": 3, - } - usage_delta_two = { - "llm": { - "OpenAILLMService#0|||gpt-4.1-mini": { - "prompt_tokens": 2_000, - "completion_tokens": 50, - "total_tokens": 2_050, - "cache_read_input_tokens": 0, - "cache_creation_input_tokens": 0, - } - }, - "tts": {}, - "stt": {}, - "call_duration_seconds": 4, - } - merged_usage = { - "llm": { - "OpenAILLMService#0|||gpt-4.1-mini": { - "prompt_tokens": 3_000, - "completion_tokens": 150, - "total_tokens": 3_150, - "cache_read_input_tokens": 0, - "cache_creation_input_tokens": 0, - } - }, - "tts": {}, - "stt": {}, - "call_duration_seconds": 7, - } - - get_org = AsyncMock(return_value=SimpleNamespace(id=42, price_per_second_usd=1.5)) - update_usage = AsyncMock() - - monkeypatch.setattr( - workflow_run_cost_mod.db_client, "get_organization_by_id", get_org - ) - monkeypatch.setattr( - workflow_run_cost_mod.db_client, "update_usage_after_run", update_usage - ) - - first_delta = await apply_usage_delta_to_organization(workflow_run, usage_delta_one) - second_delta = await apply_usage_delta_to_organization( - workflow_run, usage_delta_two - ) - total_workflow_run = SimpleNamespace(**workflow_run.__dict__) - total_workflow_run.usage_info = merged_usage - total_cost = await build_workflow_run_cost_info(total_workflow_run) - - assert first_delta is not None - assert second_delta is not None - assert total_cost is not None - assert update_usage.await_count == 2 - assert update_usage.await_args_list[0].args == ( - 42, - first_delta["dograh_token_usage"], - 3.0, - first_delta["charge_usd"], - ) - assert update_usage.await_args_list[1].args == ( - 42, - second_delta["dograh_token_usage"], - 4.0, - second_delta["charge_usd"], - ) - assert ( - first_delta["dograh_token_usage"] + second_delta["dograh_token_usage"] - ) == pytest.approx(total_cost["dograh_token_usage"]) - assert ( - first_delta["charge_usd"] + second_delta["charge_usd"] - == total_cost["charge_usd"] - ) diff --git a/api/tests/test_workflow_text_chat.py b/api/tests/test_workflow_text_chat.py index 1b830bf8..40afdcfb 100644 --- a/api/tests/test_workflow_text_chat.py +++ b/api/tests/test_workflow_text_chat.py @@ -4,7 +4,7 @@ from unittest.mock import AsyncMock, patch import pytest from api.db.models import OrganizationModel, UserModel -from api.schemas.user_configuration import UserConfiguration +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration from api.tests.integrations._run_pipeline_helpers import USER_CONFIGURATION from pipecat.tests import MockLLMService @@ -38,7 +38,7 @@ async def _create_user_and_workflow( await db_session.update_user_configuration( user_id=user.id, - configuration=UserConfiguration.model_validate(USER_CONFIGURATION), + configuration=EffectiveAIModelConfiguration.model_validate(USER_CONFIGURATION), ) workflow = await db_session.create_workflow( @@ -51,6 +51,38 @@ async def _create_user_and_workflow( return user, workflow +@pytest.mark.asyncio +async def test_text_chat_session_creation_requires_selected_organization(): + from httpx import ASGITransport, AsyncClient + + from api.app import app + from api.services.auth.depends import get_user + + user = UserModel(provider_id="textchat-user-no-selected-org") + + async def mock_get_user(): + return user + + original_override = app.dependency_overrides.get(get_user) + app.dependency_overrides[get_user] = mock_get_user + + try: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + response = await client.post( + "/api/v1/workflow/123/text-chat/sessions", json={} + ) + finally: + if original_override: + app.dependency_overrides[get_user] = original_override + else: + app.dependency_overrides.pop(get_user, None) + + assert response.status_code == 400 + assert response.json() == {"detail": "No organization selected"} + + @pytest.mark.asyncio async def test_text_chat_session_creation_executes_initial_assistant_turn( db_session, @@ -144,11 +176,7 @@ async def test_text_chat_session_creation_executes_initial_assistant_turn( assert "Start" in (created["gathered_context"] or {}).get("nodes_visited", []) workflow_run = await db_session.get_workflow_run_by_id(created["workflow_run_id"]) assert workflow_run is not None - assert workflow_run.cost_info[ - "call_duration_seconds" - ] == workflow_run.usage_info.get("call_duration_seconds", 0) - assert "cost_breakdown" in workflow_run.cost_info - assert "dograh_token_usage" in workflow_run.cost_info + assert "call_duration_seconds" in workflow_run.usage_info assert _log_texts(run_payload["logs"], "rtf-bot-text") == [ "Hello from the workflow tester." ] @@ -264,11 +292,7 @@ async def test_text_chat_message_executes_assistant_turn( assert "Start" in (payload["gathered_context"] or {}).get("nodes_visited", []) workflow_run = await db_session.get_workflow_run_by_id(created["workflow_run_id"]) assert workflow_run is not None - assert workflow_run.cost_info[ - "call_duration_seconds" - ] == workflow_run.usage_info.get("call_duration_seconds", 0) - assert "cost_breakdown" in workflow_run.cost_info - assert "dograh_token_usage" in workflow_run.cost_info + assert "call_duration_seconds" in workflow_run.usage_info assert _log_texts(run_payload["logs"], "rtf-user-transcription") == ["Hi there"] assert _log_texts(run_payload["logs"], "rtf-bot-text") == [ "Welcome to the workflow tester.", @@ -1009,7 +1033,7 @@ async def test_text_chat_session_creation_requires_selected_org_scope( await db_session.update_user_configuration( user_id=user.id, - configuration=UserConfiguration.model_validate(USER_CONFIGURATION), + configuration=EffectiveAIModelConfiguration.model_validate(USER_CONFIGURATION), ) workflow = await db_session.create_workflow( @@ -1081,7 +1105,7 @@ async def test_text_chat_session_creation_rejects_quota_before_creating_run( async with test_client_factory(user) as client: with patch( - "api.routes.workflow_text_chat.check_dograh_quota", + "api.routes.workflow_text_chat.authorize_workflow_run_start", new=AsyncMock( return_value=SimpleNamespace( has_quota=False, @@ -1096,11 +1120,16 @@ async def test_text_chat_session_creation_rejects_quota_before_creating_run( assert create_response.status_code == 402 assert create_response.json()["detail"] == "Quota exceeded" - _, total_count = await db_session.get_workflow_runs_by_workflow_id( + runs, total_count = await db_session.get_workflow_runs_by_workflow_id( workflow.id, organization_id=workflow.organization_id, ) - assert total_count == 0 + assert total_count == 1 + text_session = await db_session.get_workflow_run_text_session( + runs[0].id, + organization_id=workflow.organization_id, + ) + assert text_session is None @pytest.mark.asyncio @@ -1144,7 +1173,7 @@ async def test_text_chat_append_rejects_quota_without_mutating_session( async with test_client_factory(user) as client: with ( patch( - "api.routes.workflow_text_chat.check_dograh_quota", + "api.routes.workflow_text_chat.authorize_workflow_run_start", new=AsyncMock( side_effect=[ SimpleNamespace(has_quota=True, error_message=""), diff --git a/docker-compose.yaml b/docker-compose.yaml index d440aa10..0bd27178 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,7 +3,11 @@ services: image: pgvector/pgvector:pg17 environment: POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres + # Sourced from .env. Defaults to "postgres" + # NOTE: changing this on an existing install does NOT + # re-key the database — the password is baked into the volume on first init. + # You can manually change the password using psql in the container + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-postgres}" POSTGRES_DB: postgres logging: driver: "json-file" @@ -136,7 +140,7 @@ services: BACKEND_API_ENDPOINT: "${BACKEND_API_ENDPOINT:-http://localhost:8000}" # Database configuration (using containerized postgres) - DATABASE_URL: "postgresql+asyncpg://postgres:postgres@postgres:5432/postgres" + DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres" # Redis configuration (using containerized redis) REDIS_URL: "redis://:redissecret@redis:6379" diff --git a/docs/api-reference/openapi.json b/docs/api-reference/openapi.json index 1eb198e3..dfc4587b 100644 --- a/docs/api-reference/openapi.json +++ b/docs/api-reference/openapi.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"Dograh API","description":"API for the Dograh app","version":"1.0.0"},"servers":[{"url":"https://app.dograh.com","description":"Production"},{"url":"http://localhost:8000","description":"Local development"}],"paths":{"/api/v1/telephony/initiate-call":{"post":{"tags":["main"],"summary":"Initiate Call","description":"Initiate a call using the configured telephony provider from web browser. This is\nsupposed to be a test call method for the draft version of the agent.","operationId":"initiate_call_api_v1_telephony_initiate_call_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InitiateCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"test_phone_call","x-sdk-description":"Place a test call from a workflow to a phone number."}},"/api/v1/telephony/inbound/run":{"post":{"tags":["main"],"summary":"Handle Inbound Run","description":"Workflow-agnostic inbound dispatcher.\n\nAll providers can point a single webhook at this endpoint instead of one\nURL per workflow. The dispatcher resolves the org from the webhook's\naccount_id and the workflow from the called number's\n``inbound_workflow_id``. This is what ``configure_inbound`` writes into\neach provider's resource so per-workflow webhook bookkeeping disappears.\n\nProvider-specific signature/timestamp headers are not enumerated here \u2014\neach provider's ``verify_inbound_signature`` reads its own headers from\nthe dict, so adding a new provider doesn't require changes to this route.","operationId":"handle_inbound_run_api_v1_telephony_inbound_run_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"}}}},"/api/v1/telephony/inbound/fallback":{"post":{"tags":["main"],"summary":"Handle Inbound Fallback","description":"Fallback endpoint that returns audio message when calls cannot be processed.","operationId":"handle_inbound_fallback_api_v1_telephony_inbound_fallback_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"}}}},"/api/v1/telephony/inbound/{workflow_id}":{"post":{"tags":["main"],"summary":"Handle Inbound Telephony","description":"[LEGACY] Per-workflow inbound webhook.\n\nSuperseded by ``POST /inbound/run``, which resolves the workflow from\nthe called number's ``inbound_workflow_id`` and lets a single webhook\nURL serve every workflow in the org. New integrations should point\ntheir provider at ``/inbound/run``; this route is kept only for\nexisting provider configurations that still encode ``workflow_id``\nin the URL.","operationId":"handle_inbound_telephony_api_v1_telephony_inbound__workflow_id__post","deprecated":true,"parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/transfer-result/{transfer_id}":{"post":{"tags":["main"],"summary":"Complete Transfer Function Call","description":"Webhook endpoint to complete the function call with transfer result.\n\nCalled by Twilio's StatusCallback when the transfer call status changes.","operationId":"complete_transfer_function_call_api_v1_telephony_transfer_result__transfer_id__post","parameters":[{"name":"transfer_id","in":"path","required":true,"schema":{"type":"string","title":"Transfer Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/cloudonix/status-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Cloudonix Status Callback","description":"Handle Cloudonix-specific status callbacks.\n\nCloudonix sends call status updates to the callback URL specified during call initiation.","operationId":"handle_cloudonix_status_callback_api_v1_telephony_cloudonix_status_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/cloudonix/cdr":{"post":{"tags":["main"],"summary":"Handle Cloudonix Cdr","description":"Handle Cloudonix CDR (Call Detail Record) webhooks.\n\nCloudonix sends CDR records when calls complete. The CDR contains:\n- domain: Used to identify the organization\n- call_id: Used to find the workflow run\n- disposition: Call termination status (ANSWER, BUSY, CANCEL, FAILED, CONGESTION, NOANSWER)\n- duration/billsec: Call duration information","operationId":"handle_cloudonix_cdr_api_v1_telephony_cloudonix_cdr_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"}}}},"/api/v1/telephony/plivo/hangup-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Plivo Hangup Callback","description":"Handle Plivo hangup callbacks.","operationId":"handle_plivo_hangup_callback_api_v1_telephony_plivo_hangup_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/plivo/ring-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Plivo Ring Callback","description":"Handle Plivo ring callbacks.","operationId":"handle_plivo_ring_callback_api_v1_telephony_plivo_ring_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/telnyx/events/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Telnyx Events","description":"Handle Telnyx Call Control webhook events.\n\nTelnyx sends all call lifecycle events (call.initiated, call.answered,\ncall.hangup, streaming.started, streaming.stopped) as JSON POST requests.","operationId":"handle_telnyx_events_api_v1_telephony_telnyx_events__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/telnyx/transfer-result/{transfer_id}":{"post":{"tags":["main"],"summary":"Handle Telnyx Transfer Result","description":"Handle Telnyx Call Control events for the transfer destination leg.\n\nThe destination leg is dialed by :meth:`TelnyxProvider.transfer_call` with\nthis URL as ``webhook_url``. Telnyx sends every event for that leg here.\nOutcomes:\n\n- ``call.answered``: seed a conference with the destination's live\n ``call_control_id``, stamp ``conference_id`` onto the TransferContext,\n and publish ``DESTINATION_ANSWERED`` so ``transfer_call_handler`` can\n end the pipeline. ``TelnyxConferenceStrategy`` then joins the caller\n into this conference at pipeline teardown.\n- ``call.hangup`` pre-answer (no ``conference_id`` on the context):\n publish ``TRANSFER_FAILED`` so the LLM can recover.\n- ``call.hangup`` post-answer (``conference_id`` set): the destination\n left a bridged conference; hang up the caller's leg to tear down the\n empty bridge (Telnyx's create_conference doesn't accept\n ``end_conference_on_exit`` on the seed leg).\n\nEvent references:\n - call.answered: https://developers.telnyx.com/api-reference/callbacks/call-answered\n - call.hangup: https://developers.telnyx.com/api-reference/callbacks/call-hangup","operationId":"handle_telnyx_transfer_result_api_v1_telephony_telnyx_transfer_result__transfer_id__post","parameters":[{"name":"transfer_id","in":"path","required":true,"schema":{"type":"string","title":"Transfer Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/twilio/status-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Twilio Status Callback","description":"Handle Twilio-specific status callbacks.","operationId":"handle_twilio_status_callback_api_v1_telephony_twilio_status_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/vobiz/hangup-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Vobiz Hangup Callback","description":"Handle Vobiz hangup callback (sent when call ends).\n\nVobiz sends callbacks to hangup_url when the call terminates.\nThis includes call duration, status, and billing information.","operationId":"handle_vobiz_hangup_callback_api_v1_telephony_vobiz_hangup_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}},{"name":"x-vobiz-signature","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Signature"}},{"name":"x-vobiz-timestamp","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Timestamp"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/vobiz/ring-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Vobiz Ring Callback","description":"Handle Vobiz ring callback (sent when call starts ringing).\n\nVobiz can send callbacks to ring_url when the call starts ringing.\nThis is optional and used for tracking ringing status.","operationId":"handle_vobiz_ring_callback_api_v1_telephony_vobiz_ring_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}},{"name":"x-vobiz-signature","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Signature"}},{"name":"x-vobiz-timestamp","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Timestamp"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}":{"post":{"tags":["main"],"summary":"Handle Vobiz Hangup Callback By Workflow","description":"Handle Vobiz hangup callback with workflow_id - finds workflow run by call_id.","operationId":"handle_vobiz_hangup_callback_by_workflow_api_v1_telephony_vobiz_hangup_callback_workflow__workflow_id__post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"x-vobiz-signature","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Signature"}},{"name":"x-vobiz-timestamp","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Vobiz-Timestamp"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/vonage/events/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Vonage Events","description":"Handle Vonage-specific event webhooks.\n\nVonage sends all call events to a single endpoint.\nEvents include: started, ringing, answered, complete, failed, etc.","operationId":"handle_vonage_events_api_v1_telephony_vonage_events__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/superuser/impersonate":{"post":{"tags":["main","superuser"],"summary":"Impersonate","description":"Impersonate a user as a super-admin.\nInternally, Stack Auth requires the **provider user ID** (a UUID-ish string)\nto create an impersonation session.","operationId":"impersonate_api_v1_superuser_impersonate_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImpersonateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImpersonateResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/superuser/workflow-runs":{"get":{"tags":["main","superuser"],"summary":"Get Workflow Runs","description":"Get paginated list of all workflow runs with organization information.\nRequires superuser privileges.\n\nFilters should be provided as a JSON-encoded array of filter criteria.\nExample: [{\"field\": \"id\", \"type\": \"number\", \"value\": {\"value\": 680}}]","operationId":"get_workflow_runs_api_v1_superuser_workflow_runs_get","parameters":[{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Page number (starts from 1)","default":1,"title":"Page"},"description":"Page number (starts from 1)"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Number of items per page","default":50,"title":"Limit"},"description":"Number of items per page"},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded filter criteria","title":"Filters"},"description":"JSON-encoded filter criteria"},{"name":"sort_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Field to sort by (e.g., 'duration', 'created_at')","title":"Sort By"},"description":"Field to sort by (e.g., 'duration', 'created_at')"},{"name":"sort_order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Sort order ('asc' or 'desc')","default":"desc","title":"Sort Order"},"description":"Sort order ('asc' or 'desc')"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuperuserWorkflowRunsListResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/validate":{"post":{"tags":["main"],"summary":"Validate Workflow","description":"Validate all nodes in a workflow to ensure they have required fields.\n\nArgs:\n workflow_id: The ID of the workflow to validate\n user: The authenticated user\n\nReturns:\n Object indicating if workflow is valid and any invalid nodes/edges","operationId":"validate_workflow_api_v1_workflow__workflow_id__validate_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateWorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/create/definition":{"post":{"tags":["main"],"summary":"Create Workflow","description":"Create a new workflow from the client\n\nArgs:\n request: The create workflow request\n user: The user to create the workflow for","operationId":"create_workflow_api_v1_workflow_create_definition_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkflowRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"create_workflow","x-sdk-description":"Create a new workflow from a workflow definition."}},"/api/v1/workflow/create/template":{"post":{"tags":["main"],"summary":"Create Workflow From Template","description":"Create a new workflow from a natural language template request.\n\nThis endpoint:\n1. Uses mps_service_key_client to call MPS workflow API\n2. Passes organization ID (authenticated mode) or created_by (OSS mode)\n3. Creates the workflow in the database\n\nArgs:\n request: The template creation request with call_type, use_case, and activity_description\n user: The authenticated user\n\nReturns:\n The created workflow\n\nRaises:\n HTTPException: If MPS API call fails","operationId":"create_workflow_from_template_api_v1_workflow_create_template_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkflowTemplateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/count":{"get":{"tags":["main"],"summary":"Get Workflow Count","description":"Get workflow counts for the authenticated user's organization.\n\nThis is a lightweight endpoint for checking if the user has workflows,\nuseful for redirect logic without fetching full workflow data.","operationId":"get_workflow_count_api_v1_workflow_count_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCountResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/fetch":{"get":{"tags":["main"],"summary":"Get Workflows","description":"Get all workflows for the authenticated user's organization.\n\nReturns a lightweight response with only essential fields for listing.\nUse GET /workflow/fetch/{workflow_id} to get full workflow details.","operationId":"get_workflows_api_v1_workflow_fetch_get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by status - can be single value (active/archived) or comma-separated (active,archived)","title":"Status"},"description":"Filter by status - can be single value (active/archived) or comma-separated (active,archived)"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowListResponse"},"title":"Response Get Workflows Api V1 Workflow Fetch Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_workflows","x-sdk-description":"List all workflows in the authenticated organization."}},"/api/v1/workflow/fetch/{workflow_id}":{"get":{"tags":["main"],"summary":"Get Workflow","description":"Get a single workflow by ID.\n\nIf a draft version exists, returns the draft content for editing.\nOtherwise returns the published version's content.","operationId":"get_workflow_api_v1_workflow_fetch__workflow_id__get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"get_workflow","x-sdk-description":"Get a single workflow by ID (returns draft if one exists, else published)."}},"/api/v1/workflow/{workflow_id}/versions":{"get":{"tags":["main"],"summary":"Get Workflow Versions","description":"List versions for a workflow, newest first.\n\nPass `limit`/`offset` to page through long histories. With no `limit`,\nreturns every version (legacy behavior).","operationId":"get_workflow_versions_api_v1_workflow__workflow_id__versions_get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":100,"minimum":1},{"type":"null"}],"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowVersionResponse"},"title":"Response Get Workflow Versions Api V1 Workflow Workflow Id Versions Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/publish":{"post":{"tags":["main"],"summary":"Publish Workflow","description":"Publish the current draft version of a workflow.\n\nDrafts are allowed to be incomplete (so the editor can save mid-edit),\nbut a published version is what runtime executes \u2014 so this is the gate\nwhere the full DTO + graph + trigger-conflict checks must pass.","operationId":"publish_workflow_api_v1_workflow__workflow_id__publish_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/create-draft":{"post":{"tags":["main"],"summary":"Create Workflow Draft","description":"Create a draft version from the current published version.\n\nIf a draft already exists, returns the existing draft.","operationId":"create_workflow_draft_api_v1_workflow__workflow_id__create_draft_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVersionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/summary":{"get":{"tags":["main"],"summary":"Get Workflows Summary","description":"Get minimal workflow information (id and name only) for all workflows","operationId":"get_workflows_summary_api_v1_workflow_summary_get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by status (e.g. 'active' or 'archived'). Omit to return all.","title":"Status"},"description":"Filter by status (e.g. 'active' or 'archived'). Omit to return all."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowSummaryResponse"},"title":"Response Get Workflows Summary Api V1 Workflow Summary Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/status":{"put":{"tags":["main"],"summary":"Update Workflow Status","description":"Update the status of a workflow (e.g., archive/unarchive).\n\nArgs:\n workflow_id: The ID of the workflow to update\n request: The status update request\n\nReturns:\n The updated workflow","operationId":"update_workflow_status_api_v1_workflow__workflow_id__status_put","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkflowStatusRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/folder":{"put":{"tags":["main"],"summary":"Move Workflow To Folder","description":"Move a workflow into a folder, or to \"Uncategorized\" (folder_id=null).\n\nValidates that the target folder belongs to the caller's organization \u2014\nthe FK alone proves the folder exists, not that the caller may use it.","operationId":"move_workflow_to_folder_api_v1_workflow__workflow_id__folder_put","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MoveWorkflowToFolderRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowListResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}":{"put":{"tags":["main"],"summary":"Update Workflow","description":"Update an existing workflow.\n\nArgs:\n workflow_id: The ID of the workflow to update\n request: The update request containing the new name and workflow definition\n\nReturns:\n The updated workflow\n\nRaises:\n HTTPException: If the workflow is not found or if there's a database error","operationId":"update_workflow_api_v1_workflow__workflow_id__put","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkflowRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"update_workflow","x-sdk-description":"Update a workflow's name and/or definition. Saves as a new draft."}},"/api/v1/workflow/{workflow_id}/duplicate":{"post":{"tags":["main"],"summary":"Duplicate Workflow Endpoint","description":"Duplicate a workflow including its definition, configuration, recordings, and triggers.","operationId":"duplicate_workflow_endpoint_api_v1_workflow__workflow_id__duplicate_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/runs":{"post":{"tags":["main"],"summary":"Create Workflow Run","description":"Create a new workflow run when the user decides to execute the workflow via chat or voice\n\nArgs:\n workflow_id: The ID of the workflow to run\n request: The create workflow run request\n user: The user to create the workflow run for","operationId":"create_workflow_run_api_v1_workflow__workflow_id__runs_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkflowRunRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkflowRunResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["main"],"summary":"Get Workflow Runs","description":"Get workflow runs with optional filtering and sorting.\n\nFilters should be provided as a JSON-encoded array of filter criteria.\nExample: [{\"attribute\": \"dateRange\", \"value\": {\"from\": \"2024-01-01\", \"to\": \"2024-01-31\"}}]","operationId":"get_workflow_runs_api_v1_workflow__workflow_id__runs_get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","default":1,"title":"Page"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":50,"title":"Limit"}},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded filter criteria","title":"Filters"},"description":"JSON-encoded filter criteria"},{"name":"sort_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Field to sort by (e.g., 'duration', 'created_at')","title":"Sort By"},"description":"Field to sort by (e.g., 'duration', 'created_at')"},{"name":"sort_order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Sort order ('asc' or 'desc')","default":"desc","title":"Sort Order"},"description":"Sort order ('asc' or 'desc')"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/runs/{run_id}":{"get":{"tags":["main"],"summary":"Get Workflow Run","operationId":"get_workflow_run_api_v1_workflow__workflow_id__runs__run_id__get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"run_id","in":"path","required":true,"schema":{"type":"integer","title":"Run Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/report":{"get":{"tags":["main"],"summary":"Download Workflow Report","description":"Download a CSV report of completed runs for a workflow.","operationId":"download_workflow_report_api_v1_workflow__workflow_id__report_get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter runs created on or after this datetime (ISO 8601)","title":"Start Date"},"description":"Filter runs created on or after this datetime (ISO 8601)"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter runs created on or before this datetime (ISO 8601)","title":"End Date"},"description":"Filter runs created on or before this datetime (ISO 8601)"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/templates":{"get":{"tags":["main"],"summary":"Get Workflow Templates","description":"Get all available workflow templates.\n\nReturns:\n List of workflow templates","operationId":"get_workflow_templates_api_v1_workflow_templates_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/WorkflowTemplateResponse"},"type":"array","title":"Response Get Workflow Templates Api V1 Workflow Templates Get"}}}},"404":{"description":"Not found"}}}},"/api/v1/workflow/templates/duplicate":{"post":{"tags":["main"],"summary":"Duplicate Workflow Template","description":"Duplicate a workflow template to create a new workflow for the user.\n\nArgs:\n request: The duplicate template request\n user: The authenticated user\n\nReturns:\n The newly created workflow","operationId":"duplicate_workflow_template_api_v1_workflow_templates_duplicate_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateTemplateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/ambient-noise/upload-url":{"post":{"tags":["main"],"summary":"Get a presigned URL to upload a custom ambient noise audio file","description":"Generate a presigned PUT URL for uploading a custom ambient noise file.","operationId":"get_ambient_noise_upload_url_api_v1_workflow_ambient_noise_upload_url_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AmbientNoiseUploadRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AmbientNoiseUploadResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/text-chat/sessions":{"post":{"tags":["main","workflow-text-chat"],"summary":"Create Text Chat Session","operationId":"create_text_chat_session_api_v1_workflow__workflow_id__text_chat_sessions_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTextChatSessionRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunTextSessionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/text-chat/sessions/{run_id}":{"get":{"tags":["main","workflow-text-chat"],"summary":"Get Text Chat Session","operationId":"get_text_chat_session_api_v1_workflow__workflow_id__text_chat_sessions__run_id__get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"run_id","in":"path","required":true,"schema":{"type":"integer","title":"Run Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunTextSessionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/text-chat/sessions/{run_id}/messages":{"post":{"tags":["main","workflow-text-chat"],"summary":"Append Text Chat Message","operationId":"append_text_chat_message_api_v1_workflow__workflow_id__text_chat_sessions__run_id__messages_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"run_id","in":"path","required":true,"schema":{"type":"integer","title":"Run Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppendTextChatMessageRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunTextSessionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/text-chat/sessions/{run_id}/rewind":{"post":{"tags":["main","workflow-text-chat"],"summary":"Rewind Text Chat Session","operationId":"rewind_text_chat_session_api_v1_workflow__workflow_id__text_chat_sessions__run_id__rewind_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"run_id","in":"path","required":true,"schema":{"type":"integer","title":"Run Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RewindTextChatSessionRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunTextSessionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/configurations/defaults":{"get":{"tags":["main"],"summary":"Get Default Configurations","operationId":"get_default_configurations_api_v1_user_configurations_defaults_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DefaultConfigurationsResponse"}}}},"404":{"description":"Not found"}}}},"/api/v1/user/auth/user":{"get":{"tags":["main"],"summary":"Get Auth User","operationId":"get_auth_user_api_v1_user_auth_user_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthUserResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/configurations/user":{"get":{"tags":["main"],"summary":"Get User Configurations","operationId":"get_user_configurations_api_v1_user_configurations_user_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConfigurationRequestResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main"],"summary":"Update User Configurations","operationId":"update_user_configurations_api_v1_user_configurations_user_put","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConfigurationRequestResponseSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConfigurationRequestResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/configurations/user/validate":{"get":{"tags":["main"],"summary":"Validate User Configurations","operationId":"validate_user_configurations_api_v1_user_configurations_user_validate_get","parameters":[{"name":"validity_ttl_seconds","in":"query","required":false,"schema":{"type":"integer","maximum":86400,"minimum":0,"default":60,"title":"Validity Ttl Seconds"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIKeyStatusResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/api-keys":{"get":{"tags":["main"],"summary":"Get Api Keys","description":"Get all API keys for the user's selected organization.","operationId":"get_api_keys_api_v1_user_api_keys_get","parameters":[{"name":"include_archived","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Archived"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/APIKeyResponse"},"title":"Response Get Api Keys Api V1 User Api Keys Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main"],"summary":"Create Api Key","description":"Create a new API key for the user's selected organization.","operationId":"create_api_key_api_v1_user_api_keys_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAPIKeyRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAPIKeyResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/api-keys/{api_key_id}":{"delete":{"tags":["main"],"summary":"Archive Api Key","description":"Archive an API key (soft delete).","operationId":"archive_api_key_api_v1_user_api_keys__api_key_id__delete","parameters":[{"name":"api_key_id","in":"path","required":true,"schema":{"type":"integer","title":"Api Key Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Archive Api Key Api V1 User Api Keys Api Key Id Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/api-keys/{api_key_id}/reactivate":{"put":{"tags":["main"],"summary":"Reactivate Api Key","description":"Reactivate an archived API key.","operationId":"reactivate_api_key_api_v1_user_api_keys__api_key_id__reactivate_put","parameters":[{"name":"api_key_id","in":"path","required":true,"schema":{"type":"integer","title":"Api Key Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Reactivate Api Key Api V1 User Api Keys Api Key Id Reactivate Put"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/configurations/voices/{provider}":{"get":{"tags":["main"],"summary":"Get Voices","description":"Get available voices for a TTS provider.","operationId":"get_voices_api_v1_user_configurations_voices__provider__get","parameters":[{"name":"provider","in":"path","required":true,"schema":{"enum":["elevenlabs","deepgram","sarvam","cartesia","dograh","rime"],"type":"string","title":"Provider"}},{"name":"model","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model"}},{"name":"language","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VoicesResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/create":{"post":{"tags":["main"],"summary":"Create Campaign","description":"Create a new campaign","operationId":"create_campaign_api_v1_campaign_create_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCampaignRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/":{"get":{"tags":["main"],"summary":"Get Campaigns","description":"Get campaigns for user's organization","operationId":"get_campaigns_api_v1_campaign__get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}":{"get":{"tags":["main"],"summary":"Get Campaign","description":"Get campaign details","operationId":"get_campaign_api_v1_campaign__campaign_id__get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["main"],"summary":"Update Campaign","description":"Update campaign settings (name, retry config, max concurrency, schedule)","operationId":"update_campaign_api_v1_campaign__campaign_id__patch","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCampaignRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/start":{"post":{"tags":["main"],"summary":"Start Campaign","description":"Start campaign execution","operationId":"start_campaign_api_v1_campaign__campaign_id__start_post","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/pause":{"post":{"tags":["main"],"summary":"Pause Campaign","description":"Pause campaign execution","operationId":"pause_campaign_api_v1_campaign__campaign_id__pause_post","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/runs":{"get":{"tags":["main"],"summary":"Get Campaign Runs","description":"Get campaign workflow runs with pagination, filters and sorting","operationId":"get_campaign_runs_api_v1_campaign__campaign_id__runs_get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","default":1,"title":"Page"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":50,"title":"Limit"}},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded filter criteria","title":"Filters"},"description":"JSON-encoded filter criteria"},{"name":"sort_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Field to sort by (e.g., 'duration', 'created_at')","title":"Sort By"},"description":"Field to sort by (e.g., 'duration', 'created_at')"},{"name":"sort_order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Sort order ('asc' or 'desc')","default":"desc","title":"Sort Order"},"description":"Sort order ('asc' or 'desc')"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignRunsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/redial":{"post":{"tags":["main"],"summary":"Redial Campaign","description":"Create a new campaign that re-dials unique subscribers from a completed\ncampaign whose latest call resulted in voicemail, no-answer, or busy.\n\nThe new campaign is created in 'created' state with queued_runs pre-seeded\nfrom the parent's original initial contexts. A campaign can be redialed at\nmost once.","operationId":"redial_campaign_api_v1_campaign__campaign_id__redial_post","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RedialCampaignRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/resume":{"post":{"tags":["main"],"summary":"Resume Campaign","description":"Resume a paused campaign","operationId":"resume_campaign_api_v1_campaign__campaign_id__resume_post","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/progress":{"get":{"tags":["main"],"summary":"Get Campaign Progress","description":"Get current campaign progress and statistics","operationId":"get_campaign_progress_api_v1_campaign__campaign_id__progress_get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignProgressResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/source-download-url":{"get":{"tags":["main"],"summary":"Get Campaign Source Download Url","description":"Get presigned download URL for campaign CSV source file\nValidates that the campaign belongs to the user's organization for security.","operationId":"get_campaign_source_download_url_api_v1_campaign__campaign_id__source_download_url_get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignSourceDownloadResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/report":{"get":{"tags":["main"],"summary":"Download Campaign Report","description":"Download a CSV report of completed campaign runs.","operationId":"download_campaign_report_api_v1_campaign__campaign_id__report_get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter runs created on or after this datetime (ISO 8601)","title":"Start Date"},"description":"Filter runs created on or after this datetime (ISO 8601)"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter runs created on or before this datetime (ISO 8601)","title":"End Date"},"description":"Filter runs created on or before this datetime (ISO 8601)"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/credentials/":{"get":{"tags":["main"],"summary":"List Credentials","description":"List all webhook credentials for the user's organization.\n\nReturns:\n List of credentials (without sensitive data)","operationId":"list_credentials_api_v1_credentials__get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CredentialResponse"},"title":"Response List Credentials Api V1 Credentials Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_credentials","x-sdk-description":"List webhook credentials available to the authenticated organization."},"post":{"tags":["main"],"summary":"Create Credential","description":"Create a new webhook credential.\n\nArgs:\n request: The credential creation request\n\nReturns:\n The created credential (without sensitive data)","operationId":"create_credential_api_v1_credentials__post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCredentialRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CredentialResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/credentials/{credential_uuid}":{"get":{"tags":["main"],"summary":"Get Credential","description":"Get a specific webhook credential by UUID.\n\nArgs:\n credential_uuid: The UUID of the credential\n\nReturns:\n The credential (without sensitive data)","operationId":"get_credential_api_v1_credentials__credential_uuid__get","parameters":[{"name":"credential_uuid","in":"path","required":true,"schema":{"type":"string","title":"Credential Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CredentialResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main"],"summary":"Update Credential","description":"Update a webhook credential.\n\nArgs:\n credential_uuid: The UUID of the credential to update\n request: The update request\n\nReturns:\n The updated credential (without sensitive data)","operationId":"update_credential_api_v1_credentials__credential_uuid__put","parameters":[{"name":"credential_uuid","in":"path","required":true,"schema":{"type":"string","title":"Credential Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCredentialRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CredentialResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main"],"summary":"Delete Credential","description":"Delete (soft delete) a webhook credential.\n\nArgs:\n credential_uuid: The UUID of the credential to delete\n\nReturns:\n Success message","operationId":"delete_credential_api_v1_credentials__credential_uuid__delete","parameters":[{"name":"credential_uuid","in":"path","required":true,"schema":{"type":"string","title":"Credential Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Credential Api V1 Credentials Credential Uuid Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/tools/":{"get":{"tags":["main"],"summary":"List Tools","description":"List all tools for the user's organization.\n\nArgs:\n status: Optional filter by status (active, archived, draft)\n category: Optional filter by category (http_api, native, integration)\n\nReturns:\n List of tools","operationId":"list_tools_api_v1_tools__get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status"}},{"name":"category","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Category"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ToolResponse"},"title":"Response List Tools Api V1 Tools Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_tools","x-sdk-description":"List tools available to the authenticated organization."},"post":{"tags":["main"],"summary":"Create Tool","description":"Create a new tool.\n\nArgs:\n request: The tool creation request\n\nReturns:\n The created tool","operationId":"create_tool_api_v1_tools__post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateToolRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"create_tool","x-sdk-description":"Create a reusable tool for the authenticated organization."}},"/api/v1/tools/{tool_uuid}":{"get":{"tags":["main"],"summary":"Get Tool","description":"Get a specific tool by UUID.\n\nArgs:\n tool_uuid: The UUID of the tool\n\nReturns:\n The tool","operationId":"get_tool_api_v1_tools__tool_uuid__get","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main"],"summary":"Update Tool","description":"Update a tool.\n\nArgs:\n tool_uuid: The UUID of the tool to update\n request: The update request\n\nReturns:\n The updated tool","operationId":"update_tool_api_v1_tools__tool_uuid__put","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateToolRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main"],"summary":"Delete Tool","description":"Archive (soft delete) a tool.\n\nArgs:\n tool_uuid: The UUID of the tool to delete\n\nReturns:\n Success message","operationId":"delete_tool_api_v1_tools__tool_uuid__delete","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Tool Api V1 Tools Tool Uuid Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/tools/{tool_uuid}/mcp/refresh":{"post":{"tags":["main"],"summary":"Refresh Mcp Tools","description":"Re-discover an MCP tool's server catalog and overwrite the cached\n``definition.config.discovered_tools``. Server down \u2192 200 with error\n(cache not overwritten on transient failure).","operationId":"refresh_mcp_tools_api_v1_tools__tool_uuid__mcp_refresh_post","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpRefreshResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/tools/{tool_uuid}/unarchive":{"post":{"tags":["main"],"summary":"Unarchive Tool","description":"Unarchive a tool (restore from archived state).\n\nArgs:\n tool_uuid: The UUID of the tool to unarchive\n\nReturns:\n The unarchived tool","operationId":"unarchive_tool_api_v1_tools__tool_uuid__unarchive_post","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-providers/metadata":{"get":{"tags":["main","organizations"],"summary":"Get Telephony Providers Metadata","description":"Return the list of available telephony providers and their form schemas.\n\nThe UI uses this to render the configuration form generically instead of\nhard-coding fields per provider. Adding a new provider only requires\ndeclaring its ui_metadata in providers//__init__.py.","operationId":"get_telephony_providers_metadata_api_v1_organizations_telephony_providers_metadata_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyProvidersMetadataResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-config-warnings":{"get":{"tags":["main","organizations"],"summary":"Get Telephony Config Warnings","description":"Return aggregated warning counts for the current org's telephony configs.\n\nToday this surfaces only Telnyx configs missing ``webhook_public_key``;\nadditional warning types should be added as new fields on the response.","operationId":"get_telephony_config_warnings_api_v1_organizations_telephony_config_warnings_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigWarningsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs":{"get":{"tags":["main","organizations"],"summary":"List Telephony Configurations","description":"List the org's telephony configurations with phone-number counts.","operationId":"list_telephony_configurations_api_v1_organizations_telephony_configs_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationListResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main","organizations"],"summary":"Create Telephony Configuration","description":"Create a new telephony configuration for the org.","operationId":"create_telephony_configuration_api_v1_organizations_telephony_configs_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationDetail"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}":{"get":{"tags":["main","organizations"],"summary":"Get Telephony Configuration By Id","operationId":"get_telephony_configuration_by_id_api_v1_organizations_telephony_configs__config_id__get","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationDetail"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main","organizations"],"summary":"Update Telephony Configuration","operationId":"update_telephony_configuration_api_v1_organizations_telephony_configs__config_id__put","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationUpdateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationDetail"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main","organizations"],"summary":"Delete Telephony Configuration","operationId":"delete_telephony_configuration_api_v1_organizations_telephony_configs__config_id__delete","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}/set-default-outbound":{"post":{"tags":["main","organizations"],"summary":"Set Default Outbound","operationId":"set_default_outbound_api_v1_organizations_telephony_configs__config_id__set_default_outbound_post","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationDetail"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}/phone-numbers":{"get":{"tags":["main","organizations"],"summary":"List Phone Numbers","operationId":"list_phone_numbers_api_v1_organizations_telephony_configs__config_id__phone_numbers_get","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberListResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main","organizations"],"summary":"Create Phone Number","operationId":"create_phone_number_api_v1_organizations_telephony_configs__config_id__phone_numbers_post","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}/phone-numbers/{phone_number_id}":{"get":{"tags":["main","organizations"],"summary":"Get Phone Number","operationId":"get_phone_number_api_v1_organizations_telephony_configs__config_id__phone_numbers__phone_number_id__get","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"phone_number_id","in":"path","required":true,"schema":{"type":"integer","title":"Phone Number Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main","organizations"],"summary":"Update Phone Number","operationId":"update_phone_number_api_v1_organizations_telephony_configs__config_id__phone_numbers__phone_number_id__put","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"phone_number_id","in":"path","required":true,"schema":{"type":"integer","title":"Phone Number Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberUpdateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main","organizations"],"summary":"Delete Phone Number","operationId":"delete_phone_number_api_v1_organizations_telephony_configs__config_id__phone_numbers__phone_number_id__delete","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"phone_number_id","in":"path","required":true,"schema":{"type":"integer","title":"Phone Number Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}/phone-numbers/{phone_number_id}/set-default-caller":{"post":{"tags":["main","organizations"],"summary":"Set Default Caller Id","operationId":"set_default_caller_id_api_v1_organizations_telephony_configs__config_id__phone_numbers__phone_number_id__set_default_caller_post","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"phone_number_id","in":"path","required":true,"schema":{"type":"integer","title":"Phone Number Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-config":{"get":{"tags":["main","organizations"],"summary":"Get Telephony Configuration","description":"Legacy: returns the org's default config in the original per-provider\nresponse shape so the existing single-form UI keeps working. Prefer the\nmulti-config endpoints (``/telephony-configs``) for new clients.","operationId":"get_telephony_configuration_api_v1_organizations_telephony_config_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main","organizations"],"summary":"Save Telephony Configuration","description":"Legacy: upserts the org's default config (and its phone numbers) in the\noriginal payload shape so existing UI clients keep working. Prefer the\nmulti-config + phone-number endpoints for new clients.","operationId":"save_telephony_configuration_api_v1_organizations_telephony_config_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ARIConfigurationRequest"},{"$ref":"#/components/schemas/CloudonixConfigurationRequest"},{"$ref":"#/components/schemas/PlivoConfigurationRequest"},{"$ref":"#/components/schemas/TelnyxConfigurationRequest"},{"$ref":"#/components/schemas/TwilioConfigurationRequest"},{"$ref":"#/components/schemas/VobizConfigurationRequest"},{"$ref":"#/components/schemas/VonageConfigurationRequest"}],"discriminator":{"propertyName":"provider","mapping":{"ari":"#/components/schemas/ARIConfigurationRequest","cloudonix":"#/components/schemas/CloudonixConfigurationRequest","plivo":"#/components/schemas/PlivoConfigurationRequest","telnyx":"#/components/schemas/TelnyxConfigurationRequest","twilio":"#/components/schemas/TwilioConfigurationRequest","vobiz":"#/components/schemas/VobizConfigurationRequest","vonage":"#/components/schemas/VonageConfigurationRequest"}},"title":"Request"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/langfuse-credentials":{"get":{"tags":["main","organizations"],"summary":"Get Langfuse Credentials","description":"Get Langfuse credentials for the user's organization with masked sensitive fields.","operationId":"get_langfuse_credentials_api_v1_organizations_langfuse_credentials_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LangfuseCredentialsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main","organizations"],"summary":"Save Langfuse Credentials","description":"Save Langfuse credentials for the user's organization.","operationId":"save_langfuse_credentials_api_v1_organizations_langfuse_credentials_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LangfuseCredentialsRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main","organizations"],"summary":"Delete Langfuse Credentials","description":"Delete Langfuse credentials for the user's organization.","operationId":"delete_langfuse_credentials_api_v1_organizations_langfuse_credentials_delete","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/campaign-defaults":{"get":{"tags":["main","organizations"],"summary":"Get Campaign Defaults","description":"Get campaign limits for the user's organization.\n\nReturns the organization's concurrent call limit and default retry configuration.","operationId":"get_campaign_defaults_api_v1_organizations_campaign_defaults_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignDefaultsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/s3/signed-url":{"get":{"tags":["main","s3"],"summary":"Generate a signed S3 URL","description":"Return a short-lived signed URL for a file stored on S3 / MinIO.\n\nAccess Control:\n* Keys that embed an organization ID (``{prefix}/{org_id}/...``) are\n authorized by matching the org_id against the requesting user's\n organization.\n* Legacy keys (``recordings/{run_id}.wav``, ``transcripts/{run_id}.txt``)\n are authorized via the workflow run they belong to.\n* Superusers can request any key.","operationId":"get_signed_url_api_v1_s3_signed_url_get","parameters":[{"name":"key","in":"query","required":true,"schema":{"type":"string","description":"S3 object key","title":"Key"},"description":"S3 object key"},{"name":"expires_in","in":"query","required":false,"schema":{"type":"integer","default":3600,"title":"Expires In"}},{"name":"inline","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Inline"}},{"name":"storage_backend","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Storage backend to use (e.g. 'minio', 's3'). When omitted the backend is inferred from the resource.","title":"Storage Backend"},"description":"Storage backend to use (e.g. 'minio', 's3'). When omitted the backend is inferred from the resource."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/S3SignedUrlResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/s3/file-metadata":{"get":{"tags":["main","s3"],"summary":"Get file metadata for debugging","description":"Get file metadata including creation timestamp for debugging.\n\nAccess Control:\n* Superusers can request any key.\n* Regular users can only request resources belonging to **their** workflow runs.","operationId":"get_file_metadata_api_v1_s3_file_metadata_get","parameters":[{"name":"key","in":"query","required":true,"schema":{"type":"string","description":"S3 object key","title":"Key"},"description":"S3 object key"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileMetadataResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/s3/presigned-upload-url":{"post":{"tags":["main","s3"],"summary":"Generate a presigned URL for direct CSV upload","description":"Generate a presigned PUT URL for direct CSV file upload to S3/MinIO.\n\nThis endpoint enables browser-to-storage uploads without passing through the backend\n\nAccess Control:\n* All authenticated users can upload CSV files scoped to their organization.\n* Files are stored with organization-scoped keys for multi-tenancy.\n\nReturns:\n* upload_url: Presigned URL (valid for 15 minutes) for PUT request\n* file_key: Unique storage key to use as source_id in campaign creation\n* expires_in: URL expiration time in seconds","operationId":"get_presigned_upload_url_api_v1_s3_presigned_upload_url_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignedUploadUrlRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignedUploadUrlResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/service-keys":{"get":{"tags":["main"],"summary":"Get Service Keys","description":"Get all service keys for the user's organization.","operationId":"get_service_keys_api_v1_user_service_keys_get","parameters":[{"name":"include_archived","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Archived"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ServiceKeyResponse"},"title":"Response Get Service Keys Api V1 User Service Keys Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main"],"summary":"Create Service Key","description":"Create a new service key for the user's organization.","operationId":"create_service_key_api_v1_user_service_keys_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateServiceKeyRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateServiceKeyResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/service-keys/{service_key_id}":{"delete":{"tags":["main"],"summary":"Archive Service Key","description":"Archive a service key.","operationId":"archive_service_key_api_v1_user_service_keys__service_key_id__delete","parameters":[{"name":"service_key_id","in":"path","required":true,"schema":{"type":"string","title":"Service Key Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/service-keys/{service_key_id}/reactivate":{"put":{"tags":["main"],"summary":"Reactivate Service Key","description":"Reactivate an archived service key.\n\nNote: This endpoint is provided for API compatibility but service key\nreactivation is not supported by MPS. Once archived, a service key\ncannot be reactivated and a new key must be created instead.","operationId":"reactivate_service_key_api_v1_user_service_keys__service_key_id__reactivate_put","parameters":[{"name":"service_key_id","in":"path","required":true,"schema":{"type":"string","title":"Service Key Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/current-period":{"get":{"tags":["main"],"summary":"Get Current Period Usage","description":"Get current billing period usage for the user's organization.","operationId":"get_current_period_usage_api_v1_organizations_usage_current_period_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CurrentUsageResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/mps-credits":{"get":{"tags":["main"],"summary":"Get Mps Credits","description":"Get aggregated usage and quota from MPS.\n\nOSS users: queries by provider_id (created_by).\nHosted users: queries by organization_id.","operationId":"get_mps_credits_api_v1_organizations_usage_mps_credits_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MPSCreditsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/runs":{"get":{"tags":["main"],"summary":"Get Usage History","description":"Get paginated workflow runs with usage for the organization.","operationId":"get_usage_history_api_v1_organizations_usage_runs_get","parameters":[{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`.","examples":["2026-04-01T00:00:00Z"],"title":"Start Date"},"description":"ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`."},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`.","examples":["2026-05-01T00:00:00Z"],"title":"End Date"},"description":"ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`."},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":50,"title":"Limit"}},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded array of filter objects. Each object has the shape:\n\n```json\n{ \"attribute\": \"\", \"type\": \"\", \"value\": }\n```\n\nSupported `attribute` / `type` / `value` combinations:\n\n| attribute | type | value shape | matches |\n|-----------------|---------------|----------------------------------------------|------------------------------------------------------|\n| `runId` | `number` | `{ \"value\": 12345 }` | exact run id |\n| `workflowId` | `number` | `{ \"value\": 42 }` | exact agent (workflow) id |\n| `campaignId` | `number` | `{ \"value\": 7 }` | exact campaign id |\n| `callerNumber` | `text` | `{ \"value\": \"415555\" }` | substring match on `initial_context.caller_number` |\n| `calledNumber` | `text` | `{ \"value\": \"9911848\" }` | substring match on `initial_context.called_number` |\n| `dispositionCode` | `multiSelect` | `{ \"codes\": [\"XFER\", \"DNC\"] }` | any of the codes in `gathered_context.mapped_call_disposition` |\n| `duration` | `numberRange` | `{ \"min\": 60, \"max\": 300 }` | call duration (seconds), inclusive bounds |\n\nUnknown attributes and unsupported `type` values are silently ignored.\n\nDate filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.\n","examples":["[{\"attribute\":\"callerNumber\",\"type\":\"text\",\"value\":{\"value\":\"415555\"}}]","[{\"attribute\":\"campaignId\",\"type\":\"number\",\"value\":{\"value\":7}},{\"attribute\":\"duration\",\"type\":\"numberRange\",\"value\":{\"min\":60,\"max\":300}}]","[{\"attribute\":\"dispositionCode\",\"type\":\"multiSelect\",\"value\":{\"codes\":[\"XFER\",\"DNC\"]}}]"],"title":"Filters"},"description":"JSON-encoded array of filter objects. Each object has the shape:\n\n```json\n{ \"attribute\": \"\", \"type\": \"\", \"value\": }\n```\n\nSupported `attribute` / `type` / `value` combinations:\n\n| attribute | type | value shape | matches |\n|-----------------|---------------|----------------------------------------------|------------------------------------------------------|\n| `runId` | `number` | `{ \"value\": 12345 }` | exact run id |\n| `workflowId` | `number` | `{ \"value\": 42 }` | exact agent (workflow) id |\n| `campaignId` | `number` | `{ \"value\": 7 }` | exact campaign id |\n| `callerNumber` | `text` | `{ \"value\": \"415555\" }` | substring match on `initial_context.caller_number` |\n| `calledNumber` | `text` | `{ \"value\": \"9911848\" }` | substring match on `initial_context.called_number` |\n| `dispositionCode` | `multiSelect` | `{ \"codes\": [\"XFER\", \"DNC\"] }` | any of the codes in `gathered_context.mapped_call_disposition` |\n| `duration` | `numberRange` | `{ \"min\": 60, \"max\": 300 }` | call duration (seconds), inclusive bounds |\n\nUnknown attributes and unsupported `type` values are silently ignored.\n\nDate filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.\n"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsageHistoryResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/runs/report":{"get":{"tags":["main"],"summary":"Download Usage Runs Report","description":"Download a CSV of runs matching the same filters as `/usage/runs`.","operationId":"download_usage_runs_report_api_v1_organizations_usage_runs_report_get","parameters":[{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`.","title":"Start Date"},"description":"ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`."},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`.","title":"End Date"},"description":"ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`."},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded array of filter objects. Each object has the shape:\n\n```json\n{ \"attribute\": \"\", \"type\": \"\", \"value\": }\n```\n\nSupported `attribute` / `type` / `value` combinations:\n\n| attribute | type | value shape | matches |\n|-----------------|---------------|----------------------------------------------|------------------------------------------------------|\n| `runId` | `number` | `{ \"value\": 12345 }` | exact run id |\n| `workflowId` | `number` | `{ \"value\": 42 }` | exact agent (workflow) id |\n| `campaignId` | `number` | `{ \"value\": 7 }` | exact campaign id |\n| `callerNumber` | `text` | `{ \"value\": \"415555\" }` | substring match on `initial_context.caller_number` |\n| `calledNumber` | `text` | `{ \"value\": \"9911848\" }` | substring match on `initial_context.called_number` |\n| `dispositionCode` | `multiSelect` | `{ \"codes\": [\"XFER\", \"DNC\"] }` | any of the codes in `gathered_context.mapped_call_disposition` |\n| `duration` | `numberRange` | `{ \"min\": 60, \"max\": 300 }` | call duration (seconds), inclusive bounds |\n\nUnknown attributes and unsupported `type` values are silently ignored.\n\nDate filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.\n","title":"Filters"},"description":"JSON-encoded array of filter objects. Each object has the shape:\n\n```json\n{ \"attribute\": \"\", \"type\": \"\", \"value\": }\n```\n\nSupported `attribute` / `type` / `value` combinations:\n\n| attribute | type | value shape | matches |\n|-----------------|---------------|----------------------------------------------|------------------------------------------------------|\n| `runId` | `number` | `{ \"value\": 12345 }` | exact run id |\n| `workflowId` | `number` | `{ \"value\": 42 }` | exact agent (workflow) id |\n| `campaignId` | `number` | `{ \"value\": 7 }` | exact campaign id |\n| `callerNumber` | `text` | `{ \"value\": \"415555\" }` | substring match on `initial_context.caller_number` |\n| `calledNumber` | `text` | `{ \"value\": \"9911848\" }` | substring match on `initial_context.called_number` |\n| `dispositionCode` | `multiSelect` | `{ \"codes\": [\"XFER\", \"DNC\"] }` | any of the codes in `gathered_context.mapped_call_disposition` |\n| `duration` | `numberRange` | `{ \"min\": 60, \"max\": 300 }` | call duration (seconds), inclusive bounds |\n\nUnknown attributes and unsupported `type` values are silently ignored.\n\nDate filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.\n"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/daily-breakdown":{"get":{"tags":["main"],"summary":"Get Daily Usage Breakdown","description":"Get daily usage breakdown for the last N days. Only available for organizations with pricing.","operationId":"get_daily_usage_breakdown_api_v1_organizations_usage_daily_breakdown_get","parameters":[{"name":"days","in":"query","required":false,"schema":{"type":"integer","maximum":30,"minimum":1,"description":"Number of days to include","default":7,"title":"Days"},"description":"Number of days to include"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DailyUsageBreakdownResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/reports/daily":{"get":{"tags":["main"],"summary":"Get Daily Report","description":"Get daily report for the specified date and timezone.\nIf workflow_id is provided, filters results to that specific workflow.\nIf workflow_id is None, includes all workflows for the organization.","operationId":"get_daily_report_api_v1_organizations_reports_daily_get","parameters":[{"name":"date","in":"query","required":true,"schema":{"type":"string","description":"Date in YYYY-MM-DD format","title":"Date"},"description":"Date in YYYY-MM-DD format"},{"name":"timezone","in":"query","required":true,"schema":{"type":"string","description":"IANA timezone (e.g., 'America/New_York')","title":"Timezone"},"description":"IANA timezone (e.g., 'America/New_York')"},{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Optional workflow ID to filter by","title":"Workflow Id"},"description":"Optional workflow ID to filter by"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DailyReportResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/reports/workflows":{"get":{"tags":["main"],"summary":"Get Workflow Options","description":"Get all workflows for the user's organization.\nUsed to populate the workflow selector dropdown in the reports page.","operationId":"get_workflow_options_api_v1_organizations_reports_workflows_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowOption"},"title":"Response Get Workflow Options Api V1 Organizations Reports Workflows Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/reports/daily/runs":{"get":{"tags":["main"],"summary":"Get Daily Runs Detail","description":"Get detailed workflow runs for the specified date.\nUsed for CSV export functionality.","operationId":"get_daily_runs_detail_api_v1_organizations_reports_daily_runs_get","parameters":[{"name":"date","in":"query","required":true,"schema":{"type":"string","description":"Date in YYYY-MM-DD format","title":"Date"},"description":"Date in YYYY-MM-DD format"},{"name":"timezone","in":"query","required":true,"schema":{"type":"string","description":"IANA timezone (e.g., 'America/New_York')","title":"Timezone"},"description":"IANA timezone (e.g., 'America/New_York')"},{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Optional workflow ID to filter by","title":"Workflow Id"},"description":"Optional workflow ID to filter by"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowRunDetail"},"title":"Response Get Daily Runs Detail Api V1 Organizations Reports Daily Runs Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/turn/credentials":{"get":{"tags":["main","turn"],"summary":"Get Turn Credentials","description":"Get time-limited TURN credentials for WebRTC connections.\n\nThis endpoint generates ephemeral TURN credentials that are:\n- Valid for the configured TTL (default: 24 hours)\n- Cryptographically bound to the user via HMAC\n- Compatible with coturn's use-auth-secret mode\n\nReturns:\n TurnCredentialsResponse with username, password, ttl, and TURN URIs","operationId":"get_turn_credentials_api_v1_turn_credentials_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TurnCredentialsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/embed/init":{"post":{"tags":["main"],"summary":"Initialize Embed Session","description":"Initialize an embed session with token validation and domain checking.\n\nThis endpoint:\n1. Validates the embed token\n2. Checks domain whitelist\n3. Creates a workflow run\n4. Generates a temporary session token\n5. Returns configuration for the widget","operationId":"initialize_embed_session_api_v1_public_embed_init_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InitEmbedRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InitEmbedResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"options":{"tags":["main"],"summary":"Options Init","description":"Handle CORS preflight for init endpoint","operationId":"options_init_api_v1_public_embed_init_options","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"}}}},"/api/v1/public/embed/config/{token}":{"get":{"tags":["main"],"summary":"Get Embed Config","description":"Get embed configuration without creating a session.\n\nThis endpoint is used to fetch widget configuration for display purposes\nwithout actually starting a call session.","operationId":"get_embed_config_api_v1_public_embed_config__token__get","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","title":"Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmbedConfigResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"options":{"tags":["main"],"summary":"Options Config","description":"Handle CORS preflight for config endpoint","operationId":"options_config_api_v1_public_embed_config__token__options","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","title":"Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/embed/turn-credentials/{session_token}":{"get":{"tags":["main"],"summary":"Get Public Turn Credentials","description":"Get TURN credentials for an embed session.\n\nThis endpoint allows embedded widgets to obtain TURN server credentials\nfor WebRTC connections without requiring authentication.\n\nArgs:\n session_token: The session token from embed initialization\n\nReturns:\n TurnCredentialsResponse with username, password, ttl, and TURN URIs","operationId":"get_public_turn_credentials_api_v1_public_embed_turn_credentials__session_token__get","parameters":[{"name":"session_token","in":"path","required":true,"schema":{"type":"string","title":"Session Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TurnCredentialsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"options":{"tags":["main"],"summary":"Options Turn Credentials","description":"Handle CORS preflight for TURN credentials endpoint","operationId":"options_turn_credentials_api_v1_public_embed_turn_credentials__session_token__options","parameters":[{"name":"session_token","in":"path","required":true,"schema":{"type":"string","title":"Session Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/agent/{uuid}":{"post":{"tags":["main"],"summary":"Initiate Call","description":"Initiate a phone call against the published agent.\n\nExecutes the workflow's currently released definition.","operationId":"initiate_call_api_v1_public_agent__uuid__post","parameters":[{"name":"uuid","in":"path","required":true,"schema":{"type":"string","title":"Uuid"}},{"name":"X-API-Key","in":"header","required":true,"schema":{"type":"string","title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/agent/test/{uuid}":{"post":{"tags":["main"],"summary":"Initiate Call Test","description":"Initiate a phone call against the latest draft of the agent.\n\nUseful for verifying changes before publishing. Falls back to the\npublished definition when no draft exists.","operationId":"initiate_call_test_api_v1_public_agent_test__uuid__post","parameters":[{"name":"uuid","in":"path","required":true,"schema":{"type":"string","title":"Uuid"}},{"name":"X-API-Key","in":"header","required":true,"schema":{"type":"string","title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/agent/workflow/{workflow_uuid}":{"post":{"tags":["main"],"summary":"Initiate Call By Workflow Uuid","description":"Initiate a phone call against the published workflow identified by UUID.","operationId":"initiate_call_by_workflow_uuid_api_v1_public_agent_workflow__workflow_uuid__post","parameters":[{"name":"workflow_uuid","in":"path","required":true,"schema":{"type":"string","title":"Workflow Uuid"}},{"name":"X-API-Key","in":"header","required":true,"schema":{"type":"string","title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/agent/test/workflow/{workflow_uuid}":{"post":{"tags":["main"],"summary":"Initiate Call Test By Workflow Uuid","description":"Initiate a phone call against the latest draft of the workflow by UUID.","operationId":"initiate_call_test_by_workflow_uuid_api_v1_public_agent_test_workflow__workflow_uuid__post","parameters":[{"name":"workflow_uuid","in":"path","required":true,"schema":{"type":"string","title":"Workflow Uuid"}},{"name":"X-API-Key","in":"header","required":true,"schema":{"type":"string","title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/download/workflow/{token}/{artifact_type}":{"get":{"tags":["main"],"summary":"Download Workflow Artifact","description":"Download a workflow recording or transcript via public access token.\n\nThis endpoint:\n1. Validates the public access token\n2. Looks up the corresponding workflow run\n3. Generates a signed URL for the requested artifact\n4. Redirects to the signed URL\n\nArgs:\n token: The public access token (UUID format)\n artifact_type: Type of artifact - \"recording\" or \"transcript\"\n inline: If true, sets Content-Disposition to inline for browser preview\n\nReturns:\n RedirectResponse to the signed URL (302 redirect)\n\nRaises:\n HTTPException 404: If token is invalid or artifact not found","operationId":"download_workflow_artifact_api_v1_public_download_workflow__token___artifact_type__get","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","title":"Token"}},{"name":"artifact_type","in":"path","required":true,"schema":{"enum":["recording","transcript"],"type":"string","title":"Artifact Type"}},{"name":"inline","in":"query","required":false,"schema":{"type":"boolean","description":"Display inline in browser instead of download","default":false,"title":"Inline"},"description":"Display inline in browser instead of download"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/embed-token":{"post":{"tags":["main"],"summary":"Create Or Update Embed Token","description":"Create or update an embed token for a workflow.\nEach workflow can have only one active embed token.","operationId":"create_or_update_embed_token_api_v1_workflow__workflow_id__embed_token_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmbedTokenRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmbedTokenResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["main"],"summary":"Get Embed Token","description":"Get the embed token for a workflow if it exists.","operationId":"get_embed_token_api_v1_workflow__workflow_id__embed_token_get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/EmbedTokenResponse"},{"type":"null"}],"title":"Response Get Embed Token Api V1 Workflow Workflow Id Embed Token Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main"],"summary":"Deactivate Embed Token","description":"Deactivate the embed token for a workflow.","operationId":"deactivate_embed_token_api_v1_workflow__workflow_id__embed_token_delete","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Deactivate Embed Token Api V1 Workflow Workflow Id Embed Token Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/knowledge-base/upload-url":{"post":{"tags":["main","knowledge-base"],"summary":"Get presigned URL for document upload","description":"Generate a presigned PUT URL for uploading a document.\n\nThis endpoint:\n1. Generates a unique document UUID for organizing the S3 key\n2. Generates a presigned S3/MinIO URL for uploading the file\n3. Returns the upload URL and document metadata\n\nAfter uploading to the returned URL, call /process-document to create\nthe document record and trigger processing.\n\nAccess Control:\n* All authenticated users can upload documents scoped to their organization.","operationId":"get_upload_url_api_v1_knowledge_base_upload_url_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentUploadRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentUploadResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/knowledge-base/process-document":{"post":{"tags":["main","knowledge-base"],"summary":"Trigger document processing","description":"Trigger asynchronous processing of an uploaded document.\n\nThis endpoint should be called after successfully uploading a file to the presigned URL.\nIt will:\n1. Create a document record in the database with the specified UUID\n2. Enqueue a background task to process the document (chunking and embedding)\n\nThe document status will be updated from 'pending' -> 'processing' -> 'completed' or 'failed'.\n\nEmbedding:\nUses OpenAI text-embedding-3-small (1536-dimensional embeddings, requires API key configured in Model Configurations).\n\nAccess Control:\n* Users can only process documents in their organization.","operationId":"process_document_api_v1_knowledge_base_process_document_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProcessDocumentRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/knowledge-base/documents":{"get":{"tags":["main","knowledge-base"],"summary":"List documents","description":"List all documents for the user's organization.\n\nAccess Control:\n* Users can only see documents from their organization.","operationId":"list_documents_api_v1_knowledge_base_documents_get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by processing status","title":"Status"},"description":"Filter by processing status"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":100,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_documents","x-sdk-description":"List knowledge base documents available to the authenticated organization."}},"/api/v1/knowledge-base/documents/{document_uuid}":{"get":{"tags":["main","knowledge-base"],"summary":"Get document details","description":"Get details of a specific document.\n\nAccess Control:\n* Users can only access documents from their organization.","operationId":"get_document_api_v1_knowledge_base_documents__document_uuid__get","parameters":[{"name":"document_uuid","in":"path","required":true,"schema":{"type":"string","title":"Document Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main","knowledge-base"],"summary":"Delete document","description":"Soft delete a document and its chunks.\n\nAccess Control:\n* Users can only delete documents from their organization.","operationId":"delete_document_api_v1_knowledge_base_documents__document_uuid__delete","parameters":[{"name":"document_uuid","in":"path","required":true,"schema":{"type":"string","title":"Document Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/knowledge-base/search":{"post":{"tags":["main","knowledge-base"],"summary":"Search for similar chunks","description":"Search for document chunks similar to the query.\n\nThis endpoint uses vector similarity search to find relevant chunks.\nResults are returned without threshold filtering - apply similarity\nthresholds at the application layer after optional reranking.\n\nAccess Control:\n* Users can only search documents from their organization.","operationId":"search_chunks_api_v1_knowledge_base_search_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChunkSearchRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChunkSearchResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow-recordings/upload-url":{"post":{"tags":["main","workflow-recordings"],"summary":"Get presigned URLs for recording uploads","description":"Generate presigned PUT URLs for uploading one or more audio recordings.","operationId":"get_upload_urls_api_v1_workflow_recordings_upload_url_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchRecordingUploadRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchRecordingUploadResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow-recordings/":{"post":{"tags":["main","workflow-recordings"],"summary":"Create recording records after upload","description":"Create one or more recording records after audio files have been uploaded.","operationId":"create_recordings_api_v1_workflow_recordings__post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchRecordingCreateRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchRecordingCreateResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["main","workflow-recordings"],"summary":"List recordings","description":"List recordings for the organization, optionally filtered.","operationId":"list_recordings_api_v1_workflow_recordings__get","parameters":[{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filter by workflow ID","title":"Workflow Id"},"description":"Filter by workflow ID"},{"name":"tts_provider","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by TTS provider","title":"Tts Provider"},"description":"Filter by TTS provider"},{"name":"tts_model","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by TTS model","title":"Tts Model"},"description":"Filter by TTS model"},{"name":"tts_voice_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by TTS voice ID","title":"Tts Voice Id"},"description":"Filter by TTS voice ID"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordingListResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_recordings","x-sdk-description":"List workflow recordings available to the authenticated organization."}},"/api/v1/workflow-recordings/{recording_id}":{"delete":{"tags":["main","workflow-recordings"],"summary":"Delete a recording","description":"Soft delete a recording.","operationId":"delete_recording_api_v1_workflow_recordings__recording_id__delete","parameters":[{"name":"recording_id","in":"path","required":true,"schema":{"type":"string","title":"Recording Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow-recordings/{id}":{"patch":{"tags":["main","workflow-recordings"],"summary":"Update a recording's Recording ID","description":"Update the recording_id (descriptive name) of a recording.","operationId":"update_recording_api_v1_workflow_recordings__id__patch","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordingUpdateRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordingResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow-recordings/transcribe":{"post":{"tags":["main","workflow-recordings"],"summary":"Transcribe an audio file","description":"Transcribe an uploaded audio file using MPS STT.","operationId":"transcribe_audio_api_v1_workflow_recordings_transcribe_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_transcribe_audio_api_v1_workflow_recordings_transcribe_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/folder/":{"get":{"tags":["main"],"summary":"List Folders","description":"List all folders in the authenticated user's organization.","operationId":"list_folders_api_v1_folder__get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FolderResponse"},"title":"Response List Folders Api V1 Folder Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main"],"summary":"Create Folder","description":"Create a new folder in the authenticated user's organization.","operationId":"create_folder_api_v1_folder__post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateFolderRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/folder/{folder_id}":{"put":{"tags":["main"],"summary":"Rename Folder","description":"Rename a folder owned by the authenticated user's organization.","operationId":"rename_folder_api_v1_folder__folder_id__put","parameters":[{"name":"folder_id","in":"path","required":true,"schema":{"type":"integer","title":"Folder Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateFolderRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main"],"summary":"Delete Folder","description":"Delete a folder. Member agents are moved to \"Uncategorized\", not deleted.","operationId":"delete_folder_api_v1_folder__folder_id__delete","parameters":[{"name":"folder_id","in":"path","required":true,"schema":{"type":"integer","title":"Folder Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"boolean"},"title":"Response Delete Folder Api V1 Folder Folder Id Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/signup":{"post":{"tags":["main","auth"],"summary":"Signup","operationId":"signup_api_v1_auth_signup_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/login":{"post":{"tags":["main","auth"],"summary":"Login","operationId":"login_api_v1_auth_login_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/me":{"get":{"tags":["main","auth"],"summary":"Get Current User","operationId":"get_current_user_api_v1_auth_me_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/node-types":{"get":{"tags":["main"],"summary":"List Node Types","description":"List every registered NodeSpec.\n\nSDK clients should pin to `spec_version` and warn if the server reports\na higher version than what they were generated against.","operationId":"list_node_types_api_v1_node_types_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeTypesResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_node_types","x-sdk-description":"List every registered node type with its spec. Pinned to spec_version."}},"/api/v1/node-types/{name}":{"get":{"tags":["main"],"summary":"Get Node Type","operationId":"get_node_type_api_v1_node_types__name__get","parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeSpec"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"get_node_type","x-sdk-description":"Fetch a single node spec by name."}},"/api/v1/health":{"get":{"tags":["main"],"summary":"Health","operationId":"health_api_v1_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}},"404":{"description":"Not found"}}}}},"components":{"schemas":{"APIKeyResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"key_prefix":{"type":"string","title":"Key Prefix"},"is_active":{"type":"boolean","title":"Is Active"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"last_used_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Used At"},"archived_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Archived At"}},"type":"object","required":["id","name","key_prefix","is_active","created_at"],"title":"APIKeyResponse"},"APIKeyStatus":{"properties":{"model":{"type":"string","title":"Model"},"message":{"type":"string","title":"Message"}},"type":"object","required":["model","message"],"title":"APIKeyStatus"},"APIKeyStatusResponse":{"properties":{"status":{"items":{"$ref":"#/components/schemas/APIKeyStatus"},"type":"array","title":"Status"}},"type":"object","required":["status"],"title":"APIKeyStatusResponse"},"ARIConfigurationRequest":{"properties":{"provider":{"type":"string","const":"ari","title":"Provider","default":"ari"},"ari_endpoint":{"type":"string","title":"Ari Endpoint","description":"ARI base URL (e.g., http://asterisk.example.com:8088)"},"app_name":{"type":"string","title":"App Name","description":"Stasis application name registered in Asterisk"},"app_password":{"type":"string","title":"App Password","description":"ARI user password"},"ws_client_name":{"type":"string","title":"Ws Client Name","description":"websocket_client.conf connection name for externalMedia (e.g., dograh_staging)","default":""},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of SIP extensions/numbers for outbound calls (optional)"}},"type":"object","required":["ari_endpoint","app_name","app_password"],"title":"ARIConfigurationRequest","description":"Request schema for Asterisk ARI configuration."},"ARIConfigurationResponse":{"properties":{"provider":{"type":"string","const":"ari","title":"Provider","default":"ari"},"ari_endpoint":{"type":"string","title":"Ari Endpoint"},"app_name":{"type":"string","title":"App Name"},"app_password":{"type":"string","title":"App Password"},"ws_client_name":{"type":"string","title":"Ws Client Name","default":""},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["ari_endpoint","app_name","app_password","from_numbers"],"title":"ARIConfigurationResponse","description":"Response schema for ARI configuration with masked sensitive fields."},"AmbientNoiseUploadRequest":{"properties":{"workflow_id":{"type":"integer","title":"Workflow Id"},"filename":{"type":"string","title":"Filename"},"mime_type":{"type":"string","title":"Mime Type","default":"audio/wav"},"file_size":{"type":"integer","maximum":10485760.0,"exclusiveMinimum":0.0,"title":"File Size","description":"Max 10MB"}},"type":"object","required":["workflow_id","filename","file_size"],"title":"AmbientNoiseUploadRequest"},"AmbientNoiseUploadResponse":{"properties":{"upload_url":{"type":"string","title":"Upload Url"},"storage_key":{"type":"string","title":"Storage Key"},"storage_backend":{"type":"string","title":"Storage Backend"}},"type":"object","required":["upload_url","storage_key","storage_backend"],"title":"AmbientNoiseUploadResponse"},"AppendTextChatMessageRequest":{"properties":{"text":{"type":"string","minLength":1,"title":"Text"},"expected_revision":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Expected Revision"}},"type":"object","required":["text"],"title":"AppendTextChatMessageRequest"},"AuthResponse":{"properties":{"token":{"type":"string","title":"Token"},"user":{"$ref":"#/components/schemas/UserResponse"}},"type":"object","required":["token","user"],"title":"AuthResponse"},"AuthUserResponse":{"properties":{"id":{"type":"integer","title":"Id"},"is_superuser":{"type":"boolean","title":"Is Superuser"}},"type":"object","required":["id","is_superuser"],"title":"AuthUserResponse"},"BatchRecordingCreateRequestSchema":{"properties":{"recordings":{"items":{"$ref":"#/components/schemas/RecordingCreateRequestSchema"},"type":"array","maxItems":20,"minItems":1,"title":"Recordings","description":"List of recordings to create"}},"type":"object","required":["recordings"],"title":"BatchRecordingCreateRequestSchema","description":"Request schema for creating one or more recording records after upload."},"BatchRecordingCreateResponseSchema":{"properties":{"recordings":{"items":{"$ref":"#/components/schemas/RecordingResponseSchema"},"type":"array","title":"Recordings","description":"Created recording records"}},"type":"object","required":["recordings"],"title":"BatchRecordingCreateResponseSchema","description":"Response schema for recording creation."},"BatchRecordingUploadRequestSchema":{"properties":{"files":{"items":{"$ref":"#/components/schemas/FileDescriptor"},"type":"array","maxItems":20,"minItems":1,"title":"Files","description":"List of files to upload"}},"type":"object","required":["files"],"title":"BatchRecordingUploadRequestSchema","description":"Request schema for getting presigned upload URLs for one or more files."},"BatchRecordingUploadResponseSchema":{"properties":{"items":{"items":{"$ref":"#/components/schemas/RecordingUploadResponseSchema"},"type":"array","title":"Items","description":"Upload URLs for each file"}},"type":"object","required":["items"],"title":"BatchRecordingUploadResponseSchema","description":"Response schema with presigned upload URLs."},"Body_transcribe_audio_api_v1_workflow_recordings_transcribe_post":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File"},"language":{"type":"string","title":"Language","default":"en"}},"type":"object","required":["file"],"title":"Body_transcribe_audio_api_v1_workflow_recordings_transcribe_post"},"CalculatorToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version.","default":1},"type":{"type":"string","const":"calculator","title":"Type","description":"Tool type."}},"type":"object","required":["type"],"title":"CalculatorToolDefinition","description":"Tool definition for Calculator tools."},"CallDispositionCodes":{"properties":{"disposition_codes":{"items":{"type":"string"},"type":"array","title":"Disposition Codes","default":[]}},"type":"object","title":"CallDispositionCodes"},"CallType":{"type":"string","enum":["inbound","outbound"],"title":"CallType"},"CampaignDefaultsResponse":{"properties":{"concurrent_call_limit":{"type":"integer","title":"Concurrent Call Limit"},"from_numbers_count":{"type":"integer","title":"From Numbers Count"},"default_retry_config":{"$ref":"#/components/schemas/RetryConfigResponse"},"last_campaign_settings":{"anyOf":[{"$ref":"#/components/schemas/LastCampaignSettingsResponse"},{"type":"null"}]}},"type":"object","required":["concurrent_call_limit","from_numbers_count","default_retry_config"],"title":"CampaignDefaultsResponse"},"CampaignLogEntryResponse":{"properties":{"ts":{"type":"string","title":"Ts"},"level":{"type":"string","title":"Level"},"event":{"type":"string","title":"Event"},"message":{"type":"string","title":"Message"},"details":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Details"}},"type":"object","required":["ts","level","event","message"],"title":"CampaignLogEntryResponse","description":"A single timestamped entry from the campaign's append-only log.\n\nSurfaced in the UI so operators can see why a campaign moved to\npaused / failed without digging through server logs."},"CampaignProgressResponse":{"properties":{"campaign_id":{"type":"integer","title":"Campaign Id"},"state":{"type":"string","title":"State"},"total_rows":{"type":"integer","title":"Total Rows"},"processed_rows":{"type":"integer","title":"Processed Rows"},"failed_calls":{"type":"integer","title":"Failed Calls"},"progress_percentage":{"type":"number","title":"Progress Percentage"},"source_sync":{"additionalProperties":true,"type":"object","title":"Source Sync"},"rate_limit":{"type":"integer","title":"Rate Limit"},"started_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Started At"},"completed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Completed At"}},"type":"object","required":["campaign_id","state","total_rows","processed_rows","failed_calls","progress_percentage","source_sync","rate_limit","started_at","completed_at"],"title":"CampaignProgressResponse"},"CampaignResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"workflow_id":{"type":"integer","title":"Workflow Id"},"workflow_name":{"type":"string","title":"Workflow Name"},"state":{"type":"string","title":"State"},"source_type":{"type":"string","title":"Source Type"},"source_id":{"type":"string","title":"Source Id"},"total_rows":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Total Rows"},"processed_rows":{"type":"integer","title":"Processed Rows"},"failed_rows":{"type":"integer","title":"Failed Rows"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"started_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Started At"},"completed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Completed At"},"retry_config":{"$ref":"#/components/schemas/RetryConfigResponse"},"max_concurrency":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Concurrency"},"schedule_config":{"anyOf":[{"$ref":"#/components/schemas/ScheduleConfigResponse"},{"type":"null"}]},"circuit_breaker":{"anyOf":[{"$ref":"#/components/schemas/CircuitBreakerConfigResponse"},{"type":"null"}]},"executed_count":{"type":"integer","title":"Executed Count","default":0},"total_queued_count":{"type":"integer","title":"Total Queued Count","default":0},"parent_campaign_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Parent Campaign Id"},"redialed_campaign_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Redialed Campaign Id"},"telephony_configuration_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Telephony Configuration Id"},"telephony_configuration_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Telephony Configuration Name"},"logs":{"items":{"$ref":"#/components/schemas/CampaignLogEntryResponse"},"type":"array","title":"Logs"}},"type":"object","required":["id","name","workflow_id","workflow_name","state","source_type","source_id","total_rows","processed_rows","failed_rows","created_at","started_at","completed_at","retry_config"],"title":"CampaignResponse"},"CampaignRunsResponse":{"properties":{"runs":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Runs"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"limit":{"type":"integer","title":"Limit"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["runs","total_count","page","limit","total_pages"],"title":"CampaignRunsResponse","description":"Paginated response for campaign workflow runs"},"CampaignSourceDownloadResponse":{"properties":{"download_url":{"type":"string","title":"Download Url"},"expires_in":{"type":"integer","title":"Expires In"}},"type":"object","required":["download_url","expires_in"],"title":"CampaignSourceDownloadResponse"},"CampaignsResponse":{"properties":{"campaigns":{"items":{"$ref":"#/components/schemas/CampaignResponse"},"type":"array","title":"Campaigns"}},"type":"object","required":["campaigns"],"title":"CampaignsResponse"},"ChunkResponseSchema":{"properties":{"id":{"type":"integer","title":"Id"},"document_id":{"type":"integer","title":"Document Id"},"chunk_text":{"type":"string","title":"Chunk Text"},"contextualized_text":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Contextualized Text"},"chunk_index":{"type":"integer","title":"Chunk Index"},"chunk_metadata":{"additionalProperties":true,"type":"object","title":"Chunk Metadata"},"filename":{"type":"string","title":"Filename"},"document_uuid":{"type":"string","title":"Document Uuid"},"similarity":{"type":"number","title":"Similarity"}},"type":"object","required":["id","document_id","chunk_text","contextualized_text","chunk_index","chunk_metadata","filename","document_uuid","similarity"],"title":"ChunkResponseSchema","description":"Response schema for a document chunk."},"ChunkSearchRequestSchema":{"properties":{"query":{"type":"string","title":"Query","description":"Search query text"},"limit":{"type":"integer","maximum":50.0,"minimum":1.0,"title":"Limit","description":"Maximum number of results","default":5},"document_uuids":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Document Uuids","description":"Filter by specific document UUIDs"},"min_similarity":{"anyOf":[{"type":"number","maximum":1.0,"minimum":0.0},{"type":"null"}],"title":"Min Similarity","description":"Minimum similarity threshold"}},"type":"object","required":["query"],"title":"ChunkSearchRequestSchema","description":"Request schema for searching similar chunks."},"ChunkSearchResponseSchema":{"properties":{"chunks":{"items":{"$ref":"#/components/schemas/ChunkResponseSchema"},"type":"array","title":"Chunks"},"query":{"type":"string","title":"Query"},"total_results":{"type":"integer","title":"Total Results"}},"type":"object","required":["chunks","query","total_results"],"title":"ChunkSearchResponseSchema","description":"Response schema for chunk search results."},"CircuitBreakerConfigRequest":{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":true},"failure_threshold":{"type":"number","maximum":1.0,"minimum":0.0,"title":"Failure Threshold","default":0.5},"window_seconds":{"type":"integer","maximum":600.0,"minimum":30.0,"title":"Window Seconds","default":120},"min_calls_in_window":{"type":"integer","maximum":100.0,"minimum":1.0,"title":"Min Calls In Window","default":5}},"type":"object","title":"CircuitBreakerConfigRequest"},"CircuitBreakerConfigResponse":{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":false},"failure_threshold":{"type":"number","title":"Failure Threshold","default":0.5},"window_seconds":{"type":"integer","title":"Window Seconds","default":120},"min_calls_in_window":{"type":"integer","title":"Min Calls In Window","default":5}},"type":"object","title":"CircuitBreakerConfigResponse"},"CloudonixConfigurationRequest":{"properties":{"provider":{"type":"string","const":"cloudonix","title":"Provider","default":"cloudonix"},"bearer_token":{"type":"string","title":"Bearer Token","description":"Cloudonix API Bearer Token"},"domain_id":{"type":"string","title":"Domain Id","description":"Cloudonix Domain ID"},"application_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Name","description":"Cloudonix Voice Application name. The application's url is updated when inbound workflows are attached to numbers on this domain. If omitted, an application is auto-created on save and its name is stored on the configuration."},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Cloudonix phone numbers (optional)"}},"type":"object","required":["bearer_token","domain_id"],"title":"CloudonixConfigurationRequest","description":"Request schema for Cloudonix configuration."},"CloudonixConfigurationResponse":{"properties":{"provider":{"type":"string","const":"cloudonix","title":"Provider","default":"cloudonix"},"bearer_token":{"type":"string","title":"Bearer Token"},"domain_id":{"type":"string","title":"Domain Id"},"application_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Name"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["bearer_token","domain_id","from_numbers"],"title":"CloudonixConfigurationResponse","description":"Response schema for Cloudonix configuration with masked sensitive fields."},"CreateAPIKeyRequest":{"properties":{"name":{"type":"string","title":"Name"}},"type":"object","required":["name"],"title":"CreateAPIKeyRequest"},"CreateAPIKeyResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"key_prefix":{"type":"string","title":"Key Prefix"},"api_key":{"type":"string","title":"Api Key"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","name","key_prefix","api_key","created_at"],"title":"CreateAPIKeyResponse"},"CreateCampaignRequest":{"properties":{"name":{"type":"string","maxLength":255,"minLength":1,"title":"Name"},"workflow_id":{"type":"integer","title":"Workflow Id"},"source_type":{"type":"string","pattern":"^csv$","title":"Source Type"},"source_id":{"type":"string","title":"Source Id"},"telephony_configuration_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Telephony Configuration Id"},"retry_config":{"anyOf":[{"$ref":"#/components/schemas/RetryConfigRequest"},{"type":"null"}]},"max_concurrency":{"anyOf":[{"type":"integer","maximum":100.0,"minimum":1.0},{"type":"null"}],"title":"Max Concurrency"},"schedule_config":{"anyOf":[{"$ref":"#/components/schemas/ScheduleConfigRequest"},{"type":"null"}]},"circuit_breaker":{"anyOf":[{"$ref":"#/components/schemas/CircuitBreakerConfigRequest"},{"type":"null"}]}},"type":"object","required":["name","workflow_id","source_type","source_id"],"title":"CreateCampaignRequest"},"CreateCredentialRequest":{"properties":{"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"credential_type":{"$ref":"#/components/schemas/WebhookCredentialType"},"credential_data":{"additionalProperties":true,"type":"object","title":"Credential Data"}},"type":"object","required":["name","credential_type","credential_data"],"title":"CreateCredentialRequest","description":"Request schema for creating a webhook credential."},"CreateFolderRequest":{"properties":{"name":{"type":"string","maxLength":100,"minLength":1,"title":"Name"}},"type":"object","required":["name"],"title":"CreateFolderRequest"},"CreateServiceKeyRequest":{"properties":{"name":{"type":"string","title":"Name"},"expires_in_days":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Expires In Days","default":90}},"type":"object","required":["name"],"title":"CreateServiceKeyRequest"},"CreateServiceKeyResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"service_key":{"type":"string","title":"Service Key"},"key_prefix":{"type":"string","title":"Key Prefix"},"expires_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Expires At"}},"type":"object","required":["id","name","service_key","key_prefix"],"title":"CreateServiceKeyResponse"},"CreateTextChatSessionRequest":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"annotations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Annotations"}},"type":"object","title":"CreateTextChatSessionRequest"},"CreateToolRequest":{"properties":{"name":{"type":"string","maxLength":255,"title":"Name","description":"Display name for the tool.","llm_hint":"Use a concise action-oriented name; this influences the function name shown to the agent."},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Description shown to the agent when deciding whether to call it.","llm_hint":"State exactly when the agent should call the tool and what result it gets."},"category":{"type":"string","enum":["http_api","end_call","transfer_call","calculator","native","integration","mcp"],"title":"Category","description":"Tool category. Must match definition.type.","default":"http_api"},"icon":{"anyOf":[{"type":"string","maxLength":50},{"type":"null"}],"title":"Icon","description":"Lucide icon identifier.","default":"globe"},"icon_color":{"anyOf":[{"type":"string","maxLength":7},{"type":"null"}],"title":"Icon Color","description":"Hex color for the tool icon.","default":"#3B82F6"},"definition":{"oneOf":[{"$ref":"#/components/schemas/HttpApiToolDefinition"},{"$ref":"#/components/schemas/EndCallToolDefinition"},{"$ref":"#/components/schemas/TransferCallToolDefinition"},{"$ref":"#/components/schemas/CalculatorToolDefinition"},{"$ref":"#/components/schemas/McpToolDefinition"}],"title":"Definition","description":"Typed tool definition.","discriminator":{"propertyName":"type","mapping":{"calculator":"#/components/schemas/CalculatorToolDefinition","end_call":"#/components/schemas/EndCallToolDefinition","http_api":"#/components/schemas/HttpApiToolDefinition","mcp":"#/components/schemas/McpToolDefinition","transfer_call":"#/components/schemas/TransferCallToolDefinition"}}}},"type":"object","required":["name","definition"],"title":"CreateToolRequest","description":"Request schema for creating a reusable tool."},"CreateWorkflowRequest":{"properties":{"name":{"type":"string","title":"Name"},"workflow_definition":{"additionalProperties":true,"type":"object","title":"Workflow Definition"}},"type":"object","required":["name","workflow_definition"],"title":"CreateWorkflowRequest"},"CreateWorkflowRunRequest":{"properties":{"mode":{"type":"string","title":"Mode"},"name":{"type":"string","title":"Name"}},"type":"object","required":["mode","name"],"title":"CreateWorkflowRunRequest"},"CreateWorkflowRunResponse":{"properties":{"id":{"type":"integer","title":"Id"},"workflow_id":{"type":"integer","title":"Workflow Id"},"name":{"type":"string","title":"Name"},"mode":{"type":"string","title":"Mode"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"definition_id":{"type":"integer","title":"Definition Id"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"}},"type":"object","required":["id","workflow_id","name","mode","created_at","definition_id"],"title":"CreateWorkflowRunResponse"},"CreateWorkflowTemplateRequest":{"properties":{"call_type":{"type":"string","enum":["inbound","outbound"],"title":"Call Type"},"use_case":{"type":"string","title":"Use Case"},"activity_description":{"type":"string","title":"Activity Description"}},"type":"object","required":["call_type","use_case","activity_description"],"title":"CreateWorkflowTemplateRequest"},"CreatedByResponse":{"properties":{"id":{"type":"integer","title":"Id"},"provider_id":{"type":"string","title":"Provider Id"}},"type":"object","required":["id","provider_id"],"title":"CreatedByResponse","description":"Response schema for the user who created a tool."},"CredentialResponse":{"properties":{"uuid":{"type":"string","title":"Uuid"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"credential_type":{"type":"string","title":"Credential Type"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["uuid","name","description","credential_type","created_at","updated_at"],"title":"CredentialResponse","description":"Response schema for a webhook credential (never includes sensitive data)."},"CurrentUsageResponse":{"properties":{"period_start":{"type":"string","title":"Period Start"},"period_end":{"type":"string","title":"Period End"},"used_dograh_tokens":{"type":"number","title":"Used Dograh Tokens"},"quota_dograh_tokens":{"type":"integer","title":"Quota Dograh Tokens"},"percentage_used":{"type":"number","title":"Percentage Used"},"next_refresh_date":{"type":"string","title":"Next Refresh Date"},"quota_enabled":{"type":"boolean","title":"Quota Enabled"},"total_duration_seconds":{"type":"integer","title":"Total Duration Seconds"},"used_amount_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Used Amount Usd"},"quota_amount_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Quota Amount Usd"},"currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Currency"},"price_per_second_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Price Per Second Usd"}},"type":"object","required":["period_start","period_end","used_dograh_tokens","quota_dograh_tokens","percentage_used","next_refresh_date","quota_enabled","total_duration_seconds"],"title":"CurrentUsageResponse"},"DailyReportResponse":{"properties":{"date":{"type":"string","title":"Date"},"timezone":{"type":"string","title":"Timezone"},"workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Workflow Id"},"metrics":{"additionalProperties":{"type":"integer"},"type":"object","title":"Metrics"},"disposition_distribution":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Disposition Distribution"},"call_duration_distribution":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Call Duration Distribution"}},"type":"object","required":["date","timezone","workflow_id","metrics","disposition_distribution","call_duration_distribution"],"title":"DailyReportResponse"},"DailyUsageBreakdownResponse":{"properties":{"breakdown":{"items":{"$ref":"#/components/schemas/DailyUsageItem"},"type":"array","title":"Breakdown"},"total_minutes":{"type":"number","title":"Total Minutes"},"total_cost_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Total Cost Usd"},"total_dograh_tokens":{"type":"number","title":"Total Dograh Tokens"},"currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Currency"}},"type":"object","required":["breakdown","total_minutes","total_dograh_tokens"],"title":"DailyUsageBreakdownResponse"},"DailyUsageItem":{"properties":{"date":{"type":"string","title":"Date"},"minutes":{"type":"number","title":"Minutes"},"cost_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cost Usd"},"dograh_tokens":{"type":"number","title":"Dograh Tokens"},"call_count":{"type":"integer","title":"Call Count"}},"type":"object","required":["date","minutes","dograh_tokens","call_count"],"title":"DailyUsageItem"},"DefaultConfigurationsResponse":{"properties":{"llm":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Llm"},"tts":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Tts"},"stt":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Stt"},"embeddings":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Embeddings"},"realtime":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Realtime"},"default_providers":{"additionalProperties":{"type":"string"},"type":"object","title":"Default Providers"}},"type":"object","required":["llm","tts","stt","embeddings","realtime","default_providers"],"title":"DefaultConfigurationsResponse"},"DisplayOptions":{"properties":{"show":{"anyOf":[{"additionalProperties":{"items":{},"type":"array"},"type":"object"},{"type":"null"}],"title":"Show"},"hide":{"anyOf":[{"additionalProperties":{"items":{},"type":"array"},"type":"object"},{"type":"null"}],"title":"Hide"}},"additionalProperties":false,"type":"object","title":"DisplayOptions","description":"Conditional visibility rules.\n\n`show` keys are AND-combined: this property is visible only when EVERY\nreferenced field's value matches one of the listed values.\n\n`hide` keys are OR-combined: this property is hidden when ANY referenced\nfield's value matches one of the listed values.\n\nExample:\n DisplayOptions(show={\"extraction_enabled\": [True]})\n DisplayOptions(show={\"greeting_type\": [\"audio\"]})"},"DocumentListResponseSchema":{"properties":{"documents":{"items":{"$ref":"#/components/schemas/DocumentResponseSchema"},"type":"array","title":"Documents"},"total":{"type":"integer","title":"Total"},"limit":{"type":"integer","title":"Limit"},"offset":{"type":"integer","title":"Offset"}},"type":"object","required":["documents","total","limit","offset"],"title":"DocumentListResponseSchema","description":"Response schema for list of documents."},"DocumentResponseSchema":{"properties":{"id":{"type":"integer","title":"Id"},"document_uuid":{"type":"string","title":"Document Uuid"},"filename":{"type":"string","title":"Filename"},"file_size_bytes":{"type":"integer","title":"File Size Bytes"},"file_hash":{"type":"string","title":"File Hash"},"mime_type":{"type":"string","title":"Mime Type"},"processing_status":{"type":"string","title":"Processing Status"},"processing_error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Processing Error"},"total_chunks":{"type":"integer","title":"Total Chunks"},"retrieval_mode":{"type":"string","title":"Retrieval Mode","default":"chunked"},"custom_metadata":{"additionalProperties":true,"type":"object","title":"Custom Metadata"},"docling_metadata":{"additionalProperties":true,"type":"object","title":"Docling Metadata"},"source_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source Url"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"organization_id":{"type":"integer","title":"Organization Id"},"created_by":{"type":"integer","title":"Created By"},"is_active":{"type":"boolean","title":"Is Active"}},"type":"object","required":["id","document_uuid","filename","file_size_bytes","file_hash","mime_type","processing_status","total_chunks","custom_metadata","docling_metadata","created_at","updated_at","organization_id","created_by","is_active"],"title":"DocumentResponseSchema","description":"Response schema for document metadata."},"DocumentUploadRequestSchema":{"properties":{"filename":{"type":"string","title":"Filename","description":"Name of the file to upload"},"mime_type":{"type":"string","title":"Mime Type","description":"MIME type of the file"},"custom_metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Custom Metadata","description":"Optional custom metadata"}},"type":"object","required":["filename","mime_type"],"title":"DocumentUploadRequestSchema","description":"Request schema for initiating document upload."},"DocumentUploadResponseSchema":{"properties":{"upload_url":{"type":"string","title":"Upload Url","description":"Signed URL for uploading the file"},"document_uuid":{"type":"string","title":"Document Uuid","description":"Unique identifier for the document"},"s3_key":{"type":"string","title":"S3 Key","description":"S3 key where file should be uploaded"}},"type":"object","required":["upload_url","document_uuid","s3_key"],"title":"DocumentUploadResponseSchema","description":"Response schema containing upload URL and document metadata."},"DuplicateTemplateRequest":{"properties":{"template_id":{"type":"integer","title":"Template Id"},"workflow_name":{"type":"string","title":"Workflow Name"}},"type":"object","required":["template_id","workflow_name"],"title":"DuplicateTemplateRequest"},"EmbedConfigResponse":{"properties":{"workflow_id":{"type":"integer","title":"Workflow Id"},"settings":{"additionalProperties":true,"type":"object","title":"Settings"},"theme":{"type":"string","title":"Theme"},"position":{"type":"string","title":"Position"},"button_text":{"type":"string","title":"Button Text"},"button_color":{"type":"string","title":"Button Color"},"size":{"type":"string","title":"Size"},"auto_start":{"type":"boolean","title":"Auto Start"}},"type":"object","required":["workflow_id","settings","theme","position","button_text","button_color","size","auto_start"],"title":"EmbedConfigResponse","description":"Response model for embed configuration"},"EmbedTokenRequest":{"properties":{"allowed_domains":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Allowed Domains"},"settings":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Settings"},"usage_limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Usage Limit"},"expires_in_days":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Expires In Days","default":30}},"type":"object","title":"EmbedTokenRequest"},"EmbedTokenResponse":{"properties":{"id":{"type":"integer","title":"Id"},"token":{"type":"string","title":"Token"},"allowed_domains":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Allowed Domains"},"settings":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Settings"},"is_active":{"type":"boolean","title":"Is Active"},"usage_count":{"type":"integer","title":"Usage Count"},"usage_limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Usage Limit"},"expires_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Expires At"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"embed_script":{"type":"string","title":"Embed Script"}},"type":"object","required":["id","token","allowed_domains","settings","is_active","usage_count","usage_limit","expires_at","created_at","embed_script"],"title":"EmbedTokenResponse"},"EndCallConfig":{"properties":{"messageType":{"type":"string","enum":["none","custom","audio"],"title":"Messagetype","description":"Type of goodbye message.","default":"none"},"customMessage":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessage","description":"Custom message to play before ending the call."},"audioRecordingId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Audiorecordingid","description":"Recording ID for audio goodbye message."},"endCallReason":{"type":"boolean","title":"Endcallreason","description":"When enabled, the model must provide a reason for ending the call. The reason is set as call disposition and added to call tags.","default":false},"endCallReasonDescription":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Endcallreasondescription","description":"Description shown to the model for the reason parameter. Used only when endCallReason is enabled."}},"type":"object","title":"EndCallConfig","description":"Configuration for End Call tools."},"EndCallToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version.","default":1},"type":{"type":"string","const":"end_call","title":"Type","description":"Tool type."},"config":{"$ref":"#/components/schemas/EndCallConfig","description":"End Call configuration."}},"type":"object","required":["type","config"],"title":"EndCallToolDefinition","description":"Tool definition for End Call tools."},"FileDescriptor":{"properties":{"filename":{"type":"string","title":"Filename","description":"Original filename of the audio file"},"mime_type":{"type":"string","title":"Mime Type","description":"MIME type of the audio file","default":"audio/wav"},"file_size":{"type":"integer","maximum":5242880.0,"exclusiveMinimum":0.0,"title":"File Size","description":"File size in bytes (max 5MB)"}},"type":"object","required":["filename","file_size"],"title":"FileDescriptor","description":"Descriptor for a single file in a batch upload request."},"FileMetadataResponse":{"properties":{"key":{"type":"string","title":"Key"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["key","metadata"],"title":"FileMetadataResponse"},"FolderResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","name","created_at"],"title":"FolderResponse"},"GraphConstraints":{"properties":{"min_incoming":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Min Incoming"},"max_incoming":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Incoming"},"min_outgoing":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Min Outgoing"},"max_outgoing":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Outgoing"}},"additionalProperties":false,"type":"object","title":"GraphConstraints","description":"Per-node-type graph rules. WorkflowGraph enforces these at validation."},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HealthResponse":{"properties":{"status":{"type":"string","title":"Status"},"version":{"type":"string","title":"Version"},"backend_api_endpoint":{"type":"string","title":"Backend Api Endpoint"},"deployment_mode":{"type":"string","title":"Deployment Mode"},"auth_provider":{"type":"string","title":"Auth Provider"},"turn_enabled":{"type":"boolean","title":"Turn Enabled"},"force_turn_relay":{"type":"boolean","title":"Force Turn Relay"}},"type":"object","required":["status","version","backend_api_endpoint","deployment_mode","auth_provider","turn_enabled","force_turn_relay"],"title":"HealthResponse"},"HttpApiConfig":{"properties":{"method":{"type":"string","enum":["GET","POST","PUT","PATCH","DELETE"],"title":"Method","description":"HTTP method to use for the request.","llm_hint":"Use one of GET, POST, PUT, PATCH, DELETE."},"url":{"type":"string","title":"Url","description":"Target HTTP or HTTPS URL.","llm_hint":"Use the final endpoint URL. Authentication belongs in credential_uuid, not embedded in the URL."},"headers":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Headers","description":"Static headers to include with every request.","llm_hint":"Do not place secrets here. Store secrets in the UI credential manager and reference them with credential_uuid."},"credential_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credential Uuid","description":"Reference to an external credential for request authentication.","llm_hint":"Use a credential_uuid returned by list_credentials. The MCP flow does not create credential secrets."},"parameters":{"anyOf":[{"items":{"$ref":"#/components/schemas/ToolParameter"},"type":"array"},{"type":"null"}],"title":"Parameters","description":"Parameters the model must provide when calling this tool."},"preset_parameters":{"anyOf":[{"items":{"$ref":"#/components/schemas/PresetToolParameter"},"type":"array"},{"type":"null"}],"title":"Preset Parameters","description":"Parameters injected by Dograh from fixed values or workflow context templates."},"timeout_ms":{"anyOf":[{"type":"integer","minimum":1.0},{"type":"null"}],"title":"Timeout Ms","description":"Request timeout in milliseconds.","default":5000},"customMessage":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessage","description":"Custom message to play after tool execution."},"customMessageType":{"anyOf":[{"type":"string","enum":["text","audio"]},{"type":"null"}],"title":"Custommessagetype","description":"Type of custom message."},"customMessageRecordingId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessagerecordingid","description":"Recording ID for an audio custom message."}},"type":"object","required":["method","url"],"title":"HttpApiConfig","description":"Configuration for HTTP API tools."},"HttpApiToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version.","default":1},"type":{"type":"string","const":"http_api","title":"Type","description":"Tool type."},"config":{"$ref":"#/components/schemas/HttpApiConfig","description":"HTTP API configuration."}},"type":"object","required":["type","config"],"title":"HttpApiToolDefinition","description":"Tool definition for HTTP API tools."},"ImpersonateRequest":{"properties":{"provider_user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider User Id"},"user_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"User Id"}},"type":"object","title":"ImpersonateRequest","description":"Request payload for superadmin impersonation.\n\nEither ``provider_user_id`` **or** ``user_id`` must be supplied. If both are\nprovided, ``provider_user_id`` takes precedence."},"ImpersonateResponse":{"properties":{"refresh_token":{"type":"string","title":"Refresh Token"},"access_token":{"type":"string","title":"Access Token"}},"type":"object","required":["refresh_token","access_token"],"title":"ImpersonateResponse"},"InitEmbedRequest":{"properties":{"token":{"type":"string","title":"Token"},"context_variables":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Context Variables"}},"type":"object","required":["token"],"title":"InitEmbedRequest","description":"Request model for initializing an embed session"},"InitEmbedResponse":{"properties":{"session_token":{"type":"string","title":"Session Token"},"workflow_run_id":{"type":"integer","title":"Workflow Run Id"},"config":{"additionalProperties":true,"type":"object","title":"Config"}},"type":"object","required":["session_token","workflow_run_id","config"],"title":"InitEmbedResponse","description":"Response model for embed initialization"},"InitiateCallRequest":{"properties":{"workflow_id":{"type":"integer","title":"Workflow Id"},"workflow_run_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Workflow Run Id"},"phone_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone Number"},"telephony_configuration_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Telephony Configuration Id"},"from_phone_number_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"From Phone Number Id"}},"type":"object","required":["workflow_id"],"title":"InitiateCallRequest"},"ItemKind":{"type":"string","enum":["node","edge","workflow"],"title":"ItemKind"},"LangfuseCredentialsRequest":{"properties":{"host":{"type":"string","title":"Host"},"public_key":{"type":"string","title":"Public Key"},"secret_key":{"type":"string","title":"Secret Key"}},"type":"object","required":["host","public_key","secret_key"],"title":"LangfuseCredentialsRequest"},"LangfuseCredentialsResponse":{"properties":{"host":{"type":"string","title":"Host","default":""},"public_key":{"type":"string","title":"Public Key","default":""},"secret_key":{"type":"string","title":"Secret Key","default":""},"configured":{"type":"boolean","title":"Configured","default":false}},"type":"object","title":"LangfuseCredentialsResponse"},"LastCampaignSettingsResponse":{"properties":{"retry_config":{"anyOf":[{"$ref":"#/components/schemas/RetryConfigResponse"},{"type":"null"}]},"max_concurrency":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Concurrency"},"schedule_config":{"anyOf":[{"$ref":"#/components/schemas/ScheduleConfigResponse"},{"type":"null"}]},"circuit_breaker":{"anyOf":[{"$ref":"#/components/schemas/CircuitBreakerConfigResponse"},{"type":"null"}]}},"type":"object","title":"LastCampaignSettingsResponse"},"LoginRequest":{"properties":{"email":{"type":"string","format":"email","title":"Email"},"password":{"type":"string","title":"Password"}},"type":"object","required":["email","password"],"title":"LoginRequest"},"MPSCreditsResponse":{"properties":{"total_credits_used":{"type":"number","title":"Total Credits Used"},"remaining_credits":{"type":"number","title":"Remaining Credits"},"total_quota":{"type":"number","title":"Total Quota"}},"type":"object","required":["total_credits_used","remaining_credits","total_quota"],"title":"MPSCreditsResponse"},"McpRefreshResponse":{"properties":{"tool_uuid":{"type":"string","title":"Tool Uuid"},"discovered_tools":{"items":{},"type":"array","title":"Discovered Tools"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["tool_uuid"],"title":"McpRefreshResponse","description":"Result of re-discovering an MCP server's tool catalog."},"McpToolConfig":{"properties":{"transport":{"type":"string","const":"streamable_http","title":"Transport","description":"MCP transport protocol.","default":"streamable_http"},"url":{"type":"string","title":"Url","description":"MCP server URL. Must use http:// or https://.","llm_hint":"Use the server's streamable HTTP MCP endpoint."},"credential_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credential Uuid","description":"Reference to an external credential for MCP server auth.","llm_hint":"Use a credential_uuid returned by list_credentials. Credentials are created by the user in the UI."},"tools_filter":{"items":{"type":"string"},"type":"array","title":"Tools Filter","description":"Allowlist of MCP tool names to expose. Empty exposes all tools.","llm_hint":"Use exact MCP tool names from the remote server catalog when you need to restrict the exposed tools."},"timeout_secs":{"type":"integer","minimum":0.0,"title":"Timeout Secs","description":"Connection timeout in seconds.","default":30},"sse_read_timeout_secs":{"type":"integer","minimum":0.0,"title":"Sse Read Timeout Secs","description":"SSE read timeout in seconds.","default":300},"discovered_tools":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Discovered Tools","description":"Server-managed cache of the MCP server's tool catalog [{name, description}]. Populated best-effort by the backend.","llm_hint":"Do not author this field; the server fills it."}},"type":"object","required":["url"],"title":"McpToolConfig","description":"Configuration for a customer MCP server tool definition."},"McpToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version.","default":1},"type":{"type":"string","const":"mcp","title":"Type","description":"Tool type."},"config":{"$ref":"#/components/schemas/McpToolConfig","description":"MCP server configuration."}},"type":"object","required":["type","config"],"title":"McpToolDefinition","description":"Persisted MCP tool definition."},"MoveWorkflowToFolderRequest":{"properties":{"folder_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Folder Id"}},"type":"object","title":"MoveWorkflowToFolderRequest","description":"Move a workflow into a folder, or to \"Uncategorized\" when null."},"NodeCategory":{"type":"string","enum":["call_node","global_node","trigger","integration"],"title":"NodeCategory","description":"Drives grouping in the AddNodePanel UI."},"NodeExample":{"properties":{"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"data":{"additionalProperties":true,"type":"object","title":"Data"}},"additionalProperties":false,"type":"object","required":["name","data"],"title":"NodeExample","description":"A worked example LLMs can pattern-match. Keep small and realistic."},"NodeSpec":{"properties":{"name":{"type":"string","title":"Name"},"display_name":{"type":"string","title":"Display Name"},"description":{"type":"string","minLength":1,"title":"Description","description":"Human-facing explanation shown in AddNodePanel."},"llm_hint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Llm Hint","description":"LLM-only guidance; omitted from the UI."},"category":{"$ref":"#/components/schemas/NodeCategory"},"icon":{"type":"string","title":"Icon"},"version":{"type":"string","title":"Version","default":"1.0.0"},"properties":{"items":{"$ref":"#/components/schemas/PropertySpec"},"type":"array","title":"Properties"},"examples":{"items":{"$ref":"#/components/schemas/NodeExample"},"type":"array","title":"Examples"},"graph_constraints":{"anyOf":[{"$ref":"#/components/schemas/GraphConstraints"},{"type":"null"}]}},"additionalProperties":false,"type":"object","required":["name","display_name","description","category","icon","properties"],"title":"NodeSpec","description":"Single source of truth for a node type."},"NodeTypesResponse":{"properties":{"spec_version":{"type":"string","title":"Spec Version"},"node_types":{"items":{"$ref":"#/components/schemas/NodeSpec"},"type":"array","title":"Node Types"}},"type":"object","required":["spec_version","node_types"],"title":"NodeTypesResponse"},"PhoneNumberCreateRequest":{"properties":{"address":{"type":"string","maxLength":255,"minLength":1,"title":"Address"},"country_code":{"anyOf":[{"type":"string","maxLength":2,"minLength":2},{"type":"null"}],"title":"Country Code"},"label":{"anyOf":[{"type":"string","maxLength":64},{"type":"null"}],"title":"Label"},"inbound_workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Inbound Workflow Id"},"is_active":{"type":"boolean","title":"Is Active","default":true},"is_default_caller_id":{"type":"boolean","title":"Is Default Caller Id","default":false},"extra_metadata":{"additionalProperties":true,"type":"object","title":"Extra Metadata"}},"type":"object","required":["address"],"title":"PhoneNumberCreateRequest","description":"Create a new phone number under a telephony configuration.\n\n``address_normalized`` and ``address_type`` are computed server-side from\n``address`` (and ``country_code`` if PSTN). ``address`` itself is stored\nverbatim for display."},"PhoneNumberListResponse":{"properties":{"phone_numbers":{"items":{"$ref":"#/components/schemas/PhoneNumberResponse"},"type":"array","title":"Phone Numbers"}},"type":"object","required":["phone_numbers"],"title":"PhoneNumberListResponse"},"PhoneNumberResponse":{"properties":{"id":{"type":"integer","title":"Id"},"telephony_configuration_id":{"type":"integer","title":"Telephony Configuration Id"},"address":{"type":"string","title":"Address"},"address_normalized":{"type":"string","title":"Address Normalized"},"address_type":{"type":"string","title":"Address Type"},"country_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Country Code"},"label":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Label"},"inbound_workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Inbound Workflow Id"},"inbound_workflow_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Inbound Workflow Name"},"is_active":{"type":"boolean","title":"Is Active"},"is_default_caller_id":{"type":"boolean","title":"Is Default Caller Id"},"extra_metadata":{"additionalProperties":true,"type":"object","title":"Extra Metadata"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"provider_sync":{"anyOf":[{"$ref":"#/components/schemas/ProviderSyncStatus"},{"type":"null"}]}},"type":"object","required":["id","telephony_configuration_id","address","address_normalized","address_type","is_active","is_default_caller_id","extra_metadata","created_at","updated_at"],"title":"PhoneNumberResponse"},"PhoneNumberUpdateRequest":{"properties":{"label":{"anyOf":[{"type":"string","maxLength":64},{"type":"null"}],"title":"Label"},"inbound_workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Inbound Workflow Id"},"clear_inbound_workflow":{"type":"boolean","title":"Clear Inbound Workflow","default":false},"is_active":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Active"},"country_code":{"anyOf":[{"type":"string","maxLength":2,"minLength":2},{"type":"null"}],"title":"Country Code"},"extra_metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Extra Metadata"}},"type":"object","title":"PhoneNumberUpdateRequest","description":"Partial update. ``address`` is intentionally immutable \u2014 to change a\nnumber, delete the row and create a new one."},"PlivoConfigurationRequest":{"properties":{"provider":{"type":"string","const":"plivo","title":"Provider","default":"plivo"},"auth_id":{"type":"string","title":"Auth Id","description":"Plivo Auth ID"},"auth_token":{"type":"string","title":"Auth Token","description":"Plivo Auth Token"},"application_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Id","description":"Plivo Application ID. The application's answer_url is updated when inbound workflows are attached to numbers on this account. If omitted, an application is auto-created on save and its id is stored on the configuration."},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Plivo phone numbers"}},"type":"object","required":["auth_id","auth_token"],"title":"PlivoConfigurationRequest","description":"Request schema for Plivo configuration."},"PlivoConfigurationResponse":{"properties":{"provider":{"type":"string","const":"plivo","title":"Provider","default":"plivo"},"auth_id":{"type":"string","title":"Auth Id"},"auth_token":{"type":"string","title":"Auth Token"},"application_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Id"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["auth_id","auth_token","from_numbers"],"title":"PlivoConfigurationResponse","description":"Response schema for Plivo configuration with masked sensitive fields."},"PresetToolParameter":{"properties":{"name":{"type":"string","title":"Name","description":"Parameter name used as a key in the request body."},"type":{"type":"string","enum":["string","number","boolean","object","array"],"title":"Type","description":"JSON type for the resolved value.","llm_hint":"Allowed values are string, number, boolean, object, and array."},"value_template":{"type":"string","title":"Value Template","description":"Fixed value or template, e.g. {{initial_context.phone_number}}.","llm_hint":"Use {{initial_context.*}} for call-start context and {{gathered_context.*}} for values extracted during the call."},"required":{"type":"boolean","title":"Required","description":"Whether the parameter must resolve to a non-empty value.","default":true}},"type":"object","required":["name","type","value_template"],"title":"PresetToolParameter","description":"A parameter injected by Dograh at runtime."},"PresignedUploadUrlRequest":{"properties":{"file_name":{"type":"string","pattern":".*\\.csv$","title":"File Name","description":"CSV filename"},"file_size":{"type":"integer","maximum":10485760.0,"exclusiveMinimum":0.0,"title":"File Size","description":"File size in bytes (max 10MB)"},"content_type":{"type":"string","title":"Content Type","description":"File content type","default":"text/csv"}},"type":"object","required":["file_name","file_size"],"title":"PresignedUploadUrlRequest"},"PresignedUploadUrlResponse":{"properties":{"upload_url":{"type":"string","title":"Upload Url"},"file_key":{"type":"string","title":"File Key"},"expires_in":{"type":"integer","title":"Expires In"}},"type":"object","required":["upload_url","file_key","expires_in"],"title":"PresignedUploadUrlResponse"},"ProcessDocumentRequestSchema":{"properties":{"document_uuid":{"type":"string","title":"Document Uuid","description":"Document UUID to process"},"s3_key":{"type":"string","title":"S3 Key","description":"S3 key of the uploaded file"},"retrieval_mode":{"type":"string","title":"Retrieval Mode","description":"Retrieval mode: 'chunked' for vector search or 'full_document' for full text retrieval","default":"chunked"}},"type":"object","required":["document_uuid","s3_key"],"title":"ProcessDocumentRequestSchema","description":"Request schema for triggering document processing."},"PropertyOption":{"properties":{"value":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"boolean"},{"type":"number"}],"title":"Value"},"label":{"type":"string","title":"Label"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},"additionalProperties":false,"type":"object","required":["value","label"],"title":"PropertyOption","description":"An option in an `options` or `multi_options` dropdown."},"PropertySpec":{"properties":{"name":{"type":"string","title":"Name"},"type":{"$ref":"#/components/schemas/PropertyType"},"display_name":{"type":"string","title":"Display Name"},"description":{"type":"string","minLength":1,"title":"Description","description":"Human-facing explanation shown in the UI."},"llm_hint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Llm Hint","description":"LLM-only guidance; omitted from the UI."},"default":{"title":"Default"},"required":{"type":"boolean","title":"Required","default":false},"placeholder":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Placeholder"},"display_options":{"anyOf":[{"$ref":"#/components/schemas/DisplayOptions"},{"type":"null"}]},"options":{"anyOf":[{"items":{"$ref":"#/components/schemas/PropertyOption"},"type":"array"},{"type":"null"}],"title":"Options"},"properties":{"anyOf":[{"items":{"$ref":"#/components/schemas/PropertySpec"},"type":"array"},{"type":"null"}],"title":"Properties"},"min_value":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Min Value"},"max_value":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Max Value"},"min_length":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Min Length"},"max_length":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Length"},"pattern":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Pattern"},"editor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Editor"},"extra":{"additionalProperties":true,"type":"object","title":"Extra"}},"additionalProperties":false,"type":"object","required":["name","type","display_name","description"],"title":"PropertySpec","description":"Single field on a node.\n\n`description` is HUMAN-FACING \u2014 shown under the field in the edit\ndialog. Keep it concise and explain what the field does.\n\n`llm_hint` is LLM-FACING \u2014 appears only in the `get_node_type` MCP\nresponse and in SDK schema output. Use it for catalog tool references\n(e.g., \"Use `list_recordings`\"), array shape, expected value idioms,\nor anything that would be noise in the UI. Optional; omit when the\n`description` already suffices for both audiences."},"PropertyType":{"type":"string","enum":["string","number","boolean","options","multi_options","fixed_collection","json","tool_refs","document_refs","recording_ref","credential_ref","mention_textarea","url"],"title":"PropertyType","description":"Bounded vocabulary of property types the renderer dispatches on.\n\nAdding a value here requires a matching arm in the frontend\n`` switch and (where relevant) the SDK codegen template."},"ProviderSyncStatus":{"properties":{"ok":{"type":"boolean","title":"Ok"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"}},"type":"object","required":["ok"],"title":"ProviderSyncStatus","description":"Result of pushing a phone-number change to the upstream provider.\n\nReturned alongside create/update responses when the route attempted to\nsync inbound webhook configuration. ``ok=False`` is a warning, not a\nfatal error \u2014 the DB write succeeded."},"RecordingCreateRequestSchema":{"properties":{"recording_id":{"type":"string","title":"Recording Id","description":"Short recording ID from upload step"},"tts_provider":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Provider","description":"TTS provider (e.g. elevenlabs)"},"tts_model":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Model","description":"TTS model name"},"tts_voice_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Voice Id","description":"TTS voice identifier"},"transcript":{"type":"string","title":"Transcript","description":"User-provided transcript of the recording"},"storage_key":{"type":"string","title":"Storage Key","description":"Storage key from upload step"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata","description":"Optional metadata (file_size, duration, etc.)"}},"type":"object","required":["recording_id","transcript","storage_key"],"title":"RecordingCreateRequestSchema","description":"Request schema for creating a recording record after upload."},"RecordingListResponseSchema":{"properties":{"recordings":{"items":{"$ref":"#/components/schemas/RecordingResponseSchema"},"type":"array","title":"Recordings"},"total":{"type":"integer","title":"Total"}},"type":"object","required":["recordings","total"],"title":"RecordingListResponseSchema","description":"Response schema for list of recordings."},"RecordingResponseSchema":{"properties":{"id":{"type":"integer","title":"Id"},"recording_id":{"type":"string","title":"Recording Id"},"workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Workflow Id"},"organization_id":{"type":"integer","title":"Organization Id"},"tts_provider":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Provider"},"tts_model":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Model"},"tts_voice_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Voice Id"},"transcript":{"type":"string","title":"Transcript"},"storage_key":{"type":"string","title":"Storage Key"},"storage_backend":{"type":"string","title":"Storage Backend"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"},"created_by":{"type":"integer","title":"Created By"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"is_active":{"type":"boolean","title":"Is Active"}},"type":"object","required":["id","recording_id","organization_id","transcript","storage_key","storage_backend","metadata","created_by","created_at","is_active"],"title":"RecordingResponseSchema","description":"Response schema for a single recording."},"RecordingUpdateRequestSchema":{"properties":{"recording_id":{"type":"string","maxLength":64,"minLength":1,"pattern":"^[a-zA-Z0-9_-]+$","title":"Recording Id","description":"New descriptive recording ID (letters, numbers, hyphens, underscores only)"}},"type":"object","required":["recording_id"],"title":"RecordingUpdateRequestSchema","description":"Request schema for updating a recording's ID."},"RecordingUploadResponseSchema":{"properties":{"upload_url":{"type":"string","title":"Upload Url","description":"Presigned URL for uploading the audio"},"recording_id":{"type":"string","title":"Recording Id","description":"Short unique recording ID"},"storage_key":{"type":"string","title":"Storage Key","description":"Storage key where file will be uploaded"}},"type":"object","required":["upload_url","recording_id","storage_key"],"title":"RecordingUploadResponseSchema","description":"Response schema with presigned upload URL."},"RedialCampaignRequest":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":255,"minLength":1},{"type":"null"}],"title":"Name","description":"Name for the redial campaign"},"retry_on_voicemail":{"type":"boolean","title":"Retry On Voicemail","default":true},"retry_on_no_answer":{"type":"boolean","title":"Retry On No Answer","default":true},"retry_on_busy":{"type":"boolean","title":"Retry On Busy","default":true},"retry_config":{"anyOf":[{"$ref":"#/components/schemas/RetryConfigRequest"},{"type":"null"}]}},"type":"object","title":"RedialCampaignRequest"},"RetryConfigRequest":{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":true},"max_retries":{"type":"integer","maximum":10.0,"minimum":0.0,"title":"Max Retries","default":2},"retry_delay_seconds":{"type":"integer","maximum":3600.0,"minimum":30.0,"title":"Retry Delay Seconds","default":120},"retry_on_busy":{"type":"boolean","title":"Retry On Busy","default":true},"retry_on_no_answer":{"type":"boolean","title":"Retry On No Answer","default":true},"retry_on_voicemail":{"type":"boolean","title":"Retry On Voicemail","default":true}},"type":"object","title":"RetryConfigRequest"},"RetryConfigResponse":{"properties":{"enabled":{"type":"boolean","title":"Enabled"},"max_retries":{"type":"integer","title":"Max Retries"},"retry_delay_seconds":{"type":"integer","title":"Retry Delay Seconds"},"retry_on_busy":{"type":"boolean","title":"Retry On Busy"},"retry_on_no_answer":{"type":"boolean","title":"Retry On No Answer"},"retry_on_voicemail":{"type":"boolean","title":"Retry On Voicemail"}},"type":"object","required":["enabled","max_retries","retry_delay_seconds","retry_on_busy","retry_on_no_answer","retry_on_voicemail"],"title":"RetryConfigResponse"},"RewindTextChatSessionRequest":{"properties":{"cursor_turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor Turn Id"},"expected_revision":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Expected Revision"}},"type":"object","title":"RewindTextChatSessionRequest"},"S3SignedUrlResponse":{"properties":{"url":{"type":"string","title":"Url"},"expires_in":{"type":"integer","title":"Expires In"}},"type":"object","required":["url","expires_in"],"title":"S3SignedUrlResponse"},"ScheduleConfigRequest":{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":true},"timezone":{"type":"string","title":"Timezone","default":"UTC"},"slots":{"items":{"$ref":"#/components/schemas/TimeSlotRequest"},"type":"array","maxItems":50,"minItems":1,"title":"Slots"}},"type":"object","required":["slots"],"title":"ScheduleConfigRequest"},"ScheduleConfigResponse":{"properties":{"enabled":{"type":"boolean","title":"Enabled"},"timezone":{"type":"string","title":"Timezone"},"slots":{"items":{"$ref":"#/components/schemas/TimeSlotResponse"},"type":"array","title":"Slots"}},"type":"object","required":["enabled","timezone","slots"],"title":"ScheduleConfigResponse"},"ServiceKeyResponse":{"properties":{"name":{"type":"string","title":"Name"},"id":{"type":"integer","title":"Id"},"key_prefix":{"type":"string","title":"Key Prefix"},"is_active":{"type":"boolean","title":"Is Active"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"last_used_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Used At"},"expires_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Expires At"},"archived_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Archived At"},"created_by":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created By"}},"type":"object","required":["name","id","key_prefix","is_active","created_at"],"title":"ServiceKeyResponse"},"SignupRequest":{"properties":{"email":{"type":"string","format":"email","title":"Email"},"password":{"type":"string","title":"Password"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},"type":"object","required":["email","password"],"title":"SignupRequest"},"SuperuserWorkflowRunResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"workflow_id":{"type":"integer","title":"Workflow Id"},"workflow_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Name"},"user_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"User Id"},"organization_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Organization Id"},"organization_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Organization Name"},"mode":{"type":"string","title":"Mode"},"is_completed":{"type":"boolean","title":"Is Completed"},"recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Url"},"transcript_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Url"},"usage_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Usage Info"},"cost_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Cost Info"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"gathered_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Gathered Context"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","name","workflow_id","workflow_name","user_id","organization_id","organization_name","mode","is_completed","recording_url","transcript_url","usage_info","cost_info","initial_context","gathered_context","created_at"],"title":"SuperuserWorkflowRunResponse"},"SuperuserWorkflowRunsListResponse":{"properties":{"workflow_runs":{"items":{"$ref":"#/components/schemas/SuperuserWorkflowRunResponse"},"type":"array","title":"Workflow Runs"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"limit":{"type":"integer","title":"Limit"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["workflow_runs","total_count","page","limit","total_pages"],"title":"SuperuserWorkflowRunsListResponse"},"TelephonyConfigWarningsResponse":{"properties":{"telnyx_missing_webhook_public_key_count":{"type":"integer","title":"Telnyx Missing Webhook Public Key Count"}},"type":"object","required":["telnyx_missing_webhook_public_key_count"],"title":"TelephonyConfigWarningsResponse","description":"Aggregated telephony-configuration warning counts for the user's org.\n\nDrives the page banner and nav badge that nudge customers to finish\noptional-but-recommended configuration steps. Shape is a flat dict so\nnew warning types can be added without breaking the client."},"TelephonyConfigurationCreateRequest":{"properties":{"name":{"type":"string","maxLength":64,"minLength":1,"title":"Name"},"is_default_outbound":{"type":"boolean","title":"Is Default Outbound","default":false},"config":{"oneOf":[{"$ref":"#/components/schemas/ARIConfigurationRequest"},{"$ref":"#/components/schemas/CloudonixConfigurationRequest"},{"$ref":"#/components/schemas/PlivoConfigurationRequest"},{"$ref":"#/components/schemas/TelnyxConfigurationRequest"},{"$ref":"#/components/schemas/TwilioConfigurationRequest"},{"$ref":"#/components/schemas/VobizConfigurationRequest"},{"$ref":"#/components/schemas/VonageConfigurationRequest"}],"title":"Config","discriminator":{"propertyName":"provider","mapping":{"ari":"#/components/schemas/ARIConfigurationRequest","cloudonix":"#/components/schemas/CloudonixConfigurationRequest","plivo":"#/components/schemas/PlivoConfigurationRequest","telnyx":"#/components/schemas/TelnyxConfigurationRequest","twilio":"#/components/schemas/TwilioConfigurationRequest","vobiz":"#/components/schemas/VobizConfigurationRequest","vonage":"#/components/schemas/VonageConfigurationRequest"}}}},"type":"object","required":["name","config"],"title":"TelephonyConfigurationCreateRequest","description":"Body for ``POST /telephony-configs``.\n\n``config`` carries the provider-specific credential fields (the same\ndiscriminated union used by the legacy single-config endpoint). Any\n``from_numbers`` on the inner config are ignored \u2014 phone numbers are\nmanaged via the dedicated phone-numbers endpoints."},"TelephonyConfigurationDetail":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"provider":{"type":"string","title":"Provider"},"is_default_outbound":{"type":"boolean","title":"Is Default Outbound"},"credentials":{"additionalProperties":true,"type":"object","title":"Credentials"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","name","provider","is_default_outbound","credentials","created_at","updated_at"],"title":"TelephonyConfigurationDetail","description":"Body of ``GET /telephony-configs/{id}`` \u2014 credentials are masked."},"TelephonyConfigurationListItem":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"provider":{"type":"string","title":"Provider"},"is_default_outbound":{"type":"boolean","title":"Is Default Outbound"},"phone_number_count":{"type":"integer","title":"Phone Number Count","default":0},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","name","provider","is_default_outbound","created_at","updated_at"],"title":"TelephonyConfigurationListItem","description":"One row in ``GET /telephony-configs``."},"TelephonyConfigurationListResponse":{"properties":{"configurations":{"items":{"$ref":"#/components/schemas/TelephonyConfigurationListItem"},"type":"array","title":"Configurations"}},"type":"object","required":["configurations"],"title":"TelephonyConfigurationListResponse"},"TelephonyConfigurationResponse":{"properties":{"twilio":{"anyOf":[{"$ref":"#/components/schemas/TwilioConfigurationResponse"},{"type":"null"}]},"plivo":{"anyOf":[{"$ref":"#/components/schemas/PlivoConfigurationResponse"},{"type":"null"}]},"vonage":{"anyOf":[{"$ref":"#/components/schemas/VonageConfigurationResponse"},{"type":"null"}]},"vobiz":{"anyOf":[{"$ref":"#/components/schemas/VobizConfigurationResponse"},{"type":"null"}]},"cloudonix":{"anyOf":[{"$ref":"#/components/schemas/CloudonixConfigurationResponse"},{"type":"null"}]},"ari":{"anyOf":[{"$ref":"#/components/schemas/ARIConfigurationResponse"},{"type":"null"}]},"telnyx":{"anyOf":[{"$ref":"#/components/schemas/TelnyxConfigurationResponse"},{"type":"null"}]}},"type":"object","title":"TelephonyConfigurationResponse","description":"Top-level telephony configuration response.\n\nKeeps the per-provider field shape that the UI client depends on. When\nthe UI moves to metadata-driven forms, this can be replaced with a\nflat discriminated union."},"TelephonyConfigurationUpdateRequest":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":64,"minLength":1},{"type":"null"}],"title":"Name"},"config":{"anyOf":[{"oneOf":[{"$ref":"#/components/schemas/ARIConfigurationRequest"},{"$ref":"#/components/schemas/CloudonixConfigurationRequest"},{"$ref":"#/components/schemas/PlivoConfigurationRequest"},{"$ref":"#/components/schemas/TelnyxConfigurationRequest"},{"$ref":"#/components/schemas/TwilioConfigurationRequest"},{"$ref":"#/components/schemas/VobizConfigurationRequest"},{"$ref":"#/components/schemas/VonageConfigurationRequest"}],"discriminator":{"propertyName":"provider","mapping":{"ari":"#/components/schemas/ARIConfigurationRequest","cloudonix":"#/components/schemas/CloudonixConfigurationRequest","plivo":"#/components/schemas/PlivoConfigurationRequest","telnyx":"#/components/schemas/TelnyxConfigurationRequest","twilio":"#/components/schemas/TwilioConfigurationRequest","vobiz":"#/components/schemas/VobizConfigurationRequest","vonage":"#/components/schemas/VonageConfigurationRequest"}}},{"type":"null"}],"title":"Config"}},"type":"object","title":"TelephonyConfigurationUpdateRequest","description":"Body for ``PUT /telephony-configs/{id}``. Partial update."},"TelephonyProviderMetadata":{"properties":{"provider":{"type":"string","title":"Provider"},"display_name":{"type":"string","title":"Display Name"},"fields":{"items":{"$ref":"#/components/schemas/TelephonyProviderUIField"},"type":"array","title":"Fields"},"docs_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Docs Url"}},"type":"object","required":["provider","display_name","fields"],"title":"TelephonyProviderMetadata","description":"UI form metadata for a single telephony provider."},"TelephonyProviderUIField":{"properties":{"name":{"type":"string","title":"Name"},"label":{"type":"string","title":"Label"},"type":{"type":"string","title":"Type"},"required":{"type":"boolean","title":"Required"},"sensitive":{"type":"boolean","title":"Sensitive"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"placeholder":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Placeholder"}},"type":"object","required":["name","label","type","required","sensitive"],"title":"TelephonyProviderUIField","description":"One form field on a telephony provider's configuration UI."},"TelephonyProvidersMetadataResponse":{"properties":{"providers":{"items":{"$ref":"#/components/schemas/TelephonyProviderMetadata"},"type":"array","title":"Providers"}},"type":"object","required":["providers"],"title":"TelephonyProvidersMetadataResponse","description":"List of UI form definitions used by the telephony-config screen."},"TelnyxConfigurationRequest":{"properties":{"provider":{"type":"string","const":"telnyx","title":"Provider","default":"telnyx"},"api_key":{"type":"string","title":"Api Key","description":"Telnyx API Key"},"connection_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connection Id","description":"Telnyx Call Control Application ID (connection_id). If omitted, a Call Control Application is auto-created on save and its id is stored on the configuration."},"webhook_public_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Webhook Public Key","description":"Webhook public key from Mission Control Portal \u2192 Keys & Credentials \u2192 Public Key. Used to verify Telnyx webhook signatures."},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Telnyx phone numbers"}},"type":"object","required":["api_key"],"title":"TelnyxConfigurationRequest","description":"Request schema for Telnyx configuration."},"TelnyxConfigurationResponse":{"properties":{"provider":{"type":"string","const":"telnyx","title":"Provider","default":"telnyx"},"api_key":{"type":"string","title":"Api Key"},"connection_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connection Id"},"webhook_public_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Webhook Public Key"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["api_key","from_numbers"],"title":"TelnyxConfigurationResponse","description":"Response schema for Telnyx configuration with masked sensitive fields."},"TimeSlotRequest":{"properties":{"day_of_week":{"type":"integer","maximum":6.0,"minimum":0.0,"title":"Day Of Week"},"start_time":{"type":"string","pattern":"^\\d{2}:\\d{2}$","title":"Start Time"},"end_time":{"type":"string","pattern":"^\\d{2}:\\d{2}$","title":"End Time"}},"type":"object","required":["day_of_week","start_time","end_time"],"title":"TimeSlotRequest"},"TimeSlotResponse":{"properties":{"day_of_week":{"type":"integer","title":"Day Of Week"},"start_time":{"type":"string","title":"Start Time"},"end_time":{"type":"string","title":"End Time"}},"type":"object","required":["day_of_week","start_time","end_time"],"title":"TimeSlotResponse"},"ToolParameter":{"properties":{"name":{"type":"string","title":"Name","description":"Parameter name used as a key in the tool request body.","llm_hint":"Use a stable snake_case name the agent can naturally fill."},"type":{"type":"string","enum":["string","number","boolean","object","array"],"title":"Type","description":"JSON type for the parameter value.","llm_hint":"Allowed values are string, number, boolean, object, and array."},"description":{"type":"string","title":"Description","description":"Description shown to the model for this parameter.","llm_hint":"Write this as an instruction to the agent: what value to provide and when."},"required":{"type":"boolean","title":"Required","description":"Whether this parameter is required when the tool is called.","default":true}},"type":"object","required":["name","type","description"],"title":"ToolParameter","description":"A parameter that the tool accepts from the model at call time."},"ToolResponse":{"properties":{"id":{"type":"integer","title":"Id"},"tool_uuid":{"type":"string","title":"Tool Uuid"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"category":{"type":"string","title":"Category"},"icon":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Icon"},"icon_color":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Icon Color"},"status":{"type":"string","title":"Status"},"definition":{"additionalProperties":true,"type":"object","title":"Definition"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"created_by":{"anyOf":[{"$ref":"#/components/schemas/CreatedByResponse"},{"type":"null"}]}},"type":"object","required":["id","tool_uuid","name","description","category","icon","icon_color","status","definition","created_at","updated_at"],"title":"ToolResponse","description":"Response schema for a reusable tool."},"TransferCallConfig":{"properties":{"destination":{"type":"string","title":"Destination","description":"Phone number or SIP endpoint to transfer the call to, e.g. +1234567890 or PJSIP/1234."},"messageType":{"type":"string","enum":["none","custom","audio"],"title":"Messagetype","description":"Type of message to play before transfer.","default":"none"},"customMessage":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessage","description":"Custom message to play before transferring."},"audioRecordingId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Audiorecordingid","description":"Recording ID for audio message before transfer."},"timeout":{"type":"integer","maximum":120.0,"minimum":5.0,"title":"Timeout","description":"Maximum seconds to wait for the destination to answer.","default":30}},"type":"object","required":["destination"],"title":"TransferCallConfig","description":"Configuration for Transfer Call tools."},"TransferCallToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version.","default":1},"type":{"type":"string","const":"transfer_call","title":"Type","description":"Tool type."},"config":{"$ref":"#/components/schemas/TransferCallConfig","description":"Transfer Call configuration."}},"type":"object","required":["type","config"],"title":"TransferCallToolDefinition","description":"Tool definition for Transfer Call tools."},"TriggerCallRequest":{"properties":{"phone_number":{"type":"string","title":"Phone Number"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"telephony_configuration_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Telephony Configuration Id"}},"type":"object","required":["phone_number"],"title":"TriggerCallRequest","description":"Request model for triggering a call via API"},"TriggerCallResponse":{"properties":{"status":{"type":"string","title":"Status"},"workflow_run_id":{"type":"integer","title":"Workflow Run Id"},"workflow_run_name":{"type":"string","title":"Workflow Run Name"}},"type":"object","required":["status","workflow_run_id","workflow_run_name"],"title":"TriggerCallResponse","description":"Response model for successful call initiation"},"TurnCredentialsResponse":{"properties":{"username":{"type":"string","title":"Username"},"password":{"type":"string","title":"Password"},"ttl":{"type":"integer","title":"Ttl"},"uris":{"items":{"type":"string"},"type":"array","title":"Uris"}},"type":"object","required":["username","password","ttl","uris"],"title":"TurnCredentialsResponse","description":"Response model for TURN credentials."},"TwilioConfigurationRequest":{"properties":{"provider":{"type":"string","const":"twilio","title":"Provider","default":"twilio"},"account_sid":{"type":"string","title":"Account Sid","description":"Twilio Account SID"},"auth_token":{"type":"string","title":"Auth Token","description":"Twilio Auth Token"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Twilio phone numbers"}},"type":"object","required":["account_sid","auth_token"],"title":"TwilioConfigurationRequest","description":"Request schema for Twilio configuration."},"TwilioConfigurationResponse":{"properties":{"provider":{"type":"string","const":"twilio","title":"Provider","default":"twilio"},"account_sid":{"type":"string","title":"Account Sid"},"auth_token":{"type":"string","title":"Auth Token"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["account_sid","auth_token","from_numbers"],"title":"TwilioConfigurationResponse","description":"Response schema for Twilio configuration with masked sensitive fields."},"UpdateCampaignRequest":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":255,"minLength":1},{"type":"null"}],"title":"Name"},"retry_config":{"anyOf":[{"$ref":"#/components/schemas/RetryConfigRequest"},{"type":"null"}]},"max_concurrency":{"anyOf":[{"type":"integer","maximum":100.0,"minimum":1.0},{"type":"null"}],"title":"Max Concurrency"},"schedule_config":{"anyOf":[{"$ref":"#/components/schemas/ScheduleConfigRequest"},{"type":"null"}]},"circuit_breaker":{"anyOf":[{"$ref":"#/components/schemas/CircuitBreakerConfigRequest"},{"type":"null"}]}},"type":"object","title":"UpdateCampaignRequest"},"UpdateCredentialRequest":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"credential_type":{"anyOf":[{"$ref":"#/components/schemas/WebhookCredentialType"},{"type":"null"}]},"credential_data":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Credential Data"}},"type":"object","title":"UpdateCredentialRequest","description":"Request schema for updating a webhook credential."},"UpdateFolderRequest":{"properties":{"name":{"type":"string","maxLength":100,"minLength":1,"title":"Name"}},"type":"object","required":["name"],"title":"UpdateFolderRequest"},"UpdateToolRequest":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":255},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"icon":{"anyOf":[{"type":"string","maxLength":50},{"type":"null"}],"title":"Icon"},"icon_color":{"anyOf":[{"type":"string","maxLength":7},{"type":"null"}],"title":"Icon Color"},"definition":{"anyOf":[{"oneOf":[{"$ref":"#/components/schemas/HttpApiToolDefinition"},{"$ref":"#/components/schemas/EndCallToolDefinition"},{"$ref":"#/components/schemas/TransferCallToolDefinition"},{"$ref":"#/components/schemas/CalculatorToolDefinition"},{"$ref":"#/components/schemas/McpToolDefinition"}],"discriminator":{"propertyName":"type","mapping":{"calculator":"#/components/schemas/CalculatorToolDefinition","end_call":"#/components/schemas/EndCallToolDefinition","http_api":"#/components/schemas/HttpApiToolDefinition","mcp":"#/components/schemas/McpToolDefinition","transfer_call":"#/components/schemas/TransferCallToolDefinition"}}},{"type":"null"}],"title":"Definition"},"status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status"}},"type":"object","title":"UpdateToolRequest","description":"Request schema for updating a reusable tool."},"UpdateWorkflowRequest":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"workflow_definition":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Workflow Definition"},"template_context_variables":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Template Context Variables"},"workflow_configurations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Workflow Configurations"}},"type":"object","title":"UpdateWorkflowRequest"},"UpdateWorkflowStatusRequest":{"properties":{"status":{"type":"string","title":"Status"}},"type":"object","required":["status"],"title":"UpdateWorkflowStatusRequest"},"UsageHistoryResponse":{"properties":{"runs":{"items":{"$ref":"#/components/schemas/WorkflowRunUsageResponse"},"type":"array","title":"Runs"},"total_dograh_tokens":{"type":"number","title":"Total Dograh Tokens"},"total_duration_seconds":{"type":"integer","title":"Total Duration Seconds"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"limit":{"type":"integer","title":"Limit"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["runs","total_dograh_tokens","total_duration_seconds","total_count","page","limit","total_pages"],"title":"UsageHistoryResponse"},"UserConfigurationRequestResponseSchema":{"properties":{"llm":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Llm"},"tts":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Tts"},"stt":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Stt"},"embeddings":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Embeddings"},"realtime":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Realtime"},"is_realtime":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Realtime"},"test_phone_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Test Phone Number"},"timezone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Timezone"},"organization_pricing":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"number"},{"type":"string"},{"type":"boolean"}]},"type":"object"},{"type":"null"}],"title":"Organization Pricing"}},"type":"object","title":"UserConfigurationRequestResponseSchema"},"UserResponse":{"properties":{"id":{"type":"integer","title":"Id"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"organization_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Organization Id"},"provider_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Id"}},"type":"object","required":["id","email"],"title":"UserResponse"},"ValidateWorkflowResponse":{"properties":{"is_valid":{"type":"boolean","title":"Is Valid"},"errors":{"items":{"$ref":"#/components/schemas/WorkflowError"},"type":"array","title":"Errors"}},"type":"object","required":["is_valid","errors"],"title":"ValidateWorkflowResponse"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"VobizConfigurationRequest":{"properties":{"provider":{"type":"string","const":"vobiz","title":"Provider","default":"vobiz"},"auth_id":{"type":"string","title":"Auth Id","description":"Vobiz Account ID (e.g., MA_SYQRLN1K)"},"auth_token":{"type":"string","title":"Auth Token","description":"Vobiz Auth Token"},"application_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Id","description":"Vobiz Application ID. The application's answer_url is updated when inbound workflows are attached to numbers on this account. If omitted, an application is auto-created on save and its id is stored on the configuration."},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Vobiz phone numbers (E.164 without + prefix)"}},"type":"object","required":["auth_id","auth_token"],"title":"VobizConfigurationRequest","description":"Request schema for Vobiz configuration."},"VobizConfigurationResponse":{"properties":{"provider":{"type":"string","const":"vobiz","title":"Provider","default":"vobiz"},"auth_id":{"type":"string","title":"Auth Id"},"auth_token":{"type":"string","title":"Auth Token"},"application_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Id"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["auth_id","auth_token","from_numbers"],"title":"VobizConfigurationResponse","description":"Response schema for Vobiz configuration with masked sensitive fields."},"VoiceInfo":{"properties":{"voice_id":{"type":"string","title":"Voice Id"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"accent":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Accent"},"gender":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Gender"},"language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language"},"preview_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Preview Url"}},"type":"object","required":["voice_id","name"],"title":"VoiceInfo"},"VoicesResponse":{"properties":{"provider":{"type":"string","title":"Provider"},"voices":{"items":{"$ref":"#/components/schemas/VoiceInfo"},"type":"array","title":"Voices"}},"type":"object","required":["provider","voices"],"title":"VoicesResponse"},"VonageConfigurationRequest":{"properties":{"provider":{"type":"string","const":"vonage","title":"Provider","default":"vonage"},"api_key":{"type":"string","title":"Api Key","description":"Vonage API Key"},"api_secret":{"type":"string","title":"Api Secret","description":"Vonage API Secret"},"application_id":{"type":"string","title":"Application Id","description":"Vonage Application ID"},"private_key":{"type":"string","title":"Private Key","description":"Private key for JWT generation"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Vonage phone numbers (without + prefix)"}},"type":"object","required":["api_key","api_secret","application_id","private_key"],"title":"VonageConfigurationRequest","description":"Request schema for Vonage configuration."},"VonageConfigurationResponse":{"properties":{"provider":{"type":"string","const":"vonage","title":"Provider","default":"vonage"},"application_id":{"type":"string","title":"Application Id"},"api_key":{"type":"string","title":"Api Key"},"api_secret":{"type":"string","title":"Api Secret"},"private_key":{"type":"string","title":"Private Key"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["application_id","api_key","api_secret","private_key","from_numbers"],"title":"VonageConfigurationResponse","description":"Response schema for Vonage configuration with masked sensitive fields."},"WebhookCredentialType":{"type":"string","enum":["none","api_key","bearer_token","basic_auth","custom_header"],"title":"WebhookCredentialType","description":"Webhook credential authentication types"},"WorkflowCountResponse":{"properties":{"total":{"type":"integer","title":"Total"},"active":{"type":"integer","title":"Active"},"archived":{"type":"integer","title":"Archived"}},"type":"object","required":["total","active","archived"],"title":"WorkflowCountResponse","description":"Response for workflow count endpoint."},"WorkflowError":{"properties":{"kind":{"$ref":"#/components/schemas/ItemKind"},"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id"},"field":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Field"},"message":{"type":"string","title":"Message"}},"type":"object","required":["kind","id","field","message"],"title":"WorkflowError"},"WorkflowListResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"status":{"type":"string","title":"Status"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"total_runs":{"type":"integer","title":"Total Runs"},"folder_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Folder Id"},"workflow_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Uuid"}},"type":"object","required":["id","name","status","created_at","total_runs"],"title":"WorkflowListResponse","description":"Lightweight response for workflow listings (excludes large fields)."},"WorkflowOption":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"}},"type":"object","required":["id","name"],"title":"WorkflowOption"},"WorkflowResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"status":{"type":"string","title":"Status"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"workflow_definition":{"additionalProperties":true,"type":"object","title":"Workflow Definition"},"current_definition_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Current Definition Id"},"template_context_variables":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Template Context Variables"},"call_disposition_codes":{"anyOf":[{"$ref":"#/components/schemas/CallDispositionCodes"},{"type":"null"}]},"total_runs":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Total Runs"},"workflow_configurations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Workflow Configurations"},"version_number":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Version Number"},"version_status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version Status"},"workflow_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Uuid"}},"type":"object","required":["id","name","status","created_at","workflow_definition","current_definition_id"],"title":"WorkflowResponse"},"WorkflowRunDetail":{"properties":{"phone_number":{"type":"string","title":"Phone Number"},"disposition":{"type":"string","title":"Disposition"},"duration_seconds":{"type":"number","title":"Duration Seconds"},"workflow_id":{"type":"integer","title":"Workflow Id"},"run_id":{"type":"integer","title":"Run Id"},"workflow_name":{"type":"string","title":"Workflow Name"},"created_at":{"type":"string","title":"Created At"}},"type":"object","required":["phone_number","disposition","duration_seconds","workflow_id","run_id","workflow_name","created_at"],"title":"WorkflowRunDetail"},"WorkflowRunResponseSchema":{"properties":{"id":{"type":"integer","title":"Id"},"workflow_id":{"type":"integer","title":"Workflow Id"},"name":{"type":"string","title":"Name"},"mode":{"type":"string","title":"Mode"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"is_completed":{"type":"boolean","title":"Is Completed"},"transcript_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Url"},"recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Url"},"transcript_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Public Url"},"recording_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Public Url"},"public_access_token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Public Access Token"},"cost_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Cost Info"},"usage_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Usage Info"},"definition_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Definition Id"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"gathered_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Gathered Context"},"call_type":{"$ref":"#/components/schemas/CallType"},"logs":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Logs"},"annotations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Annotations"}},"type":"object","required":["id","workflow_id","name","mode","created_at","is_completed","transcript_url","recording_url","cost_info","definition_id","call_type"],"title":"WorkflowRunResponseSchema"},"WorkflowRunTextSessionResponse":{"properties":{"workflow_run_id":{"type":"integer","title":"Workflow Run Id"},"workflow_id":{"type":"integer","title":"Workflow Id"},"name":{"type":"string","title":"Name"},"mode":{"type":"string","title":"Mode"},"state":{"type":"string","title":"State"},"is_completed":{"type":"boolean","title":"Is Completed"},"revision":{"type":"integer","title":"Revision"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"gathered_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Gathered Context"},"annotations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Annotations"},"session_data":{"additionalProperties":true,"type":"object","title":"Session Data"},"checkpoint":{"additionalProperties":true,"type":"object","title":"Checkpoint"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["workflow_run_id","workflow_id","name","mode","state","is_completed","revision","session_data","checkpoint","created_at"],"title":"WorkflowRunTextSessionResponse"},"WorkflowRunUsageResponse":{"properties":{"id":{"type":"integer","title":"Id"},"workflow_id":{"type":"integer","title":"Workflow Id"},"workflow_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Name"},"name":{"type":"string","title":"Name"},"created_at":{"type":"string","title":"Created At"},"dograh_token_usage":{"type":"number","title":"Dograh Token Usage"},"call_duration_seconds":{"type":"integer","title":"Call Duration Seconds"},"recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Url"},"transcript_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Url"},"recording_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Public Url"},"transcript_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Public Url"},"public_access_token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Public Access Token"},"phone_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone Number","description":"Deprecated. Use caller_number and called_number instead.","deprecated":true},"caller_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Caller Number"},"called_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Called Number"},"call_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Call Type"},"mode":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Mode"},"disposition":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Disposition"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"gathered_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Gathered Context"},"charge_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Charge Usd"}},"type":"object","required":["id","workflow_id","workflow_name","name","created_at","dograh_token_usage","call_duration_seconds"],"title":"WorkflowRunUsageResponse"},"WorkflowRunsResponse":{"properties":{"runs":{"items":{"$ref":"#/components/schemas/WorkflowRunResponseSchema"},"type":"array","title":"Runs"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"limit":{"type":"integer","title":"Limit"},"total_pages":{"type":"integer","title":"Total Pages"},"applied_filters":{"anyOf":[{"items":{"additionalProperties":true,"type":"object"},"type":"array"},{"type":"null"}],"title":"Applied Filters"}},"type":"object","required":["runs","total_count","page","limit","total_pages"],"title":"WorkflowRunsResponse"},"WorkflowSummaryResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"}},"type":"object","required":["id","name"],"title":"WorkflowSummaryResponse"},"WorkflowTemplateResponse":{"properties":{"id":{"type":"integer","title":"Id"},"template_name":{"type":"string","title":"Template Name"},"template_description":{"type":"string","title":"Template Description"},"template_json":{"additionalProperties":true,"type":"object","title":"Template Json"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","template_name","template_description","template_json","created_at"],"title":"WorkflowTemplateResponse"},"WorkflowVersionResponse":{"properties":{"id":{"type":"integer","title":"Id"},"version_number":{"type":"integer","title":"Version Number"},"status":{"type":"string","title":"Status"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"published_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Published At"},"workflow_json":{"additionalProperties":true,"type":"object","title":"Workflow Json"},"workflow_configurations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Workflow Configurations"},"template_context_variables":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Template Context Variables"}},"type":"object","required":["id","version_number","status","created_at","workflow_json"],"title":"WorkflowVersionResponse"}}}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"Dograh API","description":"API for the Dograh app","version":"1.0.0"},"servers":[{"url":"https://app.dograh.com","description":"Production"},{"url":"http://localhost:8000","description":"Local development"}],"paths":{"/api/v1/telephony/initiate-call":{"post":{"tags":["main"],"summary":"Initiate Call","description":"Initiate a call using the configured telephony provider from web browser. This is\nsupposed to be a test call method for the draft version of the agent.","operationId":"initiate_call_api_v1_telephony_initiate_call_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InitiateCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"test_phone_call","x-sdk-description":"Place a test call from a workflow to a phone number."}},"/api/v1/telephony/inbound/run":{"post":{"tags":["main"],"summary":"Handle Inbound Run","description":"Workflow-agnostic inbound dispatcher.\n\nAll providers can point a single webhook at this endpoint instead of one\nURL per workflow. The dispatcher resolves the org from the webhook's\naccount_id and the workflow from the called number's\n``inbound_workflow_id``. This is what ``configure_inbound`` writes into\neach provider's resource so per-workflow webhook bookkeeping disappears.\n\nProvider-specific signature/timestamp headers are not enumerated here \u2014\neach provider's ``verify_inbound_signature`` reads its own headers from\nthe dict, so adding a new provider doesn't require changes to this route.","operationId":"handle_inbound_run_api_v1_telephony_inbound_run_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"}}}},"/api/v1/telephony/inbound/fallback":{"post":{"tags":["main"],"summary":"Handle Inbound Fallback","description":"Fallback endpoint that returns audio message when calls cannot be processed.","operationId":"handle_inbound_fallback_api_v1_telephony_inbound_fallback_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"}}}},"/api/v1/telephony/inbound/{workflow_id}":{"post":{"tags":["main"],"summary":"Handle Inbound Telephony","description":"[LEGACY] Per-workflow inbound webhook.\n\nSuperseded by ``POST /inbound/run``, which resolves the workflow from\nthe called number's ``inbound_workflow_id`` and lets a single webhook\nURL serve every workflow in the org. New integrations should point\ntheir provider at ``/inbound/run``; this route is kept only for\nexisting provider configurations that still encode ``workflow_id``\nin the URL.","operationId":"handle_inbound_telephony_api_v1_telephony_inbound__workflow_id__post","deprecated":true,"parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/transfer-result/{transfer_id}":{"post":{"tags":["main"],"summary":"Complete Transfer Function Call","description":"Webhook endpoint to complete the function call with transfer result.\n\nCalled by Twilio's StatusCallback when the transfer call status changes.","operationId":"complete_transfer_function_call_api_v1_telephony_transfer_result__transfer_id__post","parameters":[{"name":"transfer_id","in":"path","required":true,"schema":{"type":"string","title":"Transfer Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/cloudonix/status-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Cloudonix Status Callback","description":"Handle Cloudonix-specific status callbacks.\n\nCloudonix sends call status updates to the callback URL specified during call initiation.","operationId":"handle_cloudonix_status_callback_api_v1_telephony_cloudonix_status_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/cloudonix/cdr":{"post":{"tags":["main"],"summary":"Handle Cloudonix Cdr","description":"Handle Cloudonix CDR (Call Detail Record) webhooks.\n\nCloudonix sends CDR records when calls complete. The CDR contains:\n- domain: Used to identify the organization\n- call_id: Used to find the workflow run\n- disposition: Call termination status (ANSWER, BUSY, CANCEL, FAILED, CONGESTION, NOANSWER)\n- duration/billsec: Call duration information","operationId":"handle_cloudonix_cdr_api_v1_telephony_cloudonix_cdr_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"}}}},"/api/v1/telephony/plivo/hangup-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Plivo Hangup Callback","description":"Handle Plivo hangup callbacks.","operationId":"handle_plivo_hangup_callback_api_v1_telephony_plivo_hangup_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/plivo/ring-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Plivo Ring Callback","description":"Handle Plivo ring callbacks.","operationId":"handle_plivo_ring_callback_api_v1_telephony_plivo_ring_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/telnyx/events/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Telnyx Events","description":"Handle Telnyx Call Control webhook events.\n\nTelnyx sends all call lifecycle events (call.initiated, call.answered,\ncall.hangup, streaming.started, streaming.stopped) as JSON POST requests.","operationId":"handle_telnyx_events_api_v1_telephony_telnyx_events__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/telnyx/transfer-result/{transfer_id}":{"post":{"tags":["main"],"summary":"Handle Telnyx Transfer Result","description":"Handle Telnyx Call Control events for the transfer destination leg.\n\nThe destination leg is dialed by :meth:`TelnyxProvider.transfer_call` with\nthis URL as ``webhook_url``. Telnyx sends every event for that leg here.\nOutcomes:\n\n- ``call.answered``: seed a conference with the destination's live\n ``call_control_id``, stamp ``conference_id`` onto the TransferContext,\n and publish ``DESTINATION_ANSWERED`` so ``transfer_call_handler`` can\n end the pipeline. ``TelnyxConferenceStrategy`` then joins the caller\n into this conference at pipeline teardown.\n- ``call.hangup`` pre-answer (no ``conference_id`` on the context):\n publish ``TRANSFER_FAILED`` so the LLM can recover.\n- ``call.hangup`` post-answer (``conference_id`` set): the destination\n left a bridged conference; hang up the caller's leg to tear down the\n empty bridge (Telnyx's create_conference doesn't accept\n ``end_conference_on_exit`` on the seed leg).\n\nEvent references:\n - call.answered: https://developers.telnyx.com/api-reference/callbacks/call-answered\n - call.hangup: https://developers.telnyx.com/api-reference/callbacks/call-hangup","operationId":"handle_telnyx_transfer_result_api_v1_telephony_telnyx_transfer_result__transfer_id__post","parameters":[{"name":"transfer_id","in":"path","required":true,"schema":{"type":"string","title":"Transfer Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/twilio/status-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Twilio Status Callback","description":"Handle Twilio-specific status callbacks.","operationId":"handle_twilio_status_callback_api_v1_telephony_twilio_status_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/vobiz/hangup-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Vobiz Hangup Callback","description":"Handle Vobiz hangup callback (sent when call ends).\n\nVobiz sends callbacks to hangup_url when the call terminates.\nThis includes call duration, status, and billing information.","operationId":"handle_vobiz_hangup_callback_api_v1_telephony_vobiz_hangup_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/vobiz/ring-callback/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Vobiz Ring Callback","description":"Handle Vobiz ring callback (sent when call starts ringing).\n\nVobiz can send callbacks to ring_url when the call starts ringing.\nThis is optional and used for tracking ringing status.","operationId":"handle_vobiz_ring_callback_api_v1_telephony_vobiz_ring_callback__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}":{"post":{"tags":["main"],"summary":"Handle Vobiz Hangup Callback By Workflow","description":"Handle Vobiz hangup callback with workflow_id - finds workflow run by call_id.","operationId":"handle_vobiz_hangup_callback_by_workflow_api_v1_telephony_vobiz_hangup_callback_workflow__workflow_id__post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/telephony/vonage/events/{workflow_run_id}":{"post":{"tags":["main"],"summary":"Handle Vonage Events","description":"Handle Vonage-specific event webhooks.\n\nVonage sends all call events to a single endpoint.\nEvents include: started, ringing, answered, complete, failed, etc.","operationId":"handle_vonage_events_api_v1_telephony_vonage_events__workflow_run_id__post","parameters":[{"name":"workflow_run_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Run Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/superuser/impersonate":{"post":{"tags":["main","superuser"],"summary":"Impersonate","description":"Impersonate a user as a super-admin.\nInternally, Stack Auth requires the **provider user ID** (a UUID-ish string)\nto create an impersonation session.","operationId":"impersonate_api_v1_superuser_impersonate_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImpersonateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImpersonateResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/superuser/workflow-runs":{"get":{"tags":["main","superuser"],"summary":"Get Workflow Runs","description":"Get paginated list of all workflow runs with organization information.\nRequires superuser privileges.\n\nFilters should be provided as a JSON-encoded array of filter criteria.\nExample: [{\"field\": \"id\", \"type\": \"number\", \"value\": {\"value\": 680}}]","operationId":"get_workflow_runs_api_v1_superuser_workflow_runs_get","parameters":[{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"description":"Page number (starts from 1)","default":1,"title":"Page"},"description":"Page number (starts from 1)"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Number of items per page","default":50,"title":"Limit"},"description":"Number of items per page"},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded filter criteria","title":"Filters"},"description":"JSON-encoded filter criteria"},{"name":"sort_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Field to sort by (e.g., 'duration', 'created_at')","title":"Sort By"},"description":"Field to sort by (e.g., 'duration', 'created_at')"},{"name":"sort_order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Sort order ('asc' or 'desc')","default":"desc","title":"Sort Order"},"description":"Sort order ('asc' or 'desc')"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SuperuserWorkflowRunsListResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/validate":{"post":{"tags":["main"],"summary":"Validate Workflow","description":"Validate all nodes in a workflow to ensure they have required fields.\n\nArgs:\n workflow_id: The ID of the workflow to validate\n user: The authenticated user\n\nReturns:\n Object indicating if workflow is valid and any invalid nodes/edges","operationId":"validate_workflow_api_v1_workflow__workflow_id__validate_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidateWorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/create/definition":{"post":{"tags":["main"],"summary":"Create Workflow","description":"Create a new workflow from the client\n\nArgs:\n request: The create workflow request\n user: The user to create the workflow for","operationId":"create_workflow_api_v1_workflow_create_definition_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkflowRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"create_workflow","x-sdk-description":"Create a new workflow from a workflow definition."}},"/api/v1/workflow/create/template":{"post":{"tags":["main"],"summary":"Create Workflow From Template","description":"Create a new workflow from a natural language template request.\n\nThis endpoint:\n1. Uses mps_service_key_client to call MPS workflow API\n2. Passes organization ID (authenticated mode) or created_by (OSS mode)\n3. Creates the workflow in the database\n\nArgs:\n request: The template creation request with call_type, use_case, and activity_description\n user: The authenticated user\n\nReturns:\n The created workflow\n\nRaises:\n HTTPException: If MPS API call fails","operationId":"create_workflow_from_template_api_v1_workflow_create_template_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkflowTemplateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/count":{"get":{"tags":["main"],"summary":"Get Workflow Count","description":"Get workflow counts for the authenticated user's organization.\n\nThis is a lightweight endpoint for checking if the user has workflows,\nuseful for redirect logic without fetching full workflow data.","operationId":"get_workflow_count_api_v1_workflow_count_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowCountResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/fetch":{"get":{"tags":["main"],"summary":"Get Workflows","description":"Get all workflows for the authenticated user's organization.\n\nReturns a lightweight response with only essential fields for listing.\nUse GET /workflow/fetch/{workflow_id} to get full workflow details.","operationId":"get_workflows_api_v1_workflow_fetch_get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by status - can be single value (active/archived) or comma-separated (active,archived)","title":"Status"},"description":"Filter by status - can be single value (active/archived) or comma-separated (active,archived)"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowListResponse"},"title":"Response Get Workflows Api V1 Workflow Fetch Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_workflows","x-sdk-description":"List all workflows in the authenticated organization."}},"/api/v1/workflow/fetch/{workflow_id}":{"get":{"tags":["main"],"summary":"Get Workflow","description":"Get a single workflow by ID.\n\nIf a draft version exists, returns the draft content for editing.\nOtherwise returns the published version's content.","operationId":"get_workflow_api_v1_workflow_fetch__workflow_id__get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"get_workflow","x-sdk-description":"Get a single workflow by ID (returns draft if one exists, else published)."}},"/api/v1/workflow/{workflow_id}/versions":{"get":{"tags":["main"],"summary":"Get Workflow Versions","description":"List versions for a workflow, newest first.\n\nPass `limit`/`offset` to page through long histories. With no `limit`,\nreturns every version (legacy behavior).","operationId":"get_workflow_versions_api_v1_workflow__workflow_id__versions_get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"limit","in":"query","required":false,"schema":{"anyOf":[{"type":"integer","maximum":100,"minimum":1},{"type":"null"}],"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowVersionResponse"},"title":"Response Get Workflow Versions Api V1 Workflow Workflow Id Versions Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/publish":{"post":{"tags":["main"],"summary":"Publish Workflow","description":"Publish the current draft version of a workflow.\n\nDrafts are allowed to be incomplete (so the editor can save mid-edit),\nbut a published version is what runtime executes \u2014 so this is the gate\nwhere the full DTO + graph + trigger-conflict checks must pass.","operationId":"publish_workflow_api_v1_workflow__workflow_id__publish_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/create-draft":{"post":{"tags":["main"],"summary":"Create Workflow Draft","description":"Create a draft version from the current published version.\n\nIf a draft already exists, returns the existing draft.","operationId":"create_workflow_draft_api_v1_workflow__workflow_id__create_draft_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowVersionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/summary":{"get":{"tags":["main"],"summary":"Get Workflows Summary","description":"Get minimal workflow information (id and name only) for all workflows","operationId":"get_workflows_summary_api_v1_workflow_summary_get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by status (e.g. 'active' or 'archived'). Omit to return all.","title":"Status"},"description":"Filter by status (e.g. 'active' or 'archived'). Omit to return all."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowSummaryResponse"},"title":"Response Get Workflows Summary Api V1 Workflow Summary Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/status":{"put":{"tags":["main"],"summary":"Update Workflow Status","description":"Update the status of a workflow (e.g., archive/unarchive).\n\nArgs:\n workflow_id: The ID of the workflow to update\n request: The status update request\n\nReturns:\n The updated workflow","operationId":"update_workflow_status_api_v1_workflow__workflow_id__status_put","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkflowStatusRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/folder":{"put":{"tags":["main"],"summary":"Move Workflow To Folder","description":"Move a workflow into a folder, or to \"Uncategorized\" (folder_id=null).\n\nValidates that the target folder belongs to the caller's organization \u2014\nthe FK alone proves the folder exists, not that the caller may use it.","operationId":"move_workflow_to_folder_api_v1_workflow__workflow_id__folder_put","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MoveWorkflowToFolderRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowListResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}":{"put":{"tags":["main"],"summary":"Update Workflow","description":"Update an existing workflow.\n\nArgs:\n workflow_id: The ID of the workflow to update\n request: The update request containing the new name and workflow definition\n\nReturns:\n The updated workflow\n\nRaises:\n HTTPException: If the workflow is not found or if there's a database error","operationId":"update_workflow_api_v1_workflow__workflow_id__put","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkflowRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"update_workflow","x-sdk-description":"Update a workflow's name and/or definition. Saves as a new draft."}},"/api/v1/workflow/{workflow_id}/duplicate":{"post":{"tags":["main"],"summary":"Duplicate Workflow Endpoint","description":"Duplicate a workflow including its definition, configuration, recordings, and triggers.","operationId":"duplicate_workflow_endpoint_api_v1_workflow__workflow_id__duplicate_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/runs":{"post":{"tags":["main"],"summary":"Create Workflow Run","description":"Create a new workflow run when the user decides to execute the workflow via chat or voice\n\nArgs:\n workflow_id: The ID of the workflow to run\n request: The create workflow run request\n user: The user to create the workflow run for","operationId":"create_workflow_run_api_v1_workflow__workflow_id__runs_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkflowRunRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkflowRunResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["main"],"summary":"Get Workflow Runs","description":"Get workflow runs with optional filtering and sorting.\n\nFilters should be provided as a JSON-encoded array of filter criteria.\nExample: [{\"attribute\": \"dateRange\", \"value\": {\"from\": \"2024-01-01\", \"to\": \"2024-01-31\"}}]","operationId":"get_workflow_runs_api_v1_workflow__workflow_id__runs_get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","default":1,"title":"Page"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":50,"title":"Limit"}},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded filter criteria","title":"Filters"},"description":"JSON-encoded filter criteria"},{"name":"sort_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Field to sort by (e.g., 'duration', 'created_at')","title":"Sort By"},"description":"Field to sort by (e.g., 'duration', 'created_at')"},{"name":"sort_order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Sort order ('asc' or 'desc')","default":"desc","title":"Sort Order"},"description":"Sort order ('asc' or 'desc')"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/runs/{run_id}":{"get":{"tags":["main"],"summary":"Get Workflow Run","operationId":"get_workflow_run_api_v1_workflow__workflow_id__runs__run_id__get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"run_id","in":"path","required":true,"schema":{"type":"integer","title":"Run Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/report":{"get":{"tags":["main"],"summary":"Download Workflow Report","description":"Download a CSV report of completed runs for a workflow.","operationId":"download_workflow_report_api_v1_workflow__workflow_id__report_get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter runs created on or after this datetime (ISO 8601)","title":"Start Date"},"description":"Filter runs created on or after this datetime (ISO 8601)"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter runs created on or before this datetime (ISO 8601)","title":"End Date"},"description":"Filter runs created on or before this datetime (ISO 8601)"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/templates":{"get":{"tags":["main"],"summary":"Get Workflow Templates","description":"Get all available workflow templates.\n\nReturns:\n List of workflow templates","operationId":"get_workflow_templates_api_v1_workflow_templates_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"items":{"$ref":"#/components/schemas/WorkflowTemplateResponse"},"type":"array","title":"Response Get Workflow Templates Api V1 Workflow Templates Get"}}}},"404":{"description":"Not found"}}}},"/api/v1/workflow/templates/duplicate":{"post":{"tags":["main"],"summary":"Duplicate Workflow Template","description":"Duplicate a workflow template to create a new workflow for the user.\n\nArgs:\n request: The duplicate template request\n user: The authenticated user\n\nReturns:\n The newly created workflow","operationId":"duplicate_workflow_template_api_v1_workflow_templates_duplicate_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateTemplateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/ambient-noise/upload-url":{"post":{"tags":["main"],"summary":"Get a presigned URL to upload a custom ambient noise audio file","description":"Generate a presigned PUT URL for uploading a custom ambient noise file.","operationId":"get_ambient_noise_upload_url_api_v1_workflow_ambient_noise_upload_url_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AmbientNoiseUploadRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AmbientNoiseUploadResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/text-chat/sessions":{"post":{"tags":["main","workflow-text-chat"],"summary":"Create Text Chat Session","operationId":"create_text_chat_session_api_v1_workflow__workflow_id__text_chat_sessions_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTextChatSessionRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunTextSessionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/text-chat/sessions/{run_id}":{"get":{"tags":["main","workflow-text-chat"],"summary":"Get Text Chat Session","operationId":"get_text_chat_session_api_v1_workflow__workflow_id__text_chat_sessions__run_id__get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"run_id","in":"path","required":true,"schema":{"type":"integer","title":"Run Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunTextSessionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/text-chat/sessions/{run_id}/messages":{"post":{"tags":["main","workflow-text-chat"],"summary":"Append Text Chat Message","operationId":"append_text_chat_message_api_v1_workflow__workflow_id__text_chat_sessions__run_id__messages_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"run_id","in":"path","required":true,"schema":{"type":"integer","title":"Run Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AppendTextChatMessageRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunTextSessionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/text-chat/sessions/{run_id}/rewind":{"post":{"tags":["main","workflow-text-chat"],"summary":"Rewind Text Chat Session","operationId":"rewind_text_chat_session_api_v1_workflow__workflow_id__text_chat_sessions__run_id__rewind_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"run_id","in":"path","required":true,"schema":{"type":"integer","title":"Run Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RewindTextChatSessionRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkflowRunTextSessionResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/configurations/defaults":{"get":{"tags":["main"],"summary":"Get Default Configurations","operationId":"get_default_configurations_api_v1_user_configurations_defaults_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DefaultConfigurationsResponse"}}}},"404":{"description":"Not found"}}}},"/api/v1/user/auth/user":{"get":{"tags":["main"],"summary":"Get Auth User","operationId":"get_auth_user_api_v1_user_auth_user_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthUserResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/configurations/user":{"get":{"tags":["main"],"summary":"Get User Configurations","operationId":"get_user_configurations_api_v1_user_configurations_user_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConfigurationRequestResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main"],"summary":"Update User Configurations","operationId":"update_user_configurations_api_v1_user_configurations_user_put","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConfigurationRequestResponseSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserConfigurationRequestResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/configurations/user/validate":{"get":{"tags":["main"],"summary":"Validate User Configurations","operationId":"validate_user_configurations_api_v1_user_configurations_user_validate_get","parameters":[{"name":"validity_ttl_seconds","in":"query","required":false,"schema":{"type":"integer","maximum":86400,"minimum":0,"default":60,"title":"Validity Ttl Seconds"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/APIKeyStatusResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/api-keys":{"get":{"tags":["main"],"summary":"Get Api Keys","description":"Get all API keys for the user's selected organization.","operationId":"get_api_keys_api_v1_user_api_keys_get","parameters":[{"name":"include_archived","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Archived"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/APIKeyResponse"},"title":"Response Get Api Keys Api V1 User Api Keys Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main"],"summary":"Create Api Key","description":"Create a new API key for the user's selected organization.","operationId":"create_api_key_api_v1_user_api_keys_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAPIKeyRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAPIKeyResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/api-keys/{api_key_id}":{"delete":{"tags":["main"],"summary":"Archive Api Key","description":"Archive an API key (soft delete).","operationId":"archive_api_key_api_v1_user_api_keys__api_key_id__delete","parameters":[{"name":"api_key_id","in":"path","required":true,"schema":{"type":"integer","title":"Api Key Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Archive Api Key Api V1 User Api Keys Api Key Id Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/api-keys/{api_key_id}/reactivate":{"put":{"tags":["main"],"summary":"Reactivate Api Key","description":"Reactivate an archived API key.","operationId":"reactivate_api_key_api_v1_user_api_keys__api_key_id__reactivate_put","parameters":[{"name":"api_key_id","in":"path","required":true,"schema":{"type":"integer","title":"Api Key Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Reactivate Api Key Api V1 User Api Keys Api Key Id Reactivate Put"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/configurations/voices/{provider}":{"get":{"tags":["main"],"summary":"Get Voices","description":"Get available voices for a TTS provider.","operationId":"get_voices_api_v1_user_configurations_voices__provider__get","parameters":[{"name":"provider","in":"path","required":true,"schema":{"enum":["elevenlabs","deepgram","sarvam","cartesia","dograh","rime"],"type":"string","title":"Provider"}},{"name":"model","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model"}},{"name":"language","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VoicesResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/create":{"post":{"tags":["main"],"summary":"Create Campaign","description":"Create a new campaign","operationId":"create_campaign_api_v1_campaign_create_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCampaignRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/":{"get":{"tags":["main"],"summary":"Get Campaigns","description":"Get campaigns for user's organization","operationId":"get_campaigns_api_v1_campaign__get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}":{"get":{"tags":["main"],"summary":"Get Campaign","description":"Get campaign details","operationId":"get_campaign_api_v1_campaign__campaign_id__get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["main"],"summary":"Update Campaign","description":"Update campaign settings (name, retry config, max concurrency, schedule)","operationId":"update_campaign_api_v1_campaign__campaign_id__patch","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCampaignRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/start":{"post":{"tags":["main"],"summary":"Start Campaign","description":"Start campaign execution","operationId":"start_campaign_api_v1_campaign__campaign_id__start_post","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/pause":{"post":{"tags":["main"],"summary":"Pause Campaign","description":"Pause campaign execution","operationId":"pause_campaign_api_v1_campaign__campaign_id__pause_post","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/runs":{"get":{"tags":["main"],"summary":"Get Campaign Runs","description":"Get campaign workflow runs with pagination, filters and sorting","operationId":"get_campaign_runs_api_v1_campaign__campaign_id__runs_get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","default":1,"title":"Page"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":50,"title":"Limit"}},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded filter criteria","title":"Filters"},"description":"JSON-encoded filter criteria"},{"name":"sort_by","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Field to sort by (e.g., 'duration', 'created_at')","title":"Sort By"},"description":"Field to sort by (e.g., 'duration', 'created_at')"},{"name":"sort_order","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Sort order ('asc' or 'desc')","default":"desc","title":"Sort Order"},"description":"Sort order ('asc' or 'desc')"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignRunsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/redial":{"post":{"tags":["main"],"summary":"Redial Campaign","description":"Create a new campaign that re-dials unique subscribers from a completed\ncampaign whose latest call resulted in voicemail, no-answer, or busy.\n\nThe new campaign is created in 'created' state with queued_runs pre-seeded\nfrom the parent's original initial contexts. A campaign can be redialed at\nmost once.","operationId":"redial_campaign_api_v1_campaign__campaign_id__redial_post","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RedialCampaignRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/resume":{"post":{"tags":["main"],"summary":"Resume Campaign","description":"Resume a paused campaign","operationId":"resume_campaign_api_v1_campaign__campaign_id__resume_post","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/progress":{"get":{"tags":["main"],"summary":"Get Campaign Progress","description":"Get current campaign progress and statistics","operationId":"get_campaign_progress_api_v1_campaign__campaign_id__progress_get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignProgressResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/source-download-url":{"get":{"tags":["main"],"summary":"Get Campaign Source Download Url","description":"Get presigned download URL for campaign CSV source file\nValidates that the campaign belongs to the user's organization for security.","operationId":"get_campaign_source_download_url_api_v1_campaign__campaign_id__source_download_url_get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignSourceDownloadResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/campaign/{campaign_id}/report":{"get":{"tags":["main"],"summary":"Download Campaign Report","description":"Download a CSV report of completed campaign runs.","operationId":"download_campaign_report_api_v1_campaign__campaign_id__report_get","parameters":[{"name":"campaign_id","in":"path","required":true,"schema":{"type":"integer","title":"Campaign Id"}},{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter runs created on or after this datetime (ISO 8601)","title":"Start Date"},"description":"Filter runs created on or after this datetime (ISO 8601)"},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"description":"Filter runs created on or before this datetime (ISO 8601)","title":"End Date"},"description":"Filter runs created on or before this datetime (ISO 8601)"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/credentials/":{"get":{"tags":["main"],"summary":"List Credentials","description":"List all webhook credentials for the user's organization.\n\nReturns:\n List of credentials (without sensitive data)","operationId":"list_credentials_api_v1_credentials__get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/CredentialResponse"},"title":"Response List Credentials Api V1 Credentials Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_credentials","x-sdk-description":"List webhook credentials available to the authenticated organization."},"post":{"tags":["main"],"summary":"Create Credential","description":"Create a new webhook credential.\n\nArgs:\n request: The credential creation request\n\nReturns:\n The created credential (without sensitive data)","operationId":"create_credential_api_v1_credentials__post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateCredentialRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CredentialResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/credentials/{credential_uuid}":{"get":{"tags":["main"],"summary":"Get Credential","description":"Get a specific webhook credential by UUID.\n\nArgs:\n credential_uuid: The UUID of the credential\n\nReturns:\n The credential (without sensitive data)","operationId":"get_credential_api_v1_credentials__credential_uuid__get","parameters":[{"name":"credential_uuid","in":"path","required":true,"schema":{"type":"string","title":"Credential Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CredentialResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main"],"summary":"Update Credential","description":"Update a webhook credential.\n\nArgs:\n credential_uuid: The UUID of the credential to update\n request: The update request\n\nReturns:\n The updated credential (without sensitive data)","operationId":"update_credential_api_v1_credentials__credential_uuid__put","parameters":[{"name":"credential_uuid","in":"path","required":true,"schema":{"type":"string","title":"Credential Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateCredentialRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CredentialResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main"],"summary":"Delete Credential","description":"Delete (soft delete) a webhook credential.\n\nArgs:\n credential_uuid: The UUID of the credential to delete\n\nReturns:\n Success message","operationId":"delete_credential_api_v1_credentials__credential_uuid__delete","parameters":[{"name":"credential_uuid","in":"path","required":true,"schema":{"type":"string","title":"Credential Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Credential Api V1 Credentials Credential Uuid Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/tools/":{"get":{"tags":["main"],"summary":"List Tools","description":"List all tools for the user's organization.\n\nArgs:\n status: Optional filter by status (active, archived, draft)\n category: Optional filter by category (http_api, native, integration)\n\nReturns:\n List of tools","operationId":"list_tools_api_v1_tools__get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status"}},{"name":"category","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Category"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ToolResponse"},"title":"Response List Tools Api V1 Tools Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_tools","x-sdk-description":"List tools available to the authenticated organization."},"post":{"tags":["main"],"summary":"Create Tool","description":"Create a new tool.\n\nArgs:\n request: The tool creation request\n\nReturns:\n The created tool","operationId":"create_tool_api_v1_tools__post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateToolRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"create_tool","x-sdk-description":"Create a reusable tool for the authenticated organization."}},"/api/v1/tools/{tool_uuid}":{"get":{"tags":["main"],"summary":"Get Tool","description":"Get a specific tool by UUID.\n\nArgs:\n tool_uuid: The UUID of the tool\n\nReturns:\n The tool","operationId":"get_tool_api_v1_tools__tool_uuid__get","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main"],"summary":"Update Tool","description":"Update a tool.\n\nArgs:\n tool_uuid: The UUID of the tool to update\n request: The update request\n\nReturns:\n The updated tool","operationId":"update_tool_api_v1_tools__tool_uuid__put","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateToolRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main"],"summary":"Delete Tool","description":"Archive (soft delete) a tool.\n\nArgs:\n tool_uuid: The UUID of the tool to delete\n\nReturns:\n Success message","operationId":"delete_tool_api_v1_tools__tool_uuid__delete","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Delete Tool Api V1 Tools Tool Uuid Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/tools/{tool_uuid}/mcp/refresh":{"post":{"tags":["main"],"summary":"Refresh Mcp Tools","description":"Re-discover an MCP tool's server catalog and overwrite the cached\n``definition.config.discovered_tools``. Server down \u2192 200 with error\n(cache not overwritten on transient failure).","operationId":"refresh_mcp_tools_api_v1_tools__tool_uuid__mcp_refresh_post","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/McpRefreshResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/tools/{tool_uuid}/unarchive":{"post":{"tags":["main"],"summary":"Unarchive Tool","description":"Unarchive a tool (restore from archived state).\n\nArgs:\n tool_uuid: The UUID of the tool to unarchive\n\nReturns:\n The unarchived tool","operationId":"unarchive_tool_api_v1_tools__tool_uuid__unarchive_post","parameters":[{"name":"tool_uuid","in":"path","required":true,"schema":{"type":"string","title":"Tool Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ToolResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-providers/metadata":{"get":{"tags":["main","organizations"],"summary":"Get Telephony Providers Metadata","description":"Return the list of available telephony providers and their form schemas.\n\nThe UI uses this to render the configuration form generically instead of\nhard-coding fields per provider. Adding a new provider only requires\ndeclaring its ui_metadata in providers//__init__.py.","operationId":"get_telephony_providers_metadata_api_v1_organizations_telephony_providers_metadata_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyProvidersMetadataResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-config-warnings":{"get":{"tags":["main","organizations"],"summary":"Get Telephony Config Warnings","description":"Return aggregated warning counts for the current org's telephony configs.\n\nToday this surfaces only Telnyx configs missing ``webhook_public_key``;\nadditional warning types should be added as new fields on the response.","operationId":"get_telephony_config_warnings_api_v1_organizations_telephony_config_warnings_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigWarningsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/model-configurations/v2/defaults":{"get":{"tags":["main","organizations"],"summary":"Get Model Configuration V2 Defaults","operationId":"get_model_configuration_v2_defaults_api_v1_organizations_model_configurations_v2_defaults_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/model-configurations/v2":{"get":{"tags":["main","organizations"],"summary":"Get Model Configuration V2","operationId":"get_model_configuration_v2_api_v1_organizations_model_configurations_v2_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationAIModelConfigurationResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main","organizations"],"summary":"Save Model Configuration V2","operationId":"save_model_configuration_v2_api_v1_organizations_model_configurations_v2_put","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationAIModelConfigurationV2"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationAIModelConfigurationResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/model-configurations/v2/migration-preview":{"get":{"tags":["main","organizations"],"summary":"Preview Model Configuration V2 Migration","operationId":"preview_model_configuration_v2_migration_api_v1_organizations_model_configurations_v2_migration_preview_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/model-configurations/v2/migrate":{"post":{"tags":["main","organizations"],"summary":"Migrate Model Configuration V2","operationId":"migrate_model_configuration_v2_api_v1_organizations_model_configurations_v2_migrate_post","parameters":[{"name":"force","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Force"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationAIModelConfigurationResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/preferences":{"get":{"tags":["main","organizations"],"summary":"Get Preferences","operationId":"get_preferences_api_v1_organizations_preferences_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationPreferences"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main","organizations"],"summary":"Save Preferences","operationId":"save_preferences_api_v1_organizations_preferences_put","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationPreferences"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OrganizationPreferences"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs":{"get":{"tags":["main","organizations"],"summary":"List Telephony Configurations","description":"List the org's telephony configurations with phone-number counts.","operationId":"list_telephony_configurations_api_v1_organizations_telephony_configs_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationListResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main","organizations"],"summary":"Create Telephony Configuration","description":"Create a new telephony configuration for the org.","operationId":"create_telephony_configuration_api_v1_organizations_telephony_configs_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationDetail"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}":{"get":{"tags":["main","organizations"],"summary":"Get Telephony Configuration By Id","operationId":"get_telephony_configuration_by_id_api_v1_organizations_telephony_configs__config_id__get","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationDetail"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main","organizations"],"summary":"Update Telephony Configuration","operationId":"update_telephony_configuration_api_v1_organizations_telephony_configs__config_id__put","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationUpdateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationDetail"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main","organizations"],"summary":"Delete Telephony Configuration","operationId":"delete_telephony_configuration_api_v1_organizations_telephony_configs__config_id__delete","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}/set-default-outbound":{"post":{"tags":["main","organizations"],"summary":"Set Default Outbound","operationId":"set_default_outbound_api_v1_organizations_telephony_configs__config_id__set_default_outbound_post","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationDetail"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}/phone-numbers":{"get":{"tags":["main","organizations"],"summary":"List Phone Numbers","operationId":"list_phone_numbers_api_v1_organizations_telephony_configs__config_id__phone_numbers_get","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberListResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main","organizations"],"summary":"Create Phone Number","operationId":"create_phone_number_api_v1_organizations_telephony_configs__config_id__phone_numbers_post","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberCreateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}/phone-numbers/{phone_number_id}":{"get":{"tags":["main","organizations"],"summary":"Get Phone Number","operationId":"get_phone_number_api_v1_organizations_telephony_configs__config_id__phone_numbers__phone_number_id__get","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"phone_number_id","in":"path","required":true,"schema":{"type":"integer","title":"Phone Number Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main","organizations"],"summary":"Update Phone Number","operationId":"update_phone_number_api_v1_organizations_telephony_configs__config_id__phone_numbers__phone_number_id__put","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"phone_number_id","in":"path","required":true,"schema":{"type":"integer","title":"Phone Number Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberUpdateRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main","organizations"],"summary":"Delete Phone Number","operationId":"delete_phone_number_api_v1_organizations_telephony_configs__config_id__phone_numbers__phone_number_id__delete","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"phone_number_id","in":"path","required":true,"schema":{"type":"integer","title":"Phone Number Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-configs/{config_id}/phone-numbers/{phone_number_id}/set-default-caller":{"post":{"tags":["main","organizations"],"summary":"Set Default Caller Id","operationId":"set_default_caller_id_api_v1_organizations_telephony_configs__config_id__phone_numbers__phone_number_id__set_default_caller_post","parameters":[{"name":"config_id","in":"path","required":true,"schema":{"type":"integer","title":"Config Id"}},{"name":"phone_number_id","in":"path","required":true,"schema":{"type":"integer","title":"Phone Number Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/telephony-config":{"get":{"tags":["main","organizations"],"summary":"Get Telephony Configuration","description":"Legacy: returns the org's default config in the original per-provider\nresponse shape so the existing single-form UI keeps working. Prefer the\nmulti-config endpoints (``/telephony-configs``) for new clients.","operationId":"get_telephony_configuration_api_v1_organizations_telephony_config_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TelephonyConfigurationResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main","organizations"],"summary":"Save Telephony Configuration","description":"Legacy: upserts the org's default config (and its phone numbers) in the\noriginal payload shape so existing UI clients keep working. Prefer the\nmulti-config + phone-number endpoints for new clients.","operationId":"save_telephony_configuration_api_v1_organizations_telephony_config_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/ARIConfigurationRequest"},{"$ref":"#/components/schemas/CloudonixConfigurationRequest"},{"$ref":"#/components/schemas/PlivoConfigurationRequest"},{"$ref":"#/components/schemas/TelnyxConfigurationRequest"},{"$ref":"#/components/schemas/TwilioConfigurationRequest"},{"$ref":"#/components/schemas/VobizConfigurationRequest"},{"$ref":"#/components/schemas/VonageConfigurationRequest"}],"discriminator":{"propertyName":"provider","mapping":{"ari":"#/components/schemas/ARIConfigurationRequest","cloudonix":"#/components/schemas/CloudonixConfigurationRequest","plivo":"#/components/schemas/PlivoConfigurationRequest","telnyx":"#/components/schemas/TelnyxConfigurationRequest","twilio":"#/components/schemas/TwilioConfigurationRequest","vobiz":"#/components/schemas/VobizConfigurationRequest","vonage":"#/components/schemas/VonageConfigurationRequest"}},"title":"Request"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/langfuse-credentials":{"get":{"tags":["main","organizations"],"summary":"Get Langfuse Credentials","description":"Get Langfuse credentials for the user's organization with masked sensitive fields.","operationId":"get_langfuse_credentials_api_v1_organizations_langfuse_credentials_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LangfuseCredentialsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main","organizations"],"summary":"Save Langfuse Credentials","description":"Save Langfuse credentials for the user's organization.","operationId":"save_langfuse_credentials_api_v1_organizations_langfuse_credentials_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LangfuseCredentialsRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main","organizations"],"summary":"Delete Langfuse Credentials","description":"Delete Langfuse credentials for the user's organization.","operationId":"delete_langfuse_credentials_api_v1_organizations_langfuse_credentials_delete","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/campaign-defaults":{"get":{"tags":["main","organizations"],"summary":"Get Campaign Defaults","description":"Get campaign limits for the user's organization.\n\nReturns the organization's concurrent call limit and default retry configuration.","operationId":"get_campaign_defaults_api_v1_organizations_campaign_defaults_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignDefaultsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/s3/signed-url":{"get":{"tags":["main","s3"],"summary":"Generate a signed S3 URL","description":"Return a short-lived signed URL for a file stored on S3 / MinIO.\n\nAccess Control:\n* Keys that embed an organization ID (``{prefix}/{org_id}/...``) are\n authorized by matching the org_id against the requesting user's\n organization.\n* Legacy keys (``recordings/{run_id}.wav``, ``transcripts/{run_id}.txt``)\n are authorized via the workflow run they belong to.\n* Superusers can request any key.","operationId":"get_signed_url_api_v1_s3_signed_url_get","parameters":[{"name":"key","in":"query","required":true,"schema":{"type":"string","description":"S3 object key","title":"Key"},"description":"S3 object key"},{"name":"expires_in","in":"query","required":false,"schema":{"type":"integer","default":3600,"title":"Expires In"}},{"name":"inline","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Inline"}},{"name":"storage_backend","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Storage backend to use (e.g. 'minio', 's3'). When omitted the backend is inferred from the resource.","title":"Storage Backend"},"description":"Storage backend to use (e.g. 'minio', 's3'). When omitted the backend is inferred from the resource."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/S3SignedUrlResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/s3/file-metadata":{"get":{"tags":["main","s3"],"summary":"Get file metadata for debugging","description":"Get file metadata including creation timestamp for debugging.\n\nAccess Control:\n* Superusers can request any key.\n* Regular users can only request resources belonging to **their** workflow runs.","operationId":"get_file_metadata_api_v1_s3_file_metadata_get","parameters":[{"name":"key","in":"query","required":true,"schema":{"type":"string","description":"S3 object key","title":"Key"},"description":"S3 object key"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FileMetadataResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/s3/presigned-upload-url":{"post":{"tags":["main","s3"],"summary":"Generate a presigned URL for direct CSV upload","description":"Generate a presigned PUT URL for direct CSV file upload to S3/MinIO.\n\nThis endpoint enables browser-to-storage uploads without passing through the backend\n\nAccess Control:\n* All authenticated users can upload CSV files scoped to their organization.\n* Files are stored with organization-scoped keys for multi-tenancy.\n\nReturns:\n* upload_url: Presigned URL (valid for 15 minutes) for PUT request\n* file_key: Unique storage key to use as source_id in campaign creation\n* expires_in: URL expiration time in seconds","operationId":"get_presigned_upload_url_api_v1_s3_presigned_upload_url_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignedUploadUrlRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignedUploadUrlResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/service-keys":{"get":{"tags":["main"],"summary":"Get Service Keys","description":"Get all service keys for the user's organization.","operationId":"get_service_keys_api_v1_user_service_keys_get","parameters":[{"name":"include_archived","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Archived"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ServiceKeyResponse"},"title":"Response Get Service Keys Api V1 User Service Keys Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main"],"summary":"Create Service Key","description":"Create a new service key for the user's organization.","operationId":"create_service_key_api_v1_user_service_keys_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateServiceKeyRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateServiceKeyResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/service-keys/{service_key_id}":{"delete":{"tags":["main"],"summary":"Archive Service Key","description":"Archive a service key.","operationId":"archive_service_key_api_v1_user_service_keys__service_key_id__delete","parameters":[{"name":"service_key_id","in":"path","required":true,"schema":{"type":"string","title":"Service Key Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/user/service-keys/{service_key_id}/reactivate":{"put":{"tags":["main"],"summary":"Reactivate Service Key","description":"Reactivate an archived service key.\n\nNote: This endpoint is provided for API compatibility but service key\nreactivation is not supported by MPS. Once archived, a service key\ncannot be reactivated and a new key must be created instead.","operationId":"reactivate_service_key_api_v1_user_service_keys__service_key_id__reactivate_put","parameters":[{"name":"service_key_id","in":"path","required":true,"schema":{"type":"string","title":"Service Key Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/current-period":{"get":{"tags":["main"],"summary":"Get Current Period Usage","description":"Get current billing period usage for the user's organization.","operationId":"get_current_period_usage_api_v1_organizations_usage_current_period_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CurrentUsageResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/mps-credits":{"get":{"tags":["main"],"summary":"Get Mps Credits","description":"Get aggregated usage and quota from MPS.\n\nOSS users: queries by provider_id (created_by).\nHosted users: queries by organization_id.","operationId":"get_mps_credits_api_v1_organizations_usage_mps_credits_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MPSCreditsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/runs":{"get":{"tags":["main"],"summary":"Get Usage History","description":"Get paginated workflow runs with usage for the organization.","operationId":"get_usage_history_api_v1_organizations_usage_runs_get","parameters":[{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`.","examples":["2026-04-01T00:00:00Z"],"title":"Start Date"},"description":"ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`."},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`.","examples":["2026-05-01T00:00:00Z"],"title":"End Date"},"description":"ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`."},{"name":"page","in":"query","required":false,"schema":{"type":"integer","minimum":1,"default":1,"title":"Page"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":50,"title":"Limit"}},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded array of filter objects. Each object has the shape:\n\n```json\n{ \"attribute\": \"\", \"type\": \"\", \"value\": }\n```\n\nSupported `attribute` / `type` / `value` combinations:\n\n| attribute | type | value shape | matches |\n|-----------------|---------------|----------------------------------------------|------------------------------------------------------|\n| `runId` | `number` | `{ \"value\": 12345 }` | exact run id |\n| `workflowId` | `number` | `{ \"value\": 42 }` | exact agent (workflow) id |\n| `campaignId` | `number` | `{ \"value\": 7 }` | exact campaign id |\n| `callerNumber` | `text` | `{ \"value\": \"415555\" }` | substring match on `initial_context.caller_number` |\n| `calledNumber` | `text` | `{ \"value\": \"9911848\" }` | substring match on `initial_context.called_number` |\n| `dispositionCode` | `multiSelect` | `{ \"codes\": [\"XFER\", \"DNC\"] }` | any of the codes in `gathered_context.mapped_call_disposition` |\n| `duration` | `numberRange` | `{ \"min\": 60, \"max\": 300 }` | call duration (seconds), inclusive bounds |\n\nUnknown attributes and unsupported `type` values are silently ignored.\n\nDate filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.\n","examples":["[{\"attribute\":\"callerNumber\",\"type\":\"text\",\"value\":{\"value\":\"415555\"}}]","[{\"attribute\":\"campaignId\",\"type\":\"number\",\"value\":{\"value\":7}},{\"attribute\":\"duration\",\"type\":\"numberRange\",\"value\":{\"min\":60,\"max\":300}}]","[{\"attribute\":\"dispositionCode\",\"type\":\"multiSelect\",\"value\":{\"codes\":[\"XFER\",\"DNC\"]}}]"],"title":"Filters"},"description":"JSON-encoded array of filter objects. Each object has the shape:\n\n```json\n{ \"attribute\": \"\", \"type\": \"\", \"value\": }\n```\n\nSupported `attribute` / `type` / `value` combinations:\n\n| attribute | type | value shape | matches |\n|-----------------|---------------|----------------------------------------------|------------------------------------------------------|\n| `runId` | `number` | `{ \"value\": 12345 }` | exact run id |\n| `workflowId` | `number` | `{ \"value\": 42 }` | exact agent (workflow) id |\n| `campaignId` | `number` | `{ \"value\": 7 }` | exact campaign id |\n| `callerNumber` | `text` | `{ \"value\": \"415555\" }` | substring match on `initial_context.caller_number` |\n| `calledNumber` | `text` | `{ \"value\": \"9911848\" }` | substring match on `initial_context.called_number` |\n| `dispositionCode` | `multiSelect` | `{ \"codes\": [\"XFER\", \"DNC\"] }` | any of the codes in `gathered_context.mapped_call_disposition` |\n| `duration` | `numberRange` | `{ \"min\": 60, \"max\": 300 }` | call duration (seconds), inclusive bounds |\n\nUnknown attributes and unsupported `type` values are silently ignored.\n\nDate filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.\n"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UsageHistoryResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/runs/report":{"get":{"tags":["main"],"summary":"Download Usage Runs Report","description":"Download a CSV of runs matching the same filters as `/usage/runs`.","operationId":"download_usage_runs_report_api_v1_organizations_usage_runs_report_get","parameters":[{"name":"start_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`.","title":"Start Date"},"description":"ISO 8601 date-time string (UTC). Lower bound (inclusive) on `created_at`."},{"name":"end_date","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`.","title":"End Date"},"description":"ISO 8601 date-time string (UTC). Upper bound (inclusive) on `created_at`."},{"name":"filters","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"JSON-encoded array of filter objects. Each object has the shape:\n\n```json\n{ \"attribute\": \"\", \"type\": \"\", \"value\": }\n```\n\nSupported `attribute` / `type` / `value` combinations:\n\n| attribute | type | value shape | matches |\n|-----------------|---------------|----------------------------------------------|------------------------------------------------------|\n| `runId` | `number` | `{ \"value\": 12345 }` | exact run id |\n| `workflowId` | `number` | `{ \"value\": 42 }` | exact agent (workflow) id |\n| `campaignId` | `number` | `{ \"value\": 7 }` | exact campaign id |\n| `callerNumber` | `text` | `{ \"value\": \"415555\" }` | substring match on `initial_context.caller_number` |\n| `calledNumber` | `text` | `{ \"value\": \"9911848\" }` | substring match on `initial_context.called_number` |\n| `dispositionCode` | `multiSelect` | `{ \"codes\": [\"XFER\", \"DNC\"] }` | any of the codes in `gathered_context.mapped_call_disposition` |\n| `duration` | `numberRange` | `{ \"min\": 60, \"max\": 300 }` | call duration (seconds), inclusive bounds |\n\nUnknown attributes and unsupported `type` values are silently ignored.\n\nDate filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.\n","title":"Filters"},"description":"JSON-encoded array of filter objects. Each object has the shape:\n\n```json\n{ \"attribute\": \"\", \"type\": \"\", \"value\": }\n```\n\nSupported `attribute` / `type` / `value` combinations:\n\n| attribute | type | value shape | matches |\n|-----------------|---------------|----------------------------------------------|------------------------------------------------------|\n| `runId` | `number` | `{ \"value\": 12345 }` | exact run id |\n| `workflowId` | `number` | `{ \"value\": 42 }` | exact agent (workflow) id |\n| `campaignId` | `number` | `{ \"value\": 7 }` | exact campaign id |\n| `callerNumber` | `text` | `{ \"value\": \"415555\" }` | substring match on `initial_context.caller_number` |\n| `calledNumber` | `text` | `{ \"value\": \"9911848\" }` | substring match on `initial_context.called_number` |\n| `dispositionCode` | `multiSelect` | `{ \"codes\": [\"XFER\", \"DNC\"] }` | any of the codes in `gathered_context.mapped_call_disposition` |\n| `duration` | `numberRange` | `{ \"min\": 60, \"max\": 300 }` | call duration (seconds), inclusive bounds |\n\nUnknown attributes and unsupported `type` values are silently ignored.\n\nDate filtering on this endpoint is done via the dedicated `start_date` / `end_date` query params, not via a `dateRange` filter object.\n"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/daily-breakdown":{"get":{"tags":["main"],"summary":"Get Daily Usage Breakdown","description":"Get daily usage breakdown for the last N days. Only available for organizations with pricing.","operationId":"get_daily_usage_breakdown_api_v1_organizations_usage_daily_breakdown_get","parameters":[{"name":"days","in":"query","required":false,"schema":{"type":"integer","maximum":30,"minimum":1,"description":"Number of days to include","default":7,"title":"Days"},"description":"Number of days to include"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DailyUsageBreakdownResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/reports/daily":{"get":{"tags":["main"],"summary":"Get Daily Report","description":"Get daily report for the specified date and timezone.\nIf workflow_id is provided, filters results to that specific workflow.\nIf workflow_id is None, includes all workflows for the organization.","operationId":"get_daily_report_api_v1_organizations_reports_daily_get","parameters":[{"name":"date","in":"query","required":true,"schema":{"type":"string","description":"Date in YYYY-MM-DD format","title":"Date"},"description":"Date in YYYY-MM-DD format"},{"name":"timezone","in":"query","required":true,"schema":{"type":"string","description":"IANA timezone (e.g., 'America/New_York')","title":"Timezone"},"description":"IANA timezone (e.g., 'America/New_York')"},{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Optional workflow ID to filter by","title":"Workflow Id"},"description":"Optional workflow ID to filter by"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DailyReportResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/reports/workflows":{"get":{"tags":["main"],"summary":"Get Workflow Options","description":"Get all workflows for the user's organization.\nUsed to populate the workflow selector dropdown in the reports page.","operationId":"get_workflow_options_api_v1_organizations_reports_workflows_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowOption"},"title":"Response Get Workflow Options Api V1 Organizations Reports Workflows Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/reports/daily/runs":{"get":{"tags":["main"],"summary":"Get Daily Runs Detail","description":"Get detailed workflow runs for the specified date.\nUsed for CSV export functionality.","operationId":"get_daily_runs_detail_api_v1_organizations_reports_daily_runs_get","parameters":[{"name":"date","in":"query","required":true,"schema":{"type":"string","description":"Date in YYYY-MM-DD format","title":"Date"},"description":"Date in YYYY-MM-DD format"},{"name":"timezone","in":"query","required":true,"schema":{"type":"string","description":"IANA timezone (e.g., 'America/New_York')","title":"Timezone"},"description":"IANA timezone (e.g., 'America/New_York')"},{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Optional workflow ID to filter by","title":"Workflow Id"},"description":"Optional workflow ID to filter by"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkflowRunDetail"},"title":"Response Get Daily Runs Detail Api V1 Organizations Reports Daily Runs Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/turn/credentials":{"get":{"tags":["main","turn"],"summary":"Get Turn Credentials","description":"Get time-limited TURN credentials for WebRTC connections.\n\nThis endpoint generates ephemeral TURN credentials that are:\n- Valid for the configured TTL (default: 24 hours)\n- Cryptographically bound to the user via HMAC\n- Compatible with coturn's use-auth-secret mode\n\nReturns:\n TurnCredentialsResponse with username, password, ttl, and TURN URIs","operationId":"get_turn_credentials_api_v1_turn_credentials_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TurnCredentialsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/embed/init":{"post":{"tags":["main"],"summary":"Initialize Embed Session","description":"Initialize an embed session with token validation and domain checking.\n\nThis endpoint:\n1. Validates the embed token\n2. Checks domain whitelist\n3. Creates a workflow run\n4. Generates a temporary session token\n5. Returns configuration for the widget","operationId":"initialize_embed_session_api_v1_public_embed_init_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InitEmbedRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InitEmbedResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"options":{"tags":["main"],"summary":"Options Init","description":"Fallback OPTIONS handler for init endpoint.","operationId":"options_init_api_v1_public_embed_init_options","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"}}}},"/api/v1/public/embed/config/{token}":{"options":{"tags":["main"],"summary":"Options Embed Config","description":"Fallback OPTIONS handler for the embed config endpoint.\n\nBrowser preflights include Access-Control-Request-Method and are handled by\nPublicEmbedCORSMiddleware before global CORS. This keeps non-conformant\nOPTIONS requests on the same validation path.","operationId":"options_embed_config_api_v1_public_embed_config__token__options","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","title":"Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["main"],"summary":"Get Embed Config","description":"Get embed configuration without creating a session.\n\nThis endpoint is used to fetch widget configuration for display purposes\nwithout actually starting a call session.","operationId":"get_embed_config_api_v1_public_embed_config__token__get","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","title":"Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmbedConfigResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/embed/turn-credentials/{session_token}":{"get":{"tags":["main"],"summary":"Get Public Turn Credentials","description":"Get TURN credentials for an embed session.\n\nThis endpoint allows embedded widgets to obtain TURN server credentials\nfor WebRTC connections without requiring authentication.\n\nArgs:\n session_token: The session token from embed initialization\n\nReturns:\n TurnCredentialsResponse with username, password, ttl, and TURN URIs","operationId":"get_public_turn_credentials_api_v1_public_embed_turn_credentials__session_token__get","parameters":[{"name":"session_token","in":"path","required":true,"schema":{"type":"string","title":"Session Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TurnCredentialsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"options":{"tags":["main"],"summary":"Options Turn Credentials","description":"Fallback OPTIONS handler for TURN credentials endpoint.","operationId":"options_turn_credentials_api_v1_public_embed_turn_credentials__session_token__options","parameters":[{"name":"session_token","in":"path","required":true,"schema":{"type":"string","title":"Session Token"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/agent/{uuid}":{"post":{"tags":["main"],"summary":"Initiate Call","description":"Initiate a phone call against the published agent.\n\nExecutes the workflow's currently released definition.","operationId":"initiate_call_api_v1_public_agent__uuid__post","parameters":[{"name":"uuid","in":"path","required":true,"schema":{"type":"string","title":"Uuid"}},{"name":"X-API-Key","in":"header","required":true,"schema":{"type":"string","title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/agent/test/{uuid}":{"post":{"tags":["main"],"summary":"Initiate Call Test","description":"Initiate a phone call against the latest draft of the agent.\n\nUseful for verifying changes before publishing. Falls back to the\npublished definition when no draft exists.","operationId":"initiate_call_test_api_v1_public_agent_test__uuid__post","parameters":[{"name":"uuid","in":"path","required":true,"schema":{"type":"string","title":"Uuid"}},{"name":"X-API-Key","in":"header","required":true,"schema":{"type":"string","title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/agent/workflow/{workflow_uuid}":{"post":{"tags":["main"],"summary":"Initiate Call By Workflow Uuid","description":"Initiate a phone call against the published workflow identified by UUID.","operationId":"initiate_call_by_workflow_uuid_api_v1_public_agent_workflow__workflow_uuid__post","parameters":[{"name":"workflow_uuid","in":"path","required":true,"schema":{"type":"string","title":"Workflow Uuid"}},{"name":"X-API-Key","in":"header","required":true,"schema":{"type":"string","title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/agent/test/workflow/{workflow_uuid}":{"post":{"tags":["main"],"summary":"Initiate Call Test By Workflow Uuid","description":"Initiate a phone call against the latest draft of the workflow by UUID.","operationId":"initiate_call_test_by_workflow_uuid_api_v1_public_agent_test_workflow__workflow_uuid__post","parameters":[{"name":"workflow_uuid","in":"path","required":true,"schema":{"type":"string","title":"Workflow Uuid"}},{"name":"X-API-Key","in":"header","required":true,"schema":{"type":"string","title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TriggerCallResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/public/download/workflow/{token}/{artifact_type}":{"get":{"tags":["main"],"summary":"Download Workflow Artifact","description":"Download a workflow recording or transcript via public access token.\n\nThis endpoint:\n1. Validates the public access token\n2. Looks up the corresponding workflow run\n3. Generates a signed URL for the requested artifact\n4. Redirects to the signed URL\n\nArgs:\n token: The public access token (UUID format)\n artifact_type: Type of artifact - \"recording\" or \"transcript\"\n inline: If true, sets Content-Disposition to inline for browser preview\n\nReturns:\n RedirectResponse to the signed URL (302 redirect)\n\nRaises:\n HTTPException 404: If token is invalid or artifact not found","operationId":"download_workflow_artifact_api_v1_public_download_workflow__token___artifact_type__get","parameters":[{"name":"token","in":"path","required":true,"schema":{"type":"string","title":"Token"}},{"name":"artifact_type","in":"path","required":true,"schema":{"enum":["recording","transcript"],"type":"string","title":"Artifact Type"}},{"name":"inline","in":"query","required":false,"schema":{"type":"boolean","description":"Display inline in browser instead of download","default":false,"title":"Inline"},"description":"Display inline in browser instead of download"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow/{workflow_id}/embed-token":{"post":{"tags":["main"],"summary":"Create Or Update Embed Token","description":"Create or update an embed token for a workflow.\nEach workflow can have only one active embed token.","operationId":"create_or_update_embed_token_api_v1_workflow__workflow_id__embed_token_post","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmbedTokenRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EmbedTokenResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["main"],"summary":"Get Embed Token","description":"Get the embed token for a workflow if it exists.","operationId":"get_embed_token_api_v1_workflow__workflow_id__embed_token_get","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/EmbedTokenResponse"},{"type":"null"}],"title":"Response Get Embed Token Api V1 Workflow Workflow Id Embed Token Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main"],"summary":"Deactivate Embed Token","description":"Deactivate the embed token for a workflow.","operationId":"deactivate_embed_token_api_v1_workflow__workflow_id__embed_token_delete","parameters":[{"name":"workflow_id","in":"path","required":true,"schema":{"type":"integer","title":"Workflow Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"title":"Response Deactivate Embed Token Api V1 Workflow Workflow Id Embed Token Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/knowledge-base/upload-url":{"post":{"tags":["main","knowledge-base"],"summary":"Get presigned URL for document upload","description":"Generate a presigned PUT URL for uploading a document.\n\nThis endpoint:\n1. Generates a unique document UUID for organizing the S3 key\n2. Generates a presigned S3/MinIO URL for uploading the file\n3. Returns the upload URL and document metadata\n\nAfter uploading to the returned URL, call /process-document to create\nthe document record and trigger processing.\n\nAccess Control:\n* All authenticated users can upload documents scoped to their organization.","operationId":"get_upload_url_api_v1_knowledge_base_upload_url_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentUploadRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentUploadResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/knowledge-base/process-document":{"post":{"tags":["main","knowledge-base"],"summary":"Trigger document processing","description":"Trigger asynchronous processing of an uploaded document.\n\nThis endpoint should be called after successfully uploading a file to the presigned URL.\nIt will:\n1. Create a document record in the database with the specified UUID\n2. Enqueue a background task to process the document (chunking and embedding)\n\nThe document status will be updated from 'pending' -> 'processing' -> 'completed' or 'failed'.\n\nEmbedding:\nUses OpenAI text-embedding-3-small (1536-dimensional embeddings, requires API key configured in Model Configurations).\n\nAccess Control:\n* Users can only process documents in their organization.","operationId":"process_document_api_v1_knowledge_base_process_document_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ProcessDocumentRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/knowledge-base/documents":{"get":{"tags":["main","knowledge-base"],"summary":"List documents","description":"List all documents for the user's organization.\n\nAccess Control:\n* Users can only see documents from their organization.","operationId":"list_documents_api_v1_knowledge_base_documents_get","parameters":[{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by processing status","title":"Status"},"description":"Filter by processing status"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"default":100,"title":"Limit"}},{"name":"offset","in":"query","required":false,"schema":{"type":"integer","minimum":0,"default":0,"title":"Offset"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_documents","x-sdk-description":"List knowledge base documents available to the authenticated organization."}},"/api/v1/knowledge-base/documents/{document_uuid}":{"get":{"tags":["main","knowledge-base"],"summary":"Get document details","description":"Get details of a specific document.\n\nAccess Control:\n* Users can only access documents from their organization.","operationId":"get_document_api_v1_knowledge_base_documents__document_uuid__get","parameters":[{"name":"document_uuid","in":"path","required":true,"schema":{"type":"string","title":"Document Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main","knowledge-base"],"summary":"Delete document","description":"Soft delete a document and its chunks.\n\nAccess Control:\n* Users can only delete documents from their organization.","operationId":"delete_document_api_v1_knowledge_base_documents__document_uuid__delete","parameters":[{"name":"document_uuid","in":"path","required":true,"schema":{"type":"string","title":"Document Uuid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/knowledge-base/search":{"post":{"tags":["main","knowledge-base"],"summary":"Search for similar chunks","description":"Search for document chunks similar to the query.\n\nThis endpoint uses vector similarity search to find relevant chunks.\nResults are returned without threshold filtering - apply similarity\nthresholds at the application layer after optional reranking.\n\nAccess Control:\n* Users can only search documents from their organization.","operationId":"search_chunks_api_v1_knowledge_base_search_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChunkSearchRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChunkSearchResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow-recordings/upload-url":{"post":{"tags":["main","workflow-recordings"],"summary":"Get presigned URLs for recording uploads","description":"Generate presigned PUT URLs for uploading one or more audio recordings.","operationId":"get_upload_urls_api_v1_workflow_recordings_upload_url_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchRecordingUploadRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchRecordingUploadResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow-recordings/":{"post":{"tags":["main","workflow-recordings"],"summary":"Create recording records after upload","description":"Create one or more recording records after audio files have been uploaded.","operationId":"create_recordings_api_v1_workflow_recordings__post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchRecordingCreateRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BatchRecordingCreateResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"get":{"tags":["main","workflow-recordings"],"summary":"List recordings","description":"List recordings for the organization, optionally filtered.","operationId":"list_recordings_api_v1_workflow_recordings__get","parameters":[{"name":"workflow_id","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Filter by workflow ID","title":"Workflow Id"},"description":"Filter by workflow ID"},{"name":"tts_provider","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by TTS provider","title":"Tts Provider"},"description":"Filter by TTS provider"},{"name":"tts_model","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by TTS model","title":"Tts Model"},"description":"Filter by TTS model"},{"name":"tts_voice_id","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter by TTS voice ID","title":"Tts Voice Id"},"description":"Filter by TTS voice ID"},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordingListResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_recordings","x-sdk-description":"List workflow recordings available to the authenticated organization."}},"/api/v1/workflow-recordings/{recording_id}":{"delete":{"tags":["main","workflow-recordings"],"summary":"Delete a recording","description":"Soft delete a recording.","operationId":"delete_recording_api_v1_workflow_recordings__recording_id__delete","parameters":[{"name":"recording_id","in":"path","required":true,"schema":{"type":"string","title":"Recording Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow-recordings/{id}":{"patch":{"tags":["main","workflow-recordings"],"summary":"Update a recording's Recording ID","description":"Update the recording_id (descriptive name) of a recording.","operationId":"update_recording_api_v1_workflow_recordings__id__patch","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","title":"Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordingUpdateRequestSchema"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordingResponseSchema"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/workflow-recordings/transcribe":{"post":{"tags":["main","workflow-recordings"],"summary":"Transcribe an audio file","description":"Transcribe an uploaded audio file using MPS STT.","operationId":"transcribe_audio_api_v1_workflow_recordings_transcribe_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_transcribe_audio_api_v1_workflow_recordings_transcribe_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/folder/":{"get":{"tags":["main"],"summary":"List Folders","description":"List all folders in the authenticated user's organization.","operationId":"list_folders_api_v1_folder__get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/FolderResponse"},"title":"Response List Folders Api V1 Folder Get"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"post":{"tags":["main"],"summary":"Create Folder","description":"Create a new folder in the authenticated user's organization.","operationId":"create_folder_api_v1_folder__post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateFolderRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/folder/{folder_id}":{"put":{"tags":["main"],"summary":"Rename Folder","description":"Rename a folder owned by the authenticated user's organization.","operationId":"rename_folder_api_v1_folder__folder_id__put","parameters":[{"name":"folder_id","in":"path","required":true,"schema":{"type":"integer","title":"Folder Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateFolderRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"delete":{"tags":["main"],"summary":"Delete Folder","description":"Delete a folder. Member agents are moved to \"Uncategorized\", not deleted.","operationId":"delete_folder_api_v1_folder__folder_id__delete","parameters":[{"name":"folder_id","in":"path","required":true,"schema":{"type":"integer","title":"Folder Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"boolean"},"title":"Response Delete Folder Api V1 Folder Folder Id Delete"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/signup":{"post":{"tags":["main","auth"],"summary":"Signup","operationId":"signup_api_v1_auth_signup_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SignupRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/login":{"post":{"tags":["main","auth"],"summary":"Login","operationId":"login_api_v1_auth_login_post","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/auth/me":{"get":{"tags":["main","auth"],"summary":"Get Current User","operationId":"get_current_user_api_v1_auth_me_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/node-types":{"get":{"tags":["main"],"summary":"List Node Types","description":"List every registered NodeSpec.\n\nSDK clients should pin to `spec_version` and warn if the server reports\na higher version than what they were generated against.","operationId":"list_node_types_api_v1_node_types_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeTypesResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"list_node_types","x-sdk-description":"List every registered node type with its spec. Pinned to spec_version."}},"/api/v1/node-types/{name}":{"get":{"tags":["main"],"summary":"Get Node Type","operationId":"get_node_type_api_v1_node_types__name__get","parameters":[{"name":"name","in":"path","required":true,"schema":{"type":"string","title":"Name"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NodeSpec"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}},"x-sdk-method":"get_node_type","x-sdk-description":"Fetch a single node spec by name."}},"/api/v1/health":{"get":{"tags":["main"],"summary":"Health","operationId":"health_api_v1_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}},"404":{"description":"Not found"}}}}},"components":{"schemas":{"APIKeyResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"key_prefix":{"type":"string","title":"Key Prefix"},"is_active":{"type":"boolean","title":"Is Active"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"last_used_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Used At"},"archived_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Archived At"}},"type":"object","required":["id","name","key_prefix","is_active","created_at"],"title":"APIKeyResponse"},"APIKeyStatus":{"properties":{"model":{"type":"string","title":"Model"},"message":{"type":"string","title":"Message"}},"type":"object","required":["model","message"],"title":"APIKeyStatus"},"APIKeyStatusResponse":{"properties":{"status":{"items":{"$ref":"#/components/schemas/APIKeyStatus"},"type":"array","title":"Status"}},"type":"object","required":["status"],"title":"APIKeyStatusResponse"},"ARIConfigurationRequest":{"properties":{"provider":{"type":"string","const":"ari","title":"Provider","default":"ari"},"ari_endpoint":{"type":"string","title":"Ari Endpoint","description":"ARI base URL (e.g., http://asterisk.example.com:8088)"},"app_name":{"type":"string","title":"App Name","description":"Stasis application name registered in Asterisk"},"app_password":{"type":"string","title":"App Password","description":"ARI user password"},"ws_client_name":{"type":"string","title":"Ws Client Name","description":"websocket_client.conf connection name for externalMedia (e.g., dograh_staging)","default":""},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of SIP extensions/numbers for outbound calls (optional)"}},"type":"object","required":["ari_endpoint","app_name","app_password"],"title":"ARIConfigurationRequest","description":"Request schema for Asterisk ARI configuration."},"ARIConfigurationResponse":{"properties":{"provider":{"type":"string","const":"ari","title":"Provider","default":"ari"},"ari_endpoint":{"type":"string","title":"Ari Endpoint"},"app_name":{"type":"string","title":"App Name"},"app_password":{"type":"string","title":"App Password"},"ws_client_name":{"type":"string","title":"Ws Client Name","default":""},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["ari_endpoint","app_name","app_password","from_numbers"],"title":"ARIConfigurationResponse","description":"Response schema for ARI configuration with masked sensitive fields."},"AWSBedrockLLMConfiguration":{"properties":{"provider":{"type":"string","const":"aws_bedrock","title":"Provider","default":"aws_bedrock"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Api Key","description":"Not used for Bedrock \u2014 authentication is via the AWS credentials above. Leave blank."},"model":{"type":"string","title":"Model","description":"Bedrock model ID \u2014 include the region inference-profile prefix (e.g. 'us.').","default":"us.amazon.nova-pro-v1:0","examples":["us.amazon.nova-pro-v1:0","us.amazon.nova-lite-v1:0","us.amazon.nova-micro-v1:0","us.anthropic.claude-sonnet-4-20250514-v1:0","us.anthropic.claude-3-5-sonnet-20241022-v2:0","us.anthropic.claude-haiku-4-5-20251001-v1:0"],"allow_custom_input":true},"aws_access_key":{"type":"string","title":"Aws Access Key","description":"AWS access key ID with bedrock:InvokeModel permission.","default":""},"aws_secret_key":{"type":"string","title":"Aws Secret Key","description":"AWS secret access key paired with the access key ID.","default":""},"aws_region":{"type":"string","title":"Aws Region","description":"AWS region where the Bedrock model is available.","default":"us-east-1"}},"type":"object","title":"AWS Bedrock"},"AmbientNoiseUploadRequest":{"properties":{"workflow_id":{"type":"integer","title":"Workflow Id"},"filename":{"type":"string","title":"Filename"},"mime_type":{"type":"string","title":"Mime Type","default":"audio/wav"},"file_size":{"type":"integer","maximum":10485760.0,"exclusiveMinimum":0.0,"title":"File Size","description":"Max 10MB"}},"type":"object","required":["workflow_id","filename","file_size"],"title":"AmbientNoiseUploadRequest"},"AmbientNoiseUploadResponse":{"properties":{"upload_url":{"type":"string","title":"Upload Url"},"storage_key":{"type":"string","title":"Storage Key"},"storage_backend":{"type":"string","title":"Storage Backend"}},"type":"object","required":["upload_url","storage_key","storage_backend"],"title":"AmbientNoiseUploadResponse"},"AppendTextChatMessageRequest":{"properties":{"text":{"type":"string","minLength":1,"title":"Text"},"expected_revision":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Expected Revision"}},"type":"object","required":["text"],"title":"AppendTextChatMessageRequest"},"AssemblyAISTTConfiguration":{"properties":{"provider":{"type":"string","const":"assemblyai","title":"Provider","default":"assemblyai"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"AssemblyAI realtime STT model.","default":"u3-rt-pro","examples":["u3-rt-pro"]},"language":{"type":"string","title":"Language","description":"ISO 639-1 language code.","default":"en","examples":["en","es","de","fr","pt","it"]}},"type":"object","required":["api_key"],"title":"AssemblyAI"},"AuthResponse":{"properties":{"token":{"type":"string","title":"Token"},"user":{"$ref":"#/components/schemas/UserResponse"}},"type":"object","required":["token","user"],"title":"AuthResponse"},"AuthUserResponse":{"properties":{"id":{"type":"integer","title":"Id"},"is_superuser":{"type":"boolean","title":"Is Superuser"}},"type":"object","required":["id","is_superuser"],"title":"AuthUserResponse"},"AzureLLMService":{"properties":{"provider":{"type":"string","const":"azure","title":"Provider","default":"azure"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Azure deployment name (not the upstream OpenAI model id).","default":"gpt-4.1-mini","examples":["gpt-4.1-mini"],"allow_custom_input":true},"endpoint":{"type":"string","title":"Endpoint","description":"Azure OpenAI resource endpoint (e.g. https://.openai.azure.com)."}},"type":"object","required":["api_key","endpoint"],"title":"Azure OpenAI"},"AzureOpenAIEmbeddingsConfiguration":{"properties":{"provider":{"type":"string","const":"azure","title":"Provider","default":"azure"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Azure OpenAI embedding deployment name. The deployment must return 1536-dimensional embeddings.","default":"text-embedding-3-small","examples":["text-embedding-3-small","text-embedding-ada-002"],"allow_custom_input":true},"endpoint":{"type":"string","title":"Endpoint","description":"Azure OpenAI resource endpoint (e.g. https://.openai.azure.com)."},"api_version":{"type":"string","title":"Api Version","description":"Azure OpenAI API version for embeddings.","default":"2024-02-15-preview"}},"type":"object","required":["api_key","endpoint"],"title":"Azure OpenAI"},"AzureRealtimeLLMConfiguration":{"properties":{"provider":{"type":"string","const":"azure_realtime","title":"Provider","default":"azure_realtime"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Azure OpenAI realtime deployment name.","default":"gpt-4o-realtime-preview","examples":["gpt-4o-realtime-preview"],"allow_custom_input":true},"endpoint":{"type":"string","title":"Endpoint","description":"Azure OpenAI resource endpoint (e.g. https://.openai.azure.com)."},"voice":{"type":"string","title":"Voice","description":"Voice the model speaks in.","default":"alloy","examples":["alloy","ash","ballad","coral","echo","sage","shimmer","verse"],"allow_custom_input":true},"api_version":{"type":"string","title":"Api Version","description":"Azure OpenAI API version.","default":"2025-04-01-preview","examples":["2025-04-01-preview","2024-10-01-preview","2024-12-17"]}},"type":"object","required":["api_key","endpoint"],"title":"Azure OpenAI Realtime","description":"Azure OpenAI Realtime API \u2014 low-latency speech-to-speech conversations.","provider_docs_url":"https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/realtime-audio-quickstart"},"AzureSpeechSTTConfiguration":{"properties":{"provider":{"type":"string","const":"azure_speech","title":"Provider","default":"azure_speech"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Azure Speech recognition model (use 'latest_long' for continuous recognition).","default":"latest_long","examples":["latest_long","latest_short"]},"region":{"type":"string","title":"Region","description":"Azure region for Speech Services (e.g. 'eastus', 'westeurope').","default":"eastus","examples":["eastus","eastus2","westus","westus2","westus3","centralus","northcentralus","southcentralus","westcentralus","westeurope","northeurope","uksouth","ukwest","francecentral","switzerlandnorth","germanywestcentral","norwayeast","australiaeast","eastasia","southeastasia","japaneast","japanwest","koreacentral","centralindia","southindia","brazilsouth"]},"language":{"type":"string","title":"Language","description":"BCP-47 language code for recognition.","default":"en-US","examples":["en-US","en-GB","en-AU","en-CA","en-IN","es-ES","es-MX","fr-FR","fr-CA","de-DE","it-IT","ja-JP","ko-KR","zh-CN","pt-BR","pt-PT","ru-RU","ar-SA","nl-NL","pl-PL","hi-IN"],"allow_custom_input":true}},"type":"object","required":["api_key"],"title":"Azure Speech Services","description":"Azure Cognitive Services Speech \u2014 TTS and STT via the Azure Speech SDK.","provider_docs_url":"https://learn.microsoft.com/en-us/azure/ai-services/speech-service/"},"AzureSpeechTTSConfiguration":{"properties":{"provider":{"type":"string","const":"azure_speech","title":"Provider","default":"azure_speech"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Azure Speech synthesis engine (neural voices only).","default":"neural","examples":["neural"]},"region":{"type":"string","title":"Region","description":"Azure region for Speech Services (e.g. 'eastus', 'westeurope').","default":"eastus","examples":["eastus","eastus2","westus","westus2","westus3","centralus","northcentralus","southcentralus","westcentralus","westeurope","northeurope","uksouth","ukwest","francecentral","switzerlandnorth","germanywestcentral","norwayeast","australiaeast","eastasia","southeastasia","japaneast","japanwest","koreacentral","centralindia","southindia","brazilsouth"]},"voice":{"type":"string","title":"Voice","description":"Azure Neural voice name (e.g. 'en-US-AriaNeural').","default":"en-US-AriaNeural","examples":["en-US-AriaNeural","en-US-GuyNeural","en-US-JennyNeural","en-US-DavisNeural","en-US-AmberNeural","en-US-AnaNeural","en-US-AshleyNeural","en-US-BrandonNeural","en-US-ChristopherNeural","en-US-ElizabethNeural","en-US-EricNeural","en-US-JacobNeural","en-US-MichelleNeural","en-US-MonicaNeural","en-US-NancyNeural","en-US-RogerNeural","en-US-SaraNeural","en-US-SteffanNeural","en-US-TonyNeural"],"allow_custom_input":true},"language":{"type":"string","title":"Language","description":"BCP-47 language code for synthesis.","default":"en-US","examples":["en-US","en-GB","en-AU","en-CA","en-IN","es-ES","es-MX","fr-FR","fr-CA","de-DE","it-IT","ja-JP","ko-KR","zh-CN","zh-HK","zh-TW","pt-BR","pt-PT","ru-RU","ar-SA","nl-NL","pl-PL","sv-SE","hi-IN"],"allow_custom_input":true},"speed":{"type":"number","maximum":2.0,"minimum":0.5,"title":"Speed","description":"Speech speed multiplier (0.5 to 2.0).","default":1.0}},"type":"object","required":["api_key"],"title":"Azure Speech Services","description":"Azure Cognitive Services Speech \u2014 TTS and STT via the Azure Speech SDK.","provider_docs_url":"https://learn.microsoft.com/en-us/azure/ai-services/speech-service/"},"BYOKAIModelConfiguration":{"properties":{"mode":{"type":"string","enum":["pipeline","realtime"],"title":"Mode"},"pipeline":{"anyOf":[{"$ref":"#/components/schemas/BYOKPipelineAIModelConfiguration"},{"type":"null"}]},"realtime":{"anyOf":[{"$ref":"#/components/schemas/BYOKRealtimeAIModelConfiguration"},{"type":"null"}]}},"type":"object","required":["mode"],"title":"BYOKAIModelConfiguration"},"BYOKPipelineAIModelConfiguration":{"properties":{"llm":{"oneOf":[{"$ref":"#/components/schemas/OpenAILLMService"},{"$ref":"#/components/schemas/GoogleVertexLLMConfiguration"},{"$ref":"#/components/schemas/GroqLLMService"},{"$ref":"#/components/schemas/OpenRouterLLMConfiguration"},{"$ref":"#/components/schemas/GoogleLLMService"},{"$ref":"#/components/schemas/AzureLLMService"},{"$ref":"#/components/schemas/DograhLLMService"},{"$ref":"#/components/schemas/AWSBedrockLLMConfiguration"},{"$ref":"#/components/schemas/SpeachesLLMConfiguration"},{"$ref":"#/components/schemas/MiniMaxLLMConfiguration"},{"$ref":"#/components/schemas/SarvamLLMConfiguration"}],"title":"Llm","discriminator":{"propertyName":"provider","mapping":{"aws_bedrock":"#/components/schemas/AWSBedrockLLMConfiguration","azure":"#/components/schemas/AzureLLMService","dograh":"#/components/schemas/DograhLLMService","google":"#/components/schemas/GoogleLLMService","google_vertex":"#/components/schemas/GoogleVertexLLMConfiguration","groq":"#/components/schemas/GroqLLMService","minimax":"#/components/schemas/MiniMaxLLMConfiguration","openai":"#/components/schemas/OpenAILLMService","openrouter":"#/components/schemas/OpenRouterLLMConfiguration","sarvam":"#/components/schemas/SarvamLLMConfiguration","speaches":"#/components/schemas/SpeachesLLMConfiguration"}}},"tts":{"oneOf":[{"$ref":"#/components/schemas/DeepgramTTSConfiguration"},{"$ref":"#/components/schemas/GoogleTTSConfiguration"},{"$ref":"#/components/schemas/OpenAITTSService"},{"$ref":"#/components/schemas/ElevenlabsTTSConfiguration"},{"$ref":"#/components/schemas/CartesiaTTSConfiguration"},{"$ref":"#/components/schemas/DograhTTSService"},{"$ref":"#/components/schemas/SarvamTTSConfiguration"},{"$ref":"#/components/schemas/CambTTSConfiguration"},{"$ref":"#/components/schemas/RimeTTSConfiguration"},{"$ref":"#/components/schemas/SpeachesTTSConfiguration"},{"$ref":"#/components/schemas/MiniMaxTTSConfiguration"},{"$ref":"#/components/schemas/AzureSpeechTTSConfiguration"}],"title":"Tts","discriminator":{"propertyName":"provider","mapping":{"azure_speech":"#/components/schemas/AzureSpeechTTSConfiguration","camb":"#/components/schemas/CambTTSConfiguration","cartesia":"#/components/schemas/CartesiaTTSConfiguration","deepgram":"#/components/schemas/DeepgramTTSConfiguration","dograh":"#/components/schemas/DograhTTSService","elevenlabs":"#/components/schemas/ElevenlabsTTSConfiguration","google":"#/components/schemas/GoogleTTSConfiguration","minimax":"#/components/schemas/MiniMaxTTSConfiguration","openai":"#/components/schemas/OpenAITTSService","rime":"#/components/schemas/RimeTTSConfiguration","sarvam":"#/components/schemas/SarvamTTSConfiguration","speaches":"#/components/schemas/SpeachesTTSConfiguration"}}},"stt":{"oneOf":[{"$ref":"#/components/schemas/DeepgramSTTConfiguration"},{"$ref":"#/components/schemas/CartesiaSTTConfiguration"},{"$ref":"#/components/schemas/OpenAISTTConfiguration"},{"$ref":"#/components/schemas/GoogleSTTConfiguration"},{"$ref":"#/components/schemas/DograhSTTService"},{"$ref":"#/components/schemas/SpeechmaticsSTTConfiguration"},{"$ref":"#/components/schemas/SarvamSTTConfiguration"},{"$ref":"#/components/schemas/SpeachesSTTConfiguration"},{"$ref":"#/components/schemas/AssemblyAISTTConfiguration"},{"$ref":"#/components/schemas/GladiaSTTConfiguration"},{"$ref":"#/components/schemas/AzureSpeechSTTConfiguration"}],"title":"Stt","discriminator":{"propertyName":"provider","mapping":{"assemblyai":"#/components/schemas/AssemblyAISTTConfiguration","azure_speech":"#/components/schemas/AzureSpeechSTTConfiguration","cartesia":"#/components/schemas/CartesiaSTTConfiguration","deepgram":"#/components/schemas/DeepgramSTTConfiguration","dograh":"#/components/schemas/DograhSTTService","gladia":"#/components/schemas/GladiaSTTConfiguration","google":"#/components/schemas/GoogleSTTConfiguration","openai":"#/components/schemas/OpenAISTTConfiguration","sarvam":"#/components/schemas/SarvamSTTConfiguration","speaches":"#/components/schemas/SpeachesSTTConfiguration","speechmatics":"#/components/schemas/SpeechmaticsSTTConfiguration"}}},"embeddings":{"anyOf":[{"oneOf":[{"$ref":"#/components/schemas/OpenAIEmbeddingsConfiguration"},{"$ref":"#/components/schemas/OpenRouterEmbeddingsConfiguration"},{"$ref":"#/components/schemas/AzureOpenAIEmbeddingsConfiguration"},{"$ref":"#/components/schemas/DograhEmbeddingsConfiguration"}],"discriminator":{"propertyName":"provider","mapping":{"azure":"#/components/schemas/AzureOpenAIEmbeddingsConfiguration","dograh":"#/components/schemas/DograhEmbeddingsConfiguration","openai":"#/components/schemas/OpenAIEmbeddingsConfiguration","openrouter":"#/components/schemas/OpenRouterEmbeddingsConfiguration"}}},{"type":"null"}],"title":"Embeddings"}},"type":"object","required":["llm","tts","stt"],"title":"BYOKPipelineAIModelConfiguration"},"BYOKRealtimeAIModelConfiguration":{"properties":{"realtime":{"oneOf":[{"$ref":"#/components/schemas/OpenAIRealtimeLLMConfiguration"},{"$ref":"#/components/schemas/GrokRealtimeLLMConfiguration"},{"$ref":"#/components/schemas/UltravoxRealtimeLLMConfiguration"},{"$ref":"#/components/schemas/GoogleRealtimeLLMConfiguration"},{"$ref":"#/components/schemas/GoogleVertexRealtimeLLMConfiguration"},{"$ref":"#/components/schemas/AzureRealtimeLLMConfiguration"}],"title":"Realtime","discriminator":{"propertyName":"provider","mapping":{"azure_realtime":"#/components/schemas/AzureRealtimeLLMConfiguration","google_realtime":"#/components/schemas/GoogleRealtimeLLMConfiguration","google_vertex_realtime":"#/components/schemas/GoogleVertexRealtimeLLMConfiguration","grok_realtime":"#/components/schemas/GrokRealtimeLLMConfiguration","openai_realtime":"#/components/schemas/OpenAIRealtimeLLMConfiguration","ultravox_realtime":"#/components/schemas/UltravoxRealtimeLLMConfiguration"}}},"llm":{"oneOf":[{"$ref":"#/components/schemas/OpenAILLMService"},{"$ref":"#/components/schemas/GoogleVertexLLMConfiguration"},{"$ref":"#/components/schemas/GroqLLMService"},{"$ref":"#/components/schemas/OpenRouterLLMConfiguration"},{"$ref":"#/components/schemas/GoogleLLMService"},{"$ref":"#/components/schemas/AzureLLMService"},{"$ref":"#/components/schemas/DograhLLMService"},{"$ref":"#/components/schemas/AWSBedrockLLMConfiguration"},{"$ref":"#/components/schemas/SpeachesLLMConfiguration"},{"$ref":"#/components/schemas/MiniMaxLLMConfiguration"},{"$ref":"#/components/schemas/SarvamLLMConfiguration"}],"title":"Llm","discriminator":{"propertyName":"provider","mapping":{"aws_bedrock":"#/components/schemas/AWSBedrockLLMConfiguration","azure":"#/components/schemas/AzureLLMService","dograh":"#/components/schemas/DograhLLMService","google":"#/components/schemas/GoogleLLMService","google_vertex":"#/components/schemas/GoogleVertexLLMConfiguration","groq":"#/components/schemas/GroqLLMService","minimax":"#/components/schemas/MiniMaxLLMConfiguration","openai":"#/components/schemas/OpenAILLMService","openrouter":"#/components/schemas/OpenRouterLLMConfiguration","sarvam":"#/components/schemas/SarvamLLMConfiguration","speaches":"#/components/schemas/SpeachesLLMConfiguration"}}},"embeddings":{"anyOf":[{"oneOf":[{"$ref":"#/components/schemas/OpenAIEmbeddingsConfiguration"},{"$ref":"#/components/schemas/OpenRouterEmbeddingsConfiguration"},{"$ref":"#/components/schemas/AzureOpenAIEmbeddingsConfiguration"},{"$ref":"#/components/schemas/DograhEmbeddingsConfiguration"}],"discriminator":{"propertyName":"provider","mapping":{"azure":"#/components/schemas/AzureOpenAIEmbeddingsConfiguration","dograh":"#/components/schemas/DograhEmbeddingsConfiguration","openai":"#/components/schemas/OpenAIEmbeddingsConfiguration","openrouter":"#/components/schemas/OpenRouterEmbeddingsConfiguration"}}},{"type":"null"}],"title":"Embeddings"}},"type":"object","required":["realtime","llm"],"title":"BYOKRealtimeAIModelConfiguration"},"BatchRecordingCreateRequestSchema":{"properties":{"recordings":{"items":{"$ref":"#/components/schemas/RecordingCreateRequestSchema"},"type":"array","maxItems":20,"minItems":1,"title":"Recordings","description":"List of recordings to create"}},"type":"object","required":["recordings"],"title":"BatchRecordingCreateRequestSchema","description":"Request schema for creating one or more recording records after upload."},"BatchRecordingCreateResponseSchema":{"properties":{"recordings":{"items":{"$ref":"#/components/schemas/RecordingResponseSchema"},"type":"array","title":"Recordings","description":"Created recording records"}},"type":"object","required":["recordings"],"title":"BatchRecordingCreateResponseSchema","description":"Response schema for recording creation."},"BatchRecordingUploadRequestSchema":{"properties":{"files":{"items":{"$ref":"#/components/schemas/FileDescriptor"},"type":"array","maxItems":20,"minItems":1,"title":"Files","description":"List of files to upload"}},"type":"object","required":["files"],"title":"BatchRecordingUploadRequestSchema","description":"Request schema for getting presigned upload URLs for one or more files."},"BatchRecordingUploadResponseSchema":{"properties":{"items":{"items":{"$ref":"#/components/schemas/RecordingUploadResponseSchema"},"type":"array","title":"Items","description":"Upload URLs for each file"}},"type":"object","required":["items"],"title":"BatchRecordingUploadResponseSchema","description":"Response schema with presigned upload URLs."},"Body_transcribe_audio_api_v1_workflow_recordings_transcribe_post":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File"},"language":{"type":"string","title":"Language","default":"en"}},"type":"object","required":["file"],"title":"Body_transcribe_audio_api_v1_workflow_recordings_transcribe_post"},"CalculatorToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version.","default":1},"type":{"type":"string","const":"calculator","title":"Type","description":"Tool type."}},"type":"object","required":["type"],"title":"CalculatorToolDefinition","description":"Tool definition for Calculator tools."},"CallDispositionCodes":{"properties":{"disposition_codes":{"items":{"type":"string"},"type":"array","title":"Disposition Codes","default":[]}},"type":"object","title":"CallDispositionCodes"},"CallType":{"type":"string","enum":["inbound","outbound"],"title":"CallType"},"CambTTSConfiguration":{"properties":{"provider":{"type":"string","const":"camb","title":"Provider","default":"camb"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Camb.ai TTS model.","default":"mars-flash","examples":["mars-flash","mars-pro","mars-instruct"]},"voice":{"type":"string","title":"Voice","description":"Camb.ai voice ID.","default":"147320"},"language":{"type":"string","title":"Language","description":"BCP-47 language code.","default":"en-us"}},"type":"object","required":["api_key"],"title":"Camb.ai"},"CampaignDefaultsResponse":{"properties":{"concurrent_call_limit":{"type":"integer","title":"Concurrent Call Limit"},"from_numbers_count":{"type":"integer","title":"From Numbers Count"},"default_retry_config":{"$ref":"#/components/schemas/RetryConfigResponse"},"last_campaign_settings":{"anyOf":[{"$ref":"#/components/schemas/LastCampaignSettingsResponse"},{"type":"null"}]}},"type":"object","required":["concurrent_call_limit","from_numbers_count","default_retry_config"],"title":"CampaignDefaultsResponse"},"CampaignLogEntryResponse":{"properties":{"ts":{"type":"string","title":"Ts"},"level":{"type":"string","title":"Level"},"event":{"type":"string","title":"Event"},"message":{"type":"string","title":"Message"},"details":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Details"}},"type":"object","required":["ts","level","event","message"],"title":"CampaignLogEntryResponse","description":"A single timestamped entry from the campaign's append-only log.\n\nSurfaced in the UI so operators can see why a campaign moved to\npaused / failed without digging through server logs."},"CampaignProgressResponse":{"properties":{"campaign_id":{"type":"integer","title":"Campaign Id"},"state":{"type":"string","title":"State"},"total_rows":{"type":"integer","title":"Total Rows"},"processed_rows":{"type":"integer","title":"Processed Rows"},"failed_calls":{"type":"integer","title":"Failed Calls"},"progress_percentage":{"type":"number","title":"Progress Percentage"},"source_sync":{"additionalProperties":true,"type":"object","title":"Source Sync"},"rate_limit":{"type":"integer","title":"Rate Limit"},"started_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Started At"},"completed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Completed At"}},"type":"object","required":["campaign_id","state","total_rows","processed_rows","failed_calls","progress_percentage","source_sync","rate_limit","started_at","completed_at"],"title":"CampaignProgressResponse"},"CampaignResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"workflow_id":{"type":"integer","title":"Workflow Id"},"workflow_name":{"type":"string","title":"Workflow Name"},"state":{"type":"string","title":"State"},"source_type":{"type":"string","title":"Source Type"},"source_id":{"type":"string","title":"Source Id"},"total_rows":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Total Rows"},"processed_rows":{"type":"integer","title":"Processed Rows"},"failed_rows":{"type":"integer","title":"Failed Rows"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"started_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Started At"},"completed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Completed At"},"retry_config":{"$ref":"#/components/schemas/RetryConfigResponse"},"max_concurrency":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Concurrency"},"schedule_config":{"anyOf":[{"$ref":"#/components/schemas/ScheduleConfigResponse"},{"type":"null"}]},"circuit_breaker":{"anyOf":[{"$ref":"#/components/schemas/CircuitBreakerConfigResponse"},{"type":"null"}]},"executed_count":{"type":"integer","title":"Executed Count","default":0},"total_queued_count":{"type":"integer","title":"Total Queued Count","default":0},"parent_campaign_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Parent Campaign Id"},"redialed_campaign_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Redialed Campaign Id"},"telephony_configuration_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Telephony Configuration Id"},"telephony_configuration_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Telephony Configuration Name"},"logs":{"items":{"$ref":"#/components/schemas/CampaignLogEntryResponse"},"type":"array","title":"Logs"}},"type":"object","required":["id","name","workflow_id","workflow_name","state","source_type","source_id","total_rows","processed_rows","failed_rows","created_at","started_at","completed_at","retry_config"],"title":"CampaignResponse"},"CampaignRunsResponse":{"properties":{"runs":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Runs"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"limit":{"type":"integer","title":"Limit"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["runs","total_count","page","limit","total_pages"],"title":"CampaignRunsResponse","description":"Paginated response for campaign workflow runs"},"CampaignSourceDownloadResponse":{"properties":{"download_url":{"type":"string","title":"Download Url"},"expires_in":{"type":"integer","title":"Expires In"}},"type":"object","required":["download_url","expires_in"],"title":"CampaignSourceDownloadResponse"},"CampaignsResponse":{"properties":{"campaigns":{"items":{"$ref":"#/components/schemas/CampaignResponse"},"type":"array","title":"Campaigns"}},"type":"object","required":["campaigns"],"title":"CampaignsResponse"},"CartesiaSTTConfiguration":{"properties":{"provider":{"type":"string","const":"cartesia","title":"Provider","default":"cartesia"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Cartesia STT model.","default":"ink-whisper","examples":["ink-whisper"]}},"type":"object","required":["api_key"],"title":"Cartesia"},"CartesiaTTSConfiguration":{"properties":{"provider":{"type":"string","const":"cartesia","title":"Provider","default":"cartesia"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Cartesia TTS model.","default":"sonic-3","examples":["sonic-3"]},"voice":{"type":"string","title":"Voice","description":"Cartesia voice UUID from your Cartesia dashboard.","default":"3faa81ae-d3d8-4ab1-9e44-e50e46d33c30"},"speed":{"type":"number","maximum":1.5,"minimum":0.6,"title":"Speed","description":"Speed of the voice.","default":1.0},"volume":{"type":"number","maximum":2.0,"minimum":0.5,"title":"Volume","description":"Volume multiplier for generated speech.","default":1.0}},"type":"object","required":["api_key"],"title":"Cartesia"},"ChunkResponseSchema":{"properties":{"id":{"type":"integer","title":"Id"},"document_id":{"type":"integer","title":"Document Id"},"chunk_text":{"type":"string","title":"Chunk Text"},"contextualized_text":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Contextualized Text"},"chunk_index":{"type":"integer","title":"Chunk Index"},"chunk_metadata":{"additionalProperties":true,"type":"object","title":"Chunk Metadata"},"filename":{"type":"string","title":"Filename"},"document_uuid":{"type":"string","title":"Document Uuid"},"similarity":{"type":"number","title":"Similarity"}},"type":"object","required":["id","document_id","chunk_text","contextualized_text","chunk_index","chunk_metadata","filename","document_uuid","similarity"],"title":"ChunkResponseSchema","description":"Response schema for a document chunk."},"ChunkSearchRequestSchema":{"properties":{"query":{"type":"string","title":"Query","description":"Search query text"},"limit":{"type":"integer","maximum":50.0,"minimum":1.0,"title":"Limit","description":"Maximum number of results","default":5},"document_uuids":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Document Uuids","description":"Filter by specific document UUIDs"},"min_similarity":{"anyOf":[{"type":"number","maximum":1.0,"minimum":0.0},{"type":"null"}],"title":"Min Similarity","description":"Minimum similarity threshold"}},"type":"object","required":["query"],"title":"ChunkSearchRequestSchema","description":"Request schema for searching similar chunks."},"ChunkSearchResponseSchema":{"properties":{"chunks":{"items":{"$ref":"#/components/schemas/ChunkResponseSchema"},"type":"array","title":"Chunks"},"query":{"type":"string","title":"Query"},"total_results":{"type":"integer","title":"Total Results"}},"type":"object","required":["chunks","query","total_results"],"title":"ChunkSearchResponseSchema","description":"Response schema for chunk search results."},"CircuitBreakerConfigRequest":{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":true},"failure_threshold":{"type":"number","maximum":1.0,"minimum":0.0,"title":"Failure Threshold","default":0.5},"window_seconds":{"type":"integer","maximum":600.0,"minimum":30.0,"title":"Window Seconds","default":120},"min_calls_in_window":{"type":"integer","maximum":100.0,"minimum":1.0,"title":"Min Calls In Window","default":5}},"type":"object","title":"CircuitBreakerConfigRequest"},"CircuitBreakerConfigResponse":{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":false},"failure_threshold":{"type":"number","title":"Failure Threshold","default":0.5},"window_seconds":{"type":"integer","title":"Window Seconds","default":120},"min_calls_in_window":{"type":"integer","title":"Min Calls In Window","default":5}},"type":"object","title":"CircuitBreakerConfigResponse"},"CloudonixConfigurationRequest":{"properties":{"provider":{"type":"string","const":"cloudonix","title":"Provider","default":"cloudonix"},"bearer_token":{"type":"string","title":"Bearer Token","description":"Cloudonix API Bearer Token"},"domain_id":{"type":"string","title":"Domain Id","description":"Cloudonix Domain ID"},"application_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Name","description":"Cloudonix Voice Application name. The application's url is updated when inbound workflows are attached to numbers on this domain. If omitted, an application is auto-created on save and its name is stored on the configuration."},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Cloudonix phone numbers (optional)"}},"type":"object","required":["bearer_token","domain_id"],"title":"CloudonixConfigurationRequest","description":"Request schema for Cloudonix configuration."},"CloudonixConfigurationResponse":{"properties":{"provider":{"type":"string","const":"cloudonix","title":"Provider","default":"cloudonix"},"bearer_token":{"type":"string","title":"Bearer Token"},"domain_id":{"type":"string","title":"Domain Id"},"application_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Name"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["bearer_token","domain_id","from_numbers"],"title":"CloudonixConfigurationResponse","description":"Response schema for Cloudonix configuration with masked sensitive fields."},"CreateAPIKeyRequest":{"properties":{"name":{"type":"string","title":"Name"}},"type":"object","required":["name"],"title":"CreateAPIKeyRequest"},"CreateAPIKeyResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"key_prefix":{"type":"string","title":"Key Prefix"},"api_key":{"type":"string","title":"Api Key"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","name","key_prefix","api_key","created_at"],"title":"CreateAPIKeyResponse"},"CreateCampaignRequest":{"properties":{"name":{"type":"string","maxLength":255,"minLength":1,"title":"Name"},"workflow_id":{"type":"integer","title":"Workflow Id"},"source_type":{"type":"string","pattern":"^csv$","title":"Source Type"},"source_id":{"type":"string","title":"Source Id"},"telephony_configuration_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Telephony Configuration Id"},"retry_config":{"anyOf":[{"$ref":"#/components/schemas/RetryConfigRequest"},{"type":"null"}]},"max_concurrency":{"anyOf":[{"type":"integer","maximum":100.0,"minimum":1.0},{"type":"null"}],"title":"Max Concurrency"},"schedule_config":{"anyOf":[{"$ref":"#/components/schemas/ScheduleConfigRequest"},{"type":"null"}]},"circuit_breaker":{"anyOf":[{"$ref":"#/components/schemas/CircuitBreakerConfigRequest"},{"type":"null"}]}},"type":"object","required":["name","workflow_id","source_type","source_id"],"title":"CreateCampaignRequest"},"CreateCredentialRequest":{"properties":{"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"credential_type":{"$ref":"#/components/schemas/WebhookCredentialType"},"credential_data":{"additionalProperties":true,"type":"object","title":"Credential Data"}},"type":"object","required":["name","credential_type","credential_data"],"title":"CreateCredentialRequest","description":"Request schema for creating a webhook credential."},"CreateFolderRequest":{"properties":{"name":{"type":"string","maxLength":100,"minLength":1,"title":"Name"}},"type":"object","required":["name"],"title":"CreateFolderRequest"},"CreateServiceKeyRequest":{"properties":{"name":{"type":"string","title":"Name"},"expires_in_days":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Expires In Days","default":90}},"type":"object","required":["name"],"title":"CreateServiceKeyRequest"},"CreateServiceKeyResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"service_key":{"type":"string","title":"Service Key"},"key_prefix":{"type":"string","title":"Key Prefix"},"expires_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Expires At"}},"type":"object","required":["id","name","service_key","key_prefix"],"title":"CreateServiceKeyResponse"},"CreateTextChatSessionRequest":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"annotations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Annotations"}},"type":"object","title":"CreateTextChatSessionRequest"},"CreateToolRequest":{"properties":{"name":{"type":"string","maxLength":255,"title":"Name","description":"Display name for the tool.","llm_hint":"Use a concise action-oriented name; this influences the function name shown to the agent."},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description","description":"Description shown to the agent when deciding whether to call it.","llm_hint":"State exactly when the agent should call the tool and what result it gets."},"category":{"type":"string","enum":["http_api","end_call","transfer_call","calculator","native","integration","mcp"],"title":"Category","description":"Tool category. Must match definition.type.","default":"http_api"},"icon":{"anyOf":[{"type":"string","maxLength":50},{"type":"null"}],"title":"Icon","description":"Lucide icon identifier.","default":"globe"},"icon_color":{"anyOf":[{"type":"string","maxLength":7},{"type":"null"}],"title":"Icon Color","description":"Hex color for the tool icon.","default":"#3B82F6"},"definition":{"oneOf":[{"$ref":"#/components/schemas/HttpApiToolDefinition"},{"$ref":"#/components/schemas/EndCallToolDefinition"},{"$ref":"#/components/schemas/TransferCallToolDefinition"},{"$ref":"#/components/schemas/CalculatorToolDefinition"},{"$ref":"#/components/schemas/McpToolDefinition"}],"title":"Definition","description":"Typed tool definition.","discriminator":{"propertyName":"type","mapping":{"calculator":"#/components/schemas/CalculatorToolDefinition","end_call":"#/components/schemas/EndCallToolDefinition","http_api":"#/components/schemas/HttpApiToolDefinition","mcp":"#/components/schemas/McpToolDefinition","transfer_call":"#/components/schemas/TransferCallToolDefinition"}}}},"type":"object","required":["name","definition"],"title":"CreateToolRequest","description":"Request schema for creating a reusable tool."},"CreateWorkflowRequest":{"properties":{"name":{"type":"string","title":"Name"},"workflow_definition":{"additionalProperties":true,"type":"object","title":"Workflow Definition"}},"type":"object","required":["name","workflow_definition"],"title":"CreateWorkflowRequest"},"CreateWorkflowRunRequest":{"properties":{"mode":{"type":"string","title":"Mode"},"name":{"type":"string","title":"Name"}},"type":"object","required":["mode","name"],"title":"CreateWorkflowRunRequest"},"CreateWorkflowRunResponse":{"properties":{"id":{"type":"integer","title":"Id"},"workflow_id":{"type":"integer","title":"Workflow Id"},"name":{"type":"string","title":"Name"},"mode":{"type":"string","title":"Mode"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"definition_id":{"type":"integer","title":"Definition Id"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"}},"type":"object","required":["id","workflow_id","name","mode","created_at","definition_id"],"title":"CreateWorkflowRunResponse"},"CreateWorkflowTemplateRequest":{"properties":{"call_type":{"type":"string","enum":["inbound","outbound"],"title":"Call Type"},"use_case":{"type":"string","title":"Use Case"},"activity_description":{"type":"string","title":"Activity Description"}},"type":"object","required":["call_type","use_case","activity_description"],"title":"CreateWorkflowTemplateRequest"},"CreatedByResponse":{"properties":{"id":{"type":"integer","title":"Id"},"provider_id":{"type":"string","title":"Provider Id"}},"type":"object","required":["id","provider_id"],"title":"CreatedByResponse","description":"Response schema for the user who created a tool."},"CredentialResponse":{"properties":{"uuid":{"type":"string","title":"Uuid"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"credential_type":{"type":"string","title":"Credential Type"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["uuid","name","description","credential_type","created_at","updated_at"],"title":"CredentialResponse","description":"Response schema for a webhook credential (never includes sensitive data)."},"CurrentUsageResponse":{"properties":{"period_start":{"type":"string","title":"Period Start"},"period_end":{"type":"string","title":"Period End"},"used_dograh_tokens":{"type":"number","title":"Used Dograh Tokens"},"quota_dograh_tokens":{"type":"integer","title":"Quota Dograh Tokens"},"percentage_used":{"type":"number","title":"Percentage Used"},"next_refresh_date":{"type":"string","title":"Next Refresh Date"},"quota_enabled":{"type":"boolean","title":"Quota Enabled"},"total_duration_seconds":{"type":"integer","title":"Total Duration Seconds"},"used_amount_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Used Amount Usd"},"quota_amount_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Quota Amount Usd"},"currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Currency"},"price_per_second_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Price Per Second Usd"}},"type":"object","required":["period_start","period_end","used_dograh_tokens","quota_dograh_tokens","percentage_used","next_refresh_date","quota_enabled","total_duration_seconds"],"title":"CurrentUsageResponse"},"DailyReportResponse":{"properties":{"date":{"type":"string","title":"Date"},"timezone":{"type":"string","title":"Timezone"},"workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Workflow Id"},"metrics":{"additionalProperties":{"type":"integer"},"type":"object","title":"Metrics"},"disposition_distribution":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Disposition Distribution"},"call_duration_distribution":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Call Duration Distribution"}},"type":"object","required":["date","timezone","workflow_id","metrics","disposition_distribution","call_duration_distribution"],"title":"DailyReportResponse"},"DailyUsageBreakdownResponse":{"properties":{"breakdown":{"items":{"$ref":"#/components/schemas/DailyUsageItem"},"type":"array","title":"Breakdown"},"total_minutes":{"type":"number","title":"Total Minutes"},"total_cost_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Total Cost Usd"},"total_dograh_tokens":{"type":"number","title":"Total Dograh Tokens"},"currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Currency"}},"type":"object","required":["breakdown","total_minutes","total_dograh_tokens"],"title":"DailyUsageBreakdownResponse"},"DailyUsageItem":{"properties":{"date":{"type":"string","title":"Date"},"minutes":{"type":"number","title":"Minutes"},"cost_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Cost Usd"},"dograh_tokens":{"type":"number","title":"Dograh Tokens"},"call_count":{"type":"integer","title":"Call Count"}},"type":"object","required":["date","minutes","dograh_tokens","call_count"],"title":"DailyUsageItem"},"DeepgramSTTConfiguration":{"properties":{"provider":{"type":"string","const":"deepgram","title":"Provider","default":"deepgram"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Deepgram STT model.","default":"nova-3-general","examples":["nova-3-general","flux-general-en","flux-general-multi"]},"language":{"type":"string","title":"Language","description":"Language code; 'multi' enables auto-detect (Nova-3 only).","default":"multi","examples":["multi","ar","ar-AE","ar-SA","ar-QA","ar-KW","ar-SY","ar-LB","ar-PS","ar-JO","ar-EG","ar-SD","ar-TD","ar-MA","ar-DZ","ar-TN","ar-IQ","ar-IR","be","bn","bs","bg","ca","cs","da","da-DK","de","de-CH","el","en","en-US","en-AU","en-GB","en-IN","en-NZ","es","es-419","et","fa","fi","fr","fr-CA","he","hi","hr","hu","id","it","ja","kn","ko","ko-KR","lt","lv","mk","mr","ms","nl","nl-BE","no","pl","pt","pt-BR","pt-PT","ro","ru","sk","sl","sr","sv","sv-SE","ta","te","th","tl","tr","uk","ur","vi","zh-CN","zh-TW"],"model_options":{"flux-general-en":["en"],"nova-3-general":["multi","ar","ar-AE","ar-SA","ar-QA","ar-KW","ar-SY","ar-LB","ar-PS","ar-JO","ar-EG","ar-SD","ar-TD","ar-MA","ar-DZ","ar-TN","ar-IQ","ar-IR","be","bn","bs","bg","ca","cs","da","da-DK","de","de-CH","el","en","en-US","en-AU","en-GB","en-IN","en-NZ","es","es-419","et","fa","fi","fr","fr-CA","he","hi","hr","hu","id","it","ja","kn","ko","ko-KR","lt","lv","mk","mr","ms","nl","nl-BE","no","pl","pt","pt-BR","pt-PT","ro","ru","sk","sl","sr","sv","sv-SE","ta","te","th","tl","tr","uk","ur","vi","zh-CN","zh-TW"]}}},"type":"object","required":["api_key"],"title":"Deepgram"},"DeepgramTTSConfiguration":{"properties":{"provider":{"type":"string","const":"deepgram","title":"Provider","default":"deepgram"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"voice":{"type":"string","title":"Voice","description":"Deepgram voice ID (model is inferred from the 'aura-N' prefix).","default":"aura-2-helena-en"}},"type":"object","required":["api_key"],"title":"Deepgram"},"DefaultConfigurationsResponse":{"properties":{"llm":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Llm"},"tts":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Tts"},"stt":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Stt"},"embeddings":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Embeddings"},"realtime":{"additionalProperties":{"additionalProperties":true,"type":"object"},"type":"object","title":"Realtime"},"default_providers":{"additionalProperties":{"type":"string"},"type":"object","title":"Default Providers"}},"type":"object","required":["llm","tts","stt","embeddings","realtime","default_providers"],"title":"DefaultConfigurationsResponse"},"DisplayOptions":{"properties":{"show":{"anyOf":[{"additionalProperties":{"items":{},"type":"array"},"type":"object"},{"type":"null"}],"title":"Show"},"hide":{"anyOf":[{"additionalProperties":{"items":{},"type":"array"},"type":"object"},{"type":"null"}],"title":"Hide"}},"additionalProperties":false,"type":"object","title":"DisplayOptions","description":"Conditional visibility rules.\n\n`show` keys are AND-combined: this property is visible only when EVERY\nreferenced field's value matches one of the listed values.\n\n`hide` keys are OR-combined: this property is hidden when ANY referenced\nfield's value matches one of the listed values.\n\nExample:\n DisplayOptions(show={\"extraction_enabled\": [True]})\n DisplayOptions(show={\"greeting_type\": [\"audio\"]})"},"DocumentListResponseSchema":{"properties":{"documents":{"items":{"$ref":"#/components/schemas/DocumentResponseSchema"},"type":"array","title":"Documents"},"total":{"type":"integer","title":"Total"},"limit":{"type":"integer","title":"Limit"},"offset":{"type":"integer","title":"Offset"}},"type":"object","required":["documents","total","limit","offset"],"title":"DocumentListResponseSchema","description":"Response schema for list of documents."},"DocumentResponseSchema":{"properties":{"id":{"type":"integer","title":"Id"},"document_uuid":{"type":"string","title":"Document Uuid"},"filename":{"type":"string","title":"Filename"},"file_size_bytes":{"type":"integer","title":"File Size Bytes"},"file_hash":{"type":"string","title":"File Hash"},"mime_type":{"type":"string","title":"Mime Type"},"processing_status":{"type":"string","title":"Processing Status"},"processing_error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Processing Error"},"total_chunks":{"type":"integer","title":"Total Chunks"},"retrieval_mode":{"type":"string","title":"Retrieval Mode","default":"chunked"},"custom_metadata":{"additionalProperties":true,"type":"object","title":"Custom Metadata"},"docling_metadata":{"additionalProperties":true,"type":"object","title":"Docling Metadata"},"source_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source Url"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"organization_id":{"type":"integer","title":"Organization Id"},"created_by":{"type":"integer","title":"Created By"},"is_active":{"type":"boolean","title":"Is Active"}},"type":"object","required":["id","document_uuid","filename","file_size_bytes","file_hash","mime_type","processing_status","total_chunks","custom_metadata","docling_metadata","created_at","updated_at","organization_id","created_by","is_active"],"title":"DocumentResponseSchema","description":"Response schema for document metadata."},"DocumentUploadRequestSchema":{"properties":{"filename":{"type":"string","title":"Filename","description":"Name of the file to upload"},"mime_type":{"type":"string","title":"Mime Type","description":"MIME type of the file"},"custom_metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Custom Metadata","description":"Optional custom metadata"}},"type":"object","required":["filename","mime_type"],"title":"DocumentUploadRequestSchema","description":"Request schema for initiating document upload."},"DocumentUploadResponseSchema":{"properties":{"upload_url":{"type":"string","title":"Upload Url","description":"Signed URL for uploading the file"},"document_uuid":{"type":"string","title":"Document Uuid","description":"Unique identifier for the document"},"s3_key":{"type":"string","title":"S3 Key","description":"S3 key where file should be uploaded"}},"type":"object","required":["upload_url","document_uuid","s3_key"],"title":"DocumentUploadResponseSchema","description":"Response schema containing upload URL and document metadata."},"DograhEmbeddingsConfiguration":{"properties":{"provider":{"type":"string","const":"dograh","title":"Provider","default":"dograh"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Dograh-managed embedding model.","default":"default","examples":["default"]}},"type":"object","required":["api_key"],"title":"Dograh"},"DograhLLMService":{"properties":{"provider":{"type":"string","const":"dograh","title":"Provider","default":"dograh"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Dograh-hosted model tier.","default":"default","examples":["default","accurate","fast","lite","zen"],"allow_custom_input":true}},"type":"object","required":["api_key"],"title":"Dograh"},"DograhManagedAIModelConfiguration":{"properties":{"api_key":{"type":"string","title":"Api Key"},"voice":{"type":"string","title":"Voice","default":"default"},"speed":{"type":"number","title":"Speed","default":1.0},"language":{"type":"string","title":"Language","default":"multi"}},"type":"object","required":["api_key"],"title":"DograhManagedAIModelConfiguration"},"DograhSTTService":{"properties":{"provider":{"type":"string","const":"dograh","title":"Provider","default":"dograh"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Dograh STT tier.","default":"default","examples":["default"]},"language":{"type":"string","title":"Language","description":"Language code; use 'multi' for auto-detect.","default":"multi","examples":["multi","ar","ar-AE","ar-SA","ar-QA","ar-KW","ar-SY","ar-LB","ar-PS","ar-JO","ar-EG","ar-SD","ar-TD","ar-MA","ar-DZ","ar-TN","ar-IQ","ar-IR","be","bn","bs","bg","ca","cs","da","da-DK","de","de-CH","el","en","en-US","en-AU","en-GB","en-IN","en-NZ","es","es-419","et","fa","fi","fr","fr-CA","he","hi","hr","hu","id","it","ja","kn","ko","ko-KR","lt","lv","mk","mr","ms","nl","nl-BE","no","pl","pt","pt-BR","pt-PT","ro","ru","sk","sl","sr","sv","sv-SE","ta","te","th","tl","tr","uk","ur","vi","zh-CN","zh-TW"]}},"type":"object","required":["api_key"],"title":"Dograh"},"DograhTTSService":{"properties":{"provider":{"type":"string","const":"dograh","title":"Provider","default":"dograh"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Dograh TTS tier.","default":"default","examples":["default"]},"voice":{"type":"string","title":"Voice","description":"Voice preset.","default":"default"},"speed":{"type":"number","maximum":2.0,"minimum":0.5,"title":"Speed","description":"Speed of the voice.","default":1.0}},"type":"object","required":["api_key"],"title":"Dograh"},"DuplicateTemplateRequest":{"properties":{"template_id":{"type":"integer","title":"Template Id"},"workflow_name":{"type":"string","title":"Workflow Name"}},"type":"object","required":["template_id","workflow_name"],"title":"DuplicateTemplateRequest"},"ElevenlabsTTSConfiguration":{"properties":{"provider":{"type":"string","const":"elevenlabs","title":"Provider","default":"elevenlabs"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"voice":{"type":"string","title":"Voice","description":"ElevenLabs voice ID from your Voice Library.","default":"21m00Tcm4TlvDq8ikWAM"},"speed":{"type":"number","maximum":2.0,"minimum":0.1,"title":"Speed","description":"Speed of the voice.","default":1.0},"model":{"type":"string","title":"Model","description":"ElevenLabs TTS model.","default":"eleven_flash_v2_5","examples":["eleven_flash_v2_5"]},"base_url":{"type":"string","title":"Base Url","description":"ElevenLabs API base URL. Override to use a Data Residency endpoint (e.g. https://api.eu.residency.elevenlabs.io) for GDPR / HIPAA / regional compliance.","default":"https://api.elevenlabs.io"}},"type":"object","required":["api_key"],"title":"ElevenLabs"},"EmbedConfigResponse":{"properties":{"workflow_id":{"type":"integer","title":"Workflow Id"},"settings":{"additionalProperties":true,"type":"object","title":"Settings"},"theme":{"type":"string","title":"Theme"},"position":{"type":"string","title":"Position"},"button_text":{"type":"string","title":"Button Text"},"button_color":{"type":"string","title":"Button Color"},"size":{"type":"string","title":"Size"},"auto_start":{"type":"boolean","title":"Auto Start"}},"type":"object","required":["workflow_id","settings","theme","position","button_text","button_color","size","auto_start"],"title":"EmbedConfigResponse","description":"Response model for embed configuration"},"EmbedTokenRequest":{"properties":{"allowed_domains":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Allowed Domains"},"settings":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Settings"},"usage_limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Usage Limit"},"expires_in_days":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Expires In Days","default":30}},"type":"object","title":"EmbedTokenRequest"},"EmbedTokenResponse":{"properties":{"id":{"type":"integer","title":"Id"},"token":{"type":"string","title":"Token"},"allowed_domains":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Allowed Domains"},"settings":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Settings"},"is_active":{"type":"boolean","title":"Is Active"},"usage_count":{"type":"integer","title":"Usage Count"},"usage_limit":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Usage Limit"},"expires_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Expires At"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"embed_script":{"type":"string","title":"Embed Script"}},"type":"object","required":["id","token","allowed_domains","settings","is_active","usage_count","usage_limit","expires_at","created_at","embed_script"],"title":"EmbedTokenResponse"},"EndCallConfig":{"properties":{"messageType":{"type":"string","enum":["none","custom","audio"],"title":"Messagetype","description":"Type of goodbye message.","default":"none"},"customMessage":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessage","description":"Custom message to play before ending the call."},"audioRecordingId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Audiorecordingid","description":"Recording ID for audio goodbye message."},"endCallReason":{"type":"boolean","title":"Endcallreason","description":"When enabled, the model must provide a reason for ending the call. The reason is set as call disposition and added to call tags.","default":false},"endCallReasonDescription":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Endcallreasondescription","description":"Description shown to the model for the reason parameter. Used only when endCallReason is enabled."}},"type":"object","title":"EndCallConfig","description":"Configuration for End Call tools."},"EndCallToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version.","default":1},"type":{"type":"string","const":"end_call","title":"Type","description":"Tool type."},"config":{"$ref":"#/components/schemas/EndCallConfig","description":"End Call configuration."}},"type":"object","required":["type","config"],"title":"EndCallToolDefinition","description":"Tool definition for End Call tools."},"FileDescriptor":{"properties":{"filename":{"type":"string","title":"Filename","description":"Original filename of the audio file"},"mime_type":{"type":"string","title":"Mime Type","description":"MIME type of the audio file","default":"audio/wav"},"file_size":{"type":"integer","maximum":5242880.0,"exclusiveMinimum":0.0,"title":"File Size","description":"File size in bytes (max 5MB)"}},"type":"object","required":["filename","file_size"],"title":"FileDescriptor","description":"Descriptor for a single file in a batch upload request."},"FileMetadataResponse":{"properties":{"key":{"type":"string","title":"Key"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata"}},"type":"object","required":["key","metadata"],"title":"FileMetadataResponse"},"FolderResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","name","created_at"],"title":"FolderResponse"},"GladiaSTTConfiguration":{"properties":{"provider":{"type":"string","const":"gladia","title":"Provider","default":"gladia"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Gladia STT model.","default":"solaria-1","examples":["solaria-1"]},"language":{"type":"string","title":"Language","description":"ISO 639-1 language code.","default":"en","examples":["af","am","ar","as","az","ba","be","bg","bn","bo","br","bs","ca","cs","cy","da","de","el","en","es","et","eu","fa","fi","fo","fr","gl","gu","ha","haw","he","hi","hr","ht","hu","hy","id","is","it","ja","jw","ka","kk","km","kn","ko","la","lb","ln","lo","lt","lv","mg","mi","mk","ml","mn","mr","ms","mt","my","ne","nl","nn","no","oc","pa","pl","ps","pt","ro","ru","sa","sd","si","sk","sl","sn","so","sq","sr","su","sv","sw","ta","te","tg","th","tk","tl","tr","tt","uk","ur","uz","vi","wo","yi","yo","zh"]}},"type":"object","required":["api_key"],"title":"Gladia"},"GoogleLLMService":{"properties":{"provider":{"type":"string","const":"google","title":"Provider","default":"google"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Gemini model on Google AI Studio (not Vertex).","default":"gemini-2.0-flash","examples":["gemini-2.0-flash","gemini-2.0-flash-lite","gemini-2.5-flash","gemini-2.5-flash-lite","gemini-3.5-flash","gemini-3.5-flash-lite"],"allow_custom_input":true}},"type":"object","required":["api_key"],"title":"Google"},"GoogleRealtimeLLMConfiguration":{"properties":{"provider":{"type":"string","const":"google_realtime","title":"Provider","default":"google_realtime"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Gemini Live model on Google AI Studio (not Vertex).","default":"gemini-3.1-flash-live-preview","examples":["gemini-3.1-flash-live-preview"],"allow_custom_input":true},"voice":{"type":"string","title":"Voice","description":"Voice the model speaks in.","default":"Puck","examples":["Puck","Charon","Kore","Fenrir","Aoede"],"allow_custom_input":true},"language":{"type":"string","title":"Language","description":"ISO 639-1 language code.","default":"en","examples":["ar","bn","de","en","es","fr","gu","hi","id","it","ja","kn","ko","ml","mr","nl","pl","pt","ru","ta","te","th","tr","vi","zh"],"allow_custom_input":true}},"type":"object","required":["api_key"],"title":"Google Realtime"},"GoogleSTTConfiguration":{"properties":{"provider":{"type":"string","const":"google","title":"Provider","default":"google"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Api Key","description":"Not used for Google Cloud STT. Leave blank."},"model":{"type":"string","title":"Model","description":"Google Cloud Speech-to-Text V2 recognition model.","default":"latest_long","examples":["latest_long","latest_short","chirp_3"],"allow_custom_input":true},"language":{"type":"string","title":"Language","description":"Primary BCP-47 language code for recognition.","default":"en-US","examples":["af-ZA","am-ET","ar-AE","ar-BH","ar-DZ","ar-EG","ar-IL","ar-IQ","ar-JO","ar-KW","ar-LB","ar-MA","ar-MR","ar-OM","ar-PS","ar-QA","ar-SA","ar-SY","ar-TN","ar-XA","ar-YE","as-IN","ast-ES","az-AZ","be-BY","bg-BG","bn-BD","bn-IN","bs-BA","ca-ES","ceb-PH","ckb-IQ","cmn-Hans-CN","cmn-Hant-TW","cs-CZ","cy-GB","da-DK","de-AT","de-CH","de-DE","el-GR","en-AU","en-GB","en-HK","en-IE","en-IN","en-NZ","en-PH","en-PK","en-SG","en-US","es-419","es-AR","es-BO","es-CL","es-CO","es-CR","es-DO","es-EC","es-ES","es-GT","es-HN","es-MX","es-NI","es-PA","es-PE","es-PR","es-SV","es-US","es-UY","es-VE","et-EE","eu-ES","fa-IR","ff-SN","fi-FI","fil-PH","fr-BE","fr-CA","fr-CH","fr-FR","ga-IE","gl-ES","gu-IN","ha-NG","hi-IN","hr-HR","hu-HU","hy-AM","id-ID","ig-NG","is-IS","it-CH","it-IT","iw-IL","ja-JP","jv-ID","ka-GE","kam-KE","kea-CV","kk-KZ","km-KH","kn-IN","ko-KR","ky-KG","lb-LU","lg-UG","ln-CD","lo-LA","lt-LT","luo-KE","lv-LV","mi-NZ","mk-MK","ml-IN","mn-MN","mr-IN","ms-MY","mt-MT","my-MM","ne-NP","nl-BE","nl-NL","no-NO","nso-ZA","ny-MW","oc-FR","om-ET","or-IN","pa-Guru-IN","pl-PL","ps-AF","pt-BR","pt-PT","ro-RO","ru-RU","rup-BG","rw-RW","sd-IN","si-LK","sk-SK","sl-SI","sn-ZW","so-SO","sq-AL","sr-RS","ss-Latn-ZA","st-ZA","su-ID","sv-SE","sw","sw-KE","ta-IN","te-IN","tg-TJ","th-TH","tn-Latn-ZA","tr-TR","ts-ZA","uk-UA","umb-AO","ur-PK","uz-UZ","ve-ZA","vi-VN","wo-SN","xh-ZA","yo-NG","yue-Hant-HK","zu-ZA"],"allow_custom_input":true,"docs_url":"https://docs.cloud.google.com/speech-to-text/docs/speech-to-text-supported-languages"},"location":{"type":"string","title":"Location","description":"Google Cloud Speech-to-Text region (for example 'global' or 'us-central1').","default":"global"},"credentials":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credentials","description":"Paste the entire Google Cloud service-account JSON. If omitted, the server falls back to Application Default Credentials (ADC).","multiline":true}},"type":"object","title":"Google Cloud"},"GoogleTTSConfiguration":{"properties":{"provider":{"type":"string","const":"google","title":"Provider","default":"google"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Api Key","description":"Not used for Google Cloud TTS. Leave blank."},"model":{"type":"string","title":"Model","description":"Google Cloud low-latency TTS engine. Dograh maps this to Pipecat's streaming Google TTS service for Chirp 3 HD and Journey voices.","default":"chirp_3_hd","examples":["chirp_3_hd"],"allow_custom_input":true},"voice":{"type":"string","title":"Voice","description":"Google Cloud voice name. Use a Chirp 3 HD or Journey voice for streaming TTS.","default":"en-US-Chirp3-HD-Charon","examples":["en-US-Chirp3-HD-Charon"],"allow_custom_input":true},"language":{"type":"string","title":"Language","description":"BCP-47 language code for synthesis.","default":"en-US","examples":["ar-XA","bn-IN","bg-BG","yue-HK","hr-HR","cs-CZ","da-DK","nl-BE","nl-NL","en-AU","en-IN","en-GB","en-US","et-EE","fi-FI","fr-CA","fr-FR","de-DE","el-GR","gu-IN","he-IL","hi-IN","hu-HU","id-ID","it-IT","ja-JP","kn-IN","ko-KR","lv-LV","lt-LT","ml-IN","cmn-CN","mr-IN","nb-NO","pl-PL","pt-BR","pa-IN","ro-RO","ru-RU","sr-RS","sk-SK","sl-SI","es-ES","es-US","sw-KE","sv-SE","ta-IN","te-IN","th-TH","tr-TR","uk-UA","ur-IN","vi-VN"],"allow_custom_input":true},"speed":{"type":"number","maximum":2.0,"minimum":0.25,"title":"Speed","description":"Speech speed multiplier for Google streaming TTS.","default":1.0},"location":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Location","description":"Optional Google Cloud regional Text-to-Speech endpoint (for example 'us-central1'). Leave blank to use the default endpoint."},"credentials":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credentials","description":"Paste the entire Google Cloud service-account JSON. If omitted, the server falls back to Application Default Credentials (ADC).","multiline":true}},"type":"object","title":"Google Cloud"},"GoogleVertexLLMConfiguration":{"properties":{"provider":{"type":"string","const":"google_vertex","title":"Provider","default":"google_vertex"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Api Key","description":"Not used for Vertex AI \u2014 authentication is via the service account in `credentials` (or ADC). Leave blank."},"model":{"type":"string","title":"Model","description":"Gemini model on Vertex AI.","default":"gemini-2.5-flash","examples":["gemini-2.5-flash","gemini-2.5-flash-lite","gemini-3.1-flash-lite","gemini-3.5-flash"],"allow_custom_input":true},"project_id":{"type":"string","title":"Project Id","description":"Google Cloud project ID for Vertex AI."},"location":{"type":"string","title":"Location","description":"GCP region for the Vertex AI endpoint (e.g. 'global').","default":"global"},"credentials":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credentials","description":"Paste the entire service-account JSON file contents. If omitted, falls back to Application Default Credentials (ADC).","multiline":true}},"type":"object","required":["project_id"],"title":"Google Vertex"},"GoogleVertexRealtimeLLMConfiguration":{"properties":{"provider":{"type":"string","const":"google_vertex_realtime","title":"Provider","default":"google_vertex_realtime"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Api Key","description":"Not used for Vertex AI \u2014 authentication is via the service account in `credentials` (or ADC). Leave blank."},"model":{"type":"string","title":"Model","description":"Vertex AI publisher/model identifier.","default":"google/gemini-live-2.5-flash-native-audio","examples":["google/gemini-live-2.5-flash-native-audio"],"allow_custom_input":true},"voice":{"type":"string","title":"Voice","description":"Voice the model speaks in.","default":"Charon","examples":["Puck","Charon","Kore","Fenrir","Aoede"],"allow_custom_input":true},"language":{"type":"string","title":"Language","description":"BCP-47 language code (e.g. 'en-US').","default":"en","examples":["ar","bn","de","en","es","fr","gu","hi","id","it","ja","kn","ko","ml","mr","nl","pl","pt","ru","ta","te","th","tr","vi","zh"],"allow_custom_input":true},"project_id":{"type":"string","title":"Project Id","description":"Google Cloud project ID for Vertex AI."},"location":{"type":"string","title":"Location","description":"GCP region for the Vertex AI endpoint (e.g. 'global').","default":"global"},"credentials":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credentials","description":"Paste the entire service-account JSON file contents. If omitted, falls back to Application Default Credentials (ADC).","multiline":true}},"type":"object","required":["project_id"],"title":"Google Vertex Realtime"},"GraphConstraints":{"properties":{"min_incoming":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Min Incoming"},"max_incoming":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Incoming"},"min_outgoing":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Min Outgoing"},"max_outgoing":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Outgoing"}},"additionalProperties":false,"type":"object","title":"GraphConstraints","description":"Per-node-type graph rules. WorkflowGraph enforces these at validation."},"GrokRealtimeLLMConfiguration":{"properties":{"provider":{"type":"string","const":"grok_realtime","title":"Provider","default":"grok_realtime"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Grok realtime voice-agent model.","default":"grok-voice-think-fast-1.0","examples":["grok-voice-think-fast-1.0"],"allow_custom_input":true},"voice":{"type":"string","title":"Voice","description":"Voice the model speaks in.","default":"Ara","examples":["Ara","Rex","Sal","Eve","Leo"],"allow_custom_input":true}},"type":"object","required":["api_key"],"title":"Grok Realtime"},"GroqLLMService":{"properties":{"provider":{"type":"string","const":"groq","title":"Provider","default":"groq"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Groq-hosted model identifier.","default":"llama-3.3-70b-versatile","examples":["llama-3.3-70b-versatile","deepseek-r1-distill-llama-70b","qwen-qwq-32b","meta-llama/llama-4-scout-17b-16e-instruct","meta-llama/llama-4-maverick-17b-128e-instruct","gemma2-9b-it","llama-3.1-8b-instant","openai/gpt-oss-120b"],"allow_custom_input":true}},"type":"object","required":["api_key"],"title":"Groq"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HealthResponse":{"properties":{"status":{"type":"string","title":"Status"},"version":{"type":"string","title":"Version"},"backend_api_endpoint":{"type":"string","title":"Backend Api Endpoint"},"deployment_mode":{"type":"string","title":"Deployment Mode"},"auth_provider":{"type":"string","title":"Auth Provider"},"turn_enabled":{"type":"boolean","title":"Turn Enabled"},"force_turn_relay":{"type":"boolean","title":"Force Turn Relay"}},"type":"object","required":["status","version","backend_api_endpoint","deployment_mode","auth_provider","turn_enabled","force_turn_relay"],"title":"HealthResponse"},"HttpApiConfig":{"properties":{"method":{"type":"string","enum":["GET","POST","PUT","PATCH","DELETE"],"title":"Method","description":"HTTP method to use for the request.","llm_hint":"Use one of GET, POST, PUT, PATCH, DELETE."},"url":{"type":"string","title":"Url","description":"Target HTTP or HTTPS URL.","llm_hint":"Use the final endpoint URL. Authentication belongs in credential_uuid, not embedded in the URL."},"headers":{"anyOf":[{"additionalProperties":{"type":"string"},"type":"object"},{"type":"null"}],"title":"Headers","description":"Static headers to include with every request.","llm_hint":"Do not place secrets here. Store secrets in the UI credential manager and reference them with credential_uuid."},"credential_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credential Uuid","description":"Reference to an external credential for request authentication.","llm_hint":"Use a credential_uuid returned by list_credentials. The MCP flow does not create credential secrets."},"parameters":{"anyOf":[{"items":{"$ref":"#/components/schemas/ToolParameter"},"type":"array"},{"type":"null"}],"title":"Parameters","description":"Parameters the model must provide when calling this tool."},"preset_parameters":{"anyOf":[{"items":{"$ref":"#/components/schemas/PresetToolParameter"},"type":"array"},{"type":"null"}],"title":"Preset Parameters","description":"Parameters injected by Dograh from fixed values or workflow context templates."},"timeout_ms":{"anyOf":[{"type":"integer","minimum":1.0},{"type":"null"}],"title":"Timeout Ms","description":"Request timeout in milliseconds.","default":5000},"customMessage":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessage","description":"Custom message to play after tool execution."},"customMessageType":{"anyOf":[{"type":"string","enum":["text","audio"]},{"type":"null"}],"title":"Custommessagetype","description":"Type of custom message."},"customMessageRecordingId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessagerecordingid","description":"Recording ID for an audio custom message."}},"type":"object","required":["method","url"],"title":"HttpApiConfig","description":"Configuration for HTTP API tools."},"HttpApiToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version.","default":1},"type":{"type":"string","const":"http_api","title":"Type","description":"Tool type."},"config":{"$ref":"#/components/schemas/HttpApiConfig","description":"HTTP API configuration."}},"type":"object","required":["type","config"],"title":"HttpApiToolDefinition","description":"Tool definition for HTTP API tools."},"ImpersonateRequest":{"properties":{"provider_user_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider User Id"},"user_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"User Id"}},"type":"object","title":"ImpersonateRequest","description":"Request payload for superadmin impersonation.\n\nEither ``provider_user_id`` **or** ``user_id`` must be supplied. If both are\nprovided, ``provider_user_id`` takes precedence."},"ImpersonateResponse":{"properties":{"refresh_token":{"type":"string","title":"Refresh Token"},"access_token":{"type":"string","title":"Access Token"}},"type":"object","required":["refresh_token","access_token"],"title":"ImpersonateResponse"},"InitEmbedRequest":{"properties":{"token":{"type":"string","title":"Token"},"context_variables":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Context Variables"}},"type":"object","required":["token"],"title":"InitEmbedRequest","description":"Request model for initializing an embed session"},"InitEmbedResponse":{"properties":{"session_token":{"type":"string","title":"Session Token"},"workflow_run_id":{"type":"integer","title":"Workflow Run Id"},"config":{"additionalProperties":true,"type":"object","title":"Config"}},"type":"object","required":["session_token","workflow_run_id","config"],"title":"InitEmbedResponse","description":"Response model for embed initialization"},"InitiateCallRequest":{"properties":{"workflow_id":{"type":"integer","title":"Workflow Id"},"workflow_run_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Workflow Run Id"},"phone_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone Number"},"telephony_configuration_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Telephony Configuration Id"},"from_phone_number_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"From Phone Number Id"}},"type":"object","required":["workflow_id"],"title":"InitiateCallRequest"},"ItemKind":{"type":"string","enum":["node","edge","workflow"],"title":"ItemKind"},"LangfuseCredentialsRequest":{"properties":{"host":{"type":"string","title":"Host"},"public_key":{"type":"string","title":"Public Key"},"secret_key":{"type":"string","title":"Secret Key"}},"type":"object","required":["host","public_key","secret_key"],"title":"LangfuseCredentialsRequest"},"LangfuseCredentialsResponse":{"properties":{"host":{"type":"string","title":"Host","default":""},"public_key":{"type":"string","title":"Public Key","default":""},"secret_key":{"type":"string","title":"Secret Key","default":""},"configured":{"type":"boolean","title":"Configured","default":false}},"type":"object","title":"LangfuseCredentialsResponse"},"LastCampaignSettingsResponse":{"properties":{"retry_config":{"anyOf":[{"$ref":"#/components/schemas/RetryConfigResponse"},{"type":"null"}]},"max_concurrency":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Concurrency"},"schedule_config":{"anyOf":[{"$ref":"#/components/schemas/ScheduleConfigResponse"},{"type":"null"}]},"circuit_breaker":{"anyOf":[{"$ref":"#/components/schemas/CircuitBreakerConfigResponse"},{"type":"null"}]}},"type":"object","title":"LastCampaignSettingsResponse"},"LoginRequest":{"properties":{"email":{"type":"string","format":"email","title":"Email"},"password":{"type":"string","title":"Password"}},"type":"object","required":["email","password"],"title":"LoginRequest"},"MPSCreditsResponse":{"properties":{"total_credits_used":{"type":"number","title":"Total Credits Used"},"remaining_credits":{"type":"number","title":"Remaining Credits"},"total_quota":{"type":"number","title":"Total Quota"}},"type":"object","required":["total_credits_used","remaining_credits","total_quota"],"title":"MPSCreditsResponse"},"McpRefreshResponse":{"properties":{"tool_uuid":{"type":"string","title":"Tool Uuid"},"discovered_tools":{"items":{},"type":"array","title":"Discovered Tools"},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["tool_uuid"],"title":"McpRefreshResponse","description":"Result of re-discovering an MCP server's tool catalog."},"McpToolConfig":{"properties":{"transport":{"type":"string","const":"streamable_http","title":"Transport","description":"MCP transport protocol.","default":"streamable_http"},"url":{"type":"string","title":"Url","description":"MCP server URL. Must use http:// or https://.","llm_hint":"Use the server's streamable HTTP MCP endpoint."},"credential_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Credential Uuid","description":"Reference to an external credential for MCP server auth.","llm_hint":"Use a credential_uuid returned by list_credentials. Credentials are created by the user in the UI."},"tools_filter":{"items":{"type":"string"},"type":"array","title":"Tools Filter","description":"Allowlist of MCP tool names to expose. Empty exposes all tools.","llm_hint":"Use exact MCP tool names from the remote server catalog when you need to restrict the exposed tools."},"timeout_secs":{"type":"integer","minimum":0.0,"title":"Timeout Secs","description":"Connection timeout in seconds.","default":30},"sse_read_timeout_secs":{"type":"integer","minimum":0.0,"title":"Sse Read Timeout Secs","description":"SSE read timeout in seconds.","default":300},"discovered_tools":{"items":{"additionalProperties":true,"type":"object"},"type":"array","title":"Discovered Tools","description":"Server-managed cache of the MCP server's tool catalog [{name, description}]. Populated best-effort by the backend.","llm_hint":"Do not author this field; the server fills it."}},"type":"object","required":["url"],"title":"McpToolConfig","description":"Configuration for a customer MCP server tool definition."},"McpToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version.","default":1},"type":{"type":"string","const":"mcp","title":"Type","description":"Tool type."},"config":{"$ref":"#/components/schemas/McpToolConfig","description":"MCP server configuration."}},"type":"object","required":["type","config"],"title":"McpToolDefinition","description":"Persisted MCP tool definition."},"MiniMaxLLMConfiguration":{"properties":{"provider":{"type":"string","const":"minimax","title":"Provider","default":"minimax"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"MiniMax chat model.","default":"MiniMax-M2.7","examples":["MiniMax-M2.7","MiniMax-M2.7-highspeed"],"allow_custom_input":true},"base_url":{"type":"string","title":"Base Url","description":"MiniMax OpenAI-compatible API endpoint.","default":"https://api.minimax.io/v1"},"temperature":{"type":"number","maximum":2.0,"exclusiveMinimum":0.0,"title":"Temperature","description":"Sampling temperature. MiniMax requires > 0.","default":1.0}},"type":"object","required":["api_key"],"title":"MiniMaxLLMConfiguration"},"MiniMaxTTSConfiguration":{"properties":{"provider":{"type":"string","const":"minimax","title":"Provider","default":"minimax"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"MiniMax TTS model.","default":"speech-2.8-hd","examples":["speech-2.8-hd","speech-2.8-turbo"]},"voice":{"type":"string","title":"Voice","description":"MiniMax voice ID.","default":"English_Graceful_Lady","examples":["English_Graceful_Lady","English_Insightful_Speaker","English_radiant_girl","English_Persuasive_Man","English_Lucky_Robot","English_expressive_narrator"],"allow_custom_input":true},"base_url":{"type":"string","title":"Base Url","description":"MiniMax TTS API endpoint (must include the /v1/t2a_v2 path). Defaults to the global endpoint; override with https://api.minimaxi.chat/v1/t2a_v2 (mainland China) or https://api-uw.minimax.io/v1/t2a_v2 (US-West).","default":"https://api.minimax.io/v1/t2a_v2"},"speed":{"type":"number","maximum":2.0,"minimum":0.5,"title":"Speed","description":"Speech speed (0.5 to 2.0).","default":1.0},"group_id":{"type":"string","title":"Group Id","description":"MiniMax Group ID (found in your MiniMax dashboard under Account \u2192 Group)."}},"type":"object","required":["api_key","group_id"],"title":"MiniMaxTTSConfiguration"},"MoveWorkflowToFolderRequest":{"properties":{"folder_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Folder Id"}},"type":"object","title":"MoveWorkflowToFolderRequest","description":"Move a workflow into a folder, or to \"Uncategorized\" when null."},"NodeCategory":{"type":"string","enum":["call_node","global_node","trigger","integration"],"title":"NodeCategory","description":"Drives grouping in the AddNodePanel UI."},"NodeExample":{"properties":{"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"data":{"additionalProperties":true,"type":"object","title":"Data"}},"additionalProperties":false,"type":"object","required":["name","data"],"title":"NodeExample","description":"A worked example LLMs can pattern-match. Keep small and realistic."},"NodeSpec":{"properties":{"name":{"type":"string","title":"Name"},"display_name":{"type":"string","title":"Display Name"},"description":{"type":"string","minLength":1,"title":"Description","description":"Human-facing explanation shown in AddNodePanel."},"llm_hint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Llm Hint","description":"LLM-only guidance; omitted from the UI."},"category":{"$ref":"#/components/schemas/NodeCategory"},"icon":{"type":"string","title":"Icon"},"version":{"type":"string","title":"Version","default":"1.0.0"},"properties":{"items":{"$ref":"#/components/schemas/PropertySpec"},"type":"array","title":"Properties"},"examples":{"items":{"$ref":"#/components/schemas/NodeExample"},"type":"array","title":"Examples"},"graph_constraints":{"anyOf":[{"$ref":"#/components/schemas/GraphConstraints"},{"type":"null"}]}},"additionalProperties":false,"type":"object","required":["name","display_name","description","category","icon","properties"],"title":"NodeSpec","description":"Single source of truth for a node type."},"NodeTypesResponse":{"properties":{"spec_version":{"type":"string","title":"Spec Version"},"node_types":{"items":{"$ref":"#/components/schemas/NodeSpec"},"type":"array","title":"Node Types"}},"type":"object","required":["spec_version","node_types"],"title":"NodeTypesResponse"},"OpenAIEmbeddingsConfiguration":{"properties":{"provider":{"type":"string","const":"openai","title":"Provider","default":"openai"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"OpenAI embedding model.","default":"text-embedding-3-small","examples":["text-embedding-3-small"]}},"type":"object","required":["api_key"],"title":"OpenAI"},"OpenAILLMService":{"properties":{"provider":{"type":"string","const":"openai","title":"Provider","default":"openai"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"OpenAI chat model to use.","default":"gpt-4.1","examples":["gpt-4.1","gpt-4.1-mini","gpt-4.1-nano","gpt-5","gpt-5-mini","gpt-5-nano","gpt-3.5-turbo"],"allow_custom_input":true},"base_url":{"type":"string","title":"Base Url","description":"Override only if using an OpenAI-compatible API (e.g. local LLM, proxy).","default":"https://api.openai.com/v1"}},"type":"object","required":["api_key"],"title":"OpenAI"},"OpenAIRealtimeLLMConfiguration":{"properties":{"provider":{"type":"string","const":"openai_realtime","title":"Provider","default":"openai_realtime"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"OpenAI realtime (speech-to-speech) model.","default":"gpt-realtime-2","examples":["gpt-realtime-2"],"allow_custom_input":true},"voice":{"type":"string","title":"Voice","description":"Voice the model speaks in.","default":"alloy","examples":["alloy","ash","ballad","coral","echo","sage","shimmer","verse"],"allow_custom_input":true}},"type":"object","required":["api_key"],"title":"OpenAI Realtime"},"OpenAISTTConfiguration":{"properties":{"provider":{"type":"string","const":"openai","title":"Provider","default":"openai"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"OpenAI transcription model.","default":"gpt-4o-transcribe","examples":["gpt-4o-transcribe"]},"base_url":{"type":"string","title":"Base Url","description":"Override only if using an OpenAI-compatible API (e.g. local STT, proxy).","default":"https://api.openai.com/v1"}},"type":"object","required":["api_key"],"title":"OpenAI"},"OpenAITTSService":{"properties":{"provider":{"type":"string","const":"openai","title":"Provider","default":"openai"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"OpenAI TTS model.","default":"gpt-4o-mini-tts","examples":["gpt-4o-mini-tts"]},"voice":{"type":"string","title":"Voice","description":"OpenAI TTS voice name.","default":"alloy"},"base_url":{"type":"string","title":"Base Url","description":"Override only if using an OpenAI-compatible API (e.g. local TTS, proxy).","default":"https://api.openai.com/v1"}},"type":"object","required":["api_key"],"title":"OpenAI"},"OpenRouterEmbeddingsConfiguration":{"properties":{"provider":{"type":"string","const":"openrouter","title":"Provider","default":"openrouter"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"OpenRouter-hosted embedding model slug.","default":"openai/text-embedding-3-small","examples":["openai/text-embedding-3-small"]},"base_url":{"type":"string","title":"Base Url","description":"Override only if proxying OpenRouter through your own gateway.","default":"https://openrouter.ai/api/v1"}},"type":"object","required":["api_key"],"title":"Open Router"},"OpenRouterLLMConfiguration":{"properties":{"provider":{"type":"string","const":"openrouter","title":"Provider","default":"openrouter"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"OpenRouter model slug in 'vendor/model' form.","default":"openai/gpt-4.1","examples":["openai/gpt-4.1","openai/gpt-4.1-mini","anthropic/claude-sonnet-4","google/gemini-2.5-flash","google/gemini-2.0-flash","meta-llama/llama-3.3-70b-instruct","deepseek/deepseek-chat-v3-0324"],"allow_custom_input":true},"base_url":{"type":"string","title":"Base Url","description":"Override only if proxying OpenRouter through your own gateway.","default":"https://openrouter.ai/api/v1"}},"type":"object","required":["api_key"],"title":"Open Router"},"OrganizationAIModelConfigurationResponse":{"properties":{"configuration":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Configuration"},"effective_configuration":{"additionalProperties":true,"type":"object","title":"Effective Configuration"},"source":{"type":"string","enum":["organization_v2","legacy_user_v1","empty"],"title":"Source"}},"type":"object","required":["configuration","effective_configuration","source"],"title":"OrganizationAIModelConfigurationResponse"},"OrganizationAIModelConfigurationV2":{"properties":{"version":{"type":"integer","const":2,"title":"Version","default":2},"mode":{"type":"string","enum":["dograh","byok"],"title":"Mode"},"dograh":{"anyOf":[{"$ref":"#/components/schemas/DograhManagedAIModelConfiguration"},{"type":"null"}]},"byok":{"anyOf":[{"$ref":"#/components/schemas/BYOKAIModelConfiguration"},{"type":"null"}]}},"type":"object","required":["mode"],"title":"OrganizationAIModelConfigurationV2"},"OrganizationPreferences":{"properties":{"test_phone_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Test Phone Number"},"timezone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Timezone"}},"type":"object","title":"OrganizationPreferences"},"PhoneNumberCreateRequest":{"properties":{"address":{"type":"string","maxLength":255,"minLength":1,"title":"Address"},"country_code":{"anyOf":[{"type":"string","maxLength":2,"minLength":2},{"type":"null"}],"title":"Country Code"},"label":{"anyOf":[{"type":"string","maxLength":64},{"type":"null"}],"title":"Label"},"inbound_workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Inbound Workflow Id"},"is_active":{"type":"boolean","title":"Is Active","default":true},"is_default_caller_id":{"type":"boolean","title":"Is Default Caller Id","default":false},"extra_metadata":{"additionalProperties":true,"type":"object","title":"Extra Metadata"}},"type":"object","required":["address"],"title":"PhoneNumberCreateRequest","description":"Create a new phone number under a telephony configuration.\n\n``address_normalized`` and ``address_type`` are computed server-side from\n``address`` (and ``country_code`` if PSTN). ``address`` itself is stored\nverbatim for display."},"PhoneNumberListResponse":{"properties":{"phone_numbers":{"items":{"$ref":"#/components/schemas/PhoneNumberResponse"},"type":"array","title":"Phone Numbers"}},"type":"object","required":["phone_numbers"],"title":"PhoneNumberListResponse"},"PhoneNumberResponse":{"properties":{"id":{"type":"integer","title":"Id"},"telephony_configuration_id":{"type":"integer","title":"Telephony Configuration Id"},"address":{"type":"string","title":"Address"},"address_normalized":{"type":"string","title":"Address Normalized"},"address_type":{"type":"string","title":"Address Type"},"country_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Country Code"},"label":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Label"},"inbound_workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Inbound Workflow Id"},"inbound_workflow_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Inbound Workflow Name"},"is_active":{"type":"boolean","title":"Is Active"},"is_default_caller_id":{"type":"boolean","title":"Is Default Caller Id"},"extra_metadata":{"additionalProperties":true,"type":"object","title":"Extra Metadata"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"},"provider_sync":{"anyOf":[{"$ref":"#/components/schemas/ProviderSyncStatus"},{"type":"null"}]}},"type":"object","required":["id","telephony_configuration_id","address","address_normalized","address_type","is_active","is_default_caller_id","extra_metadata","created_at","updated_at"],"title":"PhoneNumberResponse"},"PhoneNumberUpdateRequest":{"properties":{"label":{"anyOf":[{"type":"string","maxLength":64},{"type":"null"}],"title":"Label"},"inbound_workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Inbound Workflow Id"},"clear_inbound_workflow":{"type":"boolean","title":"Clear Inbound Workflow","default":false},"is_active":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Active"},"country_code":{"anyOf":[{"type":"string","maxLength":2,"minLength":2},{"type":"null"}],"title":"Country Code"},"extra_metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Extra Metadata"}},"type":"object","title":"PhoneNumberUpdateRequest","description":"Partial update. ``address`` is intentionally immutable \u2014 to change a\nnumber, delete the row and create a new one."},"PlivoConfigurationRequest":{"properties":{"provider":{"type":"string","const":"plivo","title":"Provider","default":"plivo"},"auth_id":{"type":"string","title":"Auth Id","description":"Plivo Auth ID"},"auth_token":{"type":"string","title":"Auth Token","description":"Plivo Auth Token"},"application_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Id","description":"Plivo Application ID. The application's answer_url is updated when inbound workflows are attached to numbers on this account. If omitted, an application is auto-created on save and its id is stored on the configuration."},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Plivo phone numbers"}},"type":"object","required":["auth_id","auth_token"],"title":"PlivoConfigurationRequest","description":"Request schema for Plivo configuration."},"PlivoConfigurationResponse":{"properties":{"provider":{"type":"string","const":"plivo","title":"Provider","default":"plivo"},"auth_id":{"type":"string","title":"Auth Id"},"auth_token":{"type":"string","title":"Auth Token"},"application_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Id"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["auth_id","auth_token","from_numbers"],"title":"PlivoConfigurationResponse","description":"Response schema for Plivo configuration with masked sensitive fields."},"PresetToolParameter":{"properties":{"name":{"type":"string","title":"Name","description":"Parameter name used as a key in the request body."},"type":{"type":"string","enum":["string","number","boolean","object","array"],"title":"Type","description":"JSON type for the resolved value.","llm_hint":"Allowed values are string, number, boolean, object, and array."},"value_template":{"type":"string","title":"Value Template","description":"Fixed value or template, e.g. {{initial_context.phone_number}}.","llm_hint":"Use {{initial_context.*}} for call-start context and {{gathered_context.*}} for values extracted during the call."},"required":{"type":"boolean","title":"Required","description":"Whether the parameter must resolve to a non-empty value.","default":true}},"type":"object","required":["name","type","value_template"],"title":"PresetToolParameter","description":"A parameter injected by Dograh at runtime."},"PresignedUploadUrlRequest":{"properties":{"file_name":{"type":"string","pattern":".*\\.csv$","title":"File Name","description":"CSV filename"},"file_size":{"type":"integer","maximum":10485760.0,"exclusiveMinimum":0.0,"title":"File Size","description":"File size in bytes (max 10MB)"},"content_type":{"type":"string","title":"Content Type","description":"File content type","default":"text/csv"}},"type":"object","required":["file_name","file_size"],"title":"PresignedUploadUrlRequest"},"PresignedUploadUrlResponse":{"properties":{"upload_url":{"type":"string","title":"Upload Url"},"file_key":{"type":"string","title":"File Key"},"expires_in":{"type":"integer","title":"Expires In"}},"type":"object","required":["upload_url","file_key","expires_in"],"title":"PresignedUploadUrlResponse"},"ProcessDocumentRequestSchema":{"properties":{"document_uuid":{"type":"string","title":"Document Uuid","description":"Document UUID to process"},"s3_key":{"type":"string","title":"S3 Key","description":"S3 key of the uploaded file"},"retrieval_mode":{"type":"string","title":"Retrieval Mode","description":"Retrieval mode: 'chunked' for vector search or 'full_document' for full text retrieval","default":"chunked"}},"type":"object","required":["document_uuid","s3_key"],"title":"ProcessDocumentRequestSchema","description":"Request schema for triggering document processing."},"PropertyOption":{"properties":{"value":{"anyOf":[{"type":"string"},{"type":"integer"},{"type":"boolean"},{"type":"number"}],"title":"Value"},"label":{"type":"string","title":"Label"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"}},"additionalProperties":false,"type":"object","required":["value","label"],"title":"PropertyOption","description":"An option in an `options` or `multi_options` dropdown."},"PropertySpec":{"properties":{"name":{"type":"string","title":"Name"},"type":{"$ref":"#/components/schemas/PropertyType"},"display_name":{"type":"string","title":"Display Name"},"description":{"type":"string","minLength":1,"title":"Description","description":"Human-facing explanation shown in the UI."},"llm_hint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Llm Hint","description":"LLM-only guidance; omitted from the UI."},"default":{"title":"Default"},"required":{"type":"boolean","title":"Required","default":false},"placeholder":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Placeholder"},"display_options":{"anyOf":[{"$ref":"#/components/schemas/DisplayOptions"},{"type":"null"}]},"options":{"anyOf":[{"items":{"$ref":"#/components/schemas/PropertyOption"},"type":"array"},{"type":"null"}],"title":"Options"},"properties":{"anyOf":[{"items":{"$ref":"#/components/schemas/PropertySpec"},"type":"array"},{"type":"null"}],"title":"Properties"},"min_value":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Min Value"},"max_value":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Max Value"},"min_length":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Min Length"},"max_length":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Max Length"},"pattern":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Pattern"},"editor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Editor"},"extra":{"additionalProperties":true,"type":"object","title":"Extra"}},"additionalProperties":false,"type":"object","required":["name","type","display_name","description"],"title":"PropertySpec","description":"Single field on a node.\n\n`description` is HUMAN-FACING \u2014 shown under the field in the edit\ndialog. Keep it concise and explain what the field does.\n\n`llm_hint` is LLM-FACING \u2014 appears only in the `get_node_type` MCP\nresponse and in SDK schema output. Use it for catalog tool references\n(e.g., \"Use `list_recordings`\"), array shape, expected value idioms,\nor anything that would be noise in the UI. Optional; omit when the\n`description` already suffices for both audiences."},"PropertyType":{"type":"string","enum":["string","number","boolean","options","multi_options","fixed_collection","json","tool_refs","document_refs","recording_ref","credential_ref","mention_textarea","url"],"title":"PropertyType","description":"Bounded vocabulary of property types the renderer dispatches on.\n\nAdding a value here requires a matching arm in the frontend\n`` switch and (where relevant) the SDK codegen template."},"ProviderSyncStatus":{"properties":{"ok":{"type":"boolean","title":"Ok"},"message":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Message"}},"type":"object","required":["ok"],"title":"ProviderSyncStatus","description":"Result of pushing a phone-number change to the upstream provider.\n\nReturned alongside create/update responses when the route attempted to\nsync inbound webhook configuration. ``ok=False`` is a warning, not a\nfatal error \u2014 the DB write succeeded."},"RecordingCreateRequestSchema":{"properties":{"recording_id":{"type":"string","title":"Recording Id","description":"Short recording ID from upload step"},"tts_provider":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Provider","description":"TTS provider (e.g. elevenlabs)"},"tts_model":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Model","description":"TTS model name"},"tts_voice_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Voice Id","description":"TTS voice identifier"},"transcript":{"type":"string","title":"Transcript","description":"User-provided transcript of the recording"},"storage_key":{"type":"string","title":"Storage Key","description":"Storage key from upload step"},"metadata":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Metadata","description":"Optional metadata (file_size, duration, etc.)"}},"type":"object","required":["recording_id","transcript","storage_key"],"title":"RecordingCreateRequestSchema","description":"Request schema for creating a recording record after upload."},"RecordingListResponseSchema":{"properties":{"recordings":{"items":{"$ref":"#/components/schemas/RecordingResponseSchema"},"type":"array","title":"Recordings"},"total":{"type":"integer","title":"Total"}},"type":"object","required":["recordings","total"],"title":"RecordingListResponseSchema","description":"Response schema for list of recordings."},"RecordingResponseSchema":{"properties":{"id":{"type":"integer","title":"Id"},"recording_id":{"type":"string","title":"Recording Id"},"workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Workflow Id"},"organization_id":{"type":"integer","title":"Organization Id"},"tts_provider":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Provider"},"tts_model":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Model"},"tts_voice_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Tts Voice Id"},"transcript":{"type":"string","title":"Transcript"},"storage_key":{"type":"string","title":"Storage Key"},"storage_backend":{"type":"string","title":"Storage Backend"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"},"created_by":{"type":"integer","title":"Created By"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"is_active":{"type":"boolean","title":"Is Active"}},"type":"object","required":["id","recording_id","organization_id","transcript","storage_key","storage_backend","metadata","created_by","created_at","is_active"],"title":"RecordingResponseSchema","description":"Response schema for a single recording."},"RecordingUpdateRequestSchema":{"properties":{"recording_id":{"type":"string","maxLength":64,"minLength":1,"pattern":"^[a-zA-Z0-9_-]+$","title":"Recording Id","description":"New descriptive recording ID (letters, numbers, hyphens, underscores only)"}},"type":"object","required":["recording_id"],"title":"RecordingUpdateRequestSchema","description":"Request schema for updating a recording's ID."},"RecordingUploadResponseSchema":{"properties":{"upload_url":{"type":"string","title":"Upload Url","description":"Presigned URL for uploading the audio"},"recording_id":{"type":"string","title":"Recording Id","description":"Short unique recording ID"},"storage_key":{"type":"string","title":"Storage Key","description":"Storage key where file will be uploaded"}},"type":"object","required":["upload_url","recording_id","storage_key"],"title":"RecordingUploadResponseSchema","description":"Response schema with presigned upload URL."},"RedialCampaignRequest":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":255,"minLength":1},{"type":"null"}],"title":"Name","description":"Name for the redial campaign"},"retry_on_voicemail":{"type":"boolean","title":"Retry On Voicemail","default":true},"retry_on_no_answer":{"type":"boolean","title":"Retry On No Answer","default":true},"retry_on_busy":{"type":"boolean","title":"Retry On Busy","default":true},"retry_config":{"anyOf":[{"$ref":"#/components/schemas/RetryConfigRequest"},{"type":"null"}]}},"type":"object","title":"RedialCampaignRequest"},"RetryConfigRequest":{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":true},"max_retries":{"type":"integer","maximum":10.0,"minimum":0.0,"title":"Max Retries","default":2},"retry_delay_seconds":{"type":"integer","maximum":3600.0,"minimum":30.0,"title":"Retry Delay Seconds","default":120},"retry_on_busy":{"type":"boolean","title":"Retry On Busy","default":true},"retry_on_no_answer":{"type":"boolean","title":"Retry On No Answer","default":true},"retry_on_voicemail":{"type":"boolean","title":"Retry On Voicemail","default":true}},"type":"object","title":"RetryConfigRequest"},"RetryConfigResponse":{"properties":{"enabled":{"type":"boolean","title":"Enabled"},"max_retries":{"type":"integer","title":"Max Retries"},"retry_delay_seconds":{"type":"integer","title":"Retry Delay Seconds"},"retry_on_busy":{"type":"boolean","title":"Retry On Busy"},"retry_on_no_answer":{"type":"boolean","title":"Retry On No Answer"},"retry_on_voicemail":{"type":"boolean","title":"Retry On Voicemail"}},"type":"object","required":["enabled","max_retries","retry_delay_seconds","retry_on_busy","retry_on_no_answer","retry_on_voicemail"],"title":"RetryConfigResponse"},"RewindTextChatSessionRequest":{"properties":{"cursor_turn_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Cursor Turn Id"},"expected_revision":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Expected Revision"}},"type":"object","title":"RewindTextChatSessionRequest"},"RimeTTSConfiguration":{"properties":{"provider":{"type":"string","const":"rime","title":"Provider","default":"rime"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Rime TTS model.","default":"arcana","examples":["arcana","mistv3","mistv2","mist"],"allow_custom_input":true},"voice":{"type":"string","title":"Voice","description":"Rime voice ID.","default":"celeste"},"speed":{"type":"number","maximum":2.0,"minimum":0.5,"title":"Speed","description":"Speech speed multiplier.","default":1.0},"language":{"type":"string","title":"Language","description":"ISO 639-1 language code.","default":"en","examples":["en","de","fr","es","hi"],"allow_custom_input":true}},"type":"object","required":["api_key"],"title":"Rime"},"S3SignedUrlResponse":{"properties":{"url":{"type":"string","title":"Url"},"expires_in":{"type":"integer","title":"Expires In"}},"type":"object","required":["url","expires_in"],"title":"S3SignedUrlResponse"},"SarvamLLMConfiguration":{"properties":{"provider":{"type":"string","const":"sarvam","title":"Provider","default":"sarvam"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Sarvam chat model. Use sarvam-30b for low-latency voice agents; sarvam-105b for complex multi-step reasoning.","default":"sarvam-30b","examples":["sarvam-30b","sarvam-105b"],"allow_custom_input":true},"temperature":{"type":"number","maximum":2.0,"minimum":0.0,"title":"Temperature","description":"Sampling temperature. Sarvam recommends 0.5 for balanced conversational responses.","default":0.5}},"type":"object","required":["api_key"],"title":"Sarvam"},"SarvamSTTConfiguration":{"properties":{"provider":{"type":"string","const":"sarvam","title":"Provider","default":"sarvam"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Sarvam STT model. saarika:v2.5 transcribes in the spoken language; saaras:v3 is the recommended model with flexible output modes.","default":"saarika:v2.5","examples":["saarika:v2.5","saaras:v3"]},"language":{"type":"string","title":"Language","description":"BCP-47 language code. Use unknown for automatic language detection.","default":"unknown","examples":["unknown","hi-IN","bn-IN","gu-IN","kn-IN","ml-IN","mr-IN","od-IN","pa-IN","ta-IN","te-IN","en-IN"],"model_options":{"saaras:v3":["unknown","hi-IN","bn-IN","gu-IN","kn-IN","ml-IN","mr-IN","od-IN","pa-IN","ta-IN","te-IN","en-IN","as-IN","ur-IN","ne-IN","kok-IN","ks-IN","sd-IN","sa-IN","sat-IN","mni-IN","brx-IN","mai-IN","doi-IN"],"saarika:v2.5":["unknown","hi-IN","bn-IN","gu-IN","kn-IN","ml-IN","mr-IN","od-IN","pa-IN","ta-IN","te-IN","en-IN"]}}},"type":"object","required":["api_key"],"title":"Sarvam"},"SarvamTTSConfiguration":{"properties":{"provider":{"type":"string","const":"sarvam","title":"Provider","default":"sarvam"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Sarvam TTS model (voice list depends on this).","default":"bulbul:v2","examples":["bulbul:v2","bulbul:v3"]},"voice":{"type":"string","title":"Voice","description":"Sarvam voice name; must match the selected model's voice list.","default":"anushka","examples":["anushka","manisha","vidya","arya","abhilash","karun","hitesh"],"model_options":{"bulbul:v2":["anushka","manisha","vidya","arya","abhilash","karun","hitesh"],"bulbul:v3":["shubh","aditya","ritu","priya","neha","rahul","pooja","rohan","simran","kavya","amit","dev","ishita","shreya","ratan","varun","manan","sumit","roopa","kabir","aayan","ashutosh","advait","amelia","sophia","anand","tanya","tarun","sunny","mani","gokul","vijay","shruti","suhani","mohit","kavitha","rehan","soham","rupali"]}},"language":{"type":"string","title":"Language","description":"BCP-47 Indian-language code (e.g. hi-IN, en-IN).","default":"hi-IN","examples":["bn-IN","en-IN","gu-IN","hi-IN","kn-IN","ml-IN","mr-IN","od-IN","pa-IN","ta-IN","te-IN","as-IN"]}},"type":"object","required":["api_key"],"title":"Sarvam"},"ScheduleConfigRequest":{"properties":{"enabled":{"type":"boolean","title":"Enabled","default":true},"timezone":{"type":"string","title":"Timezone","default":"UTC"},"slots":{"items":{"$ref":"#/components/schemas/TimeSlotRequest"},"type":"array","maxItems":50,"minItems":1,"title":"Slots"}},"type":"object","required":["slots"],"title":"ScheduleConfigRequest"},"ScheduleConfigResponse":{"properties":{"enabled":{"type":"boolean","title":"Enabled"},"timezone":{"type":"string","title":"Timezone"},"slots":{"items":{"$ref":"#/components/schemas/TimeSlotResponse"},"type":"array","title":"Slots"}},"type":"object","required":["enabled","timezone","slots"],"title":"ScheduleConfigResponse"},"ServiceKeyResponse":{"properties":{"name":{"type":"string","title":"Name"},"id":{"type":"integer","title":"Id"},"key_prefix":{"type":"string","title":"Key Prefix"},"is_active":{"type":"boolean","title":"Is Active"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"last_used_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Last Used At"},"expires_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Expires At"},"archived_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Archived At"},"created_by":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Created By"}},"type":"object","required":["name","id","key_prefix","is_active","created_at"],"title":"ServiceKeyResponse"},"SignupRequest":{"properties":{"email":{"type":"string","format":"email","title":"Email"},"password":{"type":"string","title":"Password"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"}},"type":"object","required":["email","password"],"title":"SignupRequest"},"SpeachesLLMConfiguration":{"properties":{"provider":{"type":"string","const":"speaches","title":"Provider","default":"speaches"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Api Key","description":"Usually not required for self-hosted endpoints. Leave blank unless your server enforces one."},"model":{"type":"string","title":"Model","description":"Model name as exposed by your OpenAI-compatible server.","default":"llama3","examples":["llama3","mistral","phi3","qwen2","gemma2","deepseek-r1"],"allow_custom_input":true},"base_url":{"type":"string","title":"Base Url","description":"OpenAI-compatible endpoint (Ollama, vLLM, etc.).","default":"http://localhost:11434/v1"}},"type":"object","title":"Local Models (Speaches)","description":"Self-hosted OpenAI-compatible local models. See the Speaches project for setup and supported backends.","provider_docs_url":"https://github.com/speaches-ai/speaches"},"SpeachesSTTConfiguration":{"properties":{"provider":{"type":"string","const":"speaches","title":"Provider","default":"speaches"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Api Key","description":"Usually not required for self-hosted STT. Leave blank unless enforced."},"model":{"type":"string","title":"Model","description":"Whisper model identifier as served by your STT endpoint.","default":"Systran/faster-distil-whisper-small.en","examples":["Systran/faster-distil-whisper-small.en","Systran/faster-whisper-large-v3"],"allow_custom_input":true},"language":{"type":"string","title":"Language","description":"ISO 639-1 language code.","default":"en","examples":["en","ar","nl","fr","de","hi","it","pt","es"],"allow_custom_input":true},"base_url":{"type":"string","title":"Base Url","description":"OpenAI-compatible STT endpoint (Speaches, etc.).","default":"http://localhost:8000/v1"}},"type":"object","title":"Local Models (Speaches)","description":"Self-hosted OpenAI-compatible local models. See the Speaches project for setup and supported backends.","provider_docs_url":"https://github.com/speaches-ai/speaches"},"SpeachesTTSConfiguration":{"properties":{"provider":{"type":"string","const":"speaches","title":"Provider","default":"speaches"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Api Key","description":"Usually not required for self-hosted TTS. Leave blank unless enforced."},"model":{"type":"string","title":"Model","description":"Model name as served by your TTS endpoint (e.g. Kokoro-FastAPI).","default":"kokoro","examples":["hexgrad/Kokoro-82M"],"allow_custom_input":true},"voice":{"type":"string","title":"Voice","description":"Voice ID for the TTS engine.","default":"af_heart","allow_custom_input":true},"base_url":{"type":"string","title":"Base Url","description":"OpenAI-compatible TTS endpoint (Kokoro-FastAPI, etc.).","default":"http://localhost:8000/v1"},"speed":{"type":"number","maximum":4.0,"minimum":0.25,"title":"Speed","description":"Speech speed (0.25 to 4.0).","default":1.0}},"type":"object","title":"Local Models (Speaches)","description":"Self-hosted OpenAI-compatible local models. See the Speaches project for setup and supported backends.","provider_docs_url":"https://github.com/speaches-ai/speaches"},"SpeechmaticsSTTConfiguration":{"properties":{"provider":{"type":"string","const":"speechmatics","title":"Provider","default":"speechmatics"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Speechmatics operating point: 'standard' or 'enhanced'.","default":"enhanced"},"language":{"type":"string","title":"Language","description":"ISO 639-1 language code.","default":"en","examples":["ar","ar_en","ba","eu","be","bn","bg","yue","ca","hr","cs","da","nl","en","eo","et","fi","fr","gl","de","el","he","hi","hu","id","ia","ga","it","ja","ko","lv","lt","ms","en_ms","mt","cmn","cmn_en","cmn_en_ms_ta","mr","mn","no","fa","pl","pt","ro","ru","sk","sl","es","sw","sv","tl","ta","en_ta","th","tr","uk","ur","ug","vi","cy"]}},"type":"object","required":["api_key"],"title":"Speechmatics"},"SuperuserWorkflowRunResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"workflow_id":{"type":"integer","title":"Workflow Id"},"workflow_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Name"},"user_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"User Id"},"organization_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Organization Id"},"organization_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Organization Name"},"mode":{"type":"string","title":"Mode"},"is_completed":{"type":"boolean","title":"Is Completed"},"recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Url"},"transcript_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Url"},"usage_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Usage Info"},"cost_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Cost Info"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"gathered_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Gathered Context"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","name","workflow_id","workflow_name","user_id","organization_id","organization_name","mode","is_completed","recording_url","transcript_url","usage_info","cost_info","initial_context","gathered_context","created_at"],"title":"SuperuserWorkflowRunResponse"},"SuperuserWorkflowRunsListResponse":{"properties":{"workflow_runs":{"items":{"$ref":"#/components/schemas/SuperuserWorkflowRunResponse"},"type":"array","title":"Workflow Runs"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"limit":{"type":"integer","title":"Limit"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["workflow_runs","total_count","page","limit","total_pages"],"title":"SuperuserWorkflowRunsListResponse"},"TelephonyConfigWarningsResponse":{"properties":{"telnyx_missing_webhook_public_key_count":{"type":"integer","title":"Telnyx Missing Webhook Public Key Count"}},"type":"object","required":["telnyx_missing_webhook_public_key_count"],"title":"TelephonyConfigWarningsResponse","description":"Aggregated telephony-configuration warning counts for the user's org.\n\nDrives the page banner and nav badge that nudge customers to finish\noptional-but-recommended configuration steps. Shape is a flat dict so\nnew warning types can be added without breaking the client."},"TelephonyConfigurationCreateRequest":{"properties":{"name":{"type":"string","maxLength":64,"minLength":1,"title":"Name"},"is_default_outbound":{"type":"boolean","title":"Is Default Outbound","default":false},"config":{"oneOf":[{"$ref":"#/components/schemas/ARIConfigurationRequest"},{"$ref":"#/components/schemas/CloudonixConfigurationRequest"},{"$ref":"#/components/schemas/PlivoConfigurationRequest"},{"$ref":"#/components/schemas/TelnyxConfigurationRequest"},{"$ref":"#/components/schemas/TwilioConfigurationRequest"},{"$ref":"#/components/schemas/VobizConfigurationRequest"},{"$ref":"#/components/schemas/VonageConfigurationRequest"}],"title":"Config","discriminator":{"propertyName":"provider","mapping":{"ari":"#/components/schemas/ARIConfigurationRequest","cloudonix":"#/components/schemas/CloudonixConfigurationRequest","plivo":"#/components/schemas/PlivoConfigurationRequest","telnyx":"#/components/schemas/TelnyxConfigurationRequest","twilio":"#/components/schemas/TwilioConfigurationRequest","vobiz":"#/components/schemas/VobizConfigurationRequest","vonage":"#/components/schemas/VonageConfigurationRequest"}}}},"type":"object","required":["name","config"],"title":"TelephonyConfigurationCreateRequest","description":"Body for ``POST /telephony-configs``.\n\n``config`` carries the provider-specific credential fields (the same\ndiscriminated union used by the legacy single-config endpoint). Any\n``from_numbers`` on the inner config are ignored \u2014 phone numbers are\nmanaged via the dedicated phone-numbers endpoints."},"TelephonyConfigurationDetail":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"provider":{"type":"string","title":"Provider"},"is_default_outbound":{"type":"boolean","title":"Is Default Outbound"},"credentials":{"additionalProperties":true,"type":"object","title":"Credentials"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","name","provider","is_default_outbound","credentials","created_at","updated_at"],"title":"TelephonyConfigurationDetail","description":"Body of ``GET /telephony-configs/{id}`` \u2014 credentials are masked."},"TelephonyConfigurationListItem":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"provider":{"type":"string","title":"Provider"},"is_default_outbound":{"type":"boolean","title":"Is Default Outbound"},"phone_number_count":{"type":"integer","title":"Phone Number Count","default":0},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"type":"object","required":["id","name","provider","is_default_outbound","created_at","updated_at"],"title":"TelephonyConfigurationListItem","description":"One row in ``GET /telephony-configs``."},"TelephonyConfigurationListResponse":{"properties":{"configurations":{"items":{"$ref":"#/components/schemas/TelephonyConfigurationListItem"},"type":"array","title":"Configurations"}},"type":"object","required":["configurations"],"title":"TelephonyConfigurationListResponse"},"TelephonyConfigurationResponse":{"properties":{"twilio":{"anyOf":[{"$ref":"#/components/schemas/TwilioConfigurationResponse"},{"type":"null"}]},"plivo":{"anyOf":[{"$ref":"#/components/schemas/PlivoConfigurationResponse"},{"type":"null"}]},"vonage":{"anyOf":[{"$ref":"#/components/schemas/VonageConfigurationResponse"},{"type":"null"}]},"vobiz":{"anyOf":[{"$ref":"#/components/schemas/VobizConfigurationResponse"},{"type":"null"}]},"cloudonix":{"anyOf":[{"$ref":"#/components/schemas/CloudonixConfigurationResponse"},{"type":"null"}]},"ari":{"anyOf":[{"$ref":"#/components/schemas/ARIConfigurationResponse"},{"type":"null"}]},"telnyx":{"anyOf":[{"$ref":"#/components/schemas/TelnyxConfigurationResponse"},{"type":"null"}]}},"type":"object","title":"TelephonyConfigurationResponse","description":"Top-level telephony configuration response.\n\nKeeps the per-provider field shape that the UI client depends on. When\nthe UI moves to metadata-driven forms, this can be replaced with a\nflat discriminated union."},"TelephonyConfigurationUpdateRequest":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":64,"minLength":1},{"type":"null"}],"title":"Name"},"config":{"anyOf":[{"oneOf":[{"$ref":"#/components/schemas/ARIConfigurationRequest"},{"$ref":"#/components/schemas/CloudonixConfigurationRequest"},{"$ref":"#/components/schemas/PlivoConfigurationRequest"},{"$ref":"#/components/schemas/TelnyxConfigurationRequest"},{"$ref":"#/components/schemas/TwilioConfigurationRequest"},{"$ref":"#/components/schemas/VobizConfigurationRequest"},{"$ref":"#/components/schemas/VonageConfigurationRequest"}],"discriminator":{"propertyName":"provider","mapping":{"ari":"#/components/schemas/ARIConfigurationRequest","cloudonix":"#/components/schemas/CloudonixConfigurationRequest","plivo":"#/components/schemas/PlivoConfigurationRequest","telnyx":"#/components/schemas/TelnyxConfigurationRequest","twilio":"#/components/schemas/TwilioConfigurationRequest","vobiz":"#/components/schemas/VobizConfigurationRequest","vonage":"#/components/schemas/VonageConfigurationRequest"}}},{"type":"null"}],"title":"Config"}},"type":"object","title":"TelephonyConfigurationUpdateRequest","description":"Body for ``PUT /telephony-configs/{id}``. Partial update."},"TelephonyProviderMetadata":{"properties":{"provider":{"type":"string","title":"Provider"},"display_name":{"type":"string","title":"Display Name"},"fields":{"items":{"$ref":"#/components/schemas/TelephonyProviderUIField"},"type":"array","title":"Fields"},"docs_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Docs Url"}},"type":"object","required":["provider","display_name","fields"],"title":"TelephonyProviderMetadata","description":"UI form metadata for a single telephony provider."},"TelephonyProviderUIField":{"properties":{"name":{"type":"string","title":"Name"},"label":{"type":"string","title":"Label"},"type":{"type":"string","title":"Type"},"required":{"type":"boolean","title":"Required"},"sensitive":{"type":"boolean","title":"Sensitive"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"placeholder":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Placeholder"}},"type":"object","required":["name","label","type","required","sensitive"],"title":"TelephonyProviderUIField","description":"One form field on a telephony provider's configuration UI."},"TelephonyProvidersMetadataResponse":{"properties":{"providers":{"items":{"$ref":"#/components/schemas/TelephonyProviderMetadata"},"type":"array","title":"Providers"}},"type":"object","required":["providers"],"title":"TelephonyProvidersMetadataResponse","description":"List of UI form definitions used by the telephony-config screen."},"TelnyxConfigurationRequest":{"properties":{"provider":{"type":"string","const":"telnyx","title":"Provider","default":"telnyx"},"api_key":{"type":"string","title":"Api Key","description":"Telnyx API Key"},"connection_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connection Id","description":"Telnyx Call Control Application ID (connection_id). If omitted, a Call Control Application is auto-created on save and its id is stored on the configuration."},"webhook_public_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Webhook Public Key","description":"Webhook public key from Mission Control Portal \u2192 Keys & Credentials \u2192 Public Key. Used to verify Telnyx webhook signatures."},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Telnyx phone numbers"}},"type":"object","required":["api_key"],"title":"TelnyxConfigurationRequest","description":"Request schema for Telnyx configuration."},"TelnyxConfigurationResponse":{"properties":{"provider":{"type":"string","const":"telnyx","title":"Provider","default":"telnyx"},"api_key":{"type":"string","title":"Api Key"},"connection_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Connection Id"},"webhook_public_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Webhook Public Key"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["api_key","from_numbers"],"title":"TelnyxConfigurationResponse","description":"Response schema for Telnyx configuration with masked sensitive fields."},"TimeSlotRequest":{"properties":{"day_of_week":{"type":"integer","maximum":6.0,"minimum":0.0,"title":"Day Of Week"},"start_time":{"type":"string","pattern":"^\\d{2}:\\d{2}$","title":"Start Time"},"end_time":{"type":"string","pattern":"^\\d{2}:\\d{2}$","title":"End Time"}},"type":"object","required":["day_of_week","start_time","end_time"],"title":"TimeSlotRequest"},"TimeSlotResponse":{"properties":{"day_of_week":{"type":"integer","title":"Day Of Week"},"start_time":{"type":"string","title":"Start Time"},"end_time":{"type":"string","title":"End Time"}},"type":"object","required":["day_of_week","start_time","end_time"],"title":"TimeSlotResponse"},"ToolParameter":{"properties":{"name":{"type":"string","title":"Name","description":"Parameter name used as a key in the tool request body.","llm_hint":"Use a stable snake_case name the agent can naturally fill."},"type":{"type":"string","enum":["string","number","boolean","object","array"],"title":"Type","description":"JSON type for the parameter value.","llm_hint":"Allowed values are string, number, boolean, object, and array."},"description":{"type":"string","title":"Description","description":"Description shown to the model for this parameter.","llm_hint":"Write this as an instruction to the agent: what value to provide and when."},"required":{"type":"boolean","title":"Required","description":"Whether this parameter is required when the tool is called.","default":true}},"type":"object","required":["name","type","description"],"title":"ToolParameter","description":"A parameter that the tool accepts from the model at call time."},"ToolResponse":{"properties":{"id":{"type":"integer","title":"Id"},"tool_uuid":{"type":"string","title":"Tool Uuid"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"category":{"type":"string","title":"Category"},"icon":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Icon"},"icon_color":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Icon Color"},"status":{"type":"string","title":"Status"},"definition":{"additionalProperties":true,"type":"object","title":"Definition"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"},"created_by":{"anyOf":[{"$ref":"#/components/schemas/CreatedByResponse"},{"type":"null"}]}},"type":"object","required":["id","tool_uuid","name","description","category","icon","icon_color","status","definition","created_at","updated_at"],"title":"ToolResponse","description":"Response schema for a reusable tool."},"TransferCallConfig":{"properties":{"destination":{"type":"string","title":"Destination","description":"Phone number or SIP endpoint to transfer the call to, e.g. +1234567890 or PJSIP/1234."},"messageType":{"type":"string","enum":["none","custom","audio"],"title":"Messagetype","description":"Type of message to play before transfer.","default":"none"},"customMessage":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Custommessage","description":"Custom message to play before transferring."},"audioRecordingId":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Audiorecordingid","description":"Recording ID for audio message before transfer."},"timeout":{"type":"integer","maximum":120.0,"minimum":5.0,"title":"Timeout","description":"Maximum seconds to wait for the destination to answer.","default":30}},"type":"object","required":["destination"],"title":"TransferCallConfig","description":"Configuration for Transfer Call tools."},"TransferCallToolDefinition":{"properties":{"schema_version":{"type":"integer","title":"Schema Version","description":"Schema version.","default":1},"type":{"type":"string","const":"transfer_call","title":"Type","description":"Tool type."},"config":{"$ref":"#/components/schemas/TransferCallConfig","description":"Transfer Call configuration."}},"type":"object","required":["type","config"],"title":"TransferCallToolDefinition","description":"Tool definition for Transfer Call tools."},"TriggerCallRequest":{"properties":{"phone_number":{"type":"string","title":"Phone Number"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"telephony_configuration_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Telephony Configuration Id"}},"type":"object","required":["phone_number"],"title":"TriggerCallRequest","description":"Request model for triggering a call via API"},"TriggerCallResponse":{"properties":{"status":{"type":"string","title":"Status"},"workflow_run_id":{"type":"integer","title":"Workflow Run Id"},"workflow_run_name":{"type":"string","title":"Workflow Run Name"}},"type":"object","required":["status","workflow_run_id","workflow_run_name"],"title":"TriggerCallResponse","description":"Response model for successful call initiation"},"TurnCredentialsResponse":{"properties":{"username":{"type":"string","title":"Username"},"password":{"type":"string","title":"Password"},"ttl":{"type":"integer","title":"Ttl"},"uris":{"items":{"type":"string"},"type":"array","title":"Uris"}},"type":"object","required":["username","password","ttl","uris"],"title":"TurnCredentialsResponse","description":"Response model for TURN credentials."},"TwilioConfigurationRequest":{"properties":{"provider":{"type":"string","const":"twilio","title":"Provider","default":"twilio"},"account_sid":{"type":"string","title":"Account Sid","description":"Twilio Account SID"},"auth_token":{"type":"string","title":"Auth Token","description":"Twilio Auth Token"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Twilio phone numbers"}},"type":"object","required":["account_sid","auth_token"],"title":"TwilioConfigurationRequest","description":"Request schema for Twilio configuration."},"TwilioConfigurationResponse":{"properties":{"provider":{"type":"string","const":"twilio","title":"Provider","default":"twilio"},"account_sid":{"type":"string","title":"Account Sid"},"auth_token":{"type":"string","title":"Auth Token"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["account_sid","auth_token","from_numbers"],"title":"TwilioConfigurationResponse","description":"Response schema for Twilio configuration with masked sensitive fields."},"UltravoxRealtimeLLMConfiguration":{"properties":{"provider":{"type":"string","const":"ultravox_realtime","title":"Provider","default":"ultravox_realtime"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Ultravox realtime voice-agent model.","default":"ultravox-v0.7","examples":["ultravox-v0.7","fixie-ai/ultravox"],"allow_custom_input":true},"voice":{"type":"string","title":"Voice","description":"Ultravox voice name or voice ID.","default":"Mark"}},"type":"object","required":["api_key"],"title":"Ultravox Realtime"},"UpdateCampaignRequest":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":255,"minLength":1},{"type":"null"}],"title":"Name"},"retry_config":{"anyOf":[{"$ref":"#/components/schemas/RetryConfigRequest"},{"type":"null"}]},"max_concurrency":{"anyOf":[{"type":"integer","maximum":100.0,"minimum":1.0},{"type":"null"}],"title":"Max Concurrency"},"schedule_config":{"anyOf":[{"$ref":"#/components/schemas/ScheduleConfigRequest"},{"type":"null"}]},"circuit_breaker":{"anyOf":[{"$ref":"#/components/schemas/CircuitBreakerConfigRequest"},{"type":"null"}]}},"type":"object","title":"UpdateCampaignRequest"},"UpdateCredentialRequest":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"credential_type":{"anyOf":[{"$ref":"#/components/schemas/WebhookCredentialType"},{"type":"null"}]},"credential_data":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Credential Data"}},"type":"object","title":"UpdateCredentialRequest","description":"Request schema for updating a webhook credential."},"UpdateFolderRequest":{"properties":{"name":{"type":"string","maxLength":100,"minLength":1,"title":"Name"}},"type":"object","required":["name"],"title":"UpdateFolderRequest"},"UpdateToolRequest":{"properties":{"name":{"anyOf":[{"type":"string","maxLength":255},{"type":"null"}],"title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"icon":{"anyOf":[{"type":"string","maxLength":50},{"type":"null"}],"title":"Icon"},"icon_color":{"anyOf":[{"type":"string","maxLength":7},{"type":"null"}],"title":"Icon Color"},"definition":{"anyOf":[{"oneOf":[{"$ref":"#/components/schemas/HttpApiToolDefinition"},{"$ref":"#/components/schemas/EndCallToolDefinition"},{"$ref":"#/components/schemas/TransferCallToolDefinition"},{"$ref":"#/components/schemas/CalculatorToolDefinition"},{"$ref":"#/components/schemas/McpToolDefinition"}],"discriminator":{"propertyName":"type","mapping":{"calculator":"#/components/schemas/CalculatorToolDefinition","end_call":"#/components/schemas/EndCallToolDefinition","http_api":"#/components/schemas/HttpApiToolDefinition","mcp":"#/components/schemas/McpToolDefinition","transfer_call":"#/components/schemas/TransferCallToolDefinition"}}},{"type":"null"}],"title":"Definition"},"status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Status"}},"type":"object","title":"UpdateToolRequest","description":"Request schema for updating a reusable tool."},"UpdateWorkflowRequest":{"properties":{"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"workflow_definition":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Workflow Definition"},"template_context_variables":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Template Context Variables"},"workflow_configurations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Workflow Configurations"}},"type":"object","title":"UpdateWorkflowRequest"},"UpdateWorkflowStatusRequest":{"properties":{"status":{"type":"string","title":"Status"}},"type":"object","required":["status"],"title":"UpdateWorkflowStatusRequest"},"UsageHistoryResponse":{"properties":{"runs":{"items":{"$ref":"#/components/schemas/WorkflowRunUsageResponse"},"type":"array","title":"Runs"},"total_dograh_tokens":{"type":"number","title":"Total Dograh Tokens"},"total_duration_seconds":{"type":"integer","title":"Total Duration Seconds"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"limit":{"type":"integer","title":"Limit"},"total_pages":{"type":"integer","title":"Total Pages"}},"type":"object","required":["runs","total_dograh_tokens","total_duration_seconds","total_count","page","limit","total_pages"],"title":"UsageHistoryResponse"},"UserConfigurationRequestResponseSchema":{"properties":{"llm":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Llm"},"tts":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Tts"},"stt":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Stt"},"embeddings":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Embeddings"},"realtime":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"string"},{"type":"number"},{"items":{"type":"string"},"type":"array"},{"type":"null"}]},"type":"object"},{"type":"null"}],"title":"Realtime"},"is_realtime":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Is Realtime"},"test_phone_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Test Phone Number"},"timezone":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Timezone"},"organization_pricing":{"anyOf":[{"additionalProperties":{"anyOf":[{"type":"number"},{"type":"string"},{"type":"boolean"}]},"type":"object"},{"type":"null"}],"title":"Organization Pricing"}},"type":"object","title":"UserConfigurationRequestResponseSchema"},"UserResponse":{"properties":{"id":{"type":"integer","title":"Id"},"email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Email"},"name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Name"},"organization_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Organization Id"},"provider_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Provider Id"}},"type":"object","required":["id","email"],"title":"UserResponse"},"ValidateWorkflowResponse":{"properties":{"is_valid":{"type":"boolean","title":"Is Valid"},"errors":{"items":{"$ref":"#/components/schemas/WorkflowError"},"type":"array","title":"Errors"}},"type":"object","required":["is_valid","errors"],"title":"ValidateWorkflowResponse"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"VobizConfigurationRequest":{"properties":{"provider":{"type":"string","const":"vobiz","title":"Provider","default":"vobiz"},"auth_id":{"type":"string","title":"Auth Id","description":"Vobiz Account ID (e.g., MA_SYQRLN1K)"},"auth_token":{"type":"string","title":"Auth Token","description":"Vobiz Auth Token"},"application_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Id","description":"Vobiz Application ID. The application's answer_url is updated when inbound workflows are attached to numbers on this account. If omitted, an application is auto-created on save and its id is stored on the configuration."},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Vobiz phone numbers (E.164 without + prefix)"}},"type":"object","required":["auth_id","auth_token"],"title":"VobizConfigurationRequest","description":"Request schema for Vobiz configuration."},"VobizConfigurationResponse":{"properties":{"provider":{"type":"string","const":"vobiz","title":"Provider","default":"vobiz"},"auth_id":{"type":"string","title":"Auth Id"},"auth_token":{"type":"string","title":"Auth Token"},"application_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Application Id"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["auth_id","auth_token","from_numbers"],"title":"VobizConfigurationResponse","description":"Response schema for Vobiz configuration with masked sensitive fields."},"VoiceInfo":{"properties":{"voice_id":{"type":"string","title":"Voice Id"},"name":{"type":"string","title":"Name"},"description":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Description"},"accent":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Accent"},"gender":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Gender"},"language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language"},"preview_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Preview Url"}},"type":"object","required":["voice_id","name"],"title":"VoiceInfo"},"VoicesResponse":{"properties":{"provider":{"type":"string","title":"Provider"},"voices":{"items":{"$ref":"#/components/schemas/VoiceInfo"},"type":"array","title":"Voices"}},"type":"object","required":["provider","voices"],"title":"VoicesResponse"},"VonageConfigurationRequest":{"properties":{"provider":{"type":"string","const":"vonage","title":"Provider","default":"vonage"},"api_key":{"type":"string","title":"Api Key","description":"Vonage API Key"},"api_secret":{"type":"string","title":"Api Secret","description":"Vonage API Secret"},"application_id":{"type":"string","title":"Application Id","description":"Vonage Application ID"},"private_key":{"type":"string","title":"Private Key","description":"Private key for JWT generation"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers","description":"List of Vonage phone numbers (without + prefix)"}},"type":"object","required":["api_key","api_secret","application_id","private_key"],"title":"VonageConfigurationRequest","description":"Request schema for Vonage configuration."},"VonageConfigurationResponse":{"properties":{"provider":{"type":"string","const":"vonage","title":"Provider","default":"vonage"},"application_id":{"type":"string","title":"Application Id"},"api_key":{"type":"string","title":"Api Key"},"api_secret":{"type":"string","title":"Api Secret"},"private_key":{"type":"string","title":"Private Key"},"from_numbers":{"items":{"type":"string"},"type":"array","title":"From Numbers"}},"type":"object","required":["application_id","api_key","api_secret","private_key","from_numbers"],"title":"VonageConfigurationResponse","description":"Response schema for Vonage configuration with masked sensitive fields."},"WebhookCredentialType":{"type":"string","enum":["none","api_key","bearer_token","basic_auth","custom_header"],"title":"WebhookCredentialType","description":"Webhook credential authentication types"},"WorkflowCountResponse":{"properties":{"total":{"type":"integer","title":"Total"},"active":{"type":"integer","title":"Active"},"archived":{"type":"integer","title":"Archived"}},"type":"object","required":["total","active","archived"],"title":"WorkflowCountResponse","description":"Response for workflow count endpoint."},"WorkflowError":{"properties":{"kind":{"$ref":"#/components/schemas/ItemKind"},"id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Id"},"field":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Field"},"message":{"type":"string","title":"Message"}},"type":"object","required":["kind","id","field","message"],"title":"WorkflowError"},"WorkflowListResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"status":{"type":"string","title":"Status"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"total_runs":{"type":"integer","title":"Total Runs"},"folder_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Folder Id"},"workflow_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Uuid"}},"type":"object","required":["id","name","status","created_at","total_runs"],"title":"WorkflowListResponse","description":"Lightweight response for workflow listings (excludes large fields)."},"WorkflowOption":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"}},"type":"object","required":["id","name"],"title":"WorkflowOption"},"WorkflowResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"},"status":{"type":"string","title":"Status"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"workflow_definition":{"additionalProperties":true,"type":"object","title":"Workflow Definition"},"current_definition_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Current Definition Id"},"template_context_variables":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Template Context Variables"},"call_disposition_codes":{"anyOf":[{"$ref":"#/components/schemas/CallDispositionCodes"},{"type":"null"}]},"total_runs":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Total Runs"},"workflow_configurations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Workflow Configurations"},"version_number":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Version Number"},"version_status":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Version Status"},"workflow_uuid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Uuid"}},"type":"object","required":["id","name","status","created_at","workflow_definition","current_definition_id"],"title":"WorkflowResponse"},"WorkflowRunDetail":{"properties":{"phone_number":{"type":"string","title":"Phone Number"},"disposition":{"type":"string","title":"Disposition"},"duration_seconds":{"type":"number","title":"Duration Seconds"},"workflow_id":{"type":"integer","title":"Workflow Id"},"run_id":{"type":"integer","title":"Run Id"},"workflow_name":{"type":"string","title":"Workflow Name"},"created_at":{"type":"string","title":"Created At"}},"type":"object","required":["phone_number","disposition","duration_seconds","workflow_id","run_id","workflow_name","created_at"],"title":"WorkflowRunDetail"},"WorkflowRunResponseSchema":{"properties":{"id":{"type":"integer","title":"Id"},"workflow_id":{"type":"integer","title":"Workflow Id"},"name":{"type":"string","title":"Name"},"mode":{"type":"string","title":"Mode"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"is_completed":{"type":"boolean","title":"Is Completed"},"transcript_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Url"},"recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Url"},"transcript_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Public Url"},"recording_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Public Url"},"public_access_token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Public Access Token"},"cost_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Cost Info"},"usage_info":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Usage Info"},"definition_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Definition Id"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"gathered_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Gathered Context"},"call_type":{"$ref":"#/components/schemas/CallType"},"logs":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Logs"},"annotations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Annotations"}},"type":"object","required":["id","workflow_id","name","mode","created_at","is_completed","transcript_url","recording_url","cost_info","definition_id","call_type"],"title":"WorkflowRunResponseSchema"},"WorkflowRunTextSessionResponse":{"properties":{"workflow_run_id":{"type":"integer","title":"Workflow Run Id"},"workflow_id":{"type":"integer","title":"Workflow Id"},"name":{"type":"string","title":"Name"},"mode":{"type":"string","title":"Mode"},"state":{"type":"string","title":"State"},"is_completed":{"type":"boolean","title":"Is Completed"},"revision":{"type":"integer","title":"Revision"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"gathered_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Gathered Context"},"annotations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Annotations"},"session_data":{"additionalProperties":true,"type":"object","title":"Session Data"},"checkpoint":{"additionalProperties":true,"type":"object","title":"Checkpoint"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Updated At"}},"type":"object","required":["workflow_run_id","workflow_id","name","mode","state","is_completed","revision","session_data","checkpoint","created_at"],"title":"WorkflowRunTextSessionResponse"},"WorkflowRunUsageResponse":{"properties":{"id":{"type":"integer","title":"Id"},"workflow_id":{"type":"integer","title":"Workflow Id"},"workflow_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Workflow Name"},"name":{"type":"string","title":"Name"},"created_at":{"type":"string","title":"Created At"},"dograh_token_usage":{"type":"number","title":"Dograh Token Usage"},"call_duration_seconds":{"type":"integer","title":"Call Duration Seconds"},"recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Url"},"transcript_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Url"},"recording_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Public Url"},"transcript_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Transcript Public Url"},"public_access_token":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Public Access Token"},"phone_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Phone Number","description":"Deprecated. Use caller_number and called_number instead.","deprecated":true},"caller_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Caller Number"},"called_number":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Called Number"},"call_type":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Call Type"},"mode":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Mode"},"disposition":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Disposition"},"initial_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Initial Context"},"gathered_context":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Gathered Context"},"charge_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Charge Usd"}},"type":"object","required":["id","workflow_id","workflow_name","name","created_at","dograh_token_usage","call_duration_seconds"],"title":"WorkflowRunUsageResponse"},"WorkflowRunsResponse":{"properties":{"runs":{"items":{"$ref":"#/components/schemas/WorkflowRunResponseSchema"},"type":"array","title":"Runs"},"total_count":{"type":"integer","title":"Total Count"},"page":{"type":"integer","title":"Page"},"limit":{"type":"integer","title":"Limit"},"total_pages":{"type":"integer","title":"Total Pages"},"applied_filters":{"anyOf":[{"items":{"additionalProperties":true,"type":"object"},"type":"array"},{"type":"null"}],"title":"Applied Filters"}},"type":"object","required":["runs","total_count","page","limit","total_pages"],"title":"WorkflowRunsResponse"},"WorkflowSummaryResponse":{"properties":{"id":{"type":"integer","title":"Id"},"name":{"type":"string","title":"Name"}},"type":"object","required":["id","name"],"title":"WorkflowSummaryResponse"},"WorkflowTemplateResponse":{"properties":{"id":{"type":"integer","title":"Id"},"template_name":{"type":"string","title":"Template Name"},"template_description":{"type":"string","title":"Template Description"},"template_json":{"additionalProperties":true,"type":"object","title":"Template Json"},"created_at":{"type":"string","format":"date-time","title":"Created At"}},"type":"object","required":["id","template_name","template_description","template_json","created_at"],"title":"WorkflowTemplateResponse"},"WorkflowVersionResponse":{"properties":{"id":{"type":"integer","title":"Id"},"version_number":{"type":"integer","title":"Version Number"},"status":{"type":"string","title":"Status"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"published_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Published At"},"workflow_json":{"additionalProperties":true,"type":"object","title":"Workflow Json"},"workflow_configurations":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Workflow Configurations"},"template_context_variables":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Template Context Variables"}},"type":"object","required":["id","version_number","status","created_at","workflow_json"],"title":"WorkflowVersionResponse"}}}} \ No newline at end of file diff --git a/docs/core-concepts/context-and-variables.mdx b/docs/core-concepts/context-and-variables.mdx index bfd81b02..274689c4 100644 --- a/docs/core-concepts/context-and-variables.mdx +++ b/docs/core-concepts/context-and-variables.mdx @@ -18,20 +18,10 @@ initial_context ──► Agent ──► gathered_context Data available to the agent before the call starts — the contact's name, account details, appointment information, anything the agent should know upfront. It can be set from several places: -- **API trigger** — pass it in the request body when calling `POST /public/agent/{uuid}` or `POST /telephony/initiate-call` -- **Campaign CSV** — columns beyond `phone_number` automatically become `initial_context` fields for each contact's call -- **Dashboard** — set default template context variables on the agent, used when no external context is provided - -```json -{ - "phone_number": "+14155550100", - "initial_context": { - "customer_name": "Jane Smith", - "plan": "premium", - "renewal_date": "April 1" - } -} -``` +- **[API trigger](/voice-agent/api-trigger)** — pass it in the request body when calling `POST /public/agent/{uuid}` or `POST /telephony/initiate-call` +- **[Campaign CSV](/core-concepts/campaigns)** — columns beyond `phone_number` automatically become `initial_context` fields for each contact's call +- **[Pre-call data fetch](/voice-agent/pre-call-data-fetch)** — enrich the context with data from your CRM or ERP via an HTTP call as the call starts, before the agent speaks +- **[Agent Settings](/voice-agent/template-variables#using-template-variables-for-testing)** — set template context variables on the agent for testing; they're included in test calls from the workflow editor and ignored on production calls ### Template variables @@ -103,7 +93,7 @@ Data the agent collects *during* the call. You configure what to extract in the Extracted variables -`gathered_context` is returned in the run record after the call completes and is available in [webhook payloads](/developer/webhooks) for downstream processing. +`gathered_context` is returned in the run record after the call completes and is available in [webhook payloads](/developer/webhooks) for downstream processing. It is **not** available as a template variable in Agent prompts — prompts can only reference `initial_context` fields. ## Data flow example diff --git a/docs/deployment/docker.mdx b/docs/deployment/docker.mdx index d0f29594..28a6a8ba 100644 --- a/docs/deployment/docker.mdx +++ b/docs/deployment/docker.mdx @@ -20,18 +20,27 @@ Watch the video tutorial below for a step-by-step walkthrough of setting up Dogr allowFullScreen > -For local development and testing, you can run Dograh AI directly on your machine using Docker with a single command. +For local development and testing, you can run Dograh AI directly on your machine using Docker with a small startup script. ### Quick Start -Run this single command to download and start Dograh AI: +Download the compose file and starter script, then confirm the prompt to start Dograh AI: -```bash -curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml && REGISTRY=ghcr.io/dograh-hq ENABLE_TELEMETRY=true docker compose up --pull always + +```bash macOS/Linux +curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml && curl -o start_docker.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.sh && chmod +x start_docker.sh && ./start_docker.sh ``` +```powershell Windows +Invoke-WebRequest -OutFile docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml +Invoke-WebRequest -OutFile start_docker.ps1 https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.ps1 +.\start_docker.ps1 +``` + -This command: +This setup: - Downloads the latest docker-compose.yaml +- Creates `OSS_JWT_SECRET` in `.env` if one does not already exist +- Prompts before running Docker Compose - Starts all required services including PostgreSQL, Redis, MinIO, API, and UI - Pulls the latest images automatically @@ -43,7 +52,7 @@ http://localhost:3010 ``` -You can disable telemetry by setting `ENABLE_TELEMETRY=false` in the command above. +You can disable telemetry by setting `ENABLE_TELEMETRY=false` before running `./start_docker.sh` or `.\start_docker.ps1`. ### Troubleshooting WebRTC Connectivity @@ -72,7 +81,7 @@ The script will prompt you for: - The host browsers should use to reach TURN (press Enter for `127.0.0.1`; use your LAN IP if testing from another device on the same network) - A shared secret for the TURN server (press Enter to generate a random one) -It creates `docker-compose.yaml`, a `.env` file with TURN credentials, and the small helper bundle that `dograh-init` uses to render coturn config at startup. Start the stack with the `local-turn` profile so coturn comes up alongside the other services: +It creates `docker-compose.yaml`, a `.env` file with JWT and TURN credentials, and the small helper bundle that `dograh-init` uses to render coturn config at startup. Start the stack with the `local-turn` profile so coturn comes up alongside the other services: ```bash docker compose --profile local-turn up --pull always diff --git a/docs/deployment/update.mdx b/docs/deployment/update.mdx index c307b89b..b2897788 100644 --- a/docs/deployment/update.mdx +++ b/docs/deployment/update.mdx @@ -67,12 +67,20 @@ The script overwrites `docker-compose.yaml` and the remote helper bundle (`remot ## Local deployment -For local Docker installs (the [Quick Start](/deployment/docker#quick-start) flow or `setup_local.sh` / `setup_local.ps1`), there are no host-side config files to refresh — pull new images and restart: +For local Docker installs (the [Quick Start](/deployment/docker#quick-start) flow or `setup_local.sh` / `setup_local.ps1`), there are no host-side config files to refresh — stop the stack, then use the startup script to preserve `OSS_JWT_SECRET` and pull new images: -```bash + +```bash macOS/Linux +curl -o start_docker.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.sh && chmod +x start_docker.sh docker compose down -docker compose up --pull always +./start_docker.sh ``` +```powershell Windows +Invoke-WebRequest -OutFile start_docker.ps1 https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.ps1 +docker compose down +.\start_docker.ps1 +``` + To pin a specific version instead of `latest`, edit `docker-compose.yaml` and change both `image:` lines for `api` and `ui` to the same tag (e.g. `:1.28.0` — Docker image tags use bare semver, no `v` prefix), then run the commands above. diff --git a/docs/getting-started/index.mdx b/docs/getting-started/index.mdx index 7dfebff4..09252089 100644 --- a/docs/getting-started/index.mdx +++ b/docs/getting-started/index.mdx @@ -24,16 +24,23 @@ Watch the following video to learn about Dograh’s capabilities. ## Setting up -Get the platform up and running using Docker with a single command on your local computer. If you are looking to deploy the platform on a server different than your local machine, please check the [Deployment](deployment/introduction) section. +Get the platform up and running using Docker with a small startup script on your local computer. If you are looking to deploy the platform on a server different than your local machine, please check the [Deployment](deployment/introduction) section. -We collect anonymous usage data to improve the product. You can opt out by setting the `ENABLE_TELEMETRY` to `false` in the below command. +We collect anonymous usage data to improve the product. You can opt out by setting `ENABLE_TELEMETRY=false` before running the startup script. -```bash -curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml && REGISTRY=ghcr.io/dograh-hq ENABLE_TELEMETRY=true docker compose up --pull always + +```bash macOS/Linux +curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml && curl -o start_docker.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.sh && chmod +x start_docker.sh && ./start_docker.sh ``` +```powershell Windows +Invoke-WebRequest -OutFile docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml +Invoke-WebRequest -OutFile start_docker.ps1 https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.ps1 +.\start_docker.ps1 +``` + Please check [Prerequisites](getting-started/prerequisites) for the system requirements and [Troubleshooting](getting-started/troubleshooting) for common issues. ## Next Steps -You can see how to configure the inference provider in [Inference Provider](/configurations/inference-providers). \ No newline at end of file +You can see how to configure the inference provider in [Inference Provider](/configurations/inference-providers). diff --git a/docs/getting-started/prerequisites.mdx b/docs/getting-started/prerequisites.mdx index 48e60cdf..aebd6aad 100644 --- a/docs/getting-started/prerequisites.mdx +++ b/docs/getting-started/prerequisites.mdx @@ -74,12 +74,23 @@ Dograh images are available from two registries: - **GitHub Container Registry (Default)**: `ghcr.io/dograh-hq` - Recommended for most users - **Docker Hub**: `dograhai` - Alternative registry -To use a specific registry, set the `REGISTRY` environment variable: +To use a specific registry, set the `REGISTRY` environment variable when running the startup script: + ```bash # Using GitHub Container Registry (recommended) -REGISTRY=ghcr.io/dograh-hq docker compose up --pull always +REGISTRY=ghcr.io/dograh-hq ./start_docker.sh # Using Docker Hub -REGISTRY=dograhai docker compose up --pull always +REGISTRY=dograhai ./start_docker.sh ``` +```powershell Windows +# Using GitHub Container Registry (recommended) +$env:REGISTRY = 'ghcr.io/dograh-hq' +.\start_docker.ps1 + +# Using Docker Hub +$env:REGISTRY = 'dograhai' +.\start_docker.ps1 +``` + diff --git a/docs/images/template-variables.png b/docs/images/template-variables.png new file mode 100644 index 00000000..3594f9da Binary files /dev/null and b/docs/images/template-variables.png differ diff --git a/docs/voice-agent/api-trigger.mdx b/docs/voice-agent/api-trigger.mdx index 1a464b6d..e5168c7c 100644 --- a/docs/voice-agent/api-trigger.mdx +++ b/docs/voice-agent/api-trigger.mdx @@ -118,7 +118,7 @@ For example, if your request includes: } ``` -You can reference the user's name in your prompt as `{{initial_context.user.name}}`. +You can reference the user's name in your agent prompt as `{{user.name}}` — in Agent prompts, `initial_context` fields are referenced directly by name (not prefixed with `initial_context.`). See [template variables](/voice-agent/template-variables) for the exact syntax in prompts versus webhook payloads. See [Context & Variables](/core-concepts/context-and-variables) for more on how data flows through a call. diff --git a/docs/voice-agent/pre-call-data-fetch.mdx b/docs/voice-agent/pre-call-data-fetch.mdx index 53d1d9ae..a793930b 100644 --- a/docs/voice-agent/pre-call-data-fetch.mdx +++ b/docs/voice-agent/pre-call-data-fetch.mdx @@ -11,7 +11,7 @@ Pre-Call Data Fetch allows you to enrich the call context with external data bef 1. A call arrives (inbound) or is initiated (outbound). 2. Dograh sends a **POST** request to your configured endpoint with a standardized payload. 3. The caller hears a ring-back tone while waiting for the response. -4. Your API responds with a JSON object containing `dynamic_variables`. +4. Your API responds with a JSON object containing an `initial_context` object. 5. The variables are merged into the call's initial context. 6. The voice agent starts with full access to the fetched data via `{{variable_name}}` syntax. @@ -50,12 +50,12 @@ The `Content-Type` header is set to `application/json`. If you configured a cred ## Expected Response Format -Your API should return a **JSON object** with a `2xx` status code. The variables to inject into the call context should be placed inside the `dynamic_variables` key: +Your API should return a **JSON object** with a `2xx` status code. The variables to inject into the call context should be placed inside the `initial_context` key: ```json { "call_inbound": { - "dynamic_variables": { + "initial_context": { "customer_name": "Jane Doe", "account_status": "active", "loyalty_tier": "gold", @@ -65,34 +65,38 @@ Your API should return a **JSON object** with a `2xx` status code. The variables } ``` -You can also place `dynamic_variables` at the top level: +You can also place `initial_context` at the top level: ```json { - "dynamic_variables": { + "initial_context": { "customer_name": "Jane Doe", "account_status": "active" } } ``` + +The legacy `dynamic_variables` key is still accepted as a drop-in alias for `initial_context`, so existing integrations keep working without any changes. Use `initial_context` for new integrations. If a response contains both keys, `initial_context` takes precedence. + + After the response is received, you can reference these values anywhere template variables are supported: - **Greeting**: `Hello {{customer_name}}, thank you for calling!` - **Prompt**: `The customer is a {{loyalty_tier}} member with {{open_tickets}} open support tickets.` -If the response is not a valid JSON object, does not contain `dynamic_variables`, or the request fails or times out, the call proceeds normally without the additional context. The pre-call fetch never blocks or fails a call. +If the response is not a valid JSON object, does not contain `initial_context` (or the legacy `dynamic_variables`), or the request fails or times out, the call proceeds normally without the additional context. The pre-call fetch never blocks or fails a call. ## Nested Variables -If your `dynamic_variables` contain nested objects, you can access them using dot notation: +If your `initial_context` contains nested objects, you can access them using dot notation: ```json { "call_inbound": { - "dynamic_variables": { + "initial_context": { "customer": { "name": "Jane Doe", "address": { @@ -153,7 +157,7 @@ app.post("/dograh/pre-call", async (req, res) => { res.json({ call_inbound: { - dynamic_variables: { + initial_context: { customer_name: customer.name, account_status: customer.status, loyalty_tier: customer.tier, diff --git a/docs/voice-agent/template-variables.mdx b/docs/voice-agent/template-variables.mdx index 3db4a56b..325317fe 100644 --- a/docs/voice-agent/template-variables.mdx +++ b/docs/voice-agent/template-variables.mdx @@ -4,13 +4,23 @@ description: "You can use Template Variables in your prompts for your Agent node --- ### Template Rendering -You can reference template variables which is passed as [`initial_context`](/core-concepts/context-and-variables#initial_context) either using the [API Trigger](/voice-agent/api-trigger) or when uploading a Sheet for a [campaign](/core-concepts/campaigns). You can also use any extracted variable as [`gathered_context`](/core-concepts/context-and-variables#gathered_context) -The template rendering can take nested values. +You reference template variables with `{{double_brace}}` syntax. The data comes from [`initial_context`](/core-concepts/context-and-variables#initial_context) — set via the [API Trigger](/voice-agent/api-trigger), a [campaign](/core-concepts/campaigns) sheet, or a [Pre-Call Data Fetch](/voice-agent/pre-call-data-fetch) that enriches the context when the call starts — and, in Webhook payloads only, from [`gathered_context`](/core-concepts/context-and-variables#gathered_context) (variables extracted during the call). -Example: If the initial context is +**The syntax depends on where you use it:** -``` +| Where | `initial_context` | `gathered_context` | +| --- | --- | --- | +| Agent node prompts | `{{field_name}}` (referenced directly) | Not available | +| Webhook Node payloads | `{{initial_context.field_name}}` | `{{gathered_context.field_name}}` | + +#### Agent node prompts + +In an Agent node prompt, reference each `initial_context` field **directly by name**. Nested values are supported with dot notation. + +Example: if the initial context is + +```json { "initial_context": { "user": { @@ -20,14 +30,26 @@ Example: If the initial context is } ``` -You can write your prompt to access the user's name as below +write your prompt to access the user's name as below: -Prompt: `You are Alice, who is talking to {{initial_context.user.name}}.` +Prompt: `You are Alice, who is talking to {{user.name}}.` + + +Variables extracted during the call (`gathered_context`) are **not** available in Agent prompts — a prompt can only reference `initial_context` fields. To act on extracted data, send it to a [Webhook Node](/voice-agent/webhook). + + +#### Webhook Node payloads + +When constructing a [Webhook Node](/voice-agent/webhook) payload, the context objects are nested under their names, so reference them with the `initial_context.` and `gathered_context.` prefixes: + +Payload value: `{{initial_context.user.name}}` or `{{gathered_context.call_disposition}}` ### Using Template Variables for Testing Template variables defined in your workflow **Settings > Context Variables** are included in test calls (both web and phone) made from the workflow editor. This is useful for simulating data that would normally come from telephony or an API trigger. +Template Variables panel in workflow Settings, showing a customer_name variable and fields to add new key/value pairs + For example, you can set `caller_number` and `called_number` as context variables to test [Pre-Call Data Fetch](/voice-agent/pre-call-data-fetch#testing-with-test-calls) without needing a real inbound call. diff --git a/pipecat b/pipecat index 228324a1..0d64dc6e 160000 --- a/pipecat +++ b/pipecat @@ -1 +1 @@ -Subproject commit 228324a146a6765c6b8d610963bc80d7bc8cb9f7 +Subproject commit 0d64dc6e0e3e6b3c46cc66373e34b4f54f980268 diff --git a/scripts/setup_local.ps1 b/scripts/setup_local.ps1 index 2958f307..d8b99137 100644 --- a/scripts/setup_local.ps1 +++ b/scripts/setup_local.ps1 @@ -243,6 +243,7 @@ if ($UseCoturn) { Write-Info "[2/$TotalSteps] Creating environment file..." $ossJwtSecret = New-HexSecret 32 +$postgresPassword = New-HexSecret 32 $envLines = @( '# Container registry for Dograh images' @@ -251,6 +252,11 @@ $envLines = @( '# JWT secret for OSS authentication' "OSS_JWT_SECRET=$ossJwtSecret" '' + '# PostgreSQL password. Used by the postgres container on first init and by' + "# the API's DATABASE_URL. Do not change after the first start — the password" + '# is baked into the postgres data volume when it is first created.' + "POSTGRES_PASSWORD=$postgresPassword" + '' '# Telemetry (set to false to disable)' "ENABLE_TELEMETRY=$EnableTelemetry" '' diff --git a/scripts/setup_local.sh b/scripts/setup_local.sh index 674185e1..e94fb60c 100755 --- a/scripts/setup_local.sh +++ b/scripts/setup_local.sh @@ -150,6 +150,7 @@ fi ENV_STEP=$TOTAL_STEPS echo -e "${BLUE}[$ENV_STEP/$TOTAL_STEPS] Creating environment file...${NC}" OSS_JWT_SECRET=$(openssl rand -hex 32) +POSTGRES_PASSWORD=$(openssl rand -hex 32) cat > .env << ENV_EOF # Container registry for Dograh images @@ -158,6 +159,11 @@ REGISTRY=$REGISTRY # JWT secret for OSS authentication OSS_JWT_SECRET=$OSS_JWT_SECRET +# PostgreSQL password. Used by the postgres container on first init and by the +# API's DATABASE_URL. Do not change after the first start — the password is +# baked into the postgres data volume when it is first created. +POSTGRES_PASSWORD=$POSTGRES_PASSWORD + # Telemetry (set to false to disable) ENABLE_TELEMETRY=$ENABLE_TELEMETRY diff --git a/scripts/setup_remote.sh b/scripts/setup_remote.sh index d958b694..919c881d 100755 --- a/scripts/setup_remote.sh +++ b/scripts/setup_remote.sh @@ -251,6 +251,7 @@ echo -e "${GREEN}✓ SSL certificates generated${NC}" echo -e "${BLUE}[4/$TOTAL] Creating environment file...${NC}" OSS_JWT_SECRET=$(openssl rand -hex 32) +POSTGRES_PASSWORD=$(openssl rand -hex 32) cat > .env << ENV_EOF # Remote deployments run with production signaling and HTTPS defaults @@ -276,6 +277,11 @@ FORCE_TURN_RELAY=$FORCE_TURN_RELAY # JWT secret for OSS authentication OSS_JWT_SECRET=$OSS_JWT_SECRET +# PostgreSQL password. Used by the postgres container on first init and by the +# API's DATABASE_URL. Do not change after the first start — the password is +# baked into the postgres data volume when it is first created. +POSTGRES_PASSWORD=$POSTGRES_PASSWORD + # Telemetry (set to false to disable) ENABLE_TELEMETRY=$ENABLE_TELEMETRY diff --git a/scripts/start_docker.ps1 b/scripts/start_docker.ps1 new file mode 100644 index 00000000..e039bb9a --- /dev/null +++ b/scripts/start_docker.ps1 @@ -0,0 +1,95 @@ +$ErrorActionPreference = 'Stop' + +$EnvFile = '.env' +$Registry = if ([string]::IsNullOrEmpty($env:REGISTRY)) { 'ghcr.io/dograh-hq' } else { $env:REGISTRY } +$EnableTelemetry = if ([string]::IsNullOrEmpty($env:ENABLE_TELEMETRY)) { 'true' } else { $env:ENABLE_TELEMETRY } +$Utf8NoBom = [System.Text.UTF8Encoding]::new($false) + +function New-HexSecret { + $bytes = [byte[]]::new(32) + [System.Security.Cryptography.RandomNumberGenerator]::Fill($bytes) + return -join ($bytes | ForEach-Object { $_.ToString('x2') }) +} + +function Get-DotEnvValue { + param( + [string]$Path, + [string]$Key + ) + + if (-not (Test-Path $Path)) { + return $null + } + + $resolvedPath = (Resolve-Path $Path).Path + foreach ($line in [System.IO.File]::ReadLines($resolvedPath)) { + if ($line.StartsWith("$Key=")) { + return $line.Substring($Key.Length + 1) + } + } + + return $null +} + +function Set-DotEnvValue { + param( + [string]$Path, + [string]$Key, + [string]$Value + ) + + $lines = New-Object System.Collections.Generic.List[string] + $updated = $false + + if (Test-Path $Path) { + $resolvedPath = (Resolve-Path $Path).Path + foreach ($line in [System.IO.File]::ReadLines($resolvedPath)) { + if ($line.StartsWith("$Key=")) { + $lines.Add("$Key=$Value") + $updated = $true + } else { + $lines.Add($line) + } + } + } + + if (-not $updated) { + $lines.Add("$Key=$Value") + } + + [System.IO.File]::WriteAllLines((Join-Path (Get-Location) $Path), $lines, $Utf8NoBom) +} + +if (-not (Test-Path 'docker-compose.yaml')) { + Write-Error 'docker-compose.yaml not found. Download it first, then re-run this script.' + exit 1 +} + +$existingSecret = Get-DotEnvValue -Path $EnvFile -Key 'OSS_JWT_SECRET' +if ([string]::IsNullOrEmpty($existingSecret)) { + Set-DotEnvValue -Path $EnvFile -Key 'OSS_JWT_SECRET' -Value (New-HexSecret) + Write-Host "Created OSS_JWT_SECRET in $EnvFile." +} else { + Write-Host "OSS_JWT_SECRET is already set in $EnvFile." +} + +Write-Host '' +Write-Host "Docker registry: $Registry" +Write-Host "Telemetry enabled: $EnableTelemetry" +Write-Host '' +Write-Host 'This will run:' +Write-Host " `$env:REGISTRY = '$Registry'; `$env:ENABLE_TELEMETRY = '$EnableTelemetry'; docker compose up --pull always" +Write-Host '' + +$answer = Read-Host 'Start Dograh now? [Y/n]' +if ($answer -match '^[Nn]') { + Write-Host 'Dograh was not started.' + exit 0 +} + +$env:REGISTRY = $Registry +$env:ENABLE_TELEMETRY = $EnableTelemetry +docker compose up --pull always +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} diff --git a/scripts/start_docker.sh b/scripts/start_docker.sh new file mode 100755 index 00000000..9cb96750 --- /dev/null +++ b/scripts/start_docker.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -e + +ENV_FILE=".env" +REGISTRY="${REGISTRY:-ghcr.io/dograh-hq}" +ENABLE_TELEMETRY="${ENABLE_TELEMETRY:-true}" + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +generate_secret() { + if command -v python3 >/dev/null 2>&1 && python3 -c 'import secrets; print(secrets.token_hex(32))'; then + return + fi + + if command -v openssl >/dev/null 2>&1 && openssl rand -hex 32; then + return + fi + + if [[ -r /dev/urandom ]] && command -v od >/dev/null 2>&1 && command -v tr >/dev/null 2>&1 && od -An -N32 -tx1 /dev/urandom | tr -d ' \n'; then + return + fi + + fail "Could not generate OSS_JWT_SECRET. Install python3 or openssl, or set OSS_JWT_SECRET manually in .env." +} + +dotenv_value() { + local key=$1 + local line + + [[ -f "$ENV_FILE" ]] || return 1 + + while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + "$key"=*) + printf '%s\n' "${line#*=}" + return 0 + ;; + esac + done < "$ENV_FILE" + + return 1 +} + +set_dotenv_value() { + local key=$1 + local value=$2 + local tmp_file="${ENV_FILE}.tmp.$$" + local line + local updated=false + + if [[ -f "$ENV_FILE" ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + case "$line" in + "$key"=*) + printf '%s=%s\n' "$key" "$value" + updated=true + ;; + *) + printf '%s\n' "$line" + ;; + esac + done < "$ENV_FILE" > "$tmp_file" + + if [[ "$updated" != "true" ]]; then + printf '%s=%s\n' "$key" "$value" >> "$tmp_file" + fi + + mv "$tmp_file" "$ENV_FILE" + else + printf '%s=%s\n' "$key" "$value" > "$ENV_FILE" + fi +} + +[[ -f docker-compose.yaml ]] || fail "docker-compose.yaml not found. Download it first, then re-run this script." + +existing_secret="$(dotenv_value OSS_JWT_SECRET || true)" +if [[ -z "$existing_secret" ]]; then + set_dotenv_value OSS_JWT_SECRET "$(generate_secret)" + echo "Created OSS_JWT_SECRET in $ENV_FILE." +else + echo "OSS_JWT_SECRET is already set in $ENV_FILE." +fi + +echo "" +echo "Docker registry: $REGISTRY" +echo "Telemetry enabled: $ENABLE_TELEMETRY" +echo "" +echo "This will run:" +echo " REGISTRY=$REGISTRY ENABLE_TELEMETRY=$ENABLE_TELEMETRY docker compose up --pull always" +echo "" + +if [[ ! -t 0 ]]; then + echo "Run the command above from an interactive shell to start Dograh." + exit 0 +fi + +read -r -p "Start Dograh now? [Y/n]: " answer +case "$answer" in + [Nn]*) + echo "Dograh was not started." + exit 0 + ;; +esac + +REGISTRY="$REGISTRY" ENABLE_TELEMETRY="$ENABLE_TELEMETRY" docker compose up --pull always diff --git a/sdk/python/src/dograh_sdk/_generated_models.py b/sdk/python/src/dograh_sdk/_generated_models.py index 7f2fa8a7..48a5a546 100644 --- a/sdk/python/src/dograh_sdk/_generated_models.py +++ b/sdk/python/src/dograh_sdk/_generated_models.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: -# filename: dograh-openapi-rs5H7P.json -# timestamp: 2026-06-02T06:01:29+00:00 +# filename: dograh-openapi-XXXXXX.json.mKgFDhNhca +# timestamp: 2026-06-09T10:10:10+00:00 from __future__ import annotations diff --git a/ui/next.config.ts b/ui/next.config.ts index 1b8a3996..98242c20 100644 --- a/ui/next.config.ts +++ b/ui/next.config.ts @@ -9,11 +9,6 @@ const nextConfig: NextConfig = { }, async rewrites() { return [ - // API proxy for backend calls (excluding Next.js API routes) - { - source: "/api/:path((?!config|auth).*)*", - destination: `${process.env.BACKEND_URL || 'http://localhost:8000'}/api/:path*`, - }, { source: "/ingest/static/:path*", destination: "https://us-assets.i.posthog.com/static/:path*", diff --git a/ui/package.json b/ui/package.json index 17bdeee8..267dc837 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "ui", - "version": "1.33.0", + "version": "1.34.0", "private": true, "scripts": { "dev": "cross-env NODE_OPTIONS=--enable-source-maps next dev --turbopack", diff --git a/ui/src/app/api/v1/[...path]/route.ts b/ui/src/app/api/v1/[...path]/route.ts new file mode 100644 index 00000000..7f89b0ab --- /dev/null +++ b/ui/src/app/api/v1/[...path]/route.ts @@ -0,0 +1,104 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { getServerBackendUrl } from "@/lib/apiClient"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const HOP_BY_HOP_HEADERS = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +function trimTrailingSlash(url: string) { + return url.endsWith("/") ? url.slice(0, -1) : url; +} + +function buildBackendUrl(request: NextRequest) { + const backendUrl = trimTrailingSlash(getServerBackendUrl()); + return `${backendUrl}${request.nextUrl.pathname}${request.nextUrl.search}`; +} + +function createRequestHeaders(request: NextRequest) { + const headers = new Headers(request.headers); + + for (const header of HOP_BY_HOP_HEADERS) { + headers.delete(header); + } + + headers.delete("accept-encoding"); + headers.delete("content-length"); + headers.delete("host"); + + return headers; +} + +function createResponseHeaders(response: Response) { + const headers = new Headers(response.headers); + const setCookies = response.headers.getSetCookie(); + + for (const header of HOP_BY_HOP_HEADERS) { + headers.delete(header); + } + + headers.delete("content-encoding"); + headers.delete("content-length"); + headers.delete("set-cookie"); + + for (const cookie of setCookies) { + headers.append("set-cookie", cookie); + } + + return headers; +} + +async function getRequestBody(request: NextRequest) { + if (request.method === "GET" || request.method === "HEAD") { + return undefined; + } + + return request.arrayBuffer(); +} + +async function proxyRequest(request: NextRequest) { + const backendUrl = buildBackendUrl(request); + + try { + const response = await fetch(backendUrl, { + method: request.method, + headers: createRequestHeaders(request), + body: await getRequestBody(request), + cache: "no-store", + }); + + return new Response(request.method === "HEAD" ? null : response.body, { + status: response.status, + statusText: response.statusText, + headers: createResponseHeaders(response), + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown backend proxy error"; + + return NextResponse.json( + { + detail: `Backend request failed while proxying to ${backendUrl}: ${message}`, + }, + { status: 502 }, + ); + } +} + +export const GET = proxyRequest; +export const POST = proxyRequest; +export const PUT = proxyRequest; +export const PATCH = proxyRequest; +export const DELETE = proxyRequest; +export const OPTIONS = proxyRequest; +export const HEAD = proxyRequest; diff --git a/ui/src/app/billing/page.tsx b/ui/src/app/billing/page.tsx index 422e019e..0a9732c9 100644 --- a/ui/src/app/billing/page.tsx +++ b/ui/src/app/billing/page.tsx @@ -1,17 +1,416 @@ "use client"; -import { DograhCreditsCard } from "@/components/billing/DograhCreditsCard"; +import { + ChevronLeft, + ChevronRight, + CircleDollarSign, + CreditCard, + RefreshCw, +} from "lucide-react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { createMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPost, getBillingCreditsApiV1OrganizationsBillingCreditsGet } from "@/client/sdk.gen"; +import type { MpsBillingCreditsResponse, MpsCreditLedgerEntryResponse } from "@/client/types.gen"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useAppConfig } from "@/context/AppConfigContext"; +import { useAuth } from "@/lib/auth"; + +const LEDGER_PAGE_SIZE = 50; + +const formatCredits = (value: number | null | undefined) => ( + (value ?? 0).toLocaleString(undefined, { + maximumFractionDigits: 2, + minimumFractionDigits: 0, + }) +); + +const formatAmount = (amountMinor?: number | null, currency?: string | null) => { + if (amountMinor == null) { + return "-"; + } + + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: currency || "USD", + }).format(amountMinor / 100); +}; + +const formatDate = (value: string) => ( + new Date(value).toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) +); + +const metricLabels: Record = { + voice_minutes: "Voice usage", + platform_usage: "Platform usage", +}; + +const formatTitleCase = (value: string | null | undefined) => ( + value ? value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()) : "-" +); + +const getLedgerEntryLabel = (entry: MpsCreditLedgerEntryResponse) => { + if (entry.metric_code) { + return metricLabels[entry.metric_code] ?? formatTitleCase(entry.metric_code); + } + + if (entry.entry_type === "grant") { + return "Credit grant"; + } + + if (entry.entry_type === "purchase") { + return "Credit purchase"; + } + + return formatTitleCase(entry.entry_type); +}; + +const formatBillableQuantity = (entry: MpsCreditLedgerEntryResponse) => { + if (entry.billable_quantity == null || !entry.quantity_unit) { + return null; + } + + const unit = entry.quantity_unit === "minute" ? "min" : entry.quantity_unit; + return `${formatCredits(entry.billable_quantity)} ${unit}`; +}; + +const getRunHref = (entry: MpsCreditLedgerEntryResponse) => { + if (!entry.workflow_id || !entry.workflow_run_id) { + return null; + } + + return `/workflow/${entry.workflow_id}/run/${entry.workflow_run_id}`; +}; + +const getPageFromSearchParams = ( + searchParams: { get: (name: string) => string | null }, +) => { + const pageParam = searchParams.get("page"); + const page = pageParam ? Number.parseInt(pageParam, 10) : 1; + return Number.isFinite(page) && page > 0 ? page : 1; +}; export default function BillingPage() { - return ( -
-
-

Credits & Billing

-

- Track your Dograh model credits and request top-ups. -

-
- -
- ); + const router = useRouter(); + const searchParams = useSearchParams(); + const auth = useAuth(); + const { config } = useAppConfig(); + const [credits, setCredits] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [purchasing, setPurchasing] = useState(false); + const [currentPage, setCurrentPage] = useState( + () => getPageFromSearchParams(searchParams), + ); + + const isBillingV2 = credits?.billing_version === "v2"; + const canPurchaseCredits = isBillingV2 && config?.deploymentMode !== "oss"; + const totalQuota = credits?.total_quota ?? 0; + const remainingCredits = credits?.remaining_credits ?? 0; + const usedCredits = credits?.total_credits_used ?? 0; + const usagePercent = totalQuota > 0 ? Math.min(100, Math.round((usedCredits / totalQuota) * 100)) : 0; + + const ledgerEntries = useMemo(() => credits?.ledger_entries ?? [], [credits?.ledger_entries]); + const ledgerPage = credits?.page ?? currentPage; + const ledgerTotalCount = credits?.total_count ?? ledgerEntries.length; + const ledgerTotalPages = credits?.total_pages ?? 0; + + const fetchCredits = useCallback(async ( + page: number, + { silent = false }: { silent?: boolean } = {}, + ) => { + if (auth.loading) { + return; + } + + if (!auth.isAuthenticated) { + setLoading(false); + return; + } + + if (silent) { + setRefreshing(true); + } else { + setLoading(true); + } + + try { + const response = await getBillingCreditsApiV1OrganizationsBillingCreditsGet({ + query: { page, limit: LEDGER_PAGE_SIZE }, + }); + + if (response.error) { + throw new Error("Failed to fetch billing credits"); + } + + setCredits(response.data ?? null); + } catch (error) { + console.error("Failed to fetch billing credits:", error); + toast.error("Failed to fetch billing credits"); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [auth.isAuthenticated, auth.loading]); + + useEffect(() => { + const nextPage = getPageFromSearchParams(searchParams); + setCurrentPage((previousPage) => ( + previousPage === nextPage ? previousPage : nextPage + )); + }, [searchParams]); + + useEffect(() => { + fetchCredits(currentPage); + }, [currentPage, fetchCredits]); + + const handleRefresh = () => { + fetchCredits(currentPage, { silent: true }); + }; + + const updateUrlPage = useCallback((page: number) => { + const newParams = new URLSearchParams(searchParams.toString()); + if (page > 1) { + newParams.set("page", page.toString()); + } else { + newParams.delete("page"); + } + + const queryString = newParams.toString(); + router.push(queryString ? `/billing?${queryString}` : "/billing"); + }, [router, searchParams]); + + const handlePageChange = (page: number) => { + const nextPage = Math.max(1, page); + setCurrentPage(nextPage); + updateUrlPage(nextPage); + }; + + const handlePurchaseCredits = async () => { + if (!canPurchaseCredits) { + return; + } + + setPurchasing(true); + try { + const response = await createMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPost(); + const checkoutUrl = response.data?.checkout_url; + if (!checkoutUrl) { + throw new Error("Missing checkout URL"); + } + window.location.href = checkoutUrl; + } catch (error) { + console.error("Failed to create credit purchase URL:", error); + toast.error("Failed to open checkout"); + setPurchasing(false); + } + }; + + if (loading) { + return ( +
+
+ + +
+
+ + +
+ +
+ ); + } + + return ( +
+
+
+

Billing

+

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

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

1 credit = 1 cent

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

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

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

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

+
+ + +
+
+ )} +
+
+ ) : ( + + + Credit Usage + + + +
+ {usagePercent}% used + {formatCredits(remainingCredits)} of {formatCredits(totalQuota)} remaining +
+
+
+ )} +
+ ); } diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx index 3083bce1..15b4cfb5 100644 --- a/ui/src/app/layout.tsx +++ b/ui/src/app/layout.tsx @@ -13,8 +13,8 @@ import { ThemeProvider } from "@/components/ThemeProvider"; import { Toaster } from "@/components/ui/sonner"; import { AppConfigProvider } from "@/context/AppConfigContext"; import { OnboardingProvider } from "@/context/OnboardingContext"; +import { OrgConfigProvider } from "@/context/OrgConfigContext"; import { TelephonyConfigWarningsProvider } from "@/context/TelephonyConfigWarningsContext"; -import { UserConfigProvider } from "@/context/UserConfigContext"; import { AuthProvider } from "@/lib/auth"; @@ -70,7 +70,7 @@ export default function RootLayout({ }> - + @@ -81,7 +81,7 @@ export default function RootLayout({ - + diff --git a/ui/src/app/model-configurations/page.tsx b/ui/src/app/model-configurations/page.tsx index fc6bdd92..a72ccbd9 100644 --- a/ui/src/app/model-configurations/page.tsx +++ b/ui/src/app/model-configurations/page.tsx @@ -1,13 +1,25 @@ -import ServiceConfiguration from "@/components/ServiceConfiguration"; +import ModelConfigurationV2 from "@/components/ModelConfigurationV2"; import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation"; -export default function ServiceConfigurationPage() { +interface ServiceConfigurationPageProps { + searchParams?: Promise<{ + action?: string | string[]; + }>; +} + +export default async function ServiceConfigurationPage({ searchParams }: ServiceConfigurationPageProps) { + const params = searchParams ? await searchParams : {}; + const action = Array.isArray(params.action) ? params.action[0] : params.action; + return (
- +
diff --git a/ui/src/app/reports/page.tsx b/ui/src/app/reports/page.tsx index 37140924..3f703bdc 100644 --- a/ui/src/app/reports/page.tsx +++ b/ui/src/app/reports/page.tsx @@ -2,11 +2,12 @@ import { addDays, format, subDays } from 'date-fns'; import { Calendar, ChevronLeft, ChevronRight, Download } from 'lucide-react'; -import { useEffect,useState } from 'react'; +import { useEffect, useState } from 'react'; import { getDailyReportApiV1OrganizationsReportsDailyGet, getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet, + getPreferencesApiV1OrganizationsPreferencesGet, getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet } from '@/client/sdk.gen'; import type { WorkflowRunDetail } from '@/client/types.gen'; @@ -16,7 +17,6 @@ import { Card } from '@/components/ui/card'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Skeleton } from '@/components/ui/skeleton'; -import { useUserConfig } from '@/context/UserConfigContext'; import { useAuth } from '@/lib/auth'; import { DispositionChart } from './components/DispositionChart'; @@ -57,11 +57,9 @@ export default function ReportsPage() { const [report, setReport] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const { userConfig } = useUserConfig(); + const [timezone, setTimezone] = useState('America/New_York'); const auth = useAuth(); - const timezone = userConfig?.timezone || 'America/New_York'; - // Fetch workflows on mount useEffect(() => { const fetchWorkflows = async () => { @@ -80,6 +78,22 @@ export default function ReportsPage() { fetchWorkflows(); }, [auth.isAuthenticated]); + useEffect(() => { + const fetchPreferences = async () => { + if (!auth.isAuthenticated) return; + + try { + const response = await getPreferencesApiV1OrganizationsPreferencesGet(); + if (response.data?.timezone) { + setTimezone(response.data.timezone); + } + } catch (err) { + console.error('Failed to fetch organization preferences:', err); + } + }; + fetchPreferences(); + }, [auth.isAuthenticated]); + // Fetch report data when date or workflow changes useEffect(() => { const fetchReport = async () => { @@ -187,7 +201,9 @@ export default function ReportsPage() {
{/* Header */}
-

Daily Reports

+
+

Daily Reports

+
{/* Date Navigation & Workflow Selector */}
diff --git a/ui/src/app/settings/page.tsx b/ui/src/app/settings/page.tsx index 84345d77..a28ba44f 100644 --- a/ui/src/app/settings/page.tsx +++ b/ui/src/app/settings/page.tsx @@ -3,6 +3,7 @@ import { ExternalLink } from "lucide-react"; import { MCPSection } from "@/components/MCPSection"; +import { OrganizationPreferencesSection } from "@/components/OrganizationPreferencesSection"; import { TelemetrySection } from "@/components/TelemetrySection"; import { Card, @@ -23,6 +24,19 @@ export default function SettingsPage() {

+ + + Preferences + + Set organization-wide defaults such as the test phone number and + timezone. + + + + + + + MCP Server diff --git a/ui/src/app/usage/page.tsx b/ui/src/app/usage/page.tsx index a91451d2..a5d69839 100644 --- a/ui/src/app/usage/page.tsx +++ b/ui/src/app/usage/page.tsx @@ -6,8 +6,8 @@ import { useCallback, useEffect, useId, useState } from 'react'; import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select'; import { toast } from 'sonner'; -import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getUsageHistoryApiV1OrganizationsUsageRunsGet } from '@/client/sdk.gen'; -import type { DailyUsageBreakdownResponse, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen'; +import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getPreferencesApiV1OrganizationsPreferencesGet, getUsageHistoryApiV1OrganizationsUsageRunsGet, savePreferencesApiV1OrganizationsPreferencesPut } from '@/client/sdk.gen'; +import type { DailyUsageBreakdownResponse, OrganizationPreferences, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen'; import { CallTypeCell } from '@/components/CallTypeCell'; import { DailyUsageTable } from '@/components/DailyUsageTable'; import { FilterBuilder } from '@/components/filters/FilterBuilder'; @@ -35,7 +35,7 @@ const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone; export default function UsagePage() { const router = useRouter(); const searchParams = useSearchParams(); - const { userConfig, saveUserConfig, loading: userConfigLoading, organizationPricing } = useUserConfig(); + const { organizationPricing } = useUserConfig(); const auth = useAuth(); // Usage history state @@ -69,6 +69,8 @@ export default function UsagePage() { const localTimezone = getLocalTimezone(); const [selectedTimezone, setSelectedTimezone] = useState(''); const [savingTimezone, setSavingTimezone] = useState(false); + const [preferences, setPreferences] = useState({}); + const [preferencesLoading, setPreferencesLoading] = useState(true); const timezoneSelectId = useId(); // Stable ID for react-select to prevent hydration mismatch // Translate the FilterBuilder state into the query-param shape the @@ -148,6 +150,23 @@ export default function UsagePage() { } }, [auth.isAuthenticated, organizationPricing]); + const fetchPreferences = useCallback(async () => { + if (!auth.isAuthenticated) return; + + setPreferencesLoading(true); + try { + const response = await getPreferencesApiV1OrganizationsPreferencesGet(); + const nextPreferences = response.data || {}; + setPreferences(nextPreferences); + setSelectedTimezone(nextPreferences.timezone || localTimezone); + } catch (error) { + console.error('Failed to fetch organization preferences:', error); + setSelectedTimezone(localTimezone); + } finally { + setPreferencesLoading(false); + } + }, [auth.isAuthenticated, localTimezone]); + // Download a CSV of all runs matching the current filters. const handleDownloadReport = async () => { if (!auth.isAuthenticated) return; @@ -183,31 +202,31 @@ export default function UsagePage() { const handleTimezoneChange = async (timezone: ITimezoneOption | string) => { setSelectedTimezone(timezone); setSavingTimezone(true); + const previousTimezone = preferences.timezone || localTimezone; try { const tzValue = typeof timezone === 'string' ? timezone : timezone.value; - await saveUserConfig({ timezone: tzValue }); + const response = await savePreferencesApiV1OrganizationsPreferencesPut({ + body: { + ...preferences, + timezone: tzValue, + }, + }); + if (response.error) { + throw new Error('Failed to save timezone'); + } + setPreferences(response.data || { ...preferences, timezone: tzValue }); } catch (error) { console.error('Failed to save timezone:', error); - // Revert to previous timezone on error - const prevTz = userConfig?.timezone || localTimezone; - setSelectedTimezone(prevTz); + setSelectedTimezone(previousTimezone); } finally { setSavingTimezone(false); } }; - // Update timezone when userConfig loads + // Update timezone when organization preferences load. useEffect(() => { - if (!userConfigLoading) { - // Config has loaded - set the timezone - if (userConfig?.timezone) { - setSelectedTimezone(userConfig.timezone); - } else { - // No saved timezone, use local - setSelectedTimezone(localTimezone); - } - } - }, [userConfig, userConfigLoading, localTimezone]); + fetchPreferences(); + }, [fetchPreferences]); // Initial load - fetch when auth becomes available useEffect(() => { @@ -319,8 +338,8 @@ export default function UsagePage() { instanceId={timezoneSelectId} value={selectedTimezone} onChange={handleTimezoneChange} - isDisabled={savingTimezone || userConfigLoading} - placeholder={userConfigLoading ? "Loading..." : "Select timezone"} + isDisabled={savingTimezone || preferencesLoading} + placeholder={preferencesLoading ? "Loading..." : "Select timezone"} styles={{ control: (base, state) => ({ ...base, @@ -455,9 +474,9 @@ export default function UsagePage() { Disposition Date Duration - - {organizationPricing?.price_per_second_usd ? 'Cost (USD)' : 'Tokens'} - + {organizationPricing?.price_per_second_usd && ( + Cost (USD) + )} Actions @@ -494,12 +513,14 @@ export default function UsagePage() { {formatDuration(run.call_duration_seconds)} - - {organizationPricing?.price_per_second_usd && run.charge_usd !== undefined && run.charge_usd !== null - ? `$${run.charge_usd.toFixed(2)}` - : run.dograh_token_usage.toLocaleString() - } - + {organizationPricing?.price_per_second_usd && ( + + {run.charge_usd !== undefined && run.charge_usd !== null + ? `$${run.charge_usd.toFixed(2)}` + : '-' + } + + )} { const router = useRouter(); - const { userConfig, saveUserConfig } = useUserConfig(); - const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || ""); + const { refreshConfig } = useUserConfig(); + const [preferences, setPreferences] = useState({}); + const [preferencesLoaded, setPreferencesLoaded] = useState(false); + const [phoneNumber, setPhoneNumber] = useState(""); const [callLoading, setCallLoading] = useState(false); const [callError, setCallError] = useState(null); const [callSuccessMsg, setCallSuccessMsg] = useState(null); const [phoneChanged, setPhoneChanged] = useState(false); const [checkingConfig, setCheckingConfig] = useState(false); const [needsConfiguration, setNeedsConfiguration] = useState(null); - const [sipMode, setSipMode] = useState(() => /^(PJSIP|SIP)\//i.test(userConfig?.test_phone_number || "")); + const [sipMode, setSipMode] = useState(false); const [telephonyConfigs, setTelephonyConfigs] = useState([]); const [selectedConfigId, setSelectedConfigId] = useState(""); const [fromPhoneNumbers, setFromPhoneNumbers] = useState([]); const [selectedFromPhoneNumberId, setSelectedFromPhoneNumberId] = useState(""); const [loadingPhoneNumbers, setLoadingPhoneNumbers] = useState(false); + const fetchPreferences = useCallback(async () => { + const result = + await getPreferencesApiV1OrganizationsPreferencesGet(); + if (result.error) { + throw new Error(detailFromError(result.error, "Failed to load phone preferences")); + } + return result.data || {}; + }, []); + + const applyPreferences = useCallback((nextPreferences: OrganizationPreferences) => { + const saved = nextPreferences.test_phone_number || ""; + setPreferences(nextPreferences); + setPhoneNumber(saved); + setSipMode(/^(PJSIP|SIP)\//i.test(saved)); + setPhoneChanged(false); + }, []); + // Check telephony configuration when dialog opens useEffect(() => { const checkConfig = async () => { @@ -97,6 +123,33 @@ export const PhoneCallDialog = ({ checkConfig(); }, [open]); + // Load organization-scoped call preferences when dialog opens. + useEffect(() => { + if (!open) return; + + let cancelled = false; + setPreferencesLoaded(false); + + const loadPreferences = async () => { + try { + const nextPreferences = await fetchPreferences(); + if (cancelled) return; + applyPreferences(nextPreferences); + setPreferencesLoaded(true); + } catch (err) { + if (cancelled) return; + applyPreferences({}); + setPreferencesLoaded(false); + setCallError(err instanceof Error ? err.message : "Failed to load phone preferences"); + } + }; + + loadPreferences(); + return () => { + cancelled = true; + }; + }, [applyPreferences, fetchPreferences, open]); + // Reset state when dialog closes useEffect(() => { if (!open) { @@ -149,22 +202,9 @@ export const PhoneCallDialog = ({ }; }, [open, selectedConfigId]); - // Keep phoneNumber in sync with userConfig when dialog opens - useEffect(() => { - if (open) { - const saved = userConfig?.test_phone_number || ""; - setPhoneNumber(saved); - setSipMode(/^(PJSIP|SIP)\//i.test(saved)); - setPhoneChanged(false); - setCallError(null); - setCallSuccessMsg(null); - setCallLoading(false); - } - }, [open, userConfig?.test_phone_number]); - const handlePhoneInputChange = (formattedValue: string) => { setPhoneNumber(formattedValue); - setPhoneChanged(formattedValue !== userConfig?.test_phone_number); + setPhoneChanged(formattedValue !== (preferences.test_phone_number || "")); setCallError(null); setCallSuccessMsg(null); }; @@ -174,17 +214,39 @@ export const PhoneCallDialog = ({ router.push('/telephony-configurations'); }; + const savePhoneNumberPreference = async () => { + const currentPreferences = preferencesLoaded ? preferences : await fetchPreferences(); + const result = + await savePreferencesApiV1OrganizationsPreferencesPut({ + body: { + ...currentPreferences, + test_phone_number: phoneNumber || null, + }, + }); + + if (result.error) { + throw new Error(detailFromError(result.error, "Failed to save phone preferences")); + } + if (!result.data) { + throw new Error("Failed to save phone preferences"); + } + + setPreferences(result.data); + setPreferencesLoaded(true); + setPhoneChanged(false); + await refreshConfig(); + }; + const handleStartCall = async () => { setCallLoading(true); setCallError(null); setCallSuccessMsg(null); try { - if (!user || !userConfig) return; + if (!user) return; // Save phone number if it has changed if (phoneChanged) { - await saveUserConfig({ ...userConfig, test_phone_number: phoneNumber }); - setPhoneChanged(false); + await savePhoneNumberPreference(); } const response = await initiateCallApiV1TelephonyInitiateCallPost({ diff --git a/ui/src/app/workflow/[workflowId]/components/workflow-tester/EmbeddedVoiceTester.tsx b/ui/src/app/workflow/[workflowId]/components/workflow-tester/EmbeddedVoiceTester.tsx index 96cfb994..9a0ff85d 100644 --- a/ui/src/app/workflow/[workflowId]/components/workflow-tester/EmbeddedVoiceTester.tsx +++ b/ui/src/app/workflow/[workflowId]/components/workflow-tester/EmbeddedVoiceTester.tsx @@ -147,7 +147,7 @@ export function EmbeddedVoiceTester({ onOpenChange={setApiKeyModalOpen} error={apiKeyError} errorCode={apiKeyErrorCode} - onNavigateToCredits={() => router.push("/api-keys")} + onNavigateToBilling={() => router.push("/billing")} onNavigateToModelConfig={() => router.push("/model-configurations")} /> diff --git a/ui/src/app/workflow/[workflowId]/run/[runId]/components/ApiKeyErrorDialog.tsx b/ui/src/app/workflow/[workflowId]/run/[runId]/components/ApiKeyErrorDialog.tsx index 4704d3a5..29672545 100644 --- a/ui/src/app/workflow/[workflowId]/run/[runId]/components/ApiKeyErrorDialog.tsx +++ b/ui/src/app/workflow/[workflowId]/run/[runId]/components/ApiKeyErrorDialog.tsx @@ -8,7 +8,7 @@ interface ApiKeyErrorDialogProps { onOpenChange: (open: boolean) => void; error: string | null; errorCode: string | null; - onNavigateToCredits: () => void; + onNavigateToBilling: () => void; onNavigateToModelConfig: () => void; } @@ -17,15 +17,16 @@ export const ApiKeyErrorDialog = ({ onOpenChange, error, errorCode, - onNavigateToCredits, + onNavigateToBilling, onNavigateToModelConfig, }: ApiKeyErrorDialogProps) => { - const isQuotaError = errorCode === 'quota_exceeded'; + const isBillingCreditsError = errorCode === 'insufficient_credits'; + const isQuotaError = isBillingCreditsError || errorCode === 'quota_exceeded'; const title = isQuotaError ? "Insufficient Credits" : "API Configuration Error"; const icon = isQuotaError ? : ; - const buttonText = isQuotaError ? "Add Credits" : "Go to Model Configurations"; - const onNavigate = isQuotaError ? onNavigateToCredits : onNavigateToModelConfig; + const buttonText = isBillingCreditsError ? "Go to Billing" : "Go to Model Configurations"; + const onNavigate = isBillingCreditsError ? onNavigateToBilling : onNavigateToModelConfig; return ( @@ -40,9 +41,9 @@ export const ApiKeyErrorDialog = ({

{error}

- {isQuotaError && ( + {isBillingCreditsError && (

- Your Dograh service credits are too low to start a call. + Purchase credits from Billing to continue using Dograh-managed models.

)}
diff --git a/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx b/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx index 5121fdf9..2aaf65c7 100644 --- a/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx +++ b/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx @@ -19,6 +19,13 @@ interface UseWebSocketRTCProps { onNodeTransition?: (transition: ConversationNodeTransitionItem) => void; } +const HANDLED_SERVICE_ERROR_TYPES = new Set([ + 'quota_exceeded', + 'insufficient_credits', + 'invalid_service_key', + 'quota_check_failed', +]); + export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables, onNodeTransition }: UseWebSocketRTCProps) => { const [connectionStatus, setConnectionStatus] = useState<'idle' | 'connecting' | 'connected' | 'failed'>('idle'); const [connectionActive, setConnectionActive] = useState(false); @@ -265,9 +272,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia case 'error': // Check if this is a quota/service key error - if (message.payload?.error_type === 'quota_exceeded' || - message.payload?.error_type === 'invalid_service_key' || - message.payload?.error_type === 'quota_check_failed') { + if (HANDLED_SERVICE_ERROR_TYPES.has(message.payload?.error_type)) { // Log as info since it's a handled business logic case logger.info('Quota/service key error, showing user dialog:', message.payload.message); diff --git a/ui/src/app/workflow/[workflowId]/settings/page.tsx b/ui/src/app/workflow/[workflowId]/settings/page.tsx index d12ed8aa..c13dcb59 100644 --- a/ui/src/app/workflow/[workflowId]/settings/page.tsx +++ b/ui/src/app/workflow/[workflowId]/settings/page.tsx @@ -7,8 +7,22 @@ import { useParams, useRouter } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { downloadWorkflowReportApiV1WorkflowWorkflowIdReportGet, getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost, getWorkflowApiV1WorkflowFetchWorkflowIdGet } from "@/client/sdk.gen"; -import type { WorkflowResponse } from "@/client/types.gen"; +import { + downloadWorkflowReportApiV1WorkflowWorkflowIdReportGet, + getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost, + getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get, + getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet, + getWorkflowApiV1WorkflowFetchWorkflowIdGet, +} from "@/client/sdk.gen"; +import type { + OrganizationAiModelConfigurationResponse, + OrganizationAiModelConfigurationV2, + WorkflowResponse, +} from "@/client/types.gen"; +import { + AIModelConfigurationV2Editor, + type ModelConfigurationDefaultsV2, +} from "@/components/AIModelConfigurationV2Editor"; import { FlowEdge, FlowNode } from "@/components/flow/types"; import { LLMConfigSelector } from "@/components/LLMConfigSelector"; import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm"; @@ -26,6 +40,7 @@ import { Textarea } from "@/components/ui/textarea"; import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation"; import { UnsavedChangesProvider, useUnsavedChanges, useUnsavedChangesContext } from "@/context/UnsavedChangesContext"; import { useAudioPlayback } from "@/hooks/useAudioPlayback"; +import { detailFromError } from "@/lib/apiError"; import { useAuth } from "@/lib/auth"; import logger from "@/lib/logger"; import { @@ -1040,6 +1055,182 @@ function AgentUuidSection({ workflowUuid }: { workflowUuid: string }) { ); } +// --------------------------------------------------------------------------- +// Section: Model Overrides +// --------------------------------------------------------------------------- + +function withoutModelConfigurationOverrides(configurations: WorkflowConfigurations): WorkflowConfigurations { + const next = { ...configurations }; + delete next.model_overrides; + delete next.model_configuration_v2_override; + return next; +} + +function WorkflowModelOverridesSection({ + workflowConfigurations, + workflowName, + onSave, + modelConfigurationDefaults, + organizationModelConfiguration, + modelConfigurationLoading, + modelConfigurationError, +}: { + workflowConfigurations: WorkflowConfigurations; + workflowName: string; + onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise; + modelConfigurationDefaults: ModelConfigurationDefaultsV2 | null; + organizationModelConfiguration: OrganizationAiModelConfigurationResponse | null; + modelConfigurationLoading: boolean; + modelConfigurationError: string | null; +}) { + const savedV2Override = workflowConfigurations.model_configuration_v2_override; + const hasSavedModelOverride = Boolean(savedV2Override || workflowConfigurations.model_overrides); + const [overrideEnabled, setOverrideEnabled] = useState(Boolean(savedV2Override)); + const [isRemovingOverride, setIsRemovingOverride] = useState(false); + + useEffect(() => { + setOverrideEnabled(Boolean(workflowConfigurations.model_configuration_v2_override)); + }, [workflowConfigurations.model_configuration_v2_override]); + + const source = organizationModelConfiguration?.source || "empty"; + const isV2 = source === "organization_v2"; + + const saveLegacyOverrides = async (config: Record) => { + const nextConfigurations = withoutModelConfigurationOverrides(workflowConfigurations); + const modelOverrides = config.model_overrides as WorkflowConfigurations["model_overrides"] | undefined; + if (modelOverrides) { + nextConfigurations.model_overrides = modelOverrides; + } + await onSave(nextConfigurations, workflowName); + }; + + const saveV2Override = async (configuration: OrganizationAiModelConfigurationV2) => { + const nextConfigurations = withoutModelConfigurationOverrides(workflowConfigurations); + nextConfigurations.model_configuration_v2_override = configuration; + await onSave(nextConfigurations, workflowName); + toast.success("Model override saved"); + }; + + const removeV2Override = async () => { + setIsRemovingOverride(true); + try { + await onSave(withoutModelConfigurationOverrides(workflowConfigurations), workflowName); + setOverrideEnabled(false); + toast.success("Using organization model configuration"); + } finally { + setIsRemovingOverride(false); + } + }; + + return ( + + + + + Model Overrides + + + {isV2 + ? "Override the full organization model configuration for this workflow." + : "Override global model settings for this workflow. Toggle individual services to customize."}{" "} + Learn more + + + + {modelConfigurationLoading && ( +
+ + Loading model configuration +
+ )} + + {modelConfigurationError && ( +
+ {modelConfigurationError} +
+ )} + + {!modelConfigurationLoading && !modelConfigurationError && !isV2 && ( + <> + {source === "legacy_user_v1" && ( +
+

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

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

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

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

+ Using organization model configuration. +

+ {hasSavedModelOverride && ( + + )} +
+ )} + + )} +
+
+ ); +} + // --------------------------------------------------------------------------- // Main Page // --------------------------------------------------------------------------- @@ -1127,6 +1318,11 @@ function WorkflowSettingsInner({ const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false); const [activeSection, setActiveSection] = useState("general"); + const [modelConfigurationDefaults, setModelConfigurationDefaults] = useState(null); + const [organizationModelConfiguration, setOrganizationModelConfiguration] = useState(null); + const [modelConfigurationLoading, setModelConfigurationLoading] = useState(true); + const [modelConfigurationError, setModelConfigurationError] = useState(null); + const hasFetchedModelConfiguration = useRef(false); const workflowId = workflow.id; @@ -1166,6 +1362,37 @@ function WorkflowSettingsInner({ user, }); + useEffect(() => { + if (hasFetchedModelConfiguration.current) return; + hasFetchedModelConfiguration.current = true; + + const loadModelConfiguration = async () => { + setModelConfigurationLoading(true); + setModelConfigurationError(null); + const [defaultsResult, configurationResult] = await Promise.all([ + getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet(), + getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get(), + ]); + + if (defaultsResult.error) { + setModelConfigurationError(detailFromError(defaultsResult.error, "Failed to load model configuration defaults")); + setModelConfigurationLoading(false); + return; + } + if (configurationResult.error) { + setModelConfigurationError(detailFromError(configurationResult.error, "Failed to load model configuration")); + setModelConfigurationLoading(false); + return; + } + + setModelConfigurationDefaults(defaultsResult.data as ModelConfigurationDefaultsV2); + setOrganizationModelConfiguration(configurationResult.data || null); + setModelConfigurationLoading(false); + }; + + loadModelConfiguration(); + }, []); + // Intersection observer for active sidebar link useEffect(() => { const ids = NAV_ITEMS.map((n) => n.id); @@ -1218,37 +1445,15 @@ function WorkflowSettingsInner({ onSave={saveWorkflowConfigurations} /> - {/* Model Overrides */} - - - - - Model Overrides - - - Override global model settings for this workflow. Toggle individual services to - customize.{" "} - Learn more - - - - { - await saveWorkflowConfigurations( - { - ...workflowConfigurations, - model_overrides: - config.model_overrides as WorkflowConfigurations["model_overrides"], - } as WorkflowConfigurations, - workflowName, - ); - }} - /> - - + {/* Template Variables */} = Options2 & { /** @@ -915,6 +915,13 @@ export const refreshMcpToolsApiV1ToolsToolUuidMcpRefreshPost = (options: Options) => (options.client ?? client).post({ url: '/api/v1/tools/{tool_uuid}/unarchive', ...options }); +/** + * Get Current Organization Context + * + * Return organization-scoped configuration signals owned by Dograh. + */ +export const getCurrentOrganizationContextApiV1OrganizationsContextGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/context', ...options }); + /** * Get Telephony Providers Metadata * @@ -936,6 +943,55 @@ export const getTelephonyProvidersMetadataApiV1OrganizationsTelephonyProvidersMe */ export const getTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/telephony-config-warnings', ...options }); +/** + * Get Model Configuration V2 Defaults + */ +export const getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/model-configurations/v2/defaults', ...options }); + +/** + * Get Model Configuration V2 + */ +export const getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/model-configurations/v2', ...options }); + +/** + * Save Model Configuration V2 + */ +export const saveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Put = (options: Options) => (options.client ?? client).put({ + url: '/api/v1/organizations/model-configurations/v2', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + +/** + * Preview Model Configuration V2 Migration + */ +export const previewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/model-configurations/v2/migration-preview', ...options }); + +/** + * Migrate Model Configuration V2 + */ +export const migrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePost = (options?: Options) => (options?.client ?? client).post({ url: '/api/v1/organizations/model-configurations/v2/migrate', ...options }); + +/** + * Get Preferences + */ +export const getPreferencesApiV1OrganizationsPreferencesGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/preferences', ...options }); + +/** + * Save Preferences + */ +export const savePreferencesApiV1OrganizationsPreferencesPut = (options: Options) => (options.client ?? client).put({ + url: '/api/v1/organizations/preferences', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + /** * List Telephony Configurations * @@ -1183,7 +1239,7 @@ export const reactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePut = /** * Get Current Period Usage * - * Get current billing period usage for the user's organization. + * Get current reporting-period usage for the user's organization. */ export const getCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/usage/current-period', ...options }); @@ -1197,6 +1253,20 @@ export const getCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/usage/mps-credits', ...options }); +/** + * Get Billing Credits + * + * Return legacy MPS credits or paginated v2 billing ledger details for the org. + */ +export const getBillingCreditsApiV1OrganizationsBillingCreditsGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/billing/credits', ...options }); + +/** + * Create Mps Credit Purchase Url + * + * Create a checkout URL for organizations using Dograh-managed MPS v2. + */ +export const createMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPost = (options?: Options) => (options?.client ?? client).post({ url: '/api/v1/organizations/usage/mps-credits/purchase-url', ...options }); + /** * Get Usage History * @@ -1261,7 +1331,7 @@ export const getTurnCredentialsApiV1TurnCredentialsGet = (options?: Options) => (options?.client ?? client).options({ url: '/api/v1/public/embed/init', ...options }); @@ -1297,11 +1367,15 @@ export const initializeEmbedSessionApiV1PublicEmbedInitPost = (options: Options) => (options.client ?? client).get({ url: '/api/v1/public/embed/config/{token}', ...options }); /** - * Options Config + * Options Embed Config * - * Handle CORS preflight for config endpoint + * Fallback OPTIONS handler for the embed config endpoint. + * + * Browser preflights include Access-Control-Request-Method and are handled by + * PublicEmbedCORSMiddleware before global CORS. This keeps non-conformant + * OPTIONS requests on the same validation path. */ -export const optionsConfigApiV1PublicEmbedConfigTokenOptions = (options: Options) => (options.client ?? client).options({ url: '/api/v1/public/embed/config/{token}', ...options }); +export const optionsEmbedConfigApiV1PublicEmbedConfigTokenOptions = (options: Options) => (options.client ?? client).options({ url: '/api/v1/public/embed/config/{token}', ...options }); /** * Get Public Turn Credentials @@ -1322,7 +1396,7 @@ export const getPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionToken /** * Options Turn Credentials * - * Handle CORS preflight for TURN credentials endpoint + * Fallback OPTIONS handler for TURN credentials endpoint. */ export const optionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptions = (options: Options) => (options.client ?? client).options({ url: '/api/v1/public/embed/turn-credentials/{session_token}', ...options }); diff --git a/ui/src/client/types.gen.ts b/ui/src/client/types.gen.ts index 8398f5f8..4c6afb36 100644 --- a/ui/src/client/types.gen.ts +++ b/ui/src/client/types.gen.ts @@ -136,6 +136,46 @@ export type AriConfigurationResponse = { from_numbers: Array; }; +/** + * AWS Bedrock + */ +export type AwsBedrockLlmConfiguration = { + /** + * Provider + */ + provider?: 'aws_bedrock'; + /** + * Api Key + * + * Not used for Bedrock — authentication is via the AWS credentials above. Leave blank. + */ + api_key?: string | Array | null; + /** + * Model + * + * Bedrock model ID — include the region inference-profile prefix (e.g. 'us.'). + */ + model?: string; + /** + * Aws Access Key + * + * AWS access key ID with bedrock:InvokeModel permission. + */ + aws_access_key?: string; + /** + * Aws Secret Key + * + * AWS secret access key paired with the access key ID. + */ + aws_secret_key?: string; + /** + * Aws Region + * + * AWS region where the Bedrock model is available. + */ + aws_region?: string; +}; + /** * AmbientNoiseUploadRequest */ @@ -192,6 +232,32 @@ export type AppendTextChatMessageRequest = { expected_revision?: number | null; }; +/** + * AssemblyAI + */ +export type AssemblyAisttConfiguration = { + /** + * Provider + */ + provider?: 'assemblyai'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * AssemblyAI realtime STT model. + */ + model?: string; + /** + * Language + * + * ISO 639-1 language code. + */ + language?: string; +}; + /** * AuthResponse */ @@ -217,6 +283,354 @@ export type AuthUserResponse = { is_superuser: boolean; }; +/** + * Azure OpenAI + */ +export type AzureLlmService = { + /** + * Provider + */ + provider?: 'azure'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Azure deployment name (not the upstream OpenAI model id). + */ + model?: string; + /** + * Endpoint + * + * Azure OpenAI resource endpoint (e.g. https://.openai.azure.com). + */ + endpoint: string; +}; + +/** + * Azure OpenAI + */ +export type AzureOpenAiEmbeddingsConfiguration = { + /** + * Provider + */ + provider?: 'azure'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Azure OpenAI embedding deployment name. The deployment must return 1536-dimensional embeddings. + */ + model?: string; + /** + * Endpoint + * + * Azure OpenAI resource endpoint (e.g. https://.openai.azure.com). + */ + endpoint: string; + /** + * Api Version + * + * Azure OpenAI API version for embeddings. + */ + api_version?: string; +}; + +/** + * Azure OpenAI Realtime + * + * Azure OpenAI Realtime API — low-latency speech-to-speech conversations. + */ +export type AzureRealtimeLlmConfiguration = { + /** + * Provider + */ + provider?: 'azure_realtime'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Azure OpenAI realtime deployment name. + */ + model?: string; + /** + * Endpoint + * + * Azure OpenAI resource endpoint (e.g. https://.openai.azure.com). + */ + endpoint: string; + /** + * Voice + * + * Voice the model speaks in. + */ + voice?: string; + /** + * Api Version + * + * Azure OpenAI API version. + */ + api_version?: string; +}; + +/** + * Azure Speech Services + * + * Azure Cognitive Services Speech — TTS and STT via the Azure Speech SDK. + */ +export type AzureSpeechSttConfiguration = { + /** + * Provider + */ + provider?: 'azure_speech'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Azure Speech recognition model (use 'latest_long' for continuous recognition). + */ + model?: string; + /** + * Region + * + * Azure region for Speech Services (e.g. 'eastus', 'westeurope'). + */ + region?: string; + /** + * Language + * + * BCP-47 language code for recognition. + */ + language?: string; +}; + +/** + * Azure Speech Services + * + * Azure Cognitive Services Speech — TTS and STT via the Azure Speech SDK. + */ +export type AzureSpeechTtsConfiguration = { + /** + * Provider + */ + provider?: 'azure_speech'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Azure Speech synthesis engine (neural voices only). + */ + model?: string; + /** + * Region + * + * Azure region for Speech Services (e.g. 'eastus', 'westeurope'). + */ + region?: string; + /** + * Voice + * + * Azure Neural voice name (e.g. 'en-US-AriaNeural'). + */ + voice?: string; + /** + * Language + * + * BCP-47 language code for synthesis. + */ + language?: string; + /** + * Speed + * + * Speech speed multiplier (0.5 to 2.0). + */ + speed?: number; +}; + +/** + * BYOKAIModelConfiguration + */ +export type ByokaiModelConfiguration = { + /** + * Mode + */ + mode: 'pipeline' | 'realtime'; + pipeline?: ByokPipelineAiModelConfiguration | null; + realtime?: ByokRealtimeAiModelConfiguration | null; +}; + +/** + * BYOKPipelineAIModelConfiguration + */ +export type ByokPipelineAiModelConfiguration = { + /** + * Llm + */ + llm: ({ + provider: 'openai'; + } & OpenAillmService) | ({ + provider: 'google_vertex'; + } & GoogleVertexLlmConfiguration) | ({ + provider: 'groq'; + } & GroqLlmService) | ({ + provider: 'openrouter'; + } & OpenRouterLlmConfiguration) | ({ + provider: 'google'; + } & GoogleLlmService) | ({ + provider: 'azure'; + } & AzureLlmService) | ({ + provider: 'dograh'; + } & DograhLlmService) | ({ + provider: 'aws_bedrock'; + } & AwsBedrockLlmConfiguration) | ({ + provider: 'speaches'; + } & SpeachesLlmConfiguration) | ({ + provider: 'minimax'; + } & MiniMaxLlmConfiguration) | ({ + provider: 'sarvam'; + } & SarvamLlmConfiguration); + /** + * Tts + */ + tts: ({ + provider: 'deepgram'; + } & DeepgramTtsConfiguration) | ({ + provider: 'google'; + } & GoogleTtsConfiguration) | ({ + provider: 'openai'; + } & OpenAittsService) | ({ + provider: 'elevenlabs'; + } & ElevenlabsTtsConfiguration) | ({ + provider: 'cartesia'; + } & CartesiaTtsConfiguration) | ({ + provider: 'dograh'; + } & DograhTtsService) | ({ + provider: 'sarvam'; + } & SarvamTtsConfiguration) | ({ + provider: 'camb'; + } & CambTtsConfiguration) | ({ + provider: 'rime'; + } & RimeTtsConfiguration) | ({ + provider: 'speaches'; + } & SpeachesTtsConfiguration) | ({ + provider: 'minimax'; + } & MiniMaxTtsConfiguration) | ({ + provider: 'azure_speech'; + } & AzureSpeechTtsConfiguration); + /** + * Stt + */ + stt: ({ + provider: 'deepgram'; + } & DeepgramSttConfiguration) | ({ + provider: 'cartesia'; + } & CartesiaSttConfiguration) | ({ + provider: 'openai'; + } & OpenAisttConfiguration) | ({ + provider: 'google'; + } & GoogleSttConfiguration) | ({ + provider: 'dograh'; + } & DograhSttService) | ({ + provider: 'speechmatics'; + } & SpeechmaticsSttConfiguration) | ({ + provider: 'sarvam'; + } & SarvamSttConfiguration) | ({ + provider: 'speaches'; + } & SpeachesSttConfiguration) | ({ + provider: 'assemblyai'; + } & AssemblyAisttConfiguration) | ({ + provider: 'gladia'; + } & GladiaSttConfiguration) | ({ + provider: 'azure_speech'; + } & AzureSpeechSttConfiguration); + /** + * Embeddings + */ + embeddings?: ({ + provider: 'openai'; + } & OpenAiEmbeddingsConfiguration) | ({ + provider: 'openrouter'; + } & OpenRouterEmbeddingsConfiguration) | ({ + provider: 'azure'; + } & AzureOpenAiEmbeddingsConfiguration) | ({ + provider: 'dograh'; + } & DograhEmbeddingsConfiguration) | null; +}; + +/** + * BYOKRealtimeAIModelConfiguration + */ +export type ByokRealtimeAiModelConfiguration = { + /** + * Realtime + */ + realtime: ({ + provider: 'openai_realtime'; + } & OpenAiRealtimeLlmConfiguration) | ({ + provider: 'grok_realtime'; + } & GrokRealtimeLlmConfiguration) | ({ + provider: 'ultravox_realtime'; + } & UltravoxRealtimeLlmConfiguration) | ({ + provider: 'google_realtime'; + } & GoogleRealtimeLlmConfiguration) | ({ + provider: 'google_vertex_realtime'; + } & GoogleVertexRealtimeLlmConfiguration) | ({ + provider: 'azure_realtime'; + } & AzureRealtimeLlmConfiguration); + /** + * Llm + */ + llm: ({ + provider: 'openai'; + } & OpenAillmService) | ({ + provider: 'google_vertex'; + } & GoogleVertexLlmConfiguration) | ({ + provider: 'groq'; + } & GroqLlmService) | ({ + provider: 'openrouter'; + } & OpenRouterLlmConfiguration) | ({ + provider: 'google'; + } & GoogleLlmService) | ({ + provider: 'azure'; + } & AzureLlmService) | ({ + provider: 'dograh'; + } & DograhLlmService) | ({ + provider: 'aws_bedrock'; + } & AwsBedrockLlmConfiguration) | ({ + provider: 'speaches'; + } & SpeachesLlmConfiguration) | ({ + provider: 'minimax'; + } & MiniMaxLlmConfiguration) | ({ + provider: 'sarvam'; + } & SarvamLlmConfiguration); + /** + * Embeddings + */ + embeddings?: ({ + provider: 'openai'; + } & OpenAiEmbeddingsConfiguration) | ({ + provider: 'openrouter'; + } & OpenRouterEmbeddingsConfiguration) | ({ + provider: 'azure'; + } & AzureOpenAiEmbeddingsConfiguration) | ({ + provider: 'dograh'; + } & DograhEmbeddingsConfiguration) | null; +}; + /** * BatchRecordingCreateRequestSchema * @@ -322,6 +736,38 @@ export type CallDispositionCodes = { */ export type CallType = 'inbound' | 'outbound'; +/** + * Camb.ai + */ +export type CambTtsConfiguration = { + /** + * Provider + */ + provider?: 'camb'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Camb.ai TTS model. + */ + model?: string; + /** + * Voice + * + * Camb.ai voice ID. + */ + voice?: string; + /** + * Language + * + * BCP-47 language code. + */ + language?: string; +}; + /** * CampaignDefaultsResponse */ @@ -566,6 +1012,64 @@ export type CampaignsResponse = { campaigns: Array; }; +/** + * Cartesia + */ +export type CartesiaSttConfiguration = { + /** + * Provider + */ + provider?: 'cartesia'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Cartesia STT model. + */ + model?: string; +}; + +/** + * Cartesia + */ +export type CartesiaTtsConfiguration = { + /** + * Provider + */ + provider?: 'cartesia'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Cartesia TTS model. + */ + model?: string; + /** + * Voice + * + * Cartesia voice UUID from your Cartesia dashboard. + */ + voice?: string; + /** + * Speed + * + * Speed of the voice. + */ + speed?: number; + /** + * Volume + * + * Volume multiplier for generated speech. + */ + volume?: number; +}; + /** * ChunkResponseSchema * @@ -1138,22 +1642,6 @@ export type CurrentUsageResponse = { * Used Dograh Tokens */ used_dograh_tokens: number; - /** - * Quota Dograh Tokens - */ - quota_dograh_tokens: number; - /** - * Percentage Used - */ - percentage_used: number; - /** - * Next Refresh Date - */ - next_refresh_date: string; - /** - * Quota Enabled - */ - quota_enabled: boolean; /** * Total Duration Seconds */ @@ -1162,10 +1650,6 @@ export type CurrentUsageResponse = { * Used Amount Usd */ used_amount_usd?: number | null; - /** - * Quota Amount Usd - */ - quota_amount_usd?: number | null; /** * Currency */ @@ -1264,6 +1748,52 @@ export type DailyUsageItem = { call_count: number; }; +/** + * Deepgram + */ +export type DeepgramSttConfiguration = { + /** + * Provider + */ + provider?: 'deepgram'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Deepgram STT model. + */ + model?: string; + /** + * Language + * + * Language code; 'multi' enables auto-detect (Nova-3 only). + */ + language?: string; +}; + +/** + * Deepgram + */ +export type DeepgramTtsConfiguration = { + /** + * Provider + */ + provider?: 'deepgram'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Voice + * + * Deepgram voice ID (model is inferred from the 'aura-N' prefix). + */ + voice?: string; +}; + /** * DefaultConfigurationsResponse */ @@ -1508,6 +2038,126 @@ export type DocumentUploadResponseSchema = { s3_key: string; }; +/** + * Dograh + */ +export type DograhEmbeddingsConfiguration = { + /** + * Provider + */ + provider?: 'dograh'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Dograh-managed embedding model. + */ + model?: string; +}; + +/** + * Dograh + */ +export type DograhLlmService = { + /** + * Provider + */ + provider?: 'dograh'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Dograh-hosted model tier. + */ + model?: string; +}; + +/** + * DograhManagedAIModelConfiguration + */ +export type DograhManagedAiModelConfiguration = { + /** + * Api Key + */ + api_key: string; + /** + * Voice + */ + voice?: string; + /** + * Speed + */ + speed?: number; + /** + * Language + */ + language?: string; +}; + +/** + * Dograh + */ +export type DograhSttService = { + /** + * Provider + */ + provider?: 'dograh'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Dograh STT tier. + */ + model?: string; + /** + * Language + * + * Language code; use 'multi' for auto-detect. + */ + language?: string; +}; + +/** + * Dograh + */ +export type DograhTtsService = { + /** + * Provider + */ + provider?: 'dograh'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Dograh TTS tier. + */ + model?: string; + /** + * Voice + * + * Voice preset. + */ + voice?: string; + /** + * Speed + * + * Speed of the voice. + */ + speed?: number; +}; + /** * DuplicateTemplateRequest */ @@ -1522,6 +2172,44 @@ export type DuplicateTemplateRequest = { workflow_name: string; }; +/** + * ElevenLabs + */ +export type ElevenlabsTtsConfiguration = { + /** + * Provider + */ + provider?: 'elevenlabs'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Voice + * + * ElevenLabs voice ID from your Voice Library. + */ + voice?: string; + /** + * Speed + * + * Speed of the voice. + */ + speed?: number; + /** + * Model + * + * ElevenLabs TTS model. + */ + model?: string; + /** + * Base Url + * + * ElevenLabs API base URL. Override to use a Data Residency endpoint (e.g. https://api.eu.residency.elevenlabs.io) for GDPR / HIPAA / regional compliance. + */ + base_url?: string; +}; + /** * EmbedConfigResponse * @@ -1758,6 +2446,268 @@ export type FolderResponse = { created_at: string; }; +/** + * Gladia + */ +export type GladiaSttConfiguration = { + /** + * Provider + */ + provider?: 'gladia'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Gladia STT model. + */ + model?: string; + /** + * Language + * + * ISO 639-1 language code. + */ + language?: string; +}; + +/** + * Google + */ +export type GoogleLlmService = { + /** + * Provider + */ + provider?: 'google'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Gemini model on Google AI Studio (not Vertex). + */ + model?: string; +}; + +/** + * Google Realtime + */ +export type GoogleRealtimeLlmConfiguration = { + /** + * Provider + */ + provider?: 'google_realtime'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Gemini Live model on Google AI Studio (not Vertex). + */ + model?: string; + /** + * Voice + * + * Voice the model speaks in. + */ + voice?: string; + /** + * Language + * + * ISO 639-1 language code. + */ + language?: string; +}; + +/** + * Google Cloud + */ +export type GoogleSttConfiguration = { + /** + * Provider + */ + provider?: 'google'; + /** + * Api Key + * + * Not used for Google Cloud STT. Leave blank. + */ + api_key?: string | Array | null; + /** + * Model + * + * Google Cloud Speech-to-Text V2 recognition model. + */ + model?: string; + /** + * Language + * + * Primary BCP-47 language code for recognition. + */ + language?: string; + /** + * Location + * + * Google Cloud Speech-to-Text region (for example 'global' or 'us-central1'). + */ + location?: string; + /** + * Credentials + * + * Paste the entire Google Cloud service-account JSON. If omitted, the server falls back to Application Default Credentials (ADC). + */ + credentials?: string | null; +}; + +/** + * Google Cloud + */ +export type GoogleTtsConfiguration = { + /** + * Provider + */ + provider?: 'google'; + /** + * Api Key + * + * Not used for Google Cloud TTS. Leave blank. + */ + api_key?: string | Array | null; + /** + * Model + * + * Google Cloud low-latency TTS engine. Dograh maps this to Pipecat's streaming Google TTS service for Chirp 3 HD and Journey voices. + */ + model?: string; + /** + * Voice + * + * Google Cloud voice name. Use a Chirp 3 HD or Journey voice for streaming TTS. + */ + voice?: string; + /** + * Language + * + * BCP-47 language code for synthesis. + */ + language?: string; + /** + * Speed + * + * Speech speed multiplier for Google streaming TTS. + */ + speed?: number; + /** + * Location + * + * Optional Google Cloud regional Text-to-Speech endpoint (for example 'us-central1'). Leave blank to use the default endpoint. + */ + location?: string | null; + /** + * Credentials + * + * Paste the entire Google Cloud service-account JSON. If omitted, the server falls back to Application Default Credentials (ADC). + */ + credentials?: string | null; +}; + +/** + * Google Vertex + */ +export type GoogleVertexLlmConfiguration = { + /** + * Provider + */ + provider?: 'google_vertex'; + /** + * Api Key + * + * Not used for Vertex AI — authentication is via the service account in `credentials` (or ADC). Leave blank. + */ + api_key?: string | Array | null; + /** + * Model + * + * Gemini model on Vertex AI. + */ + model?: string; + /** + * Project Id + * + * Google Cloud project ID for Vertex AI. + */ + project_id: string; + /** + * Location + * + * GCP region for the Vertex AI endpoint (e.g. 'global'). + */ + location?: string; + /** + * Credentials + * + * Paste the entire service-account JSON file contents. If omitted, falls back to Application Default Credentials (ADC). + */ + credentials?: string | null; +}; + +/** + * Google Vertex Realtime + */ +export type GoogleVertexRealtimeLlmConfiguration = { + /** + * Provider + */ + provider?: 'google_vertex_realtime'; + /** + * Api Key + * + * Not used for Vertex AI — authentication is via the service account in `credentials` (or ADC). Leave blank. + */ + api_key?: string | Array | null; + /** + * Model + * + * Vertex AI publisher/model identifier. + */ + model?: string; + /** + * Voice + * + * Voice the model speaks in. + */ + voice?: string; + /** + * Language + * + * BCP-47 language code (e.g. 'en-US'). + */ + language?: string; + /** + * Project Id + * + * Google Cloud project ID for Vertex AI. + */ + project_id: string; + /** + * Location + * + * GCP region for the Vertex AI endpoint (e.g. 'global'). + */ + location?: string; + /** + * Credentials + * + * Paste the entire service-account JSON file contents. If omitted, falls back to Application Default Credentials (ADC). + */ + credentials?: string | null; +}; + /** * GraphConstraints * @@ -1782,6 +2732,52 @@ export type GraphConstraints = { max_outgoing?: number | null; }; +/** + * Grok Realtime + */ +export type GrokRealtimeLlmConfiguration = { + /** + * Provider + */ + provider?: 'grok_realtime'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Grok realtime voice-agent model. + */ + model?: string; + /** + * Voice + * + * Voice the model speaks in. + */ + voice?: string; +}; + +/** + * Groq + */ +export type GroqLlmService = { + /** + * Provider + */ + provider?: 'groq'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Groq-hosted model identifier. + */ + model?: string; +}; + /** * HTTPValidationError */ @@ -2091,6 +3087,165 @@ export type LoginRequest = { password: string; }; +/** + * MPSBillingAccountResponse + */ +export type MpsBillingAccountResponse = { + /** + * Id + */ + id: number; + /** + * Organization Id + */ + organization_id: number; + /** + * Billing Mode + */ + billing_mode: string; + /** + * Cached Balance Credits + */ + cached_balance_credits: number; + /** + * Currency + */ + currency: string; +}; + +/** + * MPSBillingCreditsResponse + */ +export type MpsBillingCreditsResponse = { + /** + * Billing Version + */ + billing_version: 'legacy' | 'v2'; + /** + * Total Credits Used + */ + total_credits_used?: number; + /** + * Remaining Credits + */ + remaining_credits?: number; + /** + * Total Quota + */ + total_quota?: number; + account?: MpsBillingAccountResponse | null; + /** + * Ledger Entries + */ + ledger_entries?: Array; + /** + * Total Count + */ + total_count?: number; + /** + * Page + */ + page?: number; + /** + * Limit + */ + limit?: number; + /** + * Total Pages + */ + total_pages?: number; +}; + +/** + * MPSCreditLedgerEntryResponse + */ +export type MpsCreditLedgerEntryResponse = { + /** + * Id + */ + id: number; + /** + * Entry Type + */ + entry_type: string; + /** + * Origin + */ + origin?: string | null; + /** + * Credits Delta + */ + credits_delta: number; + /** + * Balance After + */ + balance_after: number; + /** + * Amount Minor + */ + amount_minor?: number | null; + /** + * Amount Currency + */ + amount_currency?: string | null; + /** + * Payment Order Id + */ + payment_order_id?: number | null; + /** + * Metric Code + */ + metric_code?: string | null; + /** + * Correlation Id + */ + correlation_id?: string | null; + /** + * Aggregation Key + */ + aggregation_key?: string | null; + /** + * Usage Event Id + */ + usage_event_id?: number | null; + /** + * Workflow Run Id + */ + workflow_run_id?: number | null; + /** + * Workflow Id + */ + workflow_id?: number | null; + /** + * Billable Quantity + */ + billable_quantity?: number | null; + /** + * Quantity Unit + */ + quantity_unit?: string | null; + /** + * Metadata + */ + metadata?: { + [key: string]: unknown; + }; + /** + * Created At + */ + created_at: string; +}; + +/** + * MPSCreditPurchaseUrlResponse + */ +export type MpsCreditPurchaseUrlResponse = { + /** + * Checkout Url + */ + checkout_url: string; +}; + /** * MPSCreditsResponse */ @@ -2205,6 +3360,82 @@ export type McpToolDefinition = { config: McpToolConfig; }; +/** + * MiniMaxLLMConfiguration + */ +export type MiniMaxLlmConfiguration = { + /** + * Provider + */ + provider?: 'minimax'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * MiniMax chat model. + */ + model?: string; + /** + * Base Url + * + * MiniMax OpenAI-compatible API endpoint. + */ + base_url?: string; + /** + * Temperature + * + * Sampling temperature. MiniMax requires > 0. + */ + temperature?: number; +}; + +/** + * MiniMaxTTSConfiguration + */ +export type MiniMaxTtsConfiguration = { + /** + * Provider + */ + provider?: 'minimax'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * MiniMax TTS model. + */ + model?: string; + /** + * Voice + * + * MiniMax voice ID. + */ + voice?: string; + /** + * Base Url + * + * MiniMax TTS API endpoint (must include the /v1/t2a_v2 path). Defaults to the global endpoint; override with https://api.minimaxi.chat/v1/t2a_v2 (mainland China) or https://api-uw.minimax.io/v1/t2a_v2 (US-West). + */ + base_url?: string; + /** + * Speed + * + * Speech speed (0.5 to 2.0). + */ + speed?: number; + /** + * Group Id + * + * MiniMax Group ID (found in your MiniMax dashboard under Account → Group). + */ + group_id: string; +}; + /** * MoveWorkflowToFolderRequest * @@ -2306,6 +3537,277 @@ export type NodeTypesResponse = { node_types: Array; }; +/** + * OpenAI + */ +export type OpenAiEmbeddingsConfiguration = { + /** + * Provider + */ + provider?: 'openai'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * OpenAI embedding model. + */ + model?: string; +}; + +/** + * OpenAI + */ +export type OpenAillmService = { + /** + * Provider + */ + provider?: 'openai'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * OpenAI chat model to use. + */ + model?: string; + /** + * Base Url + * + * Override only if using an OpenAI-compatible API (e.g. local LLM, proxy). + */ + base_url?: string; +}; + +/** + * OpenAI Realtime + */ +export type OpenAiRealtimeLlmConfiguration = { + /** + * Provider + */ + provider?: 'openai_realtime'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * OpenAI realtime (speech-to-speech) model. + */ + model?: string; + /** + * Voice + * + * Voice the model speaks in. + */ + voice?: string; +}; + +/** + * OpenAI + */ +export type OpenAisttConfiguration = { + /** + * Provider + */ + provider?: 'openai'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * OpenAI transcription model. + */ + model?: string; + /** + * Base Url + * + * Override only if using an OpenAI-compatible API (e.g. local STT, proxy). + */ + base_url?: string; +}; + +/** + * OpenAI + */ +export type OpenAittsService = { + /** + * Provider + */ + provider?: 'openai'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * OpenAI TTS model. + */ + model?: string; + /** + * Voice + * + * OpenAI TTS voice name. + */ + voice?: string; + /** + * Base Url + * + * Override only if using an OpenAI-compatible API (e.g. local TTS, proxy). + */ + base_url?: string; +}; + +/** + * Open Router + */ +export type OpenRouterEmbeddingsConfiguration = { + /** + * Provider + */ + provider?: 'openrouter'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * OpenRouter-hosted embedding model slug. + */ + model?: string; + /** + * Base Url + * + * Override only if proxying OpenRouter through your own gateway. + */ + base_url?: string; +}; + +/** + * Open Router + */ +export type OpenRouterLlmConfiguration = { + /** + * Provider + */ + provider?: 'openrouter'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * OpenRouter model slug in 'vendor/model' form. + */ + model?: string; + /** + * Base Url + * + * Override only if proxying OpenRouter through your own gateway. + */ + base_url?: string; +}; + +/** + * OrganizationAIModelConfigurationResponse + */ +export type OrganizationAiModelConfigurationResponse = { + /** + * Configuration + */ + configuration: { + [key: string]: unknown; + } | null; + /** + * Effective Configuration + */ + effective_configuration: { + [key: string]: unknown; + }; + /** + * Source + */ + source: 'organization_v2' | 'legacy_user_v1' | 'empty'; +}; + +/** + * OrganizationAIModelConfigurationV2 + */ +export type OrganizationAiModelConfigurationV2 = { + /** + * Version + */ + version?: 2; + /** + * Mode + */ + mode: 'dograh' | 'byok'; + dograh?: DograhManagedAiModelConfiguration | null; + byok?: ByokaiModelConfiguration | null; +}; + +/** + * OrganizationContextResponse + */ +export type OrganizationContextResponse = { + /** + * Organization Id + */ + organization_id?: number | null; + /** + * Organization Provider Id + */ + organization_provider_id?: string | null; + model_services: OrganizationModelServicesContext; +}; + +/** + * OrganizationModelServicesContext + */ +export type OrganizationModelServicesContext = { + /** + * Config Source + */ + config_source: 'organization_v2' | 'legacy_user_v1' | 'empty'; + /** + * Has Model Configuration V2 + */ + has_model_configuration_v2: boolean; + /** + * Managed Service Version + */ + managed_service_version?: number | null; + /** + * Uses Managed Service V2 + */ + uses_managed_service_v2: boolean; +}; + +/** + * OrganizationPreferences + */ +export type OrganizationPreferences = { + /** + * Test Phone Number + */ + test_phone_number?: string | null; + /** + * Timezone + */ + timezone?: string | null; +}; + /** * PhoneNumberCreateRequest * @@ -3034,6 +4536,44 @@ export type RewindTextChatSessionRequest = { expected_revision?: number | null; }; +/** + * Rime + */ +export type RimeTtsConfiguration = { + /** + * Provider + */ + provider?: 'rime'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Rime TTS model. + */ + model?: string; + /** + * Voice + * + * Rime voice ID. + */ + voice?: string; + /** + * Speed + * + * Speech speed multiplier. + */ + speed?: number; + /** + * Language + * + * ISO 639-1 language code. + */ + language?: string; +}; + /** * S3SignedUrlResponse */ @@ -3048,6 +4588,90 @@ export type S3SignedUrlResponse = { expires_in: number; }; +/** + * Sarvam + */ +export type SarvamLlmConfiguration = { + /** + * Provider + */ + provider?: 'sarvam'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Sarvam chat model. Use sarvam-30b for low-latency voice agents; sarvam-105b for complex multi-step reasoning. + */ + model?: string; + /** + * Temperature + * + * Sampling temperature. Sarvam recommends 0.5 for balanced conversational responses. + */ + temperature?: number; +}; + +/** + * Sarvam + */ +export type SarvamSttConfiguration = { + /** + * Provider + */ + provider?: 'sarvam'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Sarvam STT model. saarika:v2.5 transcribes in the spoken language; saaras:v3 is the recommended model with flexible output modes. + */ + model?: string; + /** + * Language + * + * BCP-47 language code. Use unknown for automatic language detection. + */ + language?: string; +}; + +/** + * Sarvam + */ +export type SarvamTtsConfiguration = { + /** + * Provider + */ + provider?: 'sarvam'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Sarvam TTS model (voice list depends on this). + */ + model?: string; + /** + * Voice + * + * Sarvam voice name; must match the selected model's voice list. + */ + voice?: string; + /** + * Language + * + * BCP-47 Indian-language code (e.g. hi-IN, en-IN). + */ + language?: string; +}; + /** * ScheduleConfigRequest */ @@ -3144,6 +4768,140 @@ export type SignupRequest = { name?: string | null; }; +/** + * Local Models (Speaches) + * + * Self-hosted OpenAI-compatible local models. See the Speaches project for setup and supported backends. + */ +export type SpeachesLlmConfiguration = { + /** + * Provider + */ + provider?: 'speaches'; + /** + * Api Key + * + * Usually not required for self-hosted endpoints. Leave blank unless your server enforces one. + */ + api_key?: string | Array | null; + /** + * Model + * + * Model name as exposed by your OpenAI-compatible server. + */ + model?: string; + /** + * Base Url + * + * OpenAI-compatible endpoint (Ollama, vLLM, etc.). + */ + base_url?: string; +}; + +/** + * Local Models (Speaches) + * + * Self-hosted OpenAI-compatible local models. See the Speaches project for setup and supported backends. + */ +export type SpeachesSttConfiguration = { + /** + * Provider + */ + provider?: 'speaches'; + /** + * Api Key + * + * Usually not required for self-hosted STT. Leave blank unless enforced. + */ + api_key?: string | Array | null; + /** + * Model + * + * Whisper model identifier as served by your STT endpoint. + */ + model?: string; + /** + * Language + * + * ISO 639-1 language code. + */ + language?: string; + /** + * Base Url + * + * OpenAI-compatible STT endpoint (Speaches, etc.). + */ + base_url?: string; +}; + +/** + * Local Models (Speaches) + * + * Self-hosted OpenAI-compatible local models. See the Speaches project for setup and supported backends. + */ +export type SpeachesTtsConfiguration = { + /** + * Provider + */ + provider?: 'speaches'; + /** + * Api Key + * + * Usually not required for self-hosted TTS. Leave blank unless enforced. + */ + api_key?: string | Array | null; + /** + * Model + * + * Model name as served by your TTS endpoint (e.g. Kokoro-FastAPI). + */ + model?: string; + /** + * Voice + * + * Voice ID for the TTS engine. + */ + voice?: string; + /** + * Base Url + * + * OpenAI-compatible TTS endpoint (Kokoro-FastAPI, etc.). + */ + base_url?: string; + /** + * Speed + * + * Speech speed (0.25 to 4.0). + */ + speed?: number; +}; + +/** + * Speechmatics + */ +export type SpeechmaticsSttConfiguration = { + /** + * Provider + */ + provider?: 'speechmatics'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Speechmatics operating point: 'standard' or 'enhanced'. + */ + model?: string; + /** + * Language + * + * ISO 639-1 language code. + */ + language?: string; +}; + /** * SuperuserWorkflowRunResponse */ @@ -3877,6 +5635,32 @@ export type TwilioConfigurationResponse = { from_numbers: Array; }; +/** + * Ultravox Realtime + */ +export type UltravoxRealtimeLlmConfiguration = { + /** + * Provider + */ + provider?: 'ultravox_realtime'; + /** + * Api Key + */ + api_key: string | Array; + /** + * Model + * + * Ultravox realtime voice-agent model. + */ + model?: string; + /** + * Voice + * + * Ultravox voice name or voice ID. + */ + voice?: string; +}; + /** * UpdateCampaignRequest */ @@ -5277,16 +7061,6 @@ export type HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflow export type HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostData = { body?: never; - headers?: { - /** - * X-Vobiz-Signature - */ - 'x-vobiz-signature'?: string | null; - /** - * X-Vobiz-Timestamp - */ - 'x-vobiz-timestamp'?: string | null; - }; path: { /** * Workflow Run Id @@ -5319,16 +7093,6 @@ export type HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRu export type HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostData = { body?: never; - headers?: { - /** - * X-Vobiz-Signature - */ - 'x-vobiz-signature'?: string | null; - /** - * X-Vobiz-Timestamp - */ - 'x-vobiz-timestamp'?: string | null; - }; path: { /** * Workflow Run Id @@ -5361,16 +7125,6 @@ export type HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdP export type HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData = { body?: never; - headers?: { - /** - * X-Vobiz-Signature - */ - 'x-vobiz-signature'?: string | null; - /** - * X-Vobiz-Timestamp - */ - 'x-vobiz-timestamp'?: string | null; - }; path: { /** * Workflow Id @@ -8172,6 +9926,45 @@ export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses = { export type UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse = UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses[keyof UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponses]; +export type GetCurrentOrganizationContextApiV1OrganizationsContextGetData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/organizations/context'; +}; + +export type GetCurrentOrganizationContextApiV1OrganizationsContextGetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetCurrentOrganizationContextApiV1OrganizationsContextGetError = GetCurrentOrganizationContextApiV1OrganizationsContextGetErrors[keyof GetCurrentOrganizationContextApiV1OrganizationsContextGetErrors]; + +export type GetCurrentOrganizationContextApiV1OrganizationsContextGetResponses = { + /** + * Successful Response + */ + 200: OrganizationContextResponse; +}; + +export type GetCurrentOrganizationContextApiV1OrganizationsContextGetResponse = GetCurrentOrganizationContextApiV1OrganizationsContextGetResponses[keyof GetCurrentOrganizationContextApiV1OrganizationsContextGetResponses]; + export type GetTelephonyProvidersMetadataApiV1OrganizationsTelephonyProvidersMetadataGetData = { body?: never; headers?: { @@ -8250,6 +10043,280 @@ export type GetTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsG export type GetTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGetResponse = GetTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGetResponses[keyof GetTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGetResponses]; +export type GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/organizations/model-configurations/v2/defaults'; +}; + +export type GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetError = GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetErrors[keyof GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetErrors]; + +export type GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/organizations/model-configurations/v2'; +}; + +export type GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetError = GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetErrors[keyof GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetErrors]; + +export type GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetResponses = { + /** + * Successful Response + */ + 200: OrganizationAiModelConfigurationResponse; +}; + +export type GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetResponse = GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetResponses[keyof GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetResponses]; + +export type SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutData = { + body: OrganizationAiModelConfigurationV2; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/organizations/model-configurations/v2'; +}; + +export type SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutError = SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutErrors[keyof SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutErrors]; + +export type SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutResponses = { + /** + * Successful Response + */ + 200: OrganizationAiModelConfigurationResponse; +}; + +export type SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutResponse = SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutResponses[keyof SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutResponses]; + +export type PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/organizations/model-configurations/v2/migration-preview'; +}; + +export type PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetError = PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetErrors[keyof PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetErrors]; + +export type PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: { + /** + * Force + */ + force?: boolean; + }; + url: '/api/v1/organizations/model-configurations/v2/migrate'; +}; + +export type MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostError = MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostErrors[keyof MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostErrors]; + +export type MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostResponses = { + /** + * Successful Response + */ + 200: OrganizationAiModelConfigurationResponse; +}; + +export type MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostResponse = MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostResponses[keyof MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostResponses]; + +export type GetPreferencesApiV1OrganizationsPreferencesGetData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/organizations/preferences'; +}; + +export type GetPreferencesApiV1OrganizationsPreferencesGetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetPreferencesApiV1OrganizationsPreferencesGetError = GetPreferencesApiV1OrganizationsPreferencesGetErrors[keyof GetPreferencesApiV1OrganizationsPreferencesGetErrors]; + +export type GetPreferencesApiV1OrganizationsPreferencesGetResponses = { + /** + * Successful Response + */ + 200: OrganizationPreferences; +}; + +export type GetPreferencesApiV1OrganizationsPreferencesGetResponse = GetPreferencesApiV1OrganizationsPreferencesGetResponses[keyof GetPreferencesApiV1OrganizationsPreferencesGetResponses]; + +export type SavePreferencesApiV1OrganizationsPreferencesPutData = { + body: OrganizationPreferences; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/organizations/preferences'; +}; + +export type SavePreferencesApiV1OrganizationsPreferencesPutErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type SavePreferencesApiV1OrganizationsPreferencesPutError = SavePreferencesApiV1OrganizationsPreferencesPutErrors[keyof SavePreferencesApiV1OrganizationsPreferencesPutErrors]; + +export type SavePreferencesApiV1OrganizationsPreferencesPutResponses = { + /** + * Successful Response + */ + 200: OrganizationPreferences; +}; + +export type SavePreferencesApiV1OrganizationsPreferencesPutResponse = SavePreferencesApiV1OrganizationsPreferencesPutResponses[keyof SavePreferencesApiV1OrganizationsPreferencesPutResponses]; + export type ListTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGetData = { body?: never; headers?: { @@ -9417,6 +11484,93 @@ export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses = { export type GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponse = GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses[keyof GetMpsCreditsApiV1OrganizationsUsageMpsCreditsGetResponses]; +export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: { + /** + * Page + */ + page?: number; + /** + * Limit + */ + limit?: number; + }; + url: '/api/v1/organizations/billing/credits'; +}; + +export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetError = GetBillingCreditsApiV1OrganizationsBillingCreditsGetErrors[keyof GetBillingCreditsApiV1OrganizationsBillingCreditsGetErrors]; + +export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponses = { + /** + * Successful Response + */ + 200: MpsBillingCreditsResponse; +}; + +export type GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponse = GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponses[keyof GetBillingCreditsApiV1OrganizationsBillingCreditsGetResponses]; + +export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + /** + * X-Api-Key + */ + 'X-API-Key'?: string | null; + }; + path?: never; + query?: never; + url: '/api/v1/organizations/usage/mps-credits/purchase-url'; +}; + +export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostError = CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostErrors[keyof CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostErrors]; + +export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponses = { + /** + * Successful Response + */ + 200: MpsCreditPurchaseUrlResponse; +}; + +export type CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponse = CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponses[keyof CreateMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPostResponses]; + export type GetUsageHistoryApiV1OrganizationsUsageRunsGetData = { body?: never; headers?: { @@ -9909,7 +12063,7 @@ export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses = { export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponse = GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses[keyof GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses]; -export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsData = { +export type OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsData = { body?: never; path: { /** @@ -9921,7 +12075,7 @@ export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsData = { url: '/api/v1/public/embed/config/{token}'; }; -export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors = { +export type OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsErrors = { /** * Not found */ @@ -9932,9 +12086,9 @@ export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors = { 422: HttpValidationError; }; -export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsError = OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors[keyof OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors]; +export type OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsError = OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsErrors[keyof OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsErrors]; -export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsResponses = { +export type OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsResponses = { /** * Successful Response */ diff --git a/ui/src/components/AIModelConfigurationV2Editor.tsx b/ui/src/components/AIModelConfigurationV2Editor.tsx new file mode 100644 index 00000000..bbe66658 --- /dev/null +++ b/ui/src/components/AIModelConfigurationV2Editor.tsx @@ -0,0 +1,451 @@ +"use client"; + +import { KeyRound, Save } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import type { OrganizationAiModelConfigurationV2 } from "@/client/types.gen"; +import { + type ProviderSchema, + type ServiceConfigurationDefaults, + ServiceConfigurationForm, + type ServiceSegment, +} from "@/components/ServiceConfigurationForm"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages"; + +type ModelMode = "realtime" | "dograh" | "byok"; + +interface DograhDefaults { + voices: string[]; + speeds: number[]; + languages: string[]; + defaults: { + voice: string; + speed: number; + language: string; + }; +} + +export interface ModelConfigurationDefaultsV2 { + dograh: DograhDefaults; + byok: { + pipeline: ServiceConfigurationDefaults; + realtime: { + realtime: Record; + llm: Record; + embeddings: Record; + default_providers: ServiceConfigurationDefaults["default_providers"]; + }; + }; +} + +interface DograhFormState { + api_key: string; + voice: string; + speed: number; + language: string; +} + +interface AIModelConfigurationV2EditorProps { + defaults: ModelConfigurationDefaultsV2; + configuration?: OrganizationAiModelConfigurationV2 | Record | null; + effectiveConfiguration?: Record | null; + onSave: (configuration: OrganizationAiModelConfigurationV2) => Promise; + submitLabel?: string; +} + +function firstApiKey(value: unknown): string { + if (Array.isArray(value)) return String(value[0] || ""); + return typeof value === "string" ? value : ""; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function isDograhEffectiveConfig(config: Record | null | undefined): boolean { + if (!config || config.is_realtime) return false; + const llm = asRecord(config.llm); + const tts = asRecord(config.tts); + const stt = asRecord(config.stt); + return llm?.provider === "dograh" && tts?.provider === "dograh" && stt?.provider === "dograh"; +} + +function byokDefaults(defaults: ModelConfigurationDefaultsV2): ServiceConfigurationDefaults { + return { + llm: defaults.byok.pipeline.llm, + tts: defaults.byok.pipeline.tts, + stt: defaults.byok.pipeline.stt, + embeddings: defaults.byok.pipeline.embeddings, + realtime: defaults.byok.realtime.realtime, + default_providers: defaults.byok.pipeline.default_providers, + }; +} + +function byokConfigToLegacyShape(config: Record | null): Record | null { + if (!config || config.mode !== "byok") return null; + const byok = asRecord(config.byok); + if (!byok) return null; + + if (byok.mode === "realtime") { + const realtime = asRecord(byok.realtime); + return { + is_realtime: true, + realtime: realtime?.realtime, + llm: realtime?.llm, + embeddings: realtime?.embeddings, + }; + } + + const pipeline = asRecord(byok.pipeline); + return { + is_realtime: false, + llm: pipeline?.llm, + tts: pipeline?.tts, + stt: pipeline?.stt, + embeddings: pipeline?.embeddings, + }; +} + +function effectiveConfigToLegacyShape(config: Record | null): Record | null { + if (!config) return null; + return { + is_realtime: Boolean(config.is_realtime), + llm: config.llm, + tts: config.tts, + stt: config.stt, + realtime: config.realtime, + embeddings: config.embeddings, + }; +} + +function emptyByokInitialConfig(isRealtime: boolean): Record { + return { + is_realtime: isRealtime, + }; +} + +// The v2 editor surfaces realtime ("Speech to Speech") and pipeline (BYOK) as +// separate tabs, so each tab gets its own initial config. A tab is pre-filled +// only when the saved (or effective) configuration matches that tab's mode; +// otherwise it starts empty so the other tab's data does not leak across. +function getByokInitialConfig( + configuration: Record | null, + effectiveConfiguration: Record | null, + wantRealtime: boolean, +): Record { + const matchesTab = (config: Record | null) => + config ? Boolean(config.is_realtime) === wantRealtime : false; + + const byokConfiguration = byokConfigToLegacyShape(configuration); + if (byokConfiguration) { + return matchesTab(byokConfiguration) ? byokConfiguration : emptyByokInitialConfig(wantRealtime); + } + + if (configuration?.mode === "dograh" || isDograhEffectiveConfig(effectiveConfiguration)) { + return emptyByokInitialConfig(wantRealtime); + } + + const effective = effectiveConfigToLegacyShape(effectiveConfiguration); + return matchesTab(effective) ? (effective as Record) : emptyByokInitialConfig(wantRealtime); +} + +function buildDograhState( + defaults: ModelConfigurationDefaultsV2, + configuration: Record | null, + effectiveConfiguration: Record | null, +): DograhFormState { + const fallback = defaults.dograh.defaults; + const configuredDograh = configuration?.mode === "dograh" ? asRecord(configuration.dograh) : null; + if (configuredDograh) { + return { + api_key: String(configuredDograh.api_key || ""), + voice: String(configuredDograh.voice || fallback.voice), + speed: Number(configuredDograh.speed || fallback.speed), + language: String(configuredDograh.language || fallback.language), + }; + } + + if (isDograhEffectiveConfig(effectiveConfiguration)) { + const llm = asRecord(effectiveConfiguration?.llm); + const tts = asRecord(effectiveConfiguration?.tts); + const stt = asRecord(effectiveConfiguration?.stt); + return { + api_key: firstApiKey(llm?.api_key || tts?.api_key || stt?.api_key), + voice: String(tts?.voice || fallback.voice), + speed: Number(tts?.speed || fallback.speed), + language: String(stt?.language || fallback.language), + }; + } + + return { + api_key: "", + voice: fallback.voice, + speed: fallback.speed, + language: fallback.language, + }; +} + +function preferredMode( + configuration: Record | null, + effectiveConfiguration: Record | null, +): ModelMode { + if (configuration?.mode === "dograh") return "dograh"; + if (configuration?.mode === "byok") { + return asRecord(configuration.byok)?.mode === "realtime" ? "realtime" : "byok"; + } + if (isDograhEffectiveConfig(effectiveConfiguration)) return "dograh"; + return Boolean(effectiveConfiguration?.is_realtime) ? "realtime" : "byok"; +} + +function hasRequiredApiKey( + service: ServiceSegment, + serviceConfiguration: Record, + defaults: ServiceConfigurationDefaults, +): boolean { + const provider = serviceConfiguration.provider as string | undefined; + if (!provider) return false; + const providerSchema = service === "realtime" + ? defaults.realtime?.[provider] + : defaults[service as "llm" | "tts" | "stt" | "embeddings"]?.[provider]; + const requiresApiKey = providerSchema?.required?.includes("api_key") ?? false; + if (!requiresApiKey) return true; + + const apiKey = serviceConfiguration.api_key; + if (Array.isArray(apiKey)) { + return apiKey.some((key) => typeof key === "string" && key.trim().length > 0); + } + return typeof apiKey === "string" && apiKey.trim().length > 0; +} + +function requireByokService( + config: Record, + service: ServiceSegment, + defaults: ServiceConfigurationDefaults, +): Record { + const serviceConfiguration = asRecord(config[service]); + if ( + !serviceConfiguration + || !serviceConfiguration.provider + || serviceConfiguration.provider === "dograh" + || !hasRequiredApiKey(service, serviceConfiguration, defaults) + ) { + throw new Error(`${service} configuration is required`); + } + return serviceConfiguration; +} + +function optionalByokService(config: Record, service: ServiceSegment): Record | undefined { + const serviceConfiguration = asRecord(config[service]); + if (!serviceConfiguration?.provider || serviceConfiguration.provider === "dograh") return undefined; + return serviceConfiguration; +} + +export function AIModelConfigurationV2Editor({ + defaults, + configuration, + effectiveConfiguration, + onSave, + submitLabel = "Save Configuration", +}: AIModelConfigurationV2EditorProps) { + const defaultsForByok = useMemo(() => byokDefaults(defaults), [defaults]); + const [mode, setMode] = useState("dograh"); + const [dograh, setDograh] = useState(() => ({ + api_key: "", + voice: defaults.dograh.defaults.voice, + speed: defaults.dograh.defaults.speed, + language: defaults.dograh.defaults.language, + })); + const [realtimeInitialConfig, setRealtimeInitialConfig] = useState | null>(null); + const [pipelineInitialConfig, setPipelineInitialConfig] = useState | null>(null); + const [isSavingDograh, setIsSavingDograh] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const rawConfiguration = asRecord(configuration); + const rawEffectiveConfiguration = asRecord(effectiveConfiguration); + setMode(preferredMode(rawConfiguration, rawEffectiveConfiguration)); + setDograh(buildDograhState(defaults, rawConfiguration, rawEffectiveConfiguration)); + setRealtimeInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, true)); + setPipelineInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, false)); + }, [configuration, defaults, effectiveConfiguration]); + + const saveDograhConfiguration = async () => { + setIsSavingDograh(true); + setError(null); + try { + await onSave({ + version: 2, + mode: "dograh", + dograh: { + api_key: dograh.api_key.trim(), + voice: dograh.voice, + speed: dograh.speed, + language: dograh.language, + }, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save configuration"); + } finally { + setIsSavingDograh(false); + } + }; + + const saveByokConfiguration = async (config: Record) => { + setError(null); + const isRealtime = Boolean(config.is_realtime); + const llm = requireByokService(config, "llm", defaultsForByok); + const embeddings = optionalByokService(config, "embeddings"); + const body: OrganizationAiModelConfigurationV2 = { + version: 2, + mode: "byok", + byok: isRealtime + ? { + mode: "realtime", + realtime: { + realtime: requireByokService(config, "realtime", defaultsForByok) as never, + llm: llm as never, + ...(embeddings ? { embeddings: embeddings as never } : {}), + }, + } + : { + mode: "pipeline", + pipeline: { + llm: llm as never, + tts: requireByokService(config, "tts", defaultsForByok) as never, + stt: requireByokService(config, "stt", defaultsForByok) as never, + ...(embeddings ? { embeddings: embeddings as never } : {}), + }, + }, + }; + + await onSave(body); + }; + + return ( +
+ {error && ( +
+ {error} +
+ )} + + setMode(value as ModelMode)} className="space-y-6"> + + Speech to Speech + Dograh + BYOK + + + +

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

+ +
+ + +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + setDograh({ ...dograh, api_key: event.target.value })} + placeholder="Enter API key" + /> +
+
+
+ + +
+
+ + + + +
+
+ ); +} diff --git a/ui/src/components/ModelConfigurationV2.tsx b/ui/src/components/ModelConfigurationV2.tsx new file mode 100644 index 00000000..8f0a06de --- /dev/null +++ b/ui/src/components/ModelConfigurationV2.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { ExternalLink, RefreshCw } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { + getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get, + getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet, + migrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePost, + saveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Put, +} from "@/client/sdk.gen"; +import type { + OrganizationAiModelConfigurationResponse, + OrganizationAiModelConfigurationV2, +} from "@/client/types.gen"; +import { AIModelConfigurationV2Editor, type ModelConfigurationDefaultsV2 } from "@/components/AIModelConfigurationV2Editor"; +import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useUserConfig } from "@/context/UserConfigContext"; +import { detailFromError } from "@/lib/apiError"; +import { useAuth } from "@/lib/auth"; + +export default function ModelConfigurationV2({ + docsUrl, + initialAction, +}: { + docsUrl?: string; + initialAction?: string; +}) { + const auth = useAuth(); + const { refreshConfig, saveUserConfig } = useUserConfig(); + const hasFetched = useRef(false); + const hasAppliedInitialMigrationAction = useRef(false); + + const [defaults, setDefaults] = useState(null); + const [response, setResponse] = useState(null); + const [loading, setLoading] = useState(true); + const [migrating, setMigrating] = useState(false); + const [migrationDialogOpen, setMigrationDialogOpen] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + const applyResponse = (nextResponse: OrganizationAiModelConfigurationResponse) => { + setResponse(nextResponse); + }; + + useEffect(() => { + if (auth.loading || !auth.user || hasFetched.current) return; + hasFetched.current = true; + + const load = async () => { + setLoading(true); + setError(null); + const [defaultsResult, configResult] = await Promise.all([ + getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet(), + getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get(), + ]); + + if (defaultsResult.error) { + setError(detailFromError(defaultsResult.error, "Failed to load model configuration defaults")); + setLoading(false); + return; + } + if (configResult.error) { + setError(detailFromError(configResult.error, "Failed to load model configuration")); + setLoading(false); + return; + } + + const nextDefaults = defaultsResult.data as ModelConfigurationDefaultsV2; + if (!nextDefaults || !configResult.data) { + setError("Failed to load model configuration"); + setLoading(false); + return; + } + setDefaults(nextDefaults); + applyResponse(configResult.data); + setLoading(false); + }; + + load(); + + }, [auth.loading, auth.user]); + + useEffect(() => { + if (hasAppliedInitialMigrationAction.current) return; + if (initialAction !== "migrate_to_v2") return; + if (loading || response?.source !== "legacy_user_v1") return; + hasAppliedInitialMigrationAction.current = true; + setMigrationDialogOpen(true); + }, [initialAction, loading, response?.source]); + + const saveConfiguration = async (configuration: OrganizationAiModelConfigurationV2) => { + if (!defaults) return; + setError(null); + setNotice(null); + + const result = await saveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Put({ + body: configuration, + }); + + if (result.error) { + throw new Error(detailFromError(result.error, "Failed to save model configuration")); + } + if (!result.data) { + throw new Error("Failed to save model configuration"); + } + + applyResponse(result.data); + await refreshConfig(); + setNotice("Model configuration saved"); + }; + + const migrateConfiguration = async () => { + if (!defaults) return; + setMigrating(true); + setError(null); + setNotice(null); + + const result = await migrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePost(); + if (result.error) { + setError(detailFromError(result.error, "Failed to migrate model configuration")); + } else if (!result.data) { + setError("Failed to migrate model configuration"); + } else { + applyResponse(result.data); + await refreshConfig(); + setNotice("Configuration migrated to v2"); + setMigrationDialogOpen(false); + } + setMigrating(false); + }; + + const migrationWarningDialog = ( + + + + Migrate model configuration to v2? + + Your configurations will be migrated to v2. After migration, check your global configuration and workflow model overrides, then run a test call to make sure everything is working. + + + + Cancel + + + + + ); + + if (loading) { + return ( +
+ + + +
+ ); + } + + const source = response?.source || "empty"; + + if (source !== "organization_v2") { + return ( +
+
+
+
+

AI Models Configuration

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

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

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

AI Models Configuration

+

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

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

Loading...

; + } + + return ( +
+

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

+
+
+ + + setPreferences({ + ...preferences, + test_phone_number: event.target.value, + }) + } + placeholder="+15551234567" + /> +
+
+ + +
+
+ +
+ ); +} diff --git a/ui/src/components/ServiceConfigurationForm.tsx b/ui/src/components/ServiceConfigurationForm.tsx index 34e71407..f5075398 100644 --- a/ui/src/components/ServiceConfigurationForm.tsx +++ b/ui/src/components/ServiceConfigurationForm.tsx @@ -19,7 +19,7 @@ import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages"; import { useUserConfig } from "@/context/UserConfigContext"; import type { ModelOverrides } from "@/types/workflow-configurations"; -type ServiceSegment = "llm" | "tts" | "stt" | "embeddings" | "realtime"; +export type ServiceSegment = "llm" | "tts" | "stt" | "embeddings" | "realtime"; interface SchemaProperty { type?: string; @@ -35,7 +35,7 @@ interface SchemaProperty { docs_url?: string; } -interface ProviderSchema { +export interface ProviderSchema { title?: string; description?: string; provider_docs_url?: string; @@ -49,6 +49,15 @@ interface FormValues { [key: string]: string | number | boolean; } +export interface ServiceConfigurationDefaults { + llm: Record; + tts: Record; + stt: Record; + embeddings: Record; + realtime?: Record; + default_providers: Partial>; +} + const STANDARD_TABS: { key: ServiceSegment; label: string }[] = [ { key: "llm", label: "LLM" }, { key: "tts", label: "Voice" }, @@ -90,6 +99,15 @@ export interface ServiceConfigurationFormProps { onSave: (config: Record) => Promise; /** Text for the submit button. Defaults to "Save Configuration". */ submitLabel?: string; + configurationDefaults?: ServiceConfigurationDefaults | null; + initialConfig?: Record | null; + /** + * When set, locks the realtime/pipeline mode to this value and hides the + * in-form toggle. The v2 editor uses this to surface realtime + * ("Speech to Speech") and pipeline (BYOK) as separate top-level tabs. + * Leave undefined to keep the user-controllable toggle (legacy + overrides). + */ + forceRealtime?: boolean; } function getProviderDisplayName( @@ -117,10 +135,13 @@ export function ServiceConfigurationForm({ currentOverrides, onSave, submitLabel, + configurationDefaults, + initialConfig, + forceRealtime, }: ServiceConfigurationFormProps) { const [apiError, setApiError] = useState(null); const [isSaving, setIsSaving] = useState(false); - const [isRealtime, setIsRealtime] = useState(false); + const [isRealtime, setIsRealtime] = useState(forceRealtime ?? false); const { userConfig } = useUserConfig(); const [schemas, setSchemas] = useState>>({ llm: {}, @@ -165,15 +186,16 @@ export function ServiceConfigurationForm({ // Build effective config source: overlay overrides onto global config const configSource = useMemo(() => { - if (mode === 'global' || !currentOverrides) return userConfig; + const baseConfig = initialConfig ?? userConfig; + if (mode === 'global' || !currentOverrides) return baseConfig; // Merge overrides onto global config for form initialization - const merged = { ...userConfig } as Record; + const merged = { ...baseConfig } as Record; const overrideServices: (keyof ModelOverrides)[] = ["llm", "tts", "stt", "realtime"]; for (const svc of overrideServices) { if (svc === "is_realtime") continue; const overrideVal = currentOverrides[svc]; if (overrideVal && typeof overrideVal === "object") { - const globalVal = (userConfig as Record | null)?.[svc] as Record | undefined; + const globalVal = (baseConfig as Record | null)?.[svc] as Record | undefined; merged[svc] = { ...globalVal, ...overrideVal }; } } @@ -181,39 +203,50 @@ export function ServiceConfigurationForm({ merged.is_realtime = currentOverrides.is_realtime; } return merged as typeof userConfig; - }, [mode, userConfig, currentOverrides]); + }, [mode, userConfig, currentOverrides, initialConfig]); useEffect(() => { const fetchConfigurations = async () => { - const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet(); - if (!response.data) { - console.error("Failed to fetch configurations"); - return; + let defaultsData = configurationDefaults; + if (!defaultsData) { + const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet(); + if (!response.data) { + console.error("Failed to fetch configurations"); + return; + } + defaultsData = response.data as ServiceConfigurationDefaults; } - const data = response.data as Record; - const realtimeSchemas = (data.realtime || {}) as Record; + const realtimeSchemas = (defaultsData.realtime || {}) as Record; + const pickDefaultProvider = ( + service: ServiceSegment, + schemaMap: Record, + ) => { + const preferred = defaultsData.default_providers?.[service]; + if (preferred && schemaMap[preferred]) return preferred; + return Object.keys(schemaMap)[0] || ""; + }; setSchemas({ - llm: response.data.llm as Record, - tts: response.data.tts as Record, - stt: response.data.stt as Record, - embeddings: response.data.embeddings as Record, + llm: defaultsData.llm, + tts: defaultsData.tts, + stt: defaultsData.stt, + embeddings: defaultsData.embeddings, realtime: realtimeSchemas, }); - // Restore realtime toggle + // Restore realtime toggle (skip when the parent locks the mode) const configData = configSource as Record | null; - if (configData?.is_realtime) { + if (forceRealtime === undefined && configData?.is_realtime) { setIsRealtime(true); } const defaultValues: Record = {}; const selectedProviders: Record = { - llm: response.data.default_providers.llm, - tts: response.data.default_providers.tts, - stt: response.data.default_providers.stt, - embeddings: response.data.default_providers.embeddings, + llm: pickDefaultProvider("llm", defaultsData.llm), + tts: pickDefaultProvider("tts", defaultsData.tts), + stt: pickDefaultProvider("stt", defaultsData.stt), + embeddings: pickDefaultProvider("embeddings", defaultsData.embeddings), realtime: "", }; @@ -237,7 +270,7 @@ export function ServiceConfigurationForm({ const schemaSource = service === "realtime" ? realtimeSchemas - : response.data![service as "llm" | "tts" | "stt" | "embeddings"] as Record | undefined; + : defaultsData[service as "llm" | "tts" | "stt" | "embeddings"] as Record | undefined; if (src?.provider) { Object.entries(src).forEach(([field, value]) => { @@ -296,7 +329,7 @@ export function ServiceConfigurationForm({ // Detect custom inputs const detectedCustomInput: Record = {}; - const allSchemas = { ...response.data, realtime: realtimeSchemas } as unknown as Record>; + const allSchemas = { ...defaultsData, realtime: realtimeSchemas } as unknown as Record>; (["llm", "tts", "stt", "embeddings", "realtime"] as ServiceSegment[]).forEach(service => { const provider = selectedProviders[service]; const providerSchema = allSchemas[service]?.[provider]; @@ -337,7 +370,7 @@ export function ServiceConfigurationForm({ }; fetchConfigurations(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reset, configSource]); + }, [reset, configSource, configurationDefaults]); // Reset voice when TTS model changes if the provider has model-dependent voice options const ttsModel = watch("tts_model"); @@ -842,22 +875,24 @@ export function ServiceConfigurationForm({ return (
- {/* Realtime toggle */} -
-
- -

- Uses a single speech-to-speech model (no separate STT/TTS). An LLM is still required for variable extraction and QA. -

+ {/* Realtime toggle — hidden when the parent locks the mode (v2 tabs) */} + {forceRealtime === undefined && ( +
+
+ +

+ Uses a single speech-to-speech model (no separate STT/TTS). An LLM is still required for variable extraction and QA. +

+
+
- -
+ )} diff --git a/ui/src/components/layout/AppSidebar.tsx b/ui/src/components/layout/AppSidebar.tsx index bb67cc4e..c7e49bb4 100644 --- a/ui/src/components/layout/AppSidebar.tsx +++ b/ui/src/components/layout/AppSidebar.tsx @@ -139,6 +139,11 @@ const NAV_SECTIONS: SidebarNavSection[] = [ url: "/usage", icon: TrendingUp, }, + { + title: "Billing", + url: "/billing", + icon: CircleDollarSign, + }, { title: "Reports", url: "/reports", diff --git a/ui/src/context/OrgConfigContext.tsx b/ui/src/context/OrgConfigContext.tsx new file mode 100644 index 00000000..d278bf22 --- /dev/null +++ b/ui/src/context/OrgConfigContext.tsx @@ -0,0 +1,192 @@ +'use client'; + +import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react'; + +import { client } from '@/client/client.gen'; +import { getCurrentOrganizationContextApiV1OrganizationsContextGet, getUserConfigurationsApiV1UserConfigurationsUserGet, updateUserConfigurationsApiV1UserConfigurationsUserPut } from '@/client/sdk.gen'; +import type { OrganizationContextResponse, UserConfigurationRequestResponseSchema } from '@/client/types.gen'; +import { setupAuthInterceptor } from '@/lib/apiClient'; +import type { AuthUser } from '@/lib/auth'; +import { useAuth } from '@/lib/auth'; + +interface TeamPermission { + id: string; +} + +interface OrganizationPricing { + price_per_second_usd: number | null; + currency: string; + billing_enabled: boolean; +} + +interface OrgConfigContextType { + orgContext: OrganizationContextResponse | null; + userConfig: UserConfigurationRequestResponseSchema | null; + saveUserConfig: (userConfig: UserConfigurationRequestResponseSchema) => Promise; + loading: boolean; + error: Error | null; + refreshConfig: () => Promise; + permissions: TeamPermission[]; + user: AuthUser | null; + organizationPricing: OrganizationPricing | null; +} + +const OrgConfigContext = createContext(null); + +const pricingFromUserConfig = ( + userConfig: UserConfigurationRequestResponseSchema, +): OrganizationPricing | null => { + if (!userConfig.organization_pricing) { + return null; + } + + return { + price_per_second_usd: userConfig.organization_pricing.price_per_second_usd as number | null, + currency: (userConfig.organization_pricing.currency as string) || 'USD', + billing_enabled: (userConfig.organization_pricing.billing_enabled as boolean) || false, + }; +}; + +export function OrgConfigProvider({ children }: { children: ReactNode }) { + const [orgContext, setOrgContext] = useState(null); + const [userConfig, setUserConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [organizationPricing, setOrganizationPricing] = useState(null); + const [permissions, setPermissions] = useState([]); + + const auth = useAuth(); + + const authRef = useRef(auth); + authRef.current = auth; + + const hasFetchedConfig = useRef(false); + const hasFetchedPermissions = useRef(false); + + if (!auth.loading && auth.isAuthenticated) { + setupAuthInterceptor(client, auth.getAccessToken); + } + + useEffect(() => { + if (auth.loading || hasFetchedPermissions.current) { + return; + } + hasFetchedPermissions.current = true; + + const fetchPermissions = async () => { + const currentAuth = authRef.current; + if (currentAuth.provider === 'stack' && currentAuth.getSelectedTeam && currentAuth.listPermissions) { + const selectedTeam = currentAuth.getSelectedTeam(); + if (selectedTeam) { + try { + const perms = await currentAuth.listPermissions(selectedTeam); + setPermissions(Array.isArray(perms) ? perms : []); + } catch { + setPermissions([]); + } + } else { + setPermissions([]); + } + } else { + setPermissions([{ id: 'admin' }]); + } + }; + + fetchPermissions(); + }, [auth.loading, auth.provider]); + + const fetchConfig = useCallback(async () => { + const currentAuth = authRef.current; + if (!currentAuth.isAuthenticated) { + return; + } + + setLoading(true); + try { + const [orgContextResponse, userConfigResponse] = await Promise.all([ + getCurrentOrganizationContextApiV1OrganizationsContextGet(), + getUserConfigurationsApiV1UserConfigurationsUserGet(), + ]); + + if (orgContextResponse.data) { + setOrgContext(orgContextResponse.data); + } + + if (userConfigResponse.data) { + setUserConfig(userConfigResponse.data); + setOrganizationPricing(pricingFromUserConfig(userConfigResponse.data)); + } + + setError(null); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to fetch organization configuration')); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (auth.loading || !auth.isAuthenticated || hasFetchedConfig.current) { + return; + } + hasFetchedConfig.current = true; + fetchConfig(); + }, [auth.loading, auth.isAuthenticated, fetchConfig]); + + const saveUserConfig = useCallback(async (userConfigRequest: UserConfigurationRequestResponseSchema) => { + if (!authRef.current.isAuthenticated) throw new Error('No authentication available'); + const response = await updateUserConfigurationsApiV1UserConfigurationsUserPut({ + body: { + ...userConfig, + ...userConfigRequest, + } as UserConfigurationRequestResponseSchema, + }); + if (response.error) { + let msg = 'Failed to save user configuration'; + const detail = (response.error as unknown as { detail?: string | { errors: { model: string; message: string }[] } }).detail; + if (typeof detail === 'string') { + msg = detail; + } else if (Array.isArray(detail)) { + msg = detail + .map((e: { model: string; message: string }) => `${e.model}: ${e.message}`) + .join('\n'); + } + throw new Error(msg); + } + + if (response.data) { + setUserConfig(response.data); + setOrganizationPricing(pricingFromUserConfig(response.data)); + } + }, [userConfig]); + + const refreshConfig = useCallback(async () => { + await fetchConfig(); + }, [fetchConfig]); + + return ( + + {children} + + ); +} + +export function useOrgConfig() { + const context = useContext(OrgConfigContext); + if (!context) { + throw new Error('useOrgConfig must be used within an OrgConfigProvider'); + } + return context; +} diff --git a/ui/src/context/UserConfigContext.tsx b/ui/src/context/UserConfigContext.tsx index 9881ed52..7b148d70 100644 --- a/ui/src/context/UserConfigContext.tsx +++ b/ui/src/context/UserConfigContext.tsx @@ -1,205 +1,3 @@ 'use client'; -import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react'; - -import { client } from '@/client/client.gen'; -import { getUserConfigurationsApiV1UserConfigurationsUserGet, updateUserConfigurationsApiV1UserConfigurationsUserPut } from '@/client/sdk.gen'; -import type { UserConfigurationRequestResponseSchema } from '@/client/types.gen'; -import { setupAuthInterceptor } from '@/lib/apiClient'; -import type { AuthUser } from '@/lib/auth'; -import { useAuth } from '@/lib/auth'; - - -interface TeamPermission { - id: string; -} - -interface OrganizationPricing { - price_per_second_usd: number | null; - currency: string; - billing_enabled: boolean; -} - -interface UserConfigContextType { - userConfig: UserConfigurationRequestResponseSchema | null; - saveUserConfig: (userConfig: UserConfigurationRequestResponseSchema) => Promise; - loading: boolean; - error: Error | null; - refreshConfig: () => Promise; - permissions: TeamPermission[]; - user: AuthUser | null; - organizationPricing: OrganizationPricing | null; -} - -const UserConfigContext = createContext(null); - -export function UserConfigProvider({ children }: { children: ReactNode }) { - const [userConfig, setUserConfig] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [organizationPricing, setOrganizationPricing] = useState(null); - const [permissions, setPermissions] = useState([]); - - const auth = useAuth(); - - // Store auth functions in refs to avoid dependency issues - const authRef = useRef(auth); - authRef.current = auth; - - // Track initialization - const hasFetchedConfig = useRef(false); - const hasFetchedPermissions = useRef(false); - - // Register the auth interceptor synchronously during render (not in useEffect) - // so it's in place before any child effects fire API calls. - // setupAuthInterceptor is idempotent — safe for strict mode double-renders. - if (!auth.loading && auth.isAuthenticated) { - setupAuthInterceptor(client, auth.getAccessToken); - } - - // Fetch permissions once when auth is ready - useEffect(() => { - if (auth.loading || hasFetchedPermissions.current) { - return; - } - hasFetchedPermissions.current = true; - - const fetchPermissions = async () => { - const currentAuth = authRef.current; - if (currentAuth.provider === 'stack' && currentAuth.getSelectedTeam && currentAuth.listPermissions) { - const selectedTeam = currentAuth.getSelectedTeam(); - if (selectedTeam) { - try { - const perms = await currentAuth.listPermissions(selectedTeam); - setPermissions(Array.isArray(perms) ? perms : []); - } catch { - setPermissions([]); - } - } else { - setPermissions([]); - } - } else { - setPermissions([{ id: 'admin' }]); - } - }; - - fetchPermissions(); - }, [auth.loading, auth.provider]); - - // Fetch user config once when auth is ready - useEffect(() => { - if (auth.loading || !auth.isAuthenticated || hasFetchedConfig.current) { - return; - } - hasFetchedConfig.current = true; - - const fetchUserConfig = async () => { - setLoading(true); - try { - const response = await getUserConfigurationsApiV1UserConfigurationsUserGet(); - - if (response.data) { - setUserConfig(response.data); - if (response.data.organization_pricing) { - setOrganizationPricing({ - price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null, - currency: response.data.organization_pricing.currency as string || 'USD', - billing_enabled: response.data.organization_pricing.billing_enabled as boolean || false - }); - } else { - setOrganizationPricing(null); - } - } - setError(null); - } catch (err) { - setError(err instanceof Error ? err : new Error('Failed to fetch user configuration')); - } finally { - setLoading(false); - } - }; - - fetchUserConfig(); - }, [auth.loading, auth.isAuthenticated]); - - const saveUserConfig = useCallback(async (userConfigRequest: UserConfigurationRequestResponseSchema) => { - if (!authRef.current.isAuthenticated) throw new Error('No authentication available'); - const response = await updateUserConfigurationsApiV1UserConfigurationsUserPut({ - body: { - ...userConfig, - ...userConfigRequest - } as UserConfigurationRequestResponseSchema, - }); - if (response.error) { - let msg = 'Failed to save user configuration'; - const detail = (response.error as unknown as { detail?: string | { errors: { model: string; message: string }[] } }).detail; - if (typeof detail === 'string') { - msg = detail; - } else if (Array.isArray(detail)) { - msg = detail - .map((e: { model: string; message: string }) => `${e.model}: ${e.message}`) - .join('\n'); - } - throw new Error(msg); - } - setUserConfig(response.data!); - - if (response.data?.organization_pricing) { - setOrganizationPricing({ - price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null, - currency: response.data.organization_pricing.currency as string || 'USD', - billing_enabled: response.data.organization_pricing.billing_enabled as boolean || false - }); - } - }, [userConfig]); - - const refreshConfig = useCallback(async () => { - const currentAuth = authRef.current; - if (!currentAuth.isAuthenticated) return; - - setLoading(true); - try { - const response = await getUserConfigurationsApiV1UserConfigurationsUserGet(); - - if (response.data) { - setUserConfig(response.data); - if (response.data.organization_pricing) { - setOrganizationPricing({ - price_per_second_usd: response.data.organization_pricing.price_per_second_usd as number | null, - currency: response.data.organization_pricing.currency as string || 'USD', - billing_enabled: response.data.organization_pricing.billing_enabled as boolean || false - }); - } - } - setError(null); - } catch (err) { - setError(err instanceof Error ? err : new Error('Failed to fetch user configuration')); - } finally { - setLoading(false); - } - }, []); - - return ( - - {children} - - ); -} - -export function useUserConfig() { - const context = useContext(UserConfigContext); - if (!context) { - throw new Error('useUserConfig must be used within a UserConfigProvider'); - } - return context; -} +export { OrgConfigProvider as UserConfigProvider, useOrgConfig as useUserConfig } from './OrgConfigContext'; diff --git a/ui/src/lib/apiError.ts b/ui/src/lib/apiError.ts index aea5049b..6d4338c5 100644 --- a/ui/src/lib/apiError.ts +++ b/ui/src/lib/apiError.ts @@ -2,18 +2,33 @@ * Extract a human-readable message from a backend error response. * * The generated API client returns `{ error }` on failure (it does not throw), - * and FastAPI shapes that error as either `{ detail: string }` (HTTPException) - * or `{ detail: [{ msg, loc, ... }] }` (422 validation). This normalizes both - * to a single string so it can be rendered or thrown directly — never pass the - * raw `detail` to React, as the 422 array crashes rendering. + * and FastAPI shapes that error as `{ detail: string }`, `{ detail: + * [{ msg, loc, ... }] }`, or backend validation arrays like `{ detail: + * [{ model, message }] }`. This normalizes those to a single string so it can + * be rendered or thrown directly. */ export function detailFromError(err: unknown, fallback = "Request failed"): string { if (typeof err === "string") return err; const e = err as { detail?: unknown }; if (typeof e?.detail === "string") return e.detail; if (Array.isArray(e?.detail) && e.detail.length > 0) { - const first = e.detail[0] as { msg?: string }; - if (first?.msg) return first.msg; + const messages = e.detail + .map((item) => { + if (typeof item === "string") return item; + if (!item || typeof item !== "object") return null; + const detail = item as { message?: unknown; msg?: unknown; model?: unknown }; + const message = typeof detail.message === "string" + ? detail.message + : typeof detail.msg === "string" + ? detail.msg + : null; + if (!message) return null; + return typeof detail.model === "string" && detail.model + ? `${detail.model}: ${message}` + : message; + }) + .filter((message): message is string => Boolean(message)); + if (messages.length > 0) return messages.join("\n"); } return fallback; } diff --git a/ui/src/lib/auth/config.ts b/ui/src/lib/auth/config.ts index b58927bc..1958297d 100644 --- a/ui/src/lib/auth/config.ts +++ b/ui/src/lib/auth/config.ts @@ -1,5 +1,7 @@ import "server-only"; +import { getServerBackendUrl } from "@/lib/apiClient"; + let cachedAuthProvider: string | null = null; /** @@ -12,7 +14,7 @@ export async function getAuthProvider(): Promise { } try { - const backendUrl = process.env.BACKEND_URL || "http://localhost:8000"; + const backendUrl = getServerBackendUrl(); const res = await fetch(`${backendUrl}/api/v1/health`, { next: { revalidate: 300 }, }); diff --git a/ui/src/middleware.ts b/ui/src/middleware.ts index 2fd21f10..e510ca97 100644 --- a/ui/src/middleware.ts +++ b/ui/src/middleware.ts @@ -1,6 +1,8 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +import { getServerBackendUrl } from '@/lib/apiClient'; + const OSS_TOKEN_COOKIE = 'dograh_auth_token'; // Paths that don't require authentication in OSS mode @@ -14,7 +16,7 @@ async function fetchAuthProvider(): Promise { } try { - const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; + const backendUrl = getServerBackendUrl(); const res = await fetch(`${backendUrl}/api/v1/health`); if (res.ok) { const data = await res.json(); diff --git a/ui/src/types/workflow-configurations.ts b/ui/src/types/workflow-configurations.ts index 3f05c658..7a267bdb 100644 --- a/ui/src/types/workflow-configurations.ts +++ b/ui/src/types/workflow-configurations.ts @@ -1,3 +1,5 @@ +import type { OrganizationAiModelConfigurationV2 } from "@/client/types.gen"; + export interface AmbientNoiseConfiguration { enabled: boolean; volume: number; @@ -64,6 +66,7 @@ export interface WorkflowConfigurations { voicemail_detection?: VoicemailDetectionConfiguration; context_compaction_enabled?: boolean; // Summarize context on node transitions to remove stale tool calls model_overrides?: ModelOverrides; // Per-workflow model configuration overrides + model_configuration_v2_override?: OrganizationAiModelConfigurationV2; // Full v2 model configuration override [key: string]: unknown; // Allow additional properties for future configurations }