mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-10 11:12:13 +02:00
fix: fix org scoped access for resources (#517)
* fix: fix org scoped access for resources * Fix auth and config validation regressions * fix: track org config validation timestamp * fix: backfill org model configuration v2 from legacy user rows Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: align config tests with org-level v2 resolution Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: helm example values tweaks Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
041c31a613
commit
fb4038a969
59 changed files with 3531 additions and 517 deletions
|
|
@ -0,0 +1,215 @@
|
|||
"""backfill org model configuration v2 from legacy user rows
|
||||
|
||||
Revision ID: 00b0201ad918
|
||||
Revises: c52dc3cccfb0
|
||||
Create Date: 2026-07-09 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "00b0201ad918"
|
||||
down_revision: Union[str, None] = "c52dc3cccfb0"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# Organizations created before MODEL_CONFIGURATION_V2 only have per-user legacy
|
||||
# MODEL_CONFIGURATION rows, and the application no longer falls back to those.
|
||||
# This migration converts one member's legacy row per organization into an
|
||||
# org-level v2 row. The conversion below is a frozen copy of
|
||||
# convert_legacy_ai_model_configuration_to_v2 (api/services/configuration/
|
||||
# ai_model_configuration.py at this revision) operating on raw JSON, so the
|
||||
# migration never imports application code. API keys are deliberately carried
|
||||
# over without validation: a dead key fails at call time exactly as it did
|
||||
# before this migration.
|
||||
|
||||
_SPEED_MIN = 0.5
|
||||
_SPEED_MAX = 2.0
|
||||
_DEFAULT_VOICE = "default"
|
||||
_DEFAULT_LANGUAGE = "multi"
|
||||
|
||||
# One candidate row per (organization, member with a legacy config row).
|
||||
# Members are ordered the way the hosted migration script picked its
|
||||
# representative user: users whose selected organization is this org first,
|
||||
# then earliest created. The first member whose row converts wins.
|
||||
_CANDIDATE_ROWS_SQL = """
|
||||
SELECT ou.organization_id,
|
||||
uc.configuration,
|
||||
uc.last_validated_at
|
||||
FROM organization_users ou
|
||||
JOIN users u ON u.id = ou.user_id
|
||||
JOIN user_configurations uc
|
||||
ON uc.user_id = u.id AND uc.key = 'MODEL_CONFIGURATION'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM organization_configurations oc
|
||||
WHERE oc.organization_id = ou.organization_id
|
||||
AND oc.key = 'MODEL_CONFIGURATION_V2'
|
||||
)
|
||||
ORDER BY ou.organization_id,
|
||||
(u.selected_organization_id IS NOT DISTINCT FROM ou.organization_id) DESC,
|
||||
u.created_at ASC NULLS LAST,
|
||||
u.id ASC
|
||||
"""
|
||||
|
||||
_INSERT_SQL = """
|
||||
INSERT INTO organization_configurations
|
||||
(organization_id, key, value, created_at, updated_at, last_validated_at)
|
||||
VALUES
|
||||
(:organization_id, 'MODEL_CONFIGURATION_V2', CAST(:value AS json),
|
||||
now(), now(), :last_validated_at)
|
||||
ON CONFLICT ON CONSTRAINT _organization_key_uc DO NOTHING
|
||||
"""
|
||||
|
||||
|
||||
def _section(configuration: dict, name: str) -> dict | None:
|
||||
value = configuration.get(name)
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def _single_api_key(service: dict) -> str | None:
|
||||
key = service.get("api_key")
|
||||
if isinstance(key, str) and key:
|
||||
return key
|
||||
if isinstance(key, list) and len(key) == 1 and isinstance(key[0], str) and key[0]:
|
||||
return key[0]
|
||||
return None
|
||||
|
||||
|
||||
def _first_dograh_api_key(configuration: dict) -> str | None:
|
||||
for name in ("llm", "tts", "stt", "embeddings", "realtime"):
|
||||
service = _section(configuration, name)
|
||||
if service is None or service.get("provider") != "dograh":
|
||||
continue
|
||||
key = _single_api_key(service)
|
||||
if key:
|
||||
return key
|
||||
return None
|
||||
|
||||
|
||||
def _has_dograh_provider(*services: dict | None) -> bool:
|
||||
return any(
|
||||
service is not None and service.get("provider") == "dograh"
|
||||
for service in services
|
||||
)
|
||||
|
||||
|
||||
def _sanitized_speed(tts: dict | None) -> float:
|
||||
speed = (tts or {}).get("speed", 1.0)
|
||||
try:
|
||||
speed = float(speed)
|
||||
except (TypeError, ValueError):
|
||||
return 1.0
|
||||
if not _SPEED_MIN <= speed <= _SPEED_MAX:
|
||||
return 1.0
|
||||
return speed
|
||||
|
||||
|
||||
def convert_legacy_configuration_to_v2(configuration: dict) -> dict | None:
|
||||
"""Convert a legacy MODEL_CONFIGURATION JSON value to a v2 value.
|
||||
|
||||
Returns None when the legacy row is too incomplete to have produced a
|
||||
working pipeline (such rows failed at pipeline startup before v2 too).
|
||||
"""
|
||||
llm = _section(configuration, "llm")
|
||||
tts = _section(configuration, "tts")
|
||||
stt = _section(configuration, "stt")
|
||||
embeddings = _section(configuration, "embeddings")
|
||||
realtime = _section(configuration, "realtime")
|
||||
|
||||
dograh_key = _first_dograh_api_key(configuration)
|
||||
if dograh_key:
|
||||
return {
|
||||
"version": 2,
|
||||
"mode": "dograh",
|
||||
"dograh": {
|
||||
"api_key": dograh_key,
|
||||
"voice": (tts or {}).get("voice") or _DEFAULT_VOICE,
|
||||
"speed": _sanitized_speed(tts),
|
||||
"language": (stt or {}).get("language") or _DEFAULT_LANGUAGE,
|
||||
},
|
||||
}
|
||||
|
||||
if configuration.get("is_realtime"):
|
||||
# BYOK schemas reject dograh providers; a dograh provider without a
|
||||
# single resolvable key cannot be represented in v2.
|
||||
if realtime is None or llm is None or _has_dograh_provider(llm, embeddings):
|
||||
return None
|
||||
section: dict = {"realtime": realtime, "llm": llm}
|
||||
if embeddings is not None:
|
||||
section["embeddings"] = embeddings
|
||||
return {
|
||||
"version": 2,
|
||||
"mode": "byok",
|
||||
"byok": {"mode": "realtime", "realtime": section},
|
||||
}
|
||||
|
||||
if llm is None or tts is None or stt is None:
|
||||
return None
|
||||
if _has_dograh_provider(llm, tts, stt, embeddings):
|
||||
return None
|
||||
section = {"llm": llm, "tts": tts, "stt": stt}
|
||||
if embeddings is not None:
|
||||
section["embeddings"] = embeddings
|
||||
return {
|
||||
"version": 2,
|
||||
"mode": "byok",
|
||||
"byok": {"mode": "pipeline", "pipeline": section},
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
connection = op.get_bind()
|
||||
rows = connection.execute(sa.text(_CANDIDATE_ROWS_SQL)).mappings().all()
|
||||
|
||||
backfilled: set[int] = set()
|
||||
seen: set[int] = set()
|
||||
for row in rows:
|
||||
organization_id = row["organization_id"]
|
||||
seen.add(organization_id)
|
||||
if organization_id in backfilled:
|
||||
continue
|
||||
|
||||
configuration = row["configuration"]
|
||||
if isinstance(configuration, str):
|
||||
try:
|
||||
configuration = json.loads(configuration)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(configuration, dict):
|
||||
continue
|
||||
|
||||
try:
|
||||
v2_value = convert_legacy_configuration_to_v2(configuration)
|
||||
except Exception:
|
||||
v2_value = None
|
||||
if v2_value is None:
|
||||
continue
|
||||
|
||||
connection.execute(
|
||||
sa.text(_INSERT_SQL),
|
||||
{
|
||||
"organization_id": organization_id,
|
||||
"value": json.dumps(v2_value),
|
||||
"last_validated_at": row["last_validated_at"],
|
||||
},
|
||||
)
|
||||
backfilled.add(organization_id)
|
||||
|
||||
skipped = sorted(seen - backfilled)
|
||||
print(
|
||||
f"Backfilled MODEL_CONFIGURATION_V2 for {len(backfilled)} organization(s); "
|
||||
f"skipped {len(skipped)} with no convertible legacy configuration"
|
||||
+ (f": {skipped}" if skipped else "")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Backfilled rows are indistinguishable from rows written by the
|
||||
# application; leaving them in place is safe for older code.
|
||||
pass
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
"""add last_validated_at on org config
|
||||
|
||||
Revision ID: c52dc3cccfb0
|
||||
Revises: b7e3c9a1d2f4
|
||||
Create Date: 2026-07-09 19:18:29.550267
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "c52dc3cccfb0"
|
||||
down_revision: Union[str, None] = "b7e3c9a1d2f4"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"organization_configurations",
|
||||
sa.Column("last_validated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("organization_configurations", "last_validated_at")
|
||||
|
|
@ -205,11 +205,8 @@ class OrganizationConfigurationModel(Base):
|
|||
key = Column(String, nullable=False)
|
||||
value = Column(JSON, nullable=False, default=dict)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
last_validated_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
organization = relationship("OrganizationModel", back_populates="configurations")
|
||||
|
|
|
|||
|
|
@ -118,7 +118,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
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from datetime import UTC, datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.future import select
|
||||
|
|
@ -33,10 +34,15 @@ class OrganizationConfigurationClient(BaseDBClient):
|
|||
return result.scalars().all()
|
||||
|
||||
async def upsert_configuration(
|
||||
self, organization_id: int, key: str, value: Any
|
||||
self,
|
||||
organization_id: int,
|
||||
key: str,
|
||||
value: Any,
|
||||
last_validated_at: datetime | None = None,
|
||||
) -> OrganizationConfigurationModel:
|
||||
"""Create or update a configuration for an organization."""
|
||||
async with self.async_session() as session:
|
||||
now = datetime.now(UTC)
|
||||
# First try to get existing configuration
|
||||
result = await session.execute(
|
||||
select(OrganizationConfigurationModel).where(
|
||||
|
|
@ -49,12 +55,16 @@ class OrganizationConfigurationClient(BaseDBClient):
|
|||
if config:
|
||||
# Update existing configuration
|
||||
config.value = value
|
||||
config.updated_at = now
|
||||
config.last_validated_at = last_validated_at
|
||||
else:
|
||||
# Create new configuration
|
||||
config = OrganizationConfigurationModel(
|
||||
organization_id=organization_id,
|
||||
key=key,
|
||||
value=value,
|
||||
updated_at=now,
|
||||
last_validated_at=last_validated_at,
|
||||
)
|
||||
session.add(config)
|
||||
|
||||
|
|
@ -66,6 +76,30 @@ class OrganizationConfigurationClient(BaseDBClient):
|
|||
await session.refresh(config)
|
||||
return config
|
||||
|
||||
async def mark_configuration_validated(
|
||||
self, organization_id: int, key: str
|
||||
) -> Optional[OrganizationConfigurationModel]:
|
||||
"""Update the validation timestamp for an existing organization configuration."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(OrganizationConfigurationModel).where(
|
||||
OrganizationConfigurationModel.organization_id == organization_id,
|
||||
OrganizationConfigurationModel.key == key,
|
||||
)
|
||||
)
|
||||
config = result.scalars().first()
|
||||
if not config:
|
||||
return None
|
||||
|
||||
config.last_validated_at = datetime.now(UTC)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(config)
|
||||
return config
|
||||
|
||||
async def delete_configuration(self, organization_id: int, key: str) -> bool:
|
||||
"""Delete a configuration for an organization."""
|
||||
async with self.async_session() as session:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -92,6 +92,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
|
||||
|
|
|
|||
|
|
@ -285,6 +285,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,11 +309,11 @@ 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,
|
||||
},
|
||||
organization_id=embed_token.organization_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create workflow run: {e}")
|
||||
|
|
|
|||
|
|
@ -223,6 +223,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,
|
||||
)
|
||||
|
|
@ -450,11 +451,11 @@ 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,
|
||||
from_phone_number_id: Optional[int] = None,
|
||||
organization_id: int | None = None,
|
||||
) -> int:
|
||||
"""Create workflow run for inbound call and return run ID"""
|
||||
call_id = normalized_data.call_id
|
||||
|
|
@ -593,6 +594,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:
|
||||
|
|
@ -814,16 +829,17 @@ 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,
|
||||
from_phone_number_id=phone_row.id,
|
||||
organization_id=config.organization_id,
|
||||
)
|
||||
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run_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:
|
||||
|
|
@ -977,18 +993,19 @@ async def handle_inbound_telephony(
|
|||
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"),
|
||||
organization_id=organization_id,
|
||||
)
|
||||
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run_id)
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ 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,
|
||||
update_organization_ai_model_configuration_last_validated_at,
|
||||
upsert_organization_ai_model_configuration_v2,
|
||||
)
|
||||
from api.services.configuration.check_validity import (
|
||||
APIKeyStatusResponse,
|
||||
|
|
@ -105,12 +108,29 @@ class UserConfigurationRequestResponseSchema(BaseModel):
|
|||
organization_pricing: dict[str, Union[float, str, bool]] | None = None
|
||||
|
||||
|
||||
def _is_validation_cache_stale(
|
||||
last_validated_at: datetime | None,
|
||||
validity_ttl_seconds: int,
|
||||
) -> bool:
|
||||
if last_validated_at is None:
|
||||
return True
|
||||
|
||||
has_timezone = (
|
||||
last_validated_at.tzinfo is not None
|
||||
and last_validated_at.utcoffset() is not None
|
||||
)
|
||||
if has_timezone:
|
||||
now = datetime.now(last_validated_at.tzinfo)
|
||||
else:
|
||||
now = datetime.now()
|
||||
return last_validated_at < now - timedelta(seconds=validity_ttl_seconds)
|
||||
|
||||
|
||||
@router.get("/configurations/user")
|
||||
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 +159,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 +176,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 +202,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,15 +270,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
|
||||
< datetime.now() - timedelta(seconds=validity_ttl_seconds)
|
||||
if _is_validation_cache_stale(
|
||||
configurations.last_validated_at,
|
||||
validity_ttl_seconds,
|
||||
):
|
||||
validator = UserConfigurationValidator()
|
||||
try:
|
||||
|
|
@ -252,7 +285,13 @@ 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)
|
||||
if (
|
||||
resolved_config.source == "organization_v2"
|
||||
and user.selected_organization_id is not None
|
||||
):
|
||||
await update_organization_ai_model_configuration_last_validated_at(
|
||||
user.selected_organization_id
|
||||
)
|
||||
return status
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=e.args[0])
|
||||
|
|
|
|||
|
|
@ -326,6 +326,7 @@ class SignalingManager:
|
|||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user: UserModel,
|
||||
organization_id: int,
|
||||
enforce_call_concurrency: bool = False,
|
||||
call_concurrency_source: str = "webrtc",
|
||||
):
|
||||
|
|
@ -344,6 +345,7 @@ class SignalingManager:
|
|||
workflow_id,
|
||||
workflow_run_id,
|
||||
user,
|
||||
organization_id,
|
||||
connection_key,
|
||||
enforce_call_concurrency,
|
||||
call_concurrency_source,
|
||||
|
|
@ -387,6 +389,7 @@ class SignalingManager:
|
|||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user: UserModel,
|
||||
organization_id: int,
|
||||
connection_key: str,
|
||||
enforce_call_concurrency: bool,
|
||||
call_concurrency_source: str = "webrtc",
|
||||
|
|
@ -402,6 +405,7 @@ class SignalingManager:
|
|||
workflow_id,
|
||||
workflow_run_id,
|
||||
user,
|
||||
organization_id,
|
||||
connection_key,
|
||||
enforce_call_concurrency,
|
||||
call_concurrency_source,
|
||||
|
|
@ -418,6 +422,7 @@ class SignalingManager:
|
|||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user: UserModel,
|
||||
organization_id: int,
|
||||
connection_key: str,
|
||||
enforce_call_concurrency: bool,
|
||||
call_concurrency_source: str = "webrtc",
|
||||
|
|
@ -440,14 +445,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,
|
||||
)
|
||||
|
|
@ -497,21 +501,9 @@ class SignalingManager:
|
|||
pipeline_started = False
|
||||
pc = None
|
||||
if enforce_call_concurrency:
|
||||
if org_id is None:
|
||||
await ws.send_json(
|
||||
{
|
||||
"type": "error",
|
||||
"payload": {
|
||||
"error_type": "organization_not_found",
|
||||
"message": "Workflow organization not found",
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
concurrency_slot = await call_concurrency.acquire_org_slot(
|
||||
org_id,
|
||||
organization_id,
|
||||
source=call_concurrency_source,
|
||||
timeout=0,
|
||||
)
|
||||
|
|
@ -592,6 +584,7 @@ class SignalingManager:
|
|||
user.id,
|
||||
call_context_vars,
|
||||
user_provider_id=str(user.provider_id),
|
||||
organization_id=organization_id,
|
||||
)
|
||||
)
|
||||
pipeline_started = True
|
||||
|
|
@ -716,26 +709,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 workflow_run:
|
||||
logger.warning(f"workflow run {workflow_run_id} not found for user {user.id}")
|
||||
raise HTTPException(status_code=400, detail="Bad workflow_run_id")
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
# The URL's workflow_id drives org resolution, quota, and concurrency
|
||||
# accounting downstream — reject a workflow_id that doesn't own this run
|
||||
# so a caller can't charge their call to an unrelated workflow/org.
|
||||
if workflow_run.workflow_id != workflow_id:
|
||||
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} does not belong to workflow "
|
||||
f"{workflow_id} (belongs to {workflow_run.workflow_id})"
|
||||
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,
|
||||
user.selected_organization_id,
|
||||
enforce_call_concurrency=True,
|
||||
call_concurrency_source="webrtc",
|
||||
)
|
||||
|
|
@ -771,6 +769,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.
|
||||
|
|
@ -798,6 +807,7 @@ async def public_signaling_websocket(
|
|||
embed_token.workflow_id,
|
||||
embed_session.workflow_run_id,
|
||||
user,
|
||||
embed_token.organization_id,
|
||||
enforce_call_concurrency=True,
|
||||
call_concurrency_source="public_embed",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1090,7 +1090,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(
|
||||
|
|
@ -1133,7 +1132,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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -288,6 +288,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,
|
||||
)
|
||||
await call_concurrency.bind_workflow_run(concurrency_slot, workflow_run.id)
|
||||
slot_bound = True
|
||||
|
|
@ -325,6 +326,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:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from loguru import logger
|
||||
|
|
@ -11,7 +12,11 @@ from sqlalchemy.orm import selectinload
|
|||
|
||||
from api.constants import MPS_API_URL
|
||||
from api.db import db_client
|
||||
from api.db.models import WorkflowDefinitionModel, WorkflowModel
|
||||
from api.db.models import (
|
||||
OrganizationConfigurationModel,
|
||||
WorkflowDefinitionModel,
|
||||
WorkflowModel,
|
||||
)
|
||||
from api.enums import OrganizationConfigurationKey
|
||||
from api.schemas.ai_model_configuration import (
|
||||
DOGRAH_DEFAULT_LANGUAGE,
|
||||
|
|
@ -55,35 +60,36 @@ class WorkflowAIModelConfigurationMigrationResult:
|
|||
|
||||
async def get_resolved_ai_model_configuration(
|
||||
*,
|
||||
user_id: int | None,
|
||||
organization_id: int | None,
|
||||
) -> ResolvedAIModelConfiguration:
|
||||
organization_configuration = await get_organization_ai_model_configuration_v2(
|
||||
organization_id
|
||||
"""Resolve the effective model configuration for an organization."""
|
||||
organization_configuration_row = (
|
||||
await _get_organization_ai_model_configuration_v2_row(organization_id)
|
||||
)
|
||||
organization_configuration = _parse_organization_ai_model_configuration_v2(
|
||||
organization_configuration_row,
|
||||
organization_id,
|
||||
)
|
||||
if organization_configuration is not None:
|
||||
effective = compile_ai_model_configuration_v2(organization_configuration)
|
||||
if organization_configuration_row is not None:
|
||||
effective.last_validated_at = (
|
||||
organization_configuration_row.last_validated_at
|
||||
)
|
||||
return ResolvedAIModelConfiguration(
|
||||
effective=compile_ai_model_configuration_v2(organization_configuration),
|
||||
effective=effective,
|
||||
source="organization_v2",
|
||||
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 +103,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(
|
||||
|
|
@ -109,12 +114,34 @@ async def get_effective_ai_model_configuration_for_workflow(
|
|||
async def get_organization_ai_model_configuration_v2(
|
||||
organization_id: int | None,
|
||||
) -> OrganizationAIModelConfigurationV2 | None:
|
||||
if organization_id is None:
|
||||
return None
|
||||
row = await db_client.get_configuration(
|
||||
row = await _get_organization_ai_model_configuration_v2_row(organization_id)
|
||||
return _parse_organization_ai_model_configuration_v2(row, organization_id)
|
||||
|
||||
|
||||
async def update_organization_ai_model_configuration_last_validated_at(
|
||||
organization_id: int,
|
||||
) -> None:
|
||||
await db_client.mark_configuration_validated(
|
||||
organization_id,
|
||||
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
|
||||
)
|
||||
|
||||
|
||||
async def _get_organization_ai_model_configuration_v2_row(
|
||||
organization_id: int | None,
|
||||
) -> OrganizationConfigurationModel | None:
|
||||
if organization_id is None:
|
||||
return None
|
||||
return await db_client.get_configuration(
|
||||
organization_id,
|
||||
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
|
||||
)
|
||||
|
||||
|
||||
def _parse_organization_ai_model_configuration_v2(
|
||||
row: OrganizationConfigurationModel | None,
|
||||
organization_id: int | None,
|
||||
) -> OrganizationAIModelConfigurationV2 | None:
|
||||
if row is None or not row.value:
|
||||
return None
|
||||
try:
|
||||
|
|
@ -135,6 +162,7 @@ async def upsert_organization_ai_model_configuration_v2(
|
|||
organization_id,
|
||||
OrganizationConfigurationKey.MODEL_CONFIGURATION_V2.value,
|
||||
configuration.model_dump(mode="json", exclude_none=True),
|
||||
last_validated_at=datetime.now(UTC),
|
||||
)
|
||||
return configuration
|
||||
|
||||
|
|
@ -145,19 +173,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 +441,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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -329,7 +329,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 (
|
||||
|
|
@ -340,7 +340,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,
|
||||
)
|
||||
|
|
@ -385,6 +384,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
|
||||
|
|
@ -398,6 +398,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:
|
||||
try:
|
||||
|
|
@ -413,6 +414,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(
|
||||
|
|
@ -420,8 +422,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:
|
||||
|
|
@ -437,19 +445,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,
|
||||
)
|
||||
|
|
@ -472,6 +487,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,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -485,6 +501,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_worker_active_call(workflow_run_id)
|
||||
|
|
@ -499,6 +516,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:
|
||||
try:
|
||||
|
|
@ -517,6 +535,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
|
||||
|
|
@ -527,11 +546,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:
|
||||
|
|
@ -544,7 +578,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")
|
||||
|
||||
|
|
@ -576,7 +610,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 (
|
||||
|
|
@ -584,7 +618,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,
|
||||
|
|
|
|||
|
|
@ -625,6 +625,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
|
||||
|
|
|
|||
|
|
@ -195,7 +195,11 @@ async def create_workflow_run_rows(
|
|||
Returns:
|
||||
Tuple of (workflow_run, user, workflow).
|
||||
"""
|
||||
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,
|
||||
)
|
||||
|
||||
org = OrganizationModel(provider_id=f"test-org-{provider_id_suffix}")
|
||||
async_session.add(org)
|
||||
|
|
@ -208,9 +212,16 @@ async def create_workflow_run_rows(
|
|||
async_session.add(user)
|
||||
await async_session.flush()
|
||||
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -15,9 +15,11 @@ from api.services.configuration.ai_model_configuration import (
|
|||
WORKFLOW_MODEL_CONFIGURATION_V2_OVERRIDE_KEY,
|
||||
check_for_masked_keys_in_ai_model_configuration_v2,
|
||||
convert_legacy_ai_model_configuration_to_v2,
|
||||
get_resolved_ai_model_configuration,
|
||||
mask_ai_model_configuration_v2,
|
||||
merge_ai_model_configuration_v2_secrets,
|
||||
migrate_workflow_configuration_model_override_to_v2,
|
||||
upsert_organization_ai_model_configuration_v2,
|
||||
)
|
||||
from api.services.configuration.check_validity import UserConfigurationValidator
|
||||
from api.services.configuration.masking import mask_key
|
||||
|
|
@ -148,6 +150,83 @@ async def test_byok_realtime_validator_does_not_require_stt_or_tts():
|
|||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolved_org_v2_uses_last_validated_at_as_validation_cache(
|
||||
monkeypatch,
|
||||
):
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from api.services.configuration import ai_model_configuration
|
||||
|
||||
last_validated_at = datetime.now(UTC)
|
||||
config = OrganizationAIModelConfigurationV2(
|
||||
mode="dograh",
|
||||
dograh=DograhManagedAIModelConfiguration(api_key="mps-secret"),
|
||||
)
|
||||
row = SimpleNamespace(
|
||||
value=config.model_dump(mode="json", exclude_none=True),
|
||||
last_validated_at=last_validated_at,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ai_model_configuration.db_client,
|
||||
"get_configuration",
|
||||
AsyncMock(return_value=row),
|
||||
)
|
||||
|
||||
resolved = await get_resolved_ai_model_configuration(organization_id=42)
|
||||
|
||||
assert resolved.source == "organization_v2"
|
||||
assert resolved.effective.last_validated_at == last_validated_at
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_org_v2_marks_configuration_validated(monkeypatch):
|
||||
from api.services.configuration import ai_model_configuration
|
||||
|
||||
config = OrganizationAIModelConfigurationV2(
|
||||
mode="dograh",
|
||||
dograh=DograhManagedAIModelConfiguration(api_key="mps-secret"),
|
||||
)
|
||||
upsert = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
ai_model_configuration.db_client,
|
||||
"upsert_configuration",
|
||||
upsert,
|
||||
)
|
||||
|
||||
result = await upsert_organization_ai_model_configuration_v2(42, config)
|
||||
|
||||
assert result is config
|
||||
upsert.assert_awaited_once()
|
||||
args = upsert.await_args.args
|
||||
kwargs = upsert.await_args.kwargs
|
||||
assert args == (
|
||||
42,
|
||||
"MODEL_CONFIGURATION_V2",
|
||||
config.model_dump(mode="json", exclude_none=True),
|
||||
)
|
||||
assert kwargs["last_validated_at"].tzinfo is not None
|
||||
|
||||
|
||||
def test_org_config_validation_timestamp_update_does_not_touch_updated_at():
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from api.db.models import OrganizationConfigurationModel
|
||||
|
||||
stmt = (
|
||||
update(OrganizationConfigurationModel)
|
||||
.where(OrganizationConfigurationModel.id == 1)
|
||||
.values(last_validated_at=datetime.now(UTC))
|
||||
)
|
||||
|
||||
compiled = str(stmt.compile(dialect=postgresql.dialect()))
|
||||
assert "last_validated_at" in compiled
|
||||
assert "updated_at" not in compiled
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pipeline_validator_requires_stt_and_tts_when_not_realtime():
|
||||
effective = EffectiveAIModelConfiguration(
|
||||
|
|
|
|||
148
api/tests/test_backfill_org_model_configuration_v2_migration.py
Normal file
148
api/tests/test_backfill_org_model_configuration_v2_migration.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"""Tests for the 00b0201ad918 backfill migration's frozen conversion.
|
||||
|
||||
The migration writes MODEL_CONFIGURATION_V2 JSON directly, so its output must
|
||||
keep parsing with OrganizationAIModelConfigurationV2 — these tests fail if the
|
||||
schema drifts away from what the migration produces.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from api.schemas.ai_model_configuration import (
|
||||
OrganizationAIModelConfigurationV2,
|
||||
compile_ai_model_configuration_v2,
|
||||
)
|
||||
|
||||
_MIGRATION_PATH = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "alembic"
|
||||
/ "versions"
|
||||
/ "00b0201ad918_backfill_org_model_configuration_v2.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"backfill_org_model_configuration_v2", _MIGRATION_PATH
|
||||
)
|
||||
migration = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(migration)
|
||||
|
||||
|
||||
def _parse_and_compile(value: dict) -> None:
|
||||
compile_ai_model_configuration_v2(
|
||||
OrganizationAIModelConfigurationV2.model_validate(value)
|
||||
)
|
||||
|
||||
|
||||
def test_dograh_legacy_converts_to_managed_mode():
|
||||
value = migration.convert_legacy_configuration_to_v2(
|
||||
{
|
||||
"llm": {"provider": "dograh", "api_key": "mps-key", "model": "default"},
|
||||
"tts": {
|
||||
"provider": "dograh",
|
||||
"api_key": "mps-key",
|
||||
"voice": "aura",
|
||||
"speed": 1.2,
|
||||
},
|
||||
"stt": {"provider": "dograh", "api_key": "mps-key", "language": "en"},
|
||||
}
|
||||
)
|
||||
assert value["mode"] == "dograh"
|
||||
assert value["dograh"] == {
|
||||
"api_key": "mps-key",
|
||||
"voice": "aura",
|
||||
"speed": 1.2,
|
||||
"language": "en",
|
||||
}
|
||||
_parse_and_compile(value)
|
||||
|
||||
|
||||
def test_dograh_mode_wins_over_byok_sections_and_sanitizes_speed():
|
||||
value = migration.convert_legacy_configuration_to_v2(
|
||||
{
|
||||
"llm": {"provider": "dograh", "api_key": ["mps-key"]},
|
||||
"tts": {"provider": "elevenlabs", "api_key": "el-key", "speed": 9.0},
|
||||
"stt": {"provider": "deepgram", "api_key": "dg-key"},
|
||||
}
|
||||
)
|
||||
assert value["mode"] == "dograh"
|
||||
assert value["dograh"]["api_key"] == "mps-key"
|
||||
assert value["dograh"]["speed"] == 1.0
|
||||
_parse_and_compile(value)
|
||||
|
||||
|
||||
def test_byok_pipeline_legacy_converts_and_validates():
|
||||
value = migration.convert_legacy_configuration_to_v2(
|
||||
{
|
||||
"llm": {
|
||||
"provider": "openai",
|
||||
"api_key": "sk-test",
|
||||
"model": "gpt-4.1-mini",
|
||||
"temperature": 0.5,
|
||||
},
|
||||
"tts": {
|
||||
"provider": "elevenlabs",
|
||||
"api_key": "el-key",
|
||||
"model": "eleven_flash_v2_5",
|
||||
"voice": "voice-id",
|
||||
},
|
||||
"stt": {
|
||||
"provider": "deepgram",
|
||||
"api_key": "dg-key",
|
||||
"model": "nova-2-phonecall",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert value["mode"] == "byok"
|
||||
assert value["byok"]["mode"] == "pipeline"
|
||||
assert "embeddings" not in value["byok"]["pipeline"]
|
||||
_parse_and_compile(value)
|
||||
|
||||
|
||||
def test_realtime_legacy_converts_to_byok_realtime():
|
||||
value = migration.convert_legacy_configuration_to_v2(
|
||||
{
|
||||
"is_realtime": True,
|
||||
"realtime": {
|
||||
"provider": "google_realtime",
|
||||
"api_key": "google-key",
|
||||
"model": "gemini-3.1-flash-live-preview",
|
||||
"voice": "Puck",
|
||||
"language": "en",
|
||||
},
|
||||
"llm": {
|
||||
"provider": "google",
|
||||
"api_key": "google-key",
|
||||
"model": "gemini-2.5-flash",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert value["mode"] == "byok"
|
||||
assert value["byok"]["mode"] == "realtime"
|
||||
_parse_and_compile(value)
|
||||
|
||||
|
||||
def test_incomplete_pipeline_legacy_is_skipped():
|
||||
assert (
|
||||
migration.convert_legacy_configuration_to_v2(
|
||||
{
|
||||
"llm": {"provider": "openai", "api_key": "sk-test"},
|
||||
"tts": {"provider": "elevenlabs", "api_key": "el-key"},
|
||||
}
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert migration.convert_legacy_configuration_to_v2({}) is None
|
||||
|
||||
|
||||
def test_dograh_provider_without_single_key_cannot_become_byok():
|
||||
# Multiple dograh keys can't map to managed mode, and BYOK rejects the
|
||||
# dograh provider — the org must be skipped rather than written broken.
|
||||
assert (
|
||||
migration.convert_legacy_configuration_to_v2(
|
||||
{
|
||||
"llm": {"provider": "dograh", "api_key": ["key-a", "key-b"]},
|
||||
"tts": {"provider": "elevenlabs", "api_key": "el-key"},
|
||||
"stt": {"provider": "deepgram", "api_key": "dg-key"},
|
||||
}
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
|
@ -7,20 +8,25 @@ from fastapi.testclient import TestClient
|
|||
from api.routes.user import router
|
||||
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.configuration.ai_model_configuration import (
|
||||
ResolvedAIModelConfiguration,
|
||||
)
|
||||
from api.services.configuration.masking import mask_key
|
||||
from api.services.configuration.registry import (
|
||||
GoogleLLMService,
|
||||
DeepgramSTTConfiguration,
|
||||
GoogleVertexLLMConfiguration,
|
||||
OpenAILLMService,
|
||||
OpenAITTSService,
|
||||
)
|
||||
|
||||
|
||||
def _make_test_app(selected_organization_id=None):
|
||||
def _make_test_app(selected_organization_id=11):
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
mock_user = MagicMock()
|
||||
mock_user.id = 1
|
||||
mock_user.provider_id = "provider-1"
|
||||
mock_user.is_superuser = False
|
||||
mock_user.selected_organization_id = selected_organization_id
|
||||
|
||||
|
|
@ -38,28 +44,63 @@ def _existing_openai_config():
|
|||
provider="openai",
|
||||
api_key=REAL_KEY,
|
||||
model="gpt-4.1",
|
||||
)
|
||||
),
|
||||
tts=OpenAITTSService(
|
||||
provider="openai",
|
||||
api_key=REAL_KEY,
|
||||
model="gpt-4o-mini-tts",
|
||||
voice="alloy",
|
||||
),
|
||||
stt=DeepgramSTTConfiguration(
|
||||
provider="deepgram",
|
||||
api_key=REAL_KEY,
|
||||
model="nova-3-general",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patch_config_update(existing_config=None):
|
||||
existing_config = existing_config or _existing_openai_config()
|
||||
preferences = SimpleNamespace(test_phone_number=None, timezone=None)
|
||||
|
||||
with (
|
||||
patch("api.routes.user.db_client") as mock_db,
|
||||
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
|
||||
patch(
|
||||
"api.routes.user.get_resolved_ai_model_configuration",
|
||||
new=AsyncMock(
|
||||
return_value=ResolvedAIModelConfiguration(
|
||||
effective=existing_config,
|
||||
source="organization_v2",
|
||||
)
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"api.routes.user.upsert_organization_ai_model_configuration_v2",
|
||||
new=AsyncMock(),
|
||||
) as upsert_config,
|
||||
patch(
|
||||
"api.routes.user.get_organization_preferences",
|
||||
new=AsyncMock(return_value=preferences),
|
||||
),
|
||||
):
|
||||
mock_db.get_organization_by_id = AsyncMock(return_value=None)
|
||||
mock_validator.return_value.validate = AsyncMock()
|
||||
yield SimpleNamespace(
|
||||
db=mock_db,
|
||||
validator=mock_validator,
|
||||
upsert_config=upsert_config,
|
||||
)
|
||||
|
||||
|
||||
class TestMaskedKeyRejection:
|
||||
def test_rejects_masked_api_key_on_provider_change(self):
|
||||
"""Changing provider with a masked API key should return 400."""
|
||||
app = _make_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
with (
|
||||
patch("api.routes.user.db_client") as mock_db,
|
||||
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
|
||||
):
|
||||
mock_db.get_user_configurations = AsyncMock(
|
||||
return_value=_existing_openai_config()
|
||||
)
|
||||
mock_db.update_user_configuration = AsyncMock(
|
||||
side_effect=lambda uid, cfg: cfg
|
||||
)
|
||||
mock_validator.return_value.validate = AsyncMock()
|
||||
|
||||
with _patch_config_update():
|
||||
response = client.put(
|
||||
"/user/configurations/user",
|
||||
json={
|
||||
|
|
@ -79,18 +120,7 @@ class TestMaskedKeyRejection:
|
|||
app = _make_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
with (
|
||||
patch("api.routes.user.db_client") as mock_db,
|
||||
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
|
||||
):
|
||||
mock_db.get_user_configurations = AsyncMock(
|
||||
return_value=_existing_openai_config()
|
||||
)
|
||||
mock_db.update_user_configuration = AsyncMock(
|
||||
side_effect=lambda uid, cfg: cfg
|
||||
)
|
||||
mock_validator.return_value.validate = AsyncMock()
|
||||
|
||||
with _patch_config_update():
|
||||
response = client.put(
|
||||
"/user/configurations/user",
|
||||
json={
|
||||
|
|
@ -111,24 +141,7 @@ class TestMaskedKeyRejection:
|
|||
client = TestClient(app)
|
||||
|
||||
new_key = "AIzaSyNewRealKey12345678"
|
||||
updated = EffectiveAIModelConfiguration(
|
||||
llm=GoogleLLMService(
|
||||
provider="google",
|
||||
api_key=new_key,
|
||||
model="gemini-2.5-flash",
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("api.routes.user.db_client") as mock_db,
|
||||
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
|
||||
):
|
||||
mock_db.get_user_configurations = AsyncMock(
|
||||
return_value=_existing_openai_config()
|
||||
)
|
||||
mock_db.update_user_configuration = AsyncMock(return_value=updated)
|
||||
mock_validator.return_value.validate = AsyncMock()
|
||||
|
||||
with _patch_config_update() as patched:
|
||||
response = client.put(
|
||||
"/user/configurations/user",
|
||||
json={
|
||||
|
|
@ -141,21 +154,14 @@ class TestMaskedKeyRejection:
|
|||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
patched.upsert_config.assert_awaited_once()
|
||||
|
||||
def test_allows_same_provider_with_masked_key(self):
|
||||
"""Same provider with masked key should succeed (merge resolves it)."""
|
||||
app = _make_test_app()
|
||||
client = TestClient(app)
|
||||
|
||||
with (
|
||||
patch("api.routes.user.db_client") as mock_db,
|
||||
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
|
||||
):
|
||||
existing = _existing_openai_config()
|
||||
mock_db.get_user_configurations = AsyncMock(return_value=existing)
|
||||
mock_db.update_user_configuration = AsyncMock(return_value=existing)
|
||||
mock_validator.return_value.validate = AsyncMock()
|
||||
|
||||
with _patch_config_update() as patched:
|
||||
response = client.put(
|
||||
"/user/configurations/user",
|
||||
json={
|
||||
|
|
@ -170,6 +176,7 @@ class TestMaskedKeyRejection:
|
|||
# Merge resolves the masked key back to the real one,
|
||||
# so check_for_masked_keys should NOT raise.
|
||||
assert response.status_code == 200
|
||||
patched.upsert_config.assert_awaited_once()
|
||||
|
||||
def test_allows_same_provider_with_masked_vertex_credentials(self):
|
||||
"""Same provider with masked credentials should succeed."""
|
||||
|
|
@ -186,17 +193,21 @@ class TestMaskedKeyRejection:
|
|||
project_id="demo-project",
|
||||
location="us-east4",
|
||||
credentials=real_credentials,
|
||||
)
|
||||
),
|
||||
tts=OpenAITTSService(
|
||||
provider="openai",
|
||||
api_key=REAL_KEY,
|
||||
model="gpt-4o-mini-tts",
|
||||
voice="alloy",
|
||||
),
|
||||
stt=DeepgramSTTConfiguration(
|
||||
provider="deepgram",
|
||||
api_key=REAL_KEY,
|
||||
model="nova-3-general",
|
||||
),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("api.routes.user.db_client") as mock_db,
|
||||
patch("api.routes.user.UserConfigurationValidator") as mock_validator,
|
||||
):
|
||||
mock_db.get_user_configurations = AsyncMock(return_value=existing)
|
||||
mock_db.update_user_configuration = AsyncMock(return_value=existing)
|
||||
mock_validator.return_value.validate = AsyncMock()
|
||||
|
||||
with _patch_config_update(existing) as patched:
|
||||
response = client.put(
|
||||
"/user/configurations/user",
|
||||
json={
|
||||
|
|
@ -211,6 +222,7 @@ class TestMaskedKeyRejection:
|
|||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
patched.upsert_config.assert_awaited_once()
|
||||
|
||||
def test_preference_only_update_does_not_validate_or_save_model_config(self):
|
||||
"""Saving a test phone number through the legacy endpoint must not touch models."""
|
||||
|
|
@ -229,6 +241,15 @@ class TestMaskedKeyRejection:
|
|||
"api.routes.user.upsert_organization_preferences",
|
||||
new=AsyncMock(return_value=preferences),
|
||||
) as upsert_preferences,
|
||||
patch(
|
||||
"api.routes.user.get_resolved_ai_model_configuration",
|
||||
new=AsyncMock(
|
||||
return_value=ResolvedAIModelConfiguration(
|
||||
effective=_existing_openai_config(),
|
||||
source="organization_v2",
|
||||
)
|
||||
),
|
||||
),
|
||||
):
|
||||
existing = _existing_openai_config()
|
||||
mock_db.get_user_configurations = AsyncMock(return_value=existing)
|
||||
|
|
|
|||
|
|
@ -102,6 +102,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_concurrency.acquire_org_slot.assert_awaited_once_with(
|
||||
|
|
|
|||
|
|
@ -26,7 +26,12 @@ def _embed_session():
|
|||
|
||||
|
||||
def _embed_token(allowed_domains):
|
||||
return SimpleNamespace(allowed_domains=allowed_domains, created_by=7, workflow_id=3)
|
||||
return SimpleNamespace(
|
||||
allowed_domains=allowed_domains,
|
||||
created_by=7,
|
||||
workflow_id=3,
|
||||
organization_id=11,
|
||||
)
|
||||
|
||||
|
||||
def _patch_deps():
|
||||
|
|
@ -35,6 +40,7 @@ def _patch_deps():
|
|||
mgr = patch("api.routes.webrtc_signaling.signaling_manager").start()
|
||||
db.get_embed_session_by_token = AsyncMock(return_value=_embed_session())
|
||||
db.get_embed_token_by_id = AsyncMock(return_value=_embed_token(["example.com"]))
|
||||
db.get_workflow_run = AsyncMock(return_value=SimpleNamespace(workflow_id=3))
|
||||
db.get_user_by_id = AsyncMock(return_value=SimpleNamespace(id=7))
|
||||
mgr.handle_websocket = AsyncMock()
|
||||
return db, mgr
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -101,6 +101,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,
|
||||
)
|
||||
|
|
@ -366,7 +367,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 (
|
||||
|
|
|
|||
99
api/tests/test_user_configuration_validation.py
Normal file
99
api/tests/test_user_configuration_validation.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from api.routes import user as user_routes
|
||||
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
|
||||
from api.services.configuration.ai_model_configuration import (
|
||||
ResolvedAIModelConfiguration,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_user_configurations_marks_stale_org_v2_config_validated(
|
||||
monkeypatch,
|
||||
):
|
||||
stale_config = EffectiveAIModelConfiguration(
|
||||
last_validated_at=datetime.now(UTC) - timedelta(seconds=120)
|
||||
)
|
||||
resolved = ResolvedAIModelConfiguration(
|
||||
effective=stale_config,
|
||||
source="organization_v2",
|
||||
)
|
||||
validate = AsyncMock(return_value={"status": [{"model": "all", "message": "ok"}]})
|
||||
touch_validation_cache = AsyncMock()
|
||||
|
||||
class FakeValidator:
|
||||
def __init__(self):
|
||||
self.validate = validate
|
||||
|
||||
monkeypatch.setattr(
|
||||
user_routes,
|
||||
"get_resolved_ai_model_configuration",
|
||||
AsyncMock(return_value=resolved),
|
||||
)
|
||||
monkeypatch.setattr(user_routes, "UserConfigurationValidator", FakeValidator)
|
||||
monkeypatch.setattr(
|
||||
user_routes,
|
||||
"update_organization_ai_model_configuration_last_validated_at",
|
||||
touch_validation_cache,
|
||||
)
|
||||
|
||||
response = await user_routes.validate_user_configurations(
|
||||
validity_ttl_seconds=60,
|
||||
user=SimpleNamespace(
|
||||
provider_id="provider-123",
|
||||
selected_organization_id=42,
|
||||
),
|
||||
)
|
||||
|
||||
assert response == {"status": [{"model": "all", "message": "ok"}]}
|
||||
validate.assert_awaited_once_with(
|
||||
stale_config,
|
||||
organization_id=42,
|
||||
created_by="provider-123",
|
||||
)
|
||||
touch_validation_cache.assert_awaited_once_with(42)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_user_configurations_uses_fresh_org_v2_validation_cache(
|
||||
monkeypatch,
|
||||
):
|
||||
fresh_config = EffectiveAIModelConfiguration(last_validated_at=datetime.now(UTC))
|
||||
resolved = ResolvedAIModelConfiguration(
|
||||
effective=fresh_config,
|
||||
source="organization_v2",
|
||||
)
|
||||
validate = AsyncMock()
|
||||
touch_validation_cache = AsyncMock()
|
||||
|
||||
class FakeValidator:
|
||||
def __init__(self):
|
||||
self.validate = validate
|
||||
|
||||
monkeypatch.setattr(
|
||||
user_routes,
|
||||
"get_resolved_ai_model_configuration",
|
||||
AsyncMock(return_value=resolved),
|
||||
)
|
||||
monkeypatch.setattr(user_routes, "UserConfigurationValidator", FakeValidator)
|
||||
monkeypatch.setattr(
|
||||
user_routes,
|
||||
"update_organization_ai_model_configuration_last_validated_at",
|
||||
touch_validation_cache,
|
||||
)
|
||||
|
||||
response = await user_routes.validate_user_configurations(
|
||||
validity_ttl_seconds=60,
|
||||
user=SimpleNamespace(
|
||||
provider_id="provider-123",
|
||||
selected_organization_id=42,
|
||||
),
|
||||
)
|
||||
|
||||
assert response == {"status": []}
|
||||
validate.assert_not_awaited()
|
||||
touch_validation_cache.assert_not_awaited()
|
||||
|
|
@ -61,6 +61,7 @@ async def test_public_embed_offer_rejects_when_org_concurrency_limit_reached():
|
|||
workflow_id=33,
|
||||
workflow_run_id=501,
|
||||
user=user,
|
||||
organization_id=11,
|
||||
connection_key="conn-1",
|
||||
enforce_call_concurrency=True,
|
||||
call_concurrency_source="public_embed",
|
||||
|
|
@ -107,6 +108,7 @@ async def test_public_embed_renegotiation_does_not_acquire_another_slot():
|
|||
workflow_id=33,
|
||||
workflow_run_id=501,
|
||||
user=user,
|
||||
organization_id=11,
|
||||
connection_key=connection_key,
|
||||
enforce_call_concurrency=True,
|
||||
call_concurrency_source="public_embed",
|
||||
|
|
@ -126,7 +128,7 @@ async def test_signaling_websocket_rejects_run_not_owned_by_workflow():
|
|||
from api.routes.webrtc_signaling import signaling_websocket
|
||||
|
||||
ws = _FakeWebSocket()
|
||||
user = SimpleNamespace(id=7)
|
||||
user = SimpleNamespace(id=7, selected_organization_id=11)
|
||||
|
||||
with (
|
||||
patch("api.routes.webrtc_signaling.db_client") as mock_db,
|
||||
|
|
@ -151,7 +153,7 @@ async def test_signaling_websocket_accepts_matching_workflow_and_run():
|
|||
from api.routes.webrtc_signaling import signaling_websocket
|
||||
|
||||
ws = _FakeWebSocket()
|
||||
user = SimpleNamespace(id=7)
|
||||
user = SimpleNamespace(id=7, selected_organization_id=11)
|
||||
|
||||
with (
|
||||
patch("api.routes.webrtc_signaling.db_client") as mock_db,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ serviceAccount:
|
|||
eks.amazonaws.com/role-arn: "" # set via --set
|
||||
|
||||
web:
|
||||
replicaCount: 3
|
||||
replicaCount: 2
|
||||
|
||||
autoscaling:
|
||||
web:
|
||||
|
|
|
|||
|
|
@ -15,17 +15,17 @@ exposure:
|
|||
|
||||
config:
|
||||
environment: production
|
||||
logLevel: INFO
|
||||
logLevel: DEBUG
|
||||
|
||||
web:
|
||||
replicaCount: 1
|
||||
replicaCount: 2
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 1Gi
|
||||
memory: 1.5Gi
|
||||
pdb:
|
||||
enabled: false
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ workers:
|
|||
replicaCount: 1
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
|
|
@ -46,7 +46,9 @@ ui:
|
|||
|
||||
autoscaling:
|
||||
web:
|
||||
enabled: false
|
||||
enabled: true
|
||||
minReplicas: 2
|
||||
maxReplicas: 12
|
||||
|
||||
postgresql:
|
||||
persistence:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# generated by datamodel-codegen:
|
||||
# filename: dograh-openapi-XXXXXX.json.c3Lmi0soSL
|
||||
# timestamp: 2026-07-09T12:55:26+00:00
|
||||
# filename: dograh-openapi-XXXXXX.json.XNTNNgR8o4
|
||||
# timestamp: 2026-07-09T13:05:30+00:00
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
|
|||
2001
ui/package-lock.json
generated
2001
ui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -9,6 +9,7 @@
|
|||
"lint": "next lint",
|
||||
"fix-lint": "npx eslint --fix . --ignore-pattern '.next/*' --ignore-pattern 'node_modules/*' --ignore-pattern 'next-env.d.ts'",
|
||||
"generate-client": "openapi-ts",
|
||||
"test": "vitest run",
|
||||
"test:display-options": "node scripts/test-display-options.mts",
|
||||
"lint:lead-flow": "bash ../../user_onboarding/scripts/check_lead_flow.sh"
|
||||
},
|
||||
|
|
@ -62,17 +63,21 @@
|
|||
"@eslint/eslintrc": "^3",
|
||||
"@hey-api/openapi-ts": "^0.99.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "^15.3.3",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"jsdom": "^29.1.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function SpinLoader(){
|
||||
return(
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="w-15 h-15 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
interface SpinLoaderProps {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export default function SpinLoader({ label }: SpinLoaderProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-foreground" />
|
||||
{label && <span>{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import type { Team } from "@stackframe/stack";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpCircle,
|
||||
|
|
@ -25,9 +24,10 @@ import {
|
|||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import React, { useRef } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { BrandLogo } from "@/components/BrandLogo";
|
||||
import { SidebarTeamSwitcher } from "@/components/layout/SidebarTeamSwitcher";
|
||||
import ThemeToggle from "@/components/ThemeSwitcher";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -153,18 +153,11 @@ const NAV_SECTIONS: SidebarNavSection[] = [
|
|||
},
|
||||
];
|
||||
|
||||
// Lazy load SelectedTeamSwitcher - we'll pass selectedTeam from our context
|
||||
const StackTeamSwitcher = React.lazy(() =>
|
||||
import("@stackframe/stack").then((mod) => ({
|
||||
default: mod.SelectedTeamSwitcher,
|
||||
}))
|
||||
);
|
||||
|
||||
export function AppSidebar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { state, isMobile, setOpenMobile } = useSidebar();
|
||||
const { provider, getSelectedTeam, logout, user } = useAuth();
|
||||
const { provider, logout, user } = useAuth();
|
||||
const { config } = useAppConfig();
|
||||
const { openHireExpert } = useLeadForms();
|
||||
const {
|
||||
|
|
@ -176,16 +169,6 @@ export function AppSidebar() {
|
|||
vonageMissingSignatureSecretCount > 0;
|
||||
const isCollapsed = !isMobile && state === "collapsed";
|
||||
|
||||
// Get selected team for Stack auth (cast to Team type from Stack)
|
||||
// Stabilize the reference so SelectedTeamSwitcher only sees a change when the team ID changes,
|
||||
// preventing unnecessary PATCH calls to Stack Auth on every route navigation.
|
||||
const selectedTeamRef = useRef<Team | null>(null);
|
||||
const rawSelectedTeam = provider === "stack" && getSelectedTeam ? getSelectedTeam() as Team | null : null;
|
||||
if (rawSelectedTeam?.id !== selectedTeamRef.current?.id) {
|
||||
selectedTeamRef.current = rawSelectedTeam;
|
||||
}
|
||||
const selectedTeam = selectedTeamRef.current;
|
||||
|
||||
// Version info from app config context
|
||||
const versionInfo = config ? { ui: config.uiVersion, api: config.apiVersion } : null;
|
||||
|
||||
|
|
@ -397,18 +380,7 @@ export function AppSidebar() {
|
|||
|
||||
{provider === "stack" && (
|
||||
<div className={cn("mt-3 notranslate", isCollapsed && "hidden")} translate="no">
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="h-9 w-full animate-pulse rounded bg-muted" />
|
||||
}
|
||||
>
|
||||
<StackTeamSwitcher
|
||||
selectedTeam={selectedTeam || undefined}
|
||||
onChange={() => {
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
<SidebarTeamSwitcher />
|
||||
</div>
|
||||
)}
|
||||
</SidebarHeader>
|
||||
|
|
|
|||
78
ui/src/components/layout/SidebarTeamSwitcher.tsx
Normal file
78
ui/src/components/layout/SidebarTeamSwitcher.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"use client";
|
||||
|
||||
import type { CurrentUser, Team } from "@stackframe/stack";
|
||||
import React, { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import SpinLoader from "@/components/SpinLoader";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { reloadApp } from "@/lib/browserReload";
|
||||
import logger from "@/lib/logger";
|
||||
|
||||
// Lazy load Stack's SelectedTeamSwitcher, but own the selected-team write here.
|
||||
// The stock component re-applies the selectedTeam prop asynchronously, which
|
||||
// can pin the sidebar to the previous team while the user switch is in flight.
|
||||
const StackTeamSwitcher = React.lazy(() =>
|
||||
import("@stackframe/stack").then((mod) => ({
|
||||
default: mod.SelectedTeamSwitcher,
|
||||
}))
|
||||
);
|
||||
|
||||
export function SidebarTeamSwitcher() {
|
||||
const { provider, user } = useAuth();
|
||||
|
||||
// The !user guard is load-bearing (Sentry JAVASCRIPT-NEXTJS-2Z): Stack's
|
||||
// TeamSwitcher calls user?.useTeams() — a hook behind optional chaining — so
|
||||
// if useUser() flips to null mid-session (token expiry, sign-out from
|
||||
// another tab) its re-render throws React #300 "Rendered fewer hooks than
|
||||
// expected". Unmounting it here re-renders the ancestor first, removing the
|
||||
// switcher before it can re-render with a null user.
|
||||
if (provider !== "stack" || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <SidebarTeamSwitcherContent user={user as CurrentUser} />;
|
||||
}
|
||||
|
||||
function SidebarTeamSwitcherContent({ user }: { user: CurrentUser }) {
|
||||
const [isSwitching, setIsSwitching] = useState(false);
|
||||
|
||||
const handleChange = async (team: Team | null) => {
|
||||
setIsSwitching(true);
|
||||
|
||||
try {
|
||||
await user.setSelectedTeam(team);
|
||||
reloadApp();
|
||||
} catch (error) {
|
||||
logger.error("Failed to switch Stack team", error);
|
||||
toast.error("Could not switch teams. Please try again.");
|
||||
setIsSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<React.Suspense
|
||||
fallback={<div className="h-9 w-full animate-pulse rounded bg-muted" />}
|
||||
>
|
||||
<StackTeamSwitcher
|
||||
selectedTeam={user.selectedTeam || undefined}
|
||||
noUpdateSelectedTeam
|
||||
onChange={(team) => {
|
||||
void handleChange(team);
|
||||
}}
|
||||
triggerClassName="w-full"
|
||||
/>
|
||||
</React.Suspense>
|
||||
{isSwitching && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex min-h-screen items-center justify-center bg-background/90 backdrop-blur-sm"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<SpinLoader label="Switching teams..." />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
302
ui/src/components/layout/__tests__/SidebarTeamSwitcher.test.tsx
Normal file
302
ui/src/components/layout/__tests__/SidebarTeamSwitcher.test.tsx
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
/**
|
||||
* Regression tests for Sentry issue JAVASCRIPT-NEXTJS-2Z:
|
||||
* "Rendered fewer hooks than expected" (React error #300).
|
||||
*
|
||||
* Stack's TeamSwitcher Inner component calls `user?.useTeams()` — a hook behind
|
||||
* optional chaining. When Stack's useUser() flips from a user object to null
|
||||
* mid-session (token expiry, sign-out synced from another tab), the re-render
|
||||
* calls fewer hooks than the previous one and React throws.
|
||||
*
|
||||
* These tests fake useUser()/useStackApp() but render the REAL
|
||||
* SelectedTeamSwitcher component code from @stackframe/stack, so the first test
|
||||
* pins the upstream bug: if it starts failing after a @stackframe/stack
|
||||
* upgrade, the bug is fixed upstream and the guard in SidebarTeamSwitcher can
|
||||
* be removed.
|
||||
*
|
||||
* Mocking note: vi.mock("@stackframe/stack") fully replaces the package entry.
|
||||
* The real component is imported below via its dist file path — its internal
|
||||
* `import ... from "../index.js"` resolves to the same entry module and
|
||||
* therefore receives the mocked hooks.
|
||||
*/
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AuthUser } from "@/lib/auth";
|
||||
import {
|
||||
AuthContext,
|
||||
type AuthContextType,
|
||||
} from "@/lib/auth/providers/AuthProvider";
|
||||
|
||||
import { SelectedTeamSwitcher as RealSelectedTeamSwitcher } from "../../../../node_modules/@stackframe/stack/dist/esm/components/selected-team-switcher.js";
|
||||
import { TranslationContext } from "../../../../node_modules/@stackframe/stack/dist/esm/providers/translation-provider-client.js";
|
||||
import { SidebarTeamSwitcher } from "../SidebarTeamSwitcher";
|
||||
|
||||
const reloadAppMock = vi.hoisted(() => vi.fn());
|
||||
const toastErrorMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/browserReload", () => ({
|
||||
reloadApp: reloadAppMock,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: toastErrorMock,
|
||||
},
|
||||
}));
|
||||
|
||||
// Controls what the faked Stack useUser() returns; tests flip this to null to
|
||||
// simulate the session dying (token expiry / sign-out from another tab).
|
||||
const stackState: { user: ReturnType<typeof makeStackUser> | null } = {
|
||||
user: null,
|
||||
};
|
||||
|
||||
const TEAM_ONE = { id: "team-1", displayName: "Team One", profileImageUrl: null };
|
||||
const TEAM_TWO = { id: "team-2", displayName: "Team Two", profileImageUrl: null };
|
||||
const makeTeams = () => [TEAM_ONE, TEAM_TWO];
|
||||
type MockTeam = typeof TEAM_ONE | typeof TEAM_TWO;
|
||||
|
||||
function makeStackUser() {
|
||||
return {
|
||||
id: "user-1",
|
||||
selectedTeam: TEAM_ONE as MockTeam | null,
|
||||
// The real CurrentUser.useTeams() consumes React hooks internally (Stack's
|
||||
// async cache). Mirror that so the hook count matches real behavior — the
|
||||
// crash only reproduces if useTeams actually registers hooks. Use useMemo
|
||||
// (the same hook kind as the next hook in TeamSwitcher's Inner) so the
|
||||
// user→null transition completes the render with leftover hooks and React
|
||||
// throws the production error (#300) instead of a hook-slot TypeError.
|
||||
useTeams: () => React.useMemo(() => makeTeams(), []),
|
||||
setSelectedTeam: vi.fn(async (team: MockTeam | null) => {
|
||||
if (stackState.user) {
|
||||
stackState.user.selectedTeam = team;
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const mockStackApp = {
|
||||
useNavigate: () => () => {},
|
||||
useProject: () => ({ config: { clientTeamCreationEnabled: false } }),
|
||||
urls: { accountSettings: "/account-settings" },
|
||||
};
|
||||
|
||||
// Full replacement of the package entry. The factory only creates closures, so
|
||||
// it is safe to execute during import hoisting; the consts above are read at
|
||||
// render time. The raw upstream test imports the real SelectedTeamSwitcher by
|
||||
// dist path; this package export is a small controlled stand-in for
|
||||
// SidebarTeamSwitcher's lazy import.
|
||||
vi.mock("@stackframe/stack", () => ({
|
||||
useStackApp: () => mockStackApp,
|
||||
useUser: () => stackState.user,
|
||||
SelectedTeamSwitcher: (props: {
|
||||
selectedTeam?: MockTeam | null;
|
||||
onChange?: (team: MockTeam) => void;
|
||||
triggerClassName?: string;
|
||||
}) => (
|
||||
<button
|
||||
aria-controls="team-options"
|
||||
aria-expanded="false"
|
||||
className={props.triggerClassName}
|
||||
role="combobox"
|
||||
onClick={() => props.onChange?.(TEAM_TWO)}
|
||||
>
|
||||
{props.selectedTeam?.displayName ?? "Select team"}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
class CatchBoundary extends React.Component<
|
||||
{ onError: (error: Error) => void; children: React.ReactNode },
|
||||
{ failed: boolean }
|
||||
> {
|
||||
state = { failed: false };
|
||||
static getDerivedStateFromError() {
|
||||
return { failed: true };
|
||||
}
|
||||
componentDidCatch(error: Error) {
|
||||
this.props.onError(error);
|
||||
}
|
||||
render() {
|
||||
return this.state.failed ? (
|
||||
<div data-testid="crashed" />
|
||||
) : (
|
||||
this.props.children
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const translationValue = {
|
||||
quetzalKeys: new Map<string, string>(),
|
||||
quetzalLocale: new Map<string, string>(),
|
||||
};
|
||||
|
||||
function withProviders(
|
||||
children: React.ReactNode,
|
||||
authValue?: AuthContextType
|
||||
) {
|
||||
const inner = authValue ? (
|
||||
<AuthContext.Provider value={authValue}>{children}</AuthContext.Provider>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
return (
|
||||
<TranslationContext.Provider value={translationValue}>
|
||||
{inner}
|
||||
</TranslationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function makeAuthValue(user: AuthUser | null): AuthContextType {
|
||||
return {
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
loading: false,
|
||||
getAccessToken: async () => "token",
|
||||
redirectToLogin: () => {},
|
||||
logout: async () => {},
|
||||
provider: "stack",
|
||||
getSelectedTeam: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SidebarTeamSwitcher", () => {
|
||||
beforeEach(() => {
|
||||
stackState.user = makeStackUser();
|
||||
reloadAppMock.mockClear();
|
||||
toastErrorMock.mockClear();
|
||||
// React logs boundary-caught render errors; keep test output readable.
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("pins the upstream Stack bug: raw SelectedTeamSwitcher crashes with React #300 when the user flips to null", async () => {
|
||||
const errors: Error[] = [];
|
||||
|
||||
const { rerender } = render(
|
||||
withProviders(
|
||||
<CatchBoundary onError={(e) => errors.push(e)}>
|
||||
<RealSelectedTeamSwitcher />
|
||||
</CatchBoundary>
|
||||
)
|
||||
);
|
||||
expect(await screen.findByRole("combobox")).toBeTruthy();
|
||||
|
||||
stackState.user = null;
|
||||
rerender(
|
||||
withProviders(
|
||||
<CatchBoundary onError={(e) => errors.push(e)}>
|
||||
<RealSelectedTeamSwitcher />
|
||||
</CatchBoundary>
|
||||
)
|
||||
);
|
||||
|
||||
expect(
|
||||
errors.some((e) => /Rendered fewer hooks than expected/.test(e.message)),
|
||||
`expected React #300, got: ${errors.map((e) => e.message).join("; ") || "no error"}`
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("renders the switcher for an authenticated stack user", async () => {
|
||||
render(
|
||||
withProviders(
|
||||
<SidebarTeamSwitcher />,
|
||||
makeAuthValue(stackState.user as unknown as AuthUser)
|
||||
)
|
||||
);
|
||||
expect((await screen.findByRole("combobox")).textContent).toBe("Team One");
|
||||
});
|
||||
|
||||
it("blocks the app and reloads after Stack persists the selected team", async () => {
|
||||
let finishSwitch!: () => void;
|
||||
stackState.user!.setSelectedTeam = vi.fn(async (team: MockTeam | null) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
finishSwitch = () => {
|
||||
if (stackState.user) {
|
||||
stackState.user.selectedTeam = team;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
render(
|
||||
withProviders(
|
||||
<SidebarTeamSwitcher />,
|
||||
makeAuthValue(stackState.user as unknown as AuthUser)
|
||||
)
|
||||
);
|
||||
|
||||
const switcher = await screen.findByRole("combobox");
|
||||
fireEvent.click(switcher);
|
||||
|
||||
expect((await screen.findByRole("status")).textContent).toContain("Switching teams...");
|
||||
expect(reloadAppMock).not.toHaveBeenCalled();
|
||||
expect(stackState.user?.setSelectedTeam).toHaveBeenCalledWith(TEAM_TWO);
|
||||
|
||||
await act(async () => {
|
||||
finishSwitch();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reloadAppMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows feedback and does not reload when Stack fails to persist the selected team", async () => {
|
||||
stackState.user!.setSelectedTeam = vi.fn(async () => {
|
||||
throw new Error("Stack write failed");
|
||||
});
|
||||
|
||||
render(
|
||||
withProviders(
|
||||
<SidebarTeamSwitcher />,
|
||||
makeAuthValue(stackState.user as unknown as AuthUser)
|
||||
)
|
||||
);
|
||||
|
||||
const switcher = await screen.findByRole("combobox");
|
||||
fireEvent.click(switcher);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastErrorMock).toHaveBeenCalledWith(
|
||||
"Could not switch teams. Please try again."
|
||||
);
|
||||
});
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
expect(reloadAppMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("unmounts the switcher instead of crashing when the session dies (user flips to null)", async () => {
|
||||
const errors: Error[] = [];
|
||||
|
||||
const { rerender } = render(
|
||||
withProviders(
|
||||
<CatchBoundary onError={(e) => errors.push(e)}>
|
||||
<SidebarTeamSwitcher />
|
||||
</CatchBoundary>,
|
||||
makeAuthValue(stackState.user as unknown as AuthUser)
|
||||
)
|
||||
);
|
||||
expect(await screen.findByRole("combobox")).toBeTruthy();
|
||||
|
||||
// In production both changes arrive in the same update: Stack's internal
|
||||
// store empties, which updates useUser() consumers AND our AuthContext
|
||||
// (StackAuthContextProvider derives its value from the same store).
|
||||
stackState.user = null;
|
||||
rerender(
|
||||
withProviders(
|
||||
<CatchBoundary onError={(e) => errors.push(e)}>
|
||||
<SidebarTeamSwitcher />
|
||||
</CatchBoundary>,
|
||||
makeAuthValue(null)
|
||||
)
|
||||
);
|
||||
|
||||
expect(errors.map((e) => e.message)).toEqual([]);
|
||||
expect(screen.queryByRole("combobox")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { StackClientApp, StackProvider, StackTheme, useUser as useStackUser } from '@stackframe/stack';
|
||||
import { StackClientApp, StackProvider, StackTheme } from '@stackframe/stack';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
|
||||
import type { AuthUser } from '../types';
|
||||
import { AuthContext } from './AuthProvider';
|
||||
|
||||
// Create a singleton StackClientApp instance to prevent multiple initializations
|
||||
|
|
@ -35,17 +34,19 @@ interface StackProviderWrapperProps {
|
|||
publishableClientKey: string;
|
||||
}
|
||||
|
||||
// Simple context provider that uses Stack's useUser directly
|
||||
function StackAuthContextProvider({ children }: { children: React.ReactNode }) {
|
||||
const stackUser = useStackUser();
|
||||
function StackAuthContextProvider({
|
||||
children,
|
||||
app,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
app: StackClientApp<true, string>;
|
||||
}) {
|
||||
const stackUser = app.useUser();
|
||||
|
||||
// Store user in ref for callbacks to access latest value without creating new callbacks
|
||||
const userRef = useRef(stackUser);
|
||||
userRef.current = stackUser;
|
||||
|
||||
// Derive loading state: loading if we don't have a user yet
|
||||
const isLoading = stackUser === null;
|
||||
|
||||
// Stable callbacks that use ref to access current user
|
||||
const getAccessToken = React.useCallback(async () => {
|
||||
const user = userRef.current;
|
||||
|
|
@ -95,21 +96,17 @@ function StackAuthContextProvider({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// IMPORTANT: Use primitive values (userId, isLoading) in deps, NOT stackUser object
|
||||
// Stack's useUser() returns a new object reference on every render, which would cause infinite re-renders
|
||||
const userId = stackUser?.id;
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
user: userRef.current as AuthUser,
|
||||
isAuthenticated: !!userId,
|
||||
loading: isLoading,
|
||||
user: stackUser,
|
||||
isAuthenticated: !!stackUser,
|
||||
loading: false,
|
||||
getAccessToken,
|
||||
redirectToLogin,
|
||||
logout,
|
||||
provider: 'stack' as const,
|
||||
getSelectedTeam,
|
||||
listPermissions,
|
||||
}), [userId, isLoading, getAccessToken, redirectToLogin, logout, getSelectedTeam, listPermissions]);
|
||||
}), [stackUser, getAccessToken, redirectToLogin, logout, getSelectedTeam, listPermissions]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
|
|
@ -130,7 +127,7 @@ export function StackProviderWrapper({ children, projectId, publishableClientKey
|
|||
return (
|
||||
<StackProvider app={stackClientApp} translationOverrides={translationOverrides}>
|
||||
<StackTheme>
|
||||
<StackAuthContextProvider>
|
||||
<StackAuthContextProvider app={stackClientApp}>
|
||||
{children}
|
||||
</StackAuthContextProvider>
|
||||
</StackTheme>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useAuth } from "../AuthProvider";
|
||||
import { StackProviderWrapper } from "../StackProviderWrapper";
|
||||
|
||||
type MockStackUser = {
|
||||
id: string;
|
||||
selectedTeam: null;
|
||||
getAuthJson: () => Promise<{ accessToken: string }>;
|
||||
};
|
||||
|
||||
const stackState = vi.hoisted(() => ({
|
||||
user: null as MockStackUser | null,
|
||||
}));
|
||||
|
||||
vi.mock("@stackframe/stack", () => {
|
||||
class MockStackClientApp {
|
||||
useUser = vi.fn(() => stackState.user);
|
||||
|
||||
constructor(
|
||||
public options: {
|
||||
projectId: string;
|
||||
publishableClientKey: string;
|
||||
}
|
||||
) {}
|
||||
}
|
||||
|
||||
return {
|
||||
StackClientApp: MockStackClientApp,
|
||||
StackProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
StackTheme: ({ children }: { children: React.ReactNode }) => children,
|
||||
};
|
||||
});
|
||||
|
||||
function AuthProbe() {
|
||||
const auth = useAuth();
|
||||
return (
|
||||
<div data-testid="auth-state">
|
||||
{auth.loading ? "loading" : auth.user?.id ?? "signed-out"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function makeUser(): MockStackUser {
|
||||
return {
|
||||
id: "stack-user-1",
|
||||
selectedTeam: null,
|
||||
getAuthJson: async () => ({ accessToken: "token" }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("StackProviderWrapper", () => {
|
||||
beforeEach(() => {
|
||||
stackState.user = makeUser();
|
||||
});
|
||||
|
||||
it("updates AuthContext when Stack's reactive user becomes null", () => {
|
||||
const props = {
|
||||
projectId: "00000000-0000-4000-8000-000000000000",
|
||||
publishableClientKey: "publishable-client-key",
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<StackProviderWrapper {...props}>
|
||||
<AuthProbe />
|
||||
</StackProviderWrapper>
|
||||
);
|
||||
expect(screen.getByTestId("auth-state").textContent).toBe("stack-user-1");
|
||||
|
||||
stackState.user = null;
|
||||
rerender(
|
||||
<StackProviderWrapper {...props}>
|
||||
<AuthProbe />
|
||||
</StackProviderWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("auth-state").textContent).toBe("signed-out");
|
||||
});
|
||||
});
|
||||
5
ui/src/lib/browserReload.ts
Normal file
5
ui/src/lib/browserReload.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"use client";
|
||||
|
||||
export function reloadApp() {
|
||||
window.location.reload();
|
||||
}
|
||||
28
ui/vitest.config.mts
Normal file
28
ui/vitest.config.mts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
// Expose afterEach etc. globally so @testing-library/react auto-registers
|
||||
// its DOM cleanup between tests.
|
||||
globals: true,
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
server: {
|
||||
deps: {
|
||||
// Inline @stackframe so vi.mock('@stackframe/stack') also intercepts the
|
||||
// package's internal imports of its own index (e.g. team-switcher.js
|
||||
// importing useUser from '../index.js').
|
||||
inline: [/@stackframe/],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue