mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
Merge remote-tracking branch 'origin/main' into fix/org-scoped-access
# Conflicts: # api/routes/agent_stream.py # api/routes/telephony.py # api/routes/webrtc_signaling.py # docs/api-reference/openapi.json # sdk/python/src/dograh_sdk/_generated_models.py
This commit is contained in:
commit
d22c073cb5
38 changed files with 2332 additions and 471 deletions
|
|
@ -18,6 +18,10 @@ from starlette.websockets import WebSocketDisconnect
|
|||
|
||||
from api.db import db_client
|
||||
from api.enums import CallType, WorkflowRunState
|
||||
from api.services.call_concurrency import (
|
||||
CallConcurrencyLimitError,
|
||||
call_concurrency,
|
||||
)
|
||||
from api.services.quota_service import authorize_workflow_run_start
|
||||
from api.services.telephony import registry as telephony_registry
|
||||
|
||||
|
|
@ -51,6 +55,16 @@ async def agent_stream_websocket(
|
|||
await websocket.close(code=1008, reason="Workflow not found")
|
||||
return
|
||||
|
||||
try:
|
||||
concurrency_slot = await call_concurrency.acquire_org_slot(
|
||||
workflow.organization_id,
|
||||
source=f"agent_stream:{provider_name}",
|
||||
timeout=0,
|
||||
)
|
||||
except CallConcurrencyLimitError:
|
||||
await websocket.close(code=1008, reason="Concurrent call limit reached")
|
||||
return
|
||||
|
||||
numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000
|
||||
workflow_run_name = f"WR-AGS-{numeric_suffix:08d}"
|
||||
initial_context = {
|
||||
|
|
@ -58,15 +72,20 @@ async def agent_stream_websocket(
|
|||
"provider": provider_name,
|
||||
"direction": "inbound",
|
||||
}
|
||||
workflow_run = await db_client.create_workflow_run(
|
||||
workflow_run_name,
|
||||
workflow.id,
|
||||
provider_name,
|
||||
user_id=workflow.user_id,
|
||||
call_type=CallType.INBOUND,
|
||||
initial_context=initial_context,
|
||||
organization_id=workflow.organization_id,
|
||||
)
|
||||
try:
|
||||
workflow_run = await db_client.create_workflow_run(
|
||||
workflow_run_name,
|
||||
workflow.id,
|
||||
provider_name,
|
||||
user_id=workflow.user_id,
|
||||
call_type=CallType.INBOUND,
|
||||
initial_context=initial_context,
|
||||
organization_id=workflow.organization_id,
|
||||
)
|
||||
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run.id)
|
||||
except Exception:
|
||||
await call_concurrency.release_slot(concurrency_slot)
|
||||
raise
|
||||
|
||||
set_current_run_id(workflow_run.id)
|
||||
set_current_org_id(workflow.organization_id)
|
||||
|
|
@ -81,36 +100,40 @@ async def agent_stream_websocket(
|
|||
f"agent-stream quota exceeded for user {workflow.user_id}: "
|
||||
f"{quota_result.error_message}"
|
||||
)
|
||||
await call_concurrency.release_workflow_run_slot(workflow_run.id)
|
||||
await websocket.close(
|
||||
code=1008, reason=quota_result.error_message or "Quota exceeded"
|
||||
)
|
||||
return
|
||||
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run.id, state=WorkflowRunState.RUNNING.value
|
||||
)
|
||||
|
||||
provider_instance = spec.provider_cls({})
|
||||
try:
|
||||
await provider_instance.handle_external_websocket(
|
||||
websocket,
|
||||
organization_id=workflow.organization_id,
|
||||
workflow_id=workflow.id,
|
||||
user_id=workflow.user_id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
params=params,
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run.id, state=WorkflowRunState.RUNNING.value
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
logger.warning(f"agent-stream provider {provider_name} not supported: {e}")
|
||||
|
||||
provider_instance = spec.provider_cls({})
|
||||
try:
|
||||
await websocket.close(code=1011, reason=str(e))
|
||||
except RuntimeError:
|
||||
pass
|
||||
except WebSocketDisconnect as e:
|
||||
logger.info(f"agent-stream disconnected: code={e.code} reason={e.reason}")
|
||||
except Exception as e:
|
||||
logger.error(f"agent-stream error for run {workflow_run.id}: {e}")
|
||||
try:
|
||||
await websocket.close(1011, "Internal server error")
|
||||
except RuntimeError:
|
||||
pass
|
||||
await provider_instance.handle_external_websocket(
|
||||
websocket,
|
||||
organization_id=workflow.organization_id,
|
||||
workflow_id=workflow.id,
|
||||
user_id=workflow.user_id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
params=params,
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
logger.warning(f"agent-stream provider {provider_name} not supported: {e}")
|
||||
try:
|
||||
await websocket.close(code=1011, reason=str(e))
|
||||
except RuntimeError:
|
||||
pass
|
||||
except WebSocketDisconnect as e:
|
||||
logger.info(f"agent-stream disconnected: code={e.code} reason={e.reason}")
|
||||
except Exception as e:
|
||||
logger.error(f"agent-stream error for run {workflow_run.id}: {e}")
|
||||
try:
|
||||
await websocket.close(1011, "Internal server error")
|
||||
except RuntimeError:
|
||||
pass
|
||||
finally:
|
||||
await call_concurrency.unregister_active_call(workflow_run.id)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ from pydantic import BaseModel
|
|||
|
||||
from api.db import db_client
|
||||
from api.enums import TriggerState, WorkflowStatus
|
||||
from api.services.call_concurrency import (
|
||||
CallConcurrencyLimitError,
|
||||
call_concurrency,
|
||||
)
|
||||
from api.services.quota_service import authorize_workflow_run_start
|
||||
from api.services.telephony.factory import (
|
||||
get_default_telephony_provider,
|
||||
|
|
@ -243,15 +247,32 @@ async def _execute_resolved_target(
|
|||
initial_context["api_key_created_by"] = api_key_created_by
|
||||
initial_context.update(request.initial_context or {})
|
||||
|
||||
workflow_run = await db_client.create_workflow_run(
|
||||
name=workflow_run_name,
|
||||
workflow_id=target.workflow.id,
|
||||
mode=workflow_run_mode,
|
||||
initial_context=initial_context,
|
||||
user_id=execution_user_id,
|
||||
use_draft=use_draft,
|
||||
organization_id=target.organization_id,
|
||||
)
|
||||
try:
|
||||
concurrency_slot = await call_concurrency.acquire_org_slot(
|
||||
target.organization_id,
|
||||
source="public_agent",
|
||||
timeout=0,
|
||||
)
|
||||
except CallConcurrencyLimitError:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Concurrent call limit reached",
|
||||
)
|
||||
|
||||
try:
|
||||
workflow_run = await db_client.create_workflow_run(
|
||||
name=workflow_run_name,
|
||||
workflow_id=target.workflow.id,
|
||||
mode=workflow_run_mode,
|
||||
initial_context=initial_context,
|
||||
user_id=execution_user_id,
|
||||
use_draft=use_draft,
|
||||
organization_id=target.organization_id,
|
||||
)
|
||||
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run.id)
|
||||
except Exception:
|
||||
await call_concurrency.release_slot(concurrency_slot)
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
f"Created workflow run {workflow_run.id} for public agent "
|
||||
|
|
@ -268,10 +289,15 @@ async def _execute_resolved_target(
|
|||
workflow_run_id=workflow_run.id,
|
||||
)
|
||||
if not quota_result.has_quota:
|
||||
await call_concurrency.release_workflow_run_slot(workflow_run.id)
|
||||
raise HTTPException(status_code=402, detail=quota_result.error_message)
|
||||
|
||||
# 9. Construct webhook URL for telephony provider callback
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
try:
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
except Exception:
|
||||
await call_concurrency.release_workflow_run_slot(workflow_run.id)
|
||||
raise
|
||||
webhook_endpoint = provider.WEBHOOK_ENDPOINT
|
||||
|
||||
webhook_url = (
|
||||
|
|
@ -298,6 +324,7 @@ async def _execute_resolved_target(
|
|||
logger.warning(
|
||||
f"Failed to initiate call for workflow run {workflow_run.id}: {e}"
|
||||
)
|
||||
await call_concurrency.release_workflow_run_slot(workflow_run.id)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to initiate call: {e}",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,11 @@ from api.enums import CallType, WorkflowRunMode, WorkflowRunState
|
|||
from api.errors.telephony_errors import TelephonyError
|
||||
from api.sdk_expose import sdk_expose
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.call_concurrency import (
|
||||
CallConcurrencyLimitError,
|
||||
WorkflowRunSlotAlreadyBoundError,
|
||||
call_concurrency,
|
||||
)
|
||||
from api.services.quota_service import authorize_workflow_run_start
|
||||
from api.services.telephony.call_transfer_manager import get_call_transfer_manager
|
||||
from api.services.telephony.factory import (
|
||||
|
|
@ -139,71 +144,6 @@ async def initiate_call(
|
|||
# Determine the workflow run mode based on provider type
|
||||
workflow_run_mode = provider.PROVIDER_NAME
|
||||
|
||||
workflow_run_id = request.workflow_run_id
|
||||
|
||||
if not workflow_run_id:
|
||||
# Merge template context variables (e.g. caller_number, called_number
|
||||
# set in workflow settings for testing pre-call data fetch).
|
||||
template_vars = workflow.template_context_variables or {}
|
||||
|
||||
numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000
|
||||
workflow_run_name = f"WR-TEL-OUT-{numeric_suffix:08d}"
|
||||
workflow_run = await db_client.create_workflow_run(
|
||||
workflow_run_name,
|
||||
workflow.id,
|
||||
workflow_run_mode,
|
||||
user_id=execution_user_id,
|
||||
call_type=CallType.OUTBOUND,
|
||||
initial_context={
|
||||
**template_vars,
|
||||
"phone_number": phone_number,
|
||||
"called_number": phone_number,
|
||||
"provider": provider.PROVIDER_NAME,
|
||||
"telephony_configuration_id": telephony_configuration_id,
|
||||
},
|
||||
use_draft=True,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
workflow_run_id = workflow_run.id
|
||||
else:
|
||||
workflow_run = await db_client.get_workflow_run(
|
||||
workflow_run_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
if not workflow_run:
|
||||
raise HTTPException(status_code=400, detail="Workflow run not found")
|
||||
if workflow_run.workflow_id != workflow.id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="workflow_run_workflow_mismatch",
|
||||
)
|
||||
workflow_run_name = workflow_run.name
|
||||
|
||||
# Check Dograh quota after the run exists so hosted v2 can mint and store
|
||||
# the MPS correlation id before initiating the call.
|
||||
quota_result = await authorize_workflow_run_start(
|
||||
workflow_id=workflow.id,
|
||||
organization_id=user.selected_organization_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
actor_user=user,
|
||||
)
|
||||
if not quota_result.has_quota:
|
||||
raise HTTPException(status_code=402, detail=quota_result.error_message)
|
||||
|
||||
# Construct webhook URL based on provider type
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
|
||||
webhook_endpoint = provider.WEBHOOK_ENDPOINT
|
||||
|
||||
webhook_url = (
|
||||
f"{backend_endpoint}/api/v1/telephony/{webhook_endpoint}"
|
||||
f"?workflow_id={workflow.id}"
|
||||
f"&user_id={execution_user_id}"
|
||||
f"&workflow_run_id={workflow_run_id}"
|
||||
f"&organization_id={user.selected_organization_id}"
|
||||
)
|
||||
|
||||
keywords = {"workflow_id": workflow.id, "user_id": execution_user_id}
|
||||
|
||||
# Resolve optional caller-ID. The config has already been validated against
|
||||
# the user's organization, so filtering by config_id is sufficient for
|
||||
# tenant isolation.
|
||||
|
|
@ -221,14 +161,103 @@ async def initiate_call(
|
|||
raise HTTPException(status_code=400, detail="from_phone_number_not_found")
|
||||
from_number = phone_row.address_normalized
|
||||
|
||||
# Initiate call via provider
|
||||
result = await provider.initiate_call(
|
||||
to_number=phone_number,
|
||||
webhook_url=webhook_url,
|
||||
workflow_run_id = request.workflow_run_id
|
||||
try:
|
||||
concurrency_slot = await call_concurrency.acquire_org_slot(
|
||||
user.selected_organization_id,
|
||||
source="telephony_outbound",
|
||||
timeout=0,
|
||||
)
|
||||
except CallConcurrencyLimitError:
|
||||
raise HTTPException(status_code=429, detail="Concurrent call limit reached")
|
||||
|
||||
try:
|
||||
if not workflow_run_id:
|
||||
# Merge template context variables (e.g. caller_number, called_number
|
||||
# set in workflow settings for testing pre-call data fetch).
|
||||
template_vars = workflow.template_context_variables or {}
|
||||
|
||||
numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000
|
||||
workflow_run_name = f"WR-TEL-OUT-{numeric_suffix:08d}"
|
||||
workflow_run = await db_client.create_workflow_run(
|
||||
workflow_run_name,
|
||||
workflow.id,
|
||||
workflow_run_mode,
|
||||
user_id=execution_user_id,
|
||||
call_type=CallType.OUTBOUND,
|
||||
initial_context={
|
||||
**template_vars,
|
||||
"phone_number": phone_number,
|
||||
"called_number": phone_number,
|
||||
"provider": provider.PROVIDER_NAME,
|
||||
"telephony_configuration_id": telephony_configuration_id,
|
||||
},
|
||||
use_draft=True,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
workflow_run_id = workflow_run.id
|
||||
else:
|
||||
workflow_run = await db_client.get_workflow_run(
|
||||
workflow_run_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
if not workflow_run:
|
||||
raise HTTPException(status_code=400, detail="Workflow run not found")
|
||||
if workflow_run.workflow_id != workflow.id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="workflow_run_workflow_mismatch",
|
||||
)
|
||||
workflow_run_name = workflow_run.name
|
||||
|
||||
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run_id)
|
||||
except WorkflowRunSlotAlreadyBoundError:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Workflow run already has an active call",
|
||||
)
|
||||
except Exception:
|
||||
await call_concurrency.release_slot(concurrency_slot)
|
||||
raise
|
||||
|
||||
# Check Dograh quota after the run exists so hosted v2 can mint and store
|
||||
# the MPS correlation id before initiating the call.
|
||||
quota_result = await authorize_workflow_run_start(
|
||||
workflow_id=workflow.id,
|
||||
organization_id=user.selected_organization_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
from_number=from_number,
|
||||
**keywords,
|
||||
actor_user=user,
|
||||
)
|
||||
if not quota_result.has_quota:
|
||||
await call_concurrency.release_workflow_run_slot(workflow_run_id)
|
||||
raise HTTPException(status_code=402, detail=quota_result.error_message)
|
||||
|
||||
try:
|
||||
# Construct webhook URL based on provider type
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
|
||||
webhook_endpoint = provider.WEBHOOK_ENDPOINT
|
||||
|
||||
webhook_url = (
|
||||
f"{backend_endpoint}/api/v1/telephony/{webhook_endpoint}"
|
||||
f"?workflow_id={workflow.id}"
|
||||
f"&user_id={execution_user_id}"
|
||||
f"&workflow_run_id={workflow_run_id}"
|
||||
f"&organization_id={user.selected_organization_id}"
|
||||
)
|
||||
|
||||
keywords = {"workflow_id": workflow.id, "user_id": execution_user_id}
|
||||
|
||||
# Initiate call via provider
|
||||
result = await provider.initiate_call(
|
||||
to_number=phone_number,
|
||||
webhook_url=webhook_url,
|
||||
workflow_run_id=workflow_run_id,
|
||||
from_number=from_number,
|
||||
**keywords,
|
||||
)
|
||||
except Exception:
|
||||
await call_concurrency.release_workflow_run_slot(workflow_run_id)
|
||||
raise
|
||||
|
||||
# Store provider metadata and caller_number in workflow run context
|
||||
gathered_context = {
|
||||
|
|
@ -782,42 +811,68 @@ async def handle_inbound_run(request: Request):
|
|||
TelephonyError.SIGNATURE_VALIDATION_FAILED
|
||||
)
|
||||
|
||||
# 5. Create workflow run + authorize quota before returning provider
|
||||
# stream instructions.
|
||||
workflow_run_id = await _create_inbound_workflow_run(
|
||||
workflow_id,
|
||||
user_id,
|
||||
config.organization_id,
|
||||
provider_class.PROVIDER_NAME,
|
||||
normalized_data,
|
||||
telephony_configuration_id=telephony_configuration_id,
|
||||
from_phone_number_id=phone_row.id,
|
||||
)
|
||||
quota_result = await authorize_workflow_run_start(
|
||||
workflow_id=workflow_id,
|
||||
organization_id=config.organization_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
)
|
||||
if not quota_result.has_quota:
|
||||
logger.warning(
|
||||
f"User {user_id} has exceeded quota: {quota_result.error_message}"
|
||||
try:
|
||||
concurrency_slot = await call_concurrency.acquire_org_slot(
|
||||
config.organization_id,
|
||||
source=f"inbound:{provider_class.PROVIDER_NAME}",
|
||||
timeout=0,
|
||||
)
|
||||
except CallConcurrencyLimitError:
|
||||
return provider_class.generate_validation_error_response(
|
||||
TelephonyError.QUOTA_EXCEEDED
|
||||
TelephonyError.CONCURRENT_CALL_LIMIT
|
||||
)
|
||||
|
||||
backend_endpoint, wss_backend_endpoint = await get_backend_endpoints()
|
||||
websocket_url = (
|
||||
f"{wss_backend_endpoint}/api/v1/telephony/ws/"
|
||||
f"{workflow_id}/{user_id}/{workflow_run_id}"
|
||||
)
|
||||
workflow_run_id = None
|
||||
try:
|
||||
# 5. Create workflow run + authorize quota before returning provider
|
||||
# stream instructions.
|
||||
workflow_run_id = await _create_inbound_workflow_run(
|
||||
workflow_id,
|
||||
user_id,
|
||||
config.organization_id,
|
||||
provider_class.PROVIDER_NAME,
|
||||
normalized_data,
|
||||
telephony_configuration_id=telephony_configuration_id,
|
||||
from_phone_number_id=phone_row.id,
|
||||
)
|
||||
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run_id)
|
||||
|
||||
return await provider_instance.start_inbound_stream(
|
||||
websocket_url=websocket_url,
|
||||
workflow_run_id=workflow_run_id,
|
||||
normalized_data=normalized_data,
|
||||
backend_endpoint=backend_endpoint,
|
||||
)
|
||||
quota_result = await authorize_workflow_run_start(
|
||||
workflow_id=workflow_id,
|
||||
organization_id=config.organization_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
)
|
||||
if not quota_result.has_quota:
|
||||
logger.warning(
|
||||
f"User {user_id} has exceeded quota: {quota_result.error_message}"
|
||||
)
|
||||
await call_concurrency.release_workflow_run_slot(workflow_run_id)
|
||||
return provider_class.generate_validation_error_response(
|
||||
TelephonyError.QUOTA_EXCEEDED
|
||||
)
|
||||
|
||||
backend_endpoint, wss_backend_endpoint = await get_backend_endpoints()
|
||||
websocket_url = (
|
||||
f"{wss_backend_endpoint}/api/v1/telephony/ws/"
|
||||
f"{workflow_id}/{user_id}/{workflow_run_id}"
|
||||
)
|
||||
|
||||
return await provider_instance.start_inbound_stream(
|
||||
websocket_url=websocket_url,
|
||||
workflow_run_id=workflow_run_id,
|
||||
normalized_data=normalized_data,
|
||||
backend_endpoint=backend_endpoint,
|
||||
)
|
||||
except WorkflowRunSlotAlreadyBoundError:
|
||||
return provider_class.generate_validation_error_response(
|
||||
TelephonyError.CONCURRENT_CALL_LIMIT
|
||||
)
|
||||
except Exception:
|
||||
if workflow_run_id:
|
||||
await call_concurrency.release_workflow_run_slot(workflow_run_id)
|
||||
else:
|
||||
await call_concurrency.release_slot(concurrency_slot)
|
||||
raise
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"/inbound/run request parsing error: {e}")
|
||||
|
|
@ -919,40 +974,73 @@ async def handle_inbound_telephony(
|
|||
logger.error(f"Request validation failed: {error_type}")
|
||||
return provider_class.generate_validation_error_response(error_type)
|
||||
|
||||
# Create workflow run.
|
||||
user_id = workflow_context["user_id"]
|
||||
workflow_run_id = await _create_inbound_workflow_run(
|
||||
workflow_id,
|
||||
workflow_context["user_id"],
|
||||
workflow_context["organization_id"],
|
||||
workflow_context["provider"],
|
||||
normalized_data,
|
||||
telephony_configuration_id=workflow_context["telephony_configuration_id"],
|
||||
from_phone_number_id=workflow_context.get("from_phone_number_id"),
|
||||
)
|
||||
quota_result = await authorize_workflow_run_start(
|
||||
workflow_id=workflow_id,
|
||||
organization_id=workflow_context["organization_id"],
|
||||
workflow_run_id=workflow_run_id,
|
||||
)
|
||||
if not quota_result.has_quota:
|
||||
logger.warning(
|
||||
f"User {user_id} has exceeded quota for inbound calls: {quota_result.error_message}"
|
||||
organization_id = workflow_context["organization_id"]
|
||||
try:
|
||||
concurrency_slot = await call_concurrency.acquire_org_slot(
|
||||
organization_id,
|
||||
source=f"inbound_legacy:{workflow_context['provider']}",
|
||||
timeout=0,
|
||||
)
|
||||
except CallConcurrencyLimitError:
|
||||
return provider_class.generate_validation_error_response(
|
||||
TelephonyError.QUOTA_EXCEEDED
|
||||
TelephonyError.CONCURRENT_CALL_LIMIT
|
||||
)
|
||||
|
||||
# Generate response URLs
|
||||
backend_endpoint, wss_backend_endpoint = await get_backend_endpoints()
|
||||
websocket_url = f"{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{workflow_context['user_id']}/{workflow_run_id}"
|
||||
workflow_run_id = None
|
||||
try:
|
||||
# Create workflow run.
|
||||
workflow_run_id = await _create_inbound_workflow_run(
|
||||
workflow_id,
|
||||
workflow_context["user_id"],
|
||||
organization_id,
|
||||
workflow_context["provider"],
|
||||
normalized_data,
|
||||
telephony_configuration_id=workflow_context[
|
||||
"telephony_configuration_id"
|
||||
],
|
||||
from_phone_number_id=workflow_context.get("from_phone_number_id"),
|
||||
)
|
||||
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run_id)
|
||||
|
||||
response = await provider_instance.start_inbound_stream(
|
||||
websocket_url=websocket_url,
|
||||
workflow_run_id=workflow_run_id,
|
||||
normalized_data=normalized_data,
|
||||
backend_endpoint=backend_endpoint,
|
||||
)
|
||||
quota_result = await authorize_workflow_run_start(
|
||||
workflow_id=workflow_id,
|
||||
organization_id=organization_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
)
|
||||
if not quota_result.has_quota:
|
||||
logger.warning(
|
||||
f"User {user_id} has exceeded quota for inbound calls: "
|
||||
f"{quota_result.error_message}"
|
||||
)
|
||||
await call_concurrency.release_workflow_run_slot(workflow_run_id)
|
||||
return provider_class.generate_validation_error_response(
|
||||
TelephonyError.QUOTA_EXCEEDED
|
||||
)
|
||||
|
||||
# Generate response URLs
|
||||
backend_endpoint, wss_backend_endpoint = await get_backend_endpoints()
|
||||
websocket_url = (
|
||||
f"{wss_backend_endpoint}/api/v1/telephony/ws/"
|
||||
f"{workflow_id}/{workflow_context['user_id']}/{workflow_run_id}"
|
||||
)
|
||||
|
||||
response = await provider_instance.start_inbound_stream(
|
||||
websocket_url=websocket_url,
|
||||
workflow_run_id=workflow_run_id,
|
||||
normalized_data=normalized_data,
|
||||
backend_endpoint=backend_endpoint,
|
||||
)
|
||||
except WorkflowRunSlotAlreadyBoundError:
|
||||
return provider_class.generate_validation_error_response(
|
||||
TelephonyError.CONCURRENT_CALL_LIMIT
|
||||
)
|
||||
except Exception:
|
||||
if workflow_run_id:
|
||||
await call_concurrency.release_workflow_run_slot(workflow_run_id)
|
||||
else:
|
||||
await call_concurrency.release_slot(concurrency_slot)
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
f"Generated {normalized_data.provider} response for call {normalized_data.call_id}"
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ from api.routes.turn_credentials import (
|
|||
generate_turn_credentials,
|
||||
)
|
||||
from api.services.auth.depends import get_user_ws
|
||||
from api.services.call_concurrency import (
|
||||
CallConcurrencyLimitError,
|
||||
WorkflowRunSlotAlreadyBoundError,
|
||||
call_concurrency,
|
||||
)
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_smallwebrtc
|
||||
from api.services.pipecat.ws_sender_registry import (
|
||||
register_ws_sender,
|
||||
|
|
@ -322,6 +327,8 @@ class SignalingManager:
|
|||
workflow_run_id: int,
|
||||
user: UserModel,
|
||||
organization_id: int,
|
||||
enforce_call_concurrency: bool = False,
|
||||
call_concurrency_source: str = "webrtc",
|
||||
):
|
||||
"""Handle WebSocket connection for signaling."""
|
||||
await websocket.accept()
|
||||
|
|
@ -340,6 +347,8 @@ class SignalingManager:
|
|||
user,
|
||||
organization_id,
|
||||
connection_key,
|
||||
enforce_call_concurrency,
|
||||
call_concurrency_source,
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"WebSocket disconnected for {connection_id}")
|
||||
|
|
@ -382,6 +391,8 @@ class SignalingManager:
|
|||
user: UserModel,
|
||||
organization_id: int,
|
||||
connection_key: str,
|
||||
enforce_call_concurrency: bool,
|
||||
call_concurrency_source: str = "webrtc",
|
||||
):
|
||||
"""Handle incoming WebSocket messages."""
|
||||
msg_type = message.get("type")
|
||||
|
|
@ -396,6 +407,8 @@ class SignalingManager:
|
|||
user,
|
||||
organization_id,
|
||||
connection_key,
|
||||
enforce_call_concurrency,
|
||||
call_concurrency_source,
|
||||
)
|
||||
elif msg_type == "ice-candidate":
|
||||
await self._handle_ice_candidate(payload, connection_key)
|
||||
|
|
@ -411,6 +424,8 @@ class SignalingManager:
|
|||
user: UserModel,
|
||||
organization_id: int,
|
||||
connection_key: str,
|
||||
enforce_call_concurrency: bool,
|
||||
call_concurrency_source: str = "webrtc",
|
||||
):
|
||||
"""Handle offer message and create answer with ICE trickling."""
|
||||
pc_id = payload.get("pc_id")
|
||||
|
|
@ -481,70 +496,124 @@ class SignalingManager:
|
|||
}
|
||||
)
|
||||
else:
|
||||
concurrency_slot = None
|
||||
concurrency_bound = False
|
||||
pipeline_started = False
|
||||
pc = None
|
||||
if enforce_call_concurrency:
|
||||
try:
|
||||
concurrency_slot = await call_concurrency.acquire_org_slot(
|
||||
organization_id,
|
||||
source=call_concurrency_source,
|
||||
timeout=0,
|
||||
)
|
||||
await call_concurrency.bind_workflow_run(
|
||||
concurrency_slot,
|
||||
workflow_run_id,
|
||||
)
|
||||
concurrency_bound = True
|
||||
except CallConcurrencyLimitError:
|
||||
await ws.send_json(
|
||||
{
|
||||
"type": "error",
|
||||
"payload": {
|
||||
"error_type": "concurrency_limit_exceeded",
|
||||
"message": "Concurrent call limit reached",
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
except WorkflowRunSlotAlreadyBoundError:
|
||||
await ws.send_json(
|
||||
{
|
||||
"type": "error",
|
||||
"payload": {
|
||||
"error_type": "workflow_run_already_active",
|
||||
"message": "Workflow run already has an active call",
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Create new connection using correct SmallWebRTC API
|
||||
# Generate ICE servers with time-limited TURN credentials for this user
|
||||
user_ice_servers = get_ice_servers(user_id=str(user.id))
|
||||
pc = SmallWebRTCConnection(
|
||||
ice_servers=user_ice_servers, connection_timeout_secs=60
|
||||
)
|
||||
# Set the pc_id before initialization so it's available in get_answer()
|
||||
pc._pc_id = pc_id
|
||||
|
||||
# Initialize connection with offer
|
||||
await pc.initialize(sdp=sdp, type=type_)
|
||||
|
||||
# Store peer connection using client's pc_id
|
||||
self._track_peer_connection(connection_key, pc_id, pc)
|
||||
|
||||
# Register WebSocket sender for real-time feedback
|
||||
async def ws_sender(message: dict):
|
||||
if ws.application_state == WebSocketState.CONNECTED:
|
||||
await ws.send_json(message)
|
||||
|
||||
register_ws_sender(workflow_run_id, ws_sender)
|
||||
|
||||
# Setup closed handler
|
||||
@pc.event_handler("closed")
|
||||
async def handle_disconnected(webrtc_connection: SmallWebRTCConnection):
|
||||
logger.info(f"PeerConnection closed: {webrtc_connection.pc_id}")
|
||||
owner_connection_id = self._forget_peer_connection(
|
||||
webrtc_connection.pc_id
|
||||
try:
|
||||
user_ice_servers = get_ice_servers(user_id=str(user.id))
|
||||
pc = SmallWebRTCConnection(
|
||||
ice_servers=user_ice_servers, connection_timeout_secs=60
|
||||
)
|
||||
if owner_connection_id == connection_key:
|
||||
await self._notify_call_ended_and_close_websocket(
|
||||
ws,
|
||||
workflow_run_id,
|
||||
webrtc_connection.pc_id,
|
||||
reason="peer_connection_closed",
|
||||
# Set the pc_id before initialization so it's available in get_answer()
|
||||
pc._pc_id = pc_id
|
||||
|
||||
# Initialize connection with offer
|
||||
await pc.initialize(sdp=sdp, type=type_)
|
||||
|
||||
# Store peer connection using client's pc_id
|
||||
self._track_peer_connection(connection_key, pc_id, pc)
|
||||
|
||||
# Register WebSocket sender for real-time feedback
|
||||
async def ws_sender(message: dict):
|
||||
if ws.application_state == WebSocketState.CONNECTED:
|
||||
await ws.send_json(message)
|
||||
|
||||
register_ws_sender(workflow_run_id, ws_sender)
|
||||
|
||||
# Setup closed handler
|
||||
@pc.event_handler("closed")
|
||||
async def handle_disconnected(
|
||||
webrtc_connection: SmallWebRTCConnection,
|
||||
):
|
||||
logger.info(f"PeerConnection closed: {webrtc_connection.pc_id}")
|
||||
owner_connection_id = self._forget_peer_connection(
|
||||
webrtc_connection.pc_id
|
||||
)
|
||||
if owner_connection_id == connection_key:
|
||||
await self._notify_call_ended_and_close_websocket(
|
||||
ws,
|
||||
workflow_run_id,
|
||||
webrtc_connection.pc_id,
|
||||
reason="peer_connection_closed",
|
||||
)
|
||||
|
||||
# Start pipeline in background
|
||||
asyncio.create_task(
|
||||
run_pipeline_smallwebrtc(
|
||||
pc,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
user.id,
|
||||
call_context_vars,
|
||||
user_provider_id=str(user.provider_id),
|
||||
organization_id=organization_id,
|
||||
# Start pipeline in background
|
||||
asyncio.create_task(
|
||||
run_pipeline_smallwebrtc(
|
||||
pc,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
user.id,
|
||||
call_context_vars,
|
||||
user_provider_id=str(user.provider_id),
|
||||
organization_id=organization_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
pipeline_started = True
|
||||
|
||||
# Get answer after initialization
|
||||
answer = pc.get_answer()
|
||||
# Get answer after initialization
|
||||
answer = pc.get_answer()
|
||||
|
||||
# Send answer immediately (ICE candidates will be sent separately via trickling)
|
||||
await ws.send_json(
|
||||
{
|
||||
"type": "answer",
|
||||
"payload": {
|
||||
"sdp": filter_outbound_sdp(answer["sdp"]),
|
||||
"type": answer["type"],
|
||||
"pc_id": answer["pc_id"],
|
||||
},
|
||||
}
|
||||
)
|
||||
# Send answer immediately (ICE candidates will be sent separately via trickling)
|
||||
await ws.send_json(
|
||||
{
|
||||
"type": "answer",
|
||||
"payload": {
|
||||
"sdp": filter_outbound_sdp(answer["sdp"]),
|
||||
"type": answer["type"],
|
||||
"pc_id": answer["pc_id"],
|
||||
},
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
if pipeline_started and pc is not None:
|
||||
try:
|
||||
await pc.disconnect()
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to disconnect failed offer pc: {e}")
|
||||
elif concurrency_bound:
|
||||
await call_concurrency.release_workflow_run_slot(workflow_run_id)
|
||||
elif concurrency_slot is not None:
|
||||
await call_concurrency.release_slot(concurrency_slot)
|
||||
raise
|
||||
|
||||
async def _handle_ice_candidate(self, payload: dict, connection_key: str):
|
||||
"""Handle incoming ICE candidate from client.
|
||||
|
|
@ -665,6 +734,8 @@ async def signaling_websocket(
|
|||
workflow_run_id,
|
||||
user,
|
||||
user.selected_organization_id,
|
||||
enforce_call_concurrency=True,
|
||||
call_concurrency_source="webrtc",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -737,4 +808,6 @@ async def public_signaling_websocket(
|
|||
embed_session.workflow_run_id,
|
||||
user,
|
||||
embed_token.organization_id,
|
||||
enforce_call_concurrency=True,
|
||||
call_concurrency_source="public_embed",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from api.db.workflow_template_client import WorkflowTemplateClient
|
|||
from api.enums import CallType, PostHogEvent, StorageBackend, WorkflowStatus
|
||||
from api.schemas.ai_model_configuration import OrganizationAIModelConfigurationV2
|
||||
from api.schemas.workflow import WorkflowRunResponseSchema
|
||||
from api.schemas.workflow_configurations import WorkflowConfigurationDefaults
|
||||
from api.sdk_expose import sdk_expose
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.configuration.ai_model_configuration import (
|
||||
|
|
@ -284,7 +285,10 @@ class UpdateWorkflowRequest(BaseModel):
|
|||
name: str | None = None
|
||||
workflow_definition: dict | None = None
|
||||
template_context_variables: dict | None = None
|
||||
workflow_configurations: dict | None = None
|
||||
# Typed so field constraints (e.g. the max_call_duration cap) are
|
||||
# enforced by FastAPI; extra="allow" keeps passthrough keys like
|
||||
# model_configuration_v2_override intact.
|
||||
workflow_configurations: WorkflowConfigurationDefaults | None = None
|
||||
|
||||
|
||||
class WorkflowVersionResponse(BaseModel):
|
||||
|
|
@ -1039,7 +1043,13 @@ async def update_workflow(
|
|||
|
||||
# Validate model overrides. v2 uses a complete workflow-level model
|
||||
# configuration; legacy v1 uses partial service overlays.
|
||||
workflow_configurations = request.workflow_configurations
|
||||
# exclude_unset keeps stored configs sparse: keys the request didn't
|
||||
# send stay absent so runtime defaults keep applying to them.
|
||||
workflow_configurations = (
|
||||
request.workflow_configurations.model_dump(exclude_unset=True)
|
||||
if request.workflow_configurations is not None
|
||||
else None
|
||||
)
|
||||
if workflow_configurations and workflow_configurations.get(
|
||||
WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY
|
||||
):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue