diff --git a/.gitignore b/.gitignore index e4ccaf3f..3c8c2366 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ coturn/ *.wav dograh_pcm_cache/ node_modules/ + +# Superpowers brainstorm mockups (local only) +.superpowers/ +docs/superpowers/ +.gstack/ diff --git a/.release-please-manifest.json b/.release-please-manifest.json index dc38bc3d..7aa1afcd 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.34.0" + ".": "1.36.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 894aa1f8..6d2f77bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## 1.36.0 (2026-06-18) + + + +## What's Changed +### Features +* feat: add Smallest AI TTS and STT provider integration by @harshitajain165 in https://github.com/dograh-hq/dograh/pull/444 +* feat: refreshed user onboarding by @a6kme in https://github.com/dograh-hq/dograh/pull/430 +* feat: add custom sarvam tts voice by @chewwbaka in https://github.com/dograh-hq/dograh/pull/449 +* feat(examples): add load-and-edit workflow SDK example in Python and TypeScript by @nuthalapativarun in https://github.com/dograh-hq/dograh/pull/441 +* feat(examples): add multi-node Workflow SDK example in Python and TypeScript by @nuthalapativarun in https://github.com/dograh-hq/dograh/pull/440 +### Bug Fixes +* fix: add pace option in sarvam tts config by @chewwbaka in https://github.com/dograh-hq/dograh/pull/447 +* fix(ui): release microphone stream on call teardown so a second test call works by @Aymenbenpakiss in https://github.com/dograh-hq/dograh/pull/446 +* fix: add language field to CartesiaTTSConfiguration and pass to Cartesia TTS service by @nuthalapativarun in https://github.com/dograh-hq/dograh/pull/442 +* fix: sync Smallest AI voice dropdown with selected model by @harshitajain165 in https://github.com/dograh-hq/dograh/pull/451 +### Other Changes +* Validate workflow status filter to prevent 500 on invalid enum value by @a6kme in https://github.com/dograh-hq/dograh/pull/450 +* allow self-hosters to enable Stack Auth via Dockerfile build args (v33.0) by @neggmmm in https://github.com/dograh-hq/dograh/pull/445 + +## New Contributors +* @harshitajain165 made their first contribution in https://github.com/dograh-hq/dograh/pull/444 +* @Aymenbenpakiss made their first contribution in https://github.com/dograh-hq/dograh/pull/446 +* @neggmmm made their first contribution in https://github.com/dograh-hq/dograh/pull/445 + +**Full Changelog**: https://github.com/dograh-hq/dograh/compare/dograh-v1.35.0...dograh-v1.36.0 + +## 1.35.0 (2026-06-12) + + + +## What's Changed +### Features +* feat: add config v2 to simplify billing by @a6kme in https://github.com/dograh-hq/dograh/pull/428 +* feat: add Cartesia Sonic 3.5 as a TTS option by @manasseh-zw in https://github.com/dograh-hq/dograh/pull/423 +* feat: add a start docker script by @a6kme in https://github.com/dograh-hq/dograh/pull/426 +* feat: billing and credit management v2 by @a6kme in https://github.com/dograh-hq/dograh/pull/429 +### Bug Fixes +* fix(telephony): handle Cloudonix CDR webhooks missing session/disposition by @Mubashirrrr in https://github.com/dograh-hq/dograh/pull/407 + +## New Contributors +* @manasseh-zw made their first contribution in https://github.com/dograh-hq/dograh/pull/423 +* @Mubashirrrr made their first contribution in https://github.com/dograh-hq/dograh/pull/407 + +**Full Changelog**: https://github.com/dograh-hq/dograh/compare/dograh-v1.34.0...dograh-v1.35.0 + ## 1.34.0 (2026-06-03) diff --git a/README.md b/README.md index 5b3c90ba..549f814a 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,26 @@ 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 ``` +> **⚡ Prefer an AI agent to set it up for you?** +> If you use **Claude Code** or **Codex**, install the official [Dograh setup skill](https://github.com/dograh-hq/dograh-plugins) and let your agent handle installation, configuration, and troubleshooting — it detects your OS, picks the right deploy path, runs Dograh's own setup scripts, and verifies the result. +> +> ```text +> # In Claude Code +> /plugin marketplace add dograh-hq/dograh-plugins +> /plugin install dograh@dograh +> ``` +> +> Then start a new session and ask it to _"set up Dograh"_ (or run `/dograh-setup`). Codex is supported too — see the [plugin repo](https://github.com/dograh-hq/dograh-plugins#install). + > **Note** > First startup may take 2-3 minutes to download all images. Once running, open http://localhost:3010 to create your first AI voice assistant! > For common issues and solutions, see 🔧 **[Troubleshooting](docs/troubleshooting.md)**. diff --git a/README.zh-CN.md b/README.zh-CN.md index 3b2af0d3..a6cf3a07 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -84,6 +84,17 @@ 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 ``` +> **⚡ 想让 AI 智能体帮你完成部署?** +> 如果你使用 **Claude Code** 或 **Codex**,可以安装官方的 [Dograh 部署技能(skill)](https://github.com/dograh-hq/dograh-plugins),让智能体替你完成安装、配置与排障——它会识别你的操作系统、选择合适的部署方式、运行 Dograh 自带的部署脚本并验证结果。 +> +> ```text +> # 在 Claude Code 中 +> /plugin marketplace add dograh-hq/dograh-plugins +> /plugin install dograh@dograh +> ``` +> +> 然后开启一个新会话,让它 _"set up Dograh"_(或运行 `/dograh-setup`)。Codex 同样支持——详见[插件仓库](https://github.com/dograh-hq/dograh-plugins#install)。 + > **提示** > 首次启动需要 2-3 分钟拉取所有镜像。启动完成后,打开 http://localhost:3010 即可创建你的第一个 AI 语音助手! > 常见问题及解决方案请参见 🔧 **[故障排查](docs/troubleshooting.md)**。 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/91cc6ba3e1c7_add_key_to_user_configurations.py b/api/alembic/versions/91cc6ba3e1c7_add_key_to_user_configurations.py new file mode 100644 index 00000000..6bdc8b4e --- /dev/null +++ b/api/alembic/versions/91cc6ba3e1c7_add_key_to_user_configurations.py @@ -0,0 +1,52 @@ +"""add key to user_configurations + +Turns user_configurations into a per-user keyed JSON store mirroring +organization_configurations. Existing rows (the legacy v1 AI model +configuration blob) are backfilled with key MODEL_CONFIGURATION. + +Revision ID: 91cc6ba3e1c7 +Revises: efe356f488f9 +Create Date: 2026-06-12 21:04:25.561529 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "91cc6ba3e1c7" +down_revision: Union[str, None] = "efe356f488f9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Backfill existing rows (all legacy model-config blobs) via the server + # default, then drop the default — application code always supplies key. + op.add_column( + "user_configurations", + sa.Column( + "key", + sa.String(), + nullable=False, + server_default="MODEL_CONFIGURATION", + ), + ) + + op.create_unique_constraint( + "_user_configuration_key_uc", "user_configurations", ["user_id", "key"] + ) + op.alter_column("user_configurations", "key", server_default=None) + + +def downgrade() -> None: + op.drop_constraint( + "_user_configuration_key_uc", "user_configurations", type_="unique" + ) + # Non-model-config rows (e.g. ONBOARDING) have no meaning in the old + # single-blob schema; the old code would read them as the user's model + # config, so they must not survive the downgrade. + op.execute("DELETE FROM user_configurations WHERE key != 'MODEL_CONFIGURATION'") + op.drop_column("user_configurations", "key") 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/alembic/versions/efe356f488f9_add_extra_column_in_workflow_runs.py b/api/alembic/versions/efe356f488f9_add_extra_column_in_workflow_runs.py new file mode 100644 index 00000000..fd64cfdb --- /dev/null +++ b/api/alembic/versions/efe356f488f9_add_extra_column_in_workflow_runs.py @@ -0,0 +1,34 @@ +"""add extra column in workflow runs + +Revision ID: efe356f488f9 +Revises: 384be6596b36 +Create Date: 2026-06-16 12:24:30.081058 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "efe356f488f9" +down_revision: Union[str, None] = "384be6596b36" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "workflow_runs", + sa.Column( + "extra", + sa.JSON(), + server_default=sa.text("'{}'::json"), + nullable=False, + ), + ) + + +def downgrade() -> None: + op.drop_column("workflow_runs", "extra") diff --git a/api/constants.py b/api/constants.py index e5d9d7f5..b7bc9f74 100644 --- a/api/constants.py +++ b/api/constants.py @@ -30,6 +30,12 @@ CORS_ALLOWED_ORIGINS = [ o.strip() for o in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",") if o.strip() ] AUTH_PROVIDER = os.getenv("AUTH_PROVIDER", "local") +# Stack Auth public client config. These are safe to expose to the browser (the +# publishable client key is public by design, and the project id is non-sensitive), +# and are served to the UI at runtime via /api/v1/health so the frontend no longer +# needs them baked into the bundle at build time. +STACK_AUTH_PROJECT_ID = os.getenv("STACK_AUTH_PROJECT_ID") +STACK_PUBLISHABLE_CLIENT_KEY = os.getenv("STACK_PUBLISHABLE_CLIENT_KEY") DOGRAH_MPS_SECRET_KEY = os.getenv("DOGRAH_MPS_SECRET_KEY", None) MPS_API_URL = os.getenv("MPS_API_URL", "https://services.dograh.com") diff --git a/api/db/campaign_client.py b/api/db/campaign_client.py index d9ff2bae..2c988afd 100644 --- a/api/db/campaign_client.py +++ b/api/db/campaign_client.py @@ -9,6 +9,8 @@ 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 +from api.utils.recording_artifacts import get_recording_storage_key class CampaignClient(BaseDBClient): @@ -44,9 +46,11 @@ class CampaignClient(BaseDBClient): source_id=source_id, created_by=user_id, organization_id=organization_id, - retry_config=retry_config - if retry_config - else CampaignModel.retry_config.default.arg, + retry_config=( + retry_config + if retry_config + else CampaignModel.retry_config.default.arg + ), orchestrator_metadata=orchestrator_metadata, telephony_configuration_id=telephony_configuration_id, ) @@ -215,26 +219,15 @@ 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, + "user_recording_url": get_recording_storage_key( + run.extra, "user" + ), + "bot_recording_url": get_recording_storage_key( + run.extra, "bot" + ), + "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, @@ -286,9 +279,11 @@ class CampaignClient(BaseDBClient): source_id=parent_campaign.source_id, created_by=parent_campaign.created_by, organization_id=parent_campaign.organization_id, - retry_config=retry_config - if retry_config - else CampaignModel.retry_config.default.arg, + retry_config=( + retry_config + if retry_config + else CampaignModel.retry_config.default.arg + ), orchestrator_metadata=child_meta, rate_limit_per_second=parent_campaign.rate_limit_per_second, total_rows=len(queued_runs_data), @@ -354,8 +349,7 @@ class CampaignClient(BaseDBClient): # Retries create new queued_runs with suffixed source_uuids linked via # parent_queued_run_id, so group by the ROOT queued_run using a # recursive walk and pick the latest workflow_run across the tree. - sql = text( - f""" + sql = text(f""" WITH RECURSIVE run_tree AS ( SELECT id AS root_id, id AS run_id FROM queued_runs @@ -382,8 +376,7 @@ class CampaignClient(BaseDBClient): JOIN latest_run_per_root lr ON lr.root_id = q0.id WHERE q0.campaign_id = :cid AND ({tag_filter}) - """ - ) + """) async with self.async_session() as session: result = await session.execute(sql, {"cid": campaign_id}) @@ -662,7 +655,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 +678,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..d2cfc42f 100644 --- a/api/db/models.py +++ b/api/db/models.py @@ -82,12 +82,24 @@ class UserModel(Base): class UserConfigurationModel(Base): + """Per-user keyed JSON store, mirroring organization_configurations. + + Keys are defined in UserConfigurationKey. The legacy v1 AI model + configuration lives under MODEL_CONFIGURATION; last_validated_at is only + meaningful for that key. + """ + __tablename__ = "user_configurations" id = Column(Integer, primary_key=True, index=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + key = Column(String, nullable=False) configuration = Column(JSON, nullable=False, default=dict) last_validated_at = Column(DateTime(timezone=True), nullable=True) + __table_args__ = ( + UniqueConstraint("user_id", "key", name="_user_configuration_key_uc"), + ) + # New Organization model class OrganizationModel(Base): @@ -97,22 +109,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) @@ -510,6 +544,9 @@ class WorkflowRunModel(Base): is_completed = Column(Boolean, default=False) recording_url = Column(String, nullable=True) transcript_url = Column(String, nullable=True) + extra = Column( + JSON, nullable=False, default=dict, server_default=text("'{}'::json") + ) # Store storage backend as string enum (s3, minio) storage_backend = Column( Enum("s3", "minio", name="storage_backend"), @@ -593,8 +630,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 +641,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..d1a52e7c 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,13 @@ from api.db.models import ( WorkflowModel, WorkflowRunModel, ) -from api.schemas.user_configuration import UserConfiguration +from api.enums import OrganizationConfigurationKey, UserConfigurationKey +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration +from api.utils.recording_artifacts import get_recording_storage_key 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 +50,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 +72,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 +97,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 +110,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 +142,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 +195,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)) @@ -350,6 +227,9 @@ class OrganizationUsageClient(BaseDBClient): "call_duration_seconds": int(round(call_duration)), "recording_url": run.recording_url, "transcript_url": run.transcript_url, + "user_recording_url": get_recording_storage_key(run.extra, "user"), + "bot_recording_url": get_recording_storage_key(run.extra, "bot"), + "extra": run.extra, "public_access_token": run.public_access_token, "phone_number": phone_number, "caller_number": caller_number, @@ -393,13 +273,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,21 +321,44 @@ 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( - UserConfigurationModel.user_id == user_id + UserConfigurationModel.user_id == user_id, + UserConfigurationModel.key + == UserConfigurationKey.MODEL_CONFIGURATION.value, ) ) config_obj = config_result.scalar_one_or_none() if config_obj and config_obj.configuration: - 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 +377,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 +426,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..6455bc2b 100644 --- a/api/db/user_client.py +++ b/api/db/user_client.py @@ -8,7 +8,8 @@ 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.enums import UserConfigurationKey +from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration class UserClient(BaseDBClient): @@ -65,19 +66,56 @@ class UserClient(BaseDBClient): ) return result.scalars().first() - async def get_user_configurations(self, user_id: int) -> UserConfiguration: - async with self.async_session() as session: - result = await session.execute( - select(UserConfigurationModel).where( - UserConfigurationModel.user_id == user_id - ) + async def _get_user_configuration_row( + self, session, user_id: int, key: str + ) -> UserConfigurationModel | None: + result = await session.execute( + select(UserConfigurationModel).where( + UserConfigurationModel.user_id == user_id, + UserConfigurationModel.key == key, + ) + ) + return result.scalars().first() + + async def get_user_configuration_value(self, user_id: int, key: str) -> dict | None: + """Get the JSON value stored for a user under `key`, or None.""" + async with self.async_session() as session: + row = await self._get_user_configuration_row(session, user_id, key) + return row.configuration if row else None + + async def upsert_user_configuration_value( + self, user_id: int, key: str, value: dict + ) -> dict: + """Create or update the JSON value stored for a user under `key`.""" + async with self.async_session() as session: + row = await self._get_user_configuration_row(session, user_id, key) + if row: + row.configuration = value + else: + row = UserConfigurationModel( + user_id=user_id, key=key, configuration=value + ) + session.add(row) + try: + await session.commit() + except Exception as e: + await session.rollback() + raise e + await session.refresh(row) + return row.configuration + + async def get_user_configurations( + self, user_id: int + ) -> EffectiveAIModelConfiguration: + async with self.async_session() as session: + configuration_obj = await self._get_user_configuration_row( + session, user_id, UserConfigurationKey.MODEL_CONFIGURATION.value ) - 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,41 +128,23 @@ 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: - async with self.async_session() as session: - result = await session.execute( - select(UserConfigurationModel).where( - UserConfigurationModel.user_id == user_id - ) - ) - configuration_obj = result.scalars().first() - if not configuration_obj: - configuration_obj = UserConfigurationModel( - user_id=user_id, configuration=configuration.model_dump() - ) - session.add(configuration_obj) - else: - configuration_obj.configuration = configuration.model_dump() - try: - await session.commit() - except Exception as e: - await session.rollback() - raise e - await session.refresh(configuration_obj) - return UserConfiguration.model_validate(configuration_obj.configuration) + self, user_id: int, configuration: EffectiveAIModelConfiguration + ) -> EffectiveAIModelConfiguration: + value = await self.upsert_user_configuration_value( + user_id, + UserConfigurationKey.MODEL_CONFIGURATION.value, + configuration.model_dump(), + ) + return EffectiveAIModelConfiguration.model_validate(value) async def update_user_configuration_last_validated_at(self, user_id: int) -> None: async with self.async_session() as session: - result = await session.execute( - select(UserConfigurationModel).where( - UserConfigurationModel.user_id == user_id - ) + configuration_obj = await self._get_user_configuration_row( + session, user_id, UserConfigurationKey.MODEL_CONFIGURATION.value ) - configuration_obj = result.scalars().first() if not configuration_obj: raise ValueError(f"User configuration with ID {user_id} not found") configuration_obj.last_validated_at = datetime.now() diff --git a/api/db/workflow_run_client.py b/api/db/workflow_run_client.py index 57c3e02b..3e5137f7 100644 --- a/api/db/workflow_run_client.py +++ b/api/db/workflow_run_client.py @@ -16,6 +16,8 @@ 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 +from api.utils.recording_artifacts import get_recording_storage_key class WorkflowRunClient(BaseDBClient): @@ -187,13 +189,19 @@ class WorkflowRunClient(BaseDBClient): "workflow_name": run.workflow.name if run.workflow else None, "user_id": run.workflow.user_id if run.workflow else None, "organization_id": organization.id if organization else None, - "organization_name": organization.provider_id - if organization - else None, + "organization_name": ( + organization.provider_id if organization else None + ), "mode": run.mode, "is_completed": run.is_completed, "recording_url": run.recording_url, "transcript_url": run.transcript_url, + "user_recording_url": get_recording_storage_key( + run.extra, "user" + ), + "bot_recording_url": get_recording_storage_key( + run.extra, "bot" + ), "usage_info": run.usage_info, "cost_info": run.cost_info, "initial_context": run.initial_context, @@ -312,26 +320,15 @@ 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, + "user_recording_url": get_recording_storage_key( + run.extra, "user" + ), + "bot_recording_url": get_recording_storage_key( + run.extra, "bot" + ), + "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, @@ -356,6 +353,7 @@ class WorkflowRunClient(BaseDBClient): logs: dict | None = None, state: str | None = None, annotations: dict | None = None, + extra: dict | None = None, ) -> WorkflowRunModel: async with self.async_session() as session: # Use SELECT FOR UPDATE to lock the row during the update @@ -378,7 +376,12 @@ class WorkflowRunClient(BaseDBClient): if cost_info: run.cost_info = cost_info if initial_context: - run.initial_context = initial_context + # Merge initial context patches so independent call-start/runtime + # writers do not erase keys stored earlier in the run lifecycle. + run.initial_context = { + **(run.initial_context or {}), + **initial_context, + } if gathered_context: # Lets merge the incoming gathered context keys with the existing ones run.gathered_context = { @@ -390,6 +393,8 @@ class WorkflowRunClient(BaseDBClient): run.logs = {**run.logs, **logs} if annotations: run.annotations = {**run.annotations, **annotations} + if extra: + run.extra = {**run.extra, **extra} if is_completed: run.is_completed = is_completed if state: diff --git a/api/enums.py b/api/enums.py index 12557057..23f5852d 100644 --- a/api/enums.py +++ b/api/enums.py @@ -89,6 +89,20 @@ 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 UserConfigurationKey(Enum): + """Keys for the per-user keyed JSON store (user_configurations).""" + + MODEL_CONFIGURATION = ( + "MODEL_CONFIGURATION" # Legacy per-user v1 AI model configuration + ) + ONBOARDING = "ONBOARDING" # Post-signup onboarding state (gate, tooltips, actions) class WorkflowStatus(Enum): diff --git a/api/pyproject.toml b/api/pyproject.toml index b0368db3..2263e644 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -1,5 +1,5 @@ [project] name = "dograh-api" -version = "1.34.0" +version = "1.36.0" description = "Backend API for Dograh voice AI platform" requires-python = ">=3.13,<3.14" diff --git a/api/requirements.txt b/api/requirements.txt index 844738d1..0f22cef8 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -16,7 +16,7 @@ msgpack==1.1.2 pgvector==0.4.2 bcrypt==5.0.0 email-validator==2.3.0 -posthog==7.11.1 +posthog==7.19.1 fastmcp==3.2.4 tuner-pipecat-sdk==0.2.0 PyNaCl==1.6.2 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/main.py b/api/routes/main.py index 6de59b1d..067f2ab9 100644 --- a/api/routes/main.py +++ b/api/routes/main.py @@ -72,6 +72,11 @@ class HealthResponse(BaseModel): auth_provider: str turn_enabled: bool force_turn_relay: bool + # Public Stack Auth client config — only populated when auth_provider == "stack". + # The UI reads these at runtime to initialize Stack, so they no longer need to + # be baked into the browser bundle at build time. Both are public values. + stack_project_id: str | None = None + stack_publishable_client_key: str | None = None @router.get("/health", response_model=HealthResponse) @@ -81,12 +86,15 @@ async def health() -> HealthResponse: AUTH_PROVIDER, DEPLOYMENT_MODE, FORCE_TURN_RELAY, + STACK_AUTH_PROJECT_ID, + STACK_PUBLISHABLE_CLIENT_KEY, TURN_SECRET, ) from api.utils.common import get_backend_endpoints logger.debug("Health endpoint called") backend_endpoint, _ = await get_backend_endpoints() + is_stack = AUTH_PROVIDER == "stack" return HealthResponse( status="ok", version=APP_VERSION, @@ -95,4 +103,8 @@ async def health() -> HealthResponse: auth_provider=AUTH_PROVIDER, turn_enabled=bool(TURN_SECRET), force_turn_relay=FORCE_TURN_RELAY, + stack_project_id=STACK_AUTH_PROJECT_ID if is_stack else None, + stack_publishable_client_key=( + STACK_PUBLISHABLE_CLIENT_KEY if is_stack else None + ), ) diff --git a/api/routes/organization.py b/api/routes/organization.py index f60a4133..f64d3cfd 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,37 @@ 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, + DograhTTSService, + 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 +139,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 +206,247 @@ async def get_telephony_config_warnings(user: UserModel = Depends(get_user)): ) +# --------------------------------------------------------------------------- +# AI model configurations v2 +# --------------------------------------------------------------------------- + + +def _dograh_allows_custom_voice() -> bool: + extra = DograhTTSService.model_fields["voice"].json_schema_extra + if isinstance(extra, dict): + return bool(extra.get("allow_custom_input", False)) + return False + + +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], + "allow_custom_input": _dograh_allows_custom_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..2575a7b0 100644 --- a/api/routes/organization_usage.py +++ b/api/routes/organization_usage.py @@ -1,19 +1,20 @@ 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 +from api.utils.recording_artifacts import has_recording_track router = APIRouter(prefix="/organizations") @@ -22,14 +23,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 +35,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 @@ -50,8 +100,12 @@ class WorkflowRunUsageResponse(BaseModel): call_duration_seconds: int recording_url: Optional[str] = None transcript_url: Optional[str] = None + user_recording_url: Optional[str] = None + bot_recording_url: Optional[str] = None recording_public_url: Optional[str] = None transcript_public_url: Optional[str] = None + user_recording_public_url: Optional[str] = None + bot_recording_public_url: Optional[str] = None public_access_token: Optional[str] = None phone_number: Optional[str] = Field( default=None, @@ -97,7 +151,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 +196,206 @@ 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: @@ -233,6 +487,17 @@ async def get_usage_history( public_access_token, "transcript" ) run["recording_public_url"] = artifact_url(public_access_token, "recording") + run["user_recording_public_url"] = ( + artifact_url(public_access_token, "user_recording") + if has_recording_track(run.get("extra"), "user") + else None + ) + run["bot_recording_public_url"] = ( + artifact_url(public_access_token, "bot_recording") + if has_recording_track(run.get("extra"), "bot") + else None + ) + run.pop("extra", None) return { "runs": runs, 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_download.py b/api/routes/public_download.py index c84cc244..c2d70455 100644 --- a/api/routes/public_download.py +++ b/api/routes/public_download.py @@ -6,14 +6,16 @@ post-call processing for runs that execute integrations, QA, or campaign reporting. """ -from typing import Literal - from fastapi import APIRouter, HTTPException, Query from fastapi.responses import RedirectResponse from loguru import logger from api.db import db_client from api.services.storage import get_storage_for_backend +from api.utils.recording_artifacts import ( + get_recording_storage_backend, + get_recording_storage_key, +) router = APIRouter(prefix="/public/download") @@ -21,7 +23,7 @@ router = APIRouter(prefix="/public/download") @router.get("/workflow/{token}/{artifact_type}") async def download_workflow_artifact( token: str, - artifact_type: Literal["recording", "transcript"], + artifact_type: str, inline: bool = Query( default=False, description="Display inline in browser instead of download" ), @@ -36,13 +38,15 @@ async def download_workflow_artifact( Args: token: The public access token (UUID format) - artifact_type: Type of artifact - "recording" or "transcript" + artifact_type: Type of artifact - "recording", "transcript", + "user_recording", or "bot_recording" inline: If true, sets Content-Disposition to inline for browser preview Returns: RedirectResponse to the signed URL (302 redirect) Raises: + HTTPException 400: If artifact type is unsupported HTTPException 404: If token is invalid or artifact not found """ # 1. Lookup workflow run by token @@ -52,10 +56,26 @@ async def download_workflow_artifact( raise HTTPException(status_code=404, detail="Invalid or expired token") # 2. Get file path based on artifact type + artifact_storage_backend = None if artifact_type == "recording": file_path = workflow_run.recording_url - else: # transcript + elif artifact_type == "transcript": file_path = workflow_run.transcript_url + elif artifact_type == "user_recording": + file_path = get_recording_storage_key(workflow_run.extra, "user") + artifact_storage_backend = get_recording_storage_backend( + workflow_run.extra, "user" + ) + elif artifact_type == "bot_recording": + file_path = get_recording_storage_key(workflow_run.extra, "bot") + artifact_storage_backend = get_recording_storage_backend( + workflow_run.extra, "bot" + ) + else: + logger.warning( + f"Unsupported artifact type: type={artifact_type}, workflow_run_id={workflow_run.id}" + ) + raise HTTPException(status_code=400, detail="Unsupported artifact type") if not file_path: logger.warning( @@ -68,7 +88,9 @@ async def download_workflow_artifact( # 3. Get storage backend for this workflow run try: - storage = get_storage_for_backend(workflow_run.storage_backend) + storage = get_storage_for_backend( + artifact_storage_backend or workflow_run.storage_backend + ) except ValueError as e: logger.error(f"Invalid storage backend: {workflow_run.storage_backend}") raise HTTPException(status_code=500, detail="Storage configuration error") diff --git a/api/routes/s3_signed_url.py b/api/routes/s3_signed_url.py index f0008ae4..b749f98c 100644 --- a/api/routes/s3_signed_url.py +++ b/api/routes/s3_signed_url.py @@ -40,14 +40,22 @@ class PresignedUploadUrlResponse(BaseModel): router = APIRouter(prefix="/s3", tags=["s3"]) +ORG_SCOPED_STORAGE_PREFIXES = ("campaigns", "knowledge_base") + + def _extract_org_id_from_key(key: str) -> Optional[int]: """Try to extract an organization ID from a storage key. - Matches keys of the form ``{prefix}/{org_id}/...`` where *org_id* is a - positive integer. Returns ``None`` when the pattern does not match. + Matches known org-scoped keys of the form ``{prefix}/{org_id}/...`` where + *org_id* is a positive integer. Returns ``None`` when the pattern does not + match. """ parts = key.split("/") - if len(parts) >= 3 and parts[1].isdigit(): + if ( + len(parts) >= 3 + and parts[0] in ORG_SCOPED_STORAGE_PREFIXES + and parts[1].isdigit() + ): return int(parts[1]) return None @@ -58,15 +66,20 @@ def _extract_legacy_workflow_run_id(key: str) -> Optional[int]: Supports: - ``transcripts/{run_id}.txt`` - ``recordings/{run_id}.wav`` + - ``recordings/{run_id}/user.wav`` + - ``recordings/{run_id}/bot.wav`` Returns ``None`` when the key does not match a legacy pattern. """ if key.startswith("transcripts/") and key.endswith(".txt"): run_id_str = key[len("transcripts/") : -4] - elif key.startswith("recordings/") and key.endswith(".wav"): - run_id_str = key[len("recordings/") : -4] else: - return None + recording_match = re.fullmatch( + r"recordings/(\d+)(?:\.wav|/(?:user|bot)\.wav)", key + ) + if not recording_match: + return None + run_id_str = recording_match.group(1) return int(run_id_str) if run_id_str.isdigit() else None @@ -89,8 +102,13 @@ async def _validate_and_extract_workflow_run_id( """ if key.startswith("transcripts/") and key.endswith(".txt"): run_id_str = key[len("transcripts/") : -4] # strip prefix & suffix - elif key.startswith("recordings/") and key.endswith(".wav"): - run_id_str = key[len("recordings/") : -4] + elif key.startswith("recordings/"): + run_id = _extract_legacy_workflow_run_id(key) + if run_id is None: + raise HTTPException( + status_code=400, detail="Invalid workflow_run_id in key" + ) + return run_id elif allow_special_paths and key.startswith("voicemail_detections/"): return None # Skip validation for these paths else: @@ -159,9 +177,9 @@ async def get_signed_url( """Return a short-lived signed URL for a file stored on S3 / MinIO. Access Control: - * Keys that embed an organization ID (``{prefix}/{org_id}/...``) are - authorized by matching the org_id against the requesting user's - organization. + * Known org-scoped keys (for example ``campaigns/{org_id}/...`` and + ``knowledge_base/{org_id}/...``) are authorized by matching the org_id + against the requesting user's organization. * Legacy keys (``recordings/{run_id}.wav``, ``transcripts/{run_id}.txt``) are authorized via the workflow run they belong to. * Superusers can request any key. 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 20d0a41e..47949436 100644 --- a/api/routes/user.py +++ b/api/routes/user.py @@ -9,7 +9,11 @@ from api.db import db_client from api.db.models import ( UserModel, ) +from api.schemas.onboarding_state import OnboardingState, OnboardingStateUpdate 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 +23,14 @@ 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, +) +from api.services.user_onboarding import ( + get_onboarding_state, + update_onboarding_state, +) router = APIRouter(prefix="/user") @@ -91,8 +103,17 @@ class UserConfigurationRequestResponseSchema(BaseModel): async def get_user_configurations( user: UserModel = Depends(get_user), ) -> UserConfigurationRequestResponseSchema: - user_configurations = await db_client.get_user_configurations(user.id) - masked_config = mask_user_config(user_configurations) + resolved_config = await get_resolved_ai_model_configuration( + user_id=user.id, + organization_id=user.selected_organization_id, + ) + masked_config = mask_user_config(resolved_config.effective) + if user.selected_organization_id: + preferences = await get_organization_preferences(user.selected_organization_id) + if preferences.test_phone_number is not None: + masked_config["test_phone_number"] = preferences.test_phone_number + if preferences.timezone is not None: + masked_config["timezone"] = preferences.timezone # Add organization pricing info if available if user.selected_organization_id: @@ -118,34 +139,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: @@ -160,12 +208,31 @@ async def update_user_configurations( return masked_config +@router.get("/onboarding-state") +async def get_user_onboarding_state( + user: UserModel = Depends(get_user), +) -> OnboardingState: + return await get_onboarding_state(user.id) + + +@router.put("/onboarding-state") +async def update_user_onboarding_state( + request: OnboardingStateUpdate, + user: UserModel = Depends(get_user), +) -> OnboardingState: + return await update_onboarding_state(user.id, request) + + @router.get("/configurations/user/validate") 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..75ed0482 100644 --- a/api/routes/webrtc_signaling.py +++ b/api/routes/webrtc_signaling.py @@ -19,7 +19,7 @@ import ipaddress import os from datetime import UTC, datetime from enum import Enum -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Set from aiortc import RTCIceServer from aiortc.sdp import candidate_from_sdp @@ -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") @@ -246,6 +246,74 @@ class SignalingManager: def __init__(self): self._connections: Dict[str, WebSocket] = {} self._peer_connections: Dict[str, SmallWebRTCConnection] = {} + self._connection_peer_ids: Dict[str, Set[str]] = {} + self._peer_connection_owners: Dict[str, str] = {} + + def _track_peer_connection( + self, connection_id: str, pc_id: str, pc: SmallWebRTCConnection + ) -> None: + self._peer_connections[pc_id] = pc + self._peer_connection_owners[pc_id] = connection_id + self._connection_peer_ids.setdefault(connection_id, set()).add(pc_id) + + def _forget_peer_connection(self, pc_id: str) -> Optional[str]: + connection_id = self._peer_connection_owners.pop(pc_id, None) + self._peer_connections.pop(pc_id, None) + + if connection_id: + peer_ids = self._connection_peer_ids.get(connection_id) + if peer_ids is not None: + peer_ids.discard(pc_id) + if not peer_ids: + self._connection_peer_ids.pop(connection_id, None) + + return connection_id + + async def _send_json_if_connected( + self, websocket: WebSocket, message: dict + ) -> bool: + if websocket.application_state != WebSocketState.CONNECTED: + return False + + try: + await websocket.send_json(message) + return True + except Exception as e: + logger.debug(f"Failed to send signaling WebSocket message: {e}") + return False + + async def _close_websocket_if_connected( + self, websocket: WebSocket, code: int = 1000, reason: str = "" + ) -> None: + if websocket.application_state != WebSocketState.CONNECTED: + return + + try: + await websocket.close(code=code, reason=reason) + except Exception as e: + logger.debug(f"Failed to close signaling WebSocket: {e}") + + async def _notify_call_ended_and_close_websocket( + self, + websocket: WebSocket, + workflow_run_id: int, + pc_id: str, + reason: str, + ) -> None: + await self._send_json_if_connected( + websocket, + { + "type": "call-ended", + "payload": { + "workflow_run_id": workflow_run_id, + "pc_id": pc_id, + "reason": reason, + }, + }, + ) + await self._close_websocket_if_connected( + websocket, code=1000, reason="call ended" + ) async def handle_websocket( self, @@ -257,35 +325,51 @@ class SignalingManager: """Handle WebSocket connection for signaling.""" await websocket.accept() connection_id = f"{workflow_id}:{workflow_run_id}:{user.id}" - self._connections[connection_id] = websocket + connection_key = f"{connection_id}:{id(websocket)}" + self._connections[connection_key] = websocket try: while True: message = await websocket.receive_json() await self._handle_message( - websocket, message, workflow_id, workflow_run_id, user + websocket, + message, + workflow_id, + workflow_run_id, + user, + connection_key, ) except WebSocketDisconnect: logger.info(f"WebSocket disconnected for {connection_id}") except Exception as e: - logger.error(f"WebSocket error for {connection_id}: {e}") + if websocket.application_state == WebSocketState.DISCONNECTED: + logger.info(f"WebSocket disconnected for {connection_id}") + else: + logger.error(f"WebSocket error for {connection_id}: {e}") finally: # Cleanup - self._connections.pop(connection_id, None) + self._connections.pop(connection_key, None) + peer_ids = list(self._connection_peer_ids.pop(connection_key, set())) # Unregister WebSocket sender for real-time feedback unregister_ws_sender(workflow_run_id) - # Clean up all peer connections for this workflow run + # Clean up peer connections owned by this WebSocket. # Note: In a WebSocket-based signaling approach (vs HTTP PATCH), # we maintain our own connection map instead of relying on # SmallWebRTCRequestHandler's _pcs_map. This is suitable for # multi-worker FastAPI deployments where state cannot be shared. - for pc_id in list(self._peer_connections.keys()): + for pc_id in peer_ids: + self._peer_connection_owners.pop(pc_id, None) pc = self._peer_connections.pop(pc_id, None) if pc: - await pc.disconnect() - logger.debug(f"Disconnected peer connection: {pc_id}") + try: + await pc.disconnect() + logger.debug(f"Disconnected peer connection: {pc_id}") + except Exception as e: + logger.debug( + f"Failed to disconnect peer connection {pc_id}: {e}" + ) async def _handle_message( self, @@ -294,17 +378,20 @@ class SignalingManager: workflow_id: int, workflow_run_id: int, user: UserModel, + connection_key: str, ): """Handle incoming WebSocket messages.""" msg_type = message.get("type") payload = message.get("payload", {}) if msg_type == "offer": - await self._handle_offer(ws, payload, workflow_id, workflow_run_id, user) + await self._handle_offer( + ws, payload, workflow_id, workflow_run_id, user, connection_key + ) elif msg_type == "ice-candidate": - await self._handle_ice_candidate(ws, payload, workflow_run_id) + await self._handle_ice_candidate(payload, connection_key) elif msg_type == "renegotiate": - await self._handle_renegotiation(ws, payload, workflow_id, workflow_run_id) + await self._handle_renegotiation(ws, payload, connection_key) async def _handle_offer( self, @@ -313,6 +400,7 @@ class SignalingManager: workflow_id: int, workflow_run_id: int, user: UserModel, + connection_key: str, ): """Handle offer message and create answer with ICE trickling.""" pc_id = payload.get("pc_id") @@ -320,6 +408,15 @@ class SignalingManager: type_ = payload.get("type") call_context_vars = payload.get("call_context_vars", {}) + if not pc_id or not sdp or not type_: + await ws.send_json( + { + "type": "error", + "payload": {"message": "Missing offer fields"}, + } + ) + return + # Set run context for logging and tracing. org_id must be set before # pc.initialize() so that aiortc's internal tasks inherit it. set_current_run_id(workflow_run_id) @@ -329,7 +426,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( @@ -343,7 +444,16 @@ class SignalingManager: ) return - if pc_id and pc_id in self._peer_connections: + if pc_id in self._peer_connections: + if self._peer_connection_owners.get(pc_id) != connection_key: + await ws.send_json( + { + "type": "error", + "payload": {"message": "Peer connection already owned"}, + } + ) + return + # Reuse existing connection logger.info(f"Reusing existing connection for pc_id: {pc_id}") pc = self._peer_connections[pc_id] @@ -375,7 +485,7 @@ class SignalingManager: await pc.initialize(sdp=sdp, type=type_) # Store peer connection using client's pc_id - self._peer_connections[pc_id] = pc + self._track_peer_connection(connection_key, pc_id, pc) # Register WebSocket sender for real-time feedback async def ws_sender(message: dict): @@ -388,7 +498,16 @@ class SignalingManager: @pc.event_handler("closed") async def handle_disconnected(webrtc_connection: SmallWebRTCConnection): logger.info(f"PeerConnection closed: {webrtc_connection.pc_id}") - self._peer_connections.pop(webrtc_connection.pc_id, None) + owner_connection_id = self._forget_peer_connection( + webrtc_connection.pc_id + ) + if owner_connection_id == connection_key: + await self._notify_call_ended_and_close_websocket( + ws, + workflow_run_id, + webrtc_connection.pc_id, + reason="peer_connection_closed", + ) # Start pipeline in background asyncio.create_task( @@ -417,9 +536,7 @@ class SignalingManager: } ) - async def _handle_ice_candidate( - self, ws: WebSocket, payload: dict, workflow_run_id: int - ): + async def _handle_ice_candidate(self, payload: dict, connection_key: str): """Handle incoming ICE candidate from client. Uses SmallWebRTC's native ICE trickling support via add_ice_candidate(). @@ -438,6 +555,9 @@ class SignalingManager: if not pc: logger.warning(f"No peer connection found for pc_id: {pc_id}") return + if self._peer_connection_owners.get(pc_id) != connection_key: + logger.warning(f"Ignoring ICE candidate for unowned pc_id: {pc_id}") + return if candidate_data: candidate_str = candidate_data.get("candidate", "") @@ -462,7 +582,7 @@ class SignalingManager: logger.debug(f"End of ICE candidates for pc_id: {pc_id}") async def _handle_renegotiation( - self, ws: WebSocket, payload: dict, workflow_id: int, workflow_run_id: int + self, ws: WebSocket, payload: dict, connection_key: str ): """Handle renegotiation request.""" pc_id = payload.get("pc_id") @@ -475,6 +595,11 @@ class SignalingManager: {"type": "error", "payload": {"message": "Peer connection not found"}} ) return + if self._peer_connection_owners.get(pc_id) != connection_key: + await ws.send_json( + {"type": "error", "payload": {"message": "Peer connection not found"}} + ) + return pc = self._peer_connections[pc_id] await pc.renegotiate(sdp=sdp, type=type_, restart_pc=restart_pc) diff --git a/api/routes/workflow.py b/api/routes/workflow.py index 7adf0864..c106a2ee 100644 --- a/api/routes/workflow.py +++ b/api/routes/workflow.py @@ -15,10 +15,19 @@ from api.db import db_client 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.enums import CallType, PostHogEvent, StorageBackend, WorkflowStatus +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, @@ -48,6 +60,10 @@ from api.services.workflow.trigger_paths import ( ) from api.services.workflow.workflow_graph import WorkflowGraph from api.utils.artifacts import artifact_url +from api.utils.recording_artifacts import ( + get_recording_storage_key, + has_recording_track, +) router = APIRouter(prefix="/workflow") @@ -562,6 +578,31 @@ async def get_workflow_count( ) +def _validate_status_filter(status: Optional[str]) -> List[str]: + """Parse and validate a workflow ``status`` query filter. + + Accepts a single value or a comma-separated list. Returns the list of + validated status values (empty when no filter was supplied). Any value + outside the ``workflow_status`` enum raises 422 so the request fails as a + clean client error instead of a 500 from the Postgres enum cast. + """ + if status is None or status == "": + return [] + allowed = {s.value for s in WorkflowStatus} + requested = [s.strip() for s in status.split(",")] + invalid = sorted({s for s in requested if s not in allowed}) + if invalid: + invalid_display = ["" if s == "" else s for s in invalid] + raise HTTPException( + status_code=422, + detail=( + f"Invalid workflow status filter: {invalid_display}. " + f"Allowed values: {sorted(allowed)}." + ), + ) + return requested + + @router.get( "/fetch", **sdk_expose( @@ -581,21 +622,22 @@ async def get_workflows( Returns a lightweight response with only essential fields for listing. Use GET /workflow/fetch/{workflow_id} to get full workflow details. """ - # Handle comma-separated status values - if status and "," in status: - # Split comma-separated values and fetch workflows for each status - status_list = [s.strip() for s in status.split(",")] + statuses = _validate_status_filter(status) + if statuses: + # Fetch workflows for each requested status and combine the results. all_workflows = [] - for status_value in status_list: - workflows = await db_client.get_all_workflows_for_listing( - organization_id=user.selected_organization_id, status=status_value + for status_value in statuses: + all_workflows.extend( + await db_client.get_all_workflows_for_listing( + organization_id=user.selected_organization_id, + status=status_value, + ) ) - all_workflows.extend(workflows) workflows = all_workflows else: - # Single status or no status filter + # No status filter workflows = await db_client.get_all_workflows_for_listing( - organization_id=user.selected_organization_id, status=status + organization_id=user.selected_organization_id, status=None ) # Get run counts for all workflows in a single query @@ -804,10 +846,20 @@ async def get_workflows_summary( ), ) -> List[WorkflowSummaryResponse]: """Get minimal workflow information (id and name only) for all workflows""" - workflows = await db_client.get_all_workflows( - organization_id=user.selected_organization_id, - status=status, - ) + statuses = _validate_status_filter(status) + if statuses: + workflows = [] + for status_value in statuses: + workflows.extend( + await db_client.get_all_workflows( + organization_id=user.selected_organization_id, + status=status_value, + ) + ) + else: + workflows = await db_client.get_all_workflows( + organization_id=user.selected_organization_id, status=None + ) return [ WorkflowSummaryResponse(id=workflow.id, name=workflow.name) for workflow in workflows @@ -955,12 +1007,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 +1092,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 @@ -1157,7 +1295,16 @@ async def get_workflow_run( raise HTTPException(status_code=404, detail="Workflow run not found") public_access_token = run.public_access_token - if (run.transcript_url or run.recording_url) and not public_access_token: + user_recording_url = get_recording_storage_key(run.extra, "user") + bot_recording_url = get_recording_storage_key(run.extra, "bot") + has_user_recording = has_recording_track(run.extra, "user") + has_bot_recording = has_recording_track(run.extra, "bot") + if ( + run.transcript_url + or run.recording_url + or has_user_recording + or has_bot_recording + ) and not public_access_token: public_access_token = await db_client.ensure_public_access_token(run.id) return { @@ -1168,25 +1315,22 @@ async def get_workflow_run( "is_completed": run.is_completed, "transcript_url": run.transcript_url, "recording_url": run.recording_url, + "user_recording_url": user_recording_url, + "bot_recording_url": bot_recording_url, "transcript_public_url": artifact_url(public_access_token, "transcript"), "recording_public_url": artifact_url(public_access_token, "recording"), + "user_recording_public_url": ( + artifact_url(public_access_token, "user_recording") + if has_user_recording + else None + ), + "bot_recording_public_url": ( + artifact_url(public_access_token, "bot_recording") + if has_bot_recording + else None + ), "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..c5403b04 --- /dev/null +++ b/api/schemas/ai_model_configuration.py @@ -0,0 +1,194 @@ +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 + + @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/onboarding_state.py b/api/schemas/onboarding_state.py new file mode 100644 index 00000000..689200ca --- /dev/null +++ b/api/schemas/onboarding_state.py @@ -0,0 +1,47 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class OnboardingState(BaseModel): + """Per-user onboarding state, stored under UserConfigurationKey.ONBOARDING. + + Server-authoritative replacement for the browser-localStorage onboarding + store, so the post-signup gate and one-time tooltips hold across devices. + """ + + # Post-signup onboarding form gate: set once on submit/skip. + completed_at: datetime | None = None + skipped: bool = False + # One-time UI affordances (tooltip keys, milestone action keys). Kept as + # free-form strings — the UI owns the vocabulary. + seen_tooltips: list[str] = Field(default_factory=list) + completed_actions: list[str] = Field(default_factory=list) + + +class OnboardingStateUpdate(BaseModel): + """Partial update merged into the stored state. + + Scalars overwrite when supplied; list entries are unioned into the stored + lists, so concurrent updates (e.g. two tabs marking different tooltips) + don't drop each other's items. + """ + + completed_at: datetime | None = None + skipped: bool | None = None + seen_tooltips: list[str] | None = None + completed_actions: list[str] | None = None + + def apply_to(self, state: OnboardingState) -> OnboardingState: + merged = state.model_copy(deep=True) + if self.completed_at is not None: + merged.completed_at = self.completed_at + if self.skipped is not None: + merged.skipped = self.skipped + for tooltip in self.seen_tooltips or []: + if tooltip not in merged.seen_tooltips: + merged.seen_tooltips.append(tooltip) + for action in self.completed_actions or []: + if action not in merged.completed_actions: + merged.completed_actions.append(action) + return merged 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 2e62396a..00000000 --- a/api/schemas/user_configuration.py +++ /dev/null @@ -1,33 +0,0 @@ -from datetime import datetime - -from pydantic import BaseModel, model_validator - -from api.services.configuration.registry import ( - EmbeddingsConfig, - LLMConfig, - RealtimeConfig, - STTConfig, - TTSConfig, -) - - -class 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 - - @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/schemas/workflow.py b/api/schemas/workflow.py index ae0a2659..2291e11f 100644 --- a/api/schemas/workflow.py +++ b/api/schemas/workflow.py @@ -15,8 +15,12 @@ class WorkflowRunResponseSchema(BaseModel): is_completed: bool transcript_url: str | None recording_url: str | None + user_recording_url: str | None = None + bot_recording_url: str | None = None transcript_public_url: str | None = None recording_public_url: str | None = None + user_recording_public_url: str | None = None + bot_recording_public_url: str | None = None public_access_token: str | None = None cost_info: Dict[str, Any] | None usage_info: Dict[str, Any] | None = None 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 512a27e1..3e97709c 100644 --- a/api/services/configuration/check_validity.py +++ b/api/services/configuration/check_validity.py @@ -9,8 +9,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 @@ -51,6 +51,7 @@ class UserConfigurationValidator: ServiceProviders.CAMB.value: self._check_camb_api_key, ServiceProviders.AWS_BEDROCK.value: self._check_aws_bedrock_api_key, ServiceProviders.SPEACHES.value: self._check_speaches_api_key, + ServiceProviders.HUGGINGFACE.value: self._check_huggingface_api_key, ServiceProviders.GOOGLE_VERTEX.value: self._check_google_vertex_llm_api_key, ServiceProviders.OPENAI_REALTIME.value: self._check_openai_api_key, ServiceProviders.GROK_REALTIME.value: self._check_grok_realtime_api_key, @@ -62,11 +63,12 @@ class UserConfigurationValidator: ServiceProviders.GLADIA.value: self._check_gladia_api_key, ServiceProviders.RIME.value: self._check_rime_api_key, ServiceProviders.MINIMAX.value: self._check_minimax_api_key, + ServiceProviders.SMALLEST.value: self._check_smallest_api_key, } async def validate( self, - configuration: UserConfiguration, + configuration: EffectiveAIModelConfiguration, organization_id: Optional[int] = None, created_by: Optional[str] = None, ) -> APIKeyStatusResponse: @@ -77,21 +79,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) @@ -388,6 +390,14 @@ class UserConfigurationValidator: raise ValueError("base_url is required for Speaches services") return True + def _check_huggingface_api_key(self, model: str, api_key: str) -> bool: + if not api_key.startswith("hf_"): + raise ValueError( + "Invalid Hugging Face API token format. Use a token that starts with " + "'hf_' and has Inference Providers permission." + ) + return True + def _check_google_vertex_realtime_api_key(self, model: str, service_config) -> bool: if not getattr(service_config, "project_id", None): raise ValueError("project_id is required for Google Vertex Realtime") @@ -417,6 +427,7 @@ class UserConfigurationValidator: return True def _check_minimax_api_key(self, model: str, api_key: str) -> bool: - # MiniMax doesn't publish a cheap key-validation endpoint; trust the key - # at save time and surface auth errors at first call (same as Rime/Sarvam). + return True + + def _check_smallest_api_key(self, model: str, api_key: str) -> bool: return True diff --git a/api/services/configuration/masking.py b/api/services/configuration/masking.py index 877cad97..a7e1af6a 100644 --- a/api/services/configuration/masking.py +++ b/api/services/configuration/masking.py @@ -12,7 +12,7 @@ The rules are simple: import copy from typing import Any, Dict, Optional -from api.schemas.user_configuration import 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 { @@ -151,21 +151,35 @@ def mask_workflow_configurations(config: Optional[Dict]) -> Optional[Dict]: masked = copy.deepcopy(config) model_overrides = masked.get("model_overrides") - if not isinstance(model_overrides, dict): - return masked + if isinstance(model_overrides, dict): + for section in MODEL_OVERRIDE_FIELDS: + override = model_overrides.get(section) + if not isinstance(override, dict): + continue + for secret_field in SERVICE_SECRET_FIELDS: + raw = override.get(secret_field) + if raw: + override[secret_field] = _mask_secret_value(raw) - for section in MODEL_OVERRIDE_FIELDS: - override = model_overrides.get(section) - if not isinstance(override, dict): - continue - for secret_field in SERVICE_SECRET_FIELDS: - raw = override.get(secret_field) - if raw: - override[secret_field] = _mask_secret_value(raw) + v2_override = masked.get("model_configuration_v2_override") + if isinstance(v2_override, dict): + _mask_nested_service_secrets(v2_override) return masked +def _mask_nested_service_secrets(value): + if isinstance(value, dict): + for key, nested in list(value.items()): + if key in SERVICE_SECRET_FIELDS and nested: + value[key] = _mask_secret_value(nested) + else: + _mask_nested_service_secrets(nested) + elif isinstance(value, list): + for item in value: + _mask_nested_service_secrets(item) + + # --------------------------------------------------------------------------- # Workflow definition helpers – mask / merge node API keys # --------------------------------------------------------------------------- diff --git a/api/services/configuration/merge.py b/api/services/configuration/merge.py index f421648f..3100fa45 100644 --- a/api/services/configuration/merge.py +++ b/api/services/configuration/merge.py @@ -7,7 +7,7 @@ stored, while honouring masked API keys. import copy from typing import Dict -from api.schemas.user_configuration import 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,7 +113,7 @@ def merge_user_configurations( if "timezone" in incoming_partial: merged["timezone"] = incoming_partial["timezone"] - return UserConfiguration.model_validate(merged) + return EffectiveAIModelConfiguration.model_validate(merged) def merge_workflow_configuration_secrets( diff --git a/api/services/configuration/options/__init__.py b/api/services/configuration/options/__init__.py index 1e3294ae..c30b128c 100644 --- a/api/services/configuration/options/__init__.py +++ b/api/services/configuration/options/__init__.py @@ -9,7 +9,13 @@ from .azure import ( AZURE_SPEECH_TTS_LANGUAGES, AZURE_SPEECH_TTS_VOICES, ) -from .deepgram import DEEPGRAM_LANGUAGES, DEEPGRAM_STT_MODELS +from .deepgram import ( + DEEPGRAM_FLUX_MODELS, + DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS, + DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES, + DEEPGRAM_LANGUAGES, + DEEPGRAM_STT_MODELS, +) from .gladia import GLADIA_STT_LANGUAGES, GLADIA_STT_MODELS from .google import ( GOOGLE_MODELS, @@ -35,6 +41,12 @@ from .sarvam import ( SARVAM_V2_VOICES, SARVAM_V3_VOICES, ) +from .smallest import ( + SMALLEST_TTS_LANGUAGES, + SMALLEST_TTS_MODELS, + SMALLEST_TTS_PRO_VOICES, + SMALLEST_TTS_VOICES, +) from .speechmatics import SPEECHMATICS_STT_LANGUAGES __all__ = [ @@ -47,6 +59,9 @@ __all__ = [ "AZURE_SPEECH_STT_LANGUAGES", "AZURE_SPEECH_TTS_LANGUAGES", "AZURE_SPEECH_TTS_VOICES", + "DEEPGRAM_FLUX_MODELS", + "DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES", + "DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS", "DEEPGRAM_LANGUAGES", "DEEPGRAM_STT_MODELS", "GLADIA_STT_LANGUAGES", @@ -71,5 +86,9 @@ __all__ = [ "SARVAM_TTS_MODELS", "SARVAM_V2_VOICES", "SARVAM_V3_VOICES", + "SMALLEST_TTS_LANGUAGES", + "SMALLEST_TTS_MODELS", + "SMALLEST_TTS_PRO_VOICES", + "SMALLEST_TTS_VOICES", "SPEECHMATICS_STT_LANGUAGES", ] diff --git a/api/services/configuration/options/deepgram.py b/api/services/configuration/options/deepgram.py index fffa564e..1ab42a01 100644 --- a/api/services/configuration/options/deepgram.py +++ b/api/services/configuration/options/deepgram.py @@ -1,4 +1,21 @@ -DEEPGRAM_STT_MODELS = ("nova-3-general", "flux-general-en", "flux-general-multi") +DEEPGRAM_FLUX_MODELS = ("flux-general-en", "flux-general-multi") +DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES = ( + "de", + "en", + "es", + "fr", + "hi", + "it", + "ja", + "nl", + "pt", + "ru", +) +DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS = ( + "multi", + *DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES, +) +DEEPGRAM_STT_MODELS = ("nova-3-general", *DEEPGRAM_FLUX_MODELS) DEEPGRAM_LANGUAGES = ( "multi", "ar", diff --git a/api/services/configuration/options/smallest.py b/api/services/configuration/options/smallest.py new file mode 100644 index 00000000..70b90f56 --- /dev/null +++ b/api/services/configuration/options/smallest.py @@ -0,0 +1,45 @@ +SMALLEST_TTS_MODELS = ("lightning_v3.1", "lightning_v3.1_pro") +SMALLEST_TTS_VOICES = ( + "sophia", + "avery", + "liam", + "lucas", + "olivia", + "ryan", + "freya", + "william", + "devansh", + "arjun", + "niharika", + "maya", + "dhruv", + "mia", + "maithili", +) +# Premium voices for lightning_v3.1_pro (American, British, Indian accents; English + Hindi only) +SMALLEST_TTS_PRO_VOICES = ( + "meher", + "rhea", + "aviraj", + "cressida", + "willow", + "maverick", +) +SMALLEST_TTS_LANGUAGES = ( + "en", + "hi", + "fr", + "de", + "es", + "it", + "nl", + "pl", + "ru", + "ar", + "bn", + "gu", + "he", + "kn", + "mr", + "ta", +) diff --git a/api/services/configuration/registry.py b/api/services/configuration/registry.py index 29c8a018..7e269080 100644 --- a/api/services/configuration/registry.py +++ b/api/services/configuration/registry.py @@ -14,6 +14,7 @@ from api.services.configuration.options import ( AZURE_SPEECH_STT_LANGUAGES, AZURE_SPEECH_TTS_LANGUAGES, AZURE_SPEECH_TTS_VOICES, + DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS, DEEPGRAM_LANGUAGES, DEEPGRAM_STT_MODELS, GLADIA_STT_LANGUAGES, @@ -38,6 +39,10 @@ from api.services.configuration.options import ( SARVAM_TTS_MODELS, SARVAM_V2_VOICES, SARVAM_V3_VOICES, + SMALLEST_TTS_LANGUAGES, + SMALLEST_TTS_MODELS, + SMALLEST_TTS_PRO_VOICES, + SMALLEST_TTS_VOICES, SPEECHMATICS_STT_LANGUAGES, ) from api.services.configuration.options.google import GOOGLE_VERTEX_MODELS @@ -69,6 +74,7 @@ class ServiceProviders(str, Enum): CAMB = "camb" AWS_BEDROCK = "aws_bedrock" SPEACHES = "speaches" + HUGGINGFACE = "huggingface" ASSEMBLYAI = "assemblyai" GLADIA = "gladia" RIME = "rime" @@ -80,6 +86,7 @@ class ServiceProviders(str, Enum): GOOGLE_REALTIME = "google_realtime" GOOGLE_VERTEX_REALTIME = "google_vertex_realtime" AZURE_REALTIME = "azure_realtime" + SMALLEST = "smallest" class BaseServiceConfiguration(BaseModel): @@ -96,6 +103,7 @@ class BaseServiceConfiguration(BaseModel): ServiceProviders.DOGRAH, ServiceProviders.AWS_BEDROCK, ServiceProviders.SPEACHES, + ServiceProviders.HUGGINGFACE, ServiceProviders.ASSEMBLYAI, ServiceProviders.GLADIA, ServiceProviders.RIME, @@ -108,6 +116,7 @@ class BaseServiceConfiguration(BaseModel): ServiceProviders.GOOGLE_VERTEX_REALTIME, ServiceProviders.AZURE_REALTIME, ServiceProviders.SARVAM, + ServiceProviders.SMALLEST, ] api_key: str | list[str] @@ -265,6 +274,11 @@ SPEACHES_PROVIDER_MODEL_CONFIG = provider_model_config( ), provider_docs_url="https://github.com/speaches-ai/speaches", ) +HUGGINGFACE_PROVIDER_MODEL_CONFIG = provider_model_config( + "Hugging Face", + description="Hosted Hugging Face Inference Providers API for usage-based inference.", + provider_docs_url="https://huggingface.co/docs/inference-providers/en/index", +) AZURE_SPEECH_PROVIDER_MODEL_CONFIG = provider_model_config( "Azure Speech Services", description="Azure Cognitive Services Speech — TTS and STT via the Azure Speech SDK.", @@ -481,6 +495,35 @@ class SpeachesLLMConfiguration(BaseLLMConfiguration): ) +HUGGINGFACE_LLM_MODELS = [ + "openai/gpt-oss-120b:cerebras", + "deepseek-ai/DeepSeek-R1:fastest", + "Qwen/Qwen3-Coder-480B-A35B-Instruct:fastest", +] + + +@register_llm +class HuggingFaceLLMConfiguration(BaseLLMConfiguration): + model_config = HUGGINGFACE_PROVIDER_MODEL_CONFIG + provider: Literal[ServiceProviders.HUGGINGFACE] = ServiceProviders.HUGGINGFACE + model: str = Field( + default="openai/gpt-oss-120b:cerebras", + description="Hugging Face chat-completion model identifier, optionally with provider suffix.", + json_schema_extra={ + "examples": HUGGINGFACE_LLM_MODELS, + "allow_custom_input": True, + }, + ) + base_url: str = Field( + default="https://router.huggingface.co/v1", + description="Hugging Face OpenAI-compatible chat-completions router base URL.", + ) + bill_to: str | None = Field( + default=None, + description="Optional Hugging Face organization or user to bill using X-HF-Bill-To.", + ) + + MINIMAX_MODELS = [ "MiniMax-M2.7", "MiniMax-M2.7-highspeed", @@ -751,6 +794,7 @@ LLMConfig = Annotated[ DograhLLMService, AWSBedrockLLMConfiguration, SpeachesLLMConfiguration, + HuggingFaceLLMConfiguration, MiniMaxLLMConfiguration, SarvamLLMConfiguration, ], @@ -917,11 +961,12 @@ class DograhTTSService(BaseTTSConfiguration): voice: str = Field( default="default", description="Voice preset.", + json_schema_extra={"allow_custom_input": True}, ) 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"] INWORLD_TTS_MODELS = ["inworld-tts-2"] INWORLD_TTS_VOICES = ["Ashley"] INWORLD_TTS_LANGUAGES = ["en-US"] @@ -932,7 +977,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}, ) @@ -947,6 +992,11 @@ class CartesiaTTSConfiguration(BaseTTSConfiguration): le=2.0, description="Volume multiplier for generated speech.", ) + language: str = Field( + default="en", + description="Cartesia language code for TTS synthesis (e.g. 'en', 'tr', 'fr', 'de').", + json_schema_extra={"allow_custom_input": True}, + ) @register_tts @@ -1000,9 +1050,10 @@ class SarvamTTSConfiguration(BaseTTSConfiguration): ) voice: str = Field( default="anushka", - description="Sarvam voice name; must match the selected model's voice list.", + description="Sarvam voice name or custom voice ID.", json_schema_extra={ "examples": SARVAM_V2_VOICES, + "allow_custom_input": True, "model_options": { "bulbul:v2": SARVAM_V2_VOICES, "bulbul:v3": SARVAM_V3_VOICES, @@ -1014,6 +1065,12 @@ class SarvamTTSConfiguration(BaseTTSConfiguration): description="BCP-47 Indian-language code (e.g. hi-IN, en-IN).", json_schema_extra={"examples": SARVAM_LANGUAGES}, ) + speed: float = Field( + default=1.0, + ge=0.5, + le=2.0, + description="Speech speed multiplier.", + ) CAMB_TTS_MODELS = ["mars-flash", "mars-pro", "mars-instruct"] @@ -1173,6 +1230,50 @@ class AzureSpeechTTSConfiguration(BaseTTSConfiguration): ) +SMALLEST_PROVIDER_MODEL_CONFIG = provider_model_config( + "Smallest AI", + description="Smallest AI ultralow-latency TTS (Waves) and STT (Pulse) APIs.", + provider_docs_url="https://smallest.ai/docs", +) + + +@register_tts +class SmallestAITTSConfiguration(BaseTTSConfiguration): + model_config = SMALLEST_PROVIDER_MODEL_CONFIG + provider: Literal[ServiceProviders.SMALLEST] = ServiceProviders.SMALLEST + model: str = Field( + default="lightning_v3.1", + description="Smallest AI TTS model. lightning_v3.1_pro is the premium pool (American, British, Indian accents); lightning_v3.1 is the standard pool with 217 voices across 12 languages.", + json_schema_extra={"examples": SMALLEST_TTS_MODELS}, + ) + voice: str = Field( + default="sophia", + description="Smallest AI voice ID. Available voices differ by model: lightning_v3.1 has a broad multilingual pool; lightning_v3.1_pro has premium American, British, and Indian accent voices (English + Hindi only).", + json_schema_extra={ + "examples": list(SMALLEST_TTS_VOICES), + "allow_custom_input": True, + "model_options": { + "lightning_v3.1": list(SMALLEST_TTS_VOICES), + "lightning_v3.1_pro": list(SMALLEST_TTS_PRO_VOICES), + }, + }, + ) + language: str = Field( + default="en", + description="ISO 639-1 language code for synthesis.", + json_schema_extra={ + "examples": SMALLEST_TTS_LANGUAGES, + "allow_custom_input": True, + }, + ) + speed: float = Field( + default=1.0, + ge=0.5, + le=2.0, + description="Speech speed multiplier (0.5 to 2.0).", + ) + + TTSConfig = Annotated[ Union[ DeepgramTTSConfiguration, @@ -1188,6 +1289,7 @@ TTSConfig = Annotated[ SpeachesTTSConfiguration, MiniMaxTTSConfiguration, AzureSpeechTTSConfiguration, + SmallestAITTSConfiguration, ], Field(discriminator="provider"), ] @@ -1206,12 +1308,16 @@ class DeepgramSTTConfiguration(BaseSTTConfiguration): ) language: str = Field( default="multi", - description="Language code; 'multi' enables auto-detect (Nova-3 only).", + description=( + "Language code. 'multi' enables Nova-3 auto-detect and omits " + "language hints for Flux multilingual auto-detect." + ), json_schema_extra={ "examples": DEEPGRAM_LANGUAGES, "model_options": { "nova-3-general": DEEPGRAM_LANGUAGES, "flux-general-en": ("en",), + "flux-general-multi": DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS, }, }, ) @@ -1388,6 +1494,38 @@ class SpeachesSTTConfiguration(BaseSTTConfiguration): ) +HUGGINGFACE_STT_MODELS = [ + "openai/whisper-large-v3-turbo", + "openai/whisper-large-v3", +] + + +@register_stt +class HuggingFaceSTTConfiguration(BaseSTTConfiguration): + model_config = HUGGINGFACE_PROVIDER_MODEL_CONFIG + provider: Literal[ServiceProviders.HUGGINGFACE] = ServiceProviders.HUGGINGFACE + model: str = Field( + default="openai/whisper-large-v3-turbo", + description="Hugging Face ASR model identifier served through Inference Providers.", + json_schema_extra={ + "examples": HUGGINGFACE_STT_MODELS, + "allow_custom_input": True, + }, + ) + base_url: str = Field( + default="https://router.huggingface.co/hf-inference", + description="Hugging Face Inference Providers router base URL.", + ) + bill_to: str | None = Field( + default=None, + description="Optional Hugging Face organization or user to bill using X-HF-Bill-To.", + ) + return_timestamps: bool = Field( + default=False, + description="Request timestamp chunks when supported by the selected provider/model.", + ) + + ASSEMBLYAI_STT_MODELS = ["u3-rt-pro"] ASSEMBLYAI_STT_LANGUAGES = ["en", "es", "de", "fr", "pt", "it"] @@ -1450,6 +1588,62 @@ class AzureSpeechSTTConfiguration(BaseSTTConfiguration): ) +SMALLEST_STT_MODELS = ["pulse"] +SMALLEST_STT_LANGUAGES = [ + "en", + "hi", + "fr", + "de", + "es", + "it", + "nl", + "pl", + "ru", + "pt", + "bn", + "gu", + "kn", + "ml", + "mr", + "ta", + "te", + "pa", + "or", + "bg", + "cs", + "da", + "et", + "fi", + "hu", + "lt", + "lv", + "mt", + "ro", + "sk", + "sv", + "uk", +] + + +@register_stt +class SmallestAISTTConfiguration(BaseSTTConfiguration): + model_config = SMALLEST_PROVIDER_MODEL_CONFIG + provider: Literal[ServiceProviders.SMALLEST] = ServiceProviders.SMALLEST + model: str = Field( + default="pulse", + description="Smallest AI STT model. Supports 38 languages with real-time streaming.", + json_schema_extra={"examples": SMALLEST_STT_MODELS}, + ) + language: str = Field( + default="en", + description="ISO 639-1 language code for transcription.", + json_schema_extra={ + "examples": SMALLEST_STT_LANGUAGES, + "allow_custom_input": True, + }, + ) + + STTConfig = Annotated[ Union[ DeepgramSTTConfiguration, @@ -1460,9 +1654,11 @@ STTConfig = Annotated[ SpeechmaticsSTTConfiguration, SarvamSTTConfiguration, SpeachesSTTConfiguration, + HuggingFaceSTTConfiguration, AssemblyAISTTConfiguration, GladiaSTTConfiguration, AzureSpeechSTTConfiguration, + SmallestAISTTConfiguration, ], Field(discriminator="provider"), ] @@ -1526,11 +1722,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..87b95fde 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,277 @@ 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() + + usage_not_ready = ( + response.status_code == 409 and "usage_not_ready" in response.text + ) + if usage_not_ready and attempt < max_attempts: + await asyncio.sleep(attempt) + continue + + log = logger.warning if usage_not_ready else logger.error + log( + "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/event_handlers.py b/api/services/pipecat/event_handlers.py index 5ce82a9d..bb66d19f 100644 --- a/api/services/pipecat/event_handlers.py +++ b/api/services/pipecat/event_handlers.py @@ -9,8 +9,8 @@ from api.services.integrations import IntegrationRuntimeSession from api.services.pipecat.audio_config import AudioConfig from api.services.pipecat.audio_playback import play_audio_loop from api.services.pipecat.in_memory_buffers import ( - InMemoryAudioBuffer, InMemoryLogsBuffer, + InMemoryRecordingBuffers, ) from api.services.pipecat.pipeline_metrics_aggregator import PipelineMetricsAggregator from api.services.pipecat.tracing_config import get_trace_url @@ -40,11 +40,11 @@ async def _capture_call_event( "workflow_run_id": workflow_run_id, "workflow_id": workflow_run.workflow_id if workflow_run else None, "call_type": workflow_run.mode if workflow_run else None, - "call_direction": (workflow_run.initial_context or {}).get( - "direction", "outbound" - ) - if workflow_run - else None, + "call_direction": ( + (workflow_run.initial_context or {}).get("direction", "outbound") + if workflow_run + else None + ), } if extra_properties: properties.update(extra_properties) @@ -73,7 +73,7 @@ def register_event_handlers( """Register all event handlers for transport and task events. Returns: - in_memory_audio_buffer for use by other handlers. + In-memory recording buffers for use by other handlers. """ # Initialize in-memory buffers with proper audio configuration sample_rate = audio_config.pipeline_sample_rate if audio_config else 16000 @@ -84,7 +84,7 @@ def register_event_handlers( f"with sample_rate={sample_rate}Hz, channels={num_channels}" ) - in_memory_audio_buffer = InMemoryAudioBuffer( + in_memory_audio_buffers = InMemoryRecordingBuffers( workflow_run_id=workflow_run_id, sample_rate=sample_rate, num_channels=num_channels, @@ -363,14 +363,32 @@ def register_event_handlers( # Write buffers to temp files and enqueue combined processing task audio_temp_path = None + user_audio_temp_path = None + bot_audio_temp_path = None transcript_temp_path = None try: - if not in_memory_audio_buffer.is_empty: - audio_temp_path = await in_memory_audio_buffer.write_to_temp_file() + if not in_memory_audio_buffers.mixed.is_empty: + audio_temp_path = ( + await in_memory_audio_buffers.mixed.write_to_temp_file() + ) else: logger.debug("Audio buffer is empty, skipping upload") + if not in_memory_audio_buffers.user.is_empty: + user_audio_temp_path = ( + await in_memory_audio_buffers.user.write_to_temp_file() + ) + else: + logger.debug("User audio buffer is empty, skipping upload") + + if not in_memory_audio_buffers.bot.is_empty: + bot_audio_temp_path = ( + await in_memory_audio_buffers.bot.write_to_temp_file() + ) + else: + logger.debug("Bot audio buffer is empty, skipping upload") + transcript_temp_path = in_memory_logs_buffer.write_transcript_to_temp_file() if not transcript_temp_path: logger.debug("No transcript events in logs buffer, skipping upload") @@ -385,16 +403,18 @@ def register_event_handlers( workflow_run_id, audio_temp_path, transcript_temp_path, + user_audio_temp_path, + bot_audio_temp_path, ) # Return the buffer so it can be passed to other handlers - return in_memory_audio_buffer + return in_memory_audio_buffers def register_audio_data_handler( audio_buffer: AudioBufferProcessor, workflow_run_id, - in_memory_buffer: InMemoryAudioBuffer, + in_memory_buffers: InMemoryRecordingBuffers, ): """Register event handler for audio data""" logger.info(f"Registering audio data handler for workflow run {workflow_run_id}") @@ -404,9 +424,19 @@ def register_audio_data_handler( if not audio: return - # Use in-memory buffer try: - await in_memory_buffer.append(audio) + await in_memory_buffers.mixed.append(audio) except MemoryError as e: - logger.error(f"Memory buffer full: {e}") - # Could implement overflow to disk here if needed + logger.error(f"Mixed audio buffer full: {e}") + + @audio_buffer.event_handler("on_track_audio_data") + async def on_track_audio_data( + buffer, user_audio, bot_audio, sample_rate, num_channels + ): + try: + if user_audio: + await in_memory_buffers.user.append(user_audio) + if bot_audio: + await in_memory_buffers.bot.append(bot_audio) + except MemoryError as e: + logger.error(f"Track audio buffer full: {e}") diff --git a/api/services/pipecat/in_memory_buffers.py b/api/services/pipecat/in_memory_buffers.py index 3cf22d55..5c7f3030 100644 --- a/api/services/pipecat/in_memory_buffers.py +++ b/api/services/pipecat/in_memory_buffers.py @@ -75,6 +75,27 @@ class InMemoryAudioBuffer: return self._total_size +class InMemoryRecordingBuffers: + """Holds the mixed recording plus aligned user and bot mono tracks.""" + + def __init__(self, workflow_run_id: int, sample_rate: int, num_channels: int = 1): + self.mixed = InMemoryAudioBuffer( + workflow_run_id=workflow_run_id, + sample_rate=sample_rate, + num_channels=num_channels, + ) + self.user = InMemoryAudioBuffer( + workflow_run_id=workflow_run_id, + sample_rate=sample_rate, + num_channels=1, + ) + self.bot = InMemoryAudioBuffer( + workflow_run_id=workflow_run_id, + sample_rate=sample_rate, + num_channels=1, + ) + + class InMemoryLogsBuffer: """Buffer real-time feedback events in memory during a call, then save to workflow run logs.""" diff --git a/api/services/pipecat/run_pipeline.py b/api/services/pipecat/run_pipeline.py index 7ce41d87..ecea0a4e 100644 --- a/api/services/pipecat/run_pipeline.py +++ b/api/services/pipecat/run_pipeline.py @@ -6,6 +6,7 @@ from loguru import logger from api.db import db_client from api.enums import WorkflowRunMode +from api.services.configuration.options import DEEPGRAM_FLUX_MODELS from api.services.configuration.registry import ServiceProviders from api.services.integrations import ( IntegrationRuntimeContext, @@ -162,15 +163,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 +194,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 +274,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 +339,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 +385,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 +421,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 +541,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) @@ -587,7 +627,7 @@ async def _run_pipeline( # Other models use configurable turn detection strategy is_deepgram_flux = ( user_config.stt.provider == ServiceProviders.DEEPGRAM.value - and user_config.stt.model == "flux-general-en" + and user_config.stt.model in DEEPGRAM_FLUX_MODELS ) if is_deepgram_flux: @@ -679,7 +719,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 462a350f..52b48cb6 100644 --- a/api/services/pipecat/service_factory.py +++ b/api/services/pipecat/service_factory.py @@ -6,6 +6,7 @@ from fastapi import HTTPException from loguru import logger from api.constants import MPS_API_URL +from api.services.configuration.options import DEEPGRAM_FLUX_MODELS from api.services.configuration.registry import ServiceProviders from api.services.pipecat.inworld_tts import InworldOwnedSessionTTSService from api.services.pipecat.minimax_tts import MiniMaxOwnedSessionTTSService @@ -41,8 +42,17 @@ from pipecat.services.google.vertex.llm import ( ) from pipecat.services.inworld.tts import InworldTTSSettings from pipecat.services.groq.llm import GroqLLMService, GroqLLMSettings +from pipecat.services.huggingface.llm import ( + HuggingFaceLLMService, + HuggingFaceLLMSettings, +) +from pipecat.services.huggingface.stt import ( + HuggingFaceSTTService, + HuggingFaceSTTSettings, +) from pipecat.services.minimax.llm import MiniMaxLLMService from pipecat.services.minimax.tts import MiniMaxTTSSettings +from pipecat.services.openai._constants import OPENAI_SAMPLE_RATE from pipecat.services.openai.base_llm import OpenAILLMSettings from pipecat.services.openai.llm import OpenAILLMService from pipecat.services.openai.stt import ( @@ -55,6 +65,8 @@ from pipecat.services.rime.tts import RimeTTSService, RimeTTSSettings from pipecat.services.sarvam.llm import SarvamLLMService, SarvamLLMSettings from pipecat.services.sarvam.stt import SarvamSTTService, SarvamSTTSettings from pipecat.services.sarvam.tts import SarvamTTSService, SarvamTTSSettings +from pipecat.services.smallest.stt import SmallestSTTService, SmallestSTTSettings +from pipecat.services.smallest.tts import SmallestTTSService, SmallestTTSSettings from pipecat.services.speaches.llm import SpeachesLLMService, SpeachesLLMSettings from pipecat.services.speaches.stt import SpeachesSTTService, SpeachesSTTSettings from pipecat.services.speaches.tts import SpeachesTTSService, SpeachesTTSSettings @@ -69,6 +81,20 @@ if TYPE_CHECKING: from api.services.pipecat.audio_config import AudioConfig +DEEPGRAM_FLUX_LANGUAGE_HINTS = { + "de": Language.DE, + "en": Language.EN, + "es": Language.ES, + "fr": Language.FR, + "hi": Language.HI, + "it": Language.IT, + "ja": Language.JA, + "nl": Language.NL, + "pt": Language.PT, + "ru": Language.RU, +} + + def _validate_runtime_service_url(url: str, field_name: str) -> None: try: validate_user_configured_service_url( @@ -80,7 +106,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 @@ -92,17 +121,23 @@ def create_stt_service( f"Creating STT service: provider={user_config.stt.provider}, model={user_config.stt.model}" ) if user_config.stt.provider == ServiceProviders.DEEPGRAM.value: - # Check if using Flux model (English-only, no language selection) - if user_config.stt.model == "flux-general-en": + if user_config.stt.model in DEEPGRAM_FLUX_MODELS: + settings_kwargs = { + "model": user_config.stt.model, + "eot_timeout_ms": 3000, + "eot_threshold": 0.7, + "eager_eot_threshold": 0.5, + "keyterm": keyterms or [], + } + if user_config.stt.model == "flux-general-multi": + language = getattr(user_config.stt, "language", None) + language_hint = DEEPGRAM_FLUX_LANGUAGE_HINTS.get(language) + if language_hint: + settings_kwargs["language_hints"] = [language_hint] + return DeepgramFluxSTTService( api_key=user_config.stt.api_key, - settings=DeepgramFluxSTTSettings( - model=user_config.stt.model, - eot_timeout_ms=3000, - eot_threshold=0.7, - eager_eot_threshold=0.5, - keyterm=keyterms or [], - ), + settings=DeepgramFluxSTTSettings(**settings_kwargs), should_interrupt=False, # Let UserAggregator take care of sending InterruptionFrame sample_rate=audio_config.transport_in_sample_rate, ) @@ -162,6 +197,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, @@ -216,6 +252,22 @@ def create_stt_service( ), sample_rate=audio_config.transport_in_sample_rate, ) + elif user_config.stt.provider == ServiceProviders.HUGGINGFACE.value: + base_url = ( + getattr(user_config.stt, "base_url", None) + or "https://router.huggingface.co/hf-inference" + ) + _validate_runtime_service_url(base_url, "base_url") + return HuggingFaceSTTService( + api_key=user_config.stt.api_key, + base_url=base_url, + bill_to=getattr(user_config.stt, "bill_to", None), + settings=HuggingFaceSTTSettings( + model=user_config.stt.model, + return_timestamps=getattr(user_config.stt, "return_timestamps", False), + ), + sample_rate=audio_config.transport_in_sample_rate, + ) elif user_config.stt.provider == ServiceProviders.ASSEMBLYAI.value: language = getattr(user_config.stt, "language", None) settings_kwargs = {"model": user_config.stt.model, "language": language} @@ -282,13 +334,29 @@ def create_stt_service( settings=AzureSTTSettings(language=pipecat_language), sample_rate=audio_config.transport_in_sample_rate, ) + elif user_config.stt.provider == ServiceProviders.SMALLEST.value: + language_code = getattr(user_config.stt, "language", None) or "en" + try: + pipecat_language = Language(language_code) + except ValueError: + pipecat_language = Language.EN + return SmallestSTTService( + api_key=user_config.stt.api_key, + settings=SmallestSTTSettings( + model=user_config.stt.model, + language=pipecat_language, + ), + sample_rate=audio_config.transport_in_sample_rate, + ) else: raise HTTPException( status_code=400, detail=f"Invalid STT provider {user_config.stt.provider}" ) -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: @@ -316,6 +384,7 @@ def create_tts_service(user_config, audio_config: "AudioConfig"): kwargs["base_url"] = base_url return OpenAITTSService( api_key=user_config.tts.api_key, + sample_rate=OPENAI_SAMPLE_RATE, settings=OpenAITTSSettings(model=user_config.tts.model), text_filters=[xml_function_tag_filter], skip_aggregator_types=["recording_router", "recording"], @@ -385,11 +454,13 @@ def create_tts_service(user_config, audio_config: "AudioConfig"): generation_config = ( GenerationConfig(**gen_config_kwargs) if gen_config_kwargs else None ) + language = getattr(user_config.tts, "language", None) or "en" return CartesiaTTSService( api_key=user_config.tts.api_key, settings=CartesiaTTSSettings( voice=user_config.tts.voice, model=user_config.tts.model, + language=language, **( {"generation_config": generation_config} if generation_config @@ -428,6 +499,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, @@ -509,14 +581,20 @@ def create_tts_service(user_config, audio_config: "AudioConfig"): language = getattr(user_config.tts, "language", None) pipecat_language = language_mapping.get(language, Language.HI) - voice = getattr(user_config.tts, "voice", None) or "anushka" + voice = ( + getattr(user_config.tts, "voice", None) or "" + ).strip().lower() or "anushka" + speed = getattr(user_config.tts, "speed", None) + settings_kwargs = { + "model": user_config.tts.model, + "voice": voice, + "language": pipecat_language, + } + if speed and speed != 1.0: + settings_kwargs["pace"] = speed return SarvamTTSService( api_key=user_config.tts.api_key, - settings=SarvamTTSSettings( - model=user_config.tts.model, - voice=voice, - language=pipecat_language, - ), + settings=SarvamTTSSettings(**settings_kwargs), text_filters=[xml_function_tag_filter], skip_aggregator_types=["recording_router", "recording"], silence_time_s=1.0, @@ -577,6 +655,28 @@ def create_tts_service(user_config, audio_config: "AudioConfig"): skip_aggregator_types=["recording_router", "recording"], silence_time_s=1.0, ) + elif user_config.tts.provider == ServiceProviders.SMALLEST.value: + language_code = getattr(user_config.tts, "language", None) or "en" + try: + pipecat_language = Language(language_code) + except ValueError: + pipecat_language = Language.EN + speed = getattr(user_config.tts, "speed", None) + model = user_config.tts.model.replace("lightning-v", "lightning_v") + settings_kwargs = SmallestTTSSettings( + model=model, + voice=user_config.tts.voice, + language=pipecat_language, + ) + if speed and speed != 1.0: + settings_kwargs.speed = speed + return SmallestTTSService( + api_key=user_config.tts.api_key, + settings=settings_kwargs, + text_filters=[xml_function_tag_filter], + skip_aggregator_types=["recording_router", "recording"], + silence_time_s=1.0, + ) else: raise HTTPException( status_code=400, detail=f"Invalid TTS provider {user_config.tts.provider}" @@ -588,6 +688,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, @@ -597,6 +698,7 @@ def create_llm_service_from_provider( location: str | None = None, credentials: str | None = None, temperature: float | None = None, + bill_to: str | None = None, ): """Create an LLM service from explicit provider/model/api_key. @@ -661,6 +763,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: @@ -678,6 +781,15 @@ def create_llm_service_from_provider( api_key=api_key or "none", settings=SpeachesLLMSettings(model=model), ) + elif provider == ServiceProviders.HUGGINGFACE.value: + base_url = base_url or "https://router.huggingface.co/v1" + _validate_runtime_service_url(base_url, "base_url") + return HuggingFaceLLMService( + api_key=api_key, + base_url=base_url, + bill_to=bill_to, + settings=HuggingFaceLLMSettings(model=model, temperature=0.1), + ) elif provider == ServiceProviders.MINIMAX.value: base_url = base_url or "https://api.minimax.io/v1" _validate_runtime_service_url(base_url, "base_url") @@ -875,7 +987,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 @@ -890,6 +1002,9 @@ def create_llm_service(user_config): kwargs["endpoint"] = user_config.llm.endpoint elif provider == ServiceProviders.SPEACHES.value: kwargs["base_url"] = user_config.llm.base_url + elif provider == ServiceProviders.HUGGINGFACE.value: + kwargs["base_url"] = user_config.llm.base_url + kwargs["bill_to"] = user_config.llm.bill_to elif provider == ServiceProviders.AWS_BEDROCK.value: kwargs["aws_access_key"] = user_config.llm.aws_access_key kwargs["aws_secret_key"] = user_config.llm.aws_secret_key @@ -904,4 +1019,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/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/user_onboarding.py b/api/services/user_onboarding.py new file mode 100644 index 00000000..273e607f --- /dev/null +++ b/api/services/user_onboarding.py @@ -0,0 +1,37 @@ +from loguru import logger +from pydantic import ValidationError + +from api.db import db_client +from api.enums import UserConfigurationKey +from api.schemas.onboarding_state import OnboardingState, OnboardingStateUpdate + + +async def get_onboarding_state(user_id: int) -> OnboardingState: + value = await db_client.get_user_configuration_value( + user_id, UserConfigurationKey.ONBOARDING.value + ) + return _parse_state(value, user_id) + + +async def update_onboarding_state( + user_id: int, update: OnboardingStateUpdate +) -> OnboardingState: + state = update.apply_to(await get_onboarding_state(user_id)) + await db_client.upsert_user_configuration_value( + user_id, + UserConfigurationKey.ONBOARDING.value, + state.model_dump(mode="json", exclude_none=True), + ) + return state + + +def _parse_state(value, user_id: int) -> OnboardingState: + if not value or not isinstance(value, dict): + return OnboardingState() + try: + return OnboardingState.model_validate(value) + except ValidationError as exc: + logger.warning( + f"Invalid onboarding state for user {user_id}: {exc}. Returning defaults." + ) + return OnboardingState() diff --git a/api/services/workflow/dto.py b/api/services/workflow/dto.py index 60aad758..8533de36 100644 --- a/api/services/workflow/dto.py +++ b/api/services/workflow/dto.py @@ -718,6 +718,8 @@ class TriggerNodeData(BaseNodeData): "rsvp": "{{gathered_context.rsvp}}", "duration": "{{cost_info.call_duration_seconds}}", "recording_url": "{{recording_url}}", + "user_recording_url": "{{user_recording_url}}", + "bot_recording_url": "{{bot_recording_url}}", "transcript_url": "{{transcript_url}}", }, }, 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..10b5329d 100644 --- a/api/services/workflow/text_chat_runner.py +++ b/api/services/workflow/text_chat_runner.py @@ -17,7 +17,6 @@ from pipecat.frames.frames import ( LLMContextFrame, LLMFullResponseEndFrame, LLMFullResponseStartFrame, - TextFrame, TTSSpeakFrame, TTSStoppedFrame, ) @@ -32,7 +31,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 ( @@ -168,12 +166,17 @@ class _TaskQueueProxy: class _TextChatCaptureProcessor(FrameProcessor): - def __init__(self, response_window: _ResponseWindowState) -> None: + def __init__( + self, + response_window: _ResponseWindowState, + context: LLMContext, + ) -> None: super().__init__() self.last_activity_at = time.monotonic() self.activity_count = 0 self.events: list[dict[str, Any]] = [] self._response_window = response_window + self._context = context def _touch(self) -> None: self.last_activity_at = time.monotonic() @@ -193,12 +196,14 @@ class _TextChatCaptureProcessor(FrameProcessor): self._touch() if isinstance(frame, TTSSpeakFrame): - text_frame = TextFrame(frame.text) - text_frame.append_to_context = ( + append_to_context = ( frame.append_to_context if frame.append_to_context is not None else True ) - await self.push_frame(text_frame, direction) - await self.push_frame(LLMAssistantPushAggregationFrame(), direction) + text = frame.text.strip() + if text: + self._response_window.outputs.append(text) + if append_to_context: + self._context.add_message({"role": "assistant", "content": text}) return if isinstance(frame, LLMContextFrame) and direction == FrameDirection.UPSTREAM: @@ -410,14 +415,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,19 +447,25 @@ 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) ) base_checkpoint = _resolve_checkpoint_for_pending_turn(session_data, checkpoint) - response_window = _ResponseWindowState() - capture_processor = _TextChatCaptureProcessor(response_window) context = LLMContext() context.set_messages(base_checkpoint["messages"]) + response_window = _ResponseWindowState() + capture_processor = _TextChatCaptureProcessor(response_window, context) node_transition_events = capture_processor.events @@ -466,9 +494,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 +642,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..18ef328d --- /dev/null +++ b/api/services/workflow_run_billing.py @@ -0,0 +1,134 @@ +"""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") + + +def _is_usage_not_ready_error(exc: Exception) -> bool: + response = getattr(exc, "response", None) + if getattr(response, "status_code", None) != 409: + return False + return "usage_not_ready" in (getattr(response, "text", "") or "") + + +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): + logger.warning( + "Workflow run is not completed in report_workflow_run_platform_usage" + ) + 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): + logger.debug( + "Not reporting platform usage since org not using mps billing v2" + ) + 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: + if _is_usage_not_ready_error(e): + # A run can start and receive an MPS correlation id, then fail or end + # before billable STT usage is recorded. MPS returns usage_not_ready + # for that no-platform-fee path, so keep it out of error alerts. + logger.warning( + "Failed to report platform usage for workflow run {}: {}", + workflow_run.id, + e, + ) + else: + 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/run_integrations.py b/api/tasks/run_integrations.py index be11a3ce..d99eaf0e 100644 --- a/api/tasks/run_integrations.py +++ b/api/tasks/run_integrations.py @@ -27,6 +27,7 @@ from api.services.workflow.dto import ( ) from api.services.workflow.qa import run_per_node_qa_analysis from api.utils.credential_auth import build_auth_header +from api.utils.recording_artifacts import get_recording_storage_key from api.utils.template_renderer import render_template @@ -339,6 +340,10 @@ def _build_render_context( Returns: Dict containing all fields available for template rendering """ + extra = workflow_run.extra or {} + user_recording_key = get_recording_storage_key(extra, "user") + bot_recording_key = get_recording_storage_key(extra, "bot") + context = { # Top-level fields "workflow_run_id": workflow_run.id, @@ -353,6 +358,7 @@ def _build_render_context( "cost_info": workflow_run.usage_info or {}, # Annotations (includes QA results) "annotations": workflow_run.annotations or {}, + "extra": extra, } # Add public download URLs if token is available @@ -366,9 +372,17 @@ def _build_render_context( context["transcript_url"] = ( f"{base_url}/transcript" if workflow_run.transcript_url else None ) + context["user_recording_url"] = ( + f"{base_url}/user_recording" if user_recording_key else None + ) + context["bot_recording_url"] = ( + f"{base_url}/bot_recording" if bot_recording_key else None + ) else: context["recording_url"] = workflow_run.recording_url context["transcript_url"] = workflow_run.transcript_url + context["user_recording_url"] = user_recording_key + context["bot_recording_url"] = bot_recording_key return context 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..6943c0d6 --- /dev/null +++ b/api/tasks/workflow_completion.py @@ -0,0 +1,183 @@ +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 + + +def _recording_metadata(storage_key: str, storage_backend: str, track: str) -> dict: + return { + "storage_key": storage_key, + "storage_backend": storage_backend, + "format": "wav", + "track": track, + } + + +async def _upload_temp_file( + workflow_run_id: int, + temp_file_path: str, + storage_key: str, + label: str, +) -> bool: + try: + if not os.path.exists(temp_file_path): + logger.warning(f"{label} temp file not found: {temp_file_path}") + return False + + file_size = os.path.getsize(temp_file_path) + logger.debug(f"{label} file size: {file_size} bytes") + + await storage_fs.aupload_file(temp_file_path, storage_key) + logger.info(f"Successfully uploaded {label}: {storage_key}") + return True + except Exception as e: + logger.error(f"Error uploading {label} for workflow {workflow_run_id}: {e}") + return False + finally: + if os.path.exists(temp_file_path): + try: + os.remove(temp_file_path) + logger.debug(f"Cleaned up temp {label} file: {temp_file_path}") + except Exception as e: + logger.warning(f"Failed to clean up temp {label} file: {e}") + + +async def process_workflow_completion( + _ctx, + workflow_run_id: int, + audio_temp_path: Optional[str] = None, + transcript_temp_path: Optional[str] = None, + user_audio_temp_path: Optional[str] = None, + bot_audio_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 + user_audio_temp_path: Optional path to temp user-track audio file + bot_audio_temp_path: Optional path to temp bot-track audio 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 + recordings_metadata: dict[str, dict] = {} + + if audio_temp_path: + recording_url = f"recordings/{workflow_run_id}.wav" + logger.info( + f"Uploading mixed audio to {storage_backend.name} - workflow_run_id: {workflow_run_id}" + ) + if await _upload_temp_file( + workflow_run_id, audio_temp_path, recording_url, "mixed audio" + ): + recordings_metadata["mixed"] = _recording_metadata( + recording_url, storage_backend.value, "mixed" + ) + await db_client.update_workflow_run( + run_id=workflow_run_id, + recording_url=recording_url, + storage_backend=storage_backend.value, + ) + + if user_audio_temp_path: + user_recording_url = f"recordings/{workflow_run_id}/user.wav" + logger.info( + f"Uploading user audio to {storage_backend.name} - workflow_run_id: {workflow_run_id}" + ) + if await _upload_temp_file( + workflow_run_id, user_audio_temp_path, user_recording_url, "user audio" + ): + recordings_metadata["user"] = _recording_metadata( + user_recording_url, storage_backend.value, "user" + ) + + if bot_audio_temp_path: + bot_recording_url = f"recordings/{workflow_run_id}/bot.wav" + logger.info( + f"Uploading bot audio to {storage_backend.name} - workflow_run_id: {workflow_run_id}" + ) + if await _upload_temp_file( + workflow_run_id, bot_audio_temp_path, bot_recording_url, "bot audio" + ): + recordings_metadata["bot"] = _recording_metadata( + bot_recording_url, storage_backend.value, "bot" + ) + + if recordings_metadata: + await db_client.update_workflow_run( + run_id=workflow_run_id, + storage_backend=storage_backend.value, + extra={"recordings": recordings_metadata}, + ) + + # 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/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..fe155143 --- /dev/null +++ b/api/tests/test_cartesia_tts_service_factory.py @@ -0,0 +1,77 @@ +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" + + +def test_cartesia_tts_configuration_default_language_is_english(): + config = CartesiaTTSConfiguration(api_key="test-key") + + assert config.language == "en" + + +def test_create_cartesia_tts_service_passes_language_to_settings(): + 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, + language="tr", + ) + ) + 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) + + kwargs = mock_service.call_args.kwargs + assert kwargs["settings"].language == "tr" 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_deepgram_flux_service_factory.py b/api/tests/test_deepgram_flux_service_factory.py new file mode 100644 index 00000000..e94dff21 --- /dev/null +++ b/api/tests/test_deepgram_flux_service_factory.py @@ -0,0 +1,70 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from pipecat.services.settings import NOT_GIVEN +from pipecat.transcriptions.language import Language + +from api.services.configuration.registry import ( + DeepgramSTTConfiguration, + ServiceProviders, +) +from api.services.pipecat.audio_config import AudioConfig +from api.services.pipecat.service_factory import create_stt_service + + +def test_deepgram_stt_schema_includes_flux_multilingual_language_options(): + language_schema = DeepgramSTTConfiguration.model_json_schema()["properties"][ + "language" + ] + + assert "flux-general-multi" in language_schema["model_options"] + assert "multi" in language_schema["model_options"]["flux-general-multi"] + assert "es" in language_schema["model_options"]["flux-general-multi"] + + +def test_create_deepgram_flux_multi_uses_flux_service_with_language_hint(): + user_config = SimpleNamespace( + stt=SimpleNamespace( + provider=ServiceProviders.DEEPGRAM.value, + api_key="test-key", + model="flux-general-multi", + language="es", + ) + ) + audio_config = AudioConfig( + transport_in_sample_rate=16000, + transport_out_sample_rate=16000, + ) + + with patch( + "api.services.pipecat.service_factory.DeepgramFluxSTTService" + ) as mock_service: + create_stt_service(user_config, audio_config) + + kwargs = mock_service.call_args.kwargs + assert kwargs["settings"].model == "flux-general-multi" + assert kwargs["settings"].language_hints == [Language.ES] + + +def test_create_deepgram_flux_multi_omits_auto_detect_language_hint(): + user_config = SimpleNamespace( + stt=SimpleNamespace( + provider=ServiceProviders.DEEPGRAM.value, + api_key="test-key", + model="flux-general-multi", + language="multi", + ) + ) + audio_config = AudioConfig( + transport_in_sample_rate=16000, + transport_out_sample_rate=16000, + ) + + with patch( + "api.services.pipecat.service_factory.DeepgramFluxSTTService" + ) as mock_service: + create_stt_service(user_config, audio_config) + + kwargs = mock_service.call_args.kwargs + assert kwargs["settings"].model == "flux-general-multi" + assert kwargs["settings"].language_hints is NOT_GIVEN diff --git a/api/tests/test_dograh_managed_correlation.py b/api/tests/test_dograh_managed_correlation.py new file mode 100644 index 00000000..e182c495 --- /dev/null +++ b/api/tests/test_dograh_managed_correlation.py @@ -0,0 +1,146 @@ +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 + + +class _IterableFakeWebSocket(_FakeWebSocket): + def __init__(self, incoming_messages: list[dict]): + super().__init__() + self.incoming_messages = [json.dumps(message) for message in incoming_messages] + + def __aiter__(self): + return self + + async def __anext__(self) -> str: + if not self.incoming_messages: + raise StopAsyncIteration + return self.incoming_messages.pop(0) + + +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" + + +@pytest.mark.asyncio +async def test_dograh_tts_final_for_missing_context_is_ignored(): + service = DograhTTSService(api_key="mps-secret") + service._websocket = _IterableFakeWebSocket( + [{"type": "final", "context_id": "ctx-already-removed"}] + ) + service._remote_initialized_context_ids.add("ctx-already-removed") + + remove_calls = [] + + async def fake_remove_audio_context(context_id: str): + remove_calls.append(context_id) + + service.audio_context_available = lambda context_id: False + service.remove_audio_context = fake_remove_audio_context + + await service._receive_messages() + + assert remove_calls == [] + assert "ctx-already-removed" not in service._remote_initialized_context_ids 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_huggingface_stt_service_factory.py b/api/tests/test_huggingface_stt_service_factory.py new file mode 100644 index 00000000..e7516c8a --- /dev/null +++ b/api/tests/test_huggingface_stt_service_factory.py @@ -0,0 +1,131 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from api.services.configuration.check_validity import UserConfigurationValidator +from api.services.configuration.registry import ( + REGISTRY, + HuggingFaceLLMConfiguration, + HuggingFaceSTTConfiguration, + ServiceProviders, + ServiceType, +) +from api.services.pipecat.service_factory import ( + create_llm_service, + create_stt_service, +) + + +def test_huggingface_stt_configuration_defaults_and_registry(): + config = HuggingFaceSTTConfiguration(api_key="hf_test") + + assert config.provider == ServiceProviders.HUGGINGFACE + assert config.model == "openai/whisper-large-v3-turbo" + assert config.base_url == "https://router.huggingface.co/hf-inference" + assert config.return_timestamps is False + assert ( + REGISTRY[ServiceType.STT][ServiceProviders.HUGGINGFACE] + is HuggingFaceSTTConfiguration + ) + + +def test_huggingface_llm_configuration_defaults_and_registry(): + config = HuggingFaceLLMConfiguration(api_key="hf_test") + + assert config.provider == ServiceProviders.HUGGINGFACE + assert config.model == "openai/gpt-oss-120b:cerebras" + assert config.base_url == "https://router.huggingface.co/v1" + assert ( + REGISTRY[ServiceType.LLM][ServiceProviders.HUGGINGFACE] + is HuggingFaceLLMConfiguration + ) + + +def test_create_huggingface_llm_service_uses_openai_compatible_router(): + user_config = SimpleNamespace( + llm=SimpleNamespace( + provider=ServiceProviders.HUGGINGFACE.value, + api_key="hf_test", + model="deepseek-ai/DeepSeek-R1:fastest", + base_url="https://router.huggingface.co/v1", + bill_to="demo-org", + ) + ) + + with patch( + "api.services.pipecat.service_factory.HuggingFaceLLMService" + ) as mock_service: + create_llm_service(user_config) + + assert mock_service.call_count == 1 + kwargs = mock_service.call_args.kwargs + assert kwargs["api_key"] == "hf_test" + assert kwargs["base_url"] == "https://router.huggingface.co/v1" + assert kwargs["bill_to"] == "demo-org" + assert kwargs["settings"].model == "deepseek-ai/DeepSeek-R1:fastest" + assert kwargs["settings"].temperature == 0.1 + + +def test_create_huggingface_stt_service_uses_hosted_defaults(): + user_config = SimpleNamespace( + stt=SimpleNamespace( + provider=ServiceProviders.HUGGINGFACE.value, + api_key="hf_test", + model="openai/whisper-large-v3-turbo", + base_url="https://router.huggingface.co/hf-inference", + bill_to="demo-org", + return_timestamps=True, + ) + ) + audio_config = SimpleNamespace(transport_in_sample_rate=16000) + + with patch( + "api.services.pipecat.service_factory.HuggingFaceSTTService" + ) as mock_service: + create_stt_service(user_config, audio_config) + + assert mock_service.call_count == 1 + kwargs = mock_service.call_args.kwargs + assert kwargs["api_key"] == "hf_test" + assert kwargs["base_url"] == "https://router.huggingface.co/hf-inference" + assert kwargs["bill_to"] == "demo-org" + assert kwargs["sample_rate"] == 16000 + assert kwargs["settings"].model == "openai/whisper-large-v3-turbo" + assert kwargs["settings"].return_timestamps is True + + +def test_validator_accepts_huggingface_stt_token_format(): + validator = UserConfigurationValidator() + + assert ( + validator._validate_service( + HuggingFaceSTTConfiguration(api_key="hf_test"), + "stt", + ) + == [] + ) + assert ( + validator._validate_service( + HuggingFaceLLMConfiguration(api_key="hf_test"), + "llm", + ) + == [] + ) + + +def test_validator_rejects_non_huggingface_token_format(): + validator = UserConfigurationValidator() + + errors = validator._validate_service( + HuggingFaceSTTConfiguration(api_key="not-hf-token"), + "stt", + ) + + assert errors == [ + { + "model": "stt", + "message": ( + "Invalid Hugging Face API token format. Use a token that starts with " + "'hf_' and has Inference Providers permission." + ), + } + ] 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_onboarding_state.py b/api/tests/test_onboarding_state.py new file mode 100644 index 00000000..cb2f082c --- /dev/null +++ b/api/tests/test_onboarding_state.py @@ -0,0 +1,131 @@ +from datetime import UTC, datetime +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.onboarding_state import OnboardingState, OnboardingStateUpdate +from api.services.auth.depends import get_user + + +def _make_test_app(): + app = FastAPI() + app.include_router(router) + + mock_user = MagicMock() + mock_user.id = 1 + mock_user.is_superuser = False + mock_user.selected_organization_id = None + + app.dependency_overrides[get_user] = lambda: mock_user + return app + + +class TestOnboardingStateUpdateMerge: + def test_lists_union_without_duplicates(self): + state = OnboardingState( + seen_tooltips=["web_call"], completed_actions=["web_call_started"] + ) + update = OnboardingStateUpdate( + seen_tooltips=["web_call", "customize_workflow"], + completed_actions=["welcome_form_completed"], + ) + + merged = update.apply_to(state) + + assert merged.seen_tooltips == ["web_call", "customize_workflow"] + assert merged.completed_actions == [ + "web_call_started", + "welcome_form_completed", + ] + + def test_omitted_fields_preserve_existing_state(self): + completed_at = datetime(2026, 6, 12, tzinfo=UTC) + state = OnboardingState( + completed_at=completed_at, skipped=True, seen_tooltips=["web_call"] + ) + + merged = OnboardingStateUpdate().apply_to(state) + + assert merged.completed_at == completed_at + assert merged.skipped is True + assert merged.seen_tooltips == ["web_call"] + + def test_scalars_overwrite_when_supplied(self): + state = OnboardingState() + completed_at = datetime(2026, 6, 12, tzinfo=UTC) + + merged = OnboardingStateUpdate( + completed_at=completed_at, skipped=True + ).apply_to(state) + + assert merged.completed_at == completed_at + assert merged.skipped is True + + +class TestOnboardingStateRoutes: + def test_get_returns_defaults_when_no_row(self): + app = _make_test_app() + client = TestClient(app) + + with patch( + "api.services.user_onboarding.db_client.get_user_configuration_value", + new=AsyncMock(return_value=None), + ): + response = client.get("/user/onboarding-state") + + assert response.status_code == 200 + body = response.json() + assert body["completed_at"] is None + assert body["skipped"] is False + assert body["seen_tooltips"] == [] + assert body["completed_actions"] == [] + + def test_get_returns_defaults_on_invalid_stored_value(self): + app = _make_test_app() + client = TestClient(app) + + with patch( + "api.services.user_onboarding.db_client.get_user_configuration_value", + new=AsyncMock(return_value={"skipped": "not-a-bool"}), + ): + response = client.get("/user/onboarding-state") + + assert response.status_code == 200 + assert response.json()["skipped"] is False + + def test_put_merges_into_stored_state_and_persists(self): + app = _make_test_app() + client = TestClient(app) + + existing = {"seen_tooltips": ["web_call"]} + upsert = AsyncMock(side_effect=lambda user_id, key, value: value) + with ( + patch( + "api.services.user_onboarding.db_client.get_user_configuration_value", + new=AsyncMock(return_value=existing), + ), + patch( + "api.services.user_onboarding.db_client.upsert_user_configuration_value", + new=upsert, + ), + ): + response = client.put( + "/user/onboarding-state", + json={ + "completed_at": "2026-06-12T00:00:00Z", + "seen_tooltips": ["customize_workflow"], + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["seen_tooltips"] == ["web_call", "customize_workflow"] + assert body["completed_at"] is not None + + upsert.assert_awaited_once() + user_id, key, stored = upsert.await_args.args + assert user_id == 1 + assert key == "ONBOARDING" + assert stored["seen_tooltips"] == ["web_call", "customize_workflow"] diff --git a/api/tests/test_openai_tts_service_factory.py b/api/tests/test_openai_tts_service_factory.py new file mode 100644 index 00000000..cb44f4b1 --- /dev/null +++ b/api/tests/test_openai_tts_service_factory.py @@ -0,0 +1,31 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from pipecat.services.openai._constants import OPENAI_SAMPLE_RATE + +from api.services.configuration.registry import ServiceProviders +from api.services.pipecat.service_factory import create_tts_service + + +def test_create_openai_tts_service_uses_openai_pcm_sample_rate(): + user_config = SimpleNamespace( + tts=SimpleNamespace( + provider=ServiceProviders.OPENAI.value, + api_key="test-key", + model="gpt-4o-mini-tts", + voice="alloy", + base_url=None, + ) + ) + audio_config = SimpleNamespace( + transport_out_sample_rate=16000, + transport_in_sample_rate=16000, + ) + + with patch("api.services.pipecat.service_factory.OpenAITTSService") as mock_service: + create_tts_service(user_config, audio_config) + + assert mock_service.call_count == 1 + kwargs = mock_service.call_args.kwargs + assert kwargs["sample_rate"] == OPENAI_SAMPLE_RATE + assert kwargs["settings"].model == "gpt-4o-mini-tts" 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_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_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_s3_signed_url.py b/api/tests/test_s3_signed_url.py new file mode 100644 index 00000000..95156dcb --- /dev/null +++ b/api/tests/test_s3_signed_url.py @@ -0,0 +1,30 @@ +from api.routes.s3_signed_url import ( + _extract_legacy_workflow_run_id, + _extract_org_id_from_key, +) + + +def test_split_recording_keys_are_workflow_run_artifacts_not_org_keys(): + assert _extract_legacy_workflow_run_id("recordings/1855/user.wav") == 1855 + assert _extract_legacy_workflow_run_id("recordings/1855/bot.wav") == 1855 + + assert _extract_org_id_from_key("recordings/1855/user.wav") is None + assert _extract_org_id_from_key("recordings/1855/bot.wav") is None + + +def test_legacy_recording_keys_do_not_fall_through_to_org_scoped_auth(): + assert _extract_legacy_workflow_run_id("recordings/1855.wav") == 1855 + assert _extract_legacy_workflow_run_id("recordings/1855/other.wav") is None + + assert _extract_org_id_from_key("recordings/1855.wav") is None + assert _extract_org_id_from_key("recordings/1855/other.wav") is None + + +def test_known_org_scoped_keys_extract_org_id(): + assert _extract_org_id_from_key("campaigns/42/source.csv") == 42 + assert _extract_org_id_from_key("knowledge_base/42/document/file.pdf") == 42 + assert _extract_legacy_workflow_run_id("campaigns/42/source.csv") is None + + +def test_unknown_numeric_prefix_is_not_treated_as_org_scoped(): + assert _extract_org_id_from_key("unknown/42/file.wav") is None diff --git a/api/tests/test_sarvam_service_factory.py b/api/tests/test_sarvam_service_factory.py index 3040769f..38b4d807 100644 --- a/api/tests/test_sarvam_service_factory.py +++ b/api/tests/test_sarvam_service_factory.py @@ -7,6 +7,7 @@ from pipecat.transcriptions.language import Language from api.services.configuration.registry import ( SarvamLLMConfiguration, + SarvamTTSConfiguration, ServiceProviders, ) from api.services.pipecat.audio_config import AudioConfig @@ -14,6 +15,7 @@ from api.services.pipecat.service_factory import ( create_llm_service, create_llm_service_from_provider, create_stt_service, + create_tts_service, ) @@ -112,3 +114,94 @@ class TestSarvamSTTServiceFactory: kwargs = mock_service.call_args.kwargs assert kwargs["settings"].language == expected_language + + +class TestSarvamTTSServiceFactory: + def test_sarvam_tts_configuration_defaults(self): + config = SarvamTTSConfiguration(api_key="test-key") + + assert config.provider == ServiceProviders.SARVAM + assert config.model == "bulbul:v2" + assert config.voice == "anushka" + assert config.language == "hi-IN" + assert config.speed == 1.0 + + def test_sarvam_tts_voice_schema_allows_custom_model_specific_options(self): + voice_schema = SarvamTTSConfiguration.model_json_schema()["properties"]["voice"] + + assert voice_schema["allow_custom_input"] is True + assert "bulbul:v2" in voice_schema["model_options"] + assert "bulbul:v3" in voice_schema["model_options"] + + def test_create_sarvam_tts_service_maps_speed_to_pace(self): + user_config = SimpleNamespace( + tts=SimpleNamespace( + provider=ServiceProviders.SARVAM.value, + api_key="test-key", + model="bulbul:v2", + voice="anushka", + language="hi-IN", + speed=1.25, + ) + ) + audio_config = AudioConfig( + transport_in_sample_rate=16000, transport_out_sample_rate=16000 + ) + + with patch( + "api.services.pipecat.service_factory.SarvamTTSService" + ) as mock_service: + create_tts_service(user_config, audio_config) + + kwargs = mock_service.call_args.kwargs + assert kwargs["api_key"] == "test-key" + assert kwargs["settings"].model == "bulbul:v2" + assert kwargs["settings"].voice == "anushka" + assert kwargs["settings"].language == Language.HI + assert kwargs["settings"].pace == 1.25 + + def test_create_sarvam_tts_service_normalizes_custom_voice_id(self): + user_config = SimpleNamespace( + tts=SimpleNamespace( + provider=ServiceProviders.SARVAM.value, + api_key="test-key", + model="bulbul:v2", + voice=" Rehan ", + language="hi-IN", + speed=1.0, + ) + ) + audio_config = AudioConfig( + transport_in_sample_rate=16000, transport_out_sample_rate=16000 + ) + + with patch( + "api.services.pipecat.service_factory.SarvamTTSService" + ) as mock_service: + create_tts_service(user_config, audio_config) + + kwargs = mock_service.call_args.kwargs + assert kwargs["settings"].voice == "rehan" + + def test_create_sarvam_tts_service_defaults_blank_voice_id(self): + user_config = SimpleNamespace( + tts=SimpleNamespace( + provider=ServiceProviders.SARVAM.value, + api_key="test-key", + model="bulbul:v2", + voice=" ", + language="hi-IN", + speed=1.0, + ) + ) + audio_config = AudioConfig( + transport_in_sample_rate=16000, transport_out_sample_rate=16000 + ) + + with patch( + "api.services.pipecat.service_factory.SarvamTTSService" + ) as mock_service: + create_tts_service(user_config, audio_config) + + kwargs = mock_service.call_args.kwargs + assert kwargs["settings"].voice == "anushka" diff --git a/api/tests/test_smallest_service_factory.py b/api/tests/test_smallest_service_factory.py new file mode 100644 index 00000000..521fd2a8 --- /dev/null +++ b/api/tests/test_smallest_service_factory.py @@ -0,0 +1,80 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from api.services.configuration.check_validity import UserConfigurationValidator +from api.services.configuration.registry import ( + REGISTRY, + ServiceProviders, + ServiceType, + SmallestAISTTConfiguration, + SmallestAITTSConfiguration, +) +from api.services.pipecat.service_factory import create_tts_service + + +def test_smallest_tts_configuration_defaults_and_registry(): + config = SmallestAITTSConfiguration(api_key="test-key") + + assert config.provider == ServiceProviders.SMALLEST + assert config.model == "lightning_v3.1" + assert config.voice == "sophia" + assert config.language == "en" + assert config.speed == 1.0 + assert ( + REGISTRY[ServiceType.TTS][ServiceProviders.SMALLEST] + is SmallestAITTSConfiguration + ) + + +def test_smallest_stt_configuration_defaults_and_registry(): + config = SmallestAISTTConfiguration(api_key="test-key") + + assert config.provider == ServiceProviders.SMALLEST + assert config.model == "pulse" + assert config.language == "en" + assert ( + REGISTRY[ServiceType.STT][ServiceProviders.SMALLEST] + is SmallestAISTTConfiguration + ) + + +def test_validator_accepts_smallest_services(): + validator = UserConfigurationValidator() + + assert ( + validator._validate_service( + SmallestAITTSConfiguration(api_key="test-key"), + "tts", + ) + == [] + ) + assert ( + validator._validate_service( + SmallestAISTTConfiguration(api_key="test-key"), + "stt", + ) + == [] + ) + + +def test_create_smallest_tts_service_normalizes_hyphenated_model_values(): + user_config = SimpleNamespace( + tts=SimpleNamespace( + provider=ServiceProviders.SMALLEST.value, + api_key="test-key", + model="lightning-v3.1", + voice="sophia", + language="en", + speed=1.0, + ) + ) + audio_config = SimpleNamespace(transport_in_sample_rate=16000) + + with patch( + "api.services.pipecat.service_factory.SmallestTTSService" + ) as mock_service: + create_tts_service(user_config, audio_config) + + assert mock_service.call_count == 1 + kwargs = mock_service.call_args.kwargs + assert kwargs["settings"].model == "lightning_v3.1" 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_list_route.py b/api/tests/test_workflow_list_route.py index 0f1864b4..dcc2ddd5 100644 --- a/api/tests/test_workflow_list_route.py +++ b/api/tests/test_workflow_list_route.py @@ -2,6 +2,7 @@ from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, patch +import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -50,3 +51,99 @@ def test_workflow_fetch_list_includes_workflow_uuid(): "workflow_uuid": workflow.workflow_uuid, } ] + + +def test_workflow_fetch_invalid_status_returns_422_without_db_query(): + """A status outside the workflow_status enum (e.g. 'published') must fail + as a clean 422 instead of a 500 from the Postgres enum cast.""" + app = _make_test_app() + client = TestClient(app) + + with patch("api.routes.workflow.db_client") as mock_db: + mock_db.get_all_workflows_for_listing = AsyncMock() + mock_db.get_workflow_run_counts = AsyncMock() + + response = client.get("/workflow/fetch?status=published") + + assert response.status_code == 422 + assert "published" in response.json()["detail"] + # The invalid value must never reach the database layer. + mock_db.get_all_workflows_for_listing.assert_not_called() + + +def test_workflow_fetch_valid_single_status_passes_through(): + app = _make_test_app() + client = TestClient(app) + + with patch("api.routes.workflow.db_client") as mock_db: + mock_db.get_all_workflows_for_listing = AsyncMock(return_value=[]) + mock_db.get_workflow_run_counts = AsyncMock(return_value={}) + + response = client.get("/workflow/fetch?status=active") + + assert response.status_code == 200 + mock_db.get_all_workflows_for_listing.assert_awaited_once_with( + organization_id=11, status="active" + ) + + +def test_workflow_fetch_comma_separated_status_queries_each_value(): + app = _make_test_app() + client = TestClient(app) + + with patch("api.routes.workflow.db_client") as mock_db: + mock_db.get_all_workflows_for_listing = AsyncMock(return_value=[]) + mock_db.get_workflow_run_counts = AsyncMock(return_value={}) + + response = client.get("/workflow/fetch?status=active,archived") + + assert response.status_code == 200 + assert mock_db.get_all_workflows_for_listing.await_count == 2 + statuses = { + call.kwargs["status"] + for call in mock_db.get_all_workflows_for_listing.await_args_list + } + assert statuses == {"active", "archived"} + + +def test_workflow_fetch_mixed_valid_and_invalid_status_returns_422(): + app = _make_test_app() + client = TestClient(app) + + with patch("api.routes.workflow.db_client") as mock_db: + mock_db.get_all_workflows_for_listing = AsyncMock() + mock_db.get_workflow_run_counts = AsyncMock() + + response = client.get("/workflow/fetch?status=active,published") + + assert response.status_code == 422 + mock_db.get_all_workflows_for_listing.assert_not_called() + + +@pytest.mark.parametrize("status", [" ", ",", "active,,archived"]) +def test_workflow_fetch_blank_status_token_returns_422_without_db_query(status: str): + app = _make_test_app() + client = TestClient(app) + + with patch("api.routes.workflow.db_client") as mock_db: + mock_db.get_all_workflows_for_listing = AsyncMock() + mock_db.get_workflow_run_counts = AsyncMock() + + response = client.get("/workflow/fetch", params={"status": status}) + + assert response.status_code == 422 + assert "" in response.json()["detail"] + mock_db.get_all_workflows_for_listing.assert_not_called() + + +def test_workflow_summary_blank_status_token_returns_422_without_db_query(): + app = _make_test_app() + client = TestClient(app) + + with patch("api.routes.workflow.db_client") as mock_db: + mock_db.get_all_workflows = AsyncMock() + + response = client.get("/workflow/summary", params={"status": ","}) + + assert response.status_code == 422 + mock_db.get_all_workflows.assert_not_called() diff --git a/api/tests/test_workflow_run_billing.py b/api/tests/test_workflow_run_billing.py new file mode 100644 index 00000000..1dbe1828 --- /dev/null +++ b/api/tests/test_workflow_run_billing.py @@ -0,0 +1,223 @@ +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 ( + _is_usage_not_ready_error, + 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), + ), + ) + + +def test_is_usage_not_ready_error_detects_mps_409(): + exc = Exception("Failed to report platform usage") + exc.response = SimpleNamespace( + status_code=409, + text='{"detail":"usage_not_ready"}', + ) + + assert _is_usage_not_ready_error(exc) is True + + +@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/api/utils/recording_artifacts.py b/api/utils/recording_artifacts.py new file mode 100644 index 00000000..42044cf9 --- /dev/null +++ b/api/utils/recording_artifacts.py @@ -0,0 +1,35 @@ +from typing import Literal + +RecordingTrack = Literal["mixed", "user", "bot"] + + +def get_recording_storage_key(extra: dict | None, track: RecordingTrack) -> str | None: + recordings = (extra or {}).get("recordings", {}) + if not isinstance(recordings, dict): + return None + + artifact = recordings.get(track) + if isinstance(artifact, str): + return artifact + if isinstance(artifact, dict): + storage_key = artifact.get("storage_key") + return storage_key if isinstance(storage_key, str) else None + return None + + +def get_recording_storage_backend( + extra: dict | None, track: RecordingTrack +) -> str | None: + recordings = (extra or {}).get("recordings", {}) + if not isinstance(recordings, dict): + return None + + artifact = recordings.get(track) + if isinstance(artifact, dict): + storage_backend = artifact.get("storage_backend") + return storage_backend if isinstance(storage_backend, str) else None + return None + + +def has_recording_track(extra: dict | None, track: RecordingTrack) -> bool: + return bool(get_recording_storage_key(extra, track)) diff --git a/docker-compose.yaml b/docker-compose.yaml index 0bd27178..b645d295 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -164,6 +164,17 @@ services: # from this value and nginx load-balances across them with least_conn. FASTAPI_WORKERS: "${FASTAPI_WORKERS:-1}" + # Trust X-Forwarded-* headers from any peer so uvicorn honors nginx's + # `X-Forwarded-Proto: https`. nginx runs as its own container and reaches + # uvicorn from a Docker-network IP (not loopback), but uvicorn trusts only + # 127.0.0.1 by default — so without this it ignores the header, request.url + # comes back as http://…, and inbound webhook signature checks fail for + # every provider at once (telephony providers sign the public https URL, so + # the recomputed signature won't match). "*" trusts all peers; the api port + # (8000) is published, so firewall/harden it at the host — or narrow this + # to your Docker bridge subnet — if that exposure matters to you. + FORWARDED_ALLOW_IPS: "*" + # Langfuse — credentials can be set here or per-organization via the UI # at /settings. Tracing is automatically active when credentials are # available; uncomment to set defaults for all organizations. @@ -211,9 +222,13 @@ services: ui: image: ${REGISTRY:-dograhai}/dograh-ui:latest environment: + # Bind the Next.js standalone server to all interfaces + HOSTNAME: "0.0.0.0" + # Server-side URL (SSR, internal Docker network) BACKEND_URL: "${BACKEND_URL:-http://api:8000}" NODE_ENV: "oss" + # Flag to enable/ disable posthog ENABLE_TELEMETRY: "${ENABLE_TELEMETRY:-true}" @@ -229,7 +244,7 @@ services: test: [ "CMD-SHELL", - "wget --no-verbose --tries=1 --spider http://localhost:3010 || exit 1", + "wget --no-verbose --tries=1 --spider http://127.0.0.1:3010 || exit 1", ] interval: 30s timeout: 10s diff --git a/docs/api-reference/openapi.json b/docs/api-reference/openapi.json index 1eb198e3..93544bd6 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/onboarding-state":{"get":{"tags":["main"],"summary":"Get User Onboarding State","operationId":"get_user_onboarding_state_api_v1_user_onboarding_state_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/OnboardingState"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"put":{"tags":["main"],"summary":"Update User Onboarding State","operationId":"update_user_onboarding_state_api_v1_user_onboarding_state_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/OnboardingStateUpdate"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OnboardingState"}}}},"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/context":{"get":{"tags":["main","organizations"],"summary":"Get Current Organization Context","description":"Return organization-scoped configuration signals owned by Dograh.","operationId":"get_current_organization_context_api_v1_organizations_context_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/OrganizationContextResponse"}}}},"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* Known org-scoped keys (for example ``campaigns/{org_id}/...`` and\n ``knowledge_base/{org_id}/...``) are authorized by matching the org_id\n against the requesting user's 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 reporting-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/billing/credits":{"get":{"tags":["main"],"summary":"Get Billing Credits","description":"Return legacy MPS credits or paginated v2 billing ledger details for the org.","operationId":"get_billing_credits_api_v1_organizations_billing_credits_get","parameters":[{"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":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MPSBillingCreditsResponse"}}}},"404":{"description":"Not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/v1/organizations/usage/mps-credits/purchase-url":{"post":{"tags":["main"],"summary":"Create Mps Credit Purchase Url","description":"Create a checkout URL for organizations using Dograh-managed MPS v2.","operationId":"create_mps_credit_purchase_url_api_v1_organizations_usage_mps_credits_purchase_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"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MPSCreditPurchaseUrlResponse"}}}},"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\", \"transcript\",\n \"user_recording\", or \"bot_recording\"\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 400: If artifact type is unsupported\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":{"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/HuggingFaceLLMConfiguration"},{"$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","huggingface":"#/components/schemas/HuggingFaceLLMConfiguration","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"},{"$ref":"#/components/schemas/SmallestAITTSConfiguration"}],"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","smallest":"#/components/schemas/SmallestAITTSConfiguration","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/HuggingFaceSTTConfiguration"},{"$ref":"#/components/schemas/AssemblyAISTTConfiguration"},{"$ref":"#/components/schemas/GladiaSTTConfiguration"},{"$ref":"#/components/schemas/AzureSpeechSTTConfiguration"},{"$ref":"#/components/schemas/SmallestAISTTConfiguration"}],"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","huggingface":"#/components/schemas/HuggingFaceSTTConfiguration","openai":"#/components/schemas/OpenAISTTConfiguration","sarvam":"#/components/schemas/SarvamSTTConfiguration","smallest":"#/components/schemas/SmallestAISTTConfiguration","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/HuggingFaceLLMConfiguration"},{"$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","huggingface":"#/components/schemas/HuggingFaceLLMConfiguration","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.5","examples":["sonic-3.5","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},"language":{"type":"string","title":"Language","description":"Cartesia language code for TTS synthesis (e.g. 'en', 'tr', 'fr', 'de').","default":"en","allow_custom_input":true}},"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"},"total_duration_seconds":{"type":"integer","title":"Total Duration Seconds"},"used_amount_usd":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Used 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","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 Nova-3 auto-detect and omits language hints for Flux multilingual 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"],"model_options":{"flux-general-en":["en"],"flux-general-multi":["multi","de","en","es","fr","hi","it","ja","nl","pt","ru"],"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","allow_custom_input":true},"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"},"stack_project_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Stack Project Id"},"stack_publishable_client_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Stack Publishable Client Key"}},"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."},"HuggingFaceLLMConfiguration":{"properties":{"provider":{"type":"string","const":"huggingface","title":"Provider","default":"huggingface"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Hugging Face chat-completion model identifier, optionally with provider suffix.","default":"openai/gpt-oss-120b:cerebras","examples":["openai/gpt-oss-120b:cerebras","deepseek-ai/DeepSeek-R1:fastest","Qwen/Qwen3-Coder-480B-A35B-Instruct:fastest"],"allow_custom_input":true},"base_url":{"type":"string","title":"Base Url","description":"Hugging Face OpenAI-compatible chat-completions router base URL.","default":"https://router.huggingface.co/v1"},"bill_to":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bill To","description":"Optional Hugging Face organization or user to bill using X-HF-Bill-To."}},"type":"object","required":["api_key"],"title":"Hugging Face","description":"Hosted Hugging Face Inference Providers API for usage-based inference.","provider_docs_url":"https://huggingface.co/docs/inference-providers/en/index"},"HuggingFaceSTTConfiguration":{"properties":{"provider":{"type":"string","const":"huggingface","title":"Provider","default":"huggingface"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Hugging Face ASR model identifier served through Inference Providers.","default":"openai/whisper-large-v3-turbo","examples":["openai/whisper-large-v3-turbo","openai/whisper-large-v3"],"allow_custom_input":true},"base_url":{"type":"string","title":"Base Url","description":"Hugging Face Inference Providers router base URL.","default":"https://router.huggingface.co/hf-inference"},"bill_to":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bill To","description":"Optional Hugging Face organization or user to bill using X-HF-Bill-To."},"return_timestamps":{"type":"boolean","title":"Return Timestamps","description":"Request timestamp chunks when supported by the selected provider/model.","default":false}},"type":"object","required":["api_key"],"title":"Hugging Face","description":"Hosted Hugging Face Inference Providers API for usage-based inference.","provider_docs_url":"https://huggingface.co/docs/inference-providers/en/index"},"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"},"MPSBillingAccountResponse":{"properties":{"id":{"type":"integer","title":"Id"},"organization_id":{"type":"integer","title":"Organization Id"},"billing_mode":{"type":"string","title":"Billing Mode"},"cached_balance_credits":{"type":"number","title":"Cached Balance Credits"},"currency":{"type":"string","title":"Currency"}},"type":"object","required":["id","organization_id","billing_mode","cached_balance_credits","currency"],"title":"MPSBillingAccountResponse"},"MPSBillingCreditsResponse":{"properties":{"billing_version":{"type":"string","enum":["legacy","v2"],"title":"Billing Version"},"total_credits_used":{"type":"number","title":"Total Credits Used","default":0.0},"remaining_credits":{"type":"number","title":"Remaining Credits","default":0.0},"total_quota":{"type":"number","title":"Total Quota","default":0.0},"account":{"anyOf":[{"$ref":"#/components/schemas/MPSBillingAccountResponse"},{"type":"null"}]},"ledger_entries":{"items":{"$ref":"#/components/schemas/MPSCreditLedgerEntryResponse"},"type":"array","title":"Ledger Entries"},"total_count":{"type":"integer","title":"Total Count","default":0},"page":{"type":"integer","title":"Page","default":1},"limit":{"type":"integer","title":"Limit","default":50},"total_pages":{"type":"integer","title":"Total Pages","default":0}},"type":"object","required":["billing_version"],"title":"MPSBillingCreditsResponse"},"MPSCreditLedgerEntryResponse":{"properties":{"id":{"type":"integer","title":"Id"},"entry_type":{"type":"string","title":"Entry Type"},"origin":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Origin"},"credits_delta":{"type":"number","title":"Credits Delta"},"balance_after":{"type":"number","title":"Balance After"},"amount_minor":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Amount Minor"},"amount_currency":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Amount Currency"},"payment_order_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Payment Order Id"},"metric_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Metric Code"},"correlation_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Correlation Id"},"aggregation_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Aggregation Key"},"usage_event_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Usage Event Id"},"workflow_run_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Workflow Run Id"},"workflow_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Workflow Id"},"billable_quantity":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Billable Quantity"},"quantity_unit":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Quantity Unit"},"metadata":{"additionalProperties":true,"type":"object","title":"Metadata"},"created_at":{"type":"string","title":"Created At"}},"type":"object","required":["id","entry_type","credits_delta","balance_after","created_at"],"title":"MPSCreditLedgerEntryResponse"},"MPSCreditPurchaseUrlResponse":{"properties":{"checkout_url":{"type":"string","title":"Checkout Url"}},"type":"object","required":["checkout_url"],"title":"MPSCreditPurchaseUrlResponse"},"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"},"OnboardingState":{"properties":{"completed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Completed At"},"skipped":{"type":"boolean","title":"Skipped","default":false},"seen_tooltips":{"items":{"type":"string"},"type":"array","title":"Seen Tooltips"},"completed_actions":{"items":{"type":"string"},"type":"array","title":"Completed Actions"}},"type":"object","title":"OnboardingState","description":"Per-user onboarding state, stored under UserConfigurationKey.ONBOARDING.\n\nServer-authoritative replacement for the browser-localStorage onboarding\nstore, so the post-signup gate and one-time tooltips hold across devices."},"OnboardingStateUpdate":{"properties":{"completed_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Completed At"},"skipped":{"anyOf":[{"type":"boolean"},{"type":"null"}],"title":"Skipped"},"seen_tooltips":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Seen Tooltips"},"completed_actions":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Completed Actions"}},"type":"object","title":"OnboardingStateUpdate","description":"Partial update merged into the stored state.\n\nScalars overwrite when supplied; list entries are unioned into the stored\nlists, so concurrent updates (e.g. two tabs marking different tooltips)\ndon't drop each other's items."},"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"},"OrganizationContextResponse":{"properties":{"organization_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Organization Id"},"organization_provider_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Organization Provider Id"},"model_services":{"$ref":"#/components/schemas/OrganizationModelServicesContext"}},"type":"object","required":["model_services"],"title":"OrganizationContextResponse"},"OrganizationModelServicesContext":{"properties":{"config_source":{"type":"string","enum":["organization_v2","legacy_user_v1","empty"],"title":"Config Source"},"has_model_configuration_v2":{"type":"boolean","title":"Has Model Configuration V2"},"managed_service_version":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Managed Service Version"},"uses_managed_service_v2":{"type":"boolean","title":"Uses Managed Service V2"}},"type":"object","required":["config_source","has_model_configuration_v2","uses_managed_service_v2"],"title":"OrganizationModelServicesContext"},"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 or custom voice ID.","default":"anushka","examples":["anushka","manisha","vidya","arya","abhilash","karun","hitesh"],"allow_custom_input":true,"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"]},"speed":{"type":"number","maximum":2.0,"minimum":0.5,"title":"Speed","description":"Speech speed multiplier.","default":1.0}},"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"},"SmallestAISTTConfiguration":{"properties":{"provider":{"type":"string","const":"smallest","title":"Provider","default":"smallest"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Smallest AI STT model. Supports 38 languages with real-time streaming.","default":"pulse","examples":["pulse"]},"language":{"type":"string","title":"Language","description":"ISO 639-1 language code for transcription.","default":"en","examples":["en","hi","fr","de","es","it","nl","pl","ru","pt","bn","gu","kn","ml","mr","ta","te","pa","or","bg","cs","da","et","fi","hu","lt","lv","mt","ro","sk","sv","uk"],"allow_custom_input":true}},"type":"object","required":["api_key"],"title":"Smallest AI","description":"Smallest AI ultralow-latency TTS (Waves) and STT (Pulse) APIs.","provider_docs_url":"https://smallest.ai/docs"},"SmallestAITTSConfiguration":{"properties":{"provider":{"type":"string","const":"smallest","title":"Provider","default":"smallest"},"api_key":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}],"title":"Api Key"},"model":{"type":"string","title":"Model","description":"Smallest AI TTS model. lightning_v3.1_pro is the premium pool (American, British, Indian accents); lightning_v3.1 is the standard pool with 217 voices across 12 languages.","default":"lightning_v3.1","examples":["lightning_v3.1","lightning_v3.1_pro"]},"voice":{"type":"string","title":"Voice","description":"Smallest AI voice ID. Available voices differ by model: lightning_v3.1 has a broad multilingual pool; lightning_v3.1_pro has premium American, British, and Indian accent voices (English + Hindi only).","default":"sophia","examples":["sophia","avery","liam","lucas","olivia","ryan","freya","william","devansh","arjun","niharika","maya","dhruv","mia","maithili"],"allow_custom_input":true,"model_options":{"lightning_v3.1":["sophia","avery","liam","lucas","olivia","ryan","freya","william","devansh","arjun","niharika","maya","dhruv","mia","maithili"],"lightning_v3.1_pro":["meher","rhea","aviraj","cressida","willow","maverick"]}},"language":{"type":"string","title":"Language","description":"ISO 639-1 language code for synthesis.","default":"en","examples":["en","hi","fr","de","es","it","nl","pl","ru","ar","bn","gu","he","kn","mr","ta"],"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":"Smallest AI","description":"Smallest AI ultralow-latency TTS (Waves) and STT (Pulse) APIs.","provider_docs_url":"https://smallest.ai/docs"},"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"},"user_recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Recording Url"},"bot_recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bot 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"},"user_recording_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Recording Public Url"},"bot_recording_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bot 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"},"user_recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Recording Url"},"bot_recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bot Recording 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"},"user_recording_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"User Recording Public Url"},"bot_recording_public_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bot Recording 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/contribution/setup.mdx b/docs/contribution/setup.mdx index a2ed2495..beb7f8de 100644 --- a/docs/contribution/setup.mdx +++ b/docs/contribution/setup.mdx @@ -6,6 +6,23 @@ description: Set up the Dograh contributor environment with the devcontainer-fir If the steps below do not work for you, please open an issue on [GitHub](https://github.com/dograh-hq/dograh/issues). + +**Using Claude Code or Codex?** Install the official Dograh setup skill and let your agent walk you through the contributor setup — it covers both the devcontainer and host-managed paths, runs Dograh's own scripts, and verifies the stack is healthy. + + +```text Claude Code +/plugin marketplace add dograh-hq/dograh-plugins +/plugin install dograh@dograh +``` +```text Codex +codex plugin marketplace add dograh-hq/dograh-plugins +codex plugin add dograh@dograh +``` + + +Start a new session, then ask it to *"set up Dograh for development"* (or run `/dograh-setup develop` in Claude Code). More at [dograh-hq/dograh-plugins](https://github.com/dograh-hq/dograh-plugins). + + ### Recommended: Devcontainer Setup #### System Requirements diff --git a/docs/deployment/authentication.mdx b/docs/deployment/authentication.mdx new file mode 100644 index 00000000..47d05ed0 --- /dev/null +++ b/docs/deployment/authentication.mdx @@ -0,0 +1,99 @@ +--- +title: "Authentication" +description: "Configure how self-hosted Dograh authenticates users — the default local provider, or Stack Auth for social login" +--- + +Self-hosted Dograh ships with a built-in **local** authentication provider (email + password, backed by a signed JWT). This is the default and needs no external service. + +To offer social logins (Google, GitHub, and others), you can delegate sign-in to **[Stack Auth](https://stack-auth.com)**. Enabling it is a **runtime** configuration change — set a few environment variables and restart. The prebuilt `dograhai/dograh-api` and `dograhai/dograh-ui` images work as-is; you do **not** need to rebuild or build from source. + + +The active provider is controlled by the backend `AUTH_PROVIDER` variable (`local` by default). The frontend discovers the provider — and, for Stack, its public client config — at runtime from the backend's `/api/v1/health` response, so the browser bundle never needs Stack values baked in at build time. + + +## How it works + +1. The backend reads `AUTH_PROVIDER` and the Stack settings from its environment. +2. When `AUTH_PROVIDER=stack`, `/api/v1/health` returns the **public** Stack client config (project id + publishable client key). +3. The UI fetches that at runtime and initializes the Stack SDK in the browser. +4. The **secret server key** is used only server-side (by the backend and the UI's server runtime) and is never sent to the browser. + +## Prerequisites + +A Stack Auth project. Create one in the [Stack Auth dashboard](https://app.stack-auth.com) and configure the social login providers you want to offer. + +## Step 1 — Collect your Stack credentials + +From your project in the [Stack Auth dashboard](https://app.stack-auth.com), gather: + +| Value | Sensitivity | +|---|---| +| **Project ID** | Public | +| **Publishable client key** | Public (safe to expose in the browser) | +| **Secret server key** | Secret — keep server-side only | +| **API base URL** | Public. For Stack's hosted service this is `https://api.stack-auth.com` | + +## Step 2 — Configure the backend (`api`) + +Set these on the `api` service. Add them to the `environment:` block of the `api` service in your `docker-compose.yaml`: + +```yaml docker-compose.yaml +services: + api: + environment: + AUTH_PROVIDER: "stack" + STACK_AUTH_PROJECT_ID: "" + STACK_PUBLISHABLE_CLIENT_KEY: "" + STACK_SECRET_SERVER_KEY: "" + STACK_AUTH_API_URL: "https://api.stack-auth.com" +``` + +## Step 3 — Configure the UI (`ui`) + +The UI runs server-side code (SSR pages and the `/handler/*` auth routes) that calls Stack with the secret server key, so the `ui` service needs that one value too: + +```yaml docker-compose.yaml +services: + ui: + environment: + STACK_SECRET_SERVER_KEY: "" +``` + + +The `ui` service does **not** need the project id or publishable client key — it receives those from the backend at runtime via `/api/v1/health`. Only the secret server key (used server-side) is set here. + + +## Step 4 — Restart and verify + +Recreate the containers so they pick up the new environment: + +```bash +docker compose up -d +``` + +Confirm the backend reports the active provider and the public client config: + +```bash +curl -s http://localhost:8000/api/v1/health +# expect: "auth_provider":"stack", plus "stack_project_id" and "stack_publishable_client_key" +``` + +Then open the UI. The sign-in page should now present your configured Stack Auth social login options instead of the local email/password form. + +## Environment variable reference + +| Variable | Service | Secret | Notes | +|---|---|---|---| +| `AUTH_PROVIDER` | `api` | — | Set to `stack` (default `local`) | +| `STACK_AUTH_PROJECT_ID` | `api` | No | Stack project ID; served to the UI at runtime | +| `STACK_PUBLISHABLE_CLIENT_KEY` | `api` | No | Publishable key; served to the UI at runtime | +| `STACK_SECRET_SERVER_KEY` | `api` + `ui` | **Yes** | Server-side only — never exposed to the browser | +| `STACK_AUTH_API_URL` | `api` | No | Stack REST API base URL | + + +`STACK_SECRET_SERVER_KEY` is the only secret here. Keep it out of any client-visible config and never bake it into an image. The project ID and publishable client key are public by design — the backend deliberately serves them to the browser so Stack can initialize at runtime. + + +## Reverting to local auth + +Remove the variables above (or set `AUTH_PROVIDER=local`) and restart. The UI detects `local` from the backend at runtime and falls back to the built-in email/password flow — no rebuild required. diff --git a/docs/deployment/docker.mdx b/docs/deployment/docker.mdx index d0f29594..04934e50 100644 --- a/docs/deployment/docker.mdx +++ b/docs/deployment/docker.mdx @@ -8,6 +8,23 @@ Dograh AI can be deployed using Docker in two main configurations. Choose the op - **Option 1**: For local development and testing on your own machine - **Option 2**: For remote server deployment with HTTPS (using IP address). If you also have a custom domain, you can first deploy Dograh stack on your server using steps in this document and then proceed to the [Custom Domain](deployment/custom-domain) section. + +**Using Claude Code or Codex?** Install the official Dograh setup skill and let your agent drive either deployment below — it orients to your OS, picks local vs remote, runs Dograh's own setup scripts, and verifies the result with a built-in health check. + + +```text Claude Code +/plugin marketplace add dograh-hq/dograh-plugins +/plugin install dograh@dograh +``` +```text Codex +codex plugin marketplace add dograh-hq/dograh-plugins +codex plugin add dograh@dograh +``` + + +Start a new session, then ask it to *"set up Dograh"* (or run `/dograh-setup` in Claude Code). More at [dograh-hq/dograh-plugins](https://github.com/dograh-hq/dograh-plugins). + + ## Option 1: Local Docker Deployment Watch the video tutorial below for a step-by-step walkthrough of setting up Dograh AI locally with Docker. @@ -20,18 +37,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 +69,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 +98,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/developer/environment-variables.mdx b/docs/developer/environment-variables.mdx index d7f480bf..559bc30b 100644 --- a/docs/developer/environment-variables.mdx +++ b/docs/developer/environment-variables.mdx @@ -22,7 +22,7 @@ The relevant required variables for each mode are noted in the descriptions belo |---|---|---| | `ENVIRONMENT` | `local` | Runtime environment. Affects logging and behaviour. One of `local`, `production`, `test` | | `DEPLOYMENT_MODE` | `oss` | Deployment mode. Use `oss` for self-hosted | -| `AUTH_PROVIDER` | `local` | Authentication provider. Use `local` for OSS | +| `AUTH_PROVIDER` | `local` | Authentication provider. `local` (default) uses the built-in email/password flow. Set to `stack` to delegate to Stack Auth for social login — see [Authentication](/deployment/authentication) for the full setup | --- @@ -48,6 +48,19 @@ Never use the placeholder `OSS_JWT_SECRET` in a production deployment. Generate --- +## Authentication (Stack Auth) + +Set these when `AUTH_PROVIDER=stack` to delegate sign-in to [Stack Auth](https://stack-auth.com) for social login. The project id and publishable client key are public and are served to the browser at runtime via `/api/v1/health`; the secret server key stays server-side. See [Authentication](/deployment/authentication) for the full walkthrough. + +| Variable | Default | Description | +|---|---|---| +| `STACK_AUTH_PROJECT_ID` | `null` | **Required for `stack`.** Stack project ID (public) | +| `STACK_PUBLISHABLE_CLIENT_KEY` | `null` | **Required for `stack`.** Stack publishable client key (public) | +| `STACK_SECRET_SERVER_KEY` | `null` | **Required for `stack`.** Stack secret server key — server-side only, also set on the `ui` service. Keep secret | +| `STACK_AUTH_API_URL` | `null` | **Required for `stack`.** Stack REST API base URL (e.g. `https://api.stack-auth.com`) | + +--- + ## URLs | Variable | Default | Description | diff --git a/docs/docs.json b/docs/docs.json index 2bb9924e..21f2225f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -157,6 +157,7 @@ "pages": [ "deployment/introduction", "deployment/docker", + "deployment/authentication", "deployment/custom-domain", "deployment/scaling", "deployment/update", diff --git a/docs/getting-started/index.mdx b/docs/getting-started/index.mdx index 7dfebff4..cba5988c 100644 --- a/docs/getting-started/index.mdx +++ b/docs/getting-started/index.mdx @@ -24,16 +24,40 @@ 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 +``` + + + +**Using an AI coding agent?** If you work in **Claude Code** or **Codex**, install the official Dograh setup skill and let your agent handle installation, configuration, and troubleshooting for you. It orients to your OS, picks the right deploy path, runs Dograh's own setup scripts, and verifies the result. Install it once: + + +```text Claude Code +/plugin marketplace add dograh-hq/dograh-plugins +/plugin install dograh@dograh +``` +```text Codex +codex plugin marketplace add dograh-hq/dograh-plugins +codex plugin add dograh@dograh +``` + + +Then start a new session and ask it to *"set up Dograh"* (in Claude Code you can also run `/dograh-setup`). See [dograh-hq/dograh-plugins](https://github.com/dograh-hq/dograh-plugins) for details. + 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/examples/README.md b/examples/README.md index 802ed730..22e5c918 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,6 +26,14 @@ python python/fetch_workflow_and_call.py # Create a new workflow from a definition. python python/create_workflow.py + +# Build a 3-node agent with the Workflow SDK and save it as a draft. +# Edit WORKFLOW_ID at the top of the file first. +python python/build_workflow_with_sdk.py + +# Load an existing workflow, edit the startCall prompt, and save as a draft. +# Edit WORKFLOW_ID at the top of the file first. +python python/load_and_edit_workflow.py ``` ## TypeScript @@ -41,4 +49,6 @@ export DOGRAH_API_TOKEN=sk-... npm run call # fetch_workflow_and_call.ts npm run create # create_workflow.ts +npm run build # build_workflow_with_sdk.ts (edit WORKFLOW_ID in the file first) +npm run edit # load_and_edit_workflow.ts (edit WORKFLOW_ID in the file first) ``` diff --git a/examples/python/build_workflow_with_sdk.py b/examples/python/build_workflow_with_sdk.py new file mode 100644 index 00000000..78d47955 --- /dev/null +++ b/examples/python/build_workflow_with_sdk.py @@ -0,0 +1,101 @@ +"""Build a multi-node voice agent using the Workflow SDK and save it as a draft. + +Requirements: + pip install -r requirements.txt + +Environment variables (loaded from `.env` in this directory): + DOGRAH_API_ENDPOINT - Dograh API base URL (e.g. http://localhost:8000) + DOGRAH_API_TOKEN - API token sent as X-API-Key + +Run: + python build_workflow_with_sdk.py +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +from dotenv import load_dotenv + +from dograh_sdk import DograhClient, Workflow + +load_dotenv(Path(__file__).parent / ".env") + +# Replace with the numeric ID of an existing agent in your Dograh account. +# Create one via the UI or with create_workflow.py if you don't have one yet. +WORKFLOW_ID = 0 + + +def main() -> int: + api_endpoint = os.environ.get("DOGRAH_API_ENDPOINT", "http://localhost:8000") + api_token = os.environ.get("DOGRAH_API_TOKEN") + + if not api_token: + print("DOGRAH_API_TOKEN is required", file=sys.stderr) + return 1 + + if WORKFLOW_ID == 0: + print("Set WORKFLOW_ID at the top of this file to an existing workflow ID", file=sys.stderr) + return 1 + + with DograhClient(base_url=api_endpoint, api_key=api_token) as client: + existing = client.get_workflow(WORKFLOW_ID) + # Preserve the live workflow name; save_workflow sends name with the draft update. + wf = Workflow(client=client, name=existing.name) + + greeting = wf.add( + type="startCall", + name="greeting", + prompt=( + "# Goal\n" + "You are a helpful agent having a conversation over voice with a human. " + "This is a voice conversation, so transcripts can be error prone.\n\n" + "## Flow\n" + "Greet the caller warmly and ask whether they would like to continue." + ), + ) + qualify = wf.add( + type="agentNode", + name="qualify", + prompt=( + "# Goal\n" + "Qualify the lead by asking about their needs, budget, and timeline.\n\n" + "## Rules\n" + "- Keep responses short — 2-3 sentences max\n" + "- Confirm all three answers before moving on" + ), + ) + done = wf.add( + type="endCall", + name="done", + prompt="Thank the caller for their time and let them know the team will follow up shortly.", + ) + + wf.edge( + greeting, + qualify, + label="interested", + condition="Caller confirms they want to continue.", + ) + wf.edge( + qualify, + done, + label="qualified", + condition="All qualification questions have been answered.", + ) + + result = client.save_workflow(workflow_id=WORKFLOW_ID, workflow=wf) + node_count = len(result.workflow_definition.get("nodes", [])) + print( + f"Saved workflow {result.id}: {result.name!r} " + f"(version={result.version_number}, status={result.version_status}, " + f"nodes={node_count})" + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/python/load_and_edit_workflow.py b/examples/python/load_and_edit_workflow.py new file mode 100644 index 00000000..e5f92157 --- /dev/null +++ b/examples/python/load_and_edit_workflow.py @@ -0,0 +1,75 @@ +"""Load an existing workflow, edit a node prompt, and save it as a draft. + +Requirements: + pip install -r requirements.txt + +Environment variables (loaded from `.env` in this directory): + DOGRAH_API_ENDPOINT - Dograh API base URL (e.g. http://localhost:8000) + DOGRAH_API_TOKEN - API token sent as X-API-Key + +Run: + python load_and_edit_workflow.py +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +from dotenv import load_dotenv + +from dograh_sdk import DograhClient +from dograh_sdk._generated_models import UpdateWorkflowRequest + +load_dotenv(Path(__file__).parent / ".env") + +# Replace with the numeric ID of an existing agent in your Dograh account. +WORKFLOW_ID = 0 + +# Sentence appended to the startCall node's prompt when the script runs. +PROMPT_SUFFIX = " Please be concise — keep all responses under two sentences." + + +def main() -> int: + api_endpoint = os.environ.get("DOGRAH_API_ENDPOINT", "http://localhost:8000") + api_token = os.environ.get("DOGRAH_API_TOKEN") + + if not api_token: + print("DOGRAH_API_TOKEN is required", file=sys.stderr) + return 1 + + if WORKFLOW_ID == 0: + print("Set WORKFLOW_ID at the top of this file to an existing workflow ID", file=sys.stderr) + return 1 + + with DograhClient(base_url=api_endpoint, api_key=api_token) as client: + existing = client.get_workflow(WORKFLOW_ID) + print(f"Loaded workflow {existing.id}: {existing.name!r} (status={existing.status})") + + definition = dict(existing.workflow_definition) + + for node in definition.get("nodes", []): + if node.get("type") == "startCall": + data = dict(node.get("data") or {}) + data["prompt"] = (data.get("prompt") or "") + PROMPT_SUFFIX + node["data"] = data + break + + result = client.update_workflow( + WORKFLOW_ID, + body=UpdateWorkflowRequest( + name=existing.name, + workflow_definition=definition, + ), + ) + print( + f"Saved draft for workflow {result.id}: {result.name!r} " + f"(version={result.version_number}, status={result.version_status})" + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/typescript/build_workflow_with_sdk.ts b/examples/typescript/build_workflow_with_sdk.ts new file mode 100644 index 00000000..4dfb77a7 --- /dev/null +++ b/examples/typescript/build_workflow_with_sdk.ts @@ -0,0 +1,84 @@ +// Build a multi-node voice agent using the Workflow SDK and save it as a draft. +// +// Requirements: +// npm install @dograh/sdk +// +// Environment variables: +// DOGRAH_API_ENDPOINT - Dograh API base URL (e.g. http://localhost:8000) +// DOGRAH_API_TOKEN - API token sent as X-API-Key +// +// Run: +// npx tsx build_workflow_with_sdk.ts + +import { DograhClient, Workflow } from "@dograh/sdk"; + +// Replace with the numeric ID of an existing agent in your Dograh account. +// Create one via the UI or with create_workflow.ts if you don't have one yet. +const WORKFLOW_ID = 0; + +async function main(): Promise { + const apiEndpoint = process.env.DOGRAH_API_ENDPOINT ?? "http://localhost:8000"; + const apiToken = process.env.DOGRAH_API_TOKEN; + + if (!apiToken) throw new Error("DOGRAH_API_TOKEN is required"); + if (WORKFLOW_ID === 0) throw new Error("Set WORKFLOW_ID at the top of this file to an existing workflow ID"); + + const client = new DograhClient({ + baseUrl: apiEndpoint, + apiKey: apiToken, + }); + + const existing = await client.getWorkflow(WORKFLOW_ID); + // Preserve the live workflow name; saveWorkflow sends name with the draft update. + const wf = new Workflow({ client, name: existing.name }); + + const greeting = await wf.add({ + type: "startCall", + name: "greeting", + prompt: [ + "# Goal", + "You are a helpful agent having a conversation over voice with a human. This is a voice conversation, so transcripts can be error prone.", + "", + "## Flow", + "Greet the caller warmly and ask whether they would like to continue.", + ].join("\n"), + }); + const qualify = await wf.add({ + type: "agentNode", + name: "qualify", + prompt: [ + "# Goal", + "Qualify the lead by asking about their needs, budget, and timeline.", + "", + "## Rules", + "- Keep responses short — 2-3 sentences max", + "- Confirm all three answers before moving on", + ].join("\n"), + }); + const done = await wf.add({ + type: "endCall", + name: "done", + prompt: "Thank the caller for their time and let them know the team will follow up shortly.", + }); + + wf.edge(greeting, qualify, { + label: "interested", + condition: "Caller confirms they want to continue.", + }); + wf.edge(qualify, done, { + label: "qualified", + condition: "All qualification questions have been answered.", + }); + + const result = await client.saveWorkflow(WORKFLOW_ID, wf); + const nodeCount = ((result.workflow_definition?.nodes as unknown[]) ?? []).length; + console.log( + `Saved workflow ${result.id}: ${JSON.stringify(result.name)} ` + + `(version=${result.version_number}, status=${result.version_status}, nodes=${nodeCount})`, + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/examples/typescript/load_and_edit_workflow.ts b/examples/typescript/load_and_edit_workflow.ts new file mode 100644 index 00000000..4c620bca --- /dev/null +++ b/examples/typescript/load_and_edit_workflow.ts @@ -0,0 +1,63 @@ +// Load an existing workflow, edit a node prompt, and save it as a draft. +// +// Requirements: +// npm install @dograh/sdk +// +// Environment variables: +// DOGRAH_API_ENDPOINT - Dograh API base URL (e.g. http://localhost:8000) +// DOGRAH_API_TOKEN - API token sent as X-API-Key +// +// Run: +// npx tsx load_and_edit_workflow.ts + +import { DograhClient } from "@dograh/sdk"; + +// Replace with the numeric ID of an existing agent in your Dograh account. +const WORKFLOW_ID = 0; + +// Sentence appended to the startCall node's prompt when the script runs. +const PROMPT_SUFFIX = " Please be concise — keep all responses under two sentences."; + +async function main(): Promise { + const apiEndpoint = process.env.DOGRAH_API_ENDPOINT ?? "http://localhost:8000"; + const apiToken = process.env.DOGRAH_API_TOKEN; + + if (!apiToken) throw new Error("DOGRAH_API_TOKEN is required"); + if (WORKFLOW_ID === 0) throw new Error("Set WORKFLOW_ID at the top of this file to an existing workflow ID"); + + const client = new DograhClient({ + baseUrl: apiEndpoint, + apiKey: apiToken, + }); + + const existing = await client.getWorkflow(WORKFLOW_ID); + console.log(`Loaded workflow ${existing.id}: ${JSON.stringify(existing.name)} (status=${existing.status})`); + + const definition = structuredClone(existing.workflow_definition) as { + nodes?: Array<{ type?: string; data?: Record }>; + }; + + for (const node of definition.nodes ?? []) { + if (node.type === "startCall") { + node.data = node.data ?? {}; + node.data.prompt = ((node.data.prompt as string) ?? "") + PROMPT_SUFFIX; + break; + } + } + + const result = await client.updateWorkflow(WORKFLOW_ID, { + body: { + name: existing.name, + workflow_definition: definition as Record, + }, + }); + console.log( + `Saved draft for workflow ${result.id}: ${JSON.stringify(result.name)} ` + + `(version=${result.version_number}, status=${result.version_status})`, + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/examples/typescript/package.json b/examples/typescript/package.json index c48f2281..68e3782e 100644 --- a/examples/typescript/package.json +++ b/examples/typescript/package.json @@ -5,7 +5,9 @@ "type": "module", "scripts": { "call": "tsx --env-file=.env fetch_workflow_and_call.ts", - "create": "tsx --env-file=.env create_workflow.ts" + "create": "tsx --env-file=.env create_workflow.ts", + "build": "tsx --env-file=.env build_workflow_with_sdk.ts", + "edit": "tsx --env-file=.env load_and_edit_workflow.ts" }, "dependencies": { "@dograh/sdk": "latest" diff --git a/pipecat b/pipecat index 228324a1..85a48a37 160000 --- a/pipecat +++ b/pipecat @@ -1 +1 @@ -Subproject commit 228324a146a6765c6b8d610963bc80d7bc8cb9f7 +Subproject commit 85a48a37bf51e5b8854a85a2ff6319c67937b6fa diff --git a/scripts/setup_remote.sh b/scripts/setup_remote.sh index 919c881d..f1a0d149 100755 --- a/scripts/setup_remote.sh +++ b/scripts/setup_remote.sh @@ -135,10 +135,10 @@ if [[ -z "$FASTAPI_WORKERS" ]]; then if [[ -t 0 ]]; then echo "" echo -e "${YELLOW}Number of FastAPI workers (uvicorn processes nginx will load-balance):${NC}" - read -p "[4]: " FASTAPI_WORKERS - FASTAPI_WORKERS="${FASTAPI_WORKERS:-4}" + read -p "[2]: " FASTAPI_WORKERS + FASTAPI_WORKERS="${FASTAPI_WORKERS:-2}" else - FASTAPI_WORKERS="4" + FASTAPI_WORKERS="2" fi fi 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/scripts/update_remote.sh b/scripts/update_remote.sh index 119439b7..1dc75e71 100755 --- a/scripts/update_remote.sh +++ b/scripts/update_remote.sh @@ -71,10 +71,10 @@ if [[ -z "${FASTAPI_WORKERS:-}" ]]; then if [[ -t 0 ]]; then echo "" echo -e "${YELLOW}FASTAPI_WORKERS not set in .env. Number of uvicorn workers nginx will load-balance:${NC}" - read -p "[4]: " FASTAPI_WORKERS - FASTAPI_WORKERS="${FASTAPI_WORKERS:-4}" + read -p "[2]: " FASTAPI_WORKERS + FASTAPI_WORKERS="${FASTAPI_WORKERS:-2}" else - FASTAPI_WORKERS="4" + FASTAPI_WORKERS="2" fi fi diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index c502e1b1..e1c760af 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "dograh-sdk" -version = "0.1.7" +version = "0.1.8" description = "Typed builder for Dograh voice-AI workflows" readme = "README.md" requires-python = ">=3.10" diff --git a/sdk/python/src/dograh_sdk/_generated_models.py b/sdk/python/src/dograh_sdk/_generated_models.py index ec27e029..67909d56 100644 --- a/sdk/python/src/dograh_sdk/_generated_models.py +++ b/sdk/python/src/dograh_sdk/_generated_models.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: -# filename: dograh-openapi-uraOZf.json -# timestamp: 2026-06-03T11:53:30+00:00 +# filename: dograh-openapi-XXXXXX.json.mx2FLLD9Pk +# timestamp: 2026-06-18T13:32:23+00:00 from __future__ import annotations diff --git a/sdk/python/src/dograh_sdk/typed/webhook.py b/sdk/python/src/dograh_sdk/typed/webhook.py index 305c0e46..0d1f0ba6 100644 --- a/sdk/python/src/dograh_sdk/typed/webhook.py +++ b/sdk/python/src/dograh_sdk/typed/webhook.py @@ -69,7 +69,7 @@ class Webhook(TypedNode): Additional HTTP headers to include with the request. """ - payload_template: dict[str, Any] = field(default_factory=lambda: {'call_id': '{{workflow_run_id}}', 'first_name': '{{initial_context.first_name}}', 'rsvp': '{{gathered_context.rsvp}}', 'duration': '{{cost_info.call_duration_seconds}}', 'recording_url': '{{recording_url}}', 'transcript_url': '{{transcript_url}}'}) + payload_template: dict[str, Any] = field(default_factory=lambda: {'call_id': '{{workflow_run_id}}', 'first_name': '{{initial_context.first_name}}', 'rsvp': '{{gathered_context.rsvp}}', 'duration': '{{cost_info.call_duration_seconds}}', 'recording_url': '{{recording_url}}', 'user_recording_url': '{{user_recording_url}}', 'bot_recording_url': '{{bot_recording_url}}', 'transcript_url': '{{transcript_url}}'}) """ JSON body of the request. Values are Jinja-rendered against the run context — `{{workflow_run_id}}`, `{{gathered_context.foo}}`, diff --git a/sdk/typescript/package-lock.json b/sdk/typescript/package-lock.json index fa804ff4..987be493 100644 --- a/sdk/typescript/package-lock.json +++ b/sdk/typescript/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dograh/sdk", - "version": "0.1.7", + "version": "0.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dograh/sdk", - "version": "0.1.7", + "version": "0.1.8", "license": "BSD-2-Clause", "devDependencies": { "openapi-typescript": "^7.13.0", diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index a554f596..7c91ec7e 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@dograh/sdk", - "version": "0.1.7", + "version": "0.1.8", "description": "Typed builder for Dograh voice-AI workflows", "license": "BSD-2-Clause", "author": "Zansat Technologies Private Limited", diff --git a/ui/.env.example b/ui/.env.example index 741790e5..13d914eb 100644 --- a/ui/.env.example +++ b/ui/.env.example @@ -1,3 +1,5 @@ BACKEND_URL=http://localhost:8000 NEXT_PUBLIC_BACKEND_URL=http://localhost:8000 NEXT_PUBLIC_NODE_ENV=development +# form submissions backend +# NEXT_PUBLIC_ONBOARDING_API_URL=http://localhost:8001 diff --git a/ui/package-lock.json b/ui/package-lock.json index 9923bb0f..ffbc2882 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "ui", - "version": "1.30.1", + "version": "1.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ui", - "version": "1.30.1", + "version": "1.35.0", "dependencies": { "@dagrejs/dagre": "^1.1.4", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -35,8 +35,8 @@ "next-themes": "^0.4.6", "pino": "^9.9.2", "pino-pretty": "^13.1.1", - "posthog-js": "^1.255.1", - "posthog-node": "^5.1.1", + "posthog-js": "^1.388.1", + "posthog-node": "^5.38.0", "react": "^19.1.0", "react-day-picker": "^9.8.0", "react-dom": "^19.1.0", @@ -716,6 +716,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -917,7 +918,6 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", "license": "MIT", - "peer": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -1032,7 +1032,6 @@ "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", @@ -1051,15 +1050,13 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@emotion/babel-plugin/node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -1069,7 +1066,6 @@ "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", "license": "MIT", - "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", @@ -1082,22 +1078,19 @@ "version": "0.9.2", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@emotion/memoize": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@emotion/react": { "version": "11.14.0", "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1122,7 +1115,6 @@ "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", "license": "MIT", - "peer": true, "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", @@ -1135,22 +1127,19 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@emotion/unitless": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", "license": "MIT", - "peer": true, "peerDependencies": { "react": ">=16.8.0" } @@ -1159,15 +1148,13 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@emotion/weak-memoize": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", @@ -2455,7 +2442,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -2694,6 +2680,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2744,6 +2731,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", @@ -3415,6 +3403,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -3500,6 +3489,21 @@ "@oslojs/encoding": "1.0.0" } }, + "node_modules/@posthog/core": { + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.34.0.tgz", + "integrity": "sha512-hf+COAWSAG1UQHluPsfAOJDOR8tr8YY7cdf58ENGt20pEEWZ6sH3IbtvFDWkhp0ulSftA62x17w8R/f6IAwwEA==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.387.0" + } + }, + "node_modules/@posthog/types": { + "version": "1.388.0", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.388.0.tgz", + "integrity": "sha512-BQO7e9ESdjOyKsCnoPoeHtq78ologEkPiemibsUlaAWC/Gk538jdTqyd0E1oSAlXnQqEANwjMn/A26vF2jljeQ==", + "license": "MIT" + }, "node_modules/@prisma/instrumentation": { "version": "6.11.1", "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-6.11.1.tgz", @@ -8122,6 +8126,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10005,6 +10010,7 @@ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz", "integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.16" } @@ -10416,7 +10422,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -10427,7 +10432,6 @@ "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -10480,8 +10484,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/pg": { "version": "8.6.1", @@ -10508,6 +10511,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz", "integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -10518,6 +10522,7 @@ "integrity": "sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.0.0" } @@ -10527,7 +10532,6 @@ "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "*" } @@ -10557,6 +10561,13 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -11050,7 +11061,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -11060,29 +11070,25 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -11093,15 +11099,13 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -11114,7 +11118,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "license": "MIT", - "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -11124,7 +11127,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -11133,15 +11135,13 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -11158,7 +11158,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -11172,7 +11171,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -11185,7 +11183,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -11200,7 +11197,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -11216,15 +11212,13 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@xyflow/react": { "version": "12.10.2", @@ -11291,6 +11285,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -11312,7 +11307,6 @@ "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" }, @@ -11364,7 +11358,6 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -11382,7 +11375,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11398,8 +11390,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ansi-colors": { "version": "4.1.3", @@ -11711,7 +11702,6 @@ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", @@ -11839,6 +11829,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -12043,7 +12034,6 @@ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.0" } @@ -12302,7 +12292,6 @@ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", "license": "MIT", - "peer": true, "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", @@ -12474,6 +12463,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -12816,12 +12806,20 @@ "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz", + "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -12904,7 +12902,6 @@ "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", "license": "MIT", - "peer": true, "dependencies": { "is-arrayish": "^0.2.1" } @@ -12913,8 +12910,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/es-abstract": { "version": "1.23.9", @@ -13034,8 +13030,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -13187,6 +13182,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -13360,6 +13356,7 @@ "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -13647,7 +13644,6 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.x" } @@ -13753,8 +13749,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { "version": "1.1.4", @@ -13836,8 +13831,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/find-up": { "version": "5.0.0", @@ -14089,8 +14083,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/globals": { "version": "14.0.0", @@ -14304,6 +14297,7 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -14913,7 +14907,6 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -14928,7 +14921,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -15018,8 +15010,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", @@ -15351,15 +15342,13 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/loader-runner": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.11.5" }, @@ -15440,15 +15429,13 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", @@ -15479,7 +15466,6 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -15489,7 +15475,6 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -15587,14 +15572,14 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/next": { "version": "15.5.14", "resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz", "integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "15.5.14", "@swc/helpers": "0.5.15", @@ -16022,7 +16007,6 @@ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -16097,7 +16081,6 @@ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -16338,36 +16321,39 @@ } }, "node_modules/posthog-js": { - "version": "1.255.1", - "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.255.1.tgz", - "integrity": "sha512-KMh0o9MhORhEZVjXpktXB5rJ8PfDk+poqBoTSoLzWgNjhJf6D8jcyB9jUMA6vVPfn4YeepVX5NuclDRqOwr5Mw==", + "version": "1.388.1", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.388.1.tgz", + "integrity": "sha512-3WaTv0LPWRx3hT3frBd4Z7fMZcqPS4St0otB+AGO6ixOFc2KCTGSIJwujtArC2rraWS3UPlAYVf7QaA0Fgy+MA==", "license": "SEE LICENSE IN LICENSE", "dependencies": { + "@posthog/core": "^1.34.0", + "@posthog/types": "^1.388.0", "core-js": "^3.38.1", + "dompurify": "^3.3.2", "fflate": "^0.4.8", - "preact": "^10.19.3", - "web-vitals": "^4.2.4" - }, - "peerDependencies": { - "@rrweb/types": "2.0.0-alpha.17", - "rrweb-snapshot": "2.0.0-alpha.17" - }, - "peerDependenciesMeta": { - "@rrweb/types": { - "optional": true - }, - "rrweb-snapshot": { - "optional": true - } + "preact": "^10.28.2", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^5.1.0" } }, "node_modules/posthog-node": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.1.1.tgz", - "integrity": "sha512-6VISkNdxO24ehXiDA4dugyCSIV7lpGVaEu5kn/dlAj+SJ1lgcDru9PQ8p/+GSXsXVxohd1t7kHL2JKc9NoGb0w==", + "version": "5.38.0", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.38.0.tgz", + "integrity": "sha512-xEojUWq7ajYfsMPZIexG9xtvhewpqrPWvL/qRhnm8W3KVZi6BlXHe29tjywZJglrDwEQpqdtYKbRXyg2zF9MWw==", "license": "MIT", + "dependencies": { + "@posthog/core": "^1.33.0" + }, "engines": { - "node": ">=20" + "node": "^20.20.0 || >=22.22.0" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } } }, "node_modules/powershell-utils": { @@ -16488,6 +16474,12 @@ "node": ">=10.13.0" } }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -16531,6 +16523,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -16561,6 +16554,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -16587,6 +16581,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz", "integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -16611,13 +16606,15 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -16757,7 +16754,6 @@ "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", @@ -16823,7 +16819,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -16861,8 +16858,7 @@ "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", @@ -16899,7 +16895,6 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -16984,6 +16979,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -17155,7 +17151,6 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -17192,7 +17187,6 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -17204,8 +17198,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/secure-json-parse": { "version": "4.0.0", @@ -17761,8 +17754,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/supports-color": { "version": "7.2.0", @@ -17802,7 +17794,8 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.1.tgz", "integrity": "sha512-QNbdmeS979Efzim2g/bEvfuh+fTcIdp1y7gA+sb6OYSW74rt7Cr7M78AKdf6HqWT3d5AiTb7SwTT3sLQxr4/qw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -17831,7 +17824,6 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -17850,7 +17842,6 @@ "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -17883,8 +17874,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/thread-stream": { "version": "3.1.0", @@ -17961,6 +17951,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -18149,6 +18140,7 @@ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18335,7 +18327,6 @@ "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -18422,7 +18413,6 @@ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "license": "MIT", - "peer": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -18432,9 +18422,9 @@ } }, "node_modules/web-vitals": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", - "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz", + "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==", "license": "Apache-2.0" }, "node_modules/webidl-conversions": { @@ -18448,7 +18438,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -18512,7 +18501,6 @@ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -18526,7 +18514,6 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -18704,7 +18691,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", - "peer": true, "engines": { "node": ">= 6" } @@ -18813,6 +18799,7 @@ "resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz", "integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==", "license": "MIT", + "peer": true, "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", @@ -18855,6 +18842,7 @@ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.20.0" }, diff --git a/ui/package.json b/ui/package.json index 77c8e3cf..d0cd2370 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "ui", - "version": "1.34.0", + "version": "1.36.0", "private": true, "scripts": { "dev": "cross-env NODE_OPTIONS=--enable-source-maps next dev --turbopack", @@ -9,7 +9,8 @@ "lint": "next lint", "fix-lint": "npx eslint --fix . --ignore-pattern '.next/*' --ignore-pattern 'node_modules/*' --ignore-pattern 'next-env.d.ts'", "generate-client": "openapi-ts", - "test:display-options": "node scripts/test-display-options.mts" + "test:display-options": "node scripts/test-display-options.mts", + "lint:lead-flow": "bash ../../user_onboarding/scripts/check_lead_flow.sh" }, "dependencies": { "@dagrejs/dagre": "^1.1.4", @@ -39,8 +40,8 @@ "next-themes": "^0.4.6", "pino": "^9.9.2", "pino-pretty": "^13.1.1", - "posthog-js": "^1.255.1", - "posthog-node": "^5.1.1", + "posthog-js": "^1.388.1", + "posthog-node": "^5.38.0", "react": "^19.1.0", "react-day-picker": "^9.8.0", "react-dom": "^19.1.0", diff --git a/ui/public/brand-imprint-dark.svg b/ui/public/brand-imprint-dark.svg new file mode 100644 index 00000000..6d44ab46 --- /dev/null +++ b/ui/public/brand-imprint-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/public/brand-imprint-light.svg b/ui/public/brand-imprint-light.svg new file mode 100644 index 00000000..a22a0c3b --- /dev/null +++ b/ui/public/brand-imprint-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/public/dograh-logo-inverse.png b/ui/public/dograh-logo-inverse.png new file mode 100755 index 00000000..8415e795 Binary files /dev/null and b/ui/public/dograh-logo-inverse.png differ diff --git a/ui/public/dograh-logo.png b/ui/public/dograh-logo.png new file mode 100755 index 00000000..40165da8 Binary files /dev/null and b/ui/public/dograh-logo.png differ diff --git a/ui/public/dograh-mark.png b/ui/public/dograh-mark.png new file mode 100755 index 00000000..328f51f1 Binary files /dev/null and b/ui/public/dograh-mark.png differ diff --git a/ui/public/embed/dograh-widget.js b/ui/public/embed/dograh-widget.js index 9a266208..65614cf7 100644 --- a/ui/public/embed/dograh-widget.js +++ b/ui/public/embed/dograh-widget.js @@ -26,10 +26,12 @@ stream: null, sessionToken: null, workflowRunId: null, + pcId: null, connectionStatus: 'idle', // idle, connecting, connected, failed audioElement: null, turnCredentials: null, // TURN server credentials callStartedAt: null, // Timestamp when call connected (for duration tracking) + gracefulDisconnect: false, callbacks: { onReady: null, onCallStart: null, @@ -611,6 +613,7 @@ * Start voice call */ async function startCall() { + state.gracefulDisconnect = false; updateStatus('connecting', 'Connecting...', 'Please wait while we establish the connection'); if (state.callbacks.onCallStart) { @@ -630,6 +633,11 @@ // Request microphone permission try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + // Release any stream still held from a prior attempt before retaining + // the new one, so a re-entrant start can't leak the microphone. + if (state.stream) { + state.stream.getTracks().forEach(track => track.stop()); + } state.stream = stream; } catch (micError) { // Handle specific microphone permission errors @@ -657,6 +665,31 @@ } catch (error) { console.error('Dograh Widget: Failed to start call', error); + + // Release anything acquired before the failure so a retry starts clean. + // getUserMedia may have succeeded before a later step (WebSocket / + // negotiation) threw, which would otherwise leave the mic held and block + // the next getUserMedia(). Null the refs before close() so the peer/ws + // state handlers short-circuit instead of re-entering teardown. + if (state.stream) { + state.stream.getTracks().forEach(track => track.stop()); + state.stream = null; + } + if (state.pc) { + const pc = state.pc; + state.pc = null; + if (pc.signalingState !== 'closed') { + pc.close(); + } + } + if (state.ws) { + const ws = state.ws; + state.ws = null; + if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) { + ws.close(); + } + } + updateStatus('failed', 'Connection failed', error.message || 'Please check your microphone and try again'); // Trigger error callback @@ -766,45 +799,69 @@ }; // Monitor connection state - state.pc.oniceconnectionstatechange = () => { - console.log('ICE connection state:', state.pc.iceConnectionState); + state.pc.oniceconnectionstatechange = handlePeerConnectionStateChange; + state.pc.onconnectionstatechange = handlePeerConnectionStateChange; + state.pc.onicecandidate = sendIceCandidate; + } - if (state.pc.iceConnectionState === 'connected' || state.pc.iceConnectionState === 'completed') { - const wasAlreadyConnected = state.callStartedAt !== null; - updateStatus('connected', 'Connected', 'Your voice call is now active'); - if (!wasAlreadyConnected) { - state.callStartedAt = Date.now(); - if (state.callbacks.onCallConnected) { - state.callbacks.onCallConnected({ - agentId: state.config.workflowId || null, - token: state.config.token || null, - workflowRunId: state.workflowRunId || null - }); - } + function handlePeerConnectionStateChange() { + const pc = state.pc; + if (!pc) return; + + console.log('Peer connection state:', pc.connectionState, 'ICE:', pc.iceConnectionState); + + if (pc.connectionState === 'connected' || pc.iceConnectionState === 'connected' || pc.iceConnectionState === 'completed') { + const wasAlreadyConnected = state.callStartedAt !== null; + updateStatus('connected', 'Connected', 'Your voice call is now active'); + if (!wasAlreadyConnected) { + state.callStartedAt = Date.now(); + if (state.callbacks.onCallConnected) { + state.callbacks.onCallConnected({ + agentId: state.config.workflowId || null, + token: state.config.token || null, + workflowRunId: state.workflowRunId || null + }); } - } else if (state.pc.iceConnectionState === 'failed' || state.pc.iceConnectionState === 'disconnected') { - updateStatus('failed', 'Connection lost', 'The call has been disconnected'); - stopCall(); } - }; + return; + } + if (pc.connectionState === 'failed' || pc.iceConnectionState === 'failed') { + stopCall({ + graceful: false, + status: 'failed', + text: 'Connection lost', + subtext: 'The call has been disconnected' + }); + return; + } + + if ( + pc.connectionState === 'closed' || + pc.connectionState === 'disconnected' || + pc.iceConnectionState === 'closed' || + pc.iceConnectionState === 'disconnected' + ) { + stopCall({ graceful: true }); + } + } + + function sendIceCandidate(event) { // Handle ICE candidates for trickling - state.pc.onicecandidate = (event) => { - if (state.ws && state.ws.readyState === WebSocket.OPEN) { - const message = { - type: 'ice-candidate', - payload: { - candidate: event.candidate ? { - candidate: event.candidate.candidate, - sdpMid: event.candidate.sdpMid, - sdpMLineIndex: event.candidate.sdpMLineIndex - } : null, - pc_id: state.pcId - } - }; - state.ws.send(JSON.stringify(message)); - } - }; + if (state.ws && state.ws.readyState === WebSocket.OPEN) { + const message = { + type: 'ice-candidate', + payload: { + candidate: event.candidate ? { + candidate: event.candidate.candidate, + sdpMid: event.candidate.sdpMid, + sdpMLineIndex: event.candidate.sdpMLineIndex + } : null, + pc_id: state.pcId + } + }; + state.ws.send(JSON.stringify(message)); + } } /** @@ -828,9 +885,16 @@ reject(error); }; - state.ws.onclose = () => { + state.ws.onclose = (event) => { console.log('WebSocket closed'); - if (state.connectionStatus === 'connected') { + state.ws = null; + + if (event.reason === 'call ended') { + stopCall({ graceful: true, closeWebSocket: false }); + return; + } + + if (state.connectionStatus === 'connected' && !state.gracefulDisconnect) { updateStatus('failed', 'Connection lost', 'The call has been disconnected'); } }; @@ -882,6 +946,11 @@ updateStatus('failed', 'Server error', message.payload.message || 'An error occurred'); break; + case 'call-ended': + console.log('Call ended by server:', message.payload); + stopCall({ graceful: true }); + break; + default: console.warn('Unknown message type:', message.type); } @@ -913,7 +982,15 @@ /** * Stop voice call */ - function stopCall() { + function stopCall(options = {}) { + const graceful = options.graceful !== false; + const closeWebSocket = options.closeWebSocket !== false; + const status = options.status || 'idle'; + const text = options.text || 'Call ended'; + const subtext = options.subtext || 'Click below to start a new call'; + + state.gracefulDisconnect = graceful; + // Fire onCallDisconnected only if the call had actually connected, with // identifiers and duration. Must run before we clear callStartedAt. if (state.callStartedAt && state.callbacks.onCallDisconnected) { @@ -927,15 +1004,20 @@ } state.callStartedAt = null; - updateStatus('idle', 'Call ended', 'Click below to start a new call'); + updateStatus(status, text, subtext); if (state.callbacks.onCallEnd) { state.callbacks.onCallEnd(); } // Close WebSocket - if (state.ws) { - state.ws.close(); + if (closeWebSocket && state.ws) { + const ws = state.ws; + state.ws = null; + if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) { + ws.close(); + } + } else if (!closeWebSocket) { state.ws = null; } @@ -947,8 +1029,11 @@ // Close peer connection if (state.pc) { - state.pc.close(); + const pc = state.pc; state.pc = null; + if (pc.signalingState !== 'closed') { + pc.close(); + } } // Clear audio diff --git a/ui/src/app/api-keys/page.tsx b/ui/src/app/api-keys/page.tsx index 2558889b..05aa7c33 100644 --- a/ui/src/app/api-keys/page.tsx +++ b/ui/src/app/api-keys/page.tsx @@ -304,7 +304,7 @@ export default function APIKeysPage() { // Don't render content until auth is loaded if (loading || !user) { return ( -
+
@@ -319,7 +319,7 @@ export default function APIKeysPage() { const showServiceKeyArchiveControls = !isOSS; return ( -
+
diff --git a/ui/src/app/api/config/auth/route.ts b/ui/src/app/api/config/auth/route.ts index a3b33477..cf6553f3 100644 --- a/ui/src/app/api/config/auth/route.ts +++ b/ui/src/app/api/config/auth/route.ts @@ -1,10 +1,17 @@ import { NextResponse } from 'next/server'; -import { getAuthProvider } from '@/lib/auth/config'; +import { getAuthProvider, getStackConfig } from '@/lib/auth/config'; import logger from '@/lib/logger'; export async function GET() { const provider = await getAuthProvider(); + // When using Stack, hand the public client config to the browser so it can + // initialize the Stack SDK at runtime (no build-time NEXT_PUBLIC_* needed). + const stackConfig = provider === 'stack' ? await getStackConfig() : null; logger.debug(`Got provider ${provider} from getAuthProvider`) - return NextResponse.json({ provider }); + return NextResponse.json({ + provider, + stackProjectId: stackConfig?.projectId ?? null, + stackPublishableClientKey: stackConfig?.publishableClientKey ?? null, + }); } diff --git a/ui/src/app/auth/login/page.tsx b/ui/src/app/auth/login/page.tsx index 39c6ceb1..a1fef886 100644 --- a/ui/src/app/auth/login/page.tsx +++ b/ui/src/app/auth/login/page.tsx @@ -5,8 +5,9 @@ import { useState } from "react"; import { toast } from "sonner"; import { loginApiV1AuthLoginPost } from "@/client/sdk.gen"; +import { AuthEnterpriseCTA } from "@/components/auth/AuthEnterpriseCTA"; +import { AuthShell } from "@/components/auth/AuthShell"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -46,48 +47,48 @@ export default function LoginPage() { }; return ( -
- - - Sign in - Enter your email and password to continue - - -
-
- - setEmail(e.target.value)} - required - /> -
-
- - setPassword(e.target.value)} - required - /> -
- -
-

- Don't have an account?{" "} - - Sign up - -

-
-
-
+ }> +
+

Sign in

+

+ Enter your email and password to continue +

+
+ +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ +
+ +

+ Don't have an account?{" "} + + Sign up + +

+
); } diff --git a/ui/src/app/auth/signup/page.tsx b/ui/src/app/auth/signup/page.tsx index d9d98c18..ad43ec83 100644 --- a/ui/src/app/auth/signup/page.tsx +++ b/ui/src/app/auth/signup/page.tsx @@ -5,8 +5,9 @@ import { useState } from "react"; import { toast } from "sonner"; import { signupApiV1AuthSignupPost } from "@/client/sdk.gen"; +import { AuthEnterpriseCTA } from "@/components/auth/AuthEnterpriseCTA"; +import { AuthShell } from "@/components/auth/AuthShell"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -58,61 +59,59 @@ export default function SignupPage() { }; return ( -
- - - Create an account - Enter your details to get started - - -
-
- - setEmail(e.target.value)} - required - /> -
-
- - setPassword(e.target.value)} - required - minLength={8} - /> -
-
- - setConfirmPassword(e.target.value)} - required - minLength={8} - /> -
- -
-

- Already have an account?{" "} - - Sign in - -

-
-
-
+ }> +
+

Create an account

+

Enter your details to get started

+
+ +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + minLength={8} + /> +
+
+ + setConfirmPassword(e.target.value)} + required + minLength={8} + /> +
+ +
+ +

+ Already have an account?{" "} + + Sign in + +

+
); } diff --git a/ui/src/app/billing/page.tsx b/ui/src/app/billing/page.tsx new file mode 100644 index 00000000..9ac8a497 --- /dev/null +++ b/ui/src/app/billing/page.tsx @@ -0,0 +1,449 @@ +"use client"; + +import { + ChevronLeft, + ChevronRight, + CircleDollarSign, + CreditCard, + ExternalLink, + Info, + RefreshCw, +} from "lucide-react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import { createMpsCreditPurchaseUrlApiV1OrganizationsUsageMpsCreditsPurchaseUrlPost, getBillingCreditsApiV1OrganizationsBillingCreditsGet } from "@/client/sdk.gen"; +import type { MpsBillingCreditsResponse, MpsCreditLedgerEntryResponse } from "@/client/types.gen"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useAppConfig } from "@/context/AppConfigContext"; +import { useAuth } from "@/lib/auth"; + +const LEDGER_PAGE_SIZE = 50; + +const formatCredits = (value: number | null | undefined) => ( + (value ?? 0).toLocaleString(undefined, { + maximumFractionDigits: 2, + minimumFractionDigits: 0, + }) +); + +const formatAmount = (amountMinor?: number | null, currency?: string | null) => { + if (amountMinor == null) { + return "-"; + } + + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: currency || "USD", + }).format(amountMinor / 100); +}; + +const formatDate = (value: string) => ( + new Date(value).toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) +); + +const metricLabels: Record = { + voice_minutes: "Voice usage", + platform_usage: "Platform usage", +}; + +const formatTitleCase = (value: string | null | undefined) => ( + value ? value.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase()) : "-" +); + +const getLedgerEntryLabel = (entry: MpsCreditLedgerEntryResponse) => { + if (entry.metric_code) { + return metricLabels[entry.metric_code] ?? formatTitleCase(entry.metric_code); + } + + if (entry.entry_type === "grant") { + return "Credit grant"; + } + + if (entry.entry_type === "purchase") { + return "Credit purchase"; + } + + return formatTitleCase(entry.entry_type); +}; + +const formatBillableQuantity = (entry: MpsCreditLedgerEntryResponse) => { + if (entry.billable_quantity == null || !entry.quantity_unit) { + return null; + } + + const unit = entry.quantity_unit === "minute" ? "min" : entry.quantity_unit; + return `${formatCredits(entry.billable_quantity)} ${unit}`; +}; + +const getRunHref = (entry: MpsCreditLedgerEntryResponse) => { + if (!entry.workflow_id || !entry.workflow_run_id) { + return null; + } + + return `/workflow/${entry.workflow_id}/run/${entry.workflow_run_id}`; +}; + +const getPageFromSearchParams = ( + searchParams: { get: (name: string) => string | null }, +) => { + const pageParam = searchParams.get("page"); + const page = pageParam ? Number.parseInt(pageParam, 10) : 1; + return Number.isFinite(page) && page > 0 ? page : 1; +}; + +export default function BillingPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const auth = useAuth(); + const { config } = useAppConfig(); + const [credits, setCredits] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [purchasing, setPurchasing] = useState(false); + const [currentPage, setCurrentPage] = useState( + () => getPageFromSearchParams(searchParams), + ); + + const isBillingV2 = credits?.billing_version === "v2"; + const isOssMode = config?.deploymentMode === "oss"; + const canPurchaseCredits = isBillingV2 && !isOssMode; + 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 && ( + + )} +
+
+ + {isOssMode && ( +
+ +
+

Credit purchases are unavailable in OSS mode

+

+ You can't purchase credits from this self-hosted app. Sign up and + purchase credits at{" "} + + app.dograh.com + + + . Then add the generated service key in{" "} + + Model Configurations + + . Usage for that service key is visible in app.dograh.com. +

+
+
+ )} + +
+ + + {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/campaigns/new/page.tsx b/ui/src/app/campaigns/new/page.tsx index aab2adcf..c6499b4c 100644 --- a/ui/src/app/campaigns/new/page.tsx +++ b/ui/src/app/campaigns/new/page.tsx @@ -253,7 +253,7 @@ export default function NewCampaignPage() { } if (maxConcurrencyValue > effectiveLimit) { if (availableFromNumbersCount > 0 && availableFromNumbersCount < orgConcurrentLimit) { - toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. The selected configuration has ${availableFromNumbersCount} phone number(s) — add more CLIs to increase concurrency.`); + toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. The selected configuration has ${availableFromNumbersCount} phone number(s) - add more CLIs to increase concurrency.`); } else { toast.error(`Max concurrent calls cannot exceed organization limit (${effectiveLimit})`); } @@ -455,7 +455,7 @@ export default function NewCampaignPage() { value={config.id.toString()} > {config.name} ({config.provider}) - {config.is_default_outbound ? ' — default' : ''} + {config.is_default_outbound ? ' - default' : ''} )) )} diff --git a/ui/src/app/globals.css b/ui/src/app/globals.css index 0c7bdf9c..cc929a23 100644 --- a/ui/src/app/globals.css +++ b/ui/src/app/globals.css @@ -42,6 +42,8 @@ --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); + --color-cta: var(--cta); + --color-cta-foreground: var(--cta-foreground); } :root { @@ -77,6 +79,14 @@ --sidebar-ring: oklch(0.708 0 0); --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); + /* Single restrained warm accent — used only on primary CTAs + focus rings. */ + --cta: oklch(0.72 0.15 65); + --cta-foreground: oklch(0.16 0.02 60); + /* Giant faded "dograh" wordmark (authentic Proxima Nova letterforms traced + from the brand logo PNG — the font is commercial, so the lettering ships + as static artwork in /public; fill + 0.9% opacity are baked into the + files). Theme-switched here; consumed by .app-surface and .auth-imprint. */ + --brand-imprint: url("/brand-imprint-light.svg"); } .dark { @@ -111,6 +121,10 @@ --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.556 0 0); + /* Warm accent, slightly brighter against the near-black surfaces. */ + --cta: oklch(0.78 0.16 67); + --cta-foreground: oklch(0.16 0.02 60); + --brand-imprint: url("/brand-imprint-dark.svg"); } @layer base { @@ -135,3 +149,177 @@ .animate-spin-slow { animation: spin-slow 3s linear infinite; } + +@layer components { + /* CSS-only audio-waveform motif for the auth brand panel. A row of bars that + breathe at staggered intervals, evoking live voice. Decorative only. */ + .auth-waveform { + display: flex; + align-items: center; + gap: 0.3rem; + height: 3.5rem; + } + + .auth-waveform span { + display: block; + width: 0.25rem; + border-radius: 9999px; + background: linear-gradient( + to top, + color-mix(in oklch, var(--cta) 70%, transparent), + color-mix(in oklch, var(--cta) 25%, transparent) + ); + animation: auth-wave 1.4s ease-in-out infinite; + transform-origin: center; + } + + .auth-waveform span:nth-child(1) { animation-delay: 0s; height: 35%; } + .auth-waveform span:nth-child(2) { animation-delay: 0.15s; height: 65%; } + .auth-waveform span:nth-child(3) { animation-delay: 0.3s; height: 100%; } + .auth-waveform span:nth-child(4) { animation-delay: 0.45s; height: 55%; } + .auth-waveform span:nth-child(5) { animation-delay: 0.6s; height: 80%; } + .auth-waveform span:nth-child(6) { animation-delay: 0.3s; height: 45%; } + .auth-waveform span:nth-child(7) { animation-delay: 0.15s; height: 70%; } + .auth-waveform span:nth-child(8) { animation-delay: 0s; height: 30%; } + + @keyframes auth-wave { + 0%, 100% { transform: scaleY(0.4); opacity: 0.7; } + 50% { transform: scaleY(1); opacity: 1; } + } + + @media (prefers-reduced-motion: reduce) { + .auth-waveform span { animation: none; } + } + + /* Matte app background — flat charcoal (dark) / soft paper (light), NO + gradients, with one subtle graphic in BOTH themes: the giant faded + "dograh" wordmark (--brand-imprint, defined in :root/.dark) pinned to + the bottom of the viewport, echoing the dograh.com footer. */ + /* NOTE: background-attachment: fixed positions in VIEWPORT space but only + paints inside .app-surface, which starts right of the ~270px sidebar — + the +135px x-shift recentres the wordmark on the VISIBLE canvas. */ + .app-surface { + background-color: oklch(0.984 0.001 80); + background-image: var(--brand-imprint); + background-size: min(68vw, 980px) auto; + background-position: calc(50% + 135px) calc(100% - 24px); + background-repeat: no-repeat; + background-attachment: fixed; + } + /* Sidebar is offcanvas on small screens — true centre there. */ + @media (max-width: 767px) { + .app-surface { + background-position: center calc(100% - 24px); + } + } + .dark .app-surface { + background-color: oklch(0.165 0.002 80); + } + + /* Giant faded "dograh" imprint for the auth pages (applied to the AuthShell + form column, shared by Stack + OSS login/signup). Same --brand-imprint as + .app-surface; element-relative here (no fixed attachment), so it centers + and scales to whatever element carries the class. */ + .auth-imprint { + background-image: var(--brand-imprint); + background-size: min(86%, 920px) auto; + background-position: center calc(100% - 32px); + background-repeat: no-repeat; + } + +} + +/* --------------------------------------------------------------------------- + UN-LAYERED overrides. These intentionally live OUTSIDE @layer blocks: + they restyle elements that carry Tailwind utility classes (bg-sidebar, + rounded-lg, shadow-sm, border-*) and utilities sit in a later cascade + layer than @layer components — un-layered author CSS beats both. +--------------------------------------------------------------------------- */ + +/* Floating-dock sidebar: detached rounded panel. Targets the shadcn sidebar's + inner panel; applied via .app-sidebar-dock on . */ +.app-sidebar-dock [data-slot="sidebar-inner"] { + border-radius: 1.25rem; + overflow: hidden; +} +/* Flat carbon-charcoal panel with a soft light glow along the LEFT edge: + a 1px highlight line plus an inner bloom fading rightwards. */ +.dark .app-sidebar-dock [data-slot="sidebar-inner"] { + border-color: rgb(255 255 255 / 0.1); + background-color: oklch(0.18 0.002 80); + box-shadow: + inset 1px 0 0 rgb(255 255 255 / 0.1), + inset 3px 0 6px -4px rgb(255 255 255 / 0.08), + 0 24px 50px -14px rgb(0 0 0 / 0.85); +} + +/* Card surface ("Crosshatch + Top-Lit Edge", user-approved 2026-06-11 after a + 3-round design board): a 45° hairline twill weave at 1% laid over the panel + colour, plus — dark mode only — a brighter SOLID top border, like light + catching the machined top edge of the panel. Applied app-wide by the Card + primitive (components/ui/card.tsx). Un-layered so border-top-color beats + the border-border/60 utility. No gradients (user constraint). */ +.card-weave { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12'%3E%3Cpath d='M0 0l12 12M12 0L0 12' stroke='%23000000' stroke-opacity='.015' fill='none'/%3E%3C/svg%3E"); + background-repeat: repeat; +} +.dark .card-weave { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12'%3E%3Cpath d='M0 0l12 12M12 0L0 12' stroke='%23ffffff' stroke-opacity='.01' fill='none'/%3E%3C/svg%3E"); + border-top-color: rgb(255 255 255 / 0.2); +} + +/* Lead-form shell ("Ledger" treatment, user-approved 2026-06-11): neutral + charcoal slab where ONLY the header band is darker (body and footer share + the slab colour), muted compact labels, and underline-only fields with an + amber underline on focus. Applied by LeadModalShell; CaptchaChallenge + reuses slab + underline. */ +.dark .lead-form-slab { + background-color: oklch(0.215 0 0); + border-color: rgb(255 255 255 / 0.1); +} +/* Muted, compact labels — the big white default labels read amateurish. */ +.lead-form-underline label { + font-size: 0.8125rem; + font-weight: 500; + color: var(--muted-foreground); +} +/* Ghost placeholders: present on every field, but barely-there. */ +.lead-form-underline :is(input, textarea)::placeholder { + color: var(--muted-foreground); + opacity: 0.14; +} +.lead-form-underline [data-slot="select-trigger"][data-placeholder] { + color: color-mix(in oklab, var(--muted-foreground) 17%, transparent); +} +/* Underline-only fields: transparent box, hairline bottom border, amber + underline on the focused control. Compact heights keep rows tight. */ +.lead-form-underline :is(input, textarea, [data-slot="select-trigger"]) { + background-color: transparent; + border-top: 0; + border-left: 0; + border-right: 0; + border-bottom: 1px solid var(--border); + border-radius: 0; + box-shadow: none; + padding-left: 2px; + padding-right: 2px; +} +.lead-form-underline :is(input, [data-slot="select-trigger"]) { + height: 2.125rem; +} +.lead-form-underline textarea { + min-height: 3.25rem; +} +/* The phone country selector ships its own box — flatten it to match. */ +.lead-form-underline .react-international-phone-country-selector-button { + border: 0 !important; + border-bottom: 1px solid var(--border) !important; + border-radius: 0 !important; + background: transparent !important; +} +.lead-form-underline :is(input, textarea, [data-slot="select-trigger"]):focus-visible, +.lead-form-underline [data-slot="select-trigger"][data-state="open"] { + outline: none; + box-shadow: none; + border-bottom-color: var(--cta); +} diff --git a/ui/src/app/handler/[...stack]/BackButton.tsx b/ui/src/app/handler/[...stack]/BackButton.tsx index f2d730ae..5827c8ef 100644 --- a/ui/src/app/handler/[...stack]/BackButton.tsx +++ b/ui/src/app/handler/[...stack]/BackButton.tsx @@ -8,17 +8,26 @@ import { Button } from "@/components/ui/button"; export function BackButton() { const router = useRouter(); + // On a direct load (e.g. an OAuth redirect or a deep link to /handler/sign-in) + // there's no in-app history, so router.back() would bounce the user off-app. + // Fall back to the home route in that case. + const handleBack = () => { + if (typeof window !== "undefined" && window.history.length > 1) { + router.back(); + } else { + router.push("/"); + } + }; + return ( -
- -
+ ); } diff --git a/ui/src/app/handler/[...stack]/page.tsx b/ui/src/app/handler/[...stack]/page.tsx index 2ae94489..a862d5ef 100644 --- a/ui/src/app/handler/[...stack]/page.tsx +++ b/ui/src/app/handler/[...stack]/page.tsx @@ -1,18 +1,41 @@ -import { StackHandler } from "@stackframe/stack"; +import { StackHandler, StackTheme } from "@stackframe/stack"; +import { AuthEnterpriseCTA } from "@/components/auth/AuthEnterpriseCTA"; +import { AuthShell } from "@/components/auth/AuthShell"; import { getAuthProvider } from "@/lib/auth/config"; import { BackButton } from "./BackButton"; +import { stackAuthDarkTheme } from "./stack-theme"; + +// Stack Auth serves every auth page from this one catch-all. We give the brand +// split-screen shell to the user-facing FORM routes and render only the wide / +// interstitial "machine" routes full-page (so account-settings etc. aren't +// cramped into the narrow auth card). This is a BLOCKLIST, not an allowlist, so +// new or aliased form routes — Stack's `log-in`/`register` aliases, case/dash +// variants, email-verification, mfa, team-invitation — get the shell by default. +// Matching is normalized (lowercase, dashes stripped) to mirror Stack's own +// case- and dash-insensitive route resolution. +const FULL_PAGE_ROUTES = new Set([ + "accountsettings", + "oauthcallback", + "magiclinkcallback", + "signout", + "error", +]); export default async function Handler(props: unknown) { const authProvider = await getAuthProvider(); if (authProvider === "local") { return ( -
-

Local Auth Mode

-

Stack Auth handler is disabled when using local authentication.

-
+ }> +
+

Local Auth Mode

+

+ Stack Auth handler is disabled when using local authentication. +

+
+
); } @@ -20,16 +43,35 @@ export default async function Handler(props: unknown) { const { getStackServerApp } = await import("@/lib/auth/server"); const app = await getStackServerApp(); - return ( -
- -
- -
-
+ // Resolve the first route segment to decide layout. `params` is async in + // Next 15; awaiting it here does not consume it for StackHandler below. + let segment = ""; + try { + const { params } = props as { params?: Promise<{ stack?: string[] }> }; + const resolved = params ? await params : undefined; + segment = resolved?.stack?.[0] ?? ""; + } catch { + segment = ""; + } + const normalizedSegment = segment.toLowerCase().replace(/-/g, ""); + const isAuthForm = segment !== "" && !FULL_PAGE_ROUTES.has(normalizedSegment); + const showBackButton = !new Set(["signin", "login"]).has(normalizedSegment); + + const handler = ( + + + ); + + if (isAuthForm) { + return ( + }> + {showBackButton && } + {handler} + + ); + } + + // account-settings and machine routes render full-page (Stack's own layout). + return handler; } diff --git a/ui/src/app/handler/[...stack]/stack-theme.ts b/ui/src/app/handler/[...stack]/stack-theme.ts new file mode 100644 index 00000000..8d21be17 --- /dev/null +++ b/ui/src/app/handler/[...stack]/stack-theme.ts @@ -0,0 +1,34 @@ +// Dark token overrides for the embedded Stack Auth form so it blends into the +// auth card surface (zinc-900 background, zinc-100 foreground, the warm CTA +// accent on the primary button, zinc-800 borders/inputs). Stack's theme parser +// does not accept OKLCH strings, so keep these values in hex. + +import type { StackTheme } from "@stackframe/stack"; +import type { ComponentProps } from "react"; + +type ThemeConfig = NonNullable["theme"]>; + +export const stackAuthDarkTheme: ThemeConfig = { + dark: { + background: "#27272a", + foreground: "#fafafa", + card: "#27272a", + cardForeground: "#fafafa", + popover: "#27272a", + popoverForeground: "#fafafa", + primary: "#fbbf24", + primaryForeground: "#422006", + secondary: "#3f3f46", + secondaryForeground: "#fafafa", + muted: "#3f3f46", + mutedForeground: "#a1a1aa", + accent: "#3f3f46", + accentForeground: "#fafafa", + destructive: "#ef4444", + destructiveForeground: "#fafafa", + border: "#3f3f46", + input: "#3f3f46", + ring: "#fbbf24", + }, + radius: "0.625rem", +}; diff --git a/ui/src/app/impersonate/route.ts b/ui/src/app/impersonate/route.ts index c1119d29..421d995b 100644 --- a/ui/src/app/impersonate/route.ts +++ b/ui/src/app/impersonate/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; +import { getStackConfig } from "@/lib/auth/config"; + /** * Helper route that receives a refresh token via query parameters, stores it as * the regular Stack cookie *for the current sub-domain only* and finally @@ -18,6 +20,13 @@ export async function GET(request: NextRequest) { return new Response("Missing refresh_token", { status: 400 }); } + // The Stack session cookie is named `stack-refresh-`. The project + // id comes from the backend at runtime, so no inlined NEXT_PUBLIC_* is needed. + const stackConfig = await getStackConfig(); + if (!stackConfig) { + return new Response("Stack auth is not configured", { status: 400 }); + } + // Prepare redirect – if the supplied redirect path is an absolute URL we use // it as-is, otherwise we resolve it relative to the current request. const redirectUrl = redirectPath.startsWith("http") @@ -32,7 +41,7 @@ export async function GET(request: NextRequest) { // Store the refresh token cookie without an explicit domain so that it is // scoped to the current (sub-)domain. This avoids collisions between the // admin (superadmin.*) and the regular app (app.*) domains. - response.cookies.set(`stack-refresh-${process.env.NEXT_PUBLIC_STACK_PROJECT_ID}` as string, refreshToken, { + response.cookies.set(`stack-refresh-${stackConfig.projectId}`, refreshToken, { path: "/", maxAge, secure: true, diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx index b7961425..15b4cfb5 100644 --- a/ui/src/app/layout.tsx +++ b/ui/src/app/layout.tsx @@ -9,11 +9,12 @@ import AppLayout from "@/components/layout/AppLayout"; import PostHogIdentify from "@/components/PostHogIdentify"; import { SentryErrorBoundary } from "@/components/SentryErrorBoundary"; import SpinLoader from "@/components/SpinLoader"; +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"; @@ -39,21 +40,24 @@ export default function RootLayout({ }) { return ( - + - {/* Inline script to prevent flash of light theme - runs before React hydrates */} + {/* Inline script to prevent flash of light theme - runs before React hydrates. + Dark is the locked default: only an explicit stored 'light' opts out. */}