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:
Abhishek Kumar 2026-07-09 18:38:40 +05:30
commit d22c073cb5
38 changed files with 2332 additions and 471 deletions

View file

@ -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)

View file

@ -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}",

View file

@ -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}"

View file

@ -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",
)

View file

@ -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
):