diff --git a/api/db/organization_usage_client.py b/api/db/organization_usage_client.py
index 928bf8b..49cc949 100644
--- a/api/db/organization_usage_client.py
+++ b/api/db/organization_usage_client.py
@@ -10,6 +10,7 @@ from sqlalchemy.orm import joinedload
from api.db.base_client import BaseDBClient
from api.db.filters import apply_workflow_run_filters
from api.db.models import (
+ OrganizationConfigurationModel,
OrganizationModel,
OrganizationUsageCycleModel,
UserConfigurationModel,
@@ -17,6 +18,7 @@ from api.db.models import (
WorkflowModel,
WorkflowRunModel,
)
+from api.enums import OrganizationConfigurationKey
from api.schemas.user_configuration import UserConfiguration
@@ -440,8 +442,19 @@ 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
+ == OrganizationConfigurationKey.MODEL_CONFIGURATION_PREFERENCES.value,
+ )
+ )
+ pref_obj = pref_result.scalar_one_or_none()
+ if pref_obj and pref_obj.value:
+ user_timezone = pref_obj.value.get("timezone") or user_timezone
+
if user_id:
config_result = await session.execute(
select(UserConfigurationModel).where(
@@ -453,7 +466,7 @@ class OrganizationUsageClient(BaseDBClient):
user_config = UserConfiguration.model_validate(
config_obj.configuration
)
- if user_config.timezone:
+ if user_config.timezone and user_timezone == "UTC":
user_timezone = user_config.timezone
# Validate timezone string
diff --git a/api/enums.py b/api/enums.py
index 1255705..89f1d97 100644
--- a/api/enums.py
+++ b/api/enums.py
@@ -89,6 +89,12 @@ class OrganizationConfigurationKey(Enum):
LANGFUSE_CREDENTIALS = (
"LANGFUSE_CREDENTIALS" # Org-level Langfuse tracing credentials
)
+ MODEL_CONFIGURATION_V2 = (
+ "MODEL_CONFIGURATION_V2" # Org-level v2 AI model configuration
+ )
+ MODEL_CONFIGURATION_PREFERENCES = (
+ "MODEL_CONFIGURATION_PREFERENCES" # Org-level model configuration preferences
+ )
class WorkflowStatus(Enum):
diff --git a/api/routes/auth.py b/api/routes/auth.py
index b6773a6..6083b87 100644
--- a/api/routes/auth.py
+++ b/api/routes/auth.py
@@ -3,9 +3,12 @@ from loguru import logger
from api.db import db_client
from api.db.models import UserModel
-from api.enums import PostHogEvent
+from api.enums import OrganizationConfigurationKey, PostHogEvent
from api.schemas.auth import AuthResponse, LoginRequest, SignupRequest, UserResponse
from api.services.auth.depends import create_user_configuration_with_mps_key, get_user
+from api.services.configuration.ai_model_configuration import (
+ convert_legacy_ai_model_configuration_to_v2,
+)
from api.services.posthog_client import capture_event
from api.utils.auth import create_jwt_token, hash_password, verify_password
@@ -47,6 +50,12 @@ async def signup(request: SignupRequest):
)
if mps_config:
await db_client.update_user_configuration(user.id, mps_config)
+ model_config_v2 = convert_legacy_ai_model_configuration_to_v2(mps_config)
+ await db_client.upsert_configuration(
+ organization.id,
+ OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
+ model_config_v2.model_dump(mode="json", exclude_none=True),
+ )
except Exception:
logger.warning(
"Failed to create default configuration for OSS user", exc_info=True
diff --git a/api/routes/knowledge_base.py b/api/routes/knowledge_base.py
index 5bf4b0a..d915687 100644
--- a/api/routes/knowledge_base.py
+++ b/api/routes/knowledge_base.py
@@ -369,6 +369,10 @@ async def search_chunks(
try:
# Import here to avoid circular dependency
+ from api.services.configuration.ai_model_configuration import (
+ apply_managed_embeddings_base_url,
+ get_resolved_ai_model_configuration,
+ )
from api.services.configuration.registry import ServiceProviders
from api.services.gen_ai import (
AzureOpenAIEmbeddingService,
@@ -376,10 +380,15 @@ async def search_chunks(
)
# Try to get user's embeddings configuration
- user_config = await db_client.get_user_configurations(user.id)
+ resolved_config = await get_resolved_ai_model_configuration(
+ user_id=user.id,
+ organization_id=user.selected_organization_id,
+ )
+ user_config = resolved_config.effective
embeddings_api_key = None
embeddings_model = None
embeddings_provider = None
+ embeddings_base_url = None
embeddings_endpoint = None
embeddings_api_version = None
@@ -388,6 +397,10 @@ async def search_chunks(
embeddings_model = user_config.embeddings.model
embeddings_provider = getattr(user_config.embeddings, "provider", None)
embeddings_endpoint = getattr(user_config.embeddings, "endpoint", None)
+ embeddings_base_url = apply_managed_embeddings_base_url(
+ provider=embeddings_provider,
+ base_url=getattr(user_config.embeddings, "base_url", None),
+ )
embeddings_api_version = getattr(
user_config.embeddings, "api_version", None
)
@@ -406,9 +419,7 @@ async def search_chunks(
db_client=db_client,
api_key=embeddings_api_key,
model_id=embeddings_model or "text-embedding-3-small",
- base_url=getattr(user_config.embeddings, "base_url", None)
- if user_config.embeddings
- else None,
+ base_url=embeddings_base_url,
)
# Perform search
diff --git a/api/routes/organization.py b/api/routes/organization.py
index f60a413..4006045 100644
--- a/api/routes/organization.py
+++ b/api/routes/organization.py
@@ -1,6 +1,6 @@
from typing import List, Optional
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Query
from loguru import logger
from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError
@@ -10,6 +10,14 @@ from api.db import db_client
from api.db.models import UserModel
from api.db.telephony_configuration_client import TelephonyConfigurationInUseError
from api.enums import OrganizationConfigurationKey, PostHogEvent
+from api.schemas.ai_model_configuration import (
+ DOGRAH_DEFAULT_LANGUAGE,
+ DOGRAH_DEFAULT_VOICE,
+ DOGRAH_SPEED_OPTIONS,
+ OrganizationAIModelConfigurationPreferences,
+ OrganizationAIModelConfigurationResponse,
+ OrganizationAIModelConfigurationV2,
+)
from api.schemas.telephony_config import (
TelephonyConfigRequest,
TelephonyConfigurationCreateRequest,
@@ -27,7 +35,28 @@ from api.schemas.telephony_phone_number import (
ProviderSyncStatus,
)
from api.services.auth.depends import get_user
-from api.services.configuration.masking import is_mask_of, mask_key
+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_preferences,
+ 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_preferences,
+ upsert_organization_ai_model_configuration_v2,
+)
+from api.services.configuration.check_validity import UserConfigurationValidator
+from api.services.configuration.defaults import DEFAULT_SERVICE_PROVIDERS
+from api.services.configuration.masking import is_mask_of, mask_key, mask_user_config
+from api.services.configuration.registry import (
+ DOGRAH_STT_LANGUAGES,
+ REGISTRY,
+ ServiceProviders,
+ ServiceType,
+)
from api.services.posthog_client import capture_event
from api.services.telephony import registry as telephony_registry
from api.services.telephony.factory import get_telephony_provider_by_id
@@ -159,6 +188,208 @@ async def get_telephony_config_warnings(user: UserModel = Depends(get_user)):
)
+# ---------------------------------------------------------------------------
+# AI model configurations v2
+# ---------------------------------------------------------------------------
+
+
+def _require_selected_organization(user: UserModel) -> int:
+ if not user.selected_organization_id:
+ raise HTTPException(status_code=400, detail="No organization selected")
+ return user.selected_organization_id
+
+
+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),
+ preferences=resolved.preferences
+ or OrganizationAIModelConfigurationPreferences(),
+ source=resolved.source,
+ )
+
+
+@router.get("/model-configurations/v2/defaults")
+async def get_model_configuration_v2_defaults(user: UserModel = Depends(get_user)):
+ _require_selected_organization(user)
+ byok_default_providers = {
+ service: provider
+ for service, provider in DEFAULT_SERVICE_PROVIDERS.items()
+ if provider != ServiceProviders.DOGRAH.value
+ }
+ return {
+ "dograh": {
+ "voices": [DOGRAH_DEFAULT_VOICE],
+ "speeds": list(DOGRAH_SPEED_OPTIONS),
+ "languages": DOGRAH_STT_LANGUAGES,
+ "defaults": {
+ "voice": DOGRAH_DEFAULT_VOICE,
+ "speed": 1.0,
+ "language": DOGRAH_DEFAULT_LANGUAGE,
+ },
+ },
+ "byok": {
+ "pipeline": {
+ "llm": _byok_provider_schemas(ServiceType.LLM),
+ "tts": _byok_provider_schemas(ServiceType.TTS),
+ "stt": _byok_provider_schemas(ServiceType.STT),
+ "embeddings": _byok_provider_schemas(ServiceType.EMBEDDINGS),
+ "default_providers": byok_default_providers,
+ },
+ "realtime": {
+ "realtime": _byok_provider_schemas(ServiceType.REALTIME),
+ "llm": _byok_provider_schemas(ServiceType.LLM),
+ "embeddings": _byok_provider_schemas(ServiceType.EMBEDDINGS),
+ "default_providers": byok_default_providers,
+ },
+ },
+ }
+
+
+@router.get(
+ "/model-configurations/v2",
+ response_model=OrganizationAIModelConfigurationResponse,
+)
+async def get_model_configuration_v2(user: UserModel = Depends(get_user)):
+ _require_selected_organization(user)
+ 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),
+):
+ organization_id = _require_selected_organization(user)
+ 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)):
+ _require_selected_organization(user)
+ 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),
+):
+ organization_id = _require_selected_organization(user)
+ existing = await get_organization_ai_model_configuration_v2(organization_id)
+ if existing is not None and not force:
+ raise HTTPException(
+ status_code=409,
+ detail="Organization already has a v2 model configuration",
+ )
+
+ legacy = await db_client.get_user_configurations(user.id)
+ try:
+ configuration = convert_legacy_ai_model_configuration_to_v2(legacy)
+ effective = compile_ai_model_configuration_v2(configuration)
+ await UserConfigurationValidator().validate(
+ effective,
+ organization_id=organization_id,
+ created_by=user.provider_id,
+ )
+ except ValueError as exc:
+ raise HTTPException(status_code=422, detail=exc.args[0])
+
+ await upsert_organization_ai_model_configuration_v2(
+ organization_id,
+ configuration,
+ )
+ await migrate_workflow_model_configurations_to_v2(
+ organization_id=organization_id,
+ fallback_user_config=legacy,
+ )
+ return await _model_configuration_v2_response(
+ user=user,
+ configuration=configuration,
+ )
+
+
+@router.get(
+ "/model-configurations/preferences",
+ response_model=OrganizationAIModelConfigurationPreferences,
+)
+async def get_model_configuration_preferences(user: UserModel = Depends(get_user)):
+ organization_id = _require_selected_organization(user)
+ return await get_organization_ai_model_configuration_preferences(organization_id)
+
+
+@router.put(
+ "/model-configurations/preferences",
+ response_model=OrganizationAIModelConfigurationPreferences,
+)
+async def save_model_configuration_preferences(
+ request: OrganizationAIModelConfigurationPreferences,
+ user: UserModel = Depends(get_user),
+):
+ organization_id = _require_selected_organization(user)
+ return await upsert_organization_ai_model_configuration_preferences(
+ organization_id,
+ request,
+ )
+
+
def preserve_masked_fields(provider: str, request_dict: dict, existing: dict):
"""If the client re-submitted a masked sensitive field, restore the original."""
for field_name in _sensitive_fields(provider):
diff --git a/api/routes/telephony.py b/api/routes/telephony.py
index 86bbbc0..ffe0ad8 100644
--- a/api/routes/telephony.py
+++ b/api/routes/telephony.py
@@ -82,7 +82,15 @@ 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."""
+ from api.services.configuration.ai_model_configuration import (
+ get_organization_ai_model_configuration_preferences,
+ )
+
user_configuration = await db_client.get_user_configurations(user.id)
+ preferences = await get_organization_ai_model_configuration_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,7 +124,11 @@ 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
+ or user_configuration.test_phone_number
+ )
if not phone_number:
raise HTTPException(
diff --git a/api/routes/user.py b/api/routes/user.py
index 20d0a41..a7016ef 100644
--- a/api/routes/user.py
+++ b/api/routes/user.py
@@ -10,6 +10,11 @@ from api.db.models import (
UserModel,
)
from api.services.auth.depends import get_user
+from api.services.configuration.ai_model_configuration import (
+ get_organization_ai_model_configuration_preferences,
+ get_resolved_ai_model_configuration,
+ upsert_organization_ai_model_configuration_preferences,
+)
from api.services.configuration.check_validity import (
APIKeyStatusResponse,
UserConfigurationValidator,
@@ -91,8 +96,18 @@ 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 resolved_config.preferences:
+ if resolved_config.preferences.test_phone_number is not None:
+ masked_config["test_phone_number"] = (
+ resolved_config.preferences.test_phone_number
+ )
+ if resolved_config.preferences.timezone is not None:
+ masked_config["timezone"] = resolved_config.preferences.timezone
# Add organization pricing info if available
if user.selected_organization_id:
@@ -144,8 +159,31 @@ async def update_user_configurations(
user.id, user_configurations
)
+ if user.selected_organization_id and (
+ request.test_phone_number is not None or request.timezone is not None
+ ):
+ preferences = await get_organization_ai_model_configuration_preferences(
+ user.selected_organization_id
+ )
+ if request.test_phone_number is not None:
+ preferences.test_phone_number = request.test_phone_number
+ if request.timezone is not None:
+ preferences.timezone = request.timezone
+ await upsert_organization_ai_model_configuration_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_ai_model_configuration_preferences(
+ user.selected_organization_id
+ )
+ if preferences.test_phone_number is not None:
+ masked_config["test_phone_number"] = preferences.test_phone_number
+ if preferences.timezone is not None:
+ masked_config["timezone"] = preferences.timezone
# Add organization pricing info if available
if user.selected_organization_id:
@@ -165,7 +203,11 @@ async def validate_user_configurations(
validity_ttl_seconds: int = Query(default=60, ge=0, le=86400),
user: UserModel = Depends(get_user),
) -> APIKeyStatusResponse:
- configurations = await db_client.get_user_configurations(user.id)
+ resolved_config = await get_resolved_ai_model_configuration(
+ user_id=user.id,
+ organization_id=user.selected_organization_id,
+ )
+ configurations = resolved_config.effective
if (
configurations.last_validated_at
diff --git a/api/routes/workflow.py b/api/routes/workflow.py
index 7adf086..9157c5c 100644
--- a/api/routes/workflow.py
+++ b/api/routes/workflow.py
@@ -16,9 +16,18 @@ from api.db.agent_trigger_client import TriggerPathConflictError
from api.db.models import UserModel
from api.db.workflow_template_client import WorkflowTemplateClient
from api.enums import CallType, PostHogEvent, StorageBackend
+from api.schemas.ai_model_configuration import OrganizationAIModelConfigurationV2
from api.schemas.workflow import WorkflowRunResponseSchema
from api.sdk_expose import sdk_expose
from api.services.auth.depends import get_user
+from api.services.configuration.ai_model_configuration import (
+ WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY,
+ check_for_masked_keys_in_ai_model_configuration_v2,
+ compile_ai_model_configuration_v2,
+ convert_legacy_ai_model_configuration_to_v2,
+ get_resolved_ai_model_configuration,
+ merge_ai_model_configuration_v2_secrets,
+)
from api.services.configuration.check_validity import UserConfigurationValidator
from api.services.configuration.masking import (
mask_workflow_configurations,
@@ -955,12 +964,74 @@ async def update_workflow(
existing_def,
)
- # Validate model_overrides: resolve onto global config, then
- # run the same validator used by the user-configurations endpoint.
- # Also stamp the current global API key into the override so the override
- # remains functional if the global config later switches to a different provider.
+ # Validate model overrides. v2 uses a complete workflow-level model
+ # configuration; legacy v1 uses partial service overlays.
workflow_configurations = request.workflow_configurations
- if workflow_configurations and workflow_configurations.get("model_overrides"):
+ if workflow_configurations and workflow_configurations.get(
+ WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY
+ ):
+ existing_workflow = await db_client.get_workflow(
+ workflow_id, organization_id=user.selected_organization_id
+ )
+ if existing_workflow is None:
+ raise HTTPException(
+ status_code=404, detail=f"Workflow with id {workflow_id} not found"
+ )
+ existing_draft = await db_client.get_draft_version(workflow_id)
+ existing_configs = (
+ existing_draft.workflow_configurations
+ if existing_draft
+ else existing_workflow.released_definition.workflow_configurations
+ )
+ existing_v2_override = (existing_configs or {}).get(
+ WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY
+ )
+ try:
+ incoming_v2_override = (
+ OrganizationAIModelConfigurationV2.model_validate(
+ workflow_configurations[
+ WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY
+ ]
+ )
+ )
+ existing_v2_override_config = (
+ OrganizationAIModelConfigurationV2.model_validate(
+ existing_v2_override
+ )
+ if existing_v2_override
+ else None
+ )
+ v2_override = merge_ai_model_configuration_v2_secrets(
+ incoming_v2_override,
+ existing_v2_override_config,
+ )
+ if existing_v2_override_config is None:
+ resolved_config = await get_resolved_ai_model_configuration(
+ user_id=user.id,
+ organization_id=user.selected_organization_id,
+ )
+ v2_override = merge_ai_model_configuration_v2_secrets(
+ v2_override,
+ resolved_config.organization_configuration,
+ )
+ check_for_masked_keys_in_ai_model_configuration_v2(v2_override)
+ effective = compile_ai_model_configuration_v2(v2_override)
+ await UserConfigurationValidator().validate(
+ effective,
+ organization_id=user.selected_organization_id,
+ created_by=user.provider_id,
+ )
+ except (ValidationError, ValueError) as e:
+ raise HTTPException(status_code=422, detail=str(e))
+ workflow_configurations = {
+ **workflow_configurations,
+ WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY: v2_override.model_dump(
+ mode="json",
+ exclude_none=True,
+ ),
+ }
+ workflow_configurations.pop("model_overrides", None)
+ elif workflow_configurations and workflow_configurations.get("model_overrides"):
existing_workflow = await db_client.get_workflow(
workflow_id, organization_id=user.selected_organization_id
)
@@ -978,24 +1049,46 @@ async def update_workflow(
workflow_configurations,
existing_configs,
)
- user_config = await db_client.get_user_configurations(user.id)
+ resolved_config = await get_resolved_ai_model_configuration(
+ user_id=user.id,
+ organization_id=user.selected_organization_id,
+ )
+ user_config = resolved_config.effective
try:
enriched_overrides = enrich_overrides_with_api_keys(
workflow_configurations["model_overrides"],
user_config,
)
effective = resolve_effective_config(user_config, enriched_overrides)
- await UserConfigurationValidator().validate(
- effective,
- organization_id=user.selected_organization_id,
- created_by=user.provider_id,
- )
+ if resolved_config.source == "organization_v2":
+ v2_override = convert_legacy_ai_model_configuration_to_v2(effective)
+ await UserConfigurationValidator().validate(
+ compile_ai_model_configuration_v2(v2_override),
+ organization_id=user.selected_organization_id,
+ created_by=user.provider_id,
+ )
+ else:
+ await UserConfigurationValidator().validate(
+ effective,
+ organization_id=user.selected_organization_id,
+ created_by=user.provider_id,
+ )
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
- workflow_configurations = {
- **workflow_configurations,
- "model_overrides": enriched_overrides,
- }
+ if resolved_config.source == "organization_v2":
+ workflow_configurations = {
+ **workflow_configurations,
+ WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY: v2_override.model_dump(
+ mode="json",
+ exclude_none=True,
+ ),
+ }
+ workflow_configurations.pop("model_overrides", None)
+ else:
+ workflow_configurations = {
+ **workflow_configurations,
+ "model_overrides": enriched_overrides,
+ }
# Reject upfront if any new trigger path collides with another
# workflow's trigger — keeps the workflow record from
diff --git a/api/schemas/ai_model_configuration.py b/api/schemas/ai_model_configuration.py
new file mode 100644
index 0000000..b1ac15d
--- /dev/null
+++ b/api/schemas/ai_model_configuration.py
@@ -0,0 +1,176 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, Field, model_validator
+
+from api.schemas.user_configuration import EffectiveAIModelConfiguration
+from api.services.configuration.registry import (
+ DograhEmbeddingsConfiguration,
+ DograhLLMService,
+ DograhSTTService,
+ DograhTTSService,
+ EmbeddingsConfig,
+ LLMConfig,
+ RealtimeConfig,
+ ServiceProviders,
+ STTConfig,
+ TTSConfig,
+)
+
+DOGRAH_SPEED_OPTIONS: tuple[float, ...] = (0.8, 1.0, 1.2)
+DOGRAH_DEFAULT_VOICE = "default"
+DOGRAH_DEFAULT_LANGUAGE = "multi"
+
+
+class DograhManagedAIModelConfiguration(BaseModel):
+ api_key: str
+ voice: str = DOGRAH_DEFAULT_VOICE
+ speed: float = Field(default=1.0)
+ language: str = DOGRAH_DEFAULT_LANGUAGE
+
+ @model_validator(mode="after")
+ def validate_speed(self):
+ if self.speed not in DOGRAH_SPEED_OPTIONS:
+ allowed = ", ".join(str(speed) for speed in DOGRAH_SPEED_OPTIONS)
+ raise ValueError(f"Dograh speed must be one of: {allowed}")
+ return self
+
+
+class BYOKPipelineAIModelConfiguration(BaseModel):
+ llm: LLMConfig
+ tts: TTSConfig
+ stt: STTConfig
+ embeddings: EmbeddingsConfig | None = None
+
+ @model_validator(mode="after")
+ def reject_dograh_providers(self):
+ _reject_dograh_provider("llm", self.llm)
+ _reject_dograh_provider("tts", self.tts)
+ _reject_dograh_provider("stt", self.stt)
+ _reject_dograh_provider("embeddings", self.embeddings)
+ return self
+
+
+class BYOKRealtimeAIModelConfiguration(BaseModel):
+ realtime: RealtimeConfig
+ llm: LLMConfig
+ embeddings: EmbeddingsConfig | None = None
+
+ @model_validator(mode="after")
+ def reject_dograh_providers(self):
+ _reject_dograh_provider("llm", self.llm)
+ _reject_dograh_provider("embeddings", self.embeddings)
+ return self
+
+
+class BYOKAIModelConfiguration(BaseModel):
+ mode: Literal["pipeline", "realtime"]
+ pipeline: BYOKPipelineAIModelConfiguration | None = None
+ realtime: BYOKRealtimeAIModelConfiguration | None = None
+
+ @model_validator(mode="after")
+ def validate_selected_mode(self):
+ if self.mode == "pipeline" and self.pipeline is None:
+ raise ValueError("byok.pipeline is required when byok.mode is pipeline")
+ if self.mode == "realtime" and self.realtime is None:
+ raise ValueError("byok.realtime is required when byok.mode is realtime")
+ return self
+
+
+class OrganizationAIModelConfigurationV2(BaseModel):
+ version: Literal[2] = 2
+ mode: Literal["dograh", "byok"]
+ dograh: DograhManagedAIModelConfiguration | None = None
+ byok: BYOKAIModelConfiguration | None = None
+
+ @model_validator(mode="after")
+ def validate_selected_mode(self):
+ if self.mode == "dograh" and self.dograh is None:
+ raise ValueError("dograh configuration is required when mode is dograh")
+ if self.mode == "byok" and self.byok is None:
+ raise ValueError("byok configuration is required when mode is byok")
+ return self
+
+
+class OrganizationAIModelConfigurationPreferences(BaseModel):
+ test_phone_number: str | None = None
+ timezone: str | None = None
+
+
+class OrganizationAIModelConfigurationResponse(BaseModel):
+ configuration: dict | None
+ effective_configuration: dict
+ preferences: OrganizationAIModelConfigurationPreferences
+ source: Literal["organization_v2", "legacy_user_v1", "empty"]
+
+
+def compile_ai_model_configuration_v2(
+ configuration: OrganizationAIModelConfigurationV2,
+) -> EffectiveAIModelConfiguration:
+ if configuration.mode == "dograh":
+ if configuration.dograh is None:
+ raise ValueError("dograh configuration is required")
+ return _compile_dograh_configuration(configuration.dograh)
+
+ if configuration.byok is None:
+ raise ValueError("byok configuration is required")
+ if configuration.byok.mode == "pipeline":
+ if configuration.byok.pipeline is None:
+ raise ValueError("byok.pipeline is required")
+ pipeline = configuration.byok.pipeline
+ return EffectiveAIModelConfiguration(
+ llm=pipeline.llm,
+ tts=pipeline.tts,
+ stt=pipeline.stt,
+ embeddings=pipeline.embeddings,
+ is_realtime=False,
+ )
+
+ if configuration.byok.realtime is None:
+ raise ValueError("byok.realtime is required")
+ realtime = configuration.byok.realtime
+ return EffectiveAIModelConfiguration(
+ llm=realtime.llm,
+ realtime=realtime.realtime,
+ embeddings=realtime.embeddings,
+ is_realtime=True,
+ )
+
+
+def _compile_dograh_configuration(
+ configuration: DograhManagedAIModelConfiguration,
+) -> EffectiveAIModelConfiguration:
+ return EffectiveAIModelConfiguration(
+ llm=DograhLLMService(
+ provider=ServiceProviders.DOGRAH,
+ api_key=configuration.api_key,
+ model="default",
+ ),
+ tts=DograhTTSService(
+ provider=ServiceProviders.DOGRAH,
+ api_key=configuration.api_key,
+ model="default",
+ voice=configuration.voice,
+ speed=configuration.speed,
+ ),
+ stt=DograhSTTService(
+ provider=ServiceProviders.DOGRAH,
+ api_key=configuration.api_key,
+ model="default",
+ language=configuration.language,
+ ),
+ embeddings=DograhEmbeddingsConfiguration(
+ provider=ServiceProviders.DOGRAH,
+ api_key=configuration.api_key,
+ model="default",
+ ),
+ is_realtime=False,
+ )
+
+
+def _reject_dograh_provider(section: str, service) -> None:
+ if service is None:
+ return
+ if getattr(service, "provider", None) == ServiceProviders.DOGRAH:
+ raise ValueError(f"BYOK {section} cannot use Dograh provider")
diff --git a/api/schemas/user_configuration.py b/api/schemas/user_configuration.py
index 2e62396..1f8d8ff 100644
--- a/api/schemas/user_configuration.py
+++ b/api/schemas/user_configuration.py
@@ -11,7 +11,7 @@ from api.services.configuration.registry import (
)
-class UserConfiguration(BaseModel):
+class EffectiveAIModelConfiguration(BaseModel):
llm: LLMConfig | None = None
stt: STTConfig | None = None
tts: TTSConfig | None = None
@@ -31,3 +31,7 @@ class UserConfiguration(BaseModel):
if isinstance(realtime, dict) and not realtime.get("api_key"):
data.pop("realtime", None)
return data
+
+
+# Backward-compatible alias for legacy persistence and existing call sites.
+UserConfiguration = EffectiveAIModelConfiguration
diff --git a/api/services/auth/depends.py b/api/services/auth/depends.py
index 7ffabfb..328eb40 100644
--- a/api/services/auth/depends.py
+++ b/api/services/auth/depends.py
@@ -119,6 +119,19 @@ async def get_user(
await db_client.update_user_configuration(
user_model.id, mps_config
)
+ from api.enums import OrganizationConfigurationKey
+ from api.services.configuration.ai_model_configuration import (
+ convert_legacy_ai_model_configuration_to_v2,
+ )
+
+ model_config_v2 = convert_legacy_ai_model_configuration_to_v2(
+ mps_config
+ )
+ await db_client.upsert_configuration(
+ organization.id,
+ OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
+ model_config_v2.model_dump(mode="json", exclude_none=True),
+ )
except Exception as exc:
raise HTTPException(
diff --git a/api/services/configuration/ai_model_configuration.py b/api/services/configuration/ai_model_configuration.py
new file mode 100644
index 0000000..ed7a4b7
--- /dev/null
+++ b/api/services/configuration/ai_model_configuration.py
@@ -0,0 +1,532 @@
+from __future__ import annotations
+
+import copy
+from dataclasses import dataclass
+from inspect import isawaitable
+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,
+ OrganizationAIModelConfigurationPreferences,
+ OrganizationAIModelConfigurationV2,
+ compile_ai_model_configuration_v2,
+)
+from api.schemas.user_configuration import EffectiveAIModelConfiguration
+from api.services.configuration.masking import (
+ SERVICE_SECRET_FIELDS,
+ contains_masked_key,
+ mask_key,
+ resolve_masked_api_keys,
+)
+from api.services.configuration.registry import ServiceProviders
+from api.services.configuration.resolve import resolve_effective_config
+
+AIModelConfigurationSource = Literal["organization_v2", "legacy_user_v1", "empty"]
+WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY = "model_configuration_v2_override"
+
+
+@dataclass
+class ResolvedAIModelConfiguration:
+ effective: EffectiveAIModelConfiguration
+ source: AIModelConfigurationSource
+ organization_configuration: OrganizationAIModelConfigurationV2 | None = None
+ preferences: OrganizationAIModelConfigurationPreferences | 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:
+ preferences = await get_organization_ai_model_configuration_preferences(
+ organization_id
+ )
+ 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,
+ preferences=preferences,
+ )
+
+ if user_id is None:
+ return ResolvedAIModelConfiguration(
+ effective=EffectiveAIModelConfiguration(),
+ source="empty",
+ preferences=preferences,
+ )
+
+ legacy = await db_client.get_user_configurations(user_id)
+ return ResolvedAIModelConfiguration(
+ effective=legacy,
+ source="legacy_user_v1" if _has_model_services(legacy) else "empty",
+ preferences=preferences,
+ )
+
+
+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 get_organization_ai_model_configuration_preferences(
+ organization_id: int | None,
+ db=None,
+) -> OrganizationAIModelConfigurationPreferences:
+ if organization_id is None:
+ return OrganizationAIModelConfigurationPreferences()
+ db = db or db_client
+ row = db.get_configuration(
+ organization_id,
+ OrganizationConfigurationKey.MODEL_CONFIGURATION_PREFERENCES.value,
+ )
+ if isawaitable(row):
+ row = await row
+ if row is None or not row.value:
+ return OrganizationAIModelConfigurationPreferences()
+ if not isinstance(row.value, dict):
+ return OrganizationAIModelConfigurationPreferences()
+ try:
+ return OrganizationAIModelConfigurationPreferences.model_validate(row.value)
+ except ValidationError as exc:
+ logger.warning(
+ "Invalid org AI model configuration preferences for organization "
+ f"{organization_id}: {exc}. Returning defaults."
+ )
+ return OrganizationAIModelConfigurationPreferences()
+
+
+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 upsert_organization_ai_model_configuration_preferences(
+ organization_id: int,
+ preferences: OrganizationAIModelConfigurationPreferences,
+) -> OrganizationAIModelConfigurationPreferences:
+ await db_client.upsert_configuration(
+ organization_id,
+ OrganizationConfigurationKey.MODEL_CONFIGURATION_PREFERENCES.value,
+ preferences.model_dump(mode="json", exclude_none=True),
+ )
+ return preferences
+
+
+async def migrate_workflow_model_configurations_to_v2(
+ *,
+ organization_id: int,
+ fallback_user_config: EffectiveAIModelConfiguration,
+) -> WorkflowAIModelConfigurationMigrationResult:
+ workflows = await _list_workflows_for_model_configuration_migration(organization_id)
+ owner_configs: dict[int, EffectiveAIModelConfiguration] = {}
+ workflow_updates: list[tuple[int, dict]] = []
+ definition_updates: list[tuple[int, dict]] = []
+ migrated_workflow_ids: set[int] = set()
+
+ for workflow in workflows:
+ base_config = fallback_user_config
+ if workflow.user_id is not None:
+ if workflow.user_id not in owner_configs:
+ owner_configs[
+ workflow.user_id
+ ] = await db_client.get_user_configurations(workflow.user_id)
+ base_config = owner_configs[workflow.user_id]
+
+ workflow_configs, workflow_changed = (
+ migrate_workflow_configuration_model_override_to_v2(
+ workflow.workflow_configurations,
+ base_config,
+ )
+ )
+ if workflow_changed:
+ workflow_updates.append((workflow.id, workflow_configs))
+ migrated_workflow_ids.add(workflow.id)
+
+ for definition in workflow.definitions:
+ definition_configs, definition_changed = (
+ migrate_workflow_configuration_model_override_to_v2(
+ definition.workflow_configurations,
+ base_config,
+ )
+ )
+ if definition_changed:
+ definition_updates.append((definition.id, definition_configs))
+ migrated_workflow_ids.add(workflow.id)
+
+ if workflow_updates or definition_updates:
+ async with db_client.async_session() as session:
+ for workflow_id, workflow_configs in workflow_updates:
+ await session.execute(
+ update(WorkflowModel)
+ .where(WorkflowModel.id == workflow_id)
+ .values(workflow_configurations=workflow_configs)
+ )
+ for definition_id, definition_configs in definition_updates:
+ await session.execute(
+ update(WorkflowDefinitionModel)
+ .where(WorkflowDefinitionModel.id == definition_id)
+ .values(workflow_configurations=definition_configs)
+ )
+ await session.commit()
+
+ return WorkflowAIModelConfigurationMigrationResult(
+ workflow_count=len(migrated_workflow_ids),
+ definition_count=len(definition_updates),
+ workflow_ids=sorted(migrated_workflow_ids),
+ )
+
+
+def migrate_workflow_configuration_model_override_to_v2(
+ workflow_configurations: dict | None,
+ base_config: EffectiveAIModelConfiguration,
+) -> tuple[dict, bool]:
+ if not isinstance(workflow_configurations, dict):
+ return {}, False
+
+ migrated = copy.deepcopy(workflow_configurations)
+ model_overrides = migrated.get("model_overrides")
+ existing_v2_override = migrated.get(WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY)
+ if not isinstance(model_overrides, dict):
+ if "model_overrides" in migrated:
+ migrated.pop("model_overrides", None)
+ return migrated, True
+ return migrated, False
+
+ if not existing_v2_override:
+ effective = resolve_effective_config(base_config, model_overrides)
+ v2_override = convert_legacy_ai_model_configuration_to_v2(effective)
+ migrated[WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY] = v2_override.model_dump(
+ mode="json", exclude_none=True
+ )
+ migrated.pop("model_overrides", None)
+ return migrated, True
+
+
+def merge_ai_model_configuration_v2_secrets(
+ incoming: OrganizationAIModelConfigurationV2,
+ existing: OrganizationAIModelConfigurationV2 | None,
+) -> OrganizationAIModelConfigurationV2:
+ if existing is None:
+ return incoming
+
+ incoming_dict = incoming.model_dump(mode="json", exclude_none=True)
+ existing_dict = existing.model_dump(mode="json", exclude_none=True)
+
+ if incoming_dict.get("mode") == "dograh" and existing_dict.get("mode") == "dograh":
+ incoming_dograh = incoming_dict.get("dograh") or {}
+ existing_dograh = existing_dict.get("dograh") or {}
+ incoming_key = incoming_dograh.get("api_key")
+ existing_key = existing_dograh.get("api_key")
+ if incoming_key and existing_key and contains_masked_key(incoming_key):
+ incoming_dograh["api_key"] = resolve_masked_api_keys(
+ incoming_key,
+ existing_key,
+ )
+
+ if incoming_dict.get("mode") == "byok" and existing_dict.get("mode") == "byok":
+ _merge_byok_secret_fields(incoming_dict.get("byok"), existing_dict.get("byok"))
+
+ return OrganizationAIModelConfigurationV2.model_validate(incoming_dict)
+
+
+def check_for_masked_keys_in_ai_model_configuration_v2(
+ configuration: OrganizationAIModelConfigurationV2,
+) -> None:
+ data = configuration.model_dump(mode="json", exclude_none=True)
+ _raise_if_masked_secret(data)
+
+
+def mask_ai_model_configuration_v2(
+ configuration: OrganizationAIModelConfigurationV2 | None,
+) -> dict | None:
+ if configuration is None:
+ return None
+ data = configuration.model_dump(mode="json", exclude_none=True)
+ _mask_secret_fields(data)
+ return data
+
+
+def convert_legacy_ai_model_configuration_to_v2(
+ configuration: EffectiveAIModelConfiguration,
+) -> OrganizationAIModelConfigurationV2:
+ dograh_key = _first_dograh_api_key(configuration)
+ if dograh_key:
+ return _convert_any_dograh_legacy_configuration(configuration, dograh_key)
+
+ if configuration.is_realtime:
+ if configuration.realtime is None or configuration.llm is None:
+ raise ValueError("Realtime legacy configuration is incomplete")
+ return OrganizationAIModelConfigurationV2(
+ mode="byok",
+ byok=BYOKAIModelConfiguration(
+ mode="realtime",
+ realtime=BYOKRealtimeAIModelConfiguration(
+ realtime=configuration.realtime,
+ llm=configuration.llm,
+ embeddings=configuration.embeddings,
+ ),
+ ),
+ )
+
+ if (
+ configuration.llm is None
+ or configuration.tts is None
+ or configuration.stt is None
+ ):
+ raise ValueError("Pipeline legacy configuration is incomplete")
+ return OrganizationAIModelConfigurationV2(
+ mode="byok",
+ byok=BYOKAIModelConfiguration(
+ mode="pipeline",
+ pipeline=BYOKPipelineAIModelConfiguration(
+ llm=configuration.llm,
+ tts=configuration.tts,
+ stt=configuration.stt,
+ embeddings=configuration.embeddings,
+ ),
+ ),
+ )
+
+
+def dograh_embeddings_base_url() -> str:
+ return f"{MPS_API_URL}/api/v1/llm"
+
+
+def apply_managed_embeddings_base_url(
+ *,
+ provider: str | None,
+ base_url: str | None,
+) -> str | None:
+ if provider == ServiceProviders.DOGRAH.value or provider == ServiceProviders.DOGRAH:
+ return dograh_embeddings_base_url()
+ return base_url
+
+
+def _merge_byok_secret_fields(incoming_byok: dict | None, existing_byok: dict | None):
+ if not isinstance(incoming_byok, dict) or not isinstance(existing_byok, dict):
+ return
+ incoming_mode = incoming_byok.get("mode")
+ existing_mode = existing_byok.get("mode")
+ if incoming_mode != existing_mode:
+ return
+ section_names = (
+ ("llm", "tts", "stt", "embeddings")
+ if incoming_mode == "pipeline"
+ else ("realtime", "llm", "embeddings")
+ )
+ incoming_container = incoming_byok.get(incoming_mode)
+ existing_container = existing_byok.get(existing_mode)
+ if not isinstance(incoming_container, dict) or not isinstance(
+ existing_container, dict
+ ):
+ return
+ for section_name in section_names:
+ incoming_section = incoming_container.get(section_name)
+ existing_section = existing_container.get(section_name)
+ if isinstance(incoming_section, dict) and isinstance(existing_section, dict):
+ _merge_service_secret_fields(incoming_section, existing_section)
+
+
+async def _list_workflows_for_model_configuration_migration(
+ organization_id: int,
+) -> list[WorkflowModel]:
+ async with db_client.async_session() as session:
+ result = await session.execute(
+ select(WorkflowModel)
+ .options(selectinload(WorkflowModel.definitions))
+ .where(WorkflowModel.organization_id == organization_id)
+ )
+ return list(result.scalars().unique().all())
+
+
+def _merge_service_secret_fields(incoming: dict, existing: dict):
+ if (
+ incoming.get("provider") is not None
+ and existing.get("provider") is not None
+ and incoming.get("provider") != existing.get("provider")
+ ):
+ return
+ for secret_field in SERVICE_SECRET_FIELDS:
+ if secret_field not in existing:
+ continue
+ incoming_secret = incoming.get(secret_field)
+ existing_secret = existing[secret_field]
+ if incoming_secret is None:
+ incoming[secret_field] = existing_secret
+ elif contains_masked_key(incoming_secret):
+ incoming[secret_field] = resolve_masked_api_keys(
+ incoming_secret,
+ existing_secret,
+ )
+
+
+def _raise_if_masked_secret(value):
+ if isinstance(value, dict):
+ for key, nested in value.items():
+ if key in SERVICE_SECRET_FIELDS and contains_masked_key(nested):
+ raise ValueError(
+ f"The {key} appears to be masked. Please provide the actual "
+ "value, not the masked value."
+ )
+ _raise_if_masked_secret(nested)
+ elif isinstance(value, list):
+ for item in value:
+ _raise_if_masked_secret(item)
+
+
+def _mask_secret_fields(value):
+ if isinstance(value, dict):
+ for key, nested in list(value.items()):
+ if key in SERVICE_SECRET_FIELDS and nested:
+ value[key] = _mask_secret_value(nested)
+ else:
+ _mask_secret_fields(nested)
+ elif isinstance(value, list):
+ for item in value:
+ _mask_secret_fields(item)
+
+
+def _mask_secret_value(value):
+ if isinstance(value, list):
+ return [mask_key(item) for item in value]
+ return mask_key(value)
+
+
+def _has_model_services(configuration: EffectiveAIModelConfiguration) -> bool:
+ return any(
+ service is not None
+ for service in (
+ configuration.llm,
+ configuration.tts,
+ configuration.stt,
+ configuration.embeddings,
+ configuration.realtime,
+ )
+ )
+
+
+def _convert_any_dograh_legacy_configuration(
+ configuration: EffectiveAIModelConfiguration,
+ dograh_key: str,
+) -> OrganizationAIModelConfigurationV2:
+ speed = getattr(configuration.tts, "speed", 1.0)
+ if speed not in DOGRAH_SPEED_OPTIONS:
+ speed = 1.0
+ return OrganizationAIModelConfigurationV2(
+ mode="dograh",
+ dograh=DograhManagedAIModelConfiguration(
+ api_key=dograh_key,
+ voice=getattr(configuration.tts, "voice", DOGRAH_DEFAULT_VOICE)
+ or DOGRAH_DEFAULT_VOICE,
+ speed=speed,
+ language=getattr(configuration.stt, "language", DOGRAH_DEFAULT_LANGUAGE)
+ or DOGRAH_DEFAULT_LANGUAGE,
+ ),
+ )
+
+
+def _first_dograh_api_key(configuration: EffectiveAIModelConfiguration) -> str | None:
+ for service in (
+ configuration.llm,
+ configuration.tts,
+ configuration.stt,
+ configuration.embeddings,
+ configuration.realtime,
+ ):
+ if service is None or _provider(service) != ServiceProviders.DOGRAH:
+ continue
+ try:
+ return _single_api_key(service)
+ except ValueError:
+ continue
+ return None
+
+
+def _provider(service):
+ return getattr(service, "provider", None)
+
+
+def _single_api_key(service) -> str:
+ if hasattr(service, "get_all_api_keys"):
+ keys = service.get_all_api_keys()
+ if len(keys) != 1:
+ raise ValueError("Expected exactly one API key")
+ return keys[0]
+ key = getattr(service, "api_key", None)
+ if not key:
+ raise ValueError("Expected an API key")
+ return key
diff --git a/api/services/configuration/masking.py b/api/services/configuration/masking.py
index 877cad9..adbc621 100644
--- a/api/services/configuration/masking.py
+++ b/api/services/configuration/masking.py
@@ -151,21 +151,35 @@ def mask_workflow_configurations(config: Optional[Dict]) -> Optional[Dict]:
masked = copy.deepcopy(config)
model_overrides = masked.get("model_overrides")
- if not isinstance(model_overrides, dict):
- return masked
+ if isinstance(model_overrides, dict):
+ for section in MODEL_OVERRIDE_FIELDS:
+ override = model_overrides.get(section)
+ if not isinstance(override, dict):
+ continue
+ for secret_field in SERVICE_SECRET_FIELDS:
+ raw = override.get(secret_field)
+ if raw:
+ override[secret_field] = _mask_secret_value(raw)
- for section in MODEL_OVERRIDE_FIELDS:
- override = model_overrides.get(section)
- if not isinstance(override, dict):
- continue
- for secret_field in SERVICE_SECRET_FIELDS:
- raw = override.get(secret_field)
- if raw:
- override[secret_field] = _mask_secret_value(raw)
+ v2_override = masked.get("model_configuration_v2_override")
+ if isinstance(v2_override, dict):
+ _mask_nested_service_secrets(v2_override)
return masked
+def _mask_nested_service_secrets(value):
+ if isinstance(value, dict):
+ for key, nested in list(value.items()):
+ if key in SERVICE_SECRET_FIELDS and nested:
+ value[key] = _mask_secret_value(nested)
+ else:
+ _mask_nested_service_secrets(nested)
+ elif isinstance(value, list):
+ for item in value:
+ _mask_nested_service_secrets(item)
+
+
# ---------------------------------------------------------------------------
# Workflow definition helpers – mask / merge node API keys
# ---------------------------------------------------------------------------
diff --git a/api/services/configuration/registry.py b/api/services/configuration/registry.py
index f05c5f7..9fa9ee3 100644
--- a/api/services/configuration/registry.py
+++ b/api/services/configuration/registry.py
@@ -1472,11 +1472,26 @@ class AzureOpenAIEmbeddingsConfiguration(BaseEmbeddingsConfiguration):
)
+DOGRAH_EMBEDDING_MODELS = ["default"]
+
+
+@register_embeddings
+class DograhEmbeddingsConfiguration(BaseEmbeddingsConfiguration):
+ model_config = DOGRAH_PROVIDER_MODEL_CONFIG
+ provider: Literal[ServiceProviders.DOGRAH] = ServiceProviders.DOGRAH
+ model: str = Field(
+ default="default",
+ description="Dograh-managed embedding model.",
+ json_schema_extra={"examples": DOGRAH_EMBEDDING_MODELS},
+ )
+
+
EmbeddingsConfig = Annotated[
Union[
OpenAIEmbeddingsConfiguration,
OpenRouterEmbeddingsConfiguration,
AzureOpenAIEmbeddingsConfiguration,
+ DograhEmbeddingsConfiguration,
],
Field(discriminator="provider"),
]
diff --git a/api/services/pipecat/run_pipeline.py b/api/services/pipecat/run_pipeline.py
index 7ce41d8..63c11f5 100644
--- a/api/services/pipecat/run_pipeline.py
+++ b/api/services/pipecat/run_pipeline.py
@@ -195,14 +195,17 @@ async def run_pipeline_telephony(
# Resolve effective user config here so the transport can tune its
# bot-stopped-speaking fallback based on is_realtime; pass the resolved
# values into _run_pipeline so it doesn't fetch them again.
- from api.services.configuration.resolve import resolve_effective_config
+ from api.services.configuration.ai_model_configuration import (
+ get_effective_ai_model_configuration_for_workflow,
+ )
- user_config = await db_client.get_user_configurations(user_id)
run_configs = (
(workflow_run.definition.workflow_configurations or {}) if workflow_run else {}
)
- user_config = resolve_effective_config(
- user_config, run_configs.get("model_overrides")
+ user_config = await get_effective_ai_model_configuration_for_workflow(
+ user_id=user_id,
+ organization_id=workflow.organization_id if workflow else None,
+ workflow_configurations=run_configs,
)
is_realtime = bool(user_config.is_realtime and user_config.realtime is not None)
@@ -272,15 +275,18 @@ async def run_pipeline_smallwebrtc(
# Resolve workflow_run + effective user_config here so the transport can
# tune its bot-stopped-speaking fallback based on is_realtime. _run_pipeline
# reuses these via kwargs so we don't fetch twice.
- from api.services.configuration.resolve import resolve_effective_config
+ from api.services.configuration.ai_model_configuration import (
+ get_effective_ai_model_configuration_for_workflow,
+ )
workflow_run = await db_client.get_workflow_run(workflow_run_id, user_id)
- user_config = await db_client.get_user_configurations(user_id)
run_configs = (
(workflow_run.definition.workflow_configurations or {}) if workflow_run else {}
)
- user_config = resolve_effective_config(
- user_config, run_configs.get("model_overrides")
+ user_config = await get_effective_ai_model_configuration_for_workflow(
+ user_id=user_id,
+ organization_id=workflow.organization_id if workflow else None,
+ workflow_configurations=run_configs,
)
is_realtime = bool(user_config.is_realtime and user_config.realtime is not None)
@@ -380,11 +386,14 @@ async def _run_pipeline(
# Resolve model overrides from the version onto global user config (skip
# when the caller already resolved it).
if resolved_user_config is None:
- from api.services.configuration.resolve import resolve_effective_config
+ from api.services.configuration.ai_model_configuration import (
+ get_effective_ai_model_configuration_for_workflow,
+ )
- user_config = await db_client.get_user_configurations(user_id)
- user_config = resolve_effective_config(
- user_config, run_configs.get("model_overrides")
+ user_config = await get_effective_ai_model_configuration_for_workflow(
+ user_id=user_id,
+ organization_id=workflow.organization_id,
+ workflow_configurations=run_configs,
)
else:
user_config = resolved_user_config
@@ -508,10 +517,17 @@ async def _run_pipeline(
embeddings_endpoint = None
embeddings_api_version = None
if user_config and user_config.embeddings:
+ from api.services.configuration.ai_model_configuration import (
+ apply_managed_embeddings_base_url,
+ )
+
embeddings_api_key = user_config.embeddings.api_key
embeddings_model = user_config.embeddings.model
embeddings_provider = getattr(user_config.embeddings, "provider", None)
- embeddings_base_url = getattr(user_config.embeddings, "base_url", None)
+ embeddings_base_url = apply_managed_embeddings_base_url(
+ provider=embeddings_provider,
+ base_url=getattr(user_config.embeddings, "base_url", None),
+ )
embeddings_endpoint = getattr(user_config.embeddings, "endpoint", None)
embeddings_api_version = getattr(user_config.embeddings, "api_version", None)
diff --git a/api/services/quota_service.py b/api/services/quota_service.py
index 23c7120..6114ae9 100644
--- a/api/services/quota_service.py
+++ b/api/services/quota_service.py
@@ -10,8 +10,10 @@ from loguru import logger
from api.db import db_client
from api.db.models import UserModel
+from api.services.configuration.ai_model_configuration import (
+ get_effective_ai_model_configuration_for_workflow,
+)
from api.services.configuration.registry import ServiceProviders
-from api.services.configuration.resolve import resolve_effective_config
from api.services.mps_service_key_client import mps_service_key_client
@@ -48,17 +50,20 @@ async def check_dograh_quota(
if quota is insufficient.
"""
try:
- # Get user configurations
- user_config = await db_client.get_user_configurations(user.id)
+ organization_id = user.selected_organization_id
+ workflow_configurations = None
if workflow_id is not None:
workflow = await db_client.get_workflow_by_id(workflow_id)
if workflow:
- model_overrides = (workflow.workflow_configurations or {}).get(
- "model_overrides"
- )
- if model_overrides:
- user_config = resolve_effective_config(user_config, model_overrides)
+ organization_id = workflow.organization_id
+ workflow_configurations = workflow.workflow_configurations
+
+ user_config = await get_effective_ai_model_configuration_for_workflow(
+ user_id=user.id,
+ organization_id=organization_id,
+ workflow_configurations=workflow_configurations,
+ )
# Check if user is using any Dograh service
using_dograh = False
@@ -76,6 +81,13 @@ async def check_dograh_quota(
using_dograh = True
dograh_api_keys.add(user_config.tts.api_key)
+ if (
+ user_config.embeddings
+ and user_config.embeddings.provider == ServiceProviders.DOGRAH
+ ):
+ using_dograh = True
+ dograh_api_keys.add(user_config.embeddings.api_key)
+
# If not using Dograh, quota check passes
if not using_dograh:
return QuotaCheckResult(has_quota=True)
@@ -84,7 +96,9 @@ async def check_dograh_quota(
for api_key in dograh_api_keys:
try:
usage = await mps_service_key_client.check_service_key_usage(
- api_key, created_by=user.provider_id
+ api_key,
+ organization_id=organization_id,
+ created_by=user.provider_id,
)
remaining = usage.get("remaining_credits", 0.0)
diff --git a/api/services/workflow/qa/llm_config.py b/api/services/workflow/qa/llm_config.py
index 9c1159a..ec3ae41 100644
--- a/api/services/workflow/qa/llm_config.py
+++ b/api/services/workflow/qa/llm_config.py
@@ -2,7 +2,6 @@
import random
-from api.db import db_client
from api.db.models import WorkflowRunModel
from api.services.workflow.dto import QANodeData
@@ -54,7 +53,27 @@ async def resolve_user_llm_config(
llm_config: dict = {}
if user_id:
- user_configuration = await db_client.get_user_configurations(user_id)
+ from api.services.configuration.ai_model_configuration import (
+ get_effective_ai_model_configuration_for_workflow,
+ )
+
+ workflow_configurations = {}
+ if workflow_run.definition:
+ workflow_configurations = (
+ workflow_run.definition.workflow_configurations or {}
+ )
+ elif workflow_run.workflow:
+ workflow_configurations = (
+ workflow_run.workflow.workflow_configurations or {}
+ )
+
+ user_configuration = await get_effective_ai_model_configuration_for_workflow(
+ user_id=user_id,
+ organization_id=workflow_run.workflow.organization_id
+ if workflow_run.workflow
+ else None,
+ workflow_configurations=workflow_configurations,
+ )
llm_config = user_configuration.model_dump(exclude_none=True).get("llm", {})
provider = llm_config.get("provider", "openai")
diff --git a/api/services/workflow/text_chat_runner.py b/api/services/workflow/text_chat_runner.py
index 83a4ad1..59073c8 100644
--- a/api/services/workflow/text_chat_runner.py
+++ b/api/services/workflow/text_chat_runner.py
@@ -32,7 +32,6 @@ from pipecat.utils.run_context import set_current_org_id
from api.db import db_client
from api.enums import WorkflowRunMode, WorkflowRunState
-from api.services.configuration.resolve import resolve_effective_config
from api.services.pipecat.audio_config import create_audio_config
from api.services.pipecat.pipeline_builder import create_pipeline_task
from api.services.pipecat.pipeline_metrics_aggregator import (
@@ -410,9 +409,14 @@ async def execute_text_chat_pending_turn(
run_definition = workflow_run.definition
run_configs = run_definition.workflow_configurations or {}
- user_config = await db_client.get_user_configurations(workflow_run.workflow.user.id)
- user_config = resolve_effective_config(
- user_config, run_configs.get("model_overrides")
+ from api.services.configuration.ai_model_configuration import (
+ get_effective_ai_model_configuration_for_workflow,
+ )
+
+ user_config = await get_effective_ai_model_configuration_for_workflow(
+ user_id=workflow_run.workflow.user.id,
+ organization_id=workflow.organization_id,
+ workflow_configurations=run_configs,
)
if user_config.llm is None:
raise ValueError("Text chat requires an LLM configuration")
@@ -466,9 +470,17 @@ async def execute_text_chat_pending_turn(
embeddings_model = None
embeddings_base_url = None
if user_config.embeddings:
+ from api.services.configuration.ai_model_configuration import (
+ apply_managed_embeddings_base_url,
+ )
+
embeddings_api_key = user_config.embeddings.api_key
embeddings_model = user_config.embeddings.model
- embeddings_base_url = getattr(user_config.embeddings, "base_url", None)
+ embeddings_provider = getattr(user_config.embeddings, "provider", None)
+ embeddings_base_url = apply_managed_embeddings_base_url(
+ provider=embeddings_provider,
+ base_url=getattr(user_config.embeddings, "base_url", None),
+ )
has_recordings = await db_client.has_active_recordings(workflow.organization_id)
context_compaction_enabled = (workflow.workflow_configurations or {}).get(
diff --git a/api/tasks/knowledge_base_processing.py b/api/tasks/knowledge_base_processing.py
index 4e94329..f496ac0 100644
--- a/api/tasks/knowledge_base_processing.py
+++ b/api/tasks/knowledge_base_processing.py
@@ -157,12 +157,24 @@ async def process_knowledge_base_document(
embeddings_endpoint = None
embeddings_api_version = None
if document.created_by:
- user_config = await db_client.get_user_configurations(document.created_by)
+ from api.services.configuration.ai_model_configuration import (
+ apply_managed_embeddings_base_url,
+ get_resolved_ai_model_configuration,
+ )
+
+ resolved_config = await get_resolved_ai_model_configuration(
+ user_id=document.created_by,
+ organization_id=document.organization_id,
+ )
+ user_config = resolved_config.effective
if user_config.embeddings:
embeddings_provider = getattr(user_config.embeddings, "provider", None)
embeddings_api_key = user_config.embeddings.api_key
embeddings_model = user_config.embeddings.model
- embeddings_base_url = getattr(user_config.embeddings, "base_url", None)
+ embeddings_base_url = apply_managed_embeddings_base_url(
+ provider=embeddings_provider,
+ base_url=getattr(user_config.embeddings, "base_url", None),
+ )
embeddings_endpoint = getattr(user_config.embeddings, "endpoint", None)
embeddings_api_version = getattr(
user_config.embeddings, "api_version", None
diff --git a/api/tests/test_ai_model_configuration_v2.py b/api/tests/test_ai_model_configuration_v2.py
new file mode 100644
index 0000000..023a330
--- /dev/null
+++ b/api/tests/test_ai_model_configuration_v2.py
@@ -0,0 +1,295 @@
+import pytest
+from pydantic import ValidationError
+
+from api.schemas.ai_model_configuration import (
+ DograhManagedAIModelConfiguration,
+ OrganizationAIModelConfigurationV2,
+ compile_ai_model_configuration_v2,
+)
+from api.schemas.user_configuration import UserConfiguration
+from api.services.configuration.ai_model_configuration import (
+ WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY,
+ check_for_masked_keys_in_ai_model_configuration_v2,
+ convert_legacy_ai_model_configuration_to_v2,
+ mask_ai_model_configuration_v2,
+ merge_ai_model_configuration_v2_secrets,
+ migrate_workflow_configuration_model_override_to_v2,
+)
+from api.services.configuration.masking import mask_key
+from api.services.configuration.registry import (
+ DeepgramSTTConfiguration,
+ DograhLLMService,
+ DograhSTTService,
+ DograhTTSService,
+ ElevenlabsTTSConfiguration,
+ OpenAIEmbeddingsConfiguration,
+ OpenAILLMService,
+)
+
+
+def test_dograh_v2_compiles_to_effective_managed_pipeline_with_embeddings():
+ config = OrganizationAIModelConfigurationV2(
+ mode="dograh",
+ dograh=DograhManagedAIModelConfiguration(
+ api_key="mps-secret",
+ voice="default",
+ speed=1.2,
+ language="multi",
+ ),
+ )
+
+ effective = compile_ai_model_configuration_v2(config)
+
+ assert effective.is_realtime is False
+ assert effective.llm.provider == "dograh"
+ assert effective.llm.model == "default"
+ assert effective.tts.provider == "dograh"
+ assert effective.tts.speed == 1.2
+ assert effective.stt.provider == "dograh"
+ assert effective.stt.language == "multi"
+ assert effective.embeddings.provider == "dograh"
+ assert effective.embeddings.model == "default"
+
+
+def test_dograh_v2_rejects_non_predefined_speed():
+ with pytest.raises(ValidationError):
+ OrganizationAIModelConfigurationV2(
+ mode="dograh",
+ dograh=DograhManagedAIModelConfiguration(
+ api_key="mps-secret",
+ speed=1.5,
+ ),
+ )
+
+
+def test_byok_v2_rejects_dograh_provider():
+ with pytest.raises(ValidationError):
+ OrganizationAIModelConfigurationV2.model_validate(
+ {
+ "mode": "byok",
+ "byok": {
+ "mode": "pipeline",
+ "pipeline": {
+ "llm": {
+ "provider": "dograh",
+ "api_key": "mps-secret",
+ "model": "default",
+ },
+ "tts": {
+ "provider": "dograh",
+ "api_key": "mps-secret",
+ "model": "default",
+ "voice": "default",
+ },
+ "stt": {
+ "provider": "dograh",
+ "api_key": "mps-secret",
+ "model": "default",
+ },
+ },
+ },
+ }
+ )
+
+
+def test_masked_dograh_key_is_preserved_when_saving_same_mode():
+ existing = OrganizationAIModelConfigurationV2(
+ mode="dograh",
+ dograh=DograhManagedAIModelConfiguration(api_key="mps-real-secret"),
+ )
+ incoming = OrganizationAIModelConfigurationV2(
+ mode="dograh",
+ dograh=DograhManagedAIModelConfiguration(api_key=mask_key("mps-real-secret")),
+ )
+
+ merged = merge_ai_model_configuration_v2_secrets(incoming, existing)
+
+ assert merged.dograh.api_key == "mps-real-secret"
+ check_for_masked_keys_in_ai_model_configuration_v2(merged)
+
+
+def test_masked_v2_configuration_masks_nested_service_keys():
+ config = OrganizationAIModelConfigurationV2(
+ mode="byok",
+ byok={
+ "mode": "pipeline",
+ "pipeline": {
+ "llm": {
+ "provider": "openai",
+ "api_key": "sk-real-secret",
+ "model": "gpt-4.1",
+ },
+ "tts": {
+ "provider": "elevenlabs",
+ "api_key": "el-real-secret",
+ "model": "eleven_flash_v2_5",
+ "voice": "Rachel",
+ },
+ "stt": {
+ "provider": "deepgram",
+ "api_key": "dg-real-secret",
+ "model": "nova-3-general",
+ },
+ },
+ },
+ )
+
+ masked = mask_ai_model_configuration_v2(config)
+
+ assert masked["byok"]["pipeline"]["llm"]["api_key"] == mask_key("sk-real-secret")
+ assert masked["byok"]["pipeline"]["tts"]["api_key"] == mask_key("el-real-secret")
+ assert masked["byok"]["pipeline"]["stt"]["api_key"] == mask_key("dg-real-secret")
+
+
+def test_legacy_all_dograh_pipeline_converts_to_dograh_v2():
+ legacy = UserConfiguration(
+ llm=DograhLLMService(
+ provider="dograh",
+ api_key=["mps-secret"],
+ model="default",
+ ),
+ tts=DograhTTSService(
+ provider="dograh",
+ api_key=["mps-secret"],
+ model="default",
+ voice="default",
+ speed=1.0,
+ ),
+ stt=DograhSTTService(
+ provider="dograh",
+ api_key=["mps-secret"],
+ model="default",
+ language="multi",
+ ),
+ )
+
+ config = convert_legacy_ai_model_configuration_to_v2(legacy)
+
+ assert config.mode == "dograh"
+ assert config.dograh.api_key == "mps-secret"
+
+
+def test_legacy_mixed_dograh_pipeline_converts_to_dograh_v2():
+ legacy = UserConfiguration(
+ llm=OpenAILLMService(
+ provider="openai",
+ api_key="sk-llm",
+ model="gpt-4.1",
+ ),
+ tts=DograhTTSService(
+ provider="dograh",
+ api_key="mps-tts",
+ model="default",
+ voice="default",
+ ),
+ stt=DograhSTTService(
+ provider="dograh",
+ api_key="mps-stt",
+ model="default",
+ ),
+ embeddings=OpenAIEmbeddingsConfiguration(
+ provider="openai",
+ api_key="sk-emb",
+ model="text-embedding-3-small",
+ ),
+ )
+
+ config = convert_legacy_ai_model_configuration_to_v2(legacy)
+
+ assert config.mode == "dograh"
+ assert config.dograh.api_key == "mps-tts"
+ assert config.dograh.voice == "default"
+
+
+def test_legacy_byok_pipeline_converts_to_byok_v2():
+ legacy = UserConfiguration(
+ llm=OpenAILLMService(
+ provider="openai",
+ api_key="sk-llm",
+ model="gpt-4.1",
+ ),
+ tts=ElevenlabsTTSConfiguration(
+ provider="elevenlabs",
+ api_key="el-tts",
+ model="eleven_flash_v2_5",
+ voice="Rachel",
+ ),
+ stt=DeepgramSTTConfiguration(
+ provider="deepgram",
+ api_key="dg-stt",
+ model="nova-3-general",
+ ),
+ embeddings=OpenAIEmbeddingsConfiguration(
+ provider="openai",
+ api_key="sk-emb",
+ model="text-embedding-3-small",
+ ),
+ )
+
+ config = convert_legacy_ai_model_configuration_to_v2(legacy)
+
+ assert config.mode == "byok"
+ assert config.byok.mode == "pipeline"
+ assert config.byok.pipeline.llm.provider == "openai"
+ assert config.byok.pipeline.tts.provider == "elevenlabs"
+
+
+def test_workflow_model_override_migration_removes_v1_override_and_sets_v2():
+ base = UserConfiguration(
+ llm=OpenAILLMService(
+ provider="openai",
+ api_key="sk-llm",
+ model="gpt-4.1",
+ ),
+ tts=ElevenlabsTTSConfiguration(
+ provider="elevenlabs",
+ api_key="el-tts",
+ model="eleven_flash_v2_5",
+ voice="Rachel",
+ ),
+ stt=DeepgramSTTConfiguration(
+ provider="deepgram",
+ api_key="dg-stt",
+ model="nova-3-general",
+ ),
+ )
+ workflow_configurations = {
+ "ambient_noise_configuration": {"enabled": False},
+ "model_overrides": {
+ "tts": {
+ "provider": "dograh",
+ "api_key": "mps-workflow",
+ "model": "default",
+ "voice": "default",
+ }
+ },
+ }
+
+ migrated, changed = migrate_workflow_configuration_model_override_to_v2(
+ workflow_configurations,
+ base,
+ )
+
+ assert changed is True
+ assert "model_overrides" not in migrated
+ assert migrated["ambient_noise_configuration"] == {"enabled": False}
+ v2_override = migrated[WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY]
+ assert v2_override["mode"] == "dograh"
+ assert v2_override["dograh"]["api_key"] == "mps-workflow"
+
+
+def test_workflow_model_override_migration_removes_invalid_v1_override_marker():
+ base = UserConfiguration()
+ workflow_configurations = {
+ "ambient_noise_configuration": {"enabled": False},
+ "model_overrides": None,
+ }
+
+ migrated, changed = migrate_workflow_configuration_model_override_to_v2(
+ workflow_configurations,
+ base,
+ )
+
+ assert changed is True
+ assert "model_overrides" not in migrated
+ assert migrated["ambient_noise_configuration"] == {"enabled": False}
diff --git a/ui/src/app/model-configurations/page.tsx b/ui/src/app/model-configurations/page.tsx
index fbe694a..70d860d 100644
--- a/ui/src/app/model-configurations/page.tsx
+++ b/ui/src/app/model-configurations/page.tsx
@@ -1,13 +1,25 @@
-import ServiceConfiguration from "@/components/ServiceConfiguration";
+import ModelConfigurationV2 from "@/components/ModelConfigurationV2";
import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
-export default function ServiceConfigurationPage() {
+interface ServiceConfigurationPageProps {
+ searchParams?: Promise<{
+ action?: string | string[];
+ }>;
+}
+
+export default async function ServiceConfigurationPage({ searchParams }: ServiceConfigurationPageProps) {
+ const params = searchParams ? await searchParams : {};
+ const action = Array.isArray(params.action) ? params.action[0] : params.action;
+
return (
diff --git a/ui/src/app/workflow/[workflowId]/settings/page.tsx b/ui/src/app/workflow/[workflowId]/settings/page.tsx
index b1bff34..28d2a5b 100644
--- a/ui/src/app/workflow/[workflowId]/settings/page.tsx
+++ b/ui/src/app/workflow/[workflowId]/settings/page.tsx
@@ -7,8 +7,22 @@ import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
-import { downloadWorkflowReportApiV1WorkflowWorkflowIdReportGet, getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost, getWorkflowApiV1WorkflowFetchWorkflowIdGet } from "@/client/sdk.gen";
-import type { WorkflowResponse } from "@/client/types.gen";
+import {
+ downloadWorkflowReportApiV1WorkflowWorkflowIdReportGet,
+ getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost,
+ getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get,
+ getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet,
+ getWorkflowApiV1WorkflowFetchWorkflowIdGet,
+} from "@/client/sdk.gen";
+import type {
+ OrganizationAiModelConfigurationResponse,
+ OrganizationAiModelConfigurationV2,
+ WorkflowResponse,
+} from "@/client/types.gen";
+import {
+ AIModelConfigurationV2Editor,
+ type ModelConfigurationDefaultsV2,
+} from "@/components/AIModelConfigurationV2Editor";
import { FlowEdge, FlowNode } from "@/components/flow/types";
import { LLMConfigSelector } from "@/components/LLMConfigSelector";
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
@@ -26,6 +40,7 @@ import { Textarea } from "@/components/ui/textarea";
import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
import { UnsavedChangesProvider, useUnsavedChanges, useUnsavedChangesContext } from "@/context/UnsavedChangesContext";
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
+import { detailFromError } from "@/lib/apiError";
import { useAuth } from "@/lib/auth";
import logger from "@/lib/logger";
import {
@@ -1040,6 +1055,182 @@ function AgentUuidSection({ workflowUuid }: { workflowUuid: string }) {
);
}
+// ---------------------------------------------------------------------------
+// Section: Model Overrides
+// ---------------------------------------------------------------------------
+
+function withoutModelConfigurationOverrides(configurations: WorkflowConfigurations): WorkflowConfigurations {
+ const next = { ...configurations };
+ delete next.model_overrides;
+ delete next.model_configuration_v2_override;
+ return next;
+}
+
+function WorkflowModelOverridesSection({
+ workflowConfigurations,
+ workflowName,
+ onSave,
+ modelConfigurationDefaults,
+ organizationModelConfiguration,
+ modelConfigurationLoading,
+ modelConfigurationError,
+}: {
+ workflowConfigurations: WorkflowConfigurations;
+ workflowName: string;
+ onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise;
+ modelConfigurationDefaults: ModelConfigurationDefaultsV2 | null;
+ organizationModelConfiguration: OrganizationAiModelConfigurationResponse | null;
+ modelConfigurationLoading: boolean;
+ modelConfigurationError: string | null;
+}) {
+ const savedV2Override = workflowConfigurations.model_configuration_v2_override;
+ const hasSavedModelOverride = Boolean(savedV2Override || workflowConfigurations.model_overrides);
+ const [overrideEnabled, setOverrideEnabled] = useState(Boolean(savedV2Override));
+ const [isRemovingOverride, setIsRemovingOverride] = useState(false);
+
+ useEffect(() => {
+ setOverrideEnabled(Boolean(workflowConfigurations.model_configuration_v2_override));
+ }, [workflowConfigurations.model_configuration_v2_override]);
+
+ const source = organizationModelConfiguration?.source || "empty";
+ const isV2 = source === "organization_v2";
+
+ const saveLegacyOverrides = async (config: Record) => {
+ const nextConfigurations = withoutModelConfigurationOverrides(workflowConfigurations);
+ const modelOverrides = config.model_overrides as WorkflowConfigurations["model_overrides"] | undefined;
+ if (modelOverrides) {
+ nextConfigurations.model_overrides = modelOverrides;
+ }
+ await onSave(nextConfigurations, workflowName);
+ };
+
+ const saveV2Override = async (configuration: OrganizationAiModelConfigurationV2) => {
+ const nextConfigurations = withoutModelConfigurationOverrides(workflowConfigurations);
+ nextConfigurations.model_configuration_v2_override = configuration;
+ await onSave(nextConfigurations, workflowName);
+ toast.success("Model override saved");
+ };
+
+ const removeV2Override = async () => {
+ setIsRemovingOverride(true);
+ try {
+ await onSave(withoutModelConfigurationOverrides(workflowConfigurations), workflowName);
+ setOverrideEnabled(false);
+ toast.success("Using organization model configuration");
+ } finally {
+ setIsRemovingOverride(false);
+ }
+ };
+
+ return (
+
+
+
+
+ Model Overrides
+
+
+ {isV2
+ ? "Override the full organization model configuration for this workflow."
+ : "Override global model settings for this workflow. Toggle individual services to customize."}{" "}
+ Learn more
+
+
+
+ {modelConfigurationLoading && (
+
+
+ Loading model configuration
+
+ )}
+
+ {modelConfigurationError && (
+
+ {modelConfigurationError}
+
+ )}
+
+ {!modelConfigurationLoading && !modelConfigurationError && !isV2 && (
+ <>
+ {source === "legacy_user_v1" && (
+
+
+ This workflow is using legacy model overrides. Migrate organization model configuration to use v2 overrides.
+
+
+
+ )}
+
+ >
+ )}
+
+ {!modelConfigurationLoading && !modelConfigurationError && isV2 && modelConfigurationDefaults && organizationModelConfiguration && (
+ <>
+
+
+
+
+ {overrideEnabled
+ ? "This workflow uses its own complete model configuration."
+ : "This workflow uses the organization model configuration."}
+
+
+
+
+
+ {overrideEnabled ? (
+
+ ) : (
+
+
+ Using organization model configuration.
+
+ {hasSavedModelOverride && (
+
+ )}
+
+ )}
+ >
+ )}
+
+
+ );
+}
+
// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
@@ -1127,6 +1318,11 @@ function WorkflowSettingsInner({
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
const [activeSection, setActiveSection] = useState("general");
+ const [modelConfigurationDefaults, setModelConfigurationDefaults] = useState(null);
+ const [organizationModelConfiguration, setOrganizationModelConfiguration] = useState(null);
+ const [modelConfigurationLoading, setModelConfigurationLoading] = useState(true);
+ const [modelConfigurationError, setModelConfigurationError] = useState(null);
+ const hasFetchedModelConfiguration = useRef(false);
const workflowId = workflow.id;
@@ -1166,6 +1362,37 @@ function WorkflowSettingsInner({
user,
});
+ useEffect(() => {
+ if (hasFetchedModelConfiguration.current) return;
+ hasFetchedModelConfiguration.current = true;
+
+ const loadModelConfiguration = async () => {
+ setModelConfigurationLoading(true);
+ setModelConfigurationError(null);
+ const [defaultsResult, configurationResult] = await Promise.all([
+ getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet(),
+ getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get(),
+ ]);
+
+ if (defaultsResult.error) {
+ setModelConfigurationError(detailFromError(defaultsResult.error, "Failed to load model configuration defaults"));
+ setModelConfigurationLoading(false);
+ return;
+ }
+ if (configurationResult.error) {
+ setModelConfigurationError(detailFromError(configurationResult.error, "Failed to load model configuration"));
+ setModelConfigurationLoading(false);
+ return;
+ }
+
+ setModelConfigurationDefaults(defaultsResult.data as ModelConfigurationDefaultsV2);
+ setOrganizationModelConfiguration(configurationResult.data || null);
+ setModelConfigurationLoading(false);
+ };
+
+ loadModelConfiguration();
+ }, []);
+
// Intersection observer for active sidebar link
useEffect(() => {
const ids = NAV_ITEMS.map((n) => n.id);
@@ -1218,37 +1445,15 @@ function WorkflowSettingsInner({
onSave={saveWorkflowConfigurations}
/>
- {/* Model Overrides */}
-
-
-
-
- Model Overrides
-
-
- Override global model settings for this workflow. Toggle individual services to
- customize.{" "}
- Learn more
-
-
-
- {
- await saveWorkflowConfigurations(
- {
- ...workflowConfigurations,
- model_overrides:
- config.model_overrides as WorkflowConfigurations["model_overrides"],
- } as WorkflowConfigurations,
- workflowName,
- );
- }}
- />
-
-
+
{/* Template Variables */}
= Options2 & {
/**
@@ -936,6 +936,55 @@ export const getTelephonyProvidersMetadataApiV1OrganizationsTelephonyProvidersMe
*/
export const getTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/telephony-config-warnings', ...options });
+/**
+ * Get Model Configuration V2 Defaults
+ */
+export const getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/model-configurations/v2/defaults', ...options });
+
+/**
+ * Get Model Configuration V2
+ */
+export const getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/model-configurations/v2', ...options });
+
+/**
+ * Save Model Configuration V2
+ */
+export const saveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Put = (options: Options) => (options.client ?? client).put({
+ url: '/api/v1/organizations/model-configurations/v2',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ }
+});
+
+/**
+ * Preview Model Configuration V2 Migration
+ */
+export const previewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/model-configurations/v2/migration-preview', ...options });
+
+/**
+ * Migrate Model Configuration V2
+ */
+export const migrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePost = (options?: Options) => (options?.client ?? client).post({ url: '/api/v1/organizations/model-configurations/v2/migrate', ...options });
+
+/**
+ * Get Model Configuration Preferences
+ */
+export const getModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesGet = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/organizations/model-configurations/preferences', ...options });
+
+/**
+ * Save Model Configuration Preferences
+ */
+export const saveModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesPut = (options: Options) => (options.client ?? client).put({
+ url: '/api/v1/organizations/model-configurations/preferences',
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ }
+});
+
/**
* List Telephony Configurations
*
@@ -1261,7 +1310,7 @@ export const getTurnCredentialsApiV1TurnCredentialsGet = (options?: Options) => (options?.client ?? client).options({ url: '/api/v1/public/embed/init', ...options });
@@ -1297,11 +1346,15 @@ export const initializeEmbedSessionApiV1PublicEmbedInitPost = (options: Options) => (options.client ?? client).get({ url: '/api/v1/public/embed/config/{token}', ...options });
/**
- * Options Config
+ * Options Embed Config
*
- * Handle CORS preflight for config endpoint
+ * Fallback OPTIONS handler for the embed config endpoint.
+ *
+ * Browser preflights include Access-Control-Request-Method and are handled by
+ * PublicEmbedCORSMiddleware before global CORS. This keeps non-conformant
+ * OPTIONS requests on the same validation path.
*/
-export const optionsConfigApiV1PublicEmbedConfigTokenOptions = (options: Options) => (options.client ?? client).options({ url: '/api/v1/public/embed/config/{token}', ...options });
+export const optionsEmbedConfigApiV1PublicEmbedConfigTokenOptions = (options: Options) => (options.client ?? client).options({ url: '/api/v1/public/embed/config/{token}', ...options });
/**
* Get Public Turn Credentials
@@ -1322,7 +1375,7 @@ export const getPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionToken
/**
* Options Turn Credentials
*
- * Handle CORS preflight for TURN credentials endpoint
+ * Fallback OPTIONS handler for TURN credentials endpoint.
*/
export const optionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptions = (options: Options) => (options.client ?? client).options({ url: '/api/v1/public/embed/turn-credentials/{session_token}', ...options });
diff --git a/ui/src/client/types.gen.ts b/ui/src/client/types.gen.ts
index 8398f5f..6bb38f1 100644
--- a/ui/src/client/types.gen.ts
+++ b/ui/src/client/types.gen.ts
@@ -136,6 +136,46 @@ export type AriConfigurationResponse = {
from_numbers: Array;
};
+/**
+ * AWS Bedrock
+ */
+export type AwsBedrockLlmConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'aws_bedrock';
+ /**
+ * Api Key
+ *
+ * Not used for Bedrock — authentication is via the AWS credentials above. Leave blank.
+ */
+ api_key?: string | Array | null;
+ /**
+ * Model
+ *
+ * Bedrock model ID — include the region inference-profile prefix (e.g. 'us.').
+ */
+ model?: string;
+ /**
+ * Aws Access Key
+ *
+ * AWS access key ID with bedrock:InvokeModel permission.
+ */
+ aws_access_key?: string;
+ /**
+ * Aws Secret Key
+ *
+ * AWS secret access key paired with the access key ID.
+ */
+ aws_secret_key?: string;
+ /**
+ * Aws Region
+ *
+ * AWS region where the Bedrock model is available.
+ */
+ aws_region?: string;
+};
+
/**
* AmbientNoiseUploadRequest
*/
@@ -192,6 +232,32 @@ export type AppendTextChatMessageRequest = {
expected_revision?: number | null;
};
+/**
+ * AssemblyAI
+ */
+export type AssemblyAisttConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'assemblyai';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * AssemblyAI realtime STT model.
+ */
+ model?: string;
+ /**
+ * Language
+ *
+ * ISO 639-1 language code.
+ */
+ language?: string;
+};
+
/**
* AuthResponse
*/
@@ -217,6 +283,354 @@ export type AuthUserResponse = {
is_superuser: boolean;
};
+/**
+ * Azure OpenAI
+ */
+export type AzureLlmService = {
+ /**
+ * Provider
+ */
+ provider?: 'azure';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Azure deployment name (not the upstream OpenAI model id).
+ */
+ model?: string;
+ /**
+ * Endpoint
+ *
+ * Azure OpenAI resource endpoint (e.g. https://.openai.azure.com).
+ */
+ endpoint: string;
+};
+
+/**
+ * Azure OpenAI
+ */
+export type AzureOpenAiEmbeddingsConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'azure';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Azure OpenAI embedding deployment name. The deployment must return 1536-dimensional embeddings.
+ */
+ model?: string;
+ /**
+ * Endpoint
+ *
+ * Azure OpenAI resource endpoint (e.g. https://.openai.azure.com).
+ */
+ endpoint: string;
+ /**
+ * Api Version
+ *
+ * Azure OpenAI API version for embeddings.
+ */
+ api_version?: string;
+};
+
+/**
+ * Azure OpenAI Realtime
+ *
+ * Azure OpenAI Realtime API — low-latency speech-to-speech conversations.
+ */
+export type AzureRealtimeLlmConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'azure_realtime';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Azure OpenAI realtime deployment name.
+ */
+ model?: string;
+ /**
+ * Endpoint
+ *
+ * Azure OpenAI resource endpoint (e.g. https://.openai.azure.com).
+ */
+ endpoint: string;
+ /**
+ * Voice
+ *
+ * Voice the model speaks in.
+ */
+ voice?: string;
+ /**
+ * Api Version
+ *
+ * Azure OpenAI API version.
+ */
+ api_version?: string;
+};
+
+/**
+ * Azure Speech Services
+ *
+ * Azure Cognitive Services Speech — TTS and STT via the Azure Speech SDK.
+ */
+export type AzureSpeechSttConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'azure_speech';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Azure Speech recognition model (use 'latest_long' for continuous recognition).
+ */
+ model?: string;
+ /**
+ * Region
+ *
+ * Azure region for Speech Services (e.g. 'eastus', 'westeurope').
+ */
+ region?: string;
+ /**
+ * Language
+ *
+ * BCP-47 language code for recognition.
+ */
+ language?: string;
+};
+
+/**
+ * Azure Speech Services
+ *
+ * Azure Cognitive Services Speech — TTS and STT via the Azure Speech SDK.
+ */
+export type AzureSpeechTtsConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'azure_speech';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Azure Speech synthesis engine (neural voices only).
+ */
+ model?: string;
+ /**
+ * Region
+ *
+ * Azure region for Speech Services (e.g. 'eastus', 'westeurope').
+ */
+ region?: string;
+ /**
+ * Voice
+ *
+ * Azure Neural voice name (e.g. 'en-US-AriaNeural').
+ */
+ voice?: string;
+ /**
+ * Language
+ *
+ * BCP-47 language code for synthesis.
+ */
+ language?: string;
+ /**
+ * Speed
+ *
+ * Speech speed multiplier (0.5 to 2.0).
+ */
+ speed?: number;
+};
+
+/**
+ * BYOKAIModelConfiguration
+ */
+export type ByokaiModelConfiguration = {
+ /**
+ * Mode
+ */
+ mode: 'pipeline' | 'realtime';
+ pipeline?: ByokPipelineAiModelConfiguration | null;
+ realtime?: ByokRealtimeAiModelConfiguration | null;
+};
+
+/**
+ * BYOKPipelineAIModelConfiguration
+ */
+export type ByokPipelineAiModelConfiguration = {
+ /**
+ * Llm
+ */
+ llm: ({
+ provider: 'openai';
+ } & OpenAillmService) | ({
+ provider: 'google_vertex';
+ } & GoogleVertexLlmConfiguration) | ({
+ provider: 'groq';
+ } & GroqLlmService) | ({
+ provider: 'openrouter';
+ } & OpenRouterLlmConfiguration) | ({
+ provider: 'google';
+ } & GoogleLlmService) | ({
+ provider: 'azure';
+ } & AzureLlmService) | ({
+ provider: 'dograh';
+ } & DograhLlmService) | ({
+ provider: 'aws_bedrock';
+ } & AwsBedrockLlmConfiguration) | ({
+ provider: 'speaches';
+ } & SpeachesLlmConfiguration) | ({
+ provider: 'minimax';
+ } & MiniMaxLlmConfiguration) | ({
+ provider: 'sarvam';
+ } & SarvamLlmConfiguration);
+ /**
+ * Tts
+ */
+ tts: ({
+ provider: 'deepgram';
+ } & DeepgramTtsConfiguration) | ({
+ provider: 'google';
+ } & GoogleTtsConfiguration) | ({
+ provider: 'openai';
+ } & OpenAittsService) | ({
+ provider: 'elevenlabs';
+ } & ElevenlabsTtsConfiguration) | ({
+ provider: 'cartesia';
+ } & CartesiaTtsConfiguration) | ({
+ provider: 'dograh';
+ } & DograhTtsService) | ({
+ provider: 'sarvam';
+ } & SarvamTtsConfiguration) | ({
+ provider: 'camb';
+ } & CambTtsConfiguration) | ({
+ provider: 'rime';
+ } & RimeTtsConfiguration) | ({
+ provider: 'speaches';
+ } & SpeachesTtsConfiguration) | ({
+ provider: 'minimax';
+ } & MiniMaxTtsConfiguration) | ({
+ provider: 'azure_speech';
+ } & AzureSpeechTtsConfiguration);
+ /**
+ * Stt
+ */
+ stt: ({
+ provider: 'deepgram';
+ } & DeepgramSttConfiguration) | ({
+ provider: 'cartesia';
+ } & CartesiaSttConfiguration) | ({
+ provider: 'openai';
+ } & OpenAisttConfiguration) | ({
+ provider: 'google';
+ } & GoogleSttConfiguration) | ({
+ provider: 'dograh';
+ } & DograhSttService) | ({
+ provider: 'speechmatics';
+ } & SpeechmaticsSttConfiguration) | ({
+ provider: 'sarvam';
+ } & SarvamSttConfiguration) | ({
+ provider: 'speaches';
+ } & SpeachesSttConfiguration) | ({
+ provider: 'assemblyai';
+ } & AssemblyAisttConfiguration) | ({
+ provider: 'gladia';
+ } & GladiaSttConfiguration) | ({
+ provider: 'azure_speech';
+ } & AzureSpeechSttConfiguration);
+ /**
+ * Embeddings
+ */
+ embeddings?: ({
+ provider: 'openai';
+ } & OpenAiEmbeddingsConfiguration) | ({
+ provider: 'openrouter';
+ } & OpenRouterEmbeddingsConfiguration) | ({
+ provider: 'azure';
+ } & AzureOpenAiEmbeddingsConfiguration) | ({
+ provider: 'dograh';
+ } & DograhEmbeddingsConfiguration) | null;
+};
+
+/**
+ * BYOKRealtimeAIModelConfiguration
+ */
+export type ByokRealtimeAiModelConfiguration = {
+ /**
+ * Realtime
+ */
+ realtime: ({
+ provider: 'openai_realtime';
+ } & OpenAiRealtimeLlmConfiguration) | ({
+ provider: 'grok_realtime';
+ } & GrokRealtimeLlmConfiguration) | ({
+ provider: 'ultravox_realtime';
+ } & UltravoxRealtimeLlmConfiguration) | ({
+ provider: 'google_realtime';
+ } & GoogleRealtimeLlmConfiguration) | ({
+ provider: 'google_vertex_realtime';
+ } & GoogleVertexRealtimeLlmConfiguration) | ({
+ provider: 'azure_realtime';
+ } & AzureRealtimeLlmConfiguration);
+ /**
+ * Llm
+ */
+ llm: ({
+ provider: 'openai';
+ } & OpenAillmService) | ({
+ provider: 'google_vertex';
+ } & GoogleVertexLlmConfiguration) | ({
+ provider: 'groq';
+ } & GroqLlmService) | ({
+ provider: 'openrouter';
+ } & OpenRouterLlmConfiguration) | ({
+ provider: 'google';
+ } & GoogleLlmService) | ({
+ provider: 'azure';
+ } & AzureLlmService) | ({
+ provider: 'dograh';
+ } & DograhLlmService) | ({
+ provider: 'aws_bedrock';
+ } & AwsBedrockLlmConfiguration) | ({
+ provider: 'speaches';
+ } & SpeachesLlmConfiguration) | ({
+ provider: 'minimax';
+ } & MiniMaxLlmConfiguration) | ({
+ provider: 'sarvam';
+ } & SarvamLlmConfiguration);
+ /**
+ * Embeddings
+ */
+ embeddings?: ({
+ provider: 'openai';
+ } & OpenAiEmbeddingsConfiguration) | ({
+ provider: 'openrouter';
+ } & OpenRouterEmbeddingsConfiguration) | ({
+ provider: 'azure';
+ } & AzureOpenAiEmbeddingsConfiguration) | ({
+ provider: 'dograh';
+ } & DograhEmbeddingsConfiguration) | null;
+};
+
/**
* BatchRecordingCreateRequestSchema
*
@@ -322,6 +736,38 @@ export type CallDispositionCodes = {
*/
export type CallType = 'inbound' | 'outbound';
+/**
+ * Camb.ai
+ */
+export type CambTtsConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'camb';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Camb.ai TTS model.
+ */
+ model?: string;
+ /**
+ * Voice
+ *
+ * Camb.ai voice ID.
+ */
+ voice?: string;
+ /**
+ * Language
+ *
+ * BCP-47 language code.
+ */
+ language?: string;
+};
+
/**
* CampaignDefaultsResponse
*/
@@ -566,6 +1012,64 @@ export type CampaignsResponse = {
campaigns: Array;
};
+/**
+ * Cartesia
+ */
+export type CartesiaSttConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'cartesia';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Cartesia STT model.
+ */
+ model?: string;
+};
+
+/**
+ * Cartesia
+ */
+export type CartesiaTtsConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'cartesia';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Cartesia TTS model.
+ */
+ model?: string;
+ /**
+ * Voice
+ *
+ * Cartesia voice UUID from your Cartesia dashboard.
+ */
+ voice?: string;
+ /**
+ * Speed
+ *
+ * Speed of the voice.
+ */
+ speed?: number;
+ /**
+ * Volume
+ *
+ * Volume multiplier for generated speech.
+ */
+ volume?: number;
+};
+
/**
* ChunkResponseSchema
*
@@ -1264,6 +1768,52 @@ export type DailyUsageItem = {
call_count: number;
};
+/**
+ * Deepgram
+ */
+export type DeepgramSttConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'deepgram';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Deepgram STT model.
+ */
+ model?: string;
+ /**
+ * Language
+ *
+ * Language code; 'multi' enables auto-detect (Nova-3 only).
+ */
+ language?: string;
+};
+
+/**
+ * Deepgram
+ */
+export type DeepgramTtsConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'deepgram';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Voice
+ *
+ * Deepgram voice ID (model is inferred from the 'aura-N' prefix).
+ */
+ voice?: string;
+};
+
/**
* DefaultConfigurationsResponse
*/
@@ -1508,6 +2058,126 @@ export type DocumentUploadResponseSchema = {
s3_key: string;
};
+/**
+ * Dograh
+ */
+export type DograhEmbeddingsConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'dograh';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Dograh-managed embedding model.
+ */
+ model?: string;
+};
+
+/**
+ * Dograh
+ */
+export type DograhLlmService = {
+ /**
+ * Provider
+ */
+ provider?: 'dograh';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Dograh-hosted model tier.
+ */
+ model?: string;
+};
+
+/**
+ * DograhManagedAIModelConfiguration
+ */
+export type DograhManagedAiModelConfiguration = {
+ /**
+ * Api Key
+ */
+ api_key: string;
+ /**
+ * Voice
+ */
+ voice?: string;
+ /**
+ * Speed
+ */
+ speed?: number;
+ /**
+ * Language
+ */
+ language?: string;
+};
+
+/**
+ * Dograh
+ */
+export type DograhSttService = {
+ /**
+ * Provider
+ */
+ provider?: 'dograh';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Dograh STT tier.
+ */
+ model?: string;
+ /**
+ * Language
+ *
+ * Language code; use 'multi' for auto-detect.
+ */
+ language?: string;
+};
+
+/**
+ * Dograh
+ */
+export type DograhTtsService = {
+ /**
+ * Provider
+ */
+ provider?: 'dograh';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Dograh TTS tier.
+ */
+ model?: string;
+ /**
+ * Voice
+ *
+ * Voice preset.
+ */
+ voice?: string;
+ /**
+ * Speed
+ *
+ * Speed of the voice.
+ */
+ speed?: number;
+};
+
/**
* DuplicateTemplateRequest
*/
@@ -1522,6 +2192,44 @@ export type DuplicateTemplateRequest = {
workflow_name: string;
};
+/**
+ * ElevenLabs
+ */
+export type ElevenlabsTtsConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'elevenlabs';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Voice
+ *
+ * ElevenLabs voice ID from your Voice Library.
+ */
+ voice?: string;
+ /**
+ * Speed
+ *
+ * Speed of the voice.
+ */
+ speed?: number;
+ /**
+ * Model
+ *
+ * ElevenLabs TTS model.
+ */
+ model?: string;
+ /**
+ * Base Url
+ *
+ * ElevenLabs API base URL. Override to use a Data Residency endpoint (e.g. https://api.eu.residency.elevenlabs.io) for GDPR / HIPAA / regional compliance.
+ */
+ base_url?: string;
+};
+
/**
* EmbedConfigResponse
*
@@ -1758,6 +2466,268 @@ export type FolderResponse = {
created_at: string;
};
+/**
+ * Gladia
+ */
+export type GladiaSttConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'gladia';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Gladia STT model.
+ */
+ model?: string;
+ /**
+ * Language
+ *
+ * ISO 639-1 language code.
+ */
+ language?: string;
+};
+
+/**
+ * Google
+ */
+export type GoogleLlmService = {
+ /**
+ * Provider
+ */
+ provider?: 'google';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Gemini model on Google AI Studio (not Vertex).
+ */
+ model?: string;
+};
+
+/**
+ * Google Realtime
+ */
+export type GoogleRealtimeLlmConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'google_realtime';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Gemini Live model on Google AI Studio (not Vertex).
+ */
+ model?: string;
+ /**
+ * Voice
+ *
+ * Voice the model speaks in.
+ */
+ voice?: string;
+ /**
+ * Language
+ *
+ * ISO 639-1 language code.
+ */
+ language?: string;
+};
+
+/**
+ * Google Cloud
+ */
+export type GoogleSttConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'google';
+ /**
+ * Api Key
+ *
+ * Not used for Google Cloud STT. Leave blank.
+ */
+ api_key?: string | Array | null;
+ /**
+ * Model
+ *
+ * Google Cloud Speech-to-Text V2 recognition model.
+ */
+ model?: string;
+ /**
+ * Language
+ *
+ * Primary BCP-47 language code for recognition.
+ */
+ language?: string;
+ /**
+ * Location
+ *
+ * Google Cloud Speech-to-Text region (for example 'global' or 'us-central1').
+ */
+ location?: string;
+ /**
+ * Credentials
+ *
+ * Paste the entire Google Cloud service-account JSON. If omitted, the server falls back to Application Default Credentials (ADC).
+ */
+ credentials?: string | null;
+};
+
+/**
+ * Google Cloud
+ */
+export type GoogleTtsConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'google';
+ /**
+ * Api Key
+ *
+ * Not used for Google Cloud TTS. Leave blank.
+ */
+ api_key?: string | Array | null;
+ /**
+ * Model
+ *
+ * Google Cloud low-latency TTS engine. Dograh maps this to Pipecat's streaming Google TTS service for Chirp 3 HD and Journey voices.
+ */
+ model?: string;
+ /**
+ * Voice
+ *
+ * Google Cloud voice name. Use a Chirp 3 HD or Journey voice for streaming TTS.
+ */
+ voice?: string;
+ /**
+ * Language
+ *
+ * BCP-47 language code for synthesis.
+ */
+ language?: string;
+ /**
+ * Speed
+ *
+ * Speech speed multiplier for Google streaming TTS.
+ */
+ speed?: number;
+ /**
+ * Location
+ *
+ * Optional Google Cloud regional Text-to-Speech endpoint (for example 'us-central1'). Leave blank to use the default endpoint.
+ */
+ location?: string | null;
+ /**
+ * Credentials
+ *
+ * Paste the entire Google Cloud service-account JSON. If omitted, the server falls back to Application Default Credentials (ADC).
+ */
+ credentials?: string | null;
+};
+
+/**
+ * Google Vertex
+ */
+export type GoogleVertexLlmConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'google_vertex';
+ /**
+ * Api Key
+ *
+ * Not used for Vertex AI — authentication is via the service account in `credentials` (or ADC). Leave blank.
+ */
+ api_key?: string | Array | null;
+ /**
+ * Model
+ *
+ * Gemini model on Vertex AI.
+ */
+ model?: string;
+ /**
+ * Project Id
+ *
+ * Google Cloud project ID for Vertex AI.
+ */
+ project_id: string;
+ /**
+ * Location
+ *
+ * GCP region for the Vertex AI endpoint (e.g. 'global').
+ */
+ location?: string;
+ /**
+ * Credentials
+ *
+ * Paste the entire service-account JSON file contents. If omitted, falls back to Application Default Credentials (ADC).
+ */
+ credentials?: string | null;
+};
+
+/**
+ * Google Vertex Realtime
+ */
+export type GoogleVertexRealtimeLlmConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'google_vertex_realtime';
+ /**
+ * Api Key
+ *
+ * Not used for Vertex AI — authentication is via the service account in `credentials` (or ADC). Leave blank.
+ */
+ api_key?: string | Array | null;
+ /**
+ * Model
+ *
+ * Vertex AI publisher/model identifier.
+ */
+ model?: string;
+ /**
+ * Voice
+ *
+ * Voice the model speaks in.
+ */
+ voice?: string;
+ /**
+ * Language
+ *
+ * BCP-47 language code (e.g. 'en-US').
+ */
+ language?: string;
+ /**
+ * Project Id
+ *
+ * Google Cloud project ID for Vertex AI.
+ */
+ project_id: string;
+ /**
+ * Location
+ *
+ * GCP region for the Vertex AI endpoint (e.g. 'global').
+ */
+ location?: string;
+ /**
+ * Credentials
+ *
+ * Paste the entire service-account JSON file contents. If omitted, falls back to Application Default Credentials (ADC).
+ */
+ credentials?: string | null;
+};
+
/**
* GraphConstraints
*
@@ -1782,6 +2752,52 @@ export type GraphConstraints = {
max_outgoing?: number | null;
};
+/**
+ * Grok Realtime
+ */
+export type GrokRealtimeLlmConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'grok_realtime';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Grok realtime voice-agent model.
+ */
+ model?: string;
+ /**
+ * Voice
+ *
+ * Voice the model speaks in.
+ */
+ voice?: string;
+};
+
+/**
+ * Groq
+ */
+export type GroqLlmService = {
+ /**
+ * Provider
+ */
+ provider?: 'groq';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Groq-hosted model identifier.
+ */
+ model?: string;
+};
+
/**
* HTTPValidationError
*/
@@ -2205,6 +3221,82 @@ export type McpToolDefinition = {
config: McpToolConfig;
};
+/**
+ * MiniMaxLLMConfiguration
+ */
+export type MiniMaxLlmConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'minimax';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * MiniMax chat model.
+ */
+ model?: string;
+ /**
+ * Base Url
+ *
+ * MiniMax OpenAI-compatible API endpoint.
+ */
+ base_url?: string;
+ /**
+ * Temperature
+ *
+ * Sampling temperature. MiniMax requires > 0.
+ */
+ temperature?: number;
+};
+
+/**
+ * MiniMaxTTSConfiguration
+ */
+export type MiniMaxTtsConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'minimax';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * MiniMax TTS model.
+ */
+ model?: string;
+ /**
+ * Voice
+ *
+ * MiniMax voice ID.
+ */
+ voice?: string;
+ /**
+ * Base Url
+ *
+ * MiniMax TTS API endpoint (must include the /v1/t2a_v2 path). Defaults to the global endpoint; override with https://api.minimaxi.chat/v1/t2a_v2 (mainland China) or https://api-uw.minimax.io/v1/t2a_v2 (US-West).
+ */
+ base_url?: string;
+ /**
+ * Speed
+ *
+ * Speech speed (0.5 to 2.0).
+ */
+ speed?: number;
+ /**
+ * Group Id
+ *
+ * MiniMax Group ID (found in your MiniMax dashboard under Account → Group).
+ */
+ group_id: string;
+};
+
/**
* MoveWorkflowToFolderRequest
*
@@ -2306,6 +3398,241 @@ export type NodeTypesResponse = {
node_types: Array;
};
+/**
+ * OpenAI
+ */
+export type OpenAiEmbeddingsConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'openai';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * OpenAI embedding model.
+ */
+ model?: string;
+};
+
+/**
+ * OpenAI
+ */
+export type OpenAillmService = {
+ /**
+ * Provider
+ */
+ provider?: 'openai';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * OpenAI chat model to use.
+ */
+ model?: string;
+ /**
+ * Base Url
+ *
+ * Override only if using an OpenAI-compatible API (e.g. local LLM, proxy).
+ */
+ base_url?: string;
+};
+
+/**
+ * OpenAI Realtime
+ */
+export type OpenAiRealtimeLlmConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'openai_realtime';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * OpenAI realtime (speech-to-speech) model.
+ */
+ model?: string;
+ /**
+ * Voice
+ *
+ * Voice the model speaks in.
+ */
+ voice?: string;
+};
+
+/**
+ * OpenAI
+ */
+export type OpenAisttConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'openai';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * OpenAI transcription model.
+ */
+ model?: string;
+ /**
+ * Base Url
+ *
+ * Override only if using an OpenAI-compatible API (e.g. local STT, proxy).
+ */
+ base_url?: string;
+};
+
+/**
+ * OpenAI
+ */
+export type OpenAittsService = {
+ /**
+ * Provider
+ */
+ provider?: 'openai';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * OpenAI TTS model.
+ */
+ model?: string;
+ /**
+ * Voice
+ *
+ * OpenAI TTS voice name.
+ */
+ voice?: string;
+ /**
+ * Base Url
+ *
+ * Override only if using an OpenAI-compatible API (e.g. local TTS, proxy).
+ */
+ base_url?: string;
+};
+
+/**
+ * Open Router
+ */
+export type OpenRouterEmbeddingsConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'openrouter';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * OpenRouter-hosted embedding model slug.
+ */
+ model?: string;
+ /**
+ * Base Url
+ *
+ * Override only if proxying OpenRouter through your own gateway.
+ */
+ base_url?: string;
+};
+
+/**
+ * Open Router
+ */
+export type OpenRouterLlmConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'openrouter';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * OpenRouter model slug in 'vendor/model' form.
+ */
+ model?: string;
+ /**
+ * Base Url
+ *
+ * Override only if proxying OpenRouter through your own gateway.
+ */
+ base_url?: string;
+};
+
+/**
+ * OrganizationAIModelConfigurationPreferences
+ */
+export type OrganizationAiModelConfigurationPreferences = {
+ /**
+ * Test Phone Number
+ */
+ test_phone_number?: string | null;
+ /**
+ * Timezone
+ */
+ timezone?: string | null;
+};
+
+/**
+ * OrganizationAIModelConfigurationResponse
+ */
+export type OrganizationAiModelConfigurationResponse = {
+ /**
+ * Configuration
+ */
+ configuration: {
+ [key: string]: unknown;
+ } | null;
+ /**
+ * Effective Configuration
+ */
+ effective_configuration: {
+ [key: string]: unknown;
+ };
+ preferences: OrganizationAiModelConfigurationPreferences;
+ /**
+ * Source
+ */
+ source: 'organization_v2' | 'legacy_user_v1' | 'empty';
+};
+
+/**
+ * OrganizationAIModelConfigurationV2
+ */
+export type OrganizationAiModelConfigurationV2 = {
+ /**
+ * Version
+ */
+ version?: 2;
+ /**
+ * Mode
+ */
+ mode: 'dograh' | 'byok';
+ dograh?: DograhManagedAiModelConfiguration | null;
+ byok?: ByokaiModelConfiguration | null;
+};
+
/**
* PhoneNumberCreateRequest
*
@@ -3034,6 +4361,44 @@ export type RewindTextChatSessionRequest = {
expected_revision?: number | null;
};
+/**
+ * Rime
+ */
+export type RimeTtsConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'rime';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Rime TTS model.
+ */
+ model?: string;
+ /**
+ * Voice
+ *
+ * Rime voice ID.
+ */
+ voice?: string;
+ /**
+ * Speed
+ *
+ * Speech speed multiplier.
+ */
+ speed?: number;
+ /**
+ * Language
+ *
+ * ISO 639-1 language code.
+ */
+ language?: string;
+};
+
/**
* S3SignedUrlResponse
*/
@@ -3048,6 +4413,90 @@ export type S3SignedUrlResponse = {
expires_in: number;
};
+/**
+ * Sarvam
+ */
+export type SarvamLlmConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'sarvam';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Sarvam chat model. Use sarvam-30b for low-latency voice agents; sarvam-105b for complex multi-step reasoning.
+ */
+ model?: string;
+ /**
+ * Temperature
+ *
+ * Sampling temperature. Sarvam recommends 0.5 for balanced conversational responses.
+ */
+ temperature?: number;
+};
+
+/**
+ * Sarvam
+ */
+export type SarvamSttConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'sarvam';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Sarvam STT model. saarika:v2.5 transcribes in the spoken language; saaras:v3 is the recommended model with flexible output modes.
+ */
+ model?: string;
+ /**
+ * Language
+ *
+ * BCP-47 language code. Use unknown for automatic language detection.
+ */
+ language?: string;
+};
+
+/**
+ * Sarvam
+ */
+export type SarvamTtsConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'sarvam';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Sarvam TTS model (voice list depends on this).
+ */
+ model?: string;
+ /**
+ * Voice
+ *
+ * Sarvam voice name; must match the selected model's voice list.
+ */
+ voice?: string;
+ /**
+ * Language
+ *
+ * BCP-47 Indian-language code (e.g. hi-IN, en-IN).
+ */
+ language?: string;
+};
+
/**
* ScheduleConfigRequest
*/
@@ -3144,6 +4593,140 @@ export type SignupRequest = {
name?: string | null;
};
+/**
+ * Local Models (Speaches)
+ *
+ * Self-hosted OpenAI-compatible local models. See the Speaches project for setup and supported backends.
+ */
+export type SpeachesLlmConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'speaches';
+ /**
+ * Api Key
+ *
+ * Usually not required for self-hosted endpoints. Leave blank unless your server enforces one.
+ */
+ api_key?: string | Array | null;
+ /**
+ * Model
+ *
+ * Model name as exposed by your OpenAI-compatible server.
+ */
+ model?: string;
+ /**
+ * Base Url
+ *
+ * OpenAI-compatible endpoint (Ollama, vLLM, etc.).
+ */
+ base_url?: string;
+};
+
+/**
+ * Local Models (Speaches)
+ *
+ * Self-hosted OpenAI-compatible local models. See the Speaches project for setup and supported backends.
+ */
+export type SpeachesSttConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'speaches';
+ /**
+ * Api Key
+ *
+ * Usually not required for self-hosted STT. Leave blank unless enforced.
+ */
+ api_key?: string | Array | null;
+ /**
+ * Model
+ *
+ * Whisper model identifier as served by your STT endpoint.
+ */
+ model?: string;
+ /**
+ * Language
+ *
+ * ISO 639-1 language code.
+ */
+ language?: string;
+ /**
+ * Base Url
+ *
+ * OpenAI-compatible STT endpoint (Speaches, etc.).
+ */
+ base_url?: string;
+};
+
+/**
+ * Local Models (Speaches)
+ *
+ * Self-hosted OpenAI-compatible local models. See the Speaches project for setup and supported backends.
+ */
+export type SpeachesTtsConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'speaches';
+ /**
+ * Api Key
+ *
+ * Usually not required for self-hosted TTS. Leave blank unless enforced.
+ */
+ api_key?: string | Array | null;
+ /**
+ * Model
+ *
+ * Model name as served by your TTS endpoint (e.g. Kokoro-FastAPI).
+ */
+ model?: string;
+ /**
+ * Voice
+ *
+ * Voice ID for the TTS engine.
+ */
+ voice?: string;
+ /**
+ * Base Url
+ *
+ * OpenAI-compatible TTS endpoint (Kokoro-FastAPI, etc.).
+ */
+ base_url?: string;
+ /**
+ * Speed
+ *
+ * Speech speed (0.25 to 4.0).
+ */
+ speed?: number;
+};
+
+/**
+ * Speechmatics
+ */
+export type SpeechmaticsSttConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'speechmatics';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Speechmatics operating point: 'standard' or 'enhanced'.
+ */
+ model?: string;
+ /**
+ * Language
+ *
+ * ISO 639-1 language code.
+ */
+ language?: string;
+};
+
/**
* SuperuserWorkflowRunResponse
*/
@@ -3877,6 +5460,32 @@ export type TwilioConfigurationResponse = {
from_numbers: Array;
};
+/**
+ * Ultravox Realtime
+ */
+export type UltravoxRealtimeLlmConfiguration = {
+ /**
+ * Provider
+ */
+ provider?: 'ultravox_realtime';
+ /**
+ * Api Key
+ */
+ api_key: string | Array;
+ /**
+ * Model
+ *
+ * Ultravox realtime voice-agent model.
+ */
+ model?: string;
+ /**
+ * Voice
+ *
+ * Ultravox voice name or voice ID.
+ */
+ voice?: string;
+};
+
/**
* UpdateCampaignRequest
*/
@@ -5277,16 +6886,6 @@ export type HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflow
export type HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostData = {
body?: never;
- headers?: {
- /**
- * X-Vobiz-Signature
- */
- 'x-vobiz-signature'?: string | null;
- /**
- * X-Vobiz-Timestamp
- */
- 'x-vobiz-timestamp'?: string | null;
- };
path: {
/**
* Workflow Run Id
@@ -5319,16 +6918,6 @@ export type HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRu
export type HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostData = {
body?: never;
- headers?: {
- /**
- * X-Vobiz-Signature
- */
- 'x-vobiz-signature'?: string | null;
- /**
- * X-Vobiz-Timestamp
- */
- 'x-vobiz-timestamp'?: string | null;
- };
path: {
/**
* Workflow Run Id
@@ -5361,16 +6950,6 @@ export type HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdP
export type HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData = {
body?: never;
- headers?: {
- /**
- * X-Vobiz-Signature
- */
- 'x-vobiz-signature'?: string | null;
- /**
- * X-Vobiz-Timestamp
- */
- 'x-vobiz-timestamp'?: string | null;
- };
path: {
/**
* Workflow Id
@@ -8250,6 +9829,280 @@ export type GetTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsG
export type GetTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGetResponse = GetTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGetResponses[keyof GetTelephonyConfigWarningsApiV1OrganizationsTelephonyConfigWarningsGetResponses];
+export type GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetData = {
+ body?: never;
+ headers?: {
+ /**
+ * Authorization
+ */
+ authorization?: string | null;
+ /**
+ * X-Api-Key
+ */
+ 'X-API-Key'?: string | null;
+ };
+ path?: never;
+ query?: never;
+ url: '/api/v1/organizations/model-configurations/v2/defaults';
+};
+
+export type GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetErrors = {
+ /**
+ * Not found
+ */
+ 404: unknown;
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetError = GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetErrors[keyof GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetErrors];
+
+export type GetModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGetResponses = {
+ /**
+ * Successful Response
+ */
+ 200: unknown;
+};
+
+export type GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetData = {
+ body?: never;
+ headers?: {
+ /**
+ * Authorization
+ */
+ authorization?: string | null;
+ /**
+ * X-Api-Key
+ */
+ 'X-API-Key'?: string | null;
+ };
+ path?: never;
+ query?: never;
+ url: '/api/v1/organizations/model-configurations/v2';
+};
+
+export type GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetErrors = {
+ /**
+ * Not found
+ */
+ 404: unknown;
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetError = GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetErrors[keyof GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetErrors];
+
+export type GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetResponses = {
+ /**
+ * Successful Response
+ */
+ 200: OrganizationAiModelConfigurationResponse;
+};
+
+export type GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetResponse = GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetResponses[keyof GetModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2GetResponses];
+
+export type SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutData = {
+ body: OrganizationAiModelConfigurationV2;
+ headers?: {
+ /**
+ * Authorization
+ */
+ authorization?: string | null;
+ /**
+ * X-Api-Key
+ */
+ 'X-API-Key'?: string | null;
+ };
+ path?: never;
+ query?: never;
+ url: '/api/v1/organizations/model-configurations/v2';
+};
+
+export type SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutErrors = {
+ /**
+ * Not found
+ */
+ 404: unknown;
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutError = SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutErrors[keyof SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutErrors];
+
+export type SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutResponses = {
+ /**
+ * Successful Response
+ */
+ 200: OrganizationAiModelConfigurationResponse;
+};
+
+export type SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutResponse = SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutResponses[keyof SaveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2PutResponses];
+
+export type PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetData = {
+ body?: never;
+ headers?: {
+ /**
+ * Authorization
+ */
+ authorization?: string | null;
+ /**
+ * X-Api-Key
+ */
+ 'X-API-Key'?: string | null;
+ };
+ path?: never;
+ query?: never;
+ url: '/api/v1/organizations/model-configurations/v2/migration-preview';
+};
+
+export type PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetErrors = {
+ /**
+ * Not found
+ */
+ 404: unknown;
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetError = PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetErrors[keyof PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetErrors];
+
+export type PreviewModelConfigurationV2MigrationApiV1OrganizationsModelConfigurationsV2MigrationPreviewGetResponses = {
+ /**
+ * Successful Response
+ */
+ 200: unknown;
+};
+
+export type MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostData = {
+ body?: never;
+ headers?: {
+ /**
+ * Authorization
+ */
+ authorization?: string | null;
+ /**
+ * X-Api-Key
+ */
+ 'X-API-Key'?: string | null;
+ };
+ path?: never;
+ query?: {
+ /**
+ * Force
+ */
+ force?: boolean;
+ };
+ url: '/api/v1/organizations/model-configurations/v2/migrate';
+};
+
+export type MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostErrors = {
+ /**
+ * Not found
+ */
+ 404: unknown;
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostError = MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostErrors[keyof MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostErrors];
+
+export type MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostResponses = {
+ /**
+ * Successful Response
+ */
+ 200: OrganizationAiModelConfigurationResponse;
+};
+
+export type MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostResponse = MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostResponses[keyof MigrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePostResponses];
+
+export type GetModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesGetData = {
+ body?: never;
+ headers?: {
+ /**
+ * Authorization
+ */
+ authorization?: string | null;
+ /**
+ * X-Api-Key
+ */
+ 'X-API-Key'?: string | null;
+ };
+ path?: never;
+ query?: never;
+ url: '/api/v1/organizations/model-configurations/preferences';
+};
+
+export type GetModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesGetErrors = {
+ /**
+ * Not found
+ */
+ 404: unknown;
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type GetModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesGetError = GetModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesGetErrors[keyof GetModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesGetErrors];
+
+export type GetModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesGetResponses = {
+ /**
+ * Successful Response
+ */
+ 200: OrganizationAiModelConfigurationPreferences;
+};
+
+export type GetModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesGetResponse = GetModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesGetResponses[keyof GetModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesGetResponses];
+
+export type SaveModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesPutData = {
+ body: OrganizationAiModelConfigurationPreferences;
+ headers?: {
+ /**
+ * Authorization
+ */
+ authorization?: string | null;
+ /**
+ * X-Api-Key
+ */
+ 'X-API-Key'?: string | null;
+ };
+ path?: never;
+ query?: never;
+ url: '/api/v1/organizations/model-configurations/preferences';
+};
+
+export type SaveModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesPutErrors = {
+ /**
+ * Not found
+ */
+ 404: unknown;
+ /**
+ * Validation Error
+ */
+ 422: HttpValidationError;
+};
+
+export type SaveModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesPutError = SaveModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesPutErrors[keyof SaveModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesPutErrors];
+
+export type SaveModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesPutResponses = {
+ /**
+ * Successful Response
+ */
+ 200: OrganizationAiModelConfigurationPreferences;
+};
+
+export type SaveModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesPutResponse = SaveModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesPutResponses[keyof SaveModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesPutResponses];
+
export type ListTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGetData = {
body?: never;
headers?: {
@@ -9909,7 +11762,7 @@ export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses = {
export type GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponse = GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses[keyof GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponses];
-export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsData = {
+export type OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsData = {
body?: never;
path: {
/**
@@ -9921,7 +11774,7 @@ export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsData = {
url: '/api/v1/public/embed/config/{token}';
};
-export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors = {
+export type OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsErrors = {
/**
* Not found
*/
@@ -9932,9 +11785,9 @@ export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors = {
422: HttpValidationError;
};
-export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsError = OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors[keyof OptionsConfigApiV1PublicEmbedConfigTokenOptionsErrors];
+export type OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsError = OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsErrors[keyof OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsErrors];
-export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsResponses = {
+export type OptionsEmbedConfigApiV1PublicEmbedConfigTokenOptionsResponses = {
/**
* Successful Response
*/
diff --git a/ui/src/components/AIModelConfigurationV2Editor.tsx b/ui/src/components/AIModelConfigurationV2Editor.tsx
new file mode 100644
index 0000000..13ee2ed
--- /dev/null
+++ b/ui/src/components/AIModelConfigurationV2Editor.tsx
@@ -0,0 +1,419 @@
+"use client";
+
+import { KeyRound, Save } from "lucide-react";
+import { useEffect, useMemo, useState } from "react";
+
+import type { OrganizationAiModelConfigurationV2 } from "@/client/types.gen";
+import {
+ type ProviderSchema,
+ type ServiceConfigurationDefaults,
+ ServiceConfigurationForm,
+ type ServiceSegment,
+} from "@/components/ServiceConfigurationForm";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
+
+type ModelMode = "dograh" | "byok";
+
+interface DograhDefaults {
+ voices: string[];
+ speeds: number[];
+ languages: string[];
+ defaults: {
+ voice: string;
+ speed: number;
+ language: string;
+ };
+}
+
+export interface ModelConfigurationDefaultsV2 {
+ dograh: DograhDefaults;
+ byok: {
+ pipeline: ServiceConfigurationDefaults;
+ realtime: {
+ realtime: Record;
+ llm: Record;
+ embeddings: Record;
+ default_providers: ServiceConfigurationDefaults["default_providers"];
+ };
+ };
+}
+
+interface DograhFormState {
+ api_key: string;
+ voice: string;
+ speed: number;
+ language: string;
+}
+
+interface AIModelConfigurationV2EditorProps {
+ defaults: ModelConfigurationDefaultsV2;
+ configuration?: OrganizationAiModelConfigurationV2 | Record | null;
+ effectiveConfiguration?: Record | null;
+ onSave: (configuration: OrganizationAiModelConfigurationV2) => Promise;
+ submitLabel?: string;
+}
+
+function firstApiKey(value: unknown): string {
+ if (Array.isArray(value)) return String(value[0] || "");
+ return typeof value === "string" ? value : "";
+}
+
+function asRecord(value: unknown): Record | null {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? value as Record
+ : null;
+}
+
+function isDograhEffectiveConfig(config: Record | null | undefined): boolean {
+ if (!config || config.is_realtime) return false;
+ const llm = asRecord(config.llm);
+ const tts = asRecord(config.tts);
+ const stt = asRecord(config.stt);
+ return llm?.provider === "dograh" && tts?.provider === "dograh" && stt?.provider === "dograh";
+}
+
+function byokDefaults(defaults: ModelConfigurationDefaultsV2): ServiceConfigurationDefaults {
+ return {
+ llm: defaults.byok.pipeline.llm,
+ tts: defaults.byok.pipeline.tts,
+ stt: defaults.byok.pipeline.stt,
+ embeddings: defaults.byok.pipeline.embeddings,
+ realtime: defaults.byok.realtime.realtime,
+ default_providers: defaults.byok.pipeline.default_providers,
+ };
+}
+
+function byokConfigToLegacyShape(config: Record | null): Record | null {
+ if (!config || config.mode !== "byok") return null;
+ const byok = asRecord(config.byok);
+ if (!byok) return null;
+
+ if (byok.mode === "realtime") {
+ const realtime = asRecord(byok.realtime);
+ return {
+ is_realtime: true,
+ realtime: realtime?.realtime,
+ llm: realtime?.llm,
+ embeddings: realtime?.embeddings,
+ };
+ }
+
+ const pipeline = asRecord(byok.pipeline);
+ return {
+ is_realtime: false,
+ llm: pipeline?.llm,
+ tts: pipeline?.tts,
+ stt: pipeline?.stt,
+ embeddings: pipeline?.embeddings,
+ };
+}
+
+function effectiveConfigToLegacyShape(config: Record | null): Record | null {
+ if (!config) return null;
+ return {
+ is_realtime: Boolean(config.is_realtime),
+ llm: config.llm,
+ tts: config.tts,
+ stt: config.stt,
+ realtime: config.realtime,
+ embeddings: config.embeddings,
+ };
+}
+
+function emptyByokInitialConfig(): Record {
+ return {
+ is_realtime: false,
+ };
+}
+
+function getByokInitialConfig(
+ configuration: Record | null,
+ effectiveConfiguration: Record | null,
+): Record {
+ const byokConfiguration = byokConfigToLegacyShape(configuration);
+ if (byokConfiguration) return byokConfiguration;
+
+ if (configuration?.mode === "dograh" || isDograhEffectiveConfig(effectiveConfiguration)) {
+ return emptyByokInitialConfig();
+ }
+
+ return effectiveConfigToLegacyShape(effectiveConfiguration) || emptyByokInitialConfig();
+}
+
+function buildDograhState(
+ defaults: ModelConfigurationDefaultsV2,
+ configuration: Record | null,
+ effectiveConfiguration: Record | null,
+): DograhFormState {
+ const fallback = defaults.dograh.defaults;
+ const configuredDograh = configuration?.mode === "dograh" ? asRecord(configuration.dograh) : null;
+ if (configuredDograh) {
+ return {
+ api_key: String(configuredDograh.api_key || ""),
+ voice: String(configuredDograh.voice || fallback.voice),
+ speed: Number(configuredDograh.speed || fallback.speed),
+ language: String(configuredDograh.language || fallback.language),
+ };
+ }
+
+ if (isDograhEffectiveConfig(effectiveConfiguration)) {
+ const llm = asRecord(effectiveConfiguration?.llm);
+ const tts = asRecord(effectiveConfiguration?.tts);
+ const stt = asRecord(effectiveConfiguration?.stt);
+ return {
+ api_key: firstApiKey(llm?.api_key || tts?.api_key || stt?.api_key),
+ voice: String(tts?.voice || fallback.voice),
+ speed: Number(tts?.speed || fallback.speed),
+ language: String(stt?.language || fallback.language),
+ };
+ }
+
+ return {
+ api_key: "",
+ voice: fallback.voice,
+ speed: fallback.speed,
+ language: fallback.language,
+ };
+}
+
+function preferredMode(
+ configuration: Record | null,
+ effectiveConfiguration: Record | null,
+): ModelMode {
+ if (configuration?.mode === "dograh" || configuration?.mode === "byok") {
+ return configuration.mode;
+ }
+ return isDograhEffectiveConfig(effectiveConfiguration) ? "dograh" : "byok";
+}
+
+function hasRequiredApiKey(
+ service: ServiceSegment,
+ serviceConfiguration: Record,
+ defaults: ServiceConfigurationDefaults,
+): boolean {
+ const provider = serviceConfiguration.provider as string | undefined;
+ if (!provider) return false;
+ const providerSchema = service === "realtime"
+ ? defaults.realtime?.[provider]
+ : defaults[service as "llm" | "tts" | "stt" | "embeddings"]?.[provider];
+ const requiresApiKey = providerSchema?.required?.includes("api_key") ?? false;
+ if (!requiresApiKey) return true;
+
+ const apiKey = serviceConfiguration.api_key;
+ if (Array.isArray(apiKey)) {
+ return apiKey.some((key) => typeof key === "string" && key.trim().length > 0);
+ }
+ return typeof apiKey === "string" && apiKey.trim().length > 0;
+}
+
+function requireByokService(
+ config: Record,
+ service: ServiceSegment,
+ defaults: ServiceConfigurationDefaults,
+): Record {
+ const serviceConfiguration = asRecord(config[service]);
+ if (
+ !serviceConfiguration
+ || !serviceConfiguration.provider
+ || serviceConfiguration.provider === "dograh"
+ || !hasRequiredApiKey(service, serviceConfiguration, defaults)
+ ) {
+ throw new Error(`${service} configuration is required`);
+ }
+ return serviceConfiguration;
+}
+
+function optionalByokService(config: Record, service: ServiceSegment): Record | undefined {
+ const serviceConfiguration = asRecord(config[service]);
+ if (!serviceConfiguration?.provider || serviceConfiguration.provider === "dograh") return undefined;
+ return serviceConfiguration;
+}
+
+export function AIModelConfigurationV2Editor({
+ defaults,
+ configuration,
+ effectiveConfiguration,
+ onSave,
+ submitLabel = "Save Configuration",
+}: AIModelConfigurationV2EditorProps) {
+ const defaultsForByok = useMemo(() => byokDefaults(defaults), [defaults]);
+ const [mode, setMode] = useState("dograh");
+ const [dograh, setDograh] = useState(() => ({
+ api_key: "",
+ voice: defaults.dograh.defaults.voice,
+ speed: defaults.dograh.defaults.speed,
+ language: defaults.dograh.defaults.language,
+ }));
+ const [byokInitialConfig, setByokInitialConfig] = useState | null>(null);
+ const [isSavingDograh, setIsSavingDograh] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const rawConfiguration = asRecord(configuration);
+ const rawEffectiveConfiguration = asRecord(effectiveConfiguration);
+ setMode(preferredMode(rawConfiguration, rawEffectiveConfiguration));
+ setDograh(buildDograhState(defaults, rawConfiguration, rawEffectiveConfiguration));
+ setByokInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration));
+ }, [configuration, defaults, effectiveConfiguration]);
+
+ const saveDograhConfiguration = async () => {
+ setIsSavingDograh(true);
+ setError(null);
+ try {
+ await onSave({
+ version: 2,
+ mode: "dograh",
+ dograh: {
+ api_key: dograh.api_key.trim(),
+ voice: dograh.voice,
+ speed: dograh.speed,
+ language: dograh.language,
+ },
+ });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to save configuration");
+ } finally {
+ setIsSavingDograh(false);
+ }
+ };
+
+ const saveByokConfiguration = async (config: Record) => {
+ setError(null);
+ const isRealtime = Boolean(config.is_realtime);
+ const llm = requireByokService(config, "llm", defaultsForByok);
+ const embeddings = optionalByokService(config, "embeddings");
+ const body: OrganizationAiModelConfigurationV2 = {
+ version: 2,
+ mode: "byok",
+ byok: isRealtime
+ ? {
+ mode: "realtime",
+ realtime: {
+ realtime: requireByokService(config, "realtime", defaultsForByok) as never,
+ llm: llm as never,
+ ...(embeddings ? { embeddings: embeddings as never } : {}),
+ },
+ }
+ : {
+ mode: "pipeline",
+ pipeline: {
+ llm: llm as never,
+ tts: requireByokService(config, "tts", defaultsForByok) as never,
+ stt: requireByokService(config, "stt", defaultsForByok) as never,
+ ...(embeddings ? { embeddings: embeddings as never } : {}),
+ },
+ },
+ };
+
+ await onSave(body);
+ };
+
+ return (
+
+ {error && (
+
+ {error}
+
+ )}
+
+
setMode(value as ModelMode)} className="space-y-6">
+
+ Dograh
+ BYOK
+
+
+
+
+
+
+
+
+
+ setDograh({ ...dograh, api_key: event.target.value })}
+ placeholder="Enter API key"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/components/ModelConfigurationV2.tsx b/ui/src/components/ModelConfigurationV2.tsx
new file mode 100644
index 0000000..6cb4e6e
--- /dev/null
+++ b/ui/src/components/ModelConfigurationV2.tsx
@@ -0,0 +1,400 @@
+"use client";
+
+import { ExternalLink, RefreshCw, Save } from "lucide-react";
+import { useEffect, useId, useRef, useState } from "react";
+import TimezoneSelect, { type ITimezoneOption } from "react-timezone-select";
+
+import {
+ getModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesGet,
+ getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get,
+ getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet,
+ migrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePost,
+ saveModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesPut,
+ saveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Put,
+} from "@/client/sdk.gen";
+import type {
+ OrganizationAiModelConfigurationPreferences,
+ OrganizationAiModelConfigurationResponse,
+ OrganizationAiModelConfigurationV2,
+} from "@/client/types.gen";
+import { AIModelConfigurationV2Editor, type ModelConfigurationDefaultsV2 } from "@/components/AIModelConfigurationV2Editor";
+import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
+import {
+ AlertDialog,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useUserConfig } from "@/context/UserConfigContext";
+import { detailFromError } from "@/lib/apiError";
+import { useAuth } from "@/lib/auth";
+
+const emptyPreferences: OrganizationAiModelConfigurationPreferences = {
+ test_phone_number: "",
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
+};
+
+const timezoneSelectStyles = {
+ control: (base: Record, state: { isFocused: boolean }) => ({
+ ...base,
+ minHeight: "36px",
+ fontSize: "14px",
+ backgroundColor: "var(--background)",
+ borderColor: state.isFocused ? "var(--ring)" : "var(--border)",
+ boxShadow: state.isFocused ? "0 0 0 2px color-mix(in srgb, var(--ring) 20%, transparent)" : "none",
+ "&:hover": { borderColor: "var(--border)" },
+ }),
+ menu: (base: Record) => ({
+ ...base,
+ zIndex: 9999,
+ backgroundColor: "var(--popover)",
+ border: "1px solid var(--border)",
+ boxShadow: "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)",
+ }),
+ menuList: (base: Record) => ({
+ ...base,
+ backgroundColor: "var(--popover)",
+ padding: 0,
+ }),
+ option: (base: Record, state: { isSelected: boolean; isFocused: boolean }) => ({
+ ...base,
+ backgroundColor: state.isSelected ? "var(--accent)" : state.isFocused ? "var(--accent)" : "var(--popover)",
+ color: "var(--foreground)",
+ cursor: "pointer",
+ "&:active": { backgroundColor: "var(--accent)" },
+ }),
+ singleValue: (base: Record) => ({ ...base, color: "var(--foreground)" }),
+ input: (base: Record) => ({ ...base, color: "var(--foreground)" }),
+ placeholder: (base: Record) => ({ ...base, color: "var(--muted-foreground)" }),
+ indicatorSeparator: (base: Record) => ({ ...base, backgroundColor: "var(--border)" }),
+ dropdownIndicator: (base: Record) => ({
+ ...base,
+ color: "var(--muted-foreground)",
+ "&:hover": { color: "var(--foreground)" },
+ }),
+};
+
+function getTimezoneValue(tz: ITimezoneOption | string): string {
+ return typeof tz === "string" ? tz : tz.value;
+}
+
+export default function ModelConfigurationV2({
+ docsUrl,
+ initialAction,
+}: {
+ docsUrl?: string;
+ initialAction?: string;
+}) {
+ const auth = useAuth();
+ const { refreshConfig, saveUserConfig } = useUserConfig();
+ const timezoneSelectId = useId();
+ const hasFetched = useRef(false);
+ const hasAppliedInitialMigrationAction = useRef(false);
+
+ const [defaults, setDefaults] = useState(null);
+ const [response, setResponse] = useState(null);
+ const [preferences, setPreferences] = useState(emptyPreferences);
+ const [timezone, setTimezone] = useState(emptyPreferences.timezone || "UTC");
+ const [loading, setLoading] = useState(true);
+ const [savingPreferences, setSavingPreferences] = useState(false);
+ const [migrating, setMigrating] = useState(false);
+ const [migrationDialogOpen, setMigrationDialogOpen] = useState(false);
+ const [error, setError] = useState(null);
+ const [notice, setNotice] = useState(null);
+
+ const applyResponse = (nextResponse: OrganizationAiModelConfigurationResponse) => {
+ setResponse(nextResponse);
+ };
+
+ useEffect(() => {
+ if (auth.loading || !auth.user || hasFetched.current) return;
+ hasFetched.current = true;
+
+ const load = async () => {
+ setLoading(true);
+ setError(null);
+ const [defaultsResult, configResult, preferencesResult] = await Promise.all([
+ getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet(),
+ getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get(),
+ getModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesGet(),
+ ]);
+
+ if (defaultsResult.error) {
+ setError(detailFromError(defaultsResult.error, "Failed to load model configuration defaults"));
+ setLoading(false);
+ return;
+ }
+ if (configResult.error) {
+ setError(detailFromError(configResult.error, "Failed to load model configuration"));
+ setLoading(false);
+ return;
+ }
+ if (preferencesResult.error) {
+ setError(detailFromError(preferencesResult.error, "Failed to load model configuration preferences"));
+ setLoading(false);
+ return;
+ }
+
+ const nextDefaults = defaultsResult.data as ModelConfigurationDefaultsV2;
+ if (!nextDefaults || !configResult.data) {
+ setError("Failed to load model configuration");
+ setLoading(false);
+ return;
+ }
+ setDefaults(nextDefaults);
+ applyResponse(configResult.data);
+
+ const nextPreferences = preferencesResult.data || emptyPreferences;
+ setPreferences({
+ test_phone_number: nextPreferences.test_phone_number || "",
+ timezone: nextPreferences.timezone || emptyPreferences.timezone,
+ });
+ setTimezone(nextPreferences.timezone || emptyPreferences.timezone || "UTC");
+ setLoading(false);
+ };
+
+ load();
+
+ }, [auth.loading, auth.user]);
+
+ useEffect(() => {
+ if (hasAppliedInitialMigrationAction.current) return;
+ if (initialAction !== "migrate_to_v2") return;
+ if (loading || response?.source !== "legacy_user_v1") return;
+ hasAppliedInitialMigrationAction.current = true;
+ setMigrationDialogOpen(true);
+ }, [initialAction, loading, response?.source]);
+
+ const saveConfiguration = async (configuration: OrganizationAiModelConfigurationV2) => {
+ if (!defaults) return;
+ setError(null);
+ setNotice(null);
+
+ const result = await saveModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Put({
+ body: configuration,
+ });
+
+ if (result.error) {
+ throw new Error(detailFromError(result.error, "Failed to save model configuration"));
+ }
+ if (!result.data) {
+ throw new Error("Failed to save model configuration");
+ }
+
+ applyResponse(result.data);
+ await refreshConfig();
+ setNotice("Model configuration saved");
+ };
+
+ const savePreferences = async () => {
+ setSavingPreferences(true);
+ setError(null);
+ setNotice(null);
+
+ const result = await saveModelConfigurationPreferencesApiV1OrganizationsModelConfigurationsPreferencesPut({
+ body: {
+ test_phone_number: preferences.test_phone_number || null,
+ timezone: getTimezoneValue(timezone),
+ },
+ });
+
+ if (result.error) {
+ setError(detailFromError(result.error, "Failed to save preferences"));
+ } else if (!result.data) {
+ setError("Failed to save preferences");
+ } else {
+ setPreferences(result.data);
+ await refreshConfig();
+ setNotice("Preferences saved");
+ }
+ setSavingPreferences(false);
+ };
+
+ const migrateConfiguration = async () => {
+ if (!defaults) return;
+ setMigrating(true);
+ setError(null);
+ setNotice(null);
+
+ const result = await migrateModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2MigratePost();
+ if (result.error) {
+ setError(detailFromError(result.error, "Failed to migrate model configuration"));
+ } else if (!result.data) {
+ setError("Failed to migrate model configuration");
+ } else {
+ applyResponse(result.data);
+ await refreshConfig();
+ setNotice("Configuration migrated to v2");
+ setMigrationDialogOpen(false);
+ }
+ setMigrating(false);
+ };
+
+ const migrationWarningDialog = (
+
+
+
+ Migrate model configuration to v2?
+
+ Your configurations will be migrated to v2. After migration, check your global configuration and workflow model overrides, then run a test call to make sure everything is working.
+
+
+
+ Cancel
+
+
+
+
+ );
+
+ if (loading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ const source = response?.source || "empty";
+
+ if (source !== "organization_v2") {
+ return (
+
+
+
+
+
AI Models Configuration
+
+ {source === "legacy_user_v1" ? "legacy" : "v1"}
+
+
+
+ Configure your AI model, voice, and transcription services.{" "}
+ {docsUrl && (
+
+ Learn more
+
+ )}
+
+
+ {source === "legacy_user_v1" && (
+
+ )}
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {notice && (
+
+ {notice}
+
+ )}
+
+
{
+ setError(null);
+ setNotice(null);
+ await saveUserConfig(config as Parameters[0]);
+ await refreshConfig();
+ if (defaults) {
+ const configResult = await getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get();
+ if (configResult.data) {
+ applyResponse(configResult.data);
+ }
+ }
+ setNotice("Configuration saved");
+ }}
+ />
+ {migrationWarningDialog}
+
+ );
+ }
+
+ return (
+
+
+
+
AI Models Configuration
+
+ Organization-scoped model settings.{" "}
+ {docsUrl && (
+
+ Learn more
+
+ )}
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {notice && (
+
+ {notice}
+
+ )}
+
+ {defaults && response && (
+
+ )}
+
+
+
+
Preferences
+
+
+
+
+ setPreferences({ ...preferences, test_phone_number: event.target.value })}
+ placeholder="+15551234567"
+ />
+
+
+
+
+
+
+
+
+ {migrationWarningDialog}
+
+ );
+}
diff --git a/ui/src/components/ServiceConfigurationForm.tsx b/ui/src/components/ServiceConfigurationForm.tsx
index 34e7140..d082ca2 100644
--- a/ui/src/components/ServiceConfigurationForm.tsx
+++ b/ui/src/components/ServiceConfigurationForm.tsx
@@ -19,7 +19,7 @@ import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
import { useUserConfig } from "@/context/UserConfigContext";
import type { ModelOverrides } from "@/types/workflow-configurations";
-type ServiceSegment = "llm" | "tts" | "stt" | "embeddings" | "realtime";
+export type ServiceSegment = "llm" | "tts" | "stt" | "embeddings" | "realtime";
interface SchemaProperty {
type?: string;
@@ -35,7 +35,7 @@ interface SchemaProperty {
docs_url?: string;
}
-interface ProviderSchema {
+export interface ProviderSchema {
title?: string;
description?: string;
provider_docs_url?: string;
@@ -49,6 +49,15 @@ interface FormValues {
[key: string]: string | number | boolean;
}
+export interface ServiceConfigurationDefaults {
+ llm: Record;
+ tts: Record;
+ stt: Record;
+ embeddings: Record;
+ realtime?: Record;
+ default_providers: Partial>;
+}
+
const STANDARD_TABS: { key: ServiceSegment; label: string }[] = [
{ key: "llm", label: "LLM" },
{ key: "tts", label: "Voice" },
@@ -90,6 +99,8 @@ export interface ServiceConfigurationFormProps {
onSave: (config: Record) => Promise;
/** Text for the submit button. Defaults to "Save Configuration". */
submitLabel?: string;
+ configurationDefaults?: ServiceConfigurationDefaults | null;
+ initialConfig?: Record | null;
}
function getProviderDisplayName(
@@ -117,6 +128,8 @@ export function ServiceConfigurationForm({
currentOverrides,
onSave,
submitLabel,
+ configurationDefaults,
+ initialConfig,
}: ServiceConfigurationFormProps) {
const [apiError, setApiError] = useState(null);
const [isSaving, setIsSaving] = useState(false);
@@ -165,15 +178,16 @@ export function ServiceConfigurationForm({
// Build effective config source: overlay overrides onto global config
const configSource = useMemo(() => {
- if (mode === 'global' || !currentOverrides) return userConfig;
+ const baseConfig = initialConfig ?? userConfig;
+ if (mode === 'global' || !currentOverrides) return baseConfig;
// Merge overrides onto global config for form initialization
- const merged = { ...userConfig } as Record;
+ const merged = { ...baseConfig } as Record;
const overrideServices: (keyof ModelOverrides)[] = ["llm", "tts", "stt", "realtime"];
for (const svc of overrideServices) {
if (svc === "is_realtime") continue;
const overrideVal = currentOverrides[svc];
if (overrideVal && typeof overrideVal === "object") {
- const globalVal = (userConfig as Record | null)?.[svc] as Record | undefined;
+ const globalVal = (baseConfig as Record | null)?.[svc] as Record | undefined;
merged[svc] = { ...globalVal, ...overrideVal };
}
}
@@ -181,24 +195,35 @@ export function ServiceConfigurationForm({
merged.is_realtime = currentOverrides.is_realtime;
}
return merged as typeof userConfig;
- }, [mode, userConfig, currentOverrides]);
+ }, [mode, userConfig, currentOverrides, initialConfig]);
useEffect(() => {
const fetchConfigurations = async () => {
- const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet();
- if (!response.data) {
- console.error("Failed to fetch configurations");
- return;
+ let defaultsData = configurationDefaults;
+ if (!defaultsData) {
+ const response = await getDefaultConfigurationsApiV1UserConfigurationsDefaultsGet();
+ if (!response.data) {
+ console.error("Failed to fetch configurations");
+ return;
+ }
+ defaultsData = response.data as ServiceConfigurationDefaults;
}
- const data = response.data as Record;
- const realtimeSchemas = (data.realtime || {}) as Record;
+ const realtimeSchemas = (defaultsData.realtime || {}) as Record;
+ const pickDefaultProvider = (
+ service: ServiceSegment,
+ schemaMap: Record,
+ ) => {
+ const preferred = defaultsData.default_providers?.[service];
+ if (preferred && schemaMap[preferred]) return preferred;
+ return Object.keys(schemaMap)[0] || "";
+ };
setSchemas({
- llm: response.data.llm as Record,
- tts: response.data.tts as Record,
- stt: response.data.stt as Record,
- embeddings: response.data.embeddings as Record,
+ llm: defaultsData.llm,
+ tts: defaultsData.tts,
+ stt: defaultsData.stt,
+ embeddings: defaultsData.embeddings,
realtime: realtimeSchemas,
});
@@ -210,10 +235,10 @@ export function ServiceConfigurationForm({
const defaultValues: Record = {};
const selectedProviders: Record = {
- llm: response.data.default_providers.llm,
- tts: response.data.default_providers.tts,
- stt: response.data.default_providers.stt,
- embeddings: response.data.default_providers.embeddings,
+ llm: pickDefaultProvider("llm", defaultsData.llm),
+ tts: pickDefaultProvider("tts", defaultsData.tts),
+ stt: pickDefaultProvider("stt", defaultsData.stt),
+ embeddings: pickDefaultProvider("embeddings", defaultsData.embeddings),
realtime: "",
};
@@ -237,7 +262,7 @@ export function ServiceConfigurationForm({
const schemaSource = service === "realtime"
? realtimeSchemas
- : response.data![service as "llm" | "tts" | "stt" | "embeddings"] as Record | undefined;
+ : defaultsData[service as "llm" | "tts" | "stt" | "embeddings"] as Record | undefined;
if (src?.provider) {
Object.entries(src).forEach(([field, value]) => {
@@ -296,7 +321,7 @@ export function ServiceConfigurationForm({
// Detect custom inputs
const detectedCustomInput: Record = {};
- const allSchemas = { ...response.data, realtime: realtimeSchemas } as unknown as Record>;
+ const allSchemas = { ...defaultsData, realtime: realtimeSchemas } as unknown as Record>;
(["llm", "tts", "stt", "embeddings", "realtime"] as ServiceSegment[]).forEach(service => {
const provider = selectedProviders[service];
const providerSchema = allSchemas[service]?.[provider];
@@ -337,7 +362,7 @@ export function ServiceConfigurationForm({
};
fetchConfigurations();
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [reset, configSource]);
+ }, [reset, configSource, configurationDefaults]);
// Reset voice when TTS model changes if the provider has model-dependent voice options
const ttsModel = watch("tts_model");
diff --git a/ui/src/types/workflow-configurations.ts b/ui/src/types/workflow-configurations.ts
index 3f05c65..7a267bd 100644
--- a/ui/src/types/workflow-configurations.ts
+++ b/ui/src/types/workflow-configurations.ts
@@ -1,3 +1,5 @@
+import type { OrganizationAiModelConfigurationV2 } from "@/client/types.gen";
+
export interface AmbientNoiseConfiguration {
enabled: boolean;
volume: number;
@@ -64,6 +66,7 @@ export interface WorkflowConfigurations {
voicemail_detection?: VoicemailDetectionConfiguration;
context_compaction_enabled?: boolean; // Summarize context on node transitions to remove stale tool calls
model_overrides?: ModelOverrides; // Per-workflow model configuration overrides
+ model_configuration_v2_override?: OrganizationAiModelConfigurationV2; // Full v2 model configuration override
[key: string]: unknown; // Allow additional properties for future configurations
}