mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
fix: fix org scoped access for resources
This commit is contained in:
parent
f3bcf24370
commit
e4b53f78e9
47 changed files with 2667 additions and 400 deletions
|
|
@ -101,7 +101,10 @@ class OrganizationClient(BaseDBClient):
|
|||
select(
|
||||
exists().where(
|
||||
(organization_users_association.c.user_id == user_id)
|
||||
& (organization_users_association.c.organization_id == organization_id)
|
||||
& (
|
||||
organization_users_association.c.organization_id
|
||||
== organization_id
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,13 +13,10 @@ from api.db.models import (
|
|||
OrganizationConfigurationModel,
|
||||
OrganizationModel,
|
||||
OrganizationUsageCycleModel,
|
||||
UserConfigurationModel,
|
||||
UserModel,
|
||||
WorkflowModel,
|
||||
WorkflowRunModel,
|
||||
)
|
||||
from api.enums import OrganizationConfigurationKey, UserConfigurationKey
|
||||
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
|
||||
from api.enums import OrganizationConfigurationKey
|
||||
from api.utils.recording_artifacts import get_recording_storage_key
|
||||
|
||||
|
||||
|
|
@ -139,9 +136,8 @@ class OrganizationUsageClient(BaseDBClient):
|
|||
query = (
|
||||
select(WorkflowRunModel)
|
||||
.join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id)
|
||||
.join(UserModel, WorkflowModel.user_id == UserModel.id)
|
||||
.where(
|
||||
UserModel.selected_organization_id == organization_id,
|
||||
WorkflowModel.organization_id == organization_id,
|
||||
WorkflowRunModel.usage_info.isnot(None),
|
||||
)
|
||||
.order_by(WorkflowRunModel.created_at.desc())
|
||||
|
|
@ -277,9 +273,8 @@ class OrganizationUsageClient(BaseDBClient):
|
|||
WorkflowRunModel.public_access_token,
|
||||
)
|
||||
.join(WorkflowModel, WorkflowRunModel.workflow_id == WorkflowModel.id)
|
||||
.join(UserModel, WorkflowModel.user_id == UserModel.id)
|
||||
.where(
|
||||
UserModel.selected_organization_id == organization_id,
|
||||
WorkflowModel.organization_id == organization_id,
|
||||
WorkflowRunModel.usage_info.isnot(None),
|
||||
)
|
||||
.order_by(WorkflowRunModel.created_at.desc())
|
||||
|
|
@ -316,7 +311,6 @@ class OrganizationUsageClient(BaseDBClient):
|
|||
start_date: datetime,
|
||||
end_date: datetime,
|
||||
price_per_second_usd: float,
|
||||
user_id: Optional[int] = None,
|
||||
) -> dict:
|
||||
"""Get daily usage breakdown for an organization with pricing."""
|
||||
|
||||
|
|
@ -344,22 +338,6 @@ class OrganizationUsageClient(BaseDBClient):
|
|||
if pref_obj and pref_obj.value:
|
||||
user_timezone = pref_obj.value.get("timezone") or user_timezone
|
||||
|
||||
if user_id:
|
||||
config_result = await session.execute(
|
||||
select(UserConfigurationModel).where(
|
||||
UserConfigurationModel.user_id == user_id,
|
||||
UserConfigurationModel.key
|
||||
== UserConfigurationKey.MODEL_CONFIGURATION.value,
|
||||
)
|
||||
)
|
||||
config_obj = config_result.scalar_one_or_none()
|
||||
if config_obj and config_obj.configuration:
|
||||
effective_config = EffectiveAIModelConfiguration.model_validate(
|
||||
config_obj.configuration
|
||||
)
|
||||
if effective_config.timezone and user_timezone == "UTC":
|
||||
user_timezone = effective_config.timezone
|
||||
|
||||
# Validate timezone string
|
||||
try:
|
||||
# Test if timezone is valid
|
||||
|
|
@ -382,9 +360,8 @@ class OrganizationUsageClient(BaseDBClient):
|
|||
func.count(WorkflowRunModel.id).label("call_count"),
|
||||
)
|
||||
.join(WorkflowModel, WorkflowModel.id == WorkflowRunModel.workflow_id)
|
||||
.join(UserModel, UserModel.id == WorkflowModel.user_id)
|
||||
.where(
|
||||
UserModel.selected_organization_id == organization_id,
|
||||
WorkflowModel.organization_id == organization_id,
|
||||
WorkflowRunModel.created_at >= start_date,
|
||||
WorkflowRunModel.created_at <= end_date,
|
||||
WorkflowRunModel.is_completed == True,
|
||||
|
|
|
|||
|
|
@ -606,11 +606,9 @@ class WorkflowClient(BaseDBClient):
|
|||
"""Get workflows by IDs for a specific organization"""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(WorkflowModel)
|
||||
.join(WorkflowModel.user)
|
||||
.where(
|
||||
select(WorkflowModel).where(
|
||||
WorkflowModel.id.in_(workflow_ids),
|
||||
WorkflowModel.user.has(selected_organization_id=organization_id),
|
||||
WorkflowModel.organization_id == organization_id,
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
|
|
|||
|
|
@ -40,14 +40,14 @@ class WorkflowRunClient(BaseDBClient):
|
|||
workflow_query = (
|
||||
select(WorkflowModel)
|
||||
.options(joinedload(WorkflowModel.user))
|
||||
.where(
|
||||
WorkflowModel.id == workflow_id, WorkflowModel.user_id == user_id
|
||||
)
|
||||
.where(WorkflowModel.id == workflow_id)
|
||||
)
|
||||
if organization_id is not None:
|
||||
workflow_query = workflow_query.where(
|
||||
WorkflowModel.organization_id == organization_id
|
||||
)
|
||||
elif user_id is not None:
|
||||
workflow_query = workflow_query.where(WorkflowModel.user_id == user_id)
|
||||
|
||||
workflow = await session.execute(workflow_query)
|
||||
workflow = workflow.scalars().first()
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ async def agent_stream_websocket(
|
|||
user_id=workflow.user_id,
|
||||
call_type=CallType.INBOUND,
|
||||
initial_context=initial_context,
|
||||
organization_id=workflow.organization_id,
|
||||
)
|
||||
|
||||
set_current_run_id(workflow_run.id)
|
||||
|
|
@ -72,6 +73,7 @@ async def agent_stream_websocket(
|
|||
|
||||
quota_result = await authorize_workflow_run_start(
|
||||
workflow_id=workflow.id,
|
||||
organization_id=workflow.organization_id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
)
|
||||
if not quota_result.has_quota:
|
||||
|
|
|
|||
|
|
@ -351,9 +351,12 @@ async def create_campaign(
|
|||
) -> CampaignResponse:
|
||||
"""Create a new campaign"""
|
||||
# Verify workflow exists and belongs to organization
|
||||
workflow_name = await db_client.get_workflow_name(request.workflow_id, user.id)
|
||||
if not workflow_name:
|
||||
workflow = await db_client.get_workflow(
|
||||
request.workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
if not workflow:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
workflow_name = workflow.name
|
||||
|
||||
# Validate source data (phone_number column and format)
|
||||
sync_service = get_sync_service(request.source_type)
|
||||
|
|
@ -364,9 +367,6 @@ async def create_campaign(
|
|||
raise HTTPException(status_code=400, detail=validation_result.error.message)
|
||||
|
||||
# Validate template variables against source data columns
|
||||
workflow = await db_client.get_workflow(
|
||||
request.workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
if workflow:
|
||||
from api.services.workflow.dto import ReactFlowDTO
|
||||
from api.services.workflow.workflow_graph import WorkflowGraph
|
||||
|
|
@ -512,7 +512,9 @@ async def get_campaign(
|
|||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
|
||||
workflow_name = await db_client.get_workflow_name(
|
||||
campaign.workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
|
||||
executed, total = await _get_campaign_stats(campaign.id)
|
||||
cfg_name = await _get_telephony_configuration_name(
|
||||
|
|
@ -552,6 +554,7 @@ async def start_campaign(
|
|||
# model_overrides so we evaluate the keys this campaign will use).
|
||||
quota_result = await authorize_workflow_run_start(
|
||||
workflow_id=campaign.workflow_id,
|
||||
organization_id=user.selected_organization_id,
|
||||
actor_user=user,
|
||||
)
|
||||
if not quota_result.has_quota:
|
||||
|
|
@ -565,7 +568,9 @@ async def start_campaign(
|
|||
|
||||
# Get updated campaign
|
||||
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
|
||||
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
|
||||
workflow_name = await db_client.get_workflow_name(
|
||||
campaign.workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
|
||||
executed, total = await _get_campaign_stats(campaign.id)
|
||||
cfg_name = await _get_telephony_configuration_name(
|
||||
|
|
@ -599,7 +604,9 @@ async def pause_campaign(
|
|||
|
||||
# Get updated campaign
|
||||
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
|
||||
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
|
||||
workflow_name = await db_client.get_workflow_name(
|
||||
campaign.workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
|
||||
executed, total = await _get_campaign_stats(campaign.id)
|
||||
cfg_name = await _get_telephony_configuration_name(
|
||||
|
|
@ -669,7 +676,9 @@ async def update_campaign(
|
|||
|
||||
# Re-fetch to return updated data
|
||||
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
|
||||
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
|
||||
workflow_name = await db_client.get_workflow_name(
|
||||
campaign.workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
|
||||
executed, total = await _get_campaign_stats(campaign.id)
|
||||
cfg_name = await _get_telephony_configuration_name(
|
||||
|
|
@ -838,7 +847,9 @@ async def redial_campaign(
|
|||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
workflow_name = await db_client.get_workflow_name(child.workflow_id, user.id)
|
||||
workflow_name = await db_client.get_workflow_name(
|
||||
child.workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
executed, total = await _get_campaign_stats(child.id)
|
||||
cfg_name = await _get_telephony_configuration_name(
|
||||
child.telephony_configuration_id, user.selected_organization_id
|
||||
|
|
@ -877,6 +888,7 @@ async def resume_campaign(
|
|||
# model_overrides so we evaluate the keys this campaign will use).
|
||||
quota_result = await authorize_workflow_run_start(
|
||||
workflow_id=campaign.workflow_id,
|
||||
organization_id=user.selected_organization_id,
|
||||
actor_user=user,
|
||||
)
|
||||
if not quota_result.has_quota:
|
||||
|
|
@ -890,7 +902,9 @@ async def resume_campaign(
|
|||
|
||||
# Get updated campaign
|
||||
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
|
||||
workflow_name = await db_client.get_workflow_name(campaign.workflow_id, user.id)
|
||||
workflow_name = await db_client.get_workflow_name(
|
||||
campaign.workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
|
||||
executed, total = await _get_campaign_stats(campaign.id)
|
||||
cfg_name = await _get_telephony_configuration_name(
|
||||
|
|
|
|||
|
|
@ -375,9 +375,8 @@ async def search_chunks(
|
|||
)
|
||||
from api.services.gen_ai import build_embedding_service
|
||||
|
||||
# Try to get user's embeddings configuration
|
||||
# Try to get the organization's embeddings configuration
|
||||
resolved_config = await get_resolved_ai_model_configuration(
|
||||
user_id=user.id,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
effective_config = resolved_config.effective
|
||||
|
|
|
|||
|
|
@ -243,7 +243,6 @@ async def _model_configuration_v2_response(
|
|||
configuration: OrganizationAIModelConfigurationV2 | None = None,
|
||||
) -> OrganizationAIModelConfigurationResponse:
|
||||
resolved = await get_resolved_ai_model_configuration(
|
||||
user_id=user.id,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
raw_configuration = (
|
||||
|
|
|
|||
|
|
@ -512,7 +512,6 @@ async def get_daily_usage_breakdown(
|
|||
start_date,
|
||||
end_date,
|
||||
org.price_per_second_usd,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
return breakdown
|
||||
|
|
|
|||
|
|
@ -264,6 +264,7 @@ async def _execute_resolved_target(
|
|||
# the MPS correlation id before the provider starts the call.
|
||||
quota_result = await authorize_workflow_run_start(
|
||||
workflow_id=target.workflow.id,
|
||||
organization_id=target.organization_id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
)
|
||||
if not quota_result.has_quota:
|
||||
|
|
|
|||
|
|
@ -309,6 +309,7 @@ async def initialize_embed_session(
|
|||
workflow_id=embed_token.workflow_id,
|
||||
mode=WorkflowRunMode.SMALLWEBRTC.value,
|
||||
user_id=embed_token.created_by, # Use token creator as run owner
|
||||
organization_id=embed_token.organization_id,
|
||||
initial_context={
|
||||
**(init_request.context_variables or {}),
|
||||
"provider": WorkflowRunMode.SMALLWEBRTC.value,
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@ async def initiate_call(
|
|||
# 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,
|
||||
)
|
||||
|
|
@ -421,6 +422,7 @@ async def _validate_inbound_request(
|
|||
async def _create_inbound_workflow_run(
|
||||
workflow_id: int,
|
||||
user_id: int,
|
||||
organization_id: int,
|
||||
provider: str,
|
||||
normalized_data,
|
||||
telephony_configuration_id: int,
|
||||
|
|
@ -456,6 +458,7 @@ async def _create_inbound_workflow_run(
|
|||
"raw_webhook_data": normalized_data.raw_data,
|
||||
},
|
||||
},
|
||||
organization_id=organization_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
|
@ -562,6 +565,20 @@ async def _handle_telephony_websocket(
|
|||
logger.error(f"Workflow {workflow_id} not found")
|
||||
await websocket.close(code=4404, reason="Workflow not found")
|
||||
return
|
||||
if workflow_run.workflow_id != workflow.id:
|
||||
logger.error(
|
||||
f"Workflow run {workflow_run_id} belongs to workflow "
|
||||
f"{workflow_run.workflow_id}, not {workflow.id}"
|
||||
)
|
||||
await websocket.close(code=4400, reason="workflow_run_workflow_mismatch")
|
||||
return
|
||||
if workflow.user_id != user_id:
|
||||
logger.error(
|
||||
f"Telephony websocket user mismatch for workflow {workflow.id}: "
|
||||
f"got {user_id}, expected {workflow.user_id}"
|
||||
)
|
||||
await websocket.close(code=4400, reason="workflow_user_mismatch")
|
||||
return
|
||||
|
||||
# Check workflow run state - only allow 'initialized' state
|
||||
if workflow_run.state != WorkflowRunState.INITIALIZED.value:
|
||||
|
|
@ -770,6 +787,7 @@ async def handle_inbound_run(request: Request):
|
|||
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,
|
||||
|
|
@ -777,6 +795,7 @@ async def handle_inbound_run(request: Request):
|
|||
)
|
||||
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:
|
||||
|
|
@ -905,6 +924,7 @@ async def handle_inbound_telephony(
|
|||
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"],
|
||||
|
|
@ -912,6 +932,7 @@ async def handle_inbound_telephony(
|
|||
)
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ from api.schemas.workflow_configurations import (
|
|||
)
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.configuration.ai_model_configuration import (
|
||||
convert_legacy_ai_model_configuration_to_v2,
|
||||
get_resolved_ai_model_configuration,
|
||||
upsert_organization_ai_model_configuration_v2,
|
||||
)
|
||||
from api.services.configuration.check_validity import (
|
||||
APIKeyStatusResponse,
|
||||
|
|
@ -110,7 +112,6 @@ async def get_user_configurations(
|
|||
user: UserModel = Depends(get_user),
|
||||
) -> UserConfigurationRequestResponseSchema:
|
||||
resolved_config = await get_resolved_ai_model_configuration(
|
||||
user_id=user.id,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
masked_config = mask_user_config(resolved_config.effective)
|
||||
|
|
@ -139,7 +140,11 @@ async def update_user_configurations(
|
|||
request: UserConfigurationRequestResponseSchema,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> UserConfigurationRequestResponseSchema:
|
||||
existing_config = await db_client.get_user_configurations(user.id)
|
||||
existing_config = (
|
||||
await get_resolved_ai_model_configuration(
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
).effective
|
||||
|
||||
incoming_dict = request.model_dump(exclude_none=True)
|
||||
|
||||
|
|
@ -152,6 +157,9 @@ async def update_user_configurations(
|
|||
}
|
||||
|
||||
if incoming_dict:
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
# Merge via helper
|
||||
try:
|
||||
user_configurations = merge_user_configurations(
|
||||
|
|
@ -175,8 +183,16 @@ async def update_user_configurations(
|
|||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=e.args[0])
|
||||
|
||||
user_configurations = await db_client.update_user_configuration(
|
||||
user.id, user_configurations
|
||||
try:
|
||||
organization_configuration = convert_legacy_ai_model_configuration_to_v2(
|
||||
user_configurations
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
|
||||
await upsert_organization_ai_model_configuration_v2(
|
||||
user.selected_organization_id,
|
||||
organization_configuration,
|
||||
)
|
||||
else:
|
||||
user_configurations = existing_config
|
||||
|
|
@ -235,14 +251,13 @@ async def validate_user_configurations(
|
|||
user: UserModel = Depends(get_user),
|
||||
) -> APIKeyStatusResponse:
|
||||
resolved_config = await get_resolved_ai_model_configuration(
|
||||
user_id=user.id,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
configurations = resolved_config.effective
|
||||
|
||||
if (
|
||||
configurations.last_validated_at
|
||||
and configurations.last_validated_at
|
||||
not configurations.last_validated_at
|
||||
or configurations.last_validated_at
|
||||
< datetime.now() - timedelta(seconds=validity_ttl_seconds)
|
||||
):
|
||||
validator = UserConfigurationValidator()
|
||||
|
|
@ -252,7 +267,6 @@ async def validate_user_configurations(
|
|||
organization_id=user.selected_organization_id,
|
||||
created_by=user.provider_id,
|
||||
)
|
||||
await db_client.update_user_configuration_last_validated_at(user.id)
|
||||
return status
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=e.args[0])
|
||||
|
|
|
|||
|
|
@ -321,6 +321,7 @@ class SignalingManager:
|
|||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user: UserModel,
|
||||
organization_id: int,
|
||||
):
|
||||
"""Handle WebSocket connection for signaling."""
|
||||
await websocket.accept()
|
||||
|
|
@ -337,6 +338,7 @@ class SignalingManager:
|
|||
workflow_id,
|
||||
workflow_run_id,
|
||||
user,
|
||||
organization_id,
|
||||
connection_key,
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
|
|
@ -378,6 +380,7 @@ class SignalingManager:
|
|||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user: UserModel,
|
||||
organization_id: int,
|
||||
connection_key: str,
|
||||
):
|
||||
"""Handle incoming WebSocket messages."""
|
||||
|
|
@ -386,7 +389,13 @@ class SignalingManager:
|
|||
|
||||
if msg_type == "offer":
|
||||
await self._handle_offer(
|
||||
ws, payload, workflow_id, workflow_run_id, user, connection_key
|
||||
ws,
|
||||
payload,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
user,
|
||||
organization_id,
|
||||
connection_key,
|
||||
)
|
||||
elif msg_type == "ice-candidate":
|
||||
await self._handle_ice_candidate(payload, connection_key)
|
||||
|
|
@ -400,6 +409,7 @@ class SignalingManager:
|
|||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user: UserModel,
|
||||
organization_id: int,
|
||||
connection_key: str,
|
||||
):
|
||||
"""Handle offer message and create answer with ICE trickling."""
|
||||
|
|
@ -420,14 +430,13 @@ class SignalingManager:
|
|||
# Set run context for logging and tracing. org_id must be set before
|
||||
# pc.initialize() so that aiortc's internal tasks inherit it.
|
||||
set_current_run_id(workflow_run_id)
|
||||
org_id = await db_client.get_workflow_organization_id(workflow_id)
|
||||
if org_id:
|
||||
set_current_org_id(org_id)
|
||||
set_current_org_id(organization_id)
|
||||
|
||||
# Check Dograh quota before initiating the call (apply per-workflow
|
||||
# model_overrides so we evaluate the keys this workflow will use).
|
||||
quota_result = await authorize_workflow_run_start(
|
||||
workflow_id=workflow_id,
|
||||
organization_id=organization_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
actor_user=user,
|
||||
)
|
||||
|
|
@ -518,6 +527,7 @@ class SignalingManager:
|
|||
user.id,
|
||||
call_context_vars,
|
||||
user_provider_id=str(user.provider_id),
|
||||
organization_id=organization_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -630,13 +640,31 @@ async def signaling_websocket(
|
|||
user: UserModel = Depends(get_user_ws),
|
||||
):
|
||||
"""WebSocket endpoint for WebRTC signaling with ICE trickling."""
|
||||
workflow_run = await db_client.get_workflow_run(workflow_run_id, user.id)
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
workflow_run = await db_client.get_workflow_run(
|
||||
workflow_run_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
if not workflow_run:
|
||||
logger.warning(f"workflow run {workflow_run_id} not found for user {user.id}")
|
||||
logger.warning(
|
||||
f"workflow run {workflow_run_id} not found for org "
|
||||
f"{user.selected_organization_id}"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Bad workflow_run_id")
|
||||
if workflow_run.workflow_id != workflow_id:
|
||||
logger.warning(
|
||||
f"workflow run {workflow_run_id} belongs to workflow "
|
||||
f"{workflow_run.workflow_id}, not {workflow_id}"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="workflow_run_workflow_mismatch")
|
||||
|
||||
await signaling_manager.handle_websocket(
|
||||
websocket, workflow_id, workflow_run_id, user
|
||||
websocket,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
user,
|
||||
user.selected_organization_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -670,6 +698,17 @@ async def public_signaling_websocket(
|
|||
await websocket.close(code=1008, reason="Invalid embed token")
|
||||
return
|
||||
|
||||
workflow_run = await db_client.get_workflow_run(
|
||||
embed_session.workflow_run_id,
|
||||
organization_id=embed_token.organization_id,
|
||||
)
|
||||
if not workflow_run:
|
||||
await websocket.close(code=1008, reason="Invalid workflow run")
|
||||
return
|
||||
if workflow_run.workflow_id != embed_token.workflow_id:
|
||||
await websocket.close(code=1008, reason="workflow_run_workflow_mismatch")
|
||||
return
|
||||
|
||||
# Enforce the embed token's allowed-domain policy on the public signaling
|
||||
# path, mirroring the HTTP embed endpoints (issue #330). Without this a
|
||||
# leaked or replayed session token could attach from an arbitrary origin.
|
||||
|
|
@ -693,5 +732,9 @@ async def public_signaling_websocket(
|
|||
|
||||
# Handle the WebSocket connection using the existing signaling manager
|
||||
await signaling_manager.handle_websocket(
|
||||
websocket, embed_token.workflow_id, embed_session.workflow_run_id, user
|
||||
websocket,
|
||||
embed_token.workflow_id,
|
||||
embed_session.workflow_run_id,
|
||||
user,
|
||||
embed_token.organization_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1080,7 +1080,6 @@ async def update_workflow(
|
|||
)
|
||||
if existing_v2_override_config is None:
|
||||
resolved_config = await get_resolved_ai_model_configuration(
|
||||
user_id=user.id,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
v2_override = merge_ai_model_configuration_v2_secrets(
|
||||
|
|
@ -1123,7 +1122,6 @@ async def update_workflow(
|
|||
existing_configs,
|
||||
)
|
||||
resolved_config = await get_resolved_ai_model_configuration(
|
||||
user_id=user.id,
|
||||
organization_id=user.selected_organization_id,
|
||||
)
|
||||
effective_config = resolved_config.effective
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ async def _ensure_text_chat_quota(
|
|||
) -> None:
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -303,6 +303,7 @@ class CampaignCallDispatcher:
|
|||
initial_context=initial_context,
|
||||
campaign_id=campaign.id,
|
||||
queued_run_id=queued_run.id, # Link to queued run for retry tracking
|
||||
organization_id=campaign.organization_id,
|
||||
)
|
||||
|
||||
# Store slot_id mapping in Redis for cleanup later
|
||||
|
|
@ -342,6 +343,7 @@ class CampaignCallDispatcher:
|
|||
|
||||
quota_result = await authorize_workflow_run_start(
|
||||
workflow_id=campaign.workflow_id,
|
||||
organization_id=campaign.organization_id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
)
|
||||
if not quota_result.has_quota:
|
||||
|
|
|
|||
|
|
@ -55,9 +55,9 @@ class WorkflowAIModelConfigurationMigrationResult:
|
|||
|
||||
async def get_resolved_ai_model_configuration(
|
||||
*,
|
||||
user_id: int | None,
|
||||
organization_id: int | None,
|
||||
) -> ResolvedAIModelConfiguration:
|
||||
"""Resolve the effective model configuration for an organization."""
|
||||
organization_configuration = await get_organization_ai_model_configuration_v2(
|
||||
organization_id
|
||||
)
|
||||
|
|
@ -68,22 +68,14 @@ async def get_resolved_ai_model_configuration(
|
|||
organization_configuration=organization_configuration,
|
||||
)
|
||||
|
||||
if user_id is None:
|
||||
return ResolvedAIModelConfiguration(
|
||||
effective=EffectiveAIModelConfiguration(),
|
||||
source="empty",
|
||||
)
|
||||
|
||||
legacy = await db_client.get_user_configurations(user_id)
|
||||
return ResolvedAIModelConfiguration(
|
||||
effective=legacy,
|
||||
source="legacy_user_v1" if _has_model_services(legacy) else "empty",
|
||||
effective=EffectiveAIModelConfiguration(),
|
||||
source="empty",
|
||||
)
|
||||
|
||||
|
||||
async def get_effective_ai_model_configuration_for_workflow(
|
||||
*,
|
||||
user_id: int | None,
|
||||
organization_id: int | None,
|
||||
workflow_configurations: dict | None,
|
||||
) -> EffectiveAIModelConfiguration:
|
||||
|
|
@ -97,7 +89,6 @@ async def get_effective_ai_model_configuration_for_workflow(
|
|||
)
|
||||
|
||||
resolved_config = await get_resolved_ai_model_configuration(
|
||||
user_id=user_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
return resolve_effective_config(
|
||||
|
|
@ -145,19 +136,12 @@ async def migrate_workflow_model_configurations_to_v2(
|
|||
fallback_user_config: EffectiveAIModelConfiguration,
|
||||
) -> WorkflowAIModelConfigurationMigrationResult:
|
||||
workflows = await _list_workflows_for_model_configuration_migration(organization_id)
|
||||
owner_configs: dict[int, EffectiveAIModelConfiguration] = {}
|
||||
workflow_updates: list[tuple[int, dict]] = []
|
||||
definition_updates: list[tuple[int, dict]] = []
|
||||
migrated_workflow_ids: set[int] = set()
|
||||
|
||||
for workflow in workflows:
|
||||
base_config = fallback_user_config
|
||||
if workflow.user_id is not None:
|
||||
if workflow.user_id not in owner_configs:
|
||||
owner_configs[
|
||||
workflow.user_id
|
||||
] = await db_client.get_user_configurations(workflow.user_id)
|
||||
base_config = owner_configs[workflow.user_id]
|
||||
|
||||
workflow_configs, workflow_changed = (
|
||||
migrate_workflow_configuration_model_override_to_v2(
|
||||
|
|
@ -420,19 +404,6 @@ def _mask_secret_value(value):
|
|||
return mask_key(value)
|
||||
|
||||
|
||||
def _has_model_services(configuration: EffectiveAIModelConfiguration) -> bool:
|
||||
return any(
|
||||
service is not None
|
||||
for service in (
|
||||
configuration.llm,
|
||||
configuration.tts,
|
||||
configuration.stt,
|
||||
configuration.embeddings,
|
||||
configuration.realtime,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _convert_any_dograh_legacy_configuration(
|
||||
configuration: EffectiveAIModelConfiguration,
|
||||
dograh_key: str,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ async def get_organization_context(user: UserModel) -> OrganizationContextRespon
|
|||
)
|
||||
|
||||
resolved = await get_resolved_ai_model_configuration(
|
||||
user_id=user.id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
managed_service_version = resolved.effective.managed_service_version
|
||||
|
|
|
|||
|
|
@ -233,7 +233,9 @@ class InMemoryLogsBuffer:
|
|||
if self._user_speech_start_timestamp:
|
||||
payload_with_timestamps["timestamp"] = self._user_speech_start_timestamp
|
||||
if self._user_speech_end_timestamp:
|
||||
payload_with_timestamps["end_timestamp"] = self._user_speech_end_timestamp
|
||||
payload_with_timestamps["end_timestamp"] = (
|
||||
self._user_speech_end_timestamp
|
||||
)
|
||||
elif event_type == RealtimeFeedbackType.BOT_TEXT.value:
|
||||
bot_interval_is_active = self._bot_speech_end_timestamp is None
|
||||
if bot_interval_is_active and self._bot_speech_start_timestamp:
|
||||
|
|
|
|||
|
|
@ -53,10 +53,10 @@ from pipecat.frames.frames import (
|
|||
TranscriptionFrame,
|
||||
TTSSpeakFrame,
|
||||
TTSTextFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
UserMuteStartedFrame,
|
||||
UserMuteStoppedFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
VADUserStartedSpeakingFrame,
|
||||
VADUserStoppedSpeakingFrame,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ async def _run_pipeline_telephony_impl(
|
|||
"telephony_configuration_id"
|
||||
)
|
||||
|
||||
# Resolve effective user config here so the transport can tune its
|
||||
# Resolve effective org config here so the transport can tune its
|
||||
# bot-stopped-speaking fallback based on is_realtime; pass the resolved
|
||||
# values into _run_pipeline so it doesn't fetch them again.
|
||||
from api.services.configuration.ai_model_configuration import (
|
||||
|
|
@ -334,7 +334,6 @@ async def _run_pipeline_telephony_impl(
|
|||
(workflow_run.definition.workflow_configurations or {}) if workflow_run else {}
|
||||
)
|
||||
user_config = await get_effective_ai_model_configuration_for_workflow(
|
||||
user_id=user_id,
|
||||
organization_id=workflow.organization_id if workflow else None,
|
||||
workflow_configurations=run_configs,
|
||||
)
|
||||
|
|
@ -379,6 +378,7 @@ async def run_pipeline_smallwebrtc(
|
|||
user_id: int,
|
||||
call_context_vars: dict = {},
|
||||
user_provider_id: str | None = None,
|
||||
organization_id: int | None = None,
|
||||
) -> None:
|
||||
"""Run pipeline for WebRTC connections."""
|
||||
# Register before any async setup so deploy drains see calls that are still
|
||||
|
|
@ -392,6 +392,7 @@ async def run_pipeline_smallwebrtc(
|
|||
user_id,
|
||||
call_context_vars=call_context_vars,
|
||||
user_provider_id=user_provider_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
finally:
|
||||
unregister_active_call(workflow_run_id)
|
||||
|
|
@ -404,6 +405,7 @@ async def _run_pipeline_smallwebrtc_impl(
|
|||
user_id: int,
|
||||
call_context_vars: dict = {},
|
||||
user_provider_id: str | None = None,
|
||||
organization_id: int | None = None,
|
||||
) -> None:
|
||||
"""Run pipeline for WebRTC connections"""
|
||||
logger.debug(
|
||||
|
|
@ -411,8 +413,14 @@ async def _run_pipeline_smallwebrtc_impl(
|
|||
)
|
||||
set_current_run_id(workflow_run_id)
|
||||
|
||||
# Get workflow to extract all pipeline configurations
|
||||
workflow = await db_client.get_workflow(workflow_id, user_id)
|
||||
workflow_scope = (
|
||||
{"organization_id": organization_id}
|
||||
if organization_id is not None
|
||||
else {"user_id": user_id}
|
||||
)
|
||||
|
||||
# Get workflow to extract all pipeline configurations.
|
||||
workflow = await db_client.get_workflow(workflow_id, **workflow_scope)
|
||||
|
||||
# Set org context early so tasks created by the transport inherit it
|
||||
if workflow:
|
||||
|
|
@ -428,19 +436,26 @@ async def _run_pipeline_smallwebrtc_impl(
|
|||
# Create audio configuration for WebRTC
|
||||
audio_config = create_audio_config(WorkflowRunMode.SMALLWEBRTC.value)
|
||||
|
||||
# Resolve workflow_run + effective user_config here so the transport can
|
||||
# Resolve workflow_run + effective org config here so the transport can
|
||||
# tune its bot-stopped-speaking fallback based on is_realtime. _run_pipeline
|
||||
# reuses these via kwargs so we don't fetch twice.
|
||||
from api.services.configuration.ai_model_configuration import (
|
||||
get_effective_ai_model_configuration_for_workflow,
|
||||
)
|
||||
|
||||
workflow_run = await db_client.get_workflow_run(workflow_run_id, user_id)
|
||||
workflow_run = await db_client.get_workflow_run(workflow_run_id, **workflow_scope)
|
||||
if not workflow_run:
|
||||
raise HTTPException(status_code=404, detail="Workflow run not found")
|
||||
if workflow_run.workflow_id != workflow_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="workflow_run_workflow_mismatch",
|
||||
)
|
||||
|
||||
run_configs = (
|
||||
(workflow_run.definition.workflow_configurations or {}) if workflow_run else {}
|
||||
)
|
||||
user_config = await get_effective_ai_model_configuration_for_workflow(
|
||||
user_id=user_id,
|
||||
organization_id=workflow.organization_id if workflow else None,
|
||||
workflow_configurations=run_configs,
|
||||
)
|
||||
|
|
@ -463,6 +478,7 @@ async def _run_pipeline_smallwebrtc_impl(
|
|||
user_provider_id=user_provider_id,
|
||||
workflow_run=workflow_run,
|
||||
resolved_user_config=user_config,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -476,6 +492,7 @@ async def _run_pipeline(
|
|||
user_provider_id: str | None = None,
|
||||
workflow_run=None,
|
||||
resolved_user_config=None,
|
||||
organization_id: int | None = None,
|
||||
) -> None:
|
||||
"""Run the pipeline with active-call drain accounting."""
|
||||
register_active_call(workflow_run_id)
|
||||
|
|
@ -490,6 +507,7 @@ async def _run_pipeline(
|
|||
user_provider_id=user_provider_id,
|
||||
workflow_run=workflow_run,
|
||||
resolved_user_config=resolved_user_config,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
finally:
|
||||
unregister_active_call(workflow_run_id)
|
||||
|
|
@ -505,6 +523,7 @@ async def _run_pipeline_impl(
|
|||
user_provider_id: str | None = None,
|
||||
workflow_run=None,
|
||||
resolved_user_config=None,
|
||||
organization_id: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Run the pipeline with the given transport and configuration
|
||||
|
|
@ -515,11 +534,26 @@ async def _run_pipeline_impl(
|
|||
workflow_run_id: The ID of the workflow run
|
||||
user_id: The ID of the user
|
||||
workflow_run: Pre-fetched workflow run row. Fetched here if None.
|
||||
resolved_user_config: User configuration with model_overrides already
|
||||
applied. Fetched and resolved here if None.
|
||||
resolved_user_config: Organization model configuration with workflow
|
||||
model_overrides already applied. Fetched and resolved here if None.
|
||||
"""
|
||||
workflow_scope = (
|
||||
{"organization_id": organization_id}
|
||||
if organization_id is not None
|
||||
else {"user_id": user_id}
|
||||
)
|
||||
|
||||
if workflow_run is None:
|
||||
workflow_run = await db_client.get_workflow_run(workflow_run_id, user_id)
|
||||
workflow_run = await db_client.get_workflow_run(
|
||||
workflow_run_id, **workflow_scope
|
||||
)
|
||||
if not workflow_run:
|
||||
raise HTTPException(status_code=404, detail="Workflow run not found")
|
||||
if workflow_run.workflow_id != workflow_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="workflow_run_workflow_mismatch",
|
||||
)
|
||||
|
||||
# If the workflow run is already completed, we don't need to run it again
|
||||
if workflow_run.is_completed:
|
||||
|
|
@ -532,7 +566,7 @@ async def _run_pipeline_impl(
|
|||
merged_call_context_vars = {**merged_call_context_vars, **call_context_vars}
|
||||
|
||||
# Get workflow for metadata (name, organization_id, call_disposition_codes)
|
||||
workflow = await db_client.get_workflow(workflow_id, user_id)
|
||||
workflow = await db_client.get_workflow(workflow_id, **workflow_scope)
|
||||
if not workflow:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
|
||||
|
|
@ -564,7 +598,7 @@ async def _run_pipeline_impl(
|
|||
term.strip() for term in dictionary.split(",") if term.strip()
|
||||
]
|
||||
|
||||
# Resolve model overrides from the version onto global user config (skip
|
||||
# Resolve model overrides from the version onto global org config (skip
|
||||
# when the caller already resolved it).
|
||||
if resolved_user_config is None:
|
||||
from api.services.configuration.ai_model_configuration import (
|
||||
|
|
@ -572,7 +606,6 @@ async def _run_pipeline_impl(
|
|||
)
|
||||
|
||||
user_config = await get_effective_ai_model_configuration_for_workflow(
|
||||
user_id=user_id,
|
||||
organization_id=workflow.organization_id,
|
||||
workflow_configurations=run_configs,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -337,6 +337,7 @@ async def _authorize_oss_managed_v2_correlation(
|
|||
async def authorize_workflow_run_start(
|
||||
*,
|
||||
workflow_id: int,
|
||||
organization_id: int,
|
||||
workflow_run_id: int | None = None,
|
||||
actor_user: UserModel | None = None,
|
||||
) -> QuotaCheckResult:
|
||||
|
|
@ -344,30 +345,75 @@ async def authorize_workflow_run_start(
|
|||
|
||||
The workflow organization is the billing subject for hosted deployments.
|
||||
OSS deployments are billed per service key instead. The workflow owner is
|
||||
used only to resolve the effective model configuration.
|
||||
used only as billing metadata.
|
||||
"""
|
||||
if organization_id is None:
|
||||
logger.warning(
|
||||
"Workflow start authorization denied: missing organization scope for workflow {}",
|
||||
workflow_id,
|
||||
)
|
||||
return QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="workflow_not_found",
|
||||
error_message="Workflow not found",
|
||||
)
|
||||
|
||||
try:
|
||||
workflow = await db_client.get_workflow_by_id(workflow_id)
|
||||
if not workflow:
|
||||
workflow = await db_client.get_workflow(
|
||||
workflow_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Workflow start authorization denied: failed to load workflow {} for org {}: {}",
|
||||
workflow_id,
|
||||
organization_id,
|
||||
e,
|
||||
)
|
||||
return QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="workflow_not_found",
|
||||
error_message="Workflow not found",
|
||||
)
|
||||
|
||||
if not workflow:
|
||||
logger.warning(
|
||||
"Workflow start authorization denied: workflow {} not found for org {}",
|
||||
workflow_id,
|
||||
organization_id,
|
||||
)
|
||||
return QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="workflow_not_found",
|
||||
error_message="Workflow not found",
|
||||
)
|
||||
|
||||
try:
|
||||
actor_id = getattr(actor_user, "id", None) if actor_user is not None else None
|
||||
if actor_user is not None and actor_id is None:
|
||||
logger.warning(
|
||||
"Workflow start authorization denied: actor is missing id for workflow {} org {}",
|
||||
workflow_id,
|
||||
organization_id,
|
||||
)
|
||||
return QuotaCheckResult(
|
||||
has_quota=False,
|
||||
error_code="workflow_not_found",
|
||||
error_message="Workflow not found",
|
||||
)
|
||||
|
||||
actor_id = getattr(actor_user, "id", None)
|
||||
if actor_id is not None and workflow.organization_id is not None:
|
||||
if actor_id is not None:
|
||||
try:
|
||||
is_member = await db_client.is_user_member_of_organization(
|
||||
user_id=actor_id,
|
||||
organization_id=workflow.organization_id,
|
||||
organization_id=organization_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Workflow start authorization denied: failed to validate actor {} membership for workflow {} org {}: {}",
|
||||
actor_id,
|
||||
workflow_id,
|
||||
workflow.organization_id,
|
||||
organization_id,
|
||||
e,
|
||||
)
|
||||
return QuotaCheckResult(
|
||||
|
|
@ -380,7 +426,7 @@ async def authorize_workflow_run_start(
|
|||
"Workflow start authorization denied: actor {} is not a member of workflow {} org {}",
|
||||
actor_id,
|
||||
workflow_id,
|
||||
workflow.organization_id,
|
||||
organization_id,
|
||||
)
|
||||
return QuotaCheckResult(
|
||||
has_quota=False,
|
||||
|
|
@ -397,15 +443,14 @@ async def authorize_workflow_run_start(
|
|||
)
|
||||
|
||||
user_config = await get_effective_ai_model_configuration_for_workflow(
|
||||
user_id=workflow_owner.id,
|
||||
organization_id=workflow.organization_id,
|
||||
organization_id=organization_id,
|
||||
workflow_configurations=workflow.workflow_configurations,
|
||||
)
|
||||
|
||||
if DEPLOYMENT_MODE != "oss":
|
||||
return await _authorize_hosted_workflow_run_start(
|
||||
workflow_owner=workflow_owner,
|
||||
organization_id=workflow.organization_id,
|
||||
organization_id=organization_id,
|
||||
workflow_id=workflow.id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
user_config=user_config,
|
||||
|
|
|
|||
|
|
@ -591,6 +591,7 @@ class ARIConnection:
|
|||
gathered_context={
|
||||
"call_id": call_id,
|
||||
},
|
||||
organization_id=self.organization_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
|
@ -603,6 +604,7 @@ class ARIConnection:
|
|||
# store the MPS correlation id before the pipeline starts.
|
||||
quota_result = await authorize_workflow_run_start(
|
||||
workflow_id=inbound_workflow_id,
|
||||
organization_id=self.organization_id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
)
|
||||
if not quota_result.has_quota:
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ async def resolve_llm_config(
|
|||
"""Resolve the LLM provider, model, API key, and extra kwargs for QA analysis.
|
||||
|
||||
If the QA node has its own LLM configuration (qa_use_workflow_llm=False),
|
||||
use those settings directly. Otherwise, fall back to the user's configured LLM.
|
||||
use those settings directly. Otherwise, fall back to the workflow/org LLM.
|
||||
|
||||
Returns:
|
||||
(provider, model, api_key, service_kwargs) tuple — service_kwargs can be
|
||||
|
|
@ -30,7 +30,7 @@ async def resolve_llm_config(
|
|||
kwargs,
|
||||
)
|
||||
|
||||
# Fall back to user's configured LLM
|
||||
# Fall back to the workflow/org configured LLM
|
||||
provider, model, api_key, kwargs = await resolve_user_llm_config(workflow_run)
|
||||
|
||||
if qa_data.qa_model and qa_data.qa_model != "default":
|
||||
|
|
@ -42,17 +42,13 @@ async def resolve_llm_config(
|
|||
async def resolve_user_llm_config(
|
||||
workflow_run: WorkflowRunModel,
|
||||
) -> tuple[str, str, str, dict]:
|
||||
"""Resolve the user's configured LLM (from EffectiveAIModelConfiguration).
|
||||
"""Resolve the workflow/org configured LLM.
|
||||
|
||||
Returns:
|
||||
(provider, model, api_key, service_kwargs) tuple
|
||||
"""
|
||||
user_id = None
|
||||
if workflow_run.workflow and workflow_run.workflow.user:
|
||||
user_id = workflow_run.workflow.user.id
|
||||
|
||||
llm_config: dict = {}
|
||||
if user_id:
|
||||
if workflow_run.workflow:
|
||||
from api.services.configuration.ai_model_configuration import (
|
||||
get_effective_ai_model_configuration_for_workflow,
|
||||
)
|
||||
|
|
@ -68,7 +64,6 @@ async def resolve_user_llm_config(
|
|||
)
|
||||
|
||||
user_configuration = await get_effective_ai_model_configuration_for_workflow(
|
||||
user_id=user_id,
|
||||
organization_id=workflow_run.workflow.organization_id
|
||||
if workflow_run.workflow
|
||||
else None,
|
||||
|
|
|
|||
|
|
@ -458,7 +458,6 @@ async def execute_text_chat_pending_turn(
|
|||
)
|
||||
|
||||
user_config = await get_effective_ai_model_configuration_for_workflow(
|
||||
user_id=workflow_run.workflow.user.id,
|
||||
organization_id=workflow.organization_id,
|
||||
workflow_configurations=run_configs,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -143,14 +143,13 @@ async def process_knowledge_base_document(
|
|||
embeddings_base_url = None
|
||||
embeddings_endpoint = None
|
||||
embeddings_api_version = None
|
||||
if retrieval_mode == "chunked" and document.created_by:
|
||||
if retrieval_mode == "chunked":
|
||||
from api.services.configuration.ai_model_configuration import (
|
||||
apply_managed_embeddings_base_url,
|
||||
get_resolved_ai_model_configuration,
|
||||
)
|
||||
|
||||
resolved_config = await get_resolved_ai_model_configuration(
|
||||
user_id=document.created_by,
|
||||
organization_id=document.organization_id,
|
||||
)
|
||||
effective_config = resolved_config.effective
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ def test_trigger_route_executes_as_workflow_owner():
|
|||
assert response.status_code == 200
|
||||
quota_mock.assert_awaited_once_with(
|
||||
workflow_id=workflow.id,
|
||||
organization_id=workflow.organization_id,
|
||||
workflow_run_id=501,
|
||||
)
|
||||
mock_db.get_workflow.assert_awaited_once_with(workflow.id, organization_id=11)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ _ACTIVE_TOKEN = SimpleNamespace(
|
|||
expires_at=None,
|
||||
allowed_domains=[],
|
||||
workflow_id=1,
|
||||
organization_id=11,
|
||||
created_by=7,
|
||||
usage_limit=None,
|
||||
usage_count=0,
|
||||
|
|
@ -37,6 +38,7 @@ _RESTRICTED_TOKEN = SimpleNamespace(
|
|||
expires_at=None,
|
||||
allowed_domains=["allowed.example.com"],
|
||||
workflow_id=2,
|
||||
organization_id=11,
|
||||
created_by=7,
|
||||
usage_limit=None,
|
||||
usage_count=0,
|
||||
|
|
@ -49,6 +51,7 @@ _LOCALHOST_TOKEN = SimpleNamespace(
|
|||
expires_at=None,
|
||||
allowed_domains=["localhost:3000", "localhost:3020"],
|
||||
workflow_id=3,
|
||||
organization_id=11,
|
||||
created_by=7,
|
||||
usage_limit=None,
|
||||
usage_count=0,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ from api.services.configuration.registry import ServiceProviders
|
|||
from api.services.managed_model_services import MPS_CORRELATION_ID_CONTEXT_KEY
|
||||
from api.services.quota_service import QuotaCheckResult
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _dograh_config(
|
||||
api_key: str = "mps_sk_12345678",
|
||||
|
|
@ -58,11 +60,17 @@ def _actor():
|
|||
)
|
||||
|
||||
|
||||
def _patch_workflow_context(monkeypatch, *, workflow=None, owner=None):
|
||||
def _patch_workflow_context(monkeypatch, *, workflow=_UNSET, owner=None):
|
||||
workflow_value = _workflow() if workflow is _UNSET else workflow
|
||||
monkeypatch.setattr(
|
||||
quota_service.db_client,
|
||||
"get_workflow",
|
||||
AsyncMock(return_value=workflow_value),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
quota_service.db_client,
|
||||
"get_workflow_by_id",
|
||||
AsyncMock(return_value=workflow or _workflow()),
|
||||
AsyncMock(side_effect=AssertionError("quota must not use unscoped workflow")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
quota_service.db_client,
|
||||
|
|
@ -103,11 +111,14 @@ async def test_authorize_workflow_run_uses_workflow_org_for_hosted_v2(
|
|||
check_usage,
|
||||
)
|
||||
|
||||
result = await quota_service.authorize_workflow_run_start(workflow_id=7)
|
||||
result = await quota_service.authorize_workflow_run_start(
|
||||
workflow_id=7,
|
||||
organization_id=42,
|
||||
)
|
||||
|
||||
assert result.has_quota is True
|
||||
quota_service.db_client.get_workflow.assert_awaited_once_with(7, organization_id=42)
|
||||
get_config.assert_awaited_once_with(
|
||||
user_id=123,
|
||||
organization_id=42,
|
||||
workflow_configurations={"model_overrides": {}},
|
||||
)
|
||||
|
|
@ -156,7 +167,10 @@ async def test_authorize_workflow_run_v2_insufficient_credits_prompts_billing(
|
|||
check_usage,
|
||||
)
|
||||
|
||||
result = await quota_service.authorize_workflow_run_start(workflow_id=7)
|
||||
result = await quota_service.authorize_workflow_run_start(
|
||||
workflow_id=7,
|
||||
organization_id=42,
|
||||
)
|
||||
|
||||
assert result.has_quota is False
|
||||
assert result.error_code == "insufficient_credits"
|
||||
|
|
@ -189,11 +203,14 @@ async def test_authorize_workflow_run_oss_exhausted_key_blocks_run(
|
|||
check_usage,
|
||||
)
|
||||
|
||||
result = await quota_service.authorize_workflow_run_start(workflow_id=7)
|
||||
result = await quota_service.authorize_workflow_run_start(
|
||||
workflow_id=7,
|
||||
organization_id=42,
|
||||
)
|
||||
|
||||
assert result.has_quota is False
|
||||
assert result.error_code == "quota_exceeded"
|
||||
assert "founders@dograh.com" in result.error_message
|
||||
assert "app.dograh.com" in result.error_message
|
||||
assert "/billing" not in result.error_message
|
||||
check_usage.assert_awaited_once_with(api_key)
|
||||
|
||||
|
|
@ -247,6 +264,7 @@ async def test_authorize_workflow_run_managed_v2_stores_hosted_correlation(
|
|||
|
||||
result = await quota_service.authorize_workflow_run_start(
|
||||
workflow_id=7,
|
||||
organization_id=42,
|
||||
workflow_run_id=88,
|
||||
)
|
||||
|
||||
|
|
@ -314,6 +332,7 @@ async def test_authorize_workflow_run_service_token_from_wrong_org_prompts_new_t
|
|||
|
||||
result = await quota_service.authorize_workflow_run_start(
|
||||
workflow_id=7,
|
||||
organization_id=42,
|
||||
workflow_run_id=88,
|
||||
)
|
||||
|
||||
|
|
@ -383,6 +402,7 @@ async def test_authorize_workflow_run_oss_uses_key_paths_not_workflow_org(
|
|||
|
||||
result = await quota_service.authorize_workflow_run_start(
|
||||
workflow_id=7,
|
||||
organization_id=42,
|
||||
workflow_run_id=88,
|
||||
)
|
||||
|
||||
|
|
@ -411,6 +431,7 @@ async def test_authorize_workflow_run_rejects_actor_not_a_member(monkeypatch):
|
|||
|
||||
result = await quota_service.authorize_workflow_run_start(
|
||||
workflow_id=7,
|
||||
organization_id=42,
|
||||
actor_user=SimpleNamespace(id=456, selected_organization_id=999),
|
||||
)
|
||||
|
||||
|
|
@ -430,6 +451,7 @@ async def test_authorize_workflow_run_membership_lookup_error_fails_closed(monke
|
|||
|
||||
result = await quota_service.authorize_workflow_run_start(
|
||||
workflow_id=7,
|
||||
organization_id=42,
|
||||
actor_user=SimpleNamespace(id=456, selected_organization_id=42),
|
||||
)
|
||||
|
||||
|
|
@ -464,6 +486,7 @@ async def test_authorize_workflow_run_allows_invited_member(monkeypatch):
|
|||
# but is_user_member_of_organization returns True so the run should be allowed.
|
||||
result = await quota_service.authorize_workflow_run_start(
|
||||
workflow_id=7,
|
||||
organization_id=42,
|
||||
actor_user=SimpleNamespace(id=456, selected_organization_id=999),
|
||||
)
|
||||
|
||||
|
|
@ -471,16 +494,9 @@ async def test_authorize_workflow_run_allows_invited_member(monkeypatch):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorize_workflow_run_allows_personal_workflow_with_actor(monkeypatch):
|
||||
"""Personal/legacy workflows (organization_id=None) bypass membership check."""
|
||||
personal_workflow = SimpleNamespace(
|
||||
id=7,
|
||||
user_id=123,
|
||||
organization_id=None,
|
||||
workflow_configurations={"model_overrides": {}},
|
||||
)
|
||||
async def test_authorize_workflow_run_requires_organization_scope(monkeypatch):
|
||||
monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas")
|
||||
_patch_workflow_context(monkeypatch, workflow=personal_workflow)
|
||||
_patch_workflow_context(monkeypatch)
|
||||
is_member_mock = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
quota_service.db_client,
|
||||
|
|
@ -500,8 +516,35 @@ async def test_authorize_workflow_run_allows_personal_workflow_with_actor(monkey
|
|||
|
||||
result = await quota_service.authorize_workflow_run_start(
|
||||
workflow_id=7,
|
||||
organization_id=None,
|
||||
actor_user=SimpleNamespace(id=456),
|
||||
)
|
||||
|
||||
assert result.has_quota is True
|
||||
assert result.has_quota is False
|
||||
assert result.error_code == "workflow_not_found"
|
||||
quota_service.db_client.get_workflow.assert_not_awaited()
|
||||
is_member_mock.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authorize_workflow_run_rejects_workflow_outside_org(monkeypatch):
|
||||
monkeypatch.setattr(quota_service, "DEPLOYMENT_MODE", "saas")
|
||||
_patch_workflow_context(monkeypatch, workflow=None)
|
||||
is_member_mock = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
quota_service.db_client,
|
||||
"is_user_member_of_organization",
|
||||
is_member_mock,
|
||||
)
|
||||
|
||||
result = await quota_service.authorize_workflow_run_start(
|
||||
workflow_id=7,
|
||||
organization_id=99,
|
||||
actor_user=SimpleNamespace(id=456),
|
||||
)
|
||||
|
||||
assert result.has_quota is False
|
||||
assert result.error_code == "workflow_not_found"
|
||||
quota_service.db_client.get_workflow.assert_awaited_once_with(7, organization_id=99)
|
||||
is_member_mock.assert_not_awaited()
|
||||
quota_service.db_client.get_user_by_id.assert_not_awaited()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
from api.services.pipecat.realtime_feedback_events import (
|
||||
build_bot_text_event,
|
||||
build_function_call_end_event,
|
||||
build_user_transcription_event,
|
||||
build_node_transition_event,
|
||||
build_user_transcription_event,
|
||||
realtime_feedback_event_sort_key,
|
||||
stamp_realtime_feedback_event,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from types import SimpleNamespace
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pipecat.frames.frames import (
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ def test_initiate_call_executes_as_workflow_owner_for_shared_org_workflow():
|
|||
assert response.status_code == 200
|
||||
quota_mock.assert_awaited_once_with(
|
||||
workflow_id=workflow.id,
|
||||
organization_id=workflow.organization_id,
|
||||
workflow_run_id=501,
|
||||
actor_user=ANY,
|
||||
)
|
||||
|
|
@ -230,7 +231,7 @@ async def test_smallwebrtc_run_reaching_telephony_websocket_closes_without_runni
|
|||
initial_context={},
|
||||
gathered_context={},
|
||||
)
|
||||
workflow = SimpleNamespace(id=33, organization_id=11)
|
||||
workflow = SimpleNamespace(id=33, organization_id=11, user_id=99)
|
||||
provider_lookup = AsyncMock()
|
||||
|
||||
with (
|
||||
|
|
|
|||
|
|
@ -5,8 +5,12 @@ from unittest.mock import AsyncMock, patch
|
|||
import pytest
|
||||
from pipecat.processors.aggregators.llm_context import LLMSpecificMessage
|
||||
|
||||
from api.db.models import OrganizationModel, UserModel
|
||||
from api.db.models import OrganizationModel, UserModel, organization_users_association
|
||||
from api.enums import OrganizationConfigurationKey
|
||||
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
|
||||
from api.services.configuration.ai_model_configuration import (
|
||||
convert_legacy_ai_model_configuration_to_v2,
|
||||
)
|
||||
from api.services.workflow.text_chat_runner import (
|
||||
_deserialize_text_chat_checkpoint_messages,
|
||||
_serialize_text_chat_checkpoint_messages,
|
||||
|
|
@ -84,10 +88,23 @@ async def _create_user_and_workflow(
|
|||
)
|
||||
async_session.add(user)
|
||||
await async_session.flush()
|
||||
await async_session.execute(
|
||||
organization_users_association.insert().values(
|
||||
user_id=user.id,
|
||||
organization_id=org.id,
|
||||
)
|
||||
)
|
||||
|
||||
await db_session.update_user_configuration(
|
||||
user_id=user.id,
|
||||
configuration=EffectiveAIModelConfiguration.model_validate(USER_CONFIGURATION),
|
||||
user_configuration = EffectiveAIModelConfiguration.model_validate(
|
||||
USER_CONFIGURATION
|
||||
)
|
||||
await db_session.upsert_configuration(
|
||||
org.id,
|
||||
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
|
||||
convert_legacy_ai_model_configuration_to_v2(user_configuration).model_dump(
|
||||
mode="json",
|
||||
exclude_none=True,
|
||||
),
|
||||
)
|
||||
|
||||
workflow = await db_session.create_workflow(
|
||||
|
|
@ -1079,10 +1096,23 @@ async def test_text_chat_session_creation_requires_selected_org_scope(
|
|||
)
|
||||
async_session.add(user)
|
||||
await async_session.flush()
|
||||
await async_session.execute(
|
||||
organization_users_association.insert().values(
|
||||
user_id=user.id,
|
||||
organization_id=org_a.id,
|
||||
)
|
||||
)
|
||||
|
||||
await db_session.update_user_configuration(
|
||||
user_id=user.id,
|
||||
configuration=EffectiveAIModelConfiguration.model_validate(USER_CONFIGURATION),
|
||||
user_configuration = EffectiveAIModelConfiguration.model_validate(
|
||||
USER_CONFIGURATION
|
||||
)
|
||||
await db_session.upsert_configuration(
|
||||
org_a.id,
|
||||
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
|
||||
convert_legacy_ai_model_configuration_to_v2(user_configuration).model_dump(
|
||||
mode="json",
|
||||
exclude_none=True,
|
||||
),
|
||||
)
|
||||
|
||||
workflow = await db_session.create_workflow(
|
||||
|
|
|
|||
|
|
@ -42,7 +42,9 @@ def test_create_xai_tts_service_uses_pipeline_compatible_audio_format(
|
|||
transport_in_sample_rate=16000,
|
||||
)
|
||||
|
||||
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
|
||||
with patch(
|
||||
"api.services.pipecat.service_factory.XAIHttpTTSService"
|
||||
) as mock_service:
|
||||
create_tts_service(user_config, audio_config)
|
||||
|
||||
assert mock_service.call_count == 1
|
||||
|
|
@ -69,7 +71,9 @@ def test_create_xai_tts_service_converts_language():
|
|||
transport_in_sample_rate=16000,
|
||||
)
|
||||
|
||||
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
|
||||
with patch(
|
||||
"api.services.pipecat.service_factory.XAIHttpTTSService"
|
||||
) as mock_service:
|
||||
create_tts_service(user_config, audio_config)
|
||||
|
||||
kwargs = mock_service.call_args.kwargs
|
||||
|
|
@ -91,7 +95,9 @@ def test_create_xai_tts_service_falls_back_to_english_for_unknown_language():
|
|||
transport_in_sample_rate=16000,
|
||||
)
|
||||
|
||||
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
|
||||
with patch(
|
||||
"api.services.pipecat.service_factory.XAIHttpTTSService"
|
||||
) as mock_service:
|
||||
create_tts_service(user_config, audio_config)
|
||||
|
||||
kwargs = mock_service.call_args.kwargs
|
||||
|
|
@ -113,7 +119,9 @@ def test_create_xai_tts_service_preserves_auto_language():
|
|||
transport_in_sample_rate=16000,
|
||||
)
|
||||
|
||||
with patch("api.services.pipecat.service_factory.XAIHttpTTSService") as mock_service:
|
||||
with patch(
|
||||
"api.services.pipecat.service_factory.XAIHttpTTSService"
|
||||
) as mock_service:
|
||||
create_tts_service(user_config, audio_config)
|
||||
|
||||
kwargs = mock_service.call_args.kwargs
|
||||
|
|
@ -127,25 +135,20 @@ def test_xai_is_registered_for_key_validation():
|
|||
|
||||
def test_xai_key_validation_accepts_valid_key():
|
||||
validator = UserConfigurationValidator()
|
||||
with patch(
|
||||
"api.services.configuration.check_validity.httpx.get"
|
||||
) as mock_get:
|
||||
with patch("api.services.configuration.check_validity.httpx.get") as mock_get:
|
||||
mock_get.return_value.status_code = 200
|
||||
assert validator._check_xai_api_key("xai", "xai-valid-key") is True
|
||||
# Validates against the TTS-scoped voices endpoint, not /v1/models.
|
||||
called_url = mock_get.call_args.args[0]
|
||||
assert called_url == "https://api.x.ai/v1/tts/voices"
|
||||
assert (
|
||||
mock_get.call_args.kwargs["headers"]["Authorization"]
|
||||
== "Bearer xai-valid-key"
|
||||
mock_get.call_args.kwargs["headers"]["Authorization"] == "Bearer xai-valid-key"
|
||||
)
|
||||
|
||||
|
||||
def test_xai_key_validation_rejects_bad_key():
|
||||
validator = UserConfigurationValidator()
|
||||
with patch(
|
||||
"api.services.configuration.check_validity.httpx.get"
|
||||
) as mock_get:
|
||||
with patch("api.services.configuration.check_validity.httpx.get") as mock_get:
|
||||
mock_get.return_value.status_code = 401
|
||||
with pytest.raises(ValueError):
|
||||
validator._check_xai_api_key("xai", "bad-key")
|
||||
|
|
@ -153,8 +156,6 @@ def test_xai_key_validation_rejects_bad_key():
|
|||
|
||||
def test_xai_key_validation_allows_scoped_key_without_voice_list_access():
|
||||
validator = UserConfigurationValidator()
|
||||
with patch(
|
||||
"api.services.configuration.check_validity.httpx.get"
|
||||
) as mock_get:
|
||||
with patch("api.services.configuration.check_validity.httpx.get") as mock_get:
|
||||
mock_get.return_value.status_code = 403
|
||||
assert validator._check_xai_api_key("xai", "tts-scoped-key") is True
|
||||
|
|
|
|||
|
|
@ -3,14 +3,20 @@ from typing import List
|
|||
from pipecat.utils.enums import RealtimeFeedbackType
|
||||
|
||||
|
||||
def _format_timestamp_range(payload: dict, event: dict, include_end_timestamps: bool) -> str:
|
||||
def _format_timestamp_range(
|
||||
payload: dict, event: dict, include_end_timestamps: bool
|
||||
) -> str:
|
||||
start_timestamp = payload.get("timestamp") or event.get("timestamp", "")
|
||||
if not include_end_timestamps:
|
||||
return start_timestamp
|
||||
|
||||
end_timestamp = payload.get("end_timestamp")
|
||||
if end_timestamp:
|
||||
return f"{start_timestamp} -> {end_timestamp}" if start_timestamp else end_timestamp
|
||||
return (
|
||||
f"{start_timestamp} -> {end_timestamp}"
|
||||
if start_timestamp
|
||||
else end_timestamp
|
||||
)
|
||||
return start_timestamp
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue