diff --git a/api/routes/campaign.py b/api/routes/campaign.py index 1dc8b9d..44a3ef4 100644 --- a/api/routes/campaign.py +++ b/api/routes/campaign.py @@ -11,10 +11,7 @@ from api.db.models import UserModel from api.enums import OrganizationConfigurationKey from api.services.auth.depends import get_user from api.services.campaign.runner import campaign_runner_service -from api.services.campaign.source_validator import ( - validate_csv_source, - validate_google_sheet_source, -) +from api.services.campaign.source_sync_factory import get_sync_service from api.services.quota_service import check_dograh_quota from api.services.storage import storage_fs @@ -35,6 +32,20 @@ async def _get_org_concurrent_limit(organization_id: int) -> int: return DEFAULT_ORG_CONCURRENCY_LIMIT +async def _get_from_numbers_count(organization_id: int) -> int: + """Get the number of configured from_numbers for an organization.""" + try: + config = await db_client.get_configuration( + organization_id, + OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value, + ) + if config and config.value: + return len(config.value.get("from_numbers", [])) + except Exception: + pass + return 0 + + class RetryConfigRequest(BaseModel): enabled: bool = True max_retries: int = Field(default=2, ge=0, le=10) @@ -163,24 +174,31 @@ async def create_campaign( raise HTTPException(status_code=404, detail="Workflow not found") # Validate source data (phone_number column and format) - if request.source_type == "csv": - validation_result = await validate_csv_source(request.source_id) - if not validation_result.is_valid: - raise HTTPException(status_code=400, detail=validation_result.error.message) - elif request.source_type == "google-sheet": - validation_result = await validate_google_sheet_source( - request.source_id, user.selected_organization_id - ) - if not validation_result.is_valid: - raise HTTPException(status_code=400, detail=validation_result.error.message) + sync_service = get_sync_service(request.source_type) + validation_result = await sync_service.validate_source( + request.source_id, user.selected_organization_id + ) + if not validation_result.is_valid: + raise HTTPException(status_code=400, detail=validation_result.error.message) - # Validate max_concurrency against org limit if provided + # Validate max_concurrency against effective limit (min of org limit and from_numbers count) if request.max_concurrency is not None: org_limit = await _get_org_concurrent_limit(user.selected_organization_id) - if request.max_concurrency > org_limit: + from_numbers_count = await _get_from_numbers_count( + user.selected_organization_id + ) + effective_limit = ( + min(org_limit, from_numbers_count) if from_numbers_count > 0 else org_limit + ) + if request.max_concurrency > effective_limit: + if from_numbers_count > 0 and from_numbers_count < org_limit: + raise HTTPException( + status_code=400, + detail=f"max_concurrency ({request.max_concurrency}) cannot exceed {effective_limit}. You have {from_numbers_count} phone number(s) configured. Add more CLIs in telephony configuration to increase concurrency.", + ) raise HTTPException( status_code=400, - detail=f"max_concurrency ({request.max_concurrency}) cannot exceed organization limit ({org_limit})", + detail=f"max_concurrency ({request.max_concurrency}) cannot exceed organization limit ({effective_limit})", ) # Build retry_config dict if provided diff --git a/api/routes/organization.py b/api/routes/organization.py index f14e5e2..71ab78c 100644 --- a/api/routes/organization.py +++ b/api/routes/organization.py @@ -225,6 +225,7 @@ class RetryConfigResponse(BaseModel): class CampaignLimitsResponse(BaseModel): concurrent_call_limit: int + from_numbers_count: int default_retry_config: RetryConfigResponse @@ -251,7 +252,21 @@ async def get_campaign_limits(user: UserModel = Depends(get_user)): except Exception: pass + # Get from_numbers count from telephony configuration + from_numbers_count = 0 + try: + telephony_config = await db_client.get_configuration( + user.selected_organization_id, + OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value, + ) + if telephony_config and telephony_config.value: + from_numbers = telephony_config.value.get("from_numbers", []) + from_numbers_count = len(from_numbers) + except Exception: + pass + return CampaignLimitsResponse( concurrent_call_limit=concurrent_limit, + from_numbers_count=from_numbers_count, default_retry_config=RetryConfigResponse(**DEFAULT_CAMPAIGN_RETRY_CONFIG), ) diff --git a/api/services/campaign/campaign_call_dispatcher.py b/api/services/campaign/campaign_call_dispatcher.py index fea1045..121ef39 100644 --- a/api/services/campaign/campaign_call_dispatcher.py +++ b/api/services/campaign/campaign_call_dispatcher.py @@ -9,7 +9,10 @@ from api.constants import DEFAULT_ORG_CONCURRENCY_LIMIT from api.db import db_client from api.db.models import QueuedRunModel, WorkflowRunModel from api.enums import OrganizationConfigurationKey, WorkflowRunState -from api.services.campaign.errors import ConcurrentSlotAcquisitionError +from api.services.campaign.errors import ( + ConcurrentSlotAcquisitionError, + PhoneNumberPoolExhaustedError, +) from api.services.campaign.rate_limiter import rate_limiter from api.services.telephony.base import TelephonyProvider from api.services.telephony.factory import get_telephony_provider @@ -71,6 +74,16 @@ class CampaignCallDispatcher: logger.info(f"No more queued runs for campaign {campaign_id}") return 0 + # Initialize from_number pool for this org's provider + try: + provider = await self.get_telephony_provider(campaign.organization_id) + if provider.from_numbers: + await rate_limiter.initialize_from_number_pool( + campaign.organization_id, provider.from_numbers + ) + except Exception as e: + logger.warning(f"Failed to initialize from_number pool: {e}") + processed_count = 0 for i, queued_run in enumerate(queued_runs): try: @@ -103,7 +116,7 @@ class CampaignCallDispatcher: campaign_id=campaign_id, processed_rows=campaign.processed_rows + 1 ) - except ConcurrentSlotAcquisitionError: + except (ConcurrentSlotAcquisitionError, PhoneNumberPoolExhaustedError): # Revert all unprocessed runs (current and remaining) back to queued # so they can be picked up again when campaign is resumed for unprocessed_run in queued_runs[i:]: @@ -146,6 +159,8 @@ class CampaignCallDispatcher: self, queued_run: QueuedRunModel, campaign: any, slot_id: str ) -> Optional[WorkflowRunModel]: """Creates workflow run and initiates call. Requires a pre-acquired slot_id.""" + from_number = None + # Get workflow details workflow = await db_client.get_workflow_by_id(campaign.workflow_id) if not workflow: @@ -168,6 +183,17 @@ class CampaignCallDispatcher: provider = await self.get_telephony_provider(campaign.organization_id) workflow_run_mode = provider.PROVIDER_NAME + # Acquire a unique from_number from the pool + from_number = await self.acquire_from_number(campaign.organization_id) + if from_number is None: + # Release concurrent slot before raising + await rate_limiter.release_concurrent_slot( + campaign.organization_id, slot_id + ) + raise PhoneNumberPoolExhaustedError( + organization_id=campaign.organization_id + ) + logger.info(f"Provider name: {provider.PROVIDER_NAME}") logger.info(f"Queued run context: {queued_run.context_variables}") @@ -177,6 +203,7 @@ class CampaignCallDispatcher: **queued_run.context_variables, "campaign_id": campaign.id, "provider": provider.PROVIDER_NAME, + "source_uuid": queued_run.source_uuid, } logger.info(f"Final initial_context: {initial_context}") @@ -198,11 +225,20 @@ class CampaignCallDispatcher: await rate_limiter.store_workflow_slot_mapping( workflow_run.id, campaign.organization_id, slot_id ) + + # Store from_number mapping for cleanup on call completion + await rate_limiter.store_workflow_from_number_mapping( + workflow_run.id, campaign.organization_id, from_number + ) except Exception as e: - # Release slot on error + # Release slot and from_number on error await rate_limiter.release_concurrent_slot( campaign.organization_id, slot_id ) + if from_number: + await rate_limiter.release_from_number( + campaign.organization_id, from_number + ) raise # Add "retry" tag if this is a retry call @@ -233,6 +269,7 @@ class CampaignCallDispatcher: to_number=phone_number, webhook_url=webhook_url, workflow_run_id=workflow_run.id, + from_number=from_number, workflow_id=campaign.workflow_id, user_id=campaign.created_by, ) @@ -285,6 +322,15 @@ class CampaignCallDispatcher: await rate_limiter.release_concurrent_slot(org_id, slot_id) await rate_limiter.delete_workflow_slot_mapping(workflow_run.id) + # Release from_number on failure + from_number_mapping = await rate_limiter.get_workflow_from_number_mapping( + workflow_run.id + ) + if from_number_mapping: + fn_org_id, fn_number = from_number_mapping + await rate_limiter.release_from_number(fn_org_id, fn_number) + await rate_limiter.delete_workflow_from_number_mapping(workflow_run.id) + raise return workflow_run @@ -380,11 +426,42 @@ class CampaignCallDispatcher: # Wait before retrying await asyncio.sleep(1) + async def acquire_from_number( + self, organization_id: int, timeout: float = 60 + ) -> Optional[str]: + """ + Acquire a from_number from the pool with retry. + Waits up to timeout seconds, polling every 1s. + + Returns the phone number or None if timeout is exceeded. + """ + wait_start = time.time() + + while True: + from_number = await rate_limiter.acquire_from_number(organization_id) + if from_number: + return from_number + + wait_time = time.time() - wait_start + if wait_time > timeout: + logger.warning( + f"From number pool exhausted for org {organization_id} " + f"after waiting {wait_time:.1f}s" + ) + return None + + logger.debug( + f"All from_numbers in use for org {organization_id}, " + f"waited {wait_time:.1f}s, retrying..." + ) + await asyncio.sleep(1) + async def release_call_slot(self, workflow_run_id: int) -> bool: """ - Release concurrent slot when a call completes. + Release concurrent slot and from_number when a call completes. Called by Twilio webhooks or workflow completion handlers. """ + slot_released = False mapping = await rate_limiter.get_workflow_slot_mapping(workflow_run_id) if mapping: org_id, slot_id = mapping @@ -394,8 +471,22 @@ class CampaignCallDispatcher: logger.info( f"Released concurrent slot for workflow run {workflow_run_id}" ) - return success - return False + slot_released = True + + # Release from_number back to pool + from_number_mapping = await rate_limiter.get_workflow_from_number_mapping( + workflow_run_id + ) + if from_number_mapping: + fn_org_id, fn_number = from_number_mapping + fn_success = await rate_limiter.release_from_number(fn_org_id, fn_number) + if fn_success: + await rate_limiter.delete_workflow_from_number_mapping(workflow_run_id) + logger.info( + f"Released from_number {fn_number} for workflow run {workflow_run_id}" + ) + + return slot_released # Global instance diff --git a/api/services/campaign/errors.py b/api/services/campaign/errors.py index b8bd990..8d9f455 100644 --- a/api/services/campaign/errors.py +++ b/api/services/campaign/errors.py @@ -14,3 +14,14 @@ class ConcurrentSlotAcquisitionError(Exception): f"Failed to acquire concurrent slot for org {organization_id}, " f"campaign {campaign_id} after waiting {wait_time:.1f}s" ) + + +class PhoneNumberPoolExhaustedError(Exception): + """Raised when no phone numbers are available in the pool for outbound calls.""" + + def __init__(self, organization_id: int): + self.organization_id = organization_id + super().__init__( + f"All phone numbers are in use for org {organization_id}. " + f"No available from_number in pool." + ) diff --git a/api/services/campaign/rate_limiter.py b/api/services/campaign/rate_limiter.py index a69be17..c6fb54b 100644 --- a/api/services/campaign/rate_limiter.py +++ b/api/services/campaign/rate_limiter.py @@ -245,6 +245,166 @@ class RateLimiter: logger.error(f"Error deleting workflow slot mapping: {e}") return False + # ======== FROM NUMBER POOL METHODS ======== + + async def initialize_from_number_pool( + self, organization_id: int, from_numbers: list[str] + ) -> bool: + """ + Initialize the from_number pool for an organization. + Uses ZADD NX so it won't overwrite numbers that are already in use. + + Args: + organization_id: The organization ID + from_numbers: List of phone numbers to add to the pool + """ + if not from_numbers: + return False + + redis_client = await self._get_redis() + key = f"from_number_pool:{organization_id}" + + try: + # ZADD NX: only add members that don't already exist (preserves in-use scores) + members = {number: 0 for number in from_numbers} + await redis_client.zadd(key, members, nx=True) + await redis_client.expire(key, 3600) # 1 hour TTL + return True + except Exception as e: + logger.error(f"Error initializing from_number pool: {e}") + return False + + async def acquire_from_number(self, organization_id: int) -> Optional[str]: + """ + Atomically acquire an available from_number from the pool. + Cleans stale entries (score > 0 and older than 30 min) before acquiring. + + Returns the phone number if available, None if all numbers are in use. + """ + redis_client = await self._get_redis() + key = f"from_number_pool:{organization_id}" + now = time.time() + stale_cutoff = now - self.stale_call_timeout + + lua_script = """ + local key = KEYS[1] + local now = tonumber(ARGV[1]) + local stale_cutoff = tonumber(ARGV[2]) + + -- Clean stale entries: members with score > 0 and score < stale_cutoff + local stale = redis.call('ZRANGEBYSCORE', key, 1, stale_cutoff) + for i, member in ipairs(stale) do + redis.call('ZADD', key, 0, member) + end + + -- Find an available number (score == 0) + local available = redis.call('ZRANGEBYSCORE', key, 0, 0, 'LIMIT', 0, 1) + if #available == 0 then + return nil + end + + -- Mark as in-use with current timestamp + redis.call('ZADD', key, now, available[1]) + return available[1] + """ + + try: + result = await redis_client.eval(lua_script, 1, key, now, stale_cutoff) + if result: + logger.debug(f"Acquired from_number {result} for org {organization_id}") + return result + except Exception as e: + logger.error(f"Error acquiring from_number: {e}") + return None + + async def release_from_number(self, organization_id: int, from_number: str) -> bool: + """ + Release a from_number back to the pool by setting its score to 0. + Harmless if already released (score already 0). + """ + if not from_number: + return False + + redis_client = await self._get_redis() + key = f"from_number_pool:{organization_id}" + + lua_script = """ + local key = KEYS[1] + local from_number = ARGV[1] + + local score = redis.call('ZSCORE', key, from_number) + if score then + redis.call('ZADD', key, 0, from_number) + return 1 + end + return 0 + """ + + try: + result = await redis_client.eval(lua_script, 1, key, from_number) + if result: + logger.debug( + f"Released from_number {from_number} for org {organization_id}" + ) + return bool(result) + except Exception as e: + logger.error(f"Error releasing from_number: {e}") + return False + + async def store_workflow_from_number_mapping( + self, workflow_run_id: int, organization_id: int, from_number: str + ) -> bool: + """ + Store the mapping between workflow_run_id and its from_number. + Used for cleanup when calls complete. + """ + redis_client = await self._get_redis() + mapping_key = f"workflow_from_number:{workflow_run_id}" + + try: + await redis_client.hset( + mapping_key, + mapping={"org_id": organization_id, "from_number": from_number}, + ) + await redis_client.expire(mapping_key, 1800) # 30 min TTL + return True + except Exception as e: + logger.error(f"Error storing workflow from_number mapping: {e}") + return False + + async def get_workflow_from_number_mapping( + self, workflow_run_id: int + ) -> Optional[tuple[int, str]]: + """ + Get the from_number mapping for a workflow run. + Returns (organization_id, from_number) tuple or None if not found. + """ + redis_client = await self._get_redis() + mapping_key = f"workflow_from_number:{workflow_run_id}" + + try: + mapping = await redis_client.hgetall(mapping_key) + if mapping and "org_id" in mapping and "from_number" in mapping: + return (int(mapping["org_id"]), mapping["from_number"]) + return None + except Exception as e: + logger.error(f"Error getting workflow from_number mapping: {e}") + return None + + async def delete_workflow_from_number_mapping(self, workflow_run_id: int) -> bool: + """ + Delete the workflow from_number mapping after releasing the number. + """ + redis_client = await self._get_redis() + mapping_key = f"workflow_from_number:{workflow_run_id}" + + try: + deleted = await redis_client.delete(mapping_key) + return bool(deleted) + except Exception as e: + logger.error(f"Error deleting workflow from_number mapping: {e}") + return False + async def close(self): """Close Redis connection""" if self.redis_client: diff --git a/api/services/campaign/source_sync.py b/api/services/campaign/source_sync.py index 05a4130..cbdfdfc 100644 --- a/api/services/campaign/source_sync.py +++ b/api/services/campaign/source_sync.py @@ -1,12 +1,127 @@ from abc import ABC, abstractmethod -from typing import Any, Dict +from dataclasses import dataclass +from typing import Any, Dict, List, Optional from loguru import logger +@dataclass +class ValidationError: + """Represents a validation error with details.""" + + message: str + invalid_rows: Optional[List[int]] = None + + +@dataclass +class ValidationResult: + """Result of source validation.""" + + is_valid: bool + error: Optional[ValidationError] = None + + class CampaignSourceSyncService(ABC): """Base class for campaign data source synchronization""" + @staticmethod + def normalize_headers(headers: List[str]) -> List[str]: + """Normalize headers by stripping whitespace and lowercasing.""" + return [h.strip().lower() for h in headers] + + @staticmethod + def validate_source_data( + headers: List[str], rows: List[List[str]] + ) -> ValidationResult: + """ + Validate source data for campaign creation. + + Args: + headers: List of column headers + rows: List of data rows (excluding header) + + Returns: + ValidationResult with is_valid=True if valid, or error details if invalid + """ + normalized_headers = CampaignSourceSyncService.normalize_headers(headers) + + # Check for phone_number column + if "phone_number" not in normalized_headers: + return ValidationResult( + is_valid=False, + error=ValidationError( + message="Source must contain a 'phone_number' column" + ), + ) + + phone_number_idx = normalized_headers.index("phone_number") + + # Validate phone numbers in all data rows + invalid_rows = [] + for row_idx, row in enumerate( + rows, start=2 + ): # Start at 2 (1-indexed, skip header) + if len(row) <= phone_number_idx: + continue # Skip rows that don't have enough columns + + phone_number = row[phone_number_idx].strip() + if phone_number and not phone_number.startswith("+"): + invalid_rows.append(row_idx) + + if invalid_rows: + # Limit the number of rows shown in error message + if len(invalid_rows) > 5: + rows_str = f"{', '.join(map(str, invalid_rows[:5]))} and {len(invalid_rows) - 5} more" + else: + rows_str = ", ".join(map(str, invalid_rows)) + + return ValidationResult( + is_valid=False, + error=ValidationError( + message=f"Invalid phone numbers in rows: {rows_str}. All phone numbers must include country code (start with '+')", + invalid_rows=invalid_rows, + ), + ) + + # Check for duplicate phone numbers + seen_phones: dict[str, int] = {} # phone -> first row where it appeared + duplicate_rows = [] + for row_idx, row in enumerate(rows, start=2): + if len(row) <= phone_number_idx: + continue + + phone_number = row[phone_number_idx].strip() + if not phone_number: + continue + + if phone_number in seen_phones: + duplicate_rows.append(row_idx) + else: + seen_phones[phone_number] = row_idx + + if duplicate_rows: + if len(duplicate_rows) > 5: + rows_str = f"{', '.join(map(str, duplicate_rows[:5]))} and {len(duplicate_rows) - 5} more" + else: + rows_str = ", ".join(map(str, duplicate_rows)) + + return ValidationResult( + is_valid=False, + error=ValidationError( + message=f"Duplicate phone numbers found in rows: {rows_str}. Phone numbers in a campaign must be unique.", + invalid_rows=duplicate_rows, + ), + ) + + return ValidationResult(is_valid=True) + + @abstractmethod + async def validate_source( + self, source_id: str, organization_id: Optional[int] = None + ) -> ValidationResult: + """Validate source data before campaign creation.""" + pass + @abstractmethod async def sync_source_data(self, campaign_id: int) -> int: """ @@ -16,11 +131,6 @@ class CampaignSourceSyncService(ABC): """ pass - @abstractmethod - async def validate_source_schema(self, source_config: Dict[str, Any]) -> bool: - """Validates required fields exist in source""" - pass - async def get_source_credentials( self, organization_id: int, source_type: str ) -> Dict[str, Any]: diff --git a/api/services/campaign/source_validator.py b/api/services/campaign/source_validator.py deleted file mode 100644 index e031e56..0000000 --- a/api/services/campaign/source_validator.py +++ /dev/null @@ -1,295 +0,0 @@ -""" -Source validation for campaign data sources (CSV, Google Sheets). - -Validates that: -- phone_number column exists -- All phone numbers include country code (start with '+') -""" - -import csv -from dataclasses import dataclass -from io import StringIO -from typing import List, Optional - -import httpx -from loguru import logger - -from api.services.storage import storage_fs - - -@dataclass -class ValidationError: - """Represents a validation error with details.""" - - message: str - invalid_rows: Optional[List[int]] = None - - -@dataclass -class ValidationResult: - """Result of source validation.""" - - is_valid: bool - error: Optional[ValidationError] = None - - -def _validate_source_data( - headers: List[str], rows: List[List[str]] -) -> ValidationResult: - """ - Validate source data for campaign creation. - - Args: - headers: List of column headers - rows: List of data rows (excluding header) - - Returns: - ValidationResult with is_valid=True if valid, or error details if invalid - """ - # Normalize headers to lowercase for comparison - normalized_headers = [h.strip().lower() for h in headers] - - # Check for phone_number column - if "phone_number" not in normalized_headers: - return ValidationResult( - is_valid=False, - error=ValidationError( - message="Source must contain a 'phone_number' column" - ), - ) - - phone_number_idx = normalized_headers.index("phone_number") - - # Validate phone numbers in all data rows - invalid_rows = [] - for row_idx, row in enumerate(rows, start=2): # Start at 2 (1-indexed, skip header) - if len(row) <= phone_number_idx: - continue # Skip rows that don't have enough columns - - phone_number = row[phone_number_idx].strip() - if phone_number and not phone_number.startswith("+"): - invalid_rows.append(row_idx) - - if invalid_rows: - # Limit the number of rows shown in error message - if len(invalid_rows) > 5: - rows_str = f"{', '.join(map(str, invalid_rows[:5]))} and {len(invalid_rows) - 5} more" - else: - rows_str = ", ".join(map(str, invalid_rows)) - - return ValidationResult( - is_valid=False, - error=ValidationError( - message=f"Invalid phone numbers in rows: {rows_str}. All phone numbers must include country code (start with '+')", - invalid_rows=invalid_rows, - ), - ) - - # Check for duplicate phone numbers - seen_phones: dict[str, int] = {} # phone -> first row where it appeared - duplicate_rows = [] - for row_idx, row in enumerate(rows, start=2): - if len(row) <= phone_number_idx: - continue - - phone_number = row[phone_number_idx].strip() - if not phone_number: - continue - - if phone_number in seen_phones: - duplicate_rows.append(row_idx) - else: - seen_phones[phone_number] = row_idx - - if duplicate_rows: - if len(duplicate_rows) > 5: - rows_str = f"{', '.join(map(str, duplicate_rows[:5]))} and {len(duplicate_rows) - 5} more" - else: - rows_str = ", ".join(map(str, duplicate_rows)) - - return ValidationResult( - is_valid=False, - error=ValidationError( - message=f"Duplicate phone numbers found in rows: {rows_str}. Phone numbers in a campaign must be unique.", - invalid_rows=duplicate_rows, - ), - ) - - return ValidationResult(is_valid=True) - - -async def validate_csv_source(file_key: str) -> ValidationResult: - """ - Validate a CSV source file for campaign creation. - - Args: - file_key: S3/MinIO file key for the CSV file - - Returns: - ValidationResult with is_valid=True if valid, or error details if invalid - """ - # Get download URL using internal endpoint - signed_url = await storage_fs.aget_signed_url( - file_key, expiration=3600, use_internal_endpoint=True - ) - - if not signed_url: - return ValidationResult( - is_valid=False, - error=ValidationError(message=f"Failed to access CSV file: {file_key}"), - ) - - # Download CSV file - async with httpx.AsyncClient() as client: - try: - response = await client.get(signed_url) - response.raise_for_status() - csv_content = response.text - except httpx.HTTPError as e: - logger.error(f"Failed to download CSV file for validation: {e}") - return ValidationResult( - is_valid=False, - error=ValidationError( - message="Failed to download CSV file for validation" - ), - ) - - # Parse CSV - try: - csv_file = StringIO(csv_content) - reader = csv.reader(csv_file) - rows = list(reader) - except Exception as e: - logger.error(f"Failed to parse CSV: {e}") - return ValidationResult( - is_valid=False, - error=ValidationError(message=f"Invalid CSV format: {str(e)}"), - ) - - if not rows or len(rows) < 2: - return ValidationResult( - is_valid=False, - error=ValidationError( - message="CSV file must have a header row and at least one data row" - ), - ) - - headers = rows[0] - data_rows = rows[1:] - - return _validate_source_data(headers, data_rows) - - -async def validate_google_sheet_source( - sheet_url: str, organization_id: int -) -> ValidationResult: - """ - Validate a Google Sheet source for campaign creation. - - Args: - sheet_url: Google Sheets URL - organization_id: Organization ID to get integration credentials - - Returns: - ValidationResult with is_valid=True if valid, or error details if invalid - """ - import re - - from api.db import db_client - from api.services.integrations.nango import NangoService - - # Extract sheet ID from URL - pattern = r"/spreadsheets/d/([a-zA-Z0-9-_]+)" - match = re.search(pattern, sheet_url) - if not match: - return ValidationResult( - is_valid=False, - error=ValidationError(message=f"Invalid Google Sheets URL: {sheet_url}"), - ) - - sheet_id = match.group(1) - - # Get Google Sheets integration for the organization - integrations = await db_client.get_integrations_by_organization_id(organization_id) - integration = None - for intg in integrations: - if intg.provider == "google-sheet" and intg.is_active: - integration = intg - break - - if not integration: - return ValidationResult( - is_valid=False, - error=ValidationError( - message="Google Sheets integration not found or inactive" - ), - ) - - # Get OAuth token via Nango - try: - nango_service = NangoService() - token_data = await nango_service.get_access_token( - connection_id=integration.integration_id, provider_config_key="google-sheet" - ) - access_token = token_data["credentials"]["access_token"] - except Exception as e: - logger.error(f"Failed to get Google Sheets access token: {e}") - return ValidationResult( - is_valid=False, - error=ValidationError(message="Failed to authenticate with Google Sheets"), - ) - - # Fetch sheet data - sheets_api_base = "https://sheets.googleapis.com/v4/spreadsheets" - - async with httpx.AsyncClient() as client: - try: - # Get sheet metadata to find the first sheet name - metadata_url = f"{sheets_api_base}/{sheet_id}" - headers = {"Authorization": f"Bearer {access_token}"} - response = await client.get(metadata_url, headers=headers) - response.raise_for_status() - metadata = response.json() - - if not metadata.get("sheets"): - return ValidationResult( - is_valid=False, - error=ValidationError(message="No sheets found in the spreadsheet"), - ) - - sheet_name = metadata["sheets"][0]["properties"]["title"] - - # Fetch all data from sheet - data_url = f"{sheets_api_base}/{sheet_id}/values/{sheet_name}!A:Z" - response = await client.get(data_url, headers=headers) - response.raise_for_status() - data = response.json() - rows = data.get("values", []) - - except httpx.HTTPStatusError as e: - logger.error(f"HTTP error fetching Google Sheet: {e.response.status_code}") - return ValidationResult( - is_valid=False, - error=ValidationError( - message=f"Failed to fetch Google Sheet data: {e.response.status_code}" - ), - ) - except Exception as e: - logger.error(f"Error fetching Google Sheet: {e}") - return ValidationResult( - is_valid=False, - error=ValidationError(message="Failed to fetch Google Sheet data"), - ) - - if not rows or len(rows) < 2: - return ValidationResult( - is_valid=False, - error=ValidationError( - message="Google Sheet must have a header row and at least one data row" - ), - ) - - headers = rows[0] - data_rows = rows[1:] - - return _validate_source_data(headers, data_rows) diff --git a/api/services/campaign/sources/csv.py b/api/services/campaign/sources/csv.py index d65dfa5..8d74e19 100644 --- a/api/services/campaign/sources/csv.py +++ b/api/services/campaign/sources/csv.py @@ -1,19 +1,68 @@ import csv import hashlib from io import StringIO -from typing import Any, Dict, List +from typing import List, Optional import httpx from loguru import logger from api.db import db_client -from api.services.campaign.source_sync import CampaignSourceSyncService +from api.services.campaign.source_sync import ( + CampaignSourceSyncService, + ValidationError, + ValidationResult, +) from api.services.storage import storage_fs class CSVSyncService(CampaignSourceSyncService): """Implementation for CSV file synchronization""" + async def _fetch_csv_data(self, file_key: str) -> List[List[str]]: + """Download and parse CSV file from storage. Returns all rows including header.""" + signed_url = await storage_fs.aget_signed_url( + file_key, expiration=3600, use_internal_endpoint=True + ) + + if not signed_url: + raise ValueError(f"Failed to access CSV file: {file_key}") + + async with httpx.AsyncClient() as client: + try: + response = await client.get(signed_url) + response.raise_for_status() + csv_content = response.text + except httpx.HTTPError as e: + logger.error(f"Failed to download CSV file: {e} for url: {signed_url}") + raise ValueError(f"Failed to download CSV file from storage: {str(e)}") + + return self._parse_csv(csv_content) + + async def validate_source( + self, source_id: str, organization_id: Optional[int] = None + ) -> ValidationResult: + """Validate a CSV source file for campaign creation.""" + try: + csv_data = await self._fetch_csv_data(source_id) + except ValueError as e: + return ValidationResult( + is_valid=False, + error=ValidationError(message=str(e)), + ) + + if not csv_data or len(csv_data) < 2: + return ValidationResult( + is_valid=False, + error=ValidationError( + message="CSV file must have a header row and at least one data row" + ), + ) + + headers = csv_data[0] + data_rows = csv_data[1:] + + return self.validate_source_data(headers, data_rows) + async def sync_source_data(self, campaign_id: int) -> int: """ Fetches data from CSV file in S3/MinIO and creates queued_runs @@ -23,39 +72,20 @@ class CSVSyncService(CampaignSourceSyncService): if not campaign: raise ValueError(f"Campaign {campaign_id} not found") - # 1. Get download URL using internal endpoint (for container-to-container access) file_key = campaign.source_id - signed_url = await storage_fs.aget_signed_url( - file_key, expiration=3600, use_internal_endpoint=True - ) - - if not signed_url: - raise ValueError(f"Failed to generate download URL for file: {file_key}") - - # 2. Download CSV file - async with httpx.AsyncClient() as client: - try: - response = await client.get(signed_url) - response.raise_for_status() - csv_content = response.text - except httpx.HTTPError as e: - logger.error(f"Failed to download CSV file: {e} for url: {signed_url}") - raise ValueError(f"Failed to download CSV file from storage: {str(e)}") - - # 3. Parse CSV - csv_data = self._parse_csv(csv_content) + csv_data = await self._fetch_csv_data(file_key) if not csv_data or len(csv_data) < 2: logger.warning(f"No data found in CSV for campaign {campaign_id}") return 0 - headers = csv_data[0] # First row is headers - rows = csv_data[1:] # Rest is data + headers = self.normalize_headers(csv_data[0]) + rows = csv_data[1:] - # 4. Create hash of file_key for consistent source_uuid prefix + # Create hash of file_key for consistent source_uuid prefix file_hash = hashlib.md5(file_key.encode()).hexdigest()[:8] - # 5. Convert to queued_runs + # Convert to queued_runs queued_runs = [] for idx, row_values in enumerate(rows, 1): # Pad row to match headers length @@ -81,14 +111,14 @@ class CSVSyncService(CampaignSourceSyncService): } ) - # 6. Bulk insert + # Bulk insert if queued_runs: await db_client.bulk_create_queued_runs(queued_runs) logger.info( f"Created {len(queued_runs)} queued runs for campaign {campaign_id}" ) - # 7. Update campaign total_rows + # Update campaign total_rows await db_client.update_campaign( campaign_id=campaign_id, total_rows=len(queued_runs), @@ -106,35 +136,3 @@ class CSVSyncService(CampaignSourceSyncService): except Exception as e: logger.error(f"Failed to parse CSV: {e}") raise ValueError(f"Invalid CSV format: {str(e)}") - - async def validate_source_schema(self, source_config: Dict[str, Any]) -> bool: - """Validate that required columns exist in CSV""" - required_columns = ["phone_number", "first_name", "last_name"] - - file_key = source_config.get("source_id") - if not file_key: - return False - - # Get download URL using internal endpoint - signed_url = await storage_fs.aget_signed_url( - file_key, expiration=3600, use_internal_endpoint=True - ) - if not signed_url: - return False - - # Download just enough to get headers - async with httpx.AsyncClient() as client: - try: - response = await client.get(signed_url) - response.raise_for_status() - - # Get just the first line for headers - first_line = response.text.split("\n")[0] - csv_file = StringIO(first_line) - reader = csv.reader(csv_file) - headers = next(reader, []) - - return all(col in headers for col in required_columns) - except Exception as e: - logger.error(f"Failed to validate CSV schema: {e}") - return False diff --git a/api/services/campaign/sources/google_sheets.py b/api/services/campaign/sources/google_sheets.py index 5302305..ea473f5 100644 --- a/api/services/campaign/sources/google_sheets.py +++ b/api/services/campaign/sources/google_sheets.py @@ -1,11 +1,15 @@ import re -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional import httpx from loguru import logger from api.db import db_client -from api.services.campaign.source_sync import CampaignSourceSyncService +from api.services.campaign.source_sync import ( + CampaignSourceSyncService, + ValidationError, + ValidationResult, +) from api.services.integrations.nango import NangoService @@ -16,18 +20,10 @@ class GoogleSheetsSyncService(CampaignSourceSyncService): self.nango_service = NangoService() self.sheets_api_base = "https://sheets.googleapis.com/v4/spreadsheets" - async def sync_source_data(self, campaign_id: int) -> int: - """ - Fetches data from Google Sheets and creates queued_runs - """ - # Get campaign - campaign = await db_client.get_campaign_by_id(campaign_id) - if not campaign: - raise ValueError(f"Campaign {campaign_id} not found") - - # 1. Get Google Sheets integration for the organization + async def _get_access_token(self, organization_id: int) -> str: + """Get OAuth access token for Google Sheets via Nango.""" integrations = await db_client.get_integrations_by_organization_id( - campaign.organization_id + organization_id ) integration = None for intg in integrations: @@ -38,39 +34,107 @@ class GoogleSheetsSyncService(CampaignSourceSyncService): if not integration: raise ValueError("Google Sheets integration not found or inactive") - # 2. Get OAuth token via Nango using the integration_id (which is the Nango connection ID) token_data = await self.nango_service.get_access_token( connection_id=integration.integration_id, provider_config_key="google-sheet" ) - access_token = token_data["credentials"]["access_token"] + return token_data["credentials"]["access_token"] - # 3. Extract sheet ID from URL - sheet_id = self._extract_sheet_id(campaign.source_id) + async def _fetch_all_sheet_data( + self, sheet_url: str, organization_id: int + ) -> List[List[str]]: + """Fetch all data from a Google Sheet. Returns all rows including header.""" + access_token = await self._get_access_token(organization_id) + sheet_id = self._extract_sheet_id(sheet_url) - # 4. Get sheet metadata (to find data range) metadata = await self._get_sheet_metadata(sheet_id, access_token) if not metadata.get("sheets"): raise ValueError("No sheets found in the spreadsheet") sheet_name = metadata["sheets"][0]["properties"]["title"] - # 5. Fetch all data from sheet - sheet_data = await self._fetch_sheet_data( - sheet_id, - f"{sheet_name}!A:Z", # Get all columns A-Z - access_token, + return await self._fetch_sheet_data(sheet_id, f"{sheet_name}!A:Z", access_token) + + async def validate_source( + self, source_id: str, organization_id: Optional[int] = None + ) -> ValidationResult: + """Validate a Google Sheet source for campaign creation.""" + if organization_id is None: + return ValidationResult( + is_valid=False, + error=ValidationError( + message="Organization ID is required for Google Sheets validation" + ), + ) + + # Validate URL format first + pattern = r"/spreadsheets/d/([a-zA-Z0-9-_]+)" + if not re.search(pattern, source_id): + return ValidationResult( + is_valid=False, + error=ValidationError( + message=f"Invalid Google Sheets URL: {source_id}" + ), + ) + + try: + rows = await self._fetch_all_sheet_data(source_id, organization_id) + except ValueError as e: + return ValidationResult( + is_valid=False, + error=ValidationError(message=str(e)), + ) + except httpx.HTTPStatusError as e: + logger.error(f"HTTP error fetching Google Sheet: {e.response.status_code}") + return ValidationResult( + is_valid=False, + error=ValidationError( + message=f"Failed to fetch Google Sheet data: {e.response.status_code}" + ), + ) + except Exception as e: + logger.error(f"Error fetching Google Sheet: {e}") + return ValidationResult( + is_valid=False, + error=ValidationError(message="Failed to fetch Google Sheet data"), + ) + + if not rows or len(rows) < 2: + return ValidationResult( + is_valid=False, + error=ValidationError( + message="Google Sheet must have a header row and at least one data row" + ), + ) + + headers = rows[0] + data_rows = rows[1:] + + return self.validate_source_data(headers, data_rows) + + async def sync_source_data(self, campaign_id: int) -> int: + """ + Fetches data from Google Sheets and creates queued_runs + """ + # Get campaign + campaign = await db_client.get_campaign_by_id(campaign_id) + if not campaign: + raise ValueError(f"Campaign {campaign_id} not found") + + rows = await self._fetch_all_sheet_data( + campaign.source_id, campaign.organization_id ) - # 6. Convert to queued_runs - if not sheet_data or len(sheet_data) < 2: + if not rows or len(rows) < 2: logger.warning(f"No data found in sheet for campaign {campaign_id}") return 0 - headers = sheet_data[0] # First row is headers - rows = sheet_data[1:] # Rest is data + headers = self.normalize_headers(rows[0]) + data_rows = rows[1:] + + sheet_id = self._extract_sheet_id(campaign.source_id) queued_runs = [] - for idx, row_values in enumerate(rows, 1): + for idx, row_values in enumerate(data_rows, 1): # Pad row to match headers length padded_row = row_values + [""] * (len(headers) - len(row_values)) @@ -94,14 +158,14 @@ class GoogleSheetsSyncService(CampaignSourceSyncService): } ) - # 7. Bulk insert + # Bulk insert if queued_runs: await db_client.bulk_create_queued_runs(queued_runs) logger.info( f"Created {len(queued_runs)} queued runs for campaign {campaign_id}" ) - # 8. Update campaign total_rows + # Update campaign total_rows await db_client.update_campaign( campaign_id=campaign_id, total_rows=len(queued_runs), @@ -158,23 +222,3 @@ class GoogleSheetsSyncService(CampaignSourceSyncService): if match: return match.group(1) raise ValueError(f"Invalid Google Sheets URL: {sheet_url}") - - async def validate_source_schema(self, source_config: Dict[str, Any]) -> bool: - """Validate that required columns exist""" - required_columns = ["phone_number", "first_name", "last_name"] - - # Fetch just the header row - sheet_id = self._extract_sheet_id(source_config["source_id"]) - access_token = source_config["access_token"] - - headers = await self._fetch_sheet_data( - sheet_id, - "A1:Z1", # Just first row - access_token, - ) - - if not headers: - return False - - header_row = headers[0] - return all(col in header_row for col in required_columns) diff --git a/api/services/telephony/base.py b/api/services/telephony/base.py index 72d33d1..ee1d05f 100644 --- a/api/services/telephony/base.py +++ b/api/services/telephony/base.py @@ -57,6 +57,7 @@ class TelephonyProvider(ABC): to_number: str, webhook_url: str, workflow_run_id: Optional[int] = None, + from_number: Optional[str] = None, **kwargs: Any, ) -> CallInitiationResult: """ @@ -66,6 +67,7 @@ class TelephonyProvider(ABC): to_number: The destination phone number webhook_url: The URL to receive call events workflow_run_id: Optional workflow run ID for tracking + from_number: Optional caller ID to use. If None, provider selects randomly. **kwargs: Provider-specific additional parameters Returns: diff --git a/api/services/telephony/providers/cloudonix_provider.py b/api/services/telephony/providers/cloudonix_provider.py index 2b917fb..8449499 100644 --- a/api/services/telephony/providers/cloudonix_provider.py +++ b/api/services/telephony/providers/cloudonix_provider.py @@ -63,6 +63,7 @@ class CloudonixProvider(TelephonyProvider): to_number: str, webhook_url: str, workflow_run_id: Optional[int] = None, + from_number: Optional[str] = None, **kwargs: Any, ) -> CallInitiationResult: """ @@ -76,15 +77,15 @@ class CloudonixProvider(TelephonyProvider): endpoint = f"{self.base_url}/calls/{self.domain_id}/application" - # Select a random phone number for caller-id (REQUIRED by Cloudonix) - if not self.from_numbers: - raise ValueError( - "No phone numbers configured for Cloudonix provider. " - "At least one phone number is required as 'caller-id' for outbound calls. " - "Please configure phone numbers in the telephony settings." - ) - - from_number = random.choice(self.from_numbers) + # Use provided from_number or select a random one (REQUIRED by Cloudonix) + if from_number is None: + if not self.from_numbers: + raise ValueError( + "No phone numbers configured for Cloudonix provider. " + "At least one phone number is required as 'caller-id' for outbound calls. " + "Please configure phone numbers in the telephony settings." + ) + from_number = random.choice(self.from_numbers) logger.info( f"Selected phone number {from_number} for outbound call to {to_number}" ) diff --git a/api/services/telephony/providers/twilio_provider.py b/api/services/telephony/providers/twilio_provider.py index 9387964..713e282 100644 --- a/api/services/telephony/providers/twilio_provider.py +++ b/api/services/telephony/providers/twilio_provider.py @@ -57,6 +57,7 @@ class TwilioProvider(TelephonyProvider): to_number: str, webhook_url: str, workflow_run_id: Optional[int] = None, + from_number: Optional[str] = None, **kwargs: Any, ) -> CallInitiationResult: """ @@ -67,8 +68,9 @@ class TwilioProvider(TelephonyProvider): endpoint = f"{self.base_url}/Calls.json" - # Select a random phone number - from_number = random.choice(self.from_numbers) + # Use provided from_number or select a random one + if from_number is None: + from_number = random.choice(self.from_numbers) logger.info(f"Selected phone number {from_number} for outbound call") # Prepare call data diff --git a/api/services/telephony/providers/vobiz_provider.py b/api/services/telephony/providers/vobiz_provider.py index 5b2e5d5..ddaf9c2 100644 --- a/api/services/telephony/providers/vobiz_provider.py +++ b/api/services/telephony/providers/vobiz_provider.py @@ -56,6 +56,7 @@ class VobizProvider(TelephonyProvider): to_number: str, webhook_url: str, workflow_run_id: Optional[int] = None, + from_number: Optional[str] = None, **kwargs: Any, ) -> CallInitiationResult: """ @@ -72,8 +73,9 @@ class VobizProvider(TelephonyProvider): endpoint = f"{self.base_url}/v1/Account/{self.auth_id}/Call/" - # Select a random phone number - from_number = random.choice(self.from_numbers) + # Use provided from_number or select a random one + if from_number is None: + from_number = random.choice(self.from_numbers) logger.info(f"Selected Vobiz phone number {from_number} for outbound call") # Remove + prefix if present (Vobiz expects E.164 without +) diff --git a/api/services/telephony/providers/vonage_provider.py b/api/services/telephony/providers/vonage_provider.py index 24bc9a4..315f04d 100644 --- a/api/services/telephony/providers/vonage_provider.py +++ b/api/services/telephony/providers/vonage_provider.py @@ -78,6 +78,7 @@ class VonageProvider(TelephonyProvider): to_number: str, webhook_url: str, workflow_run_id: Optional[int] = None, + from_number: Optional[str] = None, **kwargs: Any, ) -> CallInitiationResult: """ @@ -88,8 +89,9 @@ class VonageProvider(TelephonyProvider): endpoint = f"{self.base_url}/v1/calls" - # Select a random phone number - from_number = random.choice(self.from_numbers) + # Use provided from_number or select a random one + if from_number is None: + from_number = random.choice(self.from_numbers) # Remove '+' prefix for Vonage from_number = from_number.replace("+", "") to_number = to_number.replace("+", "") diff --git a/api/tests/test_campaign_call_dispatcher.py b/api/tests/test_campaign_call_dispatcher.py index 3b0c878..f6cd492 100644 --- a/api/tests/test_campaign_call_dispatcher.py +++ b/api/tests/test_campaign_call_dispatcher.py @@ -195,6 +195,24 @@ def mock_rate_limiter(): async def mock_delete_mapping(*args, **kwargs): pass + async def mock_initialize_from_number_pool(*args, **kwargs): + return True + + async def mock_acquire_from_number(*args, **kwargs): + return "+15551234567" + + async def mock_release_from_number(*args, **kwargs): + return True + + async def mock_store_from_number_mapping(*args, **kwargs): + return True + + async def mock_get_from_number_mapping(*args, **kwargs): + return None + + async def mock_delete_from_number_mapping(*args, **kwargs): + return True + return { "acquire_token": mock_acquire_token, "try_acquire_concurrent_slot": mock_try_acquire_slot, @@ -202,6 +220,12 @@ def mock_rate_limiter(): "store_workflow_slot_mapping": mock_store_mapping, "get_workflow_slot_mapping": mock_get_mapping, "delete_workflow_slot_mapping": mock_delete_mapping, + "initialize_from_number_pool": mock_initialize_from_number_pool, + "acquire_from_number": mock_acquire_from_number, + "release_from_number": mock_release_from_number, + "store_workflow_from_number_mapping": mock_store_from_number_mapping, + "get_workflow_from_number_mapping": mock_get_from_number_mapping, + "delete_workflow_from_number_mapping": mock_delete_from_number_mapping, } @@ -242,6 +266,24 @@ class TestProcessBatchBasic: mock_rl.delete_workflow_slot_mapping = AsyncMock( side_effect=mock_rate_limiter["delete_workflow_slot_mapping"] ) + mock_rl.initialize_from_number_pool = AsyncMock( + side_effect=mock_rate_limiter["initialize_from_number_pool"] + ) + mock_rl.acquire_from_number = AsyncMock( + side_effect=mock_rate_limiter["acquire_from_number"] + ) + mock_rl.release_from_number = AsyncMock( + side_effect=mock_rate_limiter["release_from_number"] + ) + mock_rl.store_workflow_from_number_mapping = AsyncMock( + side_effect=mock_rate_limiter["store_workflow_from_number_mapping"] + ) + mock_rl.get_workflow_from_number_mapping = AsyncMock( + side_effect=mock_rate_limiter["get_workflow_from_number_mapping"] + ) + mock_rl.delete_workflow_from_number_mapping = AsyncMock( + side_effect=mock_rate_limiter["delete_workflow_from_number_mapping"] + ) dispatcher = CampaignCallDispatcher() @@ -307,6 +349,24 @@ class TestProcessBatchConcurrency: mock_rl.delete_workflow_slot_mapping = AsyncMock( side_effect=mock_rate_limiter["delete_workflow_slot_mapping"] ) + mock_rl.initialize_from_number_pool = AsyncMock( + side_effect=mock_rate_limiter["initialize_from_number_pool"] + ) + mock_rl.acquire_from_number = AsyncMock( + side_effect=mock_rate_limiter["acquire_from_number"] + ) + mock_rl.release_from_number = AsyncMock( + side_effect=mock_rate_limiter["release_from_number"] + ) + mock_rl.store_workflow_from_number_mapping = AsyncMock( + side_effect=mock_rate_limiter["store_workflow_from_number_mapping"] + ) + mock_rl.get_workflow_from_number_mapping = AsyncMock( + side_effect=mock_rate_limiter["get_workflow_from_number_mapping"] + ) + mock_rl.delete_workflow_from_number_mapping = AsyncMock( + side_effect=mock_rate_limiter["delete_workflow_from_number_mapping"] + ) dispatcher = CampaignCallDispatcher() @@ -379,6 +439,24 @@ class TestProcessBatchConcurrency: mock_rl.delete_workflow_slot_mapping = AsyncMock( side_effect=mock_rate_limiter["delete_workflow_slot_mapping"] ) + mock_rl.initialize_from_number_pool = AsyncMock( + side_effect=mock_rate_limiter["initialize_from_number_pool"] + ) + mock_rl.acquire_from_number = AsyncMock( + side_effect=mock_rate_limiter["acquire_from_number"] + ) + mock_rl.release_from_number = AsyncMock( + side_effect=mock_rate_limiter["release_from_number"] + ) + mock_rl.store_workflow_from_number_mapping = AsyncMock( + side_effect=mock_rate_limiter["store_workflow_from_number_mapping"] + ) + mock_rl.get_workflow_from_number_mapping = AsyncMock( + side_effect=mock_rate_limiter["get_workflow_from_number_mapping"] + ) + mock_rl.delete_workflow_from_number_mapping = AsyncMock( + side_effect=mock_rate_limiter["delete_workflow_from_number_mapping"] + ) dispatcher = CampaignCallDispatcher() @@ -450,6 +528,24 @@ class TestProcessBatchConcurrency: mock_rl.delete_workflow_slot_mapping = AsyncMock( side_effect=mock_rate_limiter["delete_workflow_slot_mapping"] ) + mock_rl.initialize_from_number_pool = AsyncMock( + side_effect=mock_rate_limiter["initialize_from_number_pool"] + ) + mock_rl.acquire_from_number = AsyncMock( + side_effect=mock_rate_limiter["acquire_from_number"] + ) + mock_rl.release_from_number = AsyncMock( + side_effect=mock_rate_limiter["release_from_number"] + ) + mock_rl.store_workflow_from_number_mapping = AsyncMock( + side_effect=mock_rate_limiter["store_workflow_from_number_mapping"] + ) + mock_rl.get_workflow_from_number_mapping = AsyncMock( + side_effect=mock_rate_limiter["get_workflow_from_number_mapping"] + ) + mock_rl.delete_workflow_from_number_mapping = AsyncMock( + side_effect=mock_rate_limiter["delete_workflow_from_number_mapping"] + ) dispatcher = CampaignCallDispatcher() @@ -513,6 +609,24 @@ class TestProcessBatchConcurrency: mock_rl.delete_workflow_slot_mapping = AsyncMock( side_effect=mock_rate_limiter["delete_workflow_slot_mapping"] ) + mock_rl.initialize_from_number_pool = AsyncMock( + side_effect=mock_rate_limiter["initialize_from_number_pool"] + ) + mock_rl.acquire_from_number = AsyncMock( + side_effect=mock_rate_limiter["acquire_from_number"] + ) + mock_rl.release_from_number = AsyncMock( + side_effect=mock_rate_limiter["release_from_number"] + ) + mock_rl.store_workflow_from_number_mapping = AsyncMock( + side_effect=mock_rate_limiter["store_workflow_from_number_mapping"] + ) + mock_rl.get_workflow_from_number_mapping = AsyncMock( + side_effect=mock_rate_limiter["get_workflow_from_number_mapping"] + ) + mock_rl.delete_workflow_from_number_mapping = AsyncMock( + side_effect=mock_rate_limiter["delete_workflow_from_number_mapping"] + ) dispatcher = CampaignCallDispatcher() diff --git a/ui/src/app/campaigns/new/page.tsx b/ui/src/app/campaigns/new/page.tsx index 315f8c8..b49aeef 100644 --- a/ui/src/app/campaigns/new/page.tsx +++ b/ui/src/app/campaigns/new/page.tsx @@ -50,6 +50,7 @@ export default function NewCampaignPage() { // Advanced settings state const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const [orgConcurrentLimit, setOrgConcurrentLimit] = useState(2); + const [fromNumbersCount, setFromNumbersCount] = useState(0); const [maxConcurrency, setMaxConcurrency] = useState(''); // Retry config state const [retryEnabled, setRetryEnabled] = useState(true); @@ -102,6 +103,7 @@ export default function NewCampaignPage() { if (response.data) { setOrgConcurrentLimit(response.data.concurrent_call_limit); + setFromNumbersCount(response.data.from_numbers_count); // Initialize retry config from defaults const retryConfig = response.data.default_retry_config; setRetryEnabled(retryConfig.enabled); @@ -124,6 +126,11 @@ export default function NewCampaignPage() { } }, [fetchWorkflows, fetchCampaignLimits, user]); + // Effective concurrency limit considering both org limit and available CLIs + const effectiveLimit = fromNumbersCount > 0 + ? Math.min(orgConcurrentLimit, fromNumbersCount) + : orgConcurrentLimit; + // Handle form submission const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -141,8 +148,12 @@ export default function NewCampaignPage() { toast.error('Max concurrent calls must be between 1 and 100'); return; } - if (maxConcurrencyValue > orgConcurrentLimit) { - toast.error(`Max concurrent calls cannot exceed organization limit (${orgConcurrentLimit})`); + if (maxConcurrencyValue > effectiveLimit) { + if (fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit) { + toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. You have ${fromNumbersCount} phone number(s) configured — add more CLIs to increase concurrency.`); + } else { + toast.error(`Max concurrent calls cannot exceed organization limit (${effectiveLimit})`); + } return; } } @@ -349,15 +360,26 @@ export default function NewCampaignPage() { setMaxConcurrency(e.target.value)} min={1} - max={orgConcurrentLimit} + max={effectiveLimit} />

- Maximum number of simultaneous calls. Leave empty to use organization limit ({orgConcurrentLimit}). + Maximum number of simultaneous calls. Leave empty to use {effectiveLimit}. + {fromNumbersCount > 0 && ` You have ${fromNumbersCount} CLI${fromNumbersCount !== 1 ? 's' : ''} and an org limit of ${orgConcurrentLimit}.`}

+ {fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit && ( +

+ Concurrency is limited to {fromNumbersCount} by your configured phone numbers. To use the full org limit of {orgConcurrentLimit}, add more CLIs in Telephony Configuration. +

+ )} + {fromNumbersCount === 0 && ( +

+ No phone numbers configured. Add CLIs in Telephony Configuration before running the campaign. +

+ )} {/* Retry Configuration */} diff --git a/ui/src/client/sdk.gen.ts b/ui/src/client/sdk.gen.ts index 88159cb..b16ab9b 100644 --- a/ui/src/client/sdk.gen.ts +++ b/ui/src/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { Client,Options as ClientOptions, TDataShape } from '@hey-api/client-fetch'; import { client as _heyApiClient } from './client.gen'; -import type { ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteError, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponse, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteError, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostError, CreateApiKeyApiV1UserApiKeysPostResponse, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostError, CreateCampaignApiV1CampaignCreatePostResponse, CreateCredentialApiV1CredentialsPostData, CreateCredentialApiV1CredentialsPostError, CreateCredentialApiV1CredentialsPostResponse, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostError, CreateLoadTestApiV1LooptalkLoadTestsPostResponse, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostError, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponse, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostError, CreateServiceKeyApiV1UserServiceKeysPostResponse, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostError, CreateSessionApiV1IntegrationSessionPostResponse, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostError, CreateTestSessionApiV1LooptalkTestSessionsPostResponse, CreateToolApiV1ToolsPostData, CreateToolApiV1ToolsPostError, CreateToolApiV1ToolsPostResponse, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostError, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponse, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostError, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponse, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostError, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponse, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteError, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponse, DeleteCredentialApiV1CredentialsCredentialUuidDeleteData, DeleteCredentialApiV1CredentialsCredentialUuidDeleteError, DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponse, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteData, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteData, DeleteToolApiV1ToolsToolUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteResponse, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetData, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetError, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetError, GetApiKeysApiV1UserApiKeysGetResponse, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetError, GetAuthUserApiV1UserAuthUserGetResponse, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetError, GetCampaignApiV1CampaignCampaignIdGetResponse, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetData, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetError, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetResponse, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetError, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetError, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetError, GetCampaignsApiV1CampaignGetResponse, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse, GetCredentialApiV1CredentialsCredentialUuidGetData, GetCredentialApiV1CredentialsCredentialUuidGetError, GetCredentialApiV1CredentialsCredentialUuidGetResponse, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetError, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetError, GetDailyReportApiV1OrganizationsReportsDailyGetResponse, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetError, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetError, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponse, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponse, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetData, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetError, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponse, GetEmbedConfigApiV1PublicEmbedConfigTokenGetData, GetEmbedConfigApiV1PublicEmbedConfigTokenGetError, GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponse, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetError, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponse, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetError, GetFileMetadataApiV1S3FileMetadataGetResponse, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetError, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetError, GetIntegrationsApiV1IntegrationGetResponse, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetError, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponse, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetError, GetServiceKeysApiV1UserServiceKeysGetResponse, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetError, GetSignedUrlApiV1S3SignedUrlGetResponse, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetError, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponse, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetError, GetToolApiV1ToolsToolUuidGetData, GetToolApiV1ToolsToolUuidGetError, GetToolApiV1ToolsToolUuidGetResponse, GetTurnCredentialsApiV1TurnCredentialsGetData, GetTurnCredentialsApiV1TurnCredentialsGetError, GetTurnCredentialsApiV1TurnCredentialsGetResponse, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostData, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostError, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponse, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponse, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetError, GetUserConfigurationsApiV1UserConfigurationsUserGetResponse, GetVoicesApiV1UserConfigurationsVoicesProviderGetData, GetVoicesApiV1UserConfigurationsVoicesProviderGetError, GetVoicesApiV1UserConfigurationsVoicesProviderGetResponse, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetError, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponse, GetWorkflowCountApiV1WorkflowCountGetData, GetWorkflowCountApiV1WorkflowCountGetError, GetWorkflowCountApiV1WorkflowCountGetResponse, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetError, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponse, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetError, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponse, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetError, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponse, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetError, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponse, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetError, GetWorkflowsApiV1WorkflowFetchGetResponse, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetError, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponse, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponse, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostError, HandleInboundFallbackApiV1TelephonyInboundFallbackPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostError, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostData, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostError, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostData, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostError, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostData, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostError, HealthApiV1HealthGetData, HealthApiV1HealthGetResponse,ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostError, ImpersonateApiV1SuperuserImpersonatePostResponse, InitializeEmbedSessionApiV1PublicEmbedInitPostData, InitializeEmbedSessionApiV1PublicEmbedInitPostError, InitializeEmbedSessionApiV1PublicEmbedInitPostResponse, InitiateCallApiV1PublicAgentUuidPostData, InitiateCallApiV1PublicAgentUuidPostError, InitiateCallApiV1PublicAgentUuidPostResponse, InitiateCallApiV1TelephonyInitiateCallPostData, InitiateCallApiV1TelephonyInitiateCallPostError, ListCredentialsApiV1CredentialsGetData, ListCredentialsApiV1CredentialsGetError, ListCredentialsApiV1CredentialsGetResponse, ListDocumentsApiV1KnowledgeBaseDocumentsGetData, ListDocumentsApiV1KnowledgeBaseDocumentsGetError, ListDocumentsApiV1KnowledgeBaseDocumentsGetResponse, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetError, ListTestSessionsApiV1LooptalkTestSessionsGetResponse, ListToolsApiV1ToolsGetData, ListToolsApiV1ToolsGetError, ListToolsApiV1ToolsGetResponse, OptionsConfigApiV1PublicEmbedConfigTokenOptionsData, OptionsConfigApiV1PublicEmbedConfigTokenOptionsError, OptionsInitApiV1PublicEmbedInitOptionsData, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostError, PauseCampaignApiV1CampaignCampaignIdPausePostResponse, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostData, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostError, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponse, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutError, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponse, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutError, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostError, ResumeCampaignApiV1CampaignCampaignIdResumePostResponse, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError, SearchChunksApiV1KnowledgeBaseSearchPostData, SearchChunksApiV1KnowledgeBaseSearchPostError, SearchChunksApiV1KnowledgeBaseSearchPostResponse, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostData, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostResponse, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostError, StartCampaignApiV1CampaignCampaignIdStartPostResponse, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostError, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostData, UnarchiveToolApiV1ToolsToolUuidUnarchivePostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse, UpdateCredentialApiV1CredentialsCredentialUuidPutData, UpdateCredentialApiV1CredentialsCredentialUuidPutError, UpdateCredentialApiV1CredentialsCredentialUuidPutResponse, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutError, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponse, UpdateToolApiV1ToolsToolUuidPutData, UpdateToolApiV1ToolsToolUuidPutError, UpdateToolApiV1ToolsToolUuidPutResponse, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutError, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutError, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponse, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutError, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetError, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponse, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostError, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponse } from './types.gen'; +import type { ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteData, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteError, ArchiveApiKeyApiV1UserApiKeysApiKeyIdDeleteResponse, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteData, ArchiveServiceKeyApiV1UserServiceKeysServiceKeyIdDeleteError, CreateApiKeyApiV1UserApiKeysPostData, CreateApiKeyApiV1UserApiKeysPostError, CreateApiKeyApiV1UserApiKeysPostResponse, CreateCampaignApiV1CampaignCreatePostData, CreateCampaignApiV1CampaignCreatePostError, CreateCampaignApiV1CampaignCreatePostResponse, CreateCredentialApiV1CredentialsPostData, CreateCredentialApiV1CredentialsPostError, CreateCredentialApiV1CredentialsPostResponse, CreateLoadTestApiV1LooptalkLoadTestsPostData, CreateLoadTestApiV1LooptalkLoadTestsPostError, CreateLoadTestApiV1LooptalkLoadTestsPostResponse, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostData, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostError, CreateOrUpdateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenPostResponse, CreateServiceKeyApiV1UserServiceKeysPostData, CreateServiceKeyApiV1UserServiceKeysPostError, CreateServiceKeyApiV1UserServiceKeysPostResponse, CreateSessionApiV1IntegrationSessionPostData, CreateSessionApiV1IntegrationSessionPostError, CreateSessionApiV1IntegrationSessionPostResponse, CreateTestSessionApiV1LooptalkTestSessionsPostData, CreateTestSessionApiV1LooptalkTestSessionsPostError, CreateTestSessionApiV1LooptalkTestSessionsPostResponse, CreateToolApiV1ToolsPostData, CreateToolApiV1ToolsPostError, CreateToolApiV1ToolsPostResponse, CreateWorkflowApiV1WorkflowCreateDefinitionPostData, CreateWorkflowApiV1WorkflowCreateDefinitionPostError, CreateWorkflowApiV1WorkflowCreateDefinitionPostResponse, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostData, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostError, CreateWorkflowFromTemplateApiV1WorkflowCreateTemplatePostResponse, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostData, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostError, CreateWorkflowRunApiV1WorkflowWorkflowIdRunsPostResponse, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteData, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteError, DeactivateEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenDeleteResponse, DeleteCredentialApiV1CredentialsCredentialUuidDeleteData, DeleteCredentialApiV1CredentialsCredentialUuidDeleteError, DeleteCredentialApiV1CredentialsCredentialUuidDeleteResponse, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteData, DeleteDocumentApiV1KnowledgeBaseDocumentsDocumentUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteData, DeleteToolApiV1ToolsToolUuidDeleteError, DeleteToolApiV1ToolsToolUuidDeleteResponse, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetData, DownloadWorkflowArtifactApiV1PublicDownloadWorkflowTokenArtifactTypeGetError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostData, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostError, DuplicateWorkflowTemplateApiV1WorkflowTemplatesDuplicatePostResponse, GetActiveTestsApiV1LooptalkActiveTestsGetData, GetActiveTestsApiV1LooptalkActiveTestsGetError, GetApiKeysApiV1UserApiKeysGetData, GetApiKeysApiV1UserApiKeysGetError, GetApiKeysApiV1UserApiKeysGetResponse, GetAuthUserApiV1UserAuthUserGetData, GetAuthUserApiV1UserAuthUserGetError, GetAuthUserApiV1UserAuthUserGetResponse, GetCampaignApiV1CampaignCampaignIdGetData, GetCampaignApiV1CampaignCampaignIdGetError, GetCampaignApiV1CampaignCampaignIdGetResponse, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetData, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetError, GetCampaignLimitsApiV1OrganizationsCampaignLimitsGetResponse, GetCampaignProgressApiV1CampaignCampaignIdProgressGetData, GetCampaignProgressApiV1CampaignCampaignIdProgressGetError, GetCampaignProgressApiV1CampaignCampaignIdProgressGetResponse, GetCampaignRunsApiV1CampaignCampaignIdRunsGetData, GetCampaignRunsApiV1CampaignCampaignIdRunsGetError, GetCampaignRunsApiV1CampaignCampaignIdRunsGetResponse, GetCampaignsApiV1CampaignGetData, GetCampaignsApiV1CampaignGetError, GetCampaignsApiV1CampaignGetResponse, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetData, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetError, GetCampaignSourceDownloadUrlApiV1CampaignCampaignIdSourceDownloadUrlGetResponse, GetCredentialApiV1CredentialsCredentialUuidGetData, GetCredentialApiV1CredentialsCredentialUuidGetError, GetCredentialApiV1CredentialsCredentialUuidGetResponse, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetData, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetError, GetCurrentPeriodUsageApiV1OrganizationsUsageCurrentPeriodGetResponse, GetDailyReportApiV1OrganizationsReportsDailyGetData, GetDailyReportApiV1OrganizationsReportsDailyGetError, GetDailyReportApiV1OrganizationsReportsDailyGetResponse, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetData, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetError, GetDailyRunsDetailApiV1OrganizationsReportsDailyRunsGetResponse, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetData, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetError, GetDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGetResponse, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetData, GetDefaultConfigurationsApiV1UserConfigurationsDefaultsGetResponse, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetData, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetError, GetDocumentApiV1KnowledgeBaseDocumentsDocumentUuidGetResponse, GetEmbedConfigApiV1PublicEmbedConfigTokenGetData, GetEmbedConfigApiV1PublicEmbedConfigTokenGetError, GetEmbedConfigApiV1PublicEmbedConfigTokenGetResponse, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetData, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetError, GetEmbedTokenApiV1WorkflowWorkflowIdEmbedTokenGetResponse, GetFileMetadataApiV1S3FileMetadataGetData, GetFileMetadataApiV1S3FileMetadataGetError, GetFileMetadataApiV1S3FileMetadataGetResponse, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetData, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetError, GetIntegrationAccessTokenApiV1IntegrationIntegrationIdAccessTokenGetResponse, GetIntegrationsApiV1IntegrationGetData, GetIntegrationsApiV1IntegrationGetError, GetIntegrationsApiV1IntegrationGetResponse, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetData, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetError, GetLoadTestStatsApiV1LooptalkLoadTestsLoadTestGroupIdStatsGetResponse, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostData, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostError, GetPresignedUploadUrlApiV1S3PresignedUploadUrlPostResponse, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetData, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetError, GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetResponse, GetServiceKeysApiV1UserServiceKeysGetData, GetServiceKeysApiV1UserServiceKeysGetError, GetServiceKeysApiV1UserServiceKeysGetResponse, GetSignedUrlApiV1S3SignedUrlGetData, GetSignedUrlApiV1S3SignedUrlGetError, GetSignedUrlApiV1S3SignedUrlGetResponse, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetData, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetError, GetTelephonyConfigurationApiV1OrganizationsTelephonyConfigGetResponse, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetData, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetError, GetTestSessionApiV1LooptalkTestSessionsTestSessionIdGetResponse, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetData, GetTestSessionConversationApiV1LooptalkTestSessionsTestSessionIdConversationGetError, GetToolApiV1ToolsToolUuidGetData, GetToolApiV1ToolsToolUuidGetError, GetToolApiV1ToolsToolUuidGetResponse, GetTurnCredentialsApiV1TurnCredentialsGetData, GetTurnCredentialsApiV1TurnCredentialsGetError, GetTurnCredentialsApiV1TurnCredentialsGetResponse, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostData, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostError, GetUploadUrlApiV1KnowledgeBaseUploadUrlPostResponse, GetUsageHistoryApiV1OrganizationsUsageRunsGetData, GetUsageHistoryApiV1OrganizationsUsageRunsGetError, GetUsageHistoryApiV1OrganizationsUsageRunsGetResponse, GetUserConfigurationsApiV1UserConfigurationsUserGetData, GetUserConfigurationsApiV1UserConfigurationsUserGetError, GetUserConfigurationsApiV1UserConfigurationsUserGetResponse, GetVoicesApiV1UserConfigurationsVoicesProviderGetData, GetVoicesApiV1UserConfigurationsVoicesProviderGetError, GetVoicesApiV1UserConfigurationsVoicesProviderGetResponse, GetWorkflowApiV1WorkflowFetchWorkflowIdGetData, GetWorkflowApiV1WorkflowFetchWorkflowIdGetError, GetWorkflowApiV1WorkflowFetchWorkflowIdGetResponse, GetWorkflowCountApiV1WorkflowCountGetData, GetWorkflowCountApiV1WorkflowCountGetError, GetWorkflowCountApiV1WorkflowCountGetResponse, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetData, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetError, GetWorkflowOptionsApiV1OrganizationsReportsWorkflowsGetResponse, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetData, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetError, GetWorkflowRunApiV1WorkflowWorkflowIdRunsRunIdGetResponse, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetData, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetError, GetWorkflowRunsApiV1SuperuserWorkflowRunsGetResponse, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetData, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetError, GetWorkflowRunsApiV1WorkflowWorkflowIdRunsGetResponse, GetWorkflowsApiV1WorkflowFetchGetData, GetWorkflowsApiV1WorkflowFetchGetError, GetWorkflowsApiV1WorkflowFetchGetResponse, GetWorkflowsSummaryApiV1WorkflowSummaryGetData, GetWorkflowsSummaryApiV1WorkflowSummaryGetError, GetWorkflowsSummaryApiV1WorkflowSummaryGetResponse, GetWorkflowTemplatesApiV1WorkflowTemplatesGetData, GetWorkflowTemplatesApiV1WorkflowTemplatesGetResponse, HandleCloudonixCdrApiV1TelephonyCloudonixCdrPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostData, HandleCloudonixStatusCallbackApiV1TelephonyCloudonixStatusCallbackWorkflowRunIdPostError, HandleInboundFallbackApiV1TelephonyInboundFallbackPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostData, HandleInboundTelephonyApiV1TelephonyInboundWorkflowIdPostError, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostData, HandleTwilioStatusCallbackApiV1TelephonyTwilioStatusCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostData, HandleVobizHangupCallbackApiV1TelephonyVobizHangupCallbackWorkflowRunIdPostError, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostData, HandleVobizHangupCallbackByWorkflowApiV1TelephonyVobizHangupCallbackWorkflowWorkflowIdPostError, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostData, HandleVobizRingCallbackApiV1TelephonyVobizRingCallbackWorkflowRunIdPostError, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostData, HandleVonageEventsApiV1TelephonyVonageEventsWorkflowRunIdPostError, HealthApiV1HealthGetData, HealthApiV1HealthGetResponse,ImpersonateApiV1SuperuserImpersonatePostData, ImpersonateApiV1SuperuserImpersonatePostError, ImpersonateApiV1SuperuserImpersonatePostResponse, InitializeEmbedSessionApiV1PublicEmbedInitPostData, InitializeEmbedSessionApiV1PublicEmbedInitPostError, InitializeEmbedSessionApiV1PublicEmbedInitPostResponse, InitiateCallApiV1PublicAgentUuidPostData, InitiateCallApiV1PublicAgentUuidPostError, InitiateCallApiV1PublicAgentUuidPostResponse, InitiateCallApiV1TelephonyInitiateCallPostData, InitiateCallApiV1TelephonyInitiateCallPostError, ListCredentialsApiV1CredentialsGetData, ListCredentialsApiV1CredentialsGetError, ListCredentialsApiV1CredentialsGetResponse, ListDocumentsApiV1KnowledgeBaseDocumentsGetData, ListDocumentsApiV1KnowledgeBaseDocumentsGetError, ListDocumentsApiV1KnowledgeBaseDocumentsGetResponse, ListTestSessionsApiV1LooptalkTestSessionsGetData, ListTestSessionsApiV1LooptalkTestSessionsGetError, ListTestSessionsApiV1LooptalkTestSessionsGetResponse, ListToolsApiV1ToolsGetData, ListToolsApiV1ToolsGetError, ListToolsApiV1ToolsGetResponse, OptionsConfigApiV1PublicEmbedConfigTokenOptionsData, OptionsConfigApiV1PublicEmbedConfigTokenOptionsError, OptionsInitApiV1PublicEmbedInitOptionsData, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsData, OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsError, PauseCampaignApiV1CampaignCampaignIdPausePostData, PauseCampaignApiV1CampaignCampaignIdPausePostError, PauseCampaignApiV1CampaignCampaignIdPausePostResponse, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostData, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostError, ProcessDocumentApiV1KnowledgeBaseProcessDocumentPostResponse, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutData, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutError, ReactivateApiKeyApiV1UserApiKeysApiKeyIdReactivatePutResponse, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutData, ReactivateServiceKeyApiV1UserServiceKeysServiceKeyIdReactivatePutError, ResumeCampaignApiV1CampaignCampaignIdResumePostData, ResumeCampaignApiV1CampaignCampaignIdResumePostError, ResumeCampaignApiV1CampaignCampaignIdResumePostResponse, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostData, SaveTelephonyConfigurationApiV1OrganizationsTelephonyConfigPostError, SearchChunksApiV1KnowledgeBaseSearchPostData, SearchChunksApiV1KnowledgeBaseSearchPostError, SearchChunksApiV1KnowledgeBaseSearchPostResponse, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostData, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostError, SetAdminCommentApiV1SuperuserWorkflowRunsRunIdCommentPostResponse, StartCampaignApiV1CampaignCampaignIdStartPostData, StartCampaignApiV1CampaignCampaignIdStartPostError, StartCampaignApiV1CampaignCampaignIdStartPostResponse, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostData, StartTestSessionApiV1LooptalkTestSessionsTestSessionIdStartPostError, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostData, StopTestSessionApiV1LooptalkTestSessionsTestSessionIdStopPostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostData, UnarchiveToolApiV1ToolsToolUuidUnarchivePostError, UnarchiveToolApiV1ToolsToolUuidUnarchivePostResponse, UpdateCredentialApiV1CredentialsCredentialUuidPutData, UpdateCredentialApiV1CredentialsCredentialUuidPutError, UpdateCredentialApiV1CredentialsCredentialUuidPutResponse, UpdateIntegrationApiV1IntegrationIntegrationIdPutData, UpdateIntegrationApiV1IntegrationIntegrationIdPutError, UpdateIntegrationApiV1IntegrationIntegrationIdPutResponse, UpdateToolApiV1ToolsToolUuidPutData, UpdateToolApiV1ToolsToolUuidPutError, UpdateToolApiV1ToolsToolUuidPutResponse, UpdateUserConfigurationsApiV1UserConfigurationsUserPutData, UpdateUserConfigurationsApiV1UserConfigurationsUserPutError, UpdateUserConfigurationsApiV1UserConfigurationsUserPutResponse, UpdateWorkflowApiV1WorkflowWorkflowIdPutData, UpdateWorkflowApiV1WorkflowWorkflowIdPutError, UpdateWorkflowApiV1WorkflowWorkflowIdPutResponse, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutData, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutError, UpdateWorkflowStatusApiV1WorkflowWorkflowIdStatusPutResponse, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetData, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetError, ValidateUserConfigurationsApiV1UserConfigurationsUserValidateGetResponse, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostData, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostError, ValidateWorkflowApiV1WorkflowWorkflowIdValidatePostResponse } from './types.gen'; export type Options = ClientOptions & { /** @@ -1337,6 +1337,37 @@ export const optionsConfigApiV1PublicEmbedConfigTokenOptions = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + url: '/api/v1/public/embed/turn-credentials/{session_token}', + ...options + }); +}; + +/** + * Options Turn Credentials + * Handle CORS preflight for TURN credentials endpoint + */ +export const optionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptions = (options: Options) => { + return (options.client ?? _heyApiClient).options({ + url: '/api/v1/public/embed/turn-credentials/{session_token}', + ...options + }); +}; + /** * Initiate Call * Initiate a phone call via API trigger. @@ -1469,9 +1500,8 @@ export const getUploadUrlApiV1KnowledgeBaseUploadUrlPost = 'processing' -> 'completed' or 'failed'. * - * Embedding Services: - * * openai (default): High-quality 1536-dimensional embeddings (requires OPENAI_API_KEY) - * * sentence_transformer: Free, offline-capable, 384-dimensional embeddings + * Embedding: + * Uses OpenAI text-embedding-3-small (1536-dimensional embeddings, requires API key configured in Model Configurations). * * Access Control: * * Users can only process documents in their organization. diff --git a/ui/src/client/types.gen.ts b/ui/src/client/types.gen.ts index dc94d93..4862540 100644 --- a/ui/src/client/types.gen.ts +++ b/ui/src/client/types.gen.ts @@ -45,6 +45,7 @@ export type CallType = 'inbound' | 'outbound'; export type CampaignLimitsResponse = { concurrent_call_limit: number; + from_numbers_count: number; default_retry_config: RetryConfigResponse; }; @@ -705,10 +706,6 @@ export type ProcessDocumentRequestSchema = { * S3 key of the uploaded file */ s3_key: string; - /** - * Embedding service to use for processing. Options: 'openai' (default, 1536-dim, requires API key) or 'sentence_transformer' (free, 384-dim) - */ - embedding_service?: 'sentence_transformer' | 'openai'; }; export type RetryConfigRequest = { @@ -4354,6 +4351,66 @@ export type OptionsConfigApiV1PublicEmbedConfigTokenOptionsResponses = { 200: unknown; }; +export type GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetData = { + body?: never; + path: { + session_token: string; + }; + query?: never; + url: '/api/v1/public/embed/turn-credentials/{session_token}'; +}; + +export type GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetError = GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetErrors[keyof GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetErrors]; + +export type GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetResponses = { + /** + * Successful Response + */ + 200: TurnCredentialsResponse; +}; + +export type GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetResponse = GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetResponses[keyof GetPublicTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenGetResponses]; + +export type OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsData = { + body?: never; + path: { + session_token: string; + }; + query?: never; + url: '/api/v1/public/embed/turn-credentials/{session_token}'; +}; + +export type OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsErrors = { + /** + * Not found + */ + 404: unknown; + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsError = OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsErrors[keyof OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsErrors]; + +export type OptionsTurnCredentialsApiV1PublicEmbedTurnCredentialsSessionTokenOptionsResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + export type InitiateCallApiV1PublicAgentUuidPostData = { body: TriggerCallRequest; headers: {