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 }