From 248822b20d59b3bb10a08834cbffe9491897ebeb Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Sun, 20 Sep 2026 09:39:12 +0000 Subject: [PATCH 1/8] feat: updating quotas --- LICENSE | 10 + README.md | 9 +- app/api/v1/routes/agents.py | 2 + app/api/v1/routes/aiproviders.py | 12 +- app/api/v1/routes/alerts.py | 8 +- app/api/v1/routes/iam.py | 2 + app/api/v1/routes/integrations.py | 11 + app/api/v1/routes/metric_studio.py | 13 +- app/api/v1/routes/metrics.py | 10 + app/api/v1/routes/prompt_optimization.py | 4 +- app/api/v1/routes/settings.py | 18 +- app/api/v1/routes/workspaces.py | 2 + app/core/license.py | 87 ++++++-- app/core/oss_quotas.py | 194 ++++++++++++++++++ app/db_sharding/sessions.py | 23 ++- app/services/ai/llm_gateway.py | 5 + app/services/ai/llm_gateway_settings.py | 24 +++ .../alerts/alert_evaluation_service.py | 12 +- app/services/invitation_service.py | 4 + .../content/docs/enterprise/index.mdx | 83 ++++++++ enterprise/LICENSE.md | 24 ++- frontend/src/App.tsx | 14 +- frontend/src/components/Layout.tsx | 6 +- frontend/src/components/WorkspaceSwitcher.tsx | 9 +- frontend/src/hooks/useOssQuotas.ts | 52 +++++ frontend/src/lib/api.ts | 16 ++ frontend/src/pages/agents/AgentsWorkspace.tsx | 16 +- .../src/pages/configurations/Integrations.tsx | 16 +- frontend/src/pages/iam/IAM.tsx | 12 +- .../src/pages/metrics/MetricsManagement.tsx | 28 ++- .../metrics/components/MetricsTabBar.tsx | 22 +- frontend/src/store/licenseStore.ts | 30 ++- tests/test_api/conftest.py | 1 + tests/test_api/test_enterprise_gating.py | 67 ++++++ tests/test_core/test_license_offerings.py | 53 +++++ tests/test_core/test_oss_quotas.py | 150 ++++++++++++++ 36 files changed, 985 insertions(+), 64 deletions(-) create mode 100644 app/core/oss_quotas.py create mode 100644 docs-fumadocs/content/docs/enterprise/index.mdx create mode 100644 frontend/src/hooks/useOssQuotas.ts create mode 100644 tests/test_api/test_enterprise_gating.py create mode 100644 tests/test_core/test_license_offerings.py create mode 100644 tests/test_core/test_oss_quotas.py diff --git a/LICENSE b/LICENSE index 24f16940..5522acf5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,13 @@ +Portions of this software are licensed as follows: + +* All content that resides under the "enterprise/" directory of this repository, + if that directory exists, is licensed under the license defined in + "enterprise/LICENSE.md". +* Content outside of the above mentioned directories or restrictions above is + available under the MIT license as defined below. + +--- + MIT License Copyright (c) 2025 EfficientAI.tech diff --git a/README.md b/README.md index ef240f86..435d6896 100644 --- a/README.md +++ b/README.md @@ -982,4 +982,11 @@ See `CONTRIBUTING.md` for PR format, review expectations, and release label conv ## 📄 License -MIT License - see LICENSE file for details +The open-source core is licensed under the **MIT License** — see [LICENSE](LICENSE). + +Content under the `enterprise/` directory is licensed separately under +[enterprise/LICENSE.md](enterprise/LICENSE.md). Enterprise product capabilities +(unlimited usage history, call imports, voice playground, alerts, and more) require +a valid **`EFFICIENTAI_LICENSE`** JWT key. Contact +[sales@efficientai.com](mailto:sales@efficientai.com) or visit +[efficientai.cloud](https://efficientai.cloud). diff --git a/app/api/v1/routes/agents.py b/app/api/v1/routes/agents.py index 9f2a2270..f9ac2c6b 100644 --- a/app/api/v1/routes/agents.py +++ b/app/api/v1/routes/agents.py @@ -15,6 +15,7 @@ from app.dependencies import get_db, get_organization_id, get_workspace_id, get_api_key from app.core.api_rate_limit import enforce_resource_create_rate_limit +from app.core.oss_quotas import enforce_oss_quota from app.core.auth import Principal from app.services.billing.flexprice_service import record_agent_test_setup_generated from app.models.database import ( @@ -597,6 +598,7 @@ async def create_agent( The agent is stamped with the active workspace from the ``X-Workspace-Id`` header (falling back to the org's Default). """ + enforce_oss_quota(db, organization_id, "agents") # Validate phone_number is provided when call_medium is phone_call if agent.call_medium == CallMediumEnumSchema.PHONE_CALL and not agent.phone_number: raise HTTPException( diff --git a/app/api/v1/routes/aiproviders.py b/app/api/v1/routes/aiproviders.py index 7f8f8e01..e7bb069f 100644 --- a/app/api/v1/routes/aiproviders.py +++ b/app/api/v1/routes/aiproviders.py @@ -91,6 +91,7 @@ def _scrub_for_response( def _validate_routing_and_api_key( *, + organization_id: UUID, routing_mode: CredentialRoutingMode, api_key: Optional[str], gateway_model: Optional[str], @@ -99,15 +100,18 @@ def _validate_routing_and_api_key( mode = routing_mode.value if hasattr(routing_mode, "value") else str(routing_mode) trimmed_key = (api_key or "").strip() + if mode == CredentialRoutingMode.GATEWAY.value: + from app.services.ai.llm_gateway_settings import assert_llm_gateway_entitlement + + assert_llm_gateway_entitlement(organization_id) + return + if mode == CredentialRoutingMode.DIRECT.value and not trimmed_key and not has_existing_key: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="api_key is required when routing_mode is direct.", ) - if mode == CredentialRoutingMode.GATEWAY.value: - return - if mode == CredentialRoutingMode.INHERIT.value: if not trimmed_key and not has_existing_key: if settings.LLM_GATEWAY_PASSTHROUGH_PROVIDER_KEYS: @@ -153,6 +157,7 @@ async def create_aiprovider( provider_value = aiprovider.provider.value if hasattr(aiprovider.provider, 'value') else aiprovider.provider _validate_routing_and_api_key( + organization_id=organization_id, routing_mode=aiprovider.routing_mode, api_key=aiprovider.api_key, gateway_model=aiprovider.gateway_model, @@ -287,6 +292,7 @@ async def update_aiprovider( if "routing_mode" in update_data or "api_key" in update_data or "gateway_model" in update_data: _validate_routing_and_api_key( + organization_id=organization_id, routing_mode=next_routing_mode, api_key=next_api_key, gateway_model=next_gateway_model, diff --git a/app/api/v1/routes/alerts.py b/app/api/v1/routes/alerts.py index be9cf9ce..cb3e2424 100644 --- a/app/api/v1/routes/alerts.py +++ b/app/api/v1/routes/alerts.py @@ -9,7 +9,7 @@ from datetime import datetime, timezone from app.database import get_db -from app.dependencies import get_organization_id +from app.dependencies import get_organization_id, require_enterprise_feature from app.models.database import Alert, AlertHistory from app.models.enums import AlertStatus, AlertHistoryStatus from app.models.schemas import ( @@ -22,7 +22,11 @@ from app.services.alerts.alert_evaluation_service import alert_evaluation_service from app.services.alerts.alert_notification_service import alert_notification_service -router = APIRouter(prefix="/alerts", tags=["alerts"]) +router = APIRouter( + prefix="/alerts", + tags=["alerts"], + dependencies=[Depends(require_enterprise_feature("alerts"))], +) # ============================================ diff --git a/app/api/v1/routes/iam.py b/app/api/v1/routes/iam.py index c67f464e..0f0f3c4d 100644 --- a/app/api/v1/routes/iam.py +++ b/app/api/v1/routes/iam.py @@ -12,6 +12,7 @@ import secrets from app.dependencies import get_db, get_organization_id, get_api_key +from app.core.oss_quotas import enforce_oss_quota from app.core.auth import Principal, get_principal from app.core.auth.rbac import require_admin from app.models.database import ( @@ -282,6 +283,7 @@ async def invite_user( Invite a user to the organization. Requires ADMIN role. """ + enforce_oss_quota(db, organization_id, "org_members") # Check if user already exists existing_user = db.query(User).filter(User.email == invitation_data.email).first() diff --git a/app/api/v1/routes/integrations.py b/app/api/v1/routes/integrations.py index 338f295b..8dab7a66 100644 --- a/app/api/v1/routes/integrations.py +++ b/app/api/v1/routes/integrations.py @@ -72,6 +72,12 @@ async def create_integration( Requires at least WRITER role. """ from sqlalchemy import func + from app.models.enums import CredentialRoutingMode + from app.services.ai.llm_gateway_settings import assert_llm_gateway_entitlement + + if integration_data.routing_mode == CredentialRoutingMode.GATEWAY: + assert_llm_gateway_entitlement(organization_id) + platform_value = integration_data.platform.value if hasattr(integration_data.platform, 'value') else integration_data.platform user_details = None @@ -282,6 +288,11 @@ async def update_integration( integration.is_active = integration_update.is_active if integration_update.routing_mode is not None: + from app.models.enums import CredentialRoutingMode + from app.services.ai.llm_gateway_settings import assert_llm_gateway_entitlement + + if integration_update.routing_mode == CredentialRoutingMode.GATEWAY: + assert_llm_gateway_entitlement(organization_id) integration.routing_mode = integration_update.routing_mode.value db.commit() diff --git a/app/api/v1/routes/metric_studio.py b/app/api/v1/routes/metric_studio.py index 10b407ca..e4984a0a 100644 --- a/app/api/v1/routes/metric_studio.py +++ b/app/api/v1/routes/metric_studio.py @@ -10,7 +10,12 @@ from sqlalchemy.orm import Session from app.database import get_db -from app.dependencies import get_api_key, get_organization_id, get_workspace_id +from app.dependencies import ( + get_api_key, + get_organization_id, + get_workspace_id, + require_enterprise_feature, +) from app.models.database import ( Metric, MetricStudioRun, @@ -28,7 +33,11 @@ from app.services.metric_studio.run_rollup import rollup_metric_studio_run from app.services.metric_studio.source_resolver import resolve_source -router = APIRouter(prefix="/metric-studio", tags=["metric-studio"]) +router = APIRouter( + prefix="/metric-studio", + tags=["metric-studio"], + dependencies=[Depends(require_enterprise_feature("metric_studio"))], +) def _serialize_run(run: MetricStudioRun) -> MetricStudioRunResponse: diff --git a/app/api/v1/routes/metrics.py b/app/api/v1/routes/metrics.py index 5d54f619..6251a779 100644 --- a/app/api/v1/routes/metrics.py +++ b/app/api/v1/routes/metrics.py @@ -13,6 +13,7 @@ from app.database import get_db from app.dependencies import get_organization_id, get_api_key, get_workspace_id +from app.core.oss_quotas import enforce_oss_quota from app.services.billing.flexprice_service import record_metrics_llm_assist from app.models.database import Metric, MetricCategory, MetricType, MetricTrigger, ModelProvider from app.models.schemas import ( @@ -240,6 +241,7 @@ def create_metric( ``parent_metric_id`` is set so a stale UI can't accidentally split a tree across workspaces or scopes. """ + enforce_oss_quota(db, organization_id, "metrics") _validate_hierarchy_fields( organization_id, db, @@ -359,6 +361,7 @@ def create_metric_draft( db: Session = Depends(get_db), ): """Create a draft metric for Metrics Studio (hidden from production flows).""" + enforce_oss_quota(db, organization_id, "metrics") _validate_hierarchy_fields( organization_id, db, @@ -455,6 +458,12 @@ def _create_metric_with_children( lifecycle: str = "active", studio_notes: Optional[str] = None, ) -> Metric: + enforce_oss_quota( + db, + organization_id, + "metrics", + additional=1 + len(payload.children), + ) if payload.selection_mode not in _VALID_SELECTION_MODES: raise HTTPException( status_code=400, @@ -686,6 +695,7 @@ def add_metric_child( db: Session = Depends(get_db), ): """Append a new child sub-metric under an existing parent.""" + enforce_oss_quota(db, organization_id, "metrics") parent = ( db.query(Metric) diff --git a/app/api/v1/routes/prompt_optimization.py b/app/api/v1/routes/prompt_optimization.py index cae668e2..5c39df06 100644 --- a/app/api/v1/routes/prompt_optimization.py +++ b/app/api/v1/routes/prompt_optimization.py @@ -1,5 +1,5 @@ """ -API routes for GEPA prompt optimization (Enterprise feature). +API routes for GEPA prompt optimization. Allows users to trigger optimization runs for voice agents, view candidates, accept the best prompt, and push it to the voice provider. @@ -19,7 +19,6 @@ get_organization_id, get_workspace_id, get_api_key, - require_enterprise_feature, ) from app.services.billing.flexprice_service import record_prompt_optimization_run_started from app.models.database import ( @@ -38,7 +37,6 @@ router = APIRouter( prefix="/prompt-optimization", tags=["Prompt Optimization"], - dependencies=[Depends(require_enterprise_feature("gepa_optimization"))], ) diff --git a/app/api/v1/routes/settings.py b/app/api/v1/routes/settings.py index 83225380..44649b00 100644 --- a/app/api/v1/routes/settings.py +++ b/app/api/v1/routes/settings.py @@ -27,11 +27,12 @@ from app.api.v1.routes.profile import get_current_user from app.core.exceptions import StorageError from app.core.license import ( + ENTERPRISE_FEATURES, get_feature_catalog, + get_features_enabled_for_org, get_license_info, - is_feature_enabled, - ENTERPRISE_FEATURES, ) +from app.core.oss_quotas import get_quota_usage, get_quotas_snapshot from app.core.usage_entitlement import get_usage_policy @@ -77,16 +78,21 @@ class ReportBrandingResponse(BaseModel): @router.get("/license-info") -def license_info(organization_id: UUID = Depends(get_organization_id)): +def license_info( + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): """ Return the current enterprise license status and enabled features. When the license is scoped to an org_id, only returns features that match the requesting organization. """ data = get_license_info() - all_licensed = data.get("features", []) if isinstance(data.get("features"), list) else [] - enabled_for_org = [f for f in all_licensed if is_feature_enabled(f, organization_id)] + enabled_for_org = get_features_enabled_for_org(organization_id) usage_policy = get_usage_policy(organization_id) + quotas = get_quotas_snapshot(organization_id) + quota_usage = get_quota_usage(db, organization_id) + return { "is_enterprise": bool(enabled_for_org), "enabled_features": enabled_for_org, @@ -94,6 +100,8 @@ def license_info(organization_id: UUID = Depends(get_organization_id)): "feature_catalog": get_feature_catalog(), "organization": data.get("org_id"), "usage_policy": usage_policy.as_dict(), + "quotas": quotas.as_dict(), + "quota_usage": quota_usage.as_dict(), } diff --git a/app/api/v1/routes/workspaces.py b/app/api/v1/routes/workspaces.py index f48c0d48..ae2efbe6 100644 --- a/app/api/v1/routes/workspaces.py +++ b/app/api/v1/routes/workspaces.py @@ -15,6 +15,7 @@ from app.core.auth.rbac import get_org_role, require_admin, require_writer from app.database import get_db from app.dependencies import get_organization_id +from app.core.oss_quotas import enforce_oss_quota from app.models.database import RoleEnum, Workspace, WorkspaceMember, WorkspaceRole from app.models.schemas import ( WorkspaceCreate, @@ -104,6 +105,7 @@ def create_workspace( db: Session = Depends(get_db), ): """Create a new (non-default) workspace; creator becomes Workspace Admin.""" + enforce_oss_quota(db, organization_id, "workspaces") slug = (payload.slug or _slugify(payload.name)).strip().lower() if not slug: raise HTTPException( diff --git a/app/core/license.py b/app/core/license.py index b15f4bdf..6a974936 100644 --- a/app/core/license.py +++ b/app/core/license.py @@ -33,11 +33,6 @@ "description": "A/B test TTS providers with blind tests and quality analytics.", "category": "playground", }, - "gepa_optimization": { - "title": "Prompt Optimization", - "description": "Self-improving voice agents via reflective prompt evolution.", - "category": "optimization", - }, "call_imports": { "title": "Call Imports", "description": "Bulk-import production call recordings via CSV and run batch evaluations on them.", @@ -48,6 +43,36 @@ "description": "Cluster failed evaluation runs from LLM rationales to surface recurring failure patterns.", "category": "evaluation", }, + "alerts": { + "title": "Alerting", + "description": "Threshold-based alerts on evaluation metrics with email and webhook notifications.", + "category": "monitoring", + }, + "metric_studio": { + "title": "Metric Studio", + "description": "Batch ad-hoc metric scoring runs against evaluation results and call imports.", + "category": "evaluation", + }, + "db_sharding": { + "title": "Call Import DB Sharding", + "description": "Horizontally shard call-import row storage across multiple database nodes.", + "category": "operations", + }, + "llm_gateway": { + "title": "LLM Gateway", + "description": "Route batch LLM workloads through Bifrost or LiteLLM Proxy from Integrations.", + "category": "integrations", + }, + "enterprise_platform": { + "title": "Enterprise Platform", + "description": "Unlocks default enterprise offerings (alerts, metric studio, sharding, gateway).", + "category": "platform", + }, + "gepa_optimization": { + "title": "Prompt Optimization", + "description": "Self-improving voice agents via reflective prompt evolution (GEPA). Open source — catalog entry kept for legacy JWT compatibility.", + "category": "optimization", + }, # --- Authentication features (gate pluggable auth providers) --- "oidc_sso": { "title": "Enterprise SSO (OIDC)", @@ -89,6 +114,14 @@ # Backward-compatible export used by existing API response shape. ENTERPRISE_FEATURES = list(FEATURE_CATALOG.keys()) +# Included with any valid enterprise contract (JWT with at least one catalog feature). +DEFAULT_ENTERPRISE_OFFERINGS: List[str] = [ + "alerts", + "metric_studio", + "db_sharding", + "llm_gateway", +] + # RSA public key used to verify enterprise license JWTs. # The corresponding private key is kept offline by the EfficientAI team. # Even though this key is visible in the source, it can only VERIFY — not sign — tokens. @@ -173,26 +206,52 @@ def get_licensed_org_id() -> Optional[str]: return get_license_info().get("org_id") +def _license_applies_to_org(organization_id: Optional[UUID] = None) -> bool: + """True when a valid JWT is present and applies to the given organization.""" + if not get_enabled_features(): + return False + + licensed_org = get_license_info().get("org_id") + if licensed_org is None: + return True + + if organization_id is None: + return False + + return str(organization_id) == str(licensed_org) + + def is_feature_enabled(feature: str, organization_id: Optional[UUID] = None) -> bool: """ Check whether an enterprise feature is enabled. If the license contains an org_id, the requesting organization must match. If org_id is absent from the license, the feature is enabled deployment-wide. + + Features in ``DEFAULT_ENTERPRISE_OFFERINGS`` are enabled for any org with + a valid enterprise entitlement, even when omitted from the JWT feature list. """ - info = get_license_info() - if feature not in get_enabled_features(): - return False + if feature in get_enabled_features(): + return _license_applies_to_org(organization_id) - licensed_org = info.get("org_id") - if licensed_org is None: + if feature in DEFAULT_ENTERPRISE_OFFERINGS and _license_applies_to_org( + organization_id + ): return True - # For org-scoped licenses, we require a concrete requesting organization. - if organization_id is None: - return False + return False - return str(organization_id) == str(licensed_org) + +def get_features_enabled_for_org(organization_id: Optional[UUID] = None) -> List[str]: + """Return all feature IDs enabled for an organization (JWT + default offerings).""" + enabled: List[str] = [] + seen: set[str] = set() + for feature_id in FEATURE_CATALOG: + if is_feature_enabled(feature_id, organization_id): + if feature_id not in seen: + enabled.append(feature_id) + seen.add(feature_id) + return enabled def has_auth_feature(feature: str) -> bool: diff --git a/app/core/oss_quotas.py b/app/core/oss_quotas.py new file mode 100644 index 00000000..343746d9 --- /dev/null +++ b/app/core/oss_quotas.py @@ -0,0 +1,194 @@ +"""Open-source quantity limits for organizations without enterprise entitlement.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, Optional +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy import and_, or_ +from sqlalchemy.orm import Session + +from app.core.usage_entitlement import has_enterprise_entitlement +from app.models.database import Agent, Metric, OrganizationMember, Workspace + +OSS_MAX_USER_METRICS = 5 +OSS_MAX_AGENTS = 3 +OSS_MAX_ORG_MEMBERS = 2 +OSS_MAX_WORKSPACES = 1 + +OssQuotaResource = Literal["metrics", "agents", "org_members", "workspaces"] + +_RESOURCE_LIMITS: dict[OssQuotaResource, int] = { + "metrics": OSS_MAX_USER_METRICS, + "agents": OSS_MAX_AGENTS, + "org_members": OSS_MAX_ORG_MEMBERS, + "workspaces": OSS_MAX_WORKSPACES, +} + +_RESOURCE_MESSAGES: dict[OssQuotaResource, str] = { + "metrics": ( + "Open source deployments are limited to " + f"{OSS_MAX_USER_METRICS} user-created metrics per organization. " + "Seeded default metrics do not count toward this limit." + ), + "agents": ( + f"Open source deployments are limited to {OSS_MAX_AGENTS} agents " + "per organization." + ), + "org_members": ( + f"Open source deployments are limited to {OSS_MAX_ORG_MEMBERS} " + "organization members (including the creator)." + ), + "workspaces": ( + f"Open source deployments are limited to {OSS_MAX_WORKSPACES} " + "workspace (the default workspace)." + ), +} + + +@dataclass(frozen=True) +class OssQuotasSnapshot: + max_user_metrics: Optional[int] + max_agents: Optional[int] + max_org_members: Optional[int] + max_workspaces: Optional[int] + + def as_dict(self) -> dict: + return { + "max_user_metrics": self.max_user_metrics, + "max_agents": self.max_agents, + "max_org_members": self.max_org_members, + "max_workspaces": self.max_workspaces, + } + + +@dataclass(frozen=True) +class OssQuotaUsageSnapshot: + user_metrics: int + agents: int + org_members: int + workspaces: int + + def as_dict(self) -> dict: + return { + "user_metrics": self.user_metrics, + "agents": self.agents, + "org_members": self.org_members, + "workspaces": self.workspaces, + } + + +def _user_metric_filter(organization_id: UUID): + return and_( + Metric.organization_id == organization_id, + Metric.is_default.is_(False), + or_( + Metric.metric_origin.is_(None), + Metric.metric_origin != "default", + ), + ) + + +def count_user_metrics(db: Session, organization_id: UUID) -> int: + return ( + db.query(Metric) + .filter(_user_metric_filter(organization_id)) + .count() + ) + + +def count_agents(db: Session, organization_id: UUID) -> int: + return ( + db.query(Agent) + .filter(Agent.organization_id == organization_id) + .count() + ) + + +def count_org_members(db: Session, organization_id: UUID) -> int: + return ( + db.query(OrganizationMember) + .filter(OrganizationMember.organization_id == organization_id) + .count() + ) + + +def count_workspaces(db: Session, organization_id: UUID) -> int: + return ( + db.query(Workspace) + .filter(Workspace.organization_id == organization_id) + .count() + ) + + +def get_quota_usage(db: Session, organization_id: UUID) -> OssQuotaUsageSnapshot: + return OssQuotaUsageSnapshot( + user_metrics=count_user_metrics(db, organization_id), + agents=count_agents(db, organization_id), + org_members=count_org_members(db, organization_id), + workspaces=count_workspaces(db, organization_id), + ) + + +def get_quotas_snapshot(organization_id: UUID) -> OssQuotasSnapshot: + if has_enterprise_entitlement(organization_id): + return OssQuotasSnapshot( + max_user_metrics=None, + max_agents=None, + max_org_members=None, + max_workspaces=None, + ) + return OssQuotasSnapshot( + max_user_metrics=OSS_MAX_USER_METRICS, + max_agents=OSS_MAX_AGENTS, + max_org_members=OSS_MAX_ORG_MEMBERS, + max_workspaces=OSS_MAX_WORKSPACES, + ) + + +def _count_for_resource( + db: Session, + organization_id: UUID, + resource: OssQuotaResource, +) -> int: + if resource == "metrics": + return count_user_metrics(db, organization_id) + if resource == "agents": + return count_agents(db, organization_id) + if resource == "org_members": + return count_org_members(db, organization_id) + if resource == "workspaces": + return count_workspaces(db, organization_id) + raise ValueError(f"Unknown OSS quota resource: {resource}") + + +def enforce_oss_quota( + db: Session, + organization_id: UUID, + resource: OssQuotaResource, + *, + additional: int = 1, +) -> None: + """Raise HTTP 403 when an OSS org would exceed a quantity cap.""" + if has_enterprise_entitlement(organization_id): + return + + limit = _RESOURCE_LIMITS[resource] + current = _count_for_resource(db, organization_id, resource) + if current + additional > limit: + raise HTTPException( + status_code=403, + detail={ + "error": "oss_quota_exceeded", + "resource": resource, + "limit": limit, + "current": current, + "message": ( + f"{_RESOURCE_MESSAGES[resource]} " + "Set EFFICIENTAI_LICENSE to unlock unlimited capacity. " + "Contact sales@efficientai.com for an enterprise license key." + ), + }, + ) diff --git a/app/db_sharding/sessions.py b/app/db_sharding/sessions.py index 45a44c64..88b90fc8 100644 --- a/app/db_sharding/sessions.py +++ b/app/db_sharding/sessions.py @@ -34,4 +34,25 @@ def row_shard_session( def is_sharding_enabled() -> bool: - return db_pool_manager.sharding_enabled + if not db_pool_manager.sharding_enabled: + return False + + from app.core.license import is_feature_enabled + from app.core.usage_entitlement import deployment_has_entitlement + from loguru import logger + + if not deployment_has_entitlement(): + logger.warning( + "DB_SHARDING_ENABLED is true but no deployment-wide enterprise " + "license is present — sharding remains disabled." + ) + return False + + if not is_feature_enabled("db_sharding"): + logger.warning( + "DB_SHARDING_ENABLED is true but db_sharding is not enabled " + "by the enterprise license — sharding remains disabled." + ) + return False + + return True diff --git a/app/services/ai/llm_gateway.py b/app/services/ai/llm_gateway.py index 2283a3a5..d07ef42a 100644 --- a/app/services/ai/llm_gateway.py +++ b/app/services/ai/llm_gateway.py @@ -516,6 +516,11 @@ def resolve_effective_routing( if credential_mode == "direct": return None, "direct" + from app.core.license import is_feature_enabled + + if not is_feature_enabled("llm_gateway", organization_id): + return None, "direct" + use_gateway = _org_wants_gateway(org, platform, credential_mode=credential_mode) if not use_gateway: return None, "direct" diff --git a/app/services/ai/llm_gateway_settings.py b/app/services/ai/llm_gateway_settings.py index 25fc8c6f..58a28cb7 100644 --- a/app/services/ai/llm_gateway_settings.py +++ b/app/services/ai/llm_gateway_settings.py @@ -144,6 +144,27 @@ def get_org_settings(organization_id: UUID, db: Session) -> Dict[str, Any]: } +def assert_llm_gateway_entitlement(organization_id: UUID) -> None: + """Raise 403 when LLM gateway enablement requires an enterprise license.""" + from app.core.license import is_feature_enabled + + if is_feature_enabled("llm_gateway", organization_id): + return + + raise HTTPException( + status_code=403, + detail={ + "error": "enterprise_feature_required", + "feature": "llm_gateway", + "message": ( + "'llm_gateway' is an EfficientAI Enterprise feature. " + "Please set EFFICIENTAI_LICENSE in your environment to unlock it. " + "Contact sales@efficientai.com to get an enterprise license key." + ), + }, + ) + + def set_org_settings( organization_id: UUID, db: Session, @@ -162,6 +183,9 @@ def set_org_settings( if not org: raise HTTPException(status_code=404, detail="Organization not found") + if mode == "enabled": + assert_llm_gateway_entitlement(organization_id) + existing = dict(org.llm_gateway_settings or {}) platform = _platform_config() payload: Dict[str, Any] = { diff --git a/app/services/alerts/alert_evaluation_service.py b/app/services/alerts/alert_evaluation_service.py index f142f64d..dd775769 100644 --- a/app/services/alerts/alert_evaluation_service.py +++ b/app/services/alerts/alert_evaluation_service.py @@ -66,12 +66,16 @@ def evaluate_all_alerts(self, db: Session) -> Dict[str, Any]: """ logger.info("[AlertEvaluation] Starting evaluation of all active alerts") - # Get all active alerts - active_alerts = ( - db.query(Alert) + from app.core.license import is_feature_enabled + + # Get all active alerts for orgs with alerting entitlement + active_alerts = [ + alert + for alert in db.query(Alert) .filter(Alert.status == AlertStatus.ACTIVE.value) .all() - ) + if is_feature_enabled("alerts", alert.organization_id) + ] logger.info(f"[AlertEvaluation] Found {len(active_alerts)} active alerts to evaluate") diff --git a/app/services/invitation_service.py b/app/services/invitation_service.py index b3d18db7..b6a09ea9 100644 --- a/app/services/invitation_service.py +++ b/app/services/invitation_service.py @@ -128,6 +128,10 @@ def accept_invitation( db.commit() return existing_member + from app.core.oss_quotas import enforce_oss_quota + + enforce_oss_quota(db, invitation.organization_id, "org_members") + member = OrganizationMember( organization_id=invitation.organization_id, user_id=user.id, diff --git a/docs-fumadocs/content/docs/enterprise/index.mdx b/docs-fumadocs/content/docs/enterprise/index.mdx new file mode 100644 index 00000000..5954c800 --- /dev/null +++ b/docs-fumadocs/content/docs/enterprise/index.mdx @@ -0,0 +1,83 @@ +--- +title: Enterprise +--- + +# Enterprise + +> **Enterprise quickstart** +> +> Set `EFFICIENTAI_LICENSE` (or `license.key` in `config.yml`) with a JWT issued +> by EfficientAI. Restart the backend and open **Settings → License** (or call +> `GET /api/v1/settings/license-info`) to verify enabled features. + +## Commercial license / license key + +EfficientAI uses a **dual-license model** (similar to LiteLLM): + +- The open-source core is **MIT** — see the root [LICENSE](https://github.com/EfficientAI-tech/efficientAI/blob/main/LICENSE). +- Enterprise terms for `enterprise/` and gated product capabilities are in + [enterprise/LICENSE.md](https://github.com/EfficientAI-tech/efficientAI/blob/main/enterprise/LICENSE.md). + +Enterprise features are unlocked with an **`EFFICIENTAI_LICENSE`** JWT (same +pattern as LiteLLM's `LITELLM_LICENSE` or Bifrost's `BIFROST_LICENSE`). Contact +[sales@efficientai.com](mailto:sales@efficientai.com) or visit +[efficientai.cloud](https://efficientai.cloud). + +## Who is Enterprise for? + +Enterprise is designed for teams running voice agent evaluation in production where you need stronger identity controls, post-production analytics, and operational governance. + +## Open source vs Enterprise + +| Capability | Open source | Enterprise | +|---|---|---| +| Voice bundles / BYOK | Included | Included | +| Agents, personas, scenarios | Up to **3 agents** | Unlimited | +| Metrics | Up to **5 user-created** (+ seeded defaults) | Unlimited | +| Evaluators | Included | + failure clustering | +| Prompt optimization (GEPA) | **Included** | Included | +| Agent playground | Included | Included | +| Metric Studio | **Not included** | Included | +| Alerts | **Not included** | Included | +| LLM gateway (Integrations) | **Not included** | Included | +| DB sharding (call imports) | **Not included** | Included | +| Post-production analytics | **Not included** | Call imports | +| Playground | Agent playground | + Voice playground | +| Usage history | **7 days** | Unlimited | +| Usage pricing overrides | **Not included** | Included | +| IAM / workspaces | **2 members**, **1 workspace** | Unlimited | +| Auth | API key + local password | OIDC, SAML, SCIM, MFA, audit export | + +Existing resources above OSS limits are **grandfathered** (readable/editable); +new creates require an enterprise license. + +## What is Enterprise + +Enterprise unlocks production-scale evaluation: bulk call import, voice A/B testing, +extended usage analytics, alerting, metric studio batch runs, LLM gateway routing, +and optional SSO / SCIM / MFA. + +Any valid enterprise contract (JWT with at least one feature) includes default +offerings: **alerts**, **metric studio**, **DB sharding**, and **LLM gateway**, +plus a-la-carte features listed in the JWT. + +## Feature IDs + +| Feature ID | Enterprise capability | +|---|---| +| `call_imports` | Call Imports | +| `voice_playground` | Voice Playground | +| `evaluation_clustering` | Evaluation Failure Clustering | +| `alerts` | Alerting | +| `metric_studio` | Metric Studio | +| `db_sharding` | Call-import DB sharding | +| `llm_gateway` | LLM gateway (Bifrost / LiteLLM Proxy) | +| `enterprise_platform` | Umbrella — unlocks default offerings only | +| `oidc_sso` | Enterprise SSO (OIDC) | +| `saml_sso` | SAML SSO | +| `scim_provisioning` | SCIM User Provisioning | +| `mfa_enforce` | Enforced MFA | +| `audit_export` | Audit Log Export | + +Open source deployments keep a **7-day** usage analytics history by default. +Enterprise licenses remove that cap. diff --git a/enterprise/LICENSE.md b/enterprise/LICENSE.md index 89cd2179..cfe884f5 100644 --- a/enterprise/LICENSE.md +++ b/enterprise/LICENSE.md @@ -8,30 +8,44 @@ The code and content within the `enterprise/` directory of this repository is licensed under this Enterprise License Agreement. It is **not** covered by the MIT License that applies to the rest of the repository. +Certain product capabilities in the main codebase are also gated behind a +valid EfficientAI Enterprise license key (`EFFICIENTAI_LICENSE`). Without a +valid key, those capabilities are unavailable or subject to open-source usage +limits as documented at https://efficientai.cloud. + ### Permitted Use -You may use the enterprise-licensed code **only** if you hold a valid -EfficientAI Enterprise license key issued by EfficientAI.cloud. +You may use enterprise-licensed code and enterprise-gated product features +**only** if you hold a valid EfficientAI Enterprise license key issued by +EfficientAI.cloud. A valid license grants you the right to: - Deploy and run enterprise features in your own infrastructure - Modify enterprise code for internal use within the scope of your license +- Unlock enterprise product capabilities according to the features encoded + in your license JWT ### Restrictions Without a valid enterprise license, you may **not**: - Use, copy, modify, or distribute the enterprise-licensed code in - production environments + production environments beyond the open-source limits - Remove or circumvent the enterprise license validation mechanism + (`EFFICIENTAI_LICENSE` / `license.key` in config) - Sublicense, sell, or redistribute the enterprise-licensed code ### License Keys -Enterprise license keys are available by contacting the EfficientAI team: +Enterprise license keys are JWT tokens signed by EfficientAI. Set them via: + +- Environment variable: `EFFICIENTAI_LICENSE` +- Config file: `license.key` in `config.yml` + +Keys are available by contacting the EfficientAI team: -- **Email:** aadhar@efficientai.cloud +- **Email:** sales@efficientai.com / aadhar@efficientai.cloud - **Website:** https://efficientai.cloud ### Disclaimer diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2ee0bdc6..8acc0811 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -186,7 +186,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> @@ -195,8 +195,8 @@ function App() { } /> }> } /> - } /> - } /> + } /> + } /> } /> } /> @@ -222,9 +222,9 @@ function App() { /> } /> } /> - } /> - } /> - } /> + } /> + } /> + } /> } /> } /> } /> @@ -245,7 +245,7 @@ function App() { path="call-imports/:id/evaluations/:evalId" element={} /> - } /> + } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 5e5f7d38..c68d4840 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -91,7 +91,7 @@ const navigationSections: NavSection[] = [ icon: ScrollText, items: [ { name: 'Partials', href: '/prompt-partials', icon: FileText }, - { name: 'Optimization', href: '/prompt-optimization', icon: Sparkles, enterpriseFeature: 'gepa_optimization' }, + { name: 'Optimization', href: '/prompt-optimization', icon: Sparkles }, ], }, { @@ -113,8 +113,8 @@ const navigationSections: NavSection[] = [ title: 'Alerting', icon: Bell, items: [ - { name: 'Alerts', href: '/alerts', icon: Bell }, - { name: 'Alert History', href: '/alerts/history', icon: History }, + { name: 'Alerts', href: '/alerts', icon: Bell, enterpriseFeature: 'alerts' }, + { name: 'Alert History', href: '/alerts/history', icon: History, enterpriseFeature: 'alerts' }, ], }, { diff --git a/frontend/src/components/WorkspaceSwitcher.tsx b/frontend/src/components/WorkspaceSwitcher.tsx index d905af68..18e7aa80 100644 --- a/frontend/src/components/WorkspaceSwitcher.tsx +++ b/frontend/src/components/WorkspaceSwitcher.tsx @@ -9,12 +9,15 @@ import type { Workspace } from '../types/api' import { useCanWrite } from '../hooks/useRole' import { useWorkspaceStore } from '../store/workspaceStore' import CreateWorkspaceModal from './CreateWorkspaceModal' +import { useOssQuotas } from '../hooks/useOssQuotas' export default function WorkspaceSwitcher() { const queryClient = useQueryClient() const navigate = useNavigate() const location = useLocation() const canWrite = useCanWrite() + const { isAtLimit, limitMessage } = useOssQuotas() + const canCreateWorkspace = canWrite && !isAtLimit('workspaces') const activeId = useWorkspaceStore((s) => s.activeWorkspaceId) const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace) const setActiveCapabilities = useWorkspaceStore((s) => s.setActiveCapabilities) @@ -100,6 +103,7 @@ export default function WorkspaceSwitcher() { } const openCreateModal = () => { + if (isAtLimit('workspaces')) return setOpen(false) setShowCreateModal(true) } @@ -140,11 +144,12 @@ export default function WorkspaceSwitcher() {
Workspaces - {canWrite && ( + {canCreateWorkspace && ( @@ -155,7 +160,7 @@ export default function WorkspaceSwitcher() { {workspaces.length === 0 && !isLoading && (
No workspaces available. - {canWrite && ( + {canCreateWorkspace && ( @@ -318,7 +330,7 @@ export default function AgentsWorkspace() {

No agents yet

Create your first test agent to get started

-
diff --git a/frontend/src/pages/configurations/Integrations.tsx b/frontend/src/pages/configurations/Integrations.tsx index 2cbaddf6..02fe99f9 100644 --- a/frontend/src/pages/configurations/Integrations.tsx +++ b/frontend/src/pages/configurations/Integrations.tsx @@ -21,6 +21,7 @@ import { getTelephonyProviderLogo, } from '../../config/providers' import WalkthroughToggleButton from '../../components/walkthrough/WalkthroughToggleButton' +import { useLicenseStore } from '../../store/licenseStore' import AIProviderEnabledModelsStep from './AIProviderEnabledModelsStep' type IntegrationType = 'voice_platform' | 'ai_provider' | 'telephony_provider' | null @@ -44,6 +45,8 @@ const AI_INTEGRATION_PROVIDERS: ModelProvider[] = [ export default function Integrations() { const queryClient = useQueryClient() const { showToast, ToastContainer } = useToast() + const isFeatureEnabled = useLicenseStore((s) => s.isFeatureEnabled) + const llmGatewayLicensed = isFeatureEnabled('llm_gateway') const [showModal, setShowModal] = useState(false) const [isEditMode, setIsEditMode] = useState(false) const [integrationType, setIntegrationType] = useState(null) @@ -988,6 +991,14 @@ export default function Integrations() { Route batch and evaluation LLM calls through Bifrost or a self-hosted LiteLLM Proxy. Real-time voice agents are unaffected.

+ {!llmGatewayLicensed && ( +
+ LLM gateway enablement is an Enterprise feature. Set{' '} + EFFICIENTAI_LICENSE{' '} + to unlock Bifrost / LiteLLM Proxy routing. +
+ )} +
setLlmGatewayMode(e.target.value as LLMGatewayMode)} className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm" + disabled={!llmGatewayLicensed && llmGatewayMode !== 'disabled' && llmGatewayMode !== 'inherit'} > - +
diff --git a/frontend/src/pages/iam/IAM.tsx b/frontend/src/pages/iam/IAM.tsx index cbb6375e..31b88d17 100644 --- a/frontend/src/pages/iam/IAM.tsx +++ b/frontend/src/pages/iam/IAM.tsx @@ -13,6 +13,7 @@ import WorkspaceRolesSection from '../../components/WorkspaceRolesSection' import WorkspaceMembersSection from '../../components/iam/WorkspaceMembersSection' import { PASSWORD_POLICY_HINT, validatePasswordPolicy } from '../../lib/passwordPolicy' import { buildInviteShareUrl } from '../../lib/inviteUrl' +import { useOssQuotas } from '../../hooks/useOssQuotas' type IamTab = 'organization' | 'workspace-members' | 'workspace-roles' @@ -26,6 +27,7 @@ export default function IAM() { const queryClient = useQueryClient() const { showToast, ToastContainer } = useToast() const isAdmin = useIsAdmin() + const { isAtLimit, limitMessage } = useOssQuotas() const [searchParams, setSearchParams] = useSearchParams() const tabParam = searchParams.get('tab') const activeTab: IamTab = @@ -325,8 +327,16 @@ export default function IAM() { {isAdmin && activeTab === 'organization' && ( diff --git a/frontend/src/pages/metrics/MetricsManagement.tsx b/frontend/src/pages/metrics/MetricsManagement.tsx index a74d42d2..a9570f6a 100644 --- a/frontend/src/pages/metrics/MetricsManagement.tsx +++ b/frontend/src/pages/metrics/MetricsManagement.tsx @@ -6,6 +6,7 @@ import Button from '../../components/Button' import AIProviderModelPicker from '../../components/AIProviderModelPicker' import type { LLMGenerationConfig } from '../../config/llmGenerationParams' import { useToast } from '../../hooks/useToast' +import { useOssQuotas } from '../../hooks/useOssQuotas' import { useWorkspaceStore } from '../../store/workspaceStore' import { Copy, @@ -198,7 +199,16 @@ export default function MetricsManagement({ // the previously-loaded metric library. const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId) const { showToast, ToastContainer } = useToast() + const { isAtLimit, limitMessage } = useOssQuotas() const [showCreateModal, setShowCreateModal] = useState(false) + + const tryOpenCreateModal = () => { + if (isAtLimit('user_metrics')) { + showToast(limitMessage('user_metrics'), 'error') + return + } + setShowCreateModal(true) + } const [isCustomMetricMode, setIsCustomMetricMode] = useState(false) const [showEnableModal, setShowEnableModal] = useState(false) // The unified "Create Metric" modal hosts two flows — pick the @@ -676,6 +686,11 @@ export default function MetricsManagement({ useEffect(() => { if (createModalOnly && createModalOpen) { + if (isAtLimit('user_metrics')) { + showToast(limitMessage('user_metrics'), 'error') + onCreateModalClose?.() + return + } setShowCreateModal(true) setIsCustomMetricMode(true) setEditingMetric(null) @@ -1185,6 +1200,10 @@ export default function MetricsManagement({ setFormData(singleFormFromMetricClipboard(payload, targetScope)) resetCategoryForm() } + if (isAtLimit('user_metrics')) { + showToast(limitMessage('user_metrics'), 'error') + return + } setShowCreateModal(true) showToast('Metric pasted — review scope and save', 'success') } catch (err) { @@ -1516,10 +1535,15 @@ export default function MetricsManagement({ scope: 'workspace', }) resetCategoryForm() - setShowCreateModal(true) + tryOpenCreateModal() }} leftIcon={} - title="Create a single custom metric or a parent category with sub-labels — switch flows from inside the modal" + disabled={isAtLimit('user_metrics')} + title={ + isAtLimit('user_metrics') + ? limitMessage('user_metrics') + : 'Create a single custom metric or a parent category with sub-labels — switch flows from inside the modal' + } > Create Custom Metric diff --git a/frontend/src/pages/metrics/components/MetricsTabBar.tsx b/frontend/src/pages/metrics/components/MetricsTabBar.tsx index 9bb41740..3ec65cab 100644 --- a/frontend/src/pages/metrics/components/MetricsTabBar.tsx +++ b/frontend/src/pages/metrics/components/MetricsTabBar.tsx @@ -1,13 +1,23 @@ import { Link, useLocation } from 'react-router-dom' -import { BarChart3, Sparkles } from 'lucide-react' +import { BarChart3, Lock, Sparkles, type LucideIcon } from 'lucide-react' +import { useLicenseStore } from '../../../store/licenseStore' -const TABS = [ +type MetricsTab = { + id: string + label: string + href: string + icon: LucideIcon + enterpriseFeature?: string +} + +const TABS: MetricsTab[] = [ { id: 'metrics', label: 'Metrics', href: '/metrics-management', icon: BarChart3 }, - { id: 'studio', label: 'Studio', href: '/metrics-management/studio', icon: Sparkles }, -] as const + { id: 'studio', label: 'Studio', href: '/metrics-management/studio', icon: Sparkles, enterpriseFeature: 'metric_studio' }, +] export default function MetricsTabBar() { const location = useLocation() + const isFeatureEnabled = useLicenseStore((s) => s.isFeatureEnabled) const isStudio = location.pathname.startsWith('/metrics-management/studio') @@ -17,6 +27,7 @@ export default function MetricsTabBar() { {TABS.map((tab) => { const active = tab.id === 'studio' ? isStudio : !isStudio const Icon = tab.icon + const isGated = tab.enterpriseFeature && !isFeatureEnabled(tab.enterpriseFeature) return ( {tab.label} + {isGated && } ) })} diff --git a/frontend/src/store/licenseStore.ts b/frontend/src/store/licenseStore.ts index 856ebc53..4ac32a1b 100644 --- a/frontend/src/store/licenseStore.ts +++ b/frontend/src/store/licenseStore.ts @@ -1,18 +1,40 @@ import { create } from 'zustand' import { apiClient } from '../lib/api' -import type { EnterpriseFeatureCatalog, EnterpriseFeatureMeta, UsagePolicy } from '../lib/api' +import type { + EnterpriseFeatureCatalog, + EnterpriseFeatureMeta, + OssQuotaUsage, + OssQuotas, + UsagePolicy, +} from '../lib/api' const DEFAULT_USAGE_POLICY: UsagePolicy = { extended_history: false, max_history_days: 7, } +const DEFAULT_OSS_QUOTAS: OssQuotas = { + max_user_metrics: 5, + max_agents: 3, + max_org_members: 2, + max_workspaces: 1, +} + +const DEFAULT_QUOTA_USAGE: OssQuotaUsage = { + user_metrics: 0, + agents: 0, + org_members: 0, + workspaces: 0, +} + interface LicenseState { isEnterprise: boolean enabledFeatures: string[] allEnterpriseFeatures: string[] featureCatalog: EnterpriseFeatureCatalog usagePolicy: UsagePolicy + quotas: OssQuotas | null + quotaUsage: OssQuotaUsage | null isLoaded: boolean fetchLicense: () => Promise isFeatureEnabled: (feature: string) => boolean @@ -26,6 +48,8 @@ export const useLicenseStore = create((set, get) => ({ allEnterpriseFeatures: [], featureCatalog: {}, usagePolicy: DEFAULT_USAGE_POLICY, + quotas: DEFAULT_OSS_QUOTAS, + quotaUsage: DEFAULT_QUOTA_USAGE, isLoaded: false, fetchLicense: async () => { @@ -37,6 +61,8 @@ export const useLicenseStore = create((set, get) => ({ allEnterpriseFeatures: info.all_enterprise_features, featureCatalog: info.feature_catalog ?? {}, usagePolicy: info.usage_policy ?? DEFAULT_USAGE_POLICY, + quotas: info.is_enterprise ? null : (info.quotas ?? DEFAULT_OSS_QUOTAS), + quotaUsage: info.quota_usage ?? DEFAULT_QUOTA_USAGE, isLoaded: true, }) } catch { @@ -46,6 +72,8 @@ export const useLicenseStore = create((set, get) => ({ allEnterpriseFeatures: [], featureCatalog: {}, usagePolicy: DEFAULT_USAGE_POLICY, + quotas: DEFAULT_OSS_QUOTAS, + quotaUsage: DEFAULT_QUOTA_USAGE, isLoaded: true, }) } diff --git a/tests/test_api/conftest.py b/tests/test_api/conftest.py index a6490272..4695b8a2 100644 --- a/tests/test_api/conftest.py +++ b/tests/test_api/conftest.py @@ -371,6 +371,7 @@ def _make_metric(**overrides): trigger=overrides.get("trigger", MetricTrigger.ALWAYS.value), enabled=overrides.get("enabled", True), is_default=overrides.get("is_default", False), + metric_origin=overrides.get("metric_origin", "custom"), custom_data_type=overrides.get("custom_data_type"), custom_config=overrides.get("custom_config"), capture_rationale=overrides.get("capture_rationale", False), diff --git a/tests/test_api/test_enterprise_gating.py b/tests/test_api/test_enterprise_gating.py new file mode 100644 index 00000000..b6c5e976 --- /dev/null +++ b/tests/test_api/test_enterprise_gating.py @@ -0,0 +1,67 @@ +"""API tests for OSS vs enterprise feature gates (unlicensed client).""" + +from __future__ import annotations + +import pytest + +import app.dependencies as app_dependencies +from app.core import license as license_module + + +@pytest.fixture +def unlicensed_client(authenticated_client): + """Authenticated client with real license checks and no enterprise JWT.""" + license_module.reset_license_cache() + license_module._license_cache = {} + app_dependencies.is_feature_enabled = license_module.is_feature_enabled + yield authenticated_client + app_dependencies.is_feature_enabled = lambda *_args, **_kwargs: True + license_module.reset_license_cache() + + +def test_alerts_forbidden_without_license(unlicensed_client): + payload = { + "name": "OSS Alert", + "metric_type": "number_of_calls", + "aggregation": "sum", + "operator": ">", + "threshold_value": 10, + "time_window_minutes": 60, + "notify_frequency": "immediate", + } + response = unlicensed_client.post("/api/v1/alerts", json=payload) + assert response.status_code == 403 + assert response.json()["detail"]["feature"] == "alerts" + + +def test_metric_studio_forbidden_without_license(unlicensed_client): + response = unlicensed_client.get("/api/v1/metric-studio/runs") + assert response.status_code == 403 + assert response.json()["detail"]["feature"] == "metric_studio" + + +def test_prompt_optimization_allowed_without_license(unlicensed_client, make_agent): + agent = make_agent(description="GEPA OSS") + response = unlicensed_client.post( + "/api/v1/prompt-optimization/runs", + json={"agent_id": str(agent.id), "config": {"max_iterations": 1}}, + ) + assert response.status_code == 201 + + +def test_llm_gateway_enable_forbidden_without_license(unlicensed_client): + response = unlicensed_client.put( + "/api/v1/organizations/llm-gateway", + json={"mode": "enabled", "gateway_type": "inherit", "gateway_interface": "inherit"}, + ) + assert response.status_code == 403 + assert response.json()["detail"]["feature"] == "llm_gateway" + + +def test_license_info_includes_oss_quotas(unlicensed_client): + response = unlicensed_client.get("/api/v1/settings/license-info") + assert response.status_code == 200 + body = response.json() + assert body["quotas"]["max_user_metrics"] == 5 + assert body["quotas"]["max_agents"] == 3 + assert "quota_usage" in body diff --git a/tests/test_core/test_license_offerings.py b/tests/test_core/test_license_offerings.py new file mode 100644 index 00000000..30bfca07 --- /dev/null +++ b/tests/test_core/test_license_offerings.py @@ -0,0 +1,53 @@ +"""Unit tests for default enterprise offerings and org-scoped feature resolution.""" + +from uuid import uuid4 + +from app.core import license as license_module + + +def test_default_offerings_enabled_with_any_entitlement(monkeypatch): + org_id = uuid4() + monkeypatch.setattr( + license_module, + "get_license_info", + lambda: {"features": ["call_imports"], "org_id": None}, + ) + monkeypatch.setattr( + license_module, + "get_enabled_features", + lambda: ["call_imports"], + ) + + assert license_module.is_feature_enabled("alerts", org_id) is True + assert license_module.is_feature_enabled("metric_studio", org_id) is True + assert license_module.is_feature_enabled("db_sharding", org_id) is True + assert license_module.is_feature_enabled("llm_gateway", org_id) is True + + +def test_default_offerings_absent_without_license(monkeypatch): + org_id = uuid4() + monkeypatch.setattr(license_module, "get_license_info", lambda: {}) + monkeypatch.setattr(license_module, "get_enabled_features", lambda: []) + + assert license_module.is_feature_enabled("alerts", org_id) is False + assert license_module.is_feature_enabled("call_imports", org_id) is False + + +def test_get_features_enabled_for_org_includes_defaults(monkeypatch): + org_id = uuid4() + monkeypatch.setattr( + license_module, + "get_license_info", + lambda: {"features": ["enterprise_platform"], "org_id": str(org_id)}, + ) + monkeypatch.setattr( + license_module, + "get_enabled_features", + lambda: ["enterprise_platform"], + ) + + enabled = license_module.get_features_enabled_for_org(org_id) + + assert "enterprise_platform" in enabled + assert "alerts" in enabled + assert "metric_studio" in enabled diff --git a/tests/test_core/test_oss_quotas.py b/tests/test_core/test_oss_quotas.py new file mode 100644 index 00000000..1d9b569d --- /dev/null +++ b/tests/test_core/test_oss_quotas.py @@ -0,0 +1,150 @@ +"""Unit tests for OSS quantity limits.""" + +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from app.core import oss_quotas as quotas_module +from app.models.database import Agent, Metric, Workspace +from app.models.enums import MetricTrigger, MetricType + + +def _add_agent(db_session, org_id, workspace_id, *, name: str, agent_id: str | None = None): + agent = Agent( + id=uuid4(), + agent_id=agent_id or str(uuid4().int % 900000 + 100000), + organization_id=org_id, + workspace_id=workspace_id, + name=name, + language="en", + call_type="outbound", + call_medium="web_call", + ) + db_session.add(agent) + db_session.commit() + return agent + + +def _add_metric( + db_session, + org_id, + workspace_id, + *, + name: str, + is_default: bool = False, + metric_origin: str = "custom", +): + metric = Metric( + id=uuid4(), + organization_id=org_id, + workspace_id=workspace_id, + name=name, + metric_type=MetricType.RATING.value, + trigger=MetricTrigger.ALWAYS.value, + enabled=True, + is_default=is_default, + metric_origin=metric_origin, + supported_surfaces=["agent"], + enabled_surfaces=["agent"], + ) + db_session.add(metric) + db_session.commit() + return metric + + +def test_enforce_oss_quota_skipped_with_entitlement(db_session, org_id, monkeypatch): + monkeypatch.setattr( + quotas_module, + "has_enterprise_entitlement", + lambda _org: True, + ) + + quotas_module.enforce_oss_quota(db_session, org_id, "agents", additional=100) + + +def test_enforce_agent_quota_blocks_fourth_agent( + db_session, org_id, seed_org, default_workspace, monkeypatch +): + monkeypatch.setattr( + quotas_module, + "has_enterprise_entitlement", + lambda _org: False, + ) + + for idx in range(3): + _add_agent( + db_session, + org_id, + default_workspace.id, + name=f"Agent {idx}", + agent_id=f"{100000 + idx}", + ) + + with pytest.raises(HTTPException) as exc: + quotas_module.enforce_oss_quota(db_session, org_id, "agents") + + assert exc.value.status_code == 403 + assert exc.value.detail["error"] == "oss_quota_exceeded" + assert exc.value.detail["resource"] == "agents" + assert exc.value.detail["limit"] == 3 + + +def test_user_metrics_exclude_defaults( + db_session, org_id, seed_org, default_workspace, monkeypatch +): + monkeypatch.setattr( + quotas_module, + "has_enterprise_entitlement", + lambda _org: False, + ) + + _add_metric( + db_session, + org_id, + default_workspace.id, + name="Default Metric", + is_default=True, + metric_origin="default", + ) + for idx in range(5): + _add_metric( + db_session, + org_id, + default_workspace.id, + name=f"Custom {idx}", + is_default=False, + metric_origin="custom", + ) + + with pytest.raises(HTTPException) as exc: + quotas_module.enforce_oss_quota(db_session, org_id, "metrics") + + assert exc.value.detail["resource"] == "metrics" + assert exc.value.detail["limit"] == 5 + assert exc.value.detail["current"] == 5 + + +def test_workspace_quota_blocks_second_workspace( + db_session, org_id, seed_org, default_workspace, monkeypatch +): + monkeypatch.setattr( + quotas_module, + "has_enterprise_entitlement", + lambda _org: False, + ) + + # OSS orgs start with one default workspace; creating another should hit the cap. + assert ( + db_session.query(Workspace) + .filter(Workspace.organization_id == org_id) + .count() + == 1 + ) + + with pytest.raises(HTTPException) as exc: + quotas_module.enforce_oss_quota(db_session, org_id, "workspaces") + + assert exc.value.detail["resource"] == "workspaces" + assert exc.value.detail["limit"] == 1 + assert exc.value.detail["current"] == 1 From 12991fdf9c7c758a7d00eb97903a7c00527222ec Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Mon, 21 Sep 2026 05:30:27 +0000 Subject: [PATCH 2/8] feat: updating some more features --- app/api/v1/routes/workspace_iam.py | 7 +- app/core/oss_quotas.py | 5 +- .../content/docs/enterprise/index.mdx | 2 +- frontend/public/vapiai.jpg | Bin 10747 -> 1955 bytes frontend/src/components/Layout.tsx | 26 +----- frontend/src/hooks/useOssQuotas.ts | 2 +- frontend/src/pages/iam/IAM.tsx | 80 +++++++++++++----- frontend/src/store/licenseStore.ts | 2 +- tests/test_api/conftest.py | 15 ++++ tests/test_api/test_enterprise_gating.py | 9 ++ .../test_core/test_oss_quotas_org_members.py | 43 ++++++++++ 11 files changed, 139 insertions(+), 52 deletions(-) create mode 100644 tests/test_core/test_oss_quotas_org_members.py diff --git a/app/api/v1/routes/workspace_iam.py b/app/api/v1/routes/workspace_iam.py index c381f120..51287d73 100644 --- a/app/api/v1/routes/workspace_iam.py +++ b/app/api/v1/routes/workspace_iam.py @@ -19,7 +19,7 @@ ) from app.core.auth.rbac import require_admin from app.database import get_db -from app.dependencies import get_organization_id +from app.dependencies import get_organization_id, require_enterprise_entitlement from app.models.database import OrganizationMember, User, Workspace, WorkspaceMember, WorkspaceRole from app.models.schemas import ( CapabilityDomainResponse, @@ -39,7 +39,10 @@ ) -router = APIRouter(tags=["Workspace IAM"]) +router = APIRouter( + tags=["Workspace IAM"], + dependencies=[Depends(require_enterprise_entitlement())], +) def _require_workspace_in_org( diff --git a/app/core/oss_quotas.py b/app/core/oss_quotas.py index 343746d9..ce5721e7 100644 --- a/app/core/oss_quotas.py +++ b/app/core/oss_quotas.py @@ -15,7 +15,7 @@ OSS_MAX_USER_METRICS = 5 OSS_MAX_AGENTS = 3 -OSS_MAX_ORG_MEMBERS = 2 +OSS_MAX_ORG_MEMBERS = 1 OSS_MAX_WORKSPACES = 1 OssQuotaResource = Literal["metrics", "agents", "org_members", "workspaces"] @@ -39,7 +39,8 @@ ), "org_members": ( f"Open source deployments are limited to {OSS_MAX_ORG_MEMBERS} " - "organization members (including the creator)." + "organization member (solo use). Invite additional users with an " + "Enterprise license." ), "workspaces": ( f"Open source deployments are limited to {OSS_MAX_WORKSPACES} " diff --git a/docs-fumadocs/content/docs/enterprise/index.mdx b/docs-fumadocs/content/docs/enterprise/index.mdx index 5954c800..5b417257 100644 --- a/docs-fumadocs/content/docs/enterprise/index.mdx +++ b/docs-fumadocs/content/docs/enterprise/index.mdx @@ -45,7 +45,7 @@ Enterprise is designed for teams running voice agent evaluation in production wh | Playground | Agent playground | + Voice playground | | Usage history | **7 days** | Unlimited | | Usage pricing overrides | **Not included** | Included | -| IAM / workspaces | **2 members**, **1 workspace** | Unlimited | +| IAM / workspaces | **1 member** (solo), **1 workspace**, no workspace IAM tabs | Unlimited members, workspaces, workspace roles | | Auth | API key + local password | OIDC, SAML, SCIM, MFA, audit export | Existing resources above OSS limits are **grandfathered** (readable/editable); diff --git a/frontend/public/vapiai.jpg b/frontend/public/vapiai.jpg index bfbaf8b0b5a915f456ca13a144d1741d59266ccb..3db15daa7dfe97f36d8bccadcce1bb67cb4daaae 100644 GIT binary patch literal 1955 zcmcJLdpO(o7stPmNnECi2C+tMT@Y#Wt-I1vHo8zkiVziJT9>#qb(zv=SJf&qQ_}oU zwC=6a)ue4U6}k@f>r~RPiAG0@5{p|RNFzV}?RmCt&-44^_dMJC^Upb-bIx;K=dJo# z^$W1o+Y9dnKp+4B*?fR%6gUTH!r&Tk7(xTCfiTcCLYkN$4ULgzW{B-(W)>)vi3JLY zGP6RPnp>NpQ0CUVt*x+oK+qIx@8ss>Xomx_5G^e&9UUEnt}X(LG(ckiZ>hQfJq@4_ z=z~H&0Mzs#P(6t1A+UWDP)!ZGN%_0r8tO2J8uXpb>b|Xj8U(7Q27|%j>c2I-si?#B z-nWM9y9Y#mVeo+s?QAh(rw8%UwQh}VXxpe03x*%o+?_)so+Qci6p7Q9l6o<83&v7>Ufmk-cF9xb*O2HwKeS z2*+N?qq(m9K_<81HJs<%J;pmfEXVS3lD6@~%fg7Q22)i}K4Tj?IIcG5I)mkk06IUs zupeRM6Q59Z`4RS65WQNMvts^T9k-|8{cG$2Foc|(2p*&(#Jmz2t1w%7Dmx3p{*4lP z(H_xJ4fPz5ep63Fh%}xJ+z;wrH@@z9DzS-c-Zj>3I^i72FVmW3#4}^@OKDNxa`SOv zCo5zo?P7jVtbo`sJd2l7==>2P6Xy#LsgXgx$e$Nd*qcSr7fpV${6$O97vO9n-Ss5Ub_%-nA@_ep_72(_e^B>4WzzCfj?1`SV$YF}S+w zTk1-Ez~TlUtK%6mp7o1U2J+^8hG3O>>F5@$NE{hZy#i8OvN=wV*q{Qi=7EO%jO2fUYxZ~EDF`&=>Gqudx zIl1z@?N*+xUG9xJOT=ODvbIBO+jY&Mt)&{}|844y93j z`|B!_d?ll&yRud&!q+a`Tu5k_Io3UC7T zyW>pr7D5VQ1&@g&lBhLo9X)QGu6X`3g=d&cPJT~%*^9Lp-&`{7r&~&&uUJc`6@RkF z(b@LU5uVikxS@zJQ*X@0c$(*pl2R=+EmG=}99VAd0Sl`!>uzPXXD~e^BYIv(`bif& z@!pXa=x>(hDVD4MrZ?HGU;{!o#H@CPH=UsfSS;k!X#a%Lk6xftAMgC!LAxM~o@!J` zYVRQR)!6Uq{U~(4K^kp2R3*1_y%DYeBR5d22mOP?4=)U!KmDqba+6A+W-$eHQCd&6 zct@VLDPZ=8XQ&trLC(1s9psVLo^&8chu(ml3HNltbZ^X#GBN4ix-3)KBLySjuCRa* zB|}}1gk~Kjvh3Ep!A_eaWri=EH5#o3LY|2kxIRH+2kQsq?u`Q;(d@y7^Cz|0O-t>k zh3?8j6gIJ%ZStH>gm&9EdWq} z6u<$l0#s%eZq7;?(wZn8Zf0I?7VfSOf9m_QJQ)9JVuZuS%+bY0AM8mz1vGWdjGVxd?UNB|F{qY*+0LE^dkZxa8Y|3u)Q2>h=_0G&%Q z!{2XrV^3{%t)li^C%=qDBoOol9q%z^{qmF+`y4|7zn^v9s^mx13JsuR@3@w}w9uk^ ztlbe%>UT?>(GMjK0t7RaYT$a<7yU7Ywdw+KGFYxZ}YyqRC zDS%EbgY)LNArvqIz&YnKgv7I4)fo<~8;H{Jq}_?(YVD^`UHs~N`JGVa;yG|B^7YtRL}0sGK`t-1%gfYZ9As+jWVvD+RMH)RNr_O{RD~wilK=_$~1mjr0wDW zunQ)cy{-o~gVJDFCUcApSS1gd!Ah6%CST#o!a9%uRxV*}M#OEO(Wg=|7dzk3c7k## zyb+{7oG3p$ziRHiIjH(e4ty*R`^U@I2EaM9z{dMFi2v&0I`{s{V3U&!l#}a_6PQ&kTx*^+pBMVgKO(quWCY5(E@t!IGr4p)~EqxjN8cp;Uuj`)Y^_iIi65DQ1219l*a7yPs)vrhSTwY``HcxB-?2Cw}o-H zd`pVDh5xi1{pCt!wo*{q+B~vBG7~H3o4x|wc0+RY~PvA z%KB;IvLtHVn)f%!`0~CF>@hgoLBQm_jd7l|odh1ZqgFh_T>8yQMK2lP0@~u_{~@`o zvYcf2Zv%`=yb?0B?zQJuc937+_I8E6Rq`|t_>Tbx&z?N@vsJh7bGbM#Z74N@~55u(ohqQ9>#|NmCe*tCtV&mV8;nU9%%Mont32!BieGG0OeqP*nA z@1+Cn{g+h$&vCTAjd>O4MG$p>bp6C$(JS&mN>Kn1sxBBydH9at=a8VWc*!Ss9U)2r z!O+d*kDlDwBjNqb{FfRKqmweO-)ALj=ESoEFC`n(!cCn+iT60c`-aj92k|_$b33T^ z7eK9kG+I4mA)$v5Gk*f-%vZ&b4p6hzrk9@Y!*p}W?VoaSOio(I{*U5w1}<|J5^o*v z_2`_I_h$zEsSH4O5wh;zi{bcgU;K@L9l|?roet&gcIgcSH_FseK@q1|WL~eKElKyk zT6E1-&-Rp0DxQc3Vv@1>*RPzdvr}HHNjM7ZV)wh<&-c(*@rPtL*d7JYWQCLatc&ms zFZViuQL@Kh>h(y|3I?un8n0BCAo$1po66^Qoh?3AE9zoEc=l}*M7ZEY7sF({08O>d|ss�N+vo zko&*|Fnj^rKYRTwsB+}+N$iFk8@zD$hqRQ(#!DsA=#wVTfE-0*y85@760)k=l9XOs zYI^;qg{v&RhpX17aHn!VxVqCv7aI+}?%v6_8cGe*k;BL!is-$CxAzY32PvB*?!r|B z`PL{2)2^@lc<@wl<4o_9`u*w`UmHLeUmqJuIgm2#th)-{tnCAyaE=Y(W_L+}Y2r z+cjB}@>qI59QCy)AdhS!=>m{vD`oicOvhN^oVlbS^+fGbF6=<%RhrVdp)SH-=9lEtPWyH;7ezCVt+03OsGs9+AsHP!FIS)xaEc<;z7 z@h(u3tBX-92`==hV=mVC9yt6ibK z72geG`XsZ7v|Z8He)c*l$Wx)6cFx0lpl+I(RS&?;^mbWLpQw%hXnU0dOCAR^YnIrr zhk{@oQR!H*wQ4O-ewg}0BffSaDnpIQ#ZjU!lcQEjs)_QY$}UU(v=2m9Rkb6Pr?z{z zq9*W-&h;gd8m;?cNOO%o=4+!;Vs(7<;@7uIdv5DM$jC{m9z3fmmMmIRQd4Vdx|UdN4a}w|KvAHAh%CF}B}7G}<`bJYt!zh6oEOezY;oGa=_=i$RbJ+~=BP&NP=3l$%Maf(0ju6!y|S_vwK`YyX&v^)(pOY)KzyQyDFN{YDoDw{E$|R#l{8c!k*-j@QN(WGv-Rh zOY69Df(B1K1>xY)xcK0=c_M#ou(ZkR>5t|-ZwrETK1+5M!iVOf@llMNv%iQB<_rI1 zpm0TwCPbAHM=?*2`2pwc?R^()EW2uR-6gx)>Z>v~yA<_lBb4XW=F$lkP1Av2GHGq~ ziF21^95dhh{kq+r~|?j%#F z59h(%c@vgNTUK1)V7tWDr_v7-hQ-6k8Q@1PaliBa;lkunGF(^NrZ?4Yk!@-cMwM^c zX}V_Hq)WVnc~iZgzr2d9klV#!SJ9T{EZ+=KY0=~kU*Xv5g$dnKU5?GHpNlhLmwmw! ze-?>Yx-Pjs5OCV@{Qr_`GF^%y_*rNGh%S9Iq5{#%3heI<4s4geBa_U$WJ`cdb`-YMfs(%x;%og6oHxG zo=ZLUyI$AWO(Jbf3!Bamnyw_|kxOm@BL9cuZk_danbsJ4n1lA{deg&qrY5j8T{8jv ze3vSPgD1^}3(9S=^tEm&$H3P%n{zc}89E(I&Ts1H;VYBBk=ZBP{Fu{43j=G{zFAB|4>m-uhQnSv) zPXfJsc#Gc$)AJ%n1Gtc_lDjarHaRZF%2cltDq?eH8OPd&+qo;2dP$6j2?|h=@*I0NtO0x{{jd0glkm^=xQO~7@6mtEsgnYx>{COE;?~s=J z#2)@y8yzS&A5kbr#l^*jkL*+ZO*4`{6FRFr)TBpmgR$3T+T0|Myg@mzii4GGV0kL( ztZZS{eyJdI$Wy_FQpNFbF}0VNFVbRLiGcoH=Yfv`bY6R?QO2fWmymx%Deu*A=u+Zz z!m3qOu^z5Zhwyre8IC`7{nUfC%r9_`@5+%LgzAEFq!m)#(glE?bp6WB{ILhP1LE1mEv zjeYQA`ZhID=_#-Pp5rGE0Lqcfw+ESBI}=o zmG>Sb4>jlt+_~+oE)*f)xR7O#kyOlN4vy04>yT}b#0F4AN>w4p^V&8Q^1_F?3X;Ho}ac+~olf3STa z!WRdDcB3aWK=X!`04;q&Rv^E2Q2e8v-Y=09?PZ6rBA&9a=*tR_Kd0SVZ~k<$`s+yg+{39N=CbIy6(Iqv>9idJ=)T}sf-RLp>8$x@mz(+?|yEp@k}f9 z^b72MDsrhTYH+2%#Mn|UfI@k!-_y2+<=OW0P<`8D(OsOrbhK@*vG?Daj|WzgW;}Vr zoczmaip5ySy#o!r^M$)vAs=rKo}dJbifEFuQZ zRoT+XHDv*tg1xbKv}|b(5|HD?*m8`;2U!CV)W{NOVCPE*JKbSkc1zo%kKE;{B#kXq zb;skn%#P2^s#OUCfmRk=a_nJNzl<#Kg_?gF&63!r^ppLtRnZ@I=+_o<)|;4oUOGr7 z7G=g=<)IVm1C0zJ#c|_8gXIeSUTTOhx#1K=q&8BehKJ>n`?f!=%D*3?P@!@Q4ecHI zdU{08F=@KwV!Ejtydyfg@CSy>lRg(jC8Soc*D*INmq=yuxh_N1Gxd!TtX8zbf)eV8 zB^@opdj#Dj1+~7n5{tOL=Iu4W31<7e*kC$;NaDBzD>UN5z%ehR&iK zJ5TC6AZo>Gy3;v&CK^$!Qg!h7SRw!Hj50x?8nI<&q;*W%2NtJdSTOz;R<$RarM@Q`op~e8|d+? zhgf_@U^v3tUACAm0Hs3kN=8itprN7u75>-t5F$#X%E<)~W|=x)Ub@gA;H7KF2e;E^ z$yI6E|awh<^C{6@k0s#z699(ov)D;kX7YRXK0x^kbi0L@FxLpZJuH6+C zS9eJwrG@i|X_&bMmC++wh9uO?gA=o0<@1+bfgYo-f(pq7ug~4I%WFth3(rM-@eu`M zj*BHHYLRM7_?v?1=?VD(dRCMjK_rag5w$4 z1=}ki=XYC{;B?4!Y(2CldIA`wn(r&1s zUlp3Q!=YtZt;rWpqU{`eSI>8%fkzf~)yQi(3LoO)hq^4N`C1)+j~>;)|`DJ^~N@2BxZcPYya zHc#cFYQ8}T$rdW#6zg3!ApYXqC$eV!$l+fnlNU+i?=T`}zA929X60x)N_u_FmK19o>+4Tiu8d#+1 zI%%vXLnOxrIfU5}I1*$1_eZGy z$OyJ3t($6@lgYfM&v!*Nk5OJg-O#&kGX3FctV2OL2+?IiOo z*zTsi+l9bWu&iL*sP&5o}aa=JObFWr?|3&uJ>fbEFxC!1a%9P7_FmSre zc|1juwYPP)HLp70&dDECV<9-F6$Tj9eoStRQuFL4J)*6c3mzWOw!m{ny4ib4sSoVd z(nwVczW$!(l-BF`Z)O~XsUpSde6^cDaYB%+T0599X{3s|wb!@A ztVrT@$vi8L2MQizQ|pH|1vs+`oTAZOeV>%~WNIrp0r?Bsn9UbQq5o zqSx@wTC(j{xh+D&DQ&(Jt4m z!#t#9a5>f2%q#6)2g3fOB&j6S4HhDCegQyol%gG;;2H*J@TQOTH1TJjNZjYNNvk8v z4{-_2Qu4{q0q8Hi9>S!QMk;w38=b*qu?s4TmPvc!>c`d(ZQ1w7;_pV$pYaLL&1|%8 z!yiS9Pkc9`^!_O4PGRH3kf)=~1BtHA;3#jgZ8S_@jJlJ1cMmhN5TOu5Y{B1S2g@l+ zC!nw@a@V#aprU!nk@Dk+gLI3LiJ0NV%Jg&|u^J4Pr^Ul4?l4f@eNPR*A;x7bX45O=wvY4BfIq4ncXT3Qi=^vw+Yb6QxU~hCHwDY`7=v_#3RM_l zUN#fpssSXb3`AW2J}Pe6p|4B0jV$-PhC2K6@yivSxL=@oQQ)CCkaPg&+ zV}@*CLU^)e%X9pE-w8vbv)jK0yu;d0b9~39n6L2ZV;8CYaDUSw9(M&L42s8=alPqA zNA0qa=pMzawYJ10N;8>7_22ZbkFEE#tk$${vwrrFs&T9|a&-e@zzjkRX{F(F!>UC6QUBYq-mw6RTtK-NG>5$tEqMQ&plKko3YkwJ> z>Ir+B`ftc5@^L&cPaXK_2}XKok))2ITgItrsTry5z#Xl=&u!!aO-FE?49&O?+-*v) z5LYTSZpaa3+WNN1r1NGn_BqnFStHo?q&lXYguV{tN!w|_Mk2k7*~kkdN%rD2{50kX z>Dj{W=xZk57b3*Mes2IG|k5v}S z2EIpB$&kBL&~Wz9&WGs8rLA8vOCP`edKixp!O0%%bluihATZ4`0l`3BF=k&-5G+<# zsc=WLYLP*!L@jQ4)i<4wJ@O(t*$I(u*7-rtIiw2#xJsi*&PbcZLySC(d8tL`e`5gRkCsE6wHRN edy(C&@UXY{!FjQx_G;QjRa8~u{Pz2css96B_&^^3 diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index c68d4840..d269601f 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -32,7 +32,6 @@ import { Clock, Volume2, Gamepad2, - Lock, ScrollText, Github, Sparkles, @@ -472,7 +471,6 @@ function SidebarContent({ collapsed?: boolean onToggleCollapse?: () => void }) { - const { isFeatureEnabled } = useLicenseStore() const [expandedSections, setExpandedSections] = useState>( new Set(['Simulations', 'Playground', 'Evaluations', 'Prompts', 'Observability', 'Alerting', 'Configurations']) ) @@ -515,7 +513,6 @@ function SidebarContent({ key={item.href} item={item} isActive={isNavItemActive(item.href, location.pathname)} - isGated={Boolean(item.enterpriseFeature && !isFeatureEnabled(item.enterpriseFeature))} /> ))} @@ -566,16 +563,13 @@ function SidebarContent({ {/* Other Navigation */} {otherNavigation.map((item) => { const isActive = isNavItemActive(item.href, location.pathname) - const isGated = item.enterpriseFeature && !isFeatureEnabled(item.enterpriseFeature) return ( {item.name} - {isGated && } ) })} @@ -621,16 +614,13 @@ function SidebarContent({
{section.items.map((item) => { const isItemActive = isNavItemActive(item.href, location.pathname) - const isGated = item.enterpriseFeature && !isFeatureEnabled(item.enterpriseFeature) return ( {item.name} - {isGated && ( - - )} ) })} @@ -693,11 +680,9 @@ function SidebarContent({ function SidebarIconLink({ item, isActive, - isGated, }: { item: NavItem isActive: boolean - isGated: boolean }) { return ( - {isGated && ( - - )} ) } diff --git a/frontend/src/hooks/useOssQuotas.ts b/frontend/src/hooks/useOssQuotas.ts index d7648d56..88365cce 100644 --- a/frontend/src/hooks/useOssQuotas.ts +++ b/frontend/src/hooks/useOssQuotas.ts @@ -41,7 +41,7 @@ export function useOssQuotas() { 'Open source limit: 5 user-created metrics. Upgrade with EFFICIENTAI_LICENSE for unlimited metrics.', agents: 'Open source limit: 3 agents. Upgrade with EFFICIENTAI_LICENSE for unlimited agents.', org_members: - 'Open source limit: 2 organization members. Upgrade with EFFICIENTAI_LICENSE to invite more.', + 'Open source limit: 1 organization member (solo use). Upgrade with EFFICIENTAI_LICENSE to invite more users.', workspaces: 'Open source limit: 1 workspace. Upgrade with EFFICIENTAI_LICENSE to create additional workspaces.', } diff --git a/frontend/src/pages/iam/IAM.tsx b/frontend/src/pages/iam/IAM.tsx index 31b88d17..574230f6 100644 --- a/frontend/src/pages/iam/IAM.tsx +++ b/frontend/src/pages/iam/IAM.tsx @@ -3,7 +3,7 @@ import { apiClient } from '../../lib/api' import { useEffect, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { Role, Invitation, OrganizationMember, InvitationCreate } from '../../types/api' -import { Users, Mail, UserPlus, Shield, ShieldCheck, ShieldAlert, X, Trash2, KeyRound, Eye, EyeOff, Building2, Copy, Check } from 'lucide-react' +import { Users, Mail, UserPlus, Shield, ShieldCheck, ShieldAlert, X, Trash2, KeyRound, Eye, EyeOff, Building2, Copy, Check, Lock } from 'lucide-react' import Button from '../../components/Button' import ConfirmModal from '../../components/ConfirmModal' import { useToast } from '../../hooks/useToast' @@ -17,17 +17,23 @@ import { useOssQuotas } from '../../hooks/useOssQuotas' type IamTab = 'organization' | 'workspace-members' | 'workspace-roles' -const IAM_TABS: { id: IamTab; label: string; icon: typeof Building2; adminOnly?: boolean }[] = [ +const IAM_TABS: { + id: IamTab + label: string + icon: typeof Building2 + adminOnly?: boolean + enterpriseOnly?: boolean +}[] = [ { id: 'organization', label: 'Organization', icon: Building2 }, - { id: 'workspace-members', label: 'Workspace Members', icon: Users }, - { id: 'workspace-roles', label: 'Workspace Roles', icon: Shield, adminOnly: true }, + { id: 'workspace-members', label: 'Workspace Members', icon: Users, enterpriseOnly: true }, + { id: 'workspace-roles', label: 'Workspace Roles', icon: Shield, adminOnly: true, enterpriseOnly: true }, ] export default function IAM() { const queryClient = useQueryClient() const { showToast, ToastContainer } = useToast() const isAdmin = useIsAdmin() - const { isAtLimit, limitMessage } = useOssQuotas() + const { isAtLimit, limitMessage, isEnterprise } = useOssQuotas() const [searchParams, setSearchParams] = useSearchParams() const tabParam = searchParams.get('tab') const activeTab: IamTab = @@ -43,6 +49,15 @@ export default function IAM() { } }, [activeTab, isAdmin, setSearchParams]) + useEffect(() => { + if ( + !isEnterprise && + (activeTab === 'workspace-members' || activeTab === 'workspace-roles') + ) { + setSearchParams({}, { replace: true }) + } + }, [activeTab, isEnterprise, setSearchParams]) + const setActiveTab = (tab: IamTab) => { setSearchParams(tab === 'organization' ? {} : { tab }) } @@ -345,21 +360,42 @@ export default function IAM() {
@@ -665,9 +701,9 @@ export default function IAM() { )} - {activeTab === 'workspace-members' && } + {activeTab === 'workspace-members' && isEnterprise && } - {activeTab === 'workspace-roles' && isAdmin && ( + {activeTab === 'workspace-roles' && isAdmin && isEnterprise && (
diff --git a/frontend/src/store/licenseStore.ts b/frontend/src/store/licenseStore.ts index 4ac32a1b..9af24ad5 100644 --- a/frontend/src/store/licenseStore.ts +++ b/frontend/src/store/licenseStore.ts @@ -16,7 +16,7 @@ const DEFAULT_USAGE_POLICY: UsagePolicy = { const DEFAULT_OSS_QUOTAS: OssQuotas = { max_user_metrics: 5, max_agents: 3, - max_org_members: 2, + max_org_members: 1, max_workspaces: 1, } diff --git a/tests/test_api/conftest.py b/tests/test_api/conftest.py index 4695b8a2..61488308 100644 --- a/tests/test_api/conftest.py +++ b/tests/test_api/conftest.py @@ -610,3 +610,18 @@ def _make_prompt_optimization_candidate(**overrides): return candidate return _make_prompt_optimization_candidate + + +@pytest.fixture(autouse=True) +def _enable_enterprise_entitlement_for_api_tests(monkeypatch, request): + """Most API route tests assume an entitled deployment unless testing OSS gates.""" + if request.module.__name__.endswith("test_enterprise_gating"): + return + + import app.core.usage_entitlement as usage_entitlement_module + + monkeypatch.setattr( + usage_entitlement_module, + "has_enterprise_entitlement", + lambda organization_id=None: True, + ) diff --git a/tests/test_api/test_enterprise_gating.py b/tests/test_api/test_enterprise_gating.py index b6c5e976..bf5dd99c 100644 --- a/tests/test_api/test_enterprise_gating.py +++ b/tests/test_api/test_enterprise_gating.py @@ -64,4 +64,13 @@ def test_license_info_includes_oss_quotas(unlicensed_client): body = response.json() assert body["quotas"]["max_user_metrics"] == 5 assert body["quotas"]["max_agents"] == 3 + assert body["quotas"]["max_org_members"] == 1 assert "quota_usage" in body + + +def test_workspace_iam_forbidden_without_license(unlicensed_client, default_workspace): + response = unlicensed_client.get( + f"/api/v1/workspaces/{default_workspace.id}/members" + ) + assert response.status_code == 403 + assert response.json()["detail"]["error"] == "enterprise_license_required" diff --git a/tests/test_core/test_oss_quotas_org_members.py b/tests/test_core/test_oss_quotas_org_members.py new file mode 100644 index 00000000..a6368def --- /dev/null +++ b/tests/test_core/test_oss_quotas_org_members.py @@ -0,0 +1,43 @@ +"""OSS org member limit (solo user).""" + +from uuid import uuid4 + +import pytest +from fastapi import HTTPException + +from app.core import oss_quotas as quotas_module +from app.models.database import OrganizationMember, RoleEnum, User + + +def test_enforce_org_member_quota_blocks_second_member( + db_session, org_id, seed_org, monkeypatch +): + monkeypatch.setattr( + quotas_module, + "has_enterprise_entitlement", + lambda _org: False, + ) + + user = User( + id=uuid4(), + email="solo@example.com", + name="Solo User", + is_active=True, + ) + db_session.add(user) + db_session.flush() + db_session.add( + OrganizationMember( + organization_id=org_id, + user_id=user.id, + role=RoleEnum.ADMIN.value, + ) + ) + db_session.commit() + + with pytest.raises(HTTPException) as exc: + quotas_module.enforce_oss_quota(db_session, org_id, "org_members") + + assert exc.value.detail["resource"] == "org_members" + assert exc.value.detail["limit"] == 1 + assert exc.value.detail["current"] == 1 From f801d392fe963bb3ed092cabef511a681ccd3144 Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Mon, 21 Sep 2026 07:29:43 +0000 Subject: [PATCH 3/8] feat: updating some minor UI issues --- frontend/src/components/ConfirmModal.tsx | 29 +-- frontend/src/lib/llmModelOptions.test.ts | 22 ++ .../components/VoiceBundleParamsModal.tsx | 81 +++++-- .../src/pages/configurations/VoiceBundles.tsx | 185 ++++++++++++++-- frontend/src/pages/iam/IAM.tsx | 201 ++++++++++-------- 5 files changed, 385 insertions(+), 133 deletions(-) diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx index a7025969..5e5fa7dc 100644 --- a/frontend/src/components/ConfirmModal.tsx +++ b/frontend/src/components/ConfirmModal.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react' +import { createPortal } from 'react-dom' import { AlertTriangle } from 'lucide-react' import Button from './Button' @@ -55,17 +56,19 @@ export default function ConfirmModal({ if (!isOpen) return null - return ( -
-
- {/* Backdrop */} -
- - {/* Modal Content */} -
+ const modal = ( +
+
+ +
{/* Header */}
{(variant === 'danger' || variant === 'warning') && ( @@ -118,7 +121,9 @@ export default function ConfirmModal({
-
) + + if (typeof document === 'undefined') return null + return createPortal(modal, document.body) } diff --git a/frontend/src/lib/llmModelOptions.test.ts b/frontend/src/lib/llmModelOptions.test.ts index b2fd5daf..02378d59 100644 --- a/frontend/src/lib/llmModelOptions.test.ts +++ b/frontend/src/lib/llmModelOptions.test.ts @@ -43,6 +43,28 @@ describe('resolveLLMModelsForCredential', () => { }) }) + it('intersects catalog with enabled_models allowlist', () => { + const credential = makeCredential({ + provider: 'openai', + gateway_model: undefined, + enabled_models: ['gpt-4o', 'gpt-4o-mini'], + }) + const catalog = [ + 'gpt-4o', + 'gpt-4o-mini', + 'gpt-4.1', + 'gpt-5-mini', + 'o3-mini', + ] + + const resolution = resolveLLMModelsForCredential(credential, catalog) + + expect(resolution).toEqual({ + mode: 'catalog', + models: ['gpt-4o', 'gpt-4o-mini'], + }) + }) + it('returns allowlist when catalog is empty for non-custom provider', () => { const credential = makeCredential({ provider: 'fireworks', diff --git a/frontend/src/pages/agents/components/VoiceBundleParamsModal.tsx b/frontend/src/pages/agents/components/VoiceBundleParamsModal.tsx index fa14e961..434f7295 100644 --- a/frontend/src/pages/agents/components/VoiceBundleParamsModal.tsx +++ b/frontend/src/pages/agents/components/VoiceBundleParamsModal.tsx @@ -18,6 +18,8 @@ import { type LLMGenerationConfig, } from '../../../config/llmGenerationParams' import Button from '../../../components/Button' +import { resolveActiveAIProvider } from '../../../lib/gatewayRouting' +import { resolveLLMModelsForCredential } from '../../../lib/llmModelOptions' import ParamSlider from './ParamSlider' type ModelOptionsCache = Record< @@ -134,6 +136,44 @@ export default function VoiceBundleParamsModal({ const llmOptions = optionsFor(bundle.llm_provider) const ttsOptions = optionsFor(bundle.tts_provider) + const editorActive = + open || mode === 'expanded' || (mode === 'collapsible' && inlineExpanded) + + const { data: aiProviders = [] } = useQuery({ + queryKey: ['aiproviders'], + queryFn: () => apiClient.listAIProviders(), + staleTime: 5 * 60 * 1000, + enabled: editorActive, + }) + + const llmCredential = useMemo(() => { + if (!bundle.llm_provider) return undefined + return resolveActiveAIProvider( + aiProviders, + bundle.llm_provider, + bundle.llm_credential_id, + ) + }, [aiProviders, bundle.llm_provider, bundle.llm_credential_id]) + + const llmModelResolution = useMemo(() => { + const catalog = llmOptions?.llm ?? [] + return resolveLLMModelsForCredential(llmCredential, catalog) + }, [llmCredential, llmOptions?.llm]) + + const resolvedGatewayDirectModel = + llmModelResolution.mode === 'gateway_direct' ? llmModelResolution.model : null + + const llmModelSelectOptions = useMemo(() => { + if (resolvedGatewayDirectModel) return [] + const enabled = + llmModelResolution.mode === 'catalog' ? llmModelResolution.models : [] + if (draft.llm_model && !enabled.includes(draft.llm_model)) { + return [draft.llm_model, ...enabled] + } + if (enabled.length > 0) return enabled + return [draft.llm_model].filter(Boolean) + }, [resolvedGatewayDirectModel, llmModelResolution, draft.llm_model]) + const ttsVoices = bundle.tts_provider && bundle.tts_model ? ttsOptions?.tts_voices?.[bundle.tts_model] || [] @@ -259,19 +299,34 @@ export default function VoiceBundleParamsModal({ - + {resolvedGatewayDirectModel ? ( + <> +
+ {resolvedGatewayDirectModel} +
+

+ Model is fixed on the integration — Bifrost gateway routing applies. +

+ + ) : ( + + )}
diff --git a/frontend/src/pages/configurations/VoiceBundles.tsx b/frontend/src/pages/configurations/VoiceBundles.tsx index d8b78ea2..0a3e35f1 100644 --- a/frontend/src/pages/configurations/VoiceBundles.tsx +++ b/frontend/src/pages/configurations/VoiceBundles.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, type ReactNode } from 'react' +import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react' import { useSearchParams, useNavigate } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { createPortal } from 'react-dom' @@ -15,7 +15,12 @@ import { summarizeLLMConfig, type LLMGenerationConfig, } from '../../config/llmGenerationParams' -import { hasGatewayLLMCredential, providerHasLLMModels } from '../../lib/llmModelOptions' +import { resolveActiveAIProvider } from '../../lib/gatewayRouting' +import { + hasGatewayLLMCredential, + providerHasLLMModels, + resolveLLMModelsForCredential, +} from '../../lib/llmModelOptions' export default function VoiceBundles() { const [searchParams, setSearchParams] = useSearchParams() @@ -469,14 +474,37 @@ export default function VoiceBundles() { } } + const resolveLlmModelsForSelection = ( + provider: ModelProvider, + credentialId: string | null = null, + ) => { + const catalog = getModelOptions(provider).llm + const credential = resolveActiveAIProvider(aiproviders, provider, credentialId) + const resolution = resolveLLMModelsForCredential(credential, catalog) + if (resolution.mode === 'gateway_direct') { + return { models: [] as string[], gatewayModel: resolution.model } + } + return { models: resolution.models, gatewayModel: null as string | null } + } + const updateModelOptions = (type: 'stt' | 'llm' | 'tts' | 's2s', provider: ModelProvider) => { const options = getModelOptions(provider) const models = options[type] + if (type === 'llm') { + const { models: enabledModels, gatewayModel } = resolveLlmModelsForSelection(provider) + if (gatewayModel || enabledModels.length > 0) { + setFormData({ + ...formData, + llm_provider: provider, + llm_model: gatewayModel ?? enabledModels[0] ?? '', + llm_credential_id: null, + }) + } + return + } if (models.length > 0) { if (type === 'stt') { setFormData({ ...formData, stt_provider: provider, stt_model: models[0], stt_credential_id: null }) - } else if (type === 'llm') { - setFormData({ ...formData, llm_provider: provider, llm_model: models[0], llm_credential_id: null }) } else if (type === 'tts') { const firstModel = models[0] const voices = options.tts_voices?.[firstModel] || [] @@ -933,6 +961,72 @@ function VoiceBundleModal({ const ttsProviders = configuredProviders.filter((provider) => getModelOptions(provider).tts.length > 0) const s2sProviders = configuredProviders.filter((provider) => getModelOptions(provider).s2s.length > 0) + const llmCredential = useMemo(() => { + if (!formData.llm_provider) return undefined + return resolveActiveAIProvider( + aiProviders, + formData.llm_provider, + formData.llm_credential_id, + ) + }, [aiProviders, formData.llm_provider, formData.llm_credential_id]) + + const llmModelResolution = useMemo(() => { + if (!formData.llm_provider) { + return { mode: 'catalog' as const, models: [] as string[] } + } + const catalog = getModelOptions(formData.llm_provider).llm + return resolveLLMModelsForCredential(llmCredential, catalog) + }, [formData.llm_provider, llmCredential, getModelOptions]) + + const resolvedGatewayDirectModel = + llmModelResolution.mode === 'gateway_direct' ? llmModelResolution.model : null + + const enabledLlmModels = + llmModelResolution.mode === 'gateway_direct' + ? [] + : llmModelResolution.models + + const llmModelSelectOptions = useMemo(() => { + if (resolvedGatewayDirectModel) return [] + if (formData.llm_model && !enabledLlmModels.includes(formData.llm_model)) { + return [formData.llm_model, ...enabledLlmModels] + } + return enabledLlmModels + }, [resolvedGatewayDirectModel, enabledLlmModels, formData.llm_model]) + + const llmSelectionRef = useRef({ + provider: formData.llm_provider, + credentialId: formData.llm_credential_id, + }) + + useEffect(() => { + const prev = llmSelectionRef.current + const selectionChanged = + prev.provider !== formData.llm_provider || + prev.credentialId !== formData.llm_credential_id + llmSelectionRef.current = { + provider: formData.llm_provider, + credentialId: formData.llm_credential_id, + } + if (!selectionChanged || !formData.llm_provider) return + + if (resolvedGatewayDirectModel) { + if (formData.llm_model !== resolvedGatewayDirectModel) { + setFormData({ ...formData, llm_model: resolvedGatewayDirectModel }) + } + return + } + if (enabledLlmModels.length === 0) return + if (formData.llm_model && enabledLlmModels.includes(formData.llm_model)) return + setFormData({ ...formData, llm_model: enabledLlmModels[0] }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + formData.llm_provider, + formData.llm_credential_id, + resolvedGatewayDirectModel, + enabledLlmModels, + ]) + useEffect(() => { const handleSttClickOutside = (event: MouseEvent) => { if (sttDropdownRef.current && !sttDropdownRef.current.contains(event.target as Node)) { @@ -1248,24 +1342,43 @@ function VoiceBundleModal({ - + {resolvedGatewayDirectModel ? ( + <> +
+ {resolvedGatewayDirectModel} +
+

+ Model is fixed on the integration — Bifrost gateway routing applies. +

+ + ) : ( + + )}
setFormData({ ...formData, llm_credential_id: id }), + (id) => { + if (!formData.llm_provider) { + setFormData({ ...formData, llm_credential_id: id }) + return + } + const catalog = getModelOptions(formData.llm_provider).llm + const credential = resolveActiveAIProvider( + aiProviders, + formData.llm_provider, + id, + ) + const resolution = resolveLLMModelsForCredential(credential, catalog) + let nextModel = formData.llm_model ?? '' + if (resolution.mode === 'gateway_direct') { + nextModel = resolution.model + } else if ( + !resolution.models.includes(nextModel) && + resolution.models.length > 0 + ) { + nextModel = resolution.models[0] + } + setFormData({ + ...formData, + llm_credential_id: id, + llm_model: nextModel, + }) + }, )}
diff --git a/frontend/src/pages/iam/IAM.tsx b/frontend/src/pages/iam/IAM.tsx index 574230f6..e1ae2158 100644 --- a/frontend/src/pages/iam/IAM.tsx +++ b/frontend/src/pages/iam/IAM.tsx @@ -1,6 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { apiClient } from '../../lib/api' -import { useEffect, useState } from 'react' +import { useEffect, useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' import { useSearchParams } from 'react-router-dom' import { Role, Invitation, OrganizationMember, InvitationCreate } from '../../types/api' import { Users, Mail, UserPlus, Shield, ShieldCheck, ShieldAlert, X, Trash2, KeyRound, Eye, EyeOff, Building2, Copy, Check, Lock } from 'lucide-react' @@ -29,6 +30,11 @@ const IAM_TABS: { { id: 'workspace-roles', label: 'Workspace Roles', icon: Shield, adminOnly: true, enterpriseOnly: true }, ] +function renderIamModal(content: ReactNode) { + if (typeof document === 'undefined') return null + return createPortal(content, document.body) +} + export default function IAM() { const queryClient = useQueryClient() const { showToast, ToastContainer } = useToast() @@ -710,70 +716,85 @@ export default function IAM() { )} {/* Invite Modal */} - {showInviteModal && ( -
-
-
-

Invite User

- -
-
-
- - setInviteEmail(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" - placeholder="user@example.com" - /> -
-
- - -
-
- - + +
-
-
-
- )} +
+
+ + setInviteEmail(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + placeholder="user@example.com" + /> +
+
+ + +
+
+ + +
+
+
+
, + )} {/* Admin Reset Password Modal */} - {showResetPasswordModal && memberToResetPassword && ( -
-
e.stopPropagation()} - > -
-

Reset User Password

- -
-
+ {showResetPasswordModal && + memberToResetPassword && + renderIamModal( +
+
+
e.stopPropagation()} + > +
+

+ Reset User Password +

+ +
+
@@ -957,9 +988,9 @@ export default function IAM() {
-
-
- )} +
+
, + )} Date: Tue, 22 Sep 2026 07:39:59 +0000 Subject: [PATCH 4/8] feat: updating lincese and couple of outher fixes --- app/api/v1/routes/aiproviders.py | 87 +++++++- app/api/v1/routes/integrations.py | 10 +- app/api/v1/routes/settings.py | 6 +- app/core/license.py | 27 ++- app/services/ai/llm_gateway.py | 4 +- app/services/ai/llm_gateway_settings.py | 44 +++- app/workers/tasks/agent_flowchart_jobs.py | 21 +- frontend/src/lib/api.ts | 1 + frontend/src/lib/apiErrors.test.ts | 20 ++ frontend/src/lib/apiErrors.ts | 65 ++++++ .../components/AgentPromptVisualization.tsx | 127 +++++++---- .../src/pages/configurations/Integrations.tsx | 197 ++++++++++++------ .../pages/promptPartials/PromptPartials.tsx | 178 +++++++++++++--- .../components/AgentPromptSectionView.tsx | 94 +++++++-- .../components/FlowchartErrorPanel.tsx | 84 ++++++++ frontend/src/store/licenseStore.ts | 4 + tests/test_api/test_enterprise_gating.py | 42 +++- tests/test_core/test_license_offerings.py | 43 ++++ 18 files changed, 883 insertions(+), 171 deletions(-) create mode 100644 frontend/src/lib/apiErrors.test.ts create mode 100644 frontend/src/pages/promptPartials/components/FlowchartErrorPanel.tsx diff --git a/app/api/v1/routes/aiproviders.py b/app/api/v1/routes/aiproviders.py index e7bb069f..e0ed86bb 100644 --- a/app/api/v1/routes/aiproviders.py +++ b/app/api/v1/routes/aiproviders.py @@ -89,6 +89,38 @@ def _scrub_for_response( ) +def _assert_gateway_fields_allowed( + organization_id: UUID, + *, + gateway_model: Optional[str] = None, + gateway_interface: Optional[str] = None, + gateway_base_url: Optional[str] = None, + gateway_auth_header: Optional[str] = None, + gateway_auth_secret_env: Optional[str] = None, + gateway_auth_secret: Optional[str] = None, + gateway_extra_headers: Optional[dict] = None, +) -> None: + from app.core.license import has_valid_license + from app.services.ai.llm_gateway_settings import assert_llm_gateway_entitlement + + if has_valid_license(organization_id): + return + + has_gateway_fields = any( + [ + (gateway_model or "").strip(), + gateway_interface not in (None, "", "inherit"), + (gateway_base_url or "").strip(), + (gateway_auth_header or "").strip(), + (gateway_auth_secret_env or "").strip(), + (gateway_auth_secret or "").strip(), + bool(gateway_extra_headers), + ] + ) + if has_gateway_fields: + assert_llm_gateway_entitlement(organization_id) + + def _validate_routing_and_api_key( *, organization_id: UUID, @@ -97,13 +129,14 @@ def _validate_routing_and_api_key( gateway_model: Optional[str], has_existing_key: bool = False, ) -> None: + from app.services.ai.llm_gateway_settings import assert_credential_routing_allowed + + assert_credential_routing_allowed(organization_id, routing_mode) + mode = routing_mode.value if hasattr(routing_mode, "value") else str(routing_mode) trimmed_key = (api_key or "").strip() if mode == CredentialRoutingMode.GATEWAY.value: - from app.services.ai.llm_gateway_settings import assert_llm_gateway_entitlement - - assert_llm_gateway_entitlement(organization_id) return if mode == CredentialRoutingMode.DIRECT.value and not trimmed_key and not has_existing_key: @@ -156,12 +189,38 @@ async def create_aiprovider( """Create a new AI Provider credential row.""" provider_value = aiprovider.provider.value if hasattr(aiprovider.provider, 'value') else aiprovider.provider + from app.core.license import has_valid_license + + if str(provider_value).lower() == "custom" and not has_valid_license(organization_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": "enterprise_license_required", + "message": ( + "Custom Bifrost model integrations require a valid " + "EFFICIENTAI_LICENSE key." + ), + }, + ) + _validate_routing_and_api_key( organization_id=organization_id, routing_mode=aiprovider.routing_mode, api_key=aiprovider.api_key, gateway_model=aiprovider.gateway_model, ) + _assert_gateway_fields_allowed( + organization_id, + gateway_model=aiprovider.gateway_model, + gateway_interface=aiprovider.gateway_interface.value + if hasattr(aiprovider.gateway_interface, "value") + else aiprovider.gateway_interface, + gateway_base_url=aiprovider.gateway_base_url, + gateway_auth_header=aiprovider.gateway_auth_header, + gateway_auth_secret_env=aiprovider.gateway_auth_secret_env, + gateway_auth_secret=aiprovider.gateway_auth_secret, + gateway_extra_headers=aiprovider.gateway_extra_headers, + ) existing_default = db.query(AIProvider).filter( AIProvider.organization_id == organization_id, @@ -302,6 +361,28 @@ async def update_aiprovider( ), ) + next_gateway_interface = ( + update_data["gateway_interface"].value + if update_data.get("gateway_interface") is not None + else db_aiprovider.gateway_interface + ) + _assert_gateway_fields_allowed( + organization_id, + gateway_model=update_data.get("gateway_model", db_aiprovider.gateway_model), + gateway_interface=next_gateway_interface, + gateway_base_url=update_data.get("gateway_base_url", db_aiprovider.gateway_base_url), + gateway_auth_header=update_data.get( + "gateway_auth_header", db_aiprovider.gateway_auth_header + ), + gateway_auth_secret_env=update_data.get( + "gateway_auth_secret_env", db_aiprovider.gateway_auth_secret_env + ), + gateway_auth_secret=update_data.get("gateway_auth_secret"), + gateway_extra_headers=update_data.get( + "gateway_extra_headers", db_aiprovider.gateway_extra_headers + ), + ) + skip_fields = { "api_key", "routing_mode", diff --git a/app/api/v1/routes/integrations.py b/app/api/v1/routes/integrations.py index 8dab7a66..7a9f383e 100644 --- a/app/api/v1/routes/integrations.py +++ b/app/api/v1/routes/integrations.py @@ -73,10 +73,9 @@ async def create_integration( """ from sqlalchemy import func from app.models.enums import CredentialRoutingMode - from app.services.ai.llm_gateway_settings import assert_llm_gateway_entitlement + from app.services.ai.llm_gateway_settings import assert_credential_routing_allowed - if integration_data.routing_mode == CredentialRoutingMode.GATEWAY: - assert_llm_gateway_entitlement(organization_id) + assert_credential_routing_allowed(organization_id, integration_data.routing_mode) platform_value = integration_data.platform.value if hasattr(integration_data.platform, 'value') else integration_data.platform @@ -289,10 +288,9 @@ async def update_integration( if integration_update.routing_mode is not None: from app.models.enums import CredentialRoutingMode - from app.services.ai.llm_gateway_settings import assert_llm_gateway_entitlement + from app.services.ai.llm_gateway_settings import assert_credential_routing_allowed - if integration_update.routing_mode == CredentialRoutingMode.GATEWAY: - assert_llm_gateway_entitlement(organization_id) + assert_credential_routing_allowed(organization_id, integration_update.routing_mode) integration.routing_mode = integration_update.routing_mode.value db.commit() diff --git a/app/api/v1/routes/settings.py b/app/api/v1/routes/settings.py index 44649b00..c431a1a6 100644 --- a/app/api/v1/routes/settings.py +++ b/app/api/v1/routes/settings.py @@ -31,6 +31,7 @@ get_feature_catalog, get_features_enabled_for_org, get_license_info, + has_valid_license, ) from app.core.oss_quotas import get_quota_usage, get_quotas_snapshot from app.core.usage_entitlement import get_usage_policy @@ -93,8 +94,11 @@ def license_info( quotas = get_quotas_snapshot(organization_id) quota_usage = get_quota_usage(db, organization_id) + licensed = has_valid_license(organization_id) + return { - "is_enterprise": bool(enabled_for_org), + "is_enterprise": licensed, + "gateway_routing_allowed": licensed, "enabled_features": enabled_for_org, "all_enterprise_features": ENTERPRISE_FEATURES, "feature_catalog": get_feature_catalog(), diff --git a/app/core/license.py b/app/core/license.py index 6a974936..fb6d1c9a 100644 --- a/app/core/license.py +++ b/app/core/license.py @@ -26,6 +26,7 @@ from loguru import logger _license_cache: Dict[str, Any] | None = None +_license_token_cached: str | None = None FEATURE_CATALOG: Dict[str, Dict[str, str]] = { "voice_playground": { @@ -180,7 +181,11 @@ def _decode_license() -> Dict[str, Any]: def get_license_info() -> Dict[str, Any]: """Return cached license payload, decoding on first call.""" - global _license_cache + global _license_cache, _license_token_cached + current_token = _get_license_token() + if current_token != _license_token_cached: + _license_cache = None + _license_token_cached = current_token if _license_cache is None: _license_cache = _decode_license() return _license_cache @@ -206,12 +211,13 @@ def get_licensed_org_id() -> Optional[str]: return get_license_info().get("org_id") -def _license_applies_to_org(organization_id: Optional[UUID] = None) -> bool: - """True when a valid JWT is present and applies to the given organization.""" - if not get_enabled_features(): +def has_valid_license(organization_id: Optional[UUID] = None) -> bool: + """True when EFFICIENTAI_LICENSE is a valid, non-expired JWT for this org.""" + info = get_license_info() + if not info: return False - licensed_org = get_license_info().get("org_id") + licensed_org = info.get("org_id") if licensed_org is None: return True @@ -221,6 +227,14 @@ def _license_applies_to_org(organization_id: Optional[UUID] = None) -> bool: return str(organization_id) == str(licensed_org) +def _license_applies_to_org(organization_id: Optional[UUID] = None) -> bool: + """True when a valid JWT is present and applies to the given organization.""" + if not get_enabled_features(): + return False + + return has_valid_license(organization_id) + + def is_feature_enabled(feature: str, organization_id: Optional[UUID] = None) -> bool: """ Check whether an enterprise feature is enabled. @@ -270,5 +284,6 @@ def has_auth_feature(feature: str) -> bool: def reset_license_cache() -> None: """Force re-evaluation of the license (useful after env change in tests).""" - global _license_cache + global _license_cache, _license_token_cached _license_cache = None + _license_token_cached = None diff --git a/app/services/ai/llm_gateway.py b/app/services/ai/llm_gateway.py index d07ef42a..ff64e97f 100644 --- a/app/services/ai/llm_gateway.py +++ b/app/services/ai/llm_gateway.py @@ -516,9 +516,9 @@ def resolve_effective_routing( if credential_mode == "direct": return None, "direct" - from app.core.license import is_feature_enabled + from app.core.license import has_valid_license - if not is_feature_enabled("llm_gateway", organization_id): + if not has_valid_license(organization_id): return None, "direct" use_gateway = _org_wants_gateway(org, platform, credential_mode=credential_mode) diff --git a/app/services/ai/llm_gateway_settings.py b/app/services/ai/llm_gateway_settings.py index 58a28cb7..dd518292 100644 --- a/app/services/ai/llm_gateway_settings.py +++ b/app/services/ai/llm_gateway_settings.py @@ -145,26 +145,54 @@ def get_org_settings(organization_id: UUID, db: Session) -> Dict[str, Any]: def assert_llm_gateway_entitlement(organization_id: UUID) -> None: - """Raise 403 when LLM gateway enablement requires an enterprise license.""" - from app.core.license import is_feature_enabled + """Raise 403 when LLM gateway enablement requires a valid enterprise license.""" + from app.core.license import has_valid_license - if is_feature_enabled("llm_gateway", organization_id): + if has_valid_license(organization_id): return raise HTTPException( status_code=403, detail={ - "error": "enterprise_feature_required", - "feature": "llm_gateway", + "error": "enterprise_license_required", "message": ( - "'llm_gateway' is an EfficientAI Enterprise feature. " - "Please set EFFICIENTAI_LICENSE in your environment to unlock it. " - "Contact sales@efficientai.com to get an enterprise license key." + "LLM gateway routing requires a valid EfficientAI Enterprise license. " + "Set EFFICIENTAI_LICENSE in your environment to unlock it. " + "Contact sales@efficientai.com to get a license key." ), }, ) +def assert_credential_routing_allowed( + organization_id: UUID, + routing_mode: Any, +) -> None: + """OSS installs may only use direct API key routing on integrations.""" + from app.core.license import has_valid_license + + if has_valid_license(organization_id): + return + + mode = ( + routing_mode.value + if hasattr(routing_mode, "value") + else str(routing_mode or "inherit") + ) + if mode != "direct": + raise HTTPException( + status_code=403, + detail={ + "error": "enterprise_license_required", + "message": ( + "Open source installs must use direct API key routing for " + "integrations. Set EFFICIENTAI_LICENSE to unlock Bifrost / " + "LiteLLM gateway routing." + ), + }, + ) + + def set_org_settings( organization_id: UUID, db: Session, diff --git a/app/workers/tasks/agent_flowchart_jobs.py b/app/workers/tasks/agent_flowchart_jobs.py index 9b52b3c0..0d5057de 100644 --- a/app/workers/tasks/agent_flowchart_jobs.py +++ b/app/workers/tasks/agent_flowchart_jobs.py @@ -23,6 +23,23 @@ def _supports_flowchart(partial: PromptPartial) -> bool: return partial_supports_flowchart(partial.tags if isinstance(partial.tags, list) else None) +def _compact_error_message(exc: BaseException) -> str: + """Store a short user-facing message instead of a full Python traceback.""" + text = str(exc).strip() or exc.__class__.__name__ + for marker in ( + "\nTraceback (most recent call last):", + "\nDuring handling of the above exception", + ): + idx = text.find(marker) + if idx >= 0: + text = text[:idx].strip() + break + first_line = text.split("\n", 1)[0].strip() + if len(first_line) <= 500: + return first_line or text[:500] + return first_line[:497] + "..." + + @celery_app.task(name="generate_agent_flowchart", bind=True, max_retries=0) def generate_agent_flowchart_task( self, @@ -104,7 +121,7 @@ def generate_agent_flowchart_task( ) partial.agent_flowchart = { **flowchart_payload, - "generation_error": str(exc), + "generation_error": _compact_error_message(exc), } partial.agent_flowchart_status = "failed" flag_modified(partial, "agent_flowchart") @@ -194,7 +211,7 @@ def map_agent_flowchart_prompt_sections_task( ) partial.agent_flowchart = { **flowchart_payload, - "mapping_error": str(exc), + "mapping_error": _compact_error_message(exc), } partial.agent_flowchart_status = "completed" flag_modified(partial, "agent_flowchart") diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 16d2c360..6d88b9bf 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -133,6 +133,7 @@ export interface OssQuotaUsage { export interface LicenseInfoResponse { is_enterprise: boolean + gateway_routing_allowed?: boolean enabled_features: string[] all_enterprise_features: string[] feature_catalog?: EnterpriseFeatureCatalog diff --git a/frontend/src/lib/apiErrors.test.ts b/frontend/src/lib/apiErrors.test.ts new file mode 100644 index 00000000..1b3147fc --- /dev/null +++ b/frontend/src/lib/apiErrors.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { summarizeFlowchartError } from './apiErrors' + +describe('summarizeFlowchartError', () => { + it('returns a short summary and strips python tracebacks', () => { + const raw = [ + 'Generation failed for openai/gemini-1.5-flash: litellm.NotFoundError: OpenAIException - The model `gemini-1.5-flash` does not exist.', + 'Traceback (most recent call last):', + ' File "/app/service.py", line 1, in run', + ' raise Error()', + ].join('\n') + + const result = summarizeFlowchartError(raw) + + expect(result.summary).toContain('gemini-1.5-flash') + expect(result.summary).not.toContain('Traceback') + expect(result.details).toContain('Generation failed for openai/gemini-1.5-flash') + expect(result.details).not.toContain('Traceback') + }) +}) diff --git a/frontend/src/lib/apiErrors.ts b/frontend/src/lib/apiErrors.ts index deefe935..b034991f 100644 --- a/frontend/src/lib/apiErrors.ts +++ b/frontend/src/lib/apiErrors.ts @@ -35,6 +35,71 @@ export function getApiErrorMessage(error: unknown, fallback: string): string { return fallback } +function coerceToErrorText(raw: unknown): string { + if (raw == null) return '' + if (typeof raw === 'string') return raw + if (typeof raw === 'object' && raw !== null && 'message' in raw) { + return String((raw as { message: unknown }).message) + } + return String(raw) +} + +function stripPythonTraceback(text: string): string { + let result = text + for (const marker of [ + '\nTraceback (most recent call last):', + '\nDuring handling of the above exception', + ]) { + const idx = result.indexOf(marker) + if (idx >= 0) { + result = result.slice(0, idx) + } + } + return result.trim() +} + +function pickBestErrorLine(lines: string[]): string | null { + if (!lines.length) return null + const preferred = lines.find( + (line) => + /does not exist|not found|invalid|failed|error|exception|unauthorized|rate limit/i.test( + line, + ) && + !line.startsWith('File ') && + !line.includes('site-packages'), + ) + return preferred || lines[0] +} + +/** Turn a long LLM/stack-trace failure into a short label plus optional detail text. */ +export function summarizeFlowchartError(raw: unknown): { + summary: string + details: string | null +} { + const text = coerceToErrorText(raw).trim() + if (!text) { + return { summary: 'Flowchart generation failed.', details: null } + } + + const withoutTrace = stripPythonTraceback(text) + const lines = withoutTrace + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + let summary = pickBestErrorLine(lines) || 'Flowchart generation failed.' + if (summary.length > 320) { + summary = `${summary.slice(0, 317)}…` + } + + const hadTraceback = text.length > withoutTrace.length + 10 + const details = + hadTraceback || withoutTrace.length > summary.length + 24 + ? withoutTrace + : null + + return { summary, details: details && details !== summary ? details : null } +} + /** Like getApiErrorMessage, but parses JSON error bodies returned as Blob (e.g. responseType: 'blob'). */ export async function getBlobApiErrorMessage( error: unknown, diff --git a/frontend/src/pages/agents/components/AgentPromptVisualization.tsx b/frontend/src/pages/agents/components/AgentPromptVisualization.tsx index 3da844bf..642ea437 100644 --- a/frontend/src/pages/agents/components/AgentPromptVisualization.tsx +++ b/frontend/src/pages/agents/components/AgentPromptVisualization.tsx @@ -1,10 +1,12 @@ import { useState, useEffect } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Sparkles, Loader2, RefreshCw, Wand2 } from 'lucide-react' +import { Sparkles, Loader2, RefreshCw, Wand2, PanelRightClose, PanelRight } from 'lucide-react' import { apiClient } from '../../../lib/api' +import { getApiErrorMessage } from '../../../lib/apiErrors' import { useToast } from '../../../hooks/useToast' import AIProviderModelPicker from '../../../components/AIProviderModelPicker' import AgentFlowChart from '../../promptPartials/components/AgentFlowChart' +import FlowchartErrorPanel from '../../promptPartials/components/FlowchartErrorPanel' import AgentPromptSectionView, { type PromptHighlightRange, } from '../../promptPartials/components/AgentPromptSectionView' @@ -41,6 +43,7 @@ export default function AgentPromptVisualization({ const [nodeMapError, setNodeMapError] = useState(null) const [llmProvider, setLlmProvider] = useState('') const [llmModel, setLlmModel] = useState('') + const [flowchartCollapsed, setFlowchartCollapsed] = useState(false) const { data: partials = [], isLoading: isLoadingPartials } = useQuery({ queryKey: ['agent-prompt-partials', agentId, linkTag], @@ -101,8 +104,8 @@ export default function AgentPromptVisualization({ queryClient.invalidateQueries({ queryKey: ['prompt-partial', linkedPartialId] }) showToast('Flowchart generation started', 'success') }, - onError: (err: any) => { - showToast(err?.response?.data?.detail || err?.message || 'Failed to generate flowchart', 'error') + onError: (err: unknown) => { + showToast(getApiErrorMessage(err, 'Failed to generate flowchart'), 'error') }, }) @@ -126,8 +129,8 @@ export default function AgentPromptVisualization({ refetchPartial() showToast('Prompt mapping started', 'success') }, - onError: (err: any) => { - showToast(err?.response?.data?.detail || 'Failed to map prompt sections', 'error') + onError: (err: unknown) => { + showToast(getApiErrorMessage(err, 'Failed to map prompt sections'), 'error') }, }) @@ -268,12 +271,31 @@ export default function AgentPromptVisualization({ ) : null}
- {nodeMapError && ( -

{nodeMapError}

- )} + {nodeMapError ? ( + + ) : null} + {flowchart?.generation_error ? ( + + ) : null} -
-
+
+
Prompt
@@ -282,39 +304,70 @@ export default function AgentPromptVisualization({ content={agentPromptContent} highlight={promptHighlight} previewMode={previewMode} + onCopied={() => showToast('Prompt copied to clipboard', 'success')} />
+ {flowchartCollapsed ? ( + + ) : null}
-
-
- Flowchart - {isFlowchartJobRunning && ( - - - {flowchartStatus}… - - )} -
-
- {flowchart?.nodes?.length ? ( - saveLayoutMutation.mutate(nodes)} - savingLayout={saveLayoutMutation.isPending} - highlightNodeId={selectedFlowNodeId} - onNodeClick={handleFlowNodeClick} - /> - ) : ( -
- {isFlowchartJobRunning - ? 'Generating flowchart…' - : 'Click "Generate flowchart" to visualize this agent prompt.'} + {!flowchartCollapsed ? ( +
+
+ Flowchart +
+ {isFlowchartJobRunning ? ( + + + {flowchartStatus}… + + ) : null} +
- )} +
+
+ {flowchart?.nodes?.length ? ( + saveLayoutMutation.mutate(nodes)} + savingLayout={saveLayoutMutation.isPending} + highlightNodeId={selectedFlowNodeId} + onNodeClick={handleFlowNodeClick} + /> + ) : isFlowchartJobRunning ? ( +
+ Generating flowchart… +
+ ) : flowchart?.generation_error || flowchartStatus === 'failed' ? ( + + ) : ( +
+ Click "Generate flowchart" to visualize this agent prompt. +
+ )} +
-
+ ) : null}
) diff --git a/frontend/src/pages/configurations/Integrations.tsx b/frontend/src/pages/configurations/Integrations.tsx index 02fe99f9..2c9b1d07 100644 --- a/frontend/src/pages/configurations/Integrations.tsx +++ b/frontend/src/pages/configurations/Integrations.tsx @@ -1,7 +1,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { apiClient } from '../../lib/api' import type { TelephonyIntegrationResponse } from '../../lib/api' -import { useState, useEffect, useRef, type ReactNode } from 'react' +import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react' import { createPortal } from 'react-dom' import { Plus, Trash2, X, AlertCircle, Plug, Edit, Brain, ChevronDown, Phone, Star, Network } from 'lucide-react' import { IntegrationCreate, IntegrationPlatform, Integration, AIProvider, AIProviderCreate, AIProviderUpdate, ModelProvider, TelephonyProvider, CredentialRoutingMode, GatewayInterfaceMode } from '../../types/api' @@ -45,8 +45,10 @@ const AI_INTEGRATION_PROVIDERS: ModelProvider[] = [ export default function Integrations() { const queryClient = useQueryClient() const { showToast, ToastContainer } = useToast() - const isFeatureEnabled = useLicenseStore((s) => s.isFeatureEnabled) - const llmGatewayLicensed = isFeatureEnabled('llm_gateway') + const gatewayRoutingAllowed = useLicenseStore((s) => s.gatewayRoutingAllowed) + const licenseLoaded = useLicenseStore((s) => s.isLoaded) + const defaultCredentialRouting = (): CredentialRoutingMode => + gatewayRoutingAllowed ? 'inherit' : 'direct' const [showModal, setShowModal] = useState(false) const [isEditMode, setIsEditMode] = useState(false) const [integrationType, setIntegrationType] = useState(null) @@ -60,7 +62,10 @@ export default function Integrations() { const [publicKey, setPublicKey] = useState('') const [name, setName] = useState('') const [azureEndpointUrl, setAzureEndpointUrl] = useState('') - const [credentialRoutingMode, setCredentialRoutingMode] = useState('inherit') + const [credentialRoutingMode, setCredentialRoutingMode] = useState('direct') + const effectiveCredentialRoutingMode: CredentialRoutingMode = gatewayRoutingAllowed + ? credentialRoutingMode + : 'direct' const [gatewayModel, setGatewayModel] = useState('') const [gatewayInterface, setGatewayInterface] = useState('inherit') const [gatewayBaseUrl, setGatewayBaseUrl] = useState('') @@ -206,17 +211,33 @@ export default function Integrations() { String(activeAIProvider || '').toLowerCase() === ModelProvider.CUSTOM const aiProviderUsesModelsStep = integrationType === 'ai_provider' && !isCustomAIProvider const showGatewayModelField = + gatewayRoutingAllowed && integrationType === 'ai_provider' && (isCustomAIProvider || credentialRoutingMode === 'gateway' || (credentialRoutingMode === 'inherit' && llmGatewaySettings?.effective_routing && llmGatewaySettings.effective_routing !== 'direct')) - const showGatewayOptionalApiKeyUi = isCustomAIProvider + const showGatewayOptionalApiKeyUi = isCustomAIProvider && gatewayRoutingAllowed const aiProviderRequiresApiKey = isCustomAIProvider - ? credentialRoutingMode === 'direct' + ? !gatewayRoutingAllowed || credentialRoutingMode === 'direct' : !isEditMode + useEffect(() => { + if (!gatewayRoutingAllowed && credentialRoutingMode !== 'direct') { + setCredentialRoutingMode('direct') + } + }, [gatewayRoutingAllowed, credentialRoutingMode]) + + useEffect(() => { + if ( + !gatewayRoutingAllowed && + selectedProvider === ModelProvider.CUSTOM + ) { + setSelectedProvider(null) + } + }, [gatewayRoutingAllowed, selectedProvider]) + const showLlmGatewayConfigOptions = llmGatewayMode !== 'disabled' useEffect(() => { @@ -382,11 +403,11 @@ export default function Integrations() { return () => { document.removeEventListener('mousedown', handleClickOutside) } }, [showProviderDropdown, showPlatformDropdown]) - const resetForm = () => { - setShowModal(false); setIsEditMode(false); setIntegrationType(null); setSelectedIntegration(null); setSelectedAIProvider(null) + const resetFormFields = () => { + setIsEditMode(false); setIntegrationType(null); setSelectedIntegration(null); setSelectedAIProvider(null) setSelectedPlatform(null); setSelectedProvider(null); setShowProviderDropdown(false); setShowPlatformDropdown(false) setApiKey(''); setPublicKey(''); setName(''); setAzureEndpointUrl('') - setCredentialRoutingMode('inherit'); setGatewayModel(''); setGatewayInterface('inherit'); setGatewayBaseUrl('') + setCredentialRoutingMode(defaultCredentialRouting()); setGatewayModel(''); setGatewayInterface('inherit'); setGatewayBaseUrl('') setGatewayAuthHeader(''); setGatewayAuthSecretEnv(''); setGatewayAuthSecret(''); setClearGatewayAuthSecret(false) setGatewayExtraHeadersJson('') setAiProviderWizardStep(1); setEnabledModels([]) @@ -394,6 +415,16 @@ export default function Integrations() { setEditingTelephonyConfigId(null); setTelephonyName('') } + const resetForm = () => { + resetFormFields() + setShowModal(false) + } + + const openAddIntegrationModal = () => { + resetFormFields() + setShowModal(true) + } + const handleEdit = (integration: Integration) => { setIntegrationType('voice_platform') setSelectedIntegration(integration) @@ -401,14 +432,19 @@ export default function Integrations() { setName(integration.name || '') setApiKey('') // Don't pre-fill API key for security setPublicKey(integration.public_key || '') - setCredentialRoutingMode(integration.routing_mode || 'inherit') + setCredentialRoutingMode( + gatewayRoutingAllowed ? (integration.routing_mode || 'inherit') : 'direct', + ) setIsEditMode(true) setShowModal(true) } const handleEditAIProvider = (provider: AIProvider) => { setIntegrationType('ai_provider'); setSelectedAIProvider(provider); setSelectedProvider(provider.provider) - setName(provider.name || ''); setApiKey(''); setCredentialRoutingMode(provider.routing_mode || 'inherit') + setName(provider.name || ''); setApiKey('') + setCredentialRoutingMode( + gatewayRoutingAllowed ? (provider.routing_mode || 'inherit') : 'direct', + ) setAzureEndpointUrl(provider.endpoint_url || '') setGatewayModel(provider.gateway_model || ''); setGatewayInterface(provider.gateway_interface || 'inherit') setGatewayBaseUrl(provider.gateway_base_url || ''); setGatewayAuthHeader(provider.gateway_auth_header || '') @@ -439,8 +475,8 @@ export default function Integrations() { if (name !== (selectedIntegration.name || '')) updateData.name = name || undefined if (apiKey) updateData.api_key = apiKey if (publicKey !== (selectedIntegration.public_key || '')) updateData.public_key = publicKey || undefined - if (credentialRoutingMode !== (selectedIntegration.routing_mode || 'inherit')) { - updateData.routing_mode = credentialRoutingMode + if (effectiveCredentialRoutingMode !== (selectedIntegration.routing_mode || 'inherit')) { + updateData.routing_mode = effectiveCredentialRoutingMode } if (Object.keys(updateData).length > 0) updateIntegrationMutation.mutate({ id: selectedIntegration.id, data: updateData }) else resetForm() @@ -451,7 +487,7 @@ export default function Integrations() { api_key: apiKey, public_key: publicKey || undefined, name: name || undefined, - routing_mode: credentialRoutingMode, + routing_mode: effectiveCredentialRoutingMode, }) } } else if (integrationType === 'ai_provider') { @@ -494,38 +530,40 @@ export default function Integrations() { if (trimmedAzureEndpointUrl !== (selectedAIProvider.endpoint_url || '')) { updateData.endpoint_url = trimmedAzureEndpointUrl || null } - if (credentialRoutingMode !== (selectedAIProvider.routing_mode || 'inherit')) { - updateData.routing_mode = credentialRoutingMode + if (effectiveCredentialRoutingMode !== (selectedAIProvider.routing_mode || 'inherit')) { + updateData.routing_mode = effectiveCredentialRoutingMode } - const trimmedGatewayModel = gatewayModel.trim() - if (trimmedGatewayModel !== (selectedAIProvider.gateway_model || '')) { - updateData.gateway_model = trimmedGatewayModel || null - } - if (gatewayInterface !== (selectedAIProvider.gateway_interface || 'inherit')) { - updateData.gateway_interface = gatewayInterface - } - const trimmedGatewayBaseUrl = gatewayBaseUrl.trim() - if (trimmedGatewayBaseUrl !== (selectedAIProvider.gateway_base_url || '')) { - updateData.gateway_base_url = trimmedGatewayBaseUrl || null - } - const trimmedGatewayAuthHeader = gatewayAuthHeader.trim() - if (trimmedGatewayAuthHeader !== (selectedAIProvider.gateway_auth_header || '')) { - updateData.gateway_auth_header = trimmedGatewayAuthHeader || null - } - const trimmedGatewayAuthSecretEnv = gatewayAuthSecretEnv.trim() - if (trimmedGatewayAuthSecretEnv !== (selectedAIProvider.gateway_auth_secret_env || '')) { - updateData.gateway_auth_secret_env = trimmedGatewayAuthSecretEnv || null - } - if (clearGatewayAuthSecret) { - updateData.clear_gateway_auth_secret = true - } else if (gatewayAuthSecret.trim()) { - updateData.gateway_auth_secret = gatewayAuthSecret.trim() - } - const existingExtraHeadersJson = formatGatewayExtraHeadersJson( - selectedAIProvider.gateway_extra_headers, - ) - if (gatewayExtraHeadersJson.trim() !== existingExtraHeadersJson.trim()) { - updateData.gateway_extra_headers = parsedGatewayExtraHeaders + if (gatewayRoutingAllowed) { + const trimmedGatewayModel = gatewayModel.trim() + if (trimmedGatewayModel !== (selectedAIProvider.gateway_model || '')) { + updateData.gateway_model = trimmedGatewayModel || null + } + if (gatewayInterface !== (selectedAIProvider.gateway_interface || 'inherit')) { + updateData.gateway_interface = gatewayInterface + } + const trimmedGatewayBaseUrl = gatewayBaseUrl.trim() + if (trimmedGatewayBaseUrl !== (selectedAIProvider.gateway_base_url || '')) { + updateData.gateway_base_url = trimmedGatewayBaseUrl || null + } + const trimmedGatewayAuthHeader = gatewayAuthHeader.trim() + if (trimmedGatewayAuthHeader !== (selectedAIProvider.gateway_auth_header || '')) { + updateData.gateway_auth_header = trimmedGatewayAuthHeader || null + } + const trimmedGatewayAuthSecretEnv = gatewayAuthSecretEnv.trim() + if (trimmedGatewayAuthSecretEnv !== (selectedAIProvider.gateway_auth_secret_env || '')) { + updateData.gateway_auth_secret_env = trimmedGatewayAuthSecretEnv || null + } + if (clearGatewayAuthSecret) { + updateData.clear_gateway_auth_secret = true + } else if (gatewayAuthSecret.trim()) { + updateData.gateway_auth_secret = gatewayAuthSecret.trim() + } + const existingExtraHeadersJson = formatGatewayExtraHeadersJson( + selectedAIProvider.gateway_extra_headers, + ) + if (gatewayExtraHeadersJson.trim() !== existingExtraHeadersJson.trim()) { + updateData.gateway_extra_headers = parsedGatewayExtraHeaders + } } updateAIProviderMutation.mutate({ id: selectedAIProvider.id, data: updateData }) } else { @@ -545,15 +583,19 @@ export default function Integrations() { provider: selectedProvider, api_key: apiKey.trim() || undefined, name: name || null, - routing_mode: credentialRoutingMode, + routing_mode: effectiveCredentialRoutingMode, endpoint_url: selectedProvider === ModelProvider.AZURE ? azureEndpointUrl.trim() : undefined, - gateway_model: gatewayModel.trim() || undefined, - gateway_interface: gatewayInterface, - gateway_base_url: gatewayBaseUrl.trim() || undefined, - gateway_auth_header: gatewayAuthHeader.trim() || undefined, - gateway_auth_secret_env: gatewayAuthSecretEnv.trim() || undefined, - gateway_auth_secret: gatewayAuthSecret.trim() || undefined, - gateway_extra_headers: parsedGatewayExtraHeaders || undefined, + ...(gatewayRoutingAllowed + ? { + gateway_model: gatewayModel.trim() || undefined, + gateway_interface: gatewayInterface, + gateway_base_url: gatewayBaseUrl.trim() || undefined, + gateway_auth_header: gatewayAuthHeader.trim() || undefined, + gateway_auth_secret_env: gatewayAuthSecretEnv.trim() || undefined, + gateway_auth_secret: gatewayAuthSecret.trim() || undefined, + gateway_extra_headers: parsedGatewayExtraHeaders || undefined, + } + : {}), enabled_models: resolvedEnabledModels || undefined, }) } @@ -633,7 +675,14 @@ export default function Integrations() { // AI Integration section should only show LLM providers. // Voice vendors belong under Voice Platform integrations. - const availableProviders = AI_INTEGRATION_PROVIDERS + const availableProviders = useMemo( + () => + AI_INTEGRATION_PROVIDERS.filter( + (provider) => + gatewayRoutingAllowed || provider !== ModelProvider.CUSTOM, + ), + [gatewayRoutingAllowed], + ) const aiIntegrationProviders = (aiproviders as AIProvider[]).filter((p) => AI_INTEGRATION_PROVIDERS.includes(p.provider as ModelProvider) ) @@ -657,11 +706,19 @@ export default function Integrations() {
- +
@@ -991,7 +1052,7 @@ export default function Integrations() { Route batch and evaluation LLM calls through Bifrost or a self-hosted LiteLLM Proxy. Real-time voice agents are unaffected.

- {!llmGatewayLicensed && ( + {!gatewayRoutingAllowed && (
LLM gateway enablement is an Enterprise feature. Set{' '} EFFICIENTAI_LICENSE{' '} @@ -1022,11 +1083,11 @@ export default function Integrations() { value={llmGatewayMode} onChange={(e) => setLlmGatewayMode(e.target.value as LLMGatewayMode)} className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm" - disabled={!llmGatewayLicensed && llmGatewayMode !== 'disabled' && llmGatewayMode !== 'inherit'} + disabled={!gatewayRoutingAllowed && llmGatewayMode !== 'disabled' && llmGatewayMode !== 'inherit'} > - @@ -1221,6 +1282,7 @@ export default function Integrations() { setSelectedPlatform(null) setSelectedProvider(null) setAiProviderWizardStep(1) + setCredentialRoutingMode(defaultCredentialRouting()) }} className={`p-3 border-2 rounded-lg text-left transition-all ${integrationType === 'ai_provider' ? 'border-primary-500 bg-primary-50' @@ -1287,6 +1349,7 @@ export default function Integrations() { setName(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500" placeholder="Integration name" />
+ {gatewayRoutingAllowed && (
setApiKey(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500" @@ -1367,6 +1431,14 @@ export default function Integrations() {

)} + {!gatewayRoutingAllowed && licenseLoaded && ( +
+ Gateway routing is locked on open source installs. Set{' '} + EFFICIENTAI_LICENSE{' '} + in config or your environment, then restart the API server. +
+ )} + {gatewayRoutingAllowed && (
+ +
{KIND_TABS.map((tab) => (
+ ) : ( + + )} {/* Right Panel - Detail / Preview */}
@@ -617,6 +684,15 @@ export default function PromptPartials() {
+ + + ) : (
@@ -769,6 +867,17 @@ export default function PromptPartials() { Click a node to jump to its prompt section

+
+ +
) : null} {flowchartError ? ( -

{flowchartError}

+ + ) : null} + {flowchart?.generation_error ? ( + ) : null} {nodeMapError ? ( -

{nodeMapError}

+ ) : null}
-
+
{flowchart && flowchart.nodes?.length ? ( + ) : flowchartStatus === 'generating' || flowchartMutation.isPending ? ( +
+ + + Generating flowchart… + +
+ ) : flowchart?.generation_error || flowchartStatus === 'failed' ? ( + ) : ( -
- {flowchartStatus === 'generating' || flowchartMutation.isPending ? ( - - - Generating flowchart… - - ) : flowchart?.generation_error ? ( - flowchart.generation_error - ) : ( - 'Generate a flowchart to visualize agent logic.' - )} +
+ Generate a flowchart to visualize agent logic.
)}
+ )}
) : selectedIsMetric ? (
@@ -1024,7 +1154,7 @@ export default function PromptPartials() {
+ ) +} + export default function AgentPromptSectionView({ content, highlight, previewMode, + onCopied, }: { content: string highlight: PromptHighlightRange | null previewMode: 'preview' | 'raw' + onCopied?: () => void }) { const highlightRef = useRef(null) @@ -26,19 +64,31 @@ export default function AgentPromptSectionView({ return () => clearTimeout(handle) }, [highlight?.start, highlight?.end, previewMode]) + const copyBar = ( +
+ +
+ ) + if (!highlight || highlight.start == null || highlight.end == null) { if (previewMode === 'preview') { return ( -
- {content} +
+ {copyBar} +
+ {content} +
) } return ( -
-
-          {content}
-        
+
+ {copyBar} +
+
+            {content}
+          
+
) } @@ -49,7 +99,9 @@ export default function AgentPromptSectionView({ if (previewMode === 'preview') { return ( -
+
+ {copyBar} +
Showing mapped prompt section. Switch to Raw for exact position in the full prompt. @@ -63,22 +115,26 @@ export default function AgentPromptSectionView({ {content}
+
) } return ( -
-
-        {before}
-        
-          {highlighted || highlight.excerpt}
-        
-        {after}
-      
+
+ {copyBar} +
+
+          {before}
+          
+            {highlighted || highlight.excerpt}
+          
+          {after}
+        
+
) } diff --git a/frontend/src/pages/promptPartials/components/FlowchartErrorPanel.tsx b/frontend/src/pages/promptPartials/components/FlowchartErrorPanel.tsx new file mode 100644 index 00000000..24f8321e --- /dev/null +++ b/frontend/src/pages/promptPartials/components/FlowchartErrorPanel.tsx @@ -0,0 +1,84 @@ +import { useMemo, useState } from 'react' +import { AlertCircle } from 'lucide-react' +import { summarizeFlowchartError } from '../../../lib/apiErrors' + +type FlowchartErrorPanelProps = { + error: unknown + title?: string + className?: string + /** Compact banner for toolbars; full panel for empty diagram areas. */ + variant?: 'inline' | 'panel' +} + +export default function FlowchartErrorPanel({ + error, + title = 'Could not generate flowchart', + className = '', + variant = 'panel', +}: FlowchartErrorPanelProps) { + const { summary, details } = useMemo(() => summarizeFlowchartError(error), [error]) + const [showDetails, setShowDetails] = useState(false) + + if (!summary) return null + + if (variant === 'inline') { + return ( +
+

{title}

+

{summary}

+ {details ? ( + <> + + {showDetails ? ( +
+                {details}
+              
+ ) : null} + + ) : null} +
+ ) + } + + return ( +
+
+
+ +
+

{title}

+

{summary}

+ {details ? ( + <> + + {showDetails ? ( +
+                    {details}
+                  
+ ) : null} + + ) : null} +
+
+
+
+ ) +} diff --git a/frontend/src/store/licenseStore.ts b/frontend/src/store/licenseStore.ts index 9af24ad5..3ced8036 100644 --- a/frontend/src/store/licenseStore.ts +++ b/frontend/src/store/licenseStore.ts @@ -29,6 +29,7 @@ const DEFAULT_QUOTA_USAGE: OssQuotaUsage = { interface LicenseState { isEnterprise: boolean + gatewayRoutingAllowed: boolean enabledFeatures: string[] allEnterpriseFeatures: string[] featureCatalog: EnterpriseFeatureCatalog @@ -44,6 +45,7 @@ interface LicenseState { export const useLicenseStore = create((set, get) => ({ isEnterprise: false, + gatewayRoutingAllowed: false, enabledFeatures: [], allEnterpriseFeatures: [], featureCatalog: {}, @@ -57,6 +59,7 @@ export const useLicenseStore = create((set, get) => ({ const info = await apiClient.getLicenseInfo() set({ isEnterprise: info.is_enterprise, + gatewayRoutingAllowed: info.gateway_routing_allowed ?? info.is_enterprise, enabledFeatures: info.enabled_features, allEnterpriseFeatures: info.all_enterprise_features, featureCatalog: info.feature_catalog ?? {}, @@ -68,6 +71,7 @@ export const useLicenseStore = create((set, get) => ({ } catch { set({ isEnterprise: false, + gatewayRoutingAllowed: false, enabledFeatures: [], allEnterpriseFeatures: [], featureCatalog: {}, diff --git a/tests/test_api/test_enterprise_gating.py b/tests/test_api/test_enterprise_gating.py index bf5dd99c..42b7aa0b 100644 --- a/tests/test_api/test_enterprise_gating.py +++ b/tests/test_api/test_enterprise_gating.py @@ -55,7 +55,47 @@ def test_llm_gateway_enable_forbidden_without_license(unlicensed_client): json={"mode": "enabled", "gateway_type": "inherit", "gateway_interface": "inherit"}, ) assert response.status_code == 403 - assert response.json()["detail"]["feature"] == "llm_gateway" + assert response.json()["detail"]["error"] == "enterprise_license_required" + + +def test_aiprovider_gateway_routing_forbidden_without_license(unlicensed_client): + response = unlicensed_client.post( + "/api/v1/aiproviders", + json={ + "provider": "openai", + "name": "OSS should not gateway", + "routing_mode": "gateway", + }, + ) + assert response.status_code == 403 + assert response.json()["detail"]["error"] == "enterprise_license_required" + + +def test_aiprovider_inherit_routing_forbidden_without_license(unlicensed_client): + response = unlicensed_client.post( + "/api/v1/aiproviders", + json={ + "provider": "openai", + "name": "OSS inherit blocked", + "routing_mode": "inherit", + }, + ) + assert response.status_code == 403 + assert response.json()["detail"]["error"] == "enterprise_license_required" + + +def test_aiprovider_direct_routing_allowed_without_license(unlicensed_client): + response = unlicensed_client.post( + "/api/v1/aiproviders", + json={ + "provider": "openai", + "name": "OSS direct ok", + "api_key": "sk-test-direct-only", + "routing_mode": "direct", + }, + ) + assert response.status_code == 201 + assert response.json()["routing_mode"] == "direct" def test_license_info_includes_oss_quotas(unlicensed_client): diff --git a/tests/test_core/test_license_offerings.py b/tests/test_core/test_license_offerings.py index 30bfca07..ef5167d7 100644 --- a/tests/test_core/test_license_offerings.py +++ b/tests/test_core/test_license_offerings.py @@ -33,6 +33,49 @@ def test_default_offerings_absent_without_license(monkeypatch): assert license_module.is_feature_enabled("call_imports", org_id) is False +def test_license_cache_invalidates_when_token_changes(monkeypatch): + state = {"token": "valid-token"} + + def fake_decode(): + if state["token"] == "valid-token": + return {"features": ["call_imports"], "org_id": None} + return {} + + monkeypatch.setattr(license_module, "_get_license_token", lambda: state["token"]) + monkeypatch.setattr(license_module, "_decode_license", fake_decode) + license_module.reset_license_cache() + + assert license_module.has_valid_license() is True + + state["token"] = "removed-token" + assert license_module.has_valid_license() is False + + +def test_has_valid_license_without_feature_entitlements(monkeypatch): + org_id = uuid4() + monkeypatch.setattr( + license_module, + "get_license_info", + lambda: {"features": [], "org_id": str(org_id)}, + ) + + assert license_module.has_valid_license(org_id) is True + assert license_module.is_feature_enabled("alerts", org_id) is False + + +def test_gateway_entitlement_uses_valid_license_not_feature_flag(monkeypatch): + org_id = uuid4() + monkeypatch.setattr( + license_module, + "get_license_info", + lambda: {"features": [], "org_id": None}, + ) + monkeypatch.setattr(license_module, "get_enabled_features", lambda: []) + + assert license_module.has_valid_license(org_id) is True + assert license_module.is_feature_enabled("llm_gateway", org_id) is False + + def test_get_features_enabled_for_org_includes_defaults(monkeypatch): org_id = uuid4() monkeypatch.setattr( From aed029cbe11d9752b229227c5ea05c00c858a599 Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Tue, 22 Sep 2026 11:38:41 +0000 Subject: [PATCH 5/8] fix: updating couple gating fixes --- app/api/v1/routes/aiproviders.py | 108 ++++++++++++------ app/core/oss_quotas.py | 13 ++- app/db_sharding/__init__.py | 8 +- app/db_sharding/sessions.py | 20 ++-- .../src/components/CreateWorkspaceModal.tsx | 2 + frontend/src/pages/agents/AgentsWorkspace.tsx | 5 + frontend/src/pages/auth/InviteAccept.tsx | 3 + frontend/src/pages/auth/Login.tsx | 2 + frontend/src/pages/auth/LoginCallback.tsx | 2 + .../src/pages/configurations/Integrations.tsx | 18 +-- frontend/src/pages/iam/IAM.tsx | 2 + .../src/pages/metrics/MetricsManagement.tsx | 4 + frontend/src/pages/profile/Profile.tsx | 2 + frontend/src/store/licenseStore.ts | 5 + tests/test_api/test_enterprise_gating.py | 108 ++++++++++++++++++ tests/test_core/test_oss_quotas.py | 38 ++++++ tests/test_db_sharding/test_sessions.py | 97 ++++++++++++++++ .../test_sharding_postgres_integration.py | 11 +- 18 files changed, 397 insertions(+), 51 deletions(-) create mode 100644 tests/test_db_sharding/test_sessions.py diff --git a/app/api/v1/routes/aiproviders.py b/app/api/v1/routes/aiproviders.py index e0ed86bb..6e78ef35 100644 --- a/app/api/v1/routes/aiproviders.py +++ b/app/api/v1/routes/aiproviders.py @@ -12,7 +12,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import desc, func from sqlalchemy.orm import Session -from typing import List, Optional +from typing import Any, List, Optional from uuid import UUID from app.dependencies import get_db, get_organization_id @@ -121,6 +121,59 @@ def _assert_gateway_fields_allowed( assert_llm_gateway_entitlement(organization_id) +def _normalize_gateway_interface(value: Any) -> Optional[str]: + if value is None: + return None + return value.value if hasattr(value, "value") else str(value) + + +def _assert_gateway_update_allowed( + organization_id: UUID, + db_aiprovider: AIProvider, + update_data: dict, +) -> None: + """Reject new gateway configuration on OSS; allow clears and unrelated edits.""" + from app.core.license import has_valid_license + from app.services.ai.llm_gateway_settings import assert_llm_gateway_entitlement + + if has_valid_license(organization_id): + return + + def _enabling(field: str, value: Any) -> bool: + if field == "gateway_interface": + normalized = (_normalize_gateway_interface(value) or "").strip().lower() + return normalized not in ("", "inherit") + if field == "gateway_extra_headers": + return bool(value) + return bool((value or "").strip()) + + checks: list[tuple[str, Any]] = [ + ("gateway_model", db_aiprovider.gateway_model), + ("gateway_interface", db_aiprovider.gateway_interface), + ("gateway_base_url", db_aiprovider.gateway_base_url), + ("gateway_auth_header", db_aiprovider.gateway_auth_header), + ("gateway_auth_secret_env", db_aiprovider.gateway_auth_secret_env), + ("gateway_extra_headers", db_aiprovider.gateway_extra_headers), + ] + + for field, stored in checks: + if field not in update_data: + continue + new_value = update_data[field] + if not _enabling(field, new_value): + continue + stored_cmp = stored + if field == "gateway_interface": + new_value = _normalize_gateway_interface(new_value) + stored_cmp = _normalize_gateway_interface(stored) + if new_value != stored_cmp: + assert_llm_gateway_entitlement(organization_id) + return + + if update_data.get("gateway_auth_secret"): + assert_llm_gateway_entitlement(organization_id) + + def _validate_routing_and_api_key( *, organization_id: UUID, @@ -128,10 +181,12 @@ def _validate_routing_and_api_key( api_key: Optional[str], gateway_model: Optional[str], has_existing_key: bool = False, + check_routing_entitlement: bool = True, ) -> None: from app.services.ai.llm_gateway_settings import assert_credential_routing_allowed - assert_credential_routing_allowed(organization_id, routing_mode) + if check_routing_entitlement: + assert_credential_routing_allowed(organization_id, routing_mode) mode = routing_mode.value if hasattr(routing_mode, "value") else str(routing_mode) trimmed_key = (api_key or "").strip() @@ -342,46 +397,35 @@ async def update_aiprovider( ) update_data = aiprovider_update.model_dump(exclude_unset=True) - next_routing_mode = update_data.get( - "routing_mode", - CredentialRoutingMode(db_aiprovider.routing_mode), - ) + stored_routing_mode = CredentialRoutingMode(db_aiprovider.routing_mode) + next_routing_mode = update_data.get("routing_mode", stored_routing_mode) next_gateway_model = update_data.get("gateway_model", db_aiprovider.gateway_model) next_api_key = update_data.get("api_key") + has_existing_key = ( + bool(db_aiprovider.api_key) + and not is_gateway_managed_stored_key(db_aiprovider.api_key) + ) - if "routing_mode" in update_data or "api_key" in update_data or "gateway_model" in update_data: + if "routing_mode" in update_data: _validate_routing_and_api_key( organization_id=organization_id, routing_mode=next_routing_mode, api_key=next_api_key, gateway_model=next_gateway_model, - has_existing_key=( - bool(db_aiprovider.api_key) - and not is_gateway_managed_stored_key(db_aiprovider.api_key) - ), + has_existing_key=has_existing_key, + check_routing_entitlement=True, + ) + elif "api_key" in update_data: + _validate_routing_and_api_key( + organization_id=organization_id, + routing_mode=stored_routing_mode, + api_key=next_api_key, + gateway_model=next_gateway_model, + has_existing_key=has_existing_key, + check_routing_entitlement=False, ) - next_gateway_interface = ( - update_data["gateway_interface"].value - if update_data.get("gateway_interface") is not None - else db_aiprovider.gateway_interface - ) - _assert_gateway_fields_allowed( - organization_id, - gateway_model=update_data.get("gateway_model", db_aiprovider.gateway_model), - gateway_interface=next_gateway_interface, - gateway_base_url=update_data.get("gateway_base_url", db_aiprovider.gateway_base_url), - gateway_auth_header=update_data.get( - "gateway_auth_header", db_aiprovider.gateway_auth_header - ), - gateway_auth_secret_env=update_data.get( - "gateway_auth_secret_env", db_aiprovider.gateway_auth_secret_env - ), - gateway_auth_secret=update_data.get("gateway_auth_secret"), - gateway_extra_headers=update_data.get( - "gateway_extra_headers", db_aiprovider.gateway_extra_headers - ), - ) + _assert_gateway_update_allowed(organization_id, db_aiprovider, update_data) skip_fields = { "api_key", diff --git a/app/core/oss_quotas.py b/app/core/oss_quotas.py index ce5721e7..ddcfde86 100644 --- a/app/core/oss_quotas.py +++ b/app/core/oss_quotas.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import Session from app.core.usage_entitlement import has_enterprise_entitlement -from app.models.database import Agent, Metric, OrganizationMember, Workspace +from app.models.database import Agent, Metric, Organization, OrganizationMember, Workspace OSS_MAX_USER_METRICS = 5 OSS_MAX_AGENTS = 3 @@ -165,6 +165,15 @@ def _count_for_resource( raise ValueError(f"Unknown OSS quota resource: {resource}") +def _lock_organization_for_quota(db: Session, organization_id: UUID) -> None: + """Serialize concurrent quota checks for the same organization (PostgreSQL).""" + bind = db.get_bind() + if bind.dialect.name != "postgresql": + return + + db.query(Organization).filter(Organization.id == organization_id).with_for_update().one() + + def enforce_oss_quota( db: Session, organization_id: UUID, @@ -176,6 +185,8 @@ def enforce_oss_quota( if has_enterprise_entitlement(organization_id): return + _lock_organization_for_quota(db, organization_id) + limit = _RESOURCE_LIMITS[resource] current = _count_for_resource(db, organization_id, resource) if current + additional > limit: diff --git a/app/db_sharding/__init__.py b/app/db_sharding/__init__.py index 0c2a5ead..3985ec8e 100644 --- a/app/db_sharding/__init__.py +++ b/app/db_sharding/__init__.py @@ -2,11 +2,17 @@ from app.db_sharding.pool_manager import db_pool_manager from app.db_sharding.router import ShardRouter -from app.db_sharding.sessions import catalog_session, is_sharding_enabled, row_shard_session +from app.db_sharding.sessions import ( + ShardingEntitlementError, + catalog_session, + is_sharding_enabled, + row_shard_session, +) from app.db_sharding.live_entity_router import live_entity_shard_id __all__ = [ "ShardRouter", + "ShardingEntitlementError", "catalog_session", "db_pool_manager", "is_sharding_enabled", diff --git a/app/db_sharding/sessions.py b/app/db_sharding/sessions.py index 88b90fc8..9d249877 100644 --- a/app/db_sharding/sessions.py +++ b/app/db_sharding/sessions.py @@ -6,12 +6,17 @@ from typing import Iterator, Tuple from uuid import UUID +from loguru import logger from sqlalchemy.orm import Session from app.db_sharding.pool_manager import db_pool_manager, open_row_shard_session from app.db_sharding.pool_manager import open_catalog_session +class ShardingEntitlementError(RuntimeError): + """Sharding pools are configured but deployment entitlement is missing.""" + + @contextmanager def catalog_session() -> Iterator[Session]: db = open_catalog_session() @@ -39,20 +44,21 @@ def is_sharding_enabled() -> bool: from app.core.license import is_feature_enabled from app.core.usage_entitlement import deployment_has_entitlement - from loguru import logger if not deployment_has_entitlement(): - logger.warning( + message = ( "DB_SHARDING_ENABLED is true but no deployment-wide enterprise " - "license is present — sharding remains disabled." + "license is present. Refusing catalog fallback to avoid split storage." ) - return False + logger.error(message) + raise ShardingEntitlementError(message) if not is_feature_enabled("db_sharding"): - logger.warning( + message = ( "DB_SHARDING_ENABLED is true but db_sharding is not enabled " - "by the enterprise license — sharding remains disabled." + "by the enterprise license. Refusing catalog fallback to avoid split storage." ) - return False + logger.error(message) + raise ShardingEntitlementError(message) return True diff --git a/frontend/src/components/CreateWorkspaceModal.tsx b/frontend/src/components/CreateWorkspaceModal.tsx index 2fa59e29..5ab0298e 100644 --- a/frontend/src/components/CreateWorkspaceModal.tsx +++ b/frontend/src/components/CreateWorkspaceModal.tsx @@ -8,6 +8,7 @@ import Button from './Button' import { useToast } from '../hooks/useToast' import { getApiErrorMessage } from '../lib/apiErrors' import { useAuthStore } from '../store/authStore' +import { refreshOssQuotaUsage } from '../store/licenseStore' export interface PendingWorkspaceMember { user_id: string @@ -185,6 +186,7 @@ export default function CreateWorkspaceModal({ } resetForm() + void refreshOssQuotaUsage() await onCreated(created) onClose() diff --git a/frontend/src/pages/agents/AgentsWorkspace.tsx b/frontend/src/pages/agents/AgentsWorkspace.tsx index dadda396..8f94c935 100644 --- a/frontend/src/pages/agents/AgentsWorkspace.tsx +++ b/frontend/src/pages/agents/AgentsWorkspace.tsx @@ -13,6 +13,7 @@ import AgentsListSidebar, { agentMatchesRoute, agentRouteId } from './components import { CreateAgentModal, DeleteAgentModal } from './components' import WalkthroughToggleButton from '../../components/walkthrough/WalkthroughToggleButton' import { useOssQuotas } from '../../hooks/useOssQuotas' +import { refreshOssQuotaUsage } from '../../store/licenseStore' const AGENTS_NAV_CRUMBS: AgentsHierarchyCrumb[] = [{ label: 'Test Agents', to: '/agents' }] @@ -127,10 +128,12 @@ export default function AgentsWorkspace() { const handleCreateSuccess = () => { queryClient.invalidateQueries({ queryKey: ['agents'] }) queryClient.invalidateQueries({ queryKey: ['telephony-numbers'] }) + void refreshOssQuotaUsage() setShowCreateModal(false) } const handleDeleteSuccess = () => { + void refreshOssQuotaUsage() setShowDeleteModal(false) setSelectedAgent(null) setBlockingConversations([]) @@ -139,6 +142,7 @@ export default function AgentsWorkspace() { const handleAgentDeletedFromDetail = () => { queryClient.invalidateQueries({ queryKey: ['agents'] }) + void refreshOssQuotaUsage() const remaining = agents.filter( (a: TestAgent) => !routeAgentId || !agentMatchesRoute(a, routeAgentId) ) @@ -185,6 +189,7 @@ export default function AgentsWorkspace() { if (successCount > 0) { queryClient.invalidateQueries({ queryKey: ['agents'] }) + void refreshOssQuotaUsage() } if (failedAgents.length === 0) { diff --git a/frontend/src/pages/auth/InviteAccept.tsx b/frontend/src/pages/auth/InviteAccept.tsx index cc2716b0..b6e39d17 100644 --- a/frontend/src/pages/auth/InviteAccept.tsx +++ b/frontend/src/pages/auth/InviteAccept.tsx @@ -12,6 +12,7 @@ import { buildAuthorizeUrl } from '../../lib/oidc' import { PASSWORD_POLICY_HINT, validatePasswordPolicy } from '../../lib/passwordPolicy' import { storePendingInviteToken } from '../../lib/inviteToken' import { storeJoinNotice } from '../../lib/joinNotice' +import { refreshOssQuotaUsage } from '../../store/licenseStore' type Mode = 'signup' | 'password' | 'sso' @@ -125,6 +126,7 @@ export default function InviteAccept() { .then((res) => { if (!active) return storeJoinNotice(res.join_notice) + void refreshOssQuotaUsage() setSession( res.user, res.access_token ? { access: res.access_token, refresh: res.refresh_token } : undefined, @@ -214,6 +216,7 @@ export default function InviteAccept() { } const accepted = await apiClient.acceptInvitationByToken(token) storeJoinNotice(accepted.join_notice) + void refreshOssQuotaUsage() setSession( accepted.user, accepted.access_token diff --git a/frontend/src/pages/auth/Login.tsx b/frontend/src/pages/auth/Login.tsx index 14898cc8..48684c28 100644 --- a/frontend/src/pages/auth/Login.tsx +++ b/frontend/src/pages/auth/Login.tsx @@ -13,6 +13,7 @@ import { storePendingInviteToken, } from '../../lib/inviteToken' import { storeJoinNotice } from '../../lib/joinNotice' +import { refreshOssQuotaUsage } from '../../store/licenseStore' import { AlertCircle, Building2, Eye, EyeOff, Loader2 } from 'lucide-react' import Logo from '../../components/Logo' import { Card, CardBody, Button, Divider, Tabs, Tab } from '@heroui/react' @@ -150,6 +151,7 @@ export default function Login() { const accepted = await apiClient.acceptInvitationByToken(token) consumePendingInviteToken() storeJoinNotice(accepted.join_notice) + void refreshOssQuotaUsage() setSession( accepted.user, accepted.access_token diff --git a/frontend/src/pages/auth/LoginCallback.tsx b/frontend/src/pages/auth/LoginCallback.tsx index 89dba132..c17efa0e 100644 --- a/frontend/src/pages/auth/LoginCallback.tsx +++ b/frontend/src/pages/auth/LoginCallback.tsx @@ -7,6 +7,7 @@ import { useAuthStore } from '../../store/authStore' import { exchangeAuthorizationCode, readPkceState } from '../../lib/oidc' import { consumePendingInviteToken, getPendingInviteToken } from '../../lib/inviteToken' import { storeJoinNotice } from '../../lib/joinNotice' +import { refreshOssQuotaUsage } from '../../store/licenseStore' export default function LoginCallback() { const navigate = useNavigate() @@ -57,6 +58,7 @@ export default function LoginCallback() { const accepted = await apiClient.acceptInvitationByToken(pendingInvite) consumePendingInviteToken() storeJoinNotice(accepted.join_notice) + void refreshOssQuotaUsage() setSession( accepted.user, accepted.access_token diff --git a/frontend/src/pages/configurations/Integrations.tsx b/frontend/src/pages/configurations/Integrations.tsx index 2c9b1d07..d492552f 100644 --- a/frontend/src/pages/configurations/Integrations.tsx +++ b/frontend/src/pages/configurations/Integrations.tsx @@ -432,9 +432,7 @@ export default function Integrations() { setName(integration.name || '') setApiKey('') // Don't pre-fill API key for security setPublicKey(integration.public_key || '') - setCredentialRoutingMode( - gatewayRoutingAllowed ? (integration.routing_mode || 'inherit') : 'direct', - ) + setCredentialRoutingMode(integration.routing_mode || 'inherit') setIsEditMode(true) setShowModal(true) } @@ -442,9 +440,7 @@ export default function Integrations() { const handleEditAIProvider = (provider: AIProvider) => { setIntegrationType('ai_provider'); setSelectedAIProvider(provider); setSelectedProvider(provider.provider) setName(provider.name || ''); setApiKey('') - setCredentialRoutingMode( - gatewayRoutingAllowed ? (provider.routing_mode || 'inherit') : 'direct', - ) + setCredentialRoutingMode(provider.routing_mode || 'inherit') setAzureEndpointUrl(provider.endpoint_url || '') setGatewayModel(provider.gateway_model || ''); setGatewayInterface(provider.gateway_interface || 'inherit') setGatewayBaseUrl(provider.gateway_base_url || ''); setGatewayAuthHeader(provider.gateway_auth_header || '') @@ -475,7 +471,10 @@ export default function Integrations() { if (name !== (selectedIntegration.name || '')) updateData.name = name || undefined if (apiKey) updateData.api_key = apiKey if (publicKey !== (selectedIntegration.public_key || '')) updateData.public_key = publicKey || undefined - if (effectiveCredentialRoutingMode !== (selectedIntegration.routing_mode || 'inherit')) { + if ( + gatewayRoutingAllowed && + effectiveCredentialRoutingMode !== (selectedIntegration.routing_mode || 'inherit') + ) { updateData.routing_mode = effectiveCredentialRoutingMode } if (Object.keys(updateData).length > 0) updateIntegrationMutation.mutate({ id: selectedIntegration.id, data: updateData }) @@ -530,7 +529,10 @@ export default function Integrations() { if (trimmedAzureEndpointUrl !== (selectedAIProvider.endpoint_url || '')) { updateData.endpoint_url = trimmedAzureEndpointUrl || null } - if (effectiveCredentialRoutingMode !== (selectedAIProvider.routing_mode || 'inherit')) { + if ( + gatewayRoutingAllowed && + effectiveCredentialRoutingMode !== (selectedAIProvider.routing_mode || 'inherit') + ) { updateData.routing_mode = effectiveCredentialRoutingMode } if (gatewayRoutingAllowed) { diff --git a/frontend/src/pages/iam/IAM.tsx b/frontend/src/pages/iam/IAM.tsx index e1ae2158..83dd47c8 100644 --- a/frontend/src/pages/iam/IAM.tsx +++ b/frontend/src/pages/iam/IAM.tsx @@ -15,6 +15,7 @@ import WorkspaceMembersSection from '../../components/iam/WorkspaceMembersSectio import { PASSWORD_POLICY_HINT, validatePasswordPolicy } from '../../lib/passwordPolicy' import { buildInviteShareUrl } from '../../lib/inviteUrl' import { useOssQuotas } from '../../hooks/useOssQuotas' +import { refreshOssQuotaUsage } from '../../store/licenseStore' type IamTab = 'organization' | 'workspace-members' | 'workspace-roles' @@ -159,6 +160,7 @@ export default function IAM() { mutationFn: (userId: string) => apiClient.removeUser(userId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['iam', 'users'] }) + void refreshOssQuotaUsage() setShowRemoveModal(false) setMemberToRemove(null) showToast('User removed successfully', 'success') diff --git a/frontend/src/pages/metrics/MetricsManagement.tsx b/frontend/src/pages/metrics/MetricsManagement.tsx index a9570f6a..cffb6c54 100644 --- a/frontend/src/pages/metrics/MetricsManagement.tsx +++ b/frontend/src/pages/metrics/MetricsManagement.tsx @@ -7,6 +7,7 @@ import AIProviderModelPicker from '../../components/AIProviderModelPicker' import type { LLMGenerationConfig } from '../../config/llmGenerationParams' import { useToast } from '../../hooks/useToast' import { useOssQuotas } from '../../hooks/useOssQuotas' +import { refreshOssQuotaUsage } from '../../store/licenseStore' import { useWorkspaceStore } from '../../store/workspaceStore' import { Copy, @@ -707,6 +708,7 @@ export default function MetricsManagement({ draftMode ? apiClient.createMetricDraft(data as any) : apiClient.createMetric(data), onSuccess: (metric) => { queryClient.invalidateQueries({ queryKey: ['metrics'] }) + void refreshOssQuotaUsage() onMetricCreated?.(metric) showToast( draftMode ? 'Draft metric created' : 'Metric created', @@ -736,6 +738,7 @@ export default function MetricsManagement({ mutationFn: (id: string) => apiClient.deleteMetric(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['metrics'] }) + void refreshOssQuotaUsage() }, onError: (err: unknown) => { showToast(getApiErrorMessage(err, 'Failed to delete metric'), 'error') @@ -804,6 +807,7 @@ export default function MetricsManagement({ : apiClient.createMetricWithChildren(payload), onSuccess: (metric) => { queryClient.invalidateQueries({ queryKey: ['metrics'] }) + void refreshOssQuotaUsage() onMetricCreated?.(metric) closeModal() if (!draftMode) { diff --git a/frontend/src/pages/profile/Profile.tsx b/frontend/src/pages/profile/Profile.tsx index f1944201..8295e5a4 100644 --- a/frontend/src/pages/profile/Profile.tsx +++ b/frontend/src/pages/profile/Profile.tsx @@ -9,6 +9,7 @@ import { redirectToLoginWithMessage } from '../../lib/authSession' import { PASSWORD_POLICY_HINT, validatePasswordPolicy } from '../../lib/passwordPolicy' import { useOrgSwitch } from '../../hooks/useOrgSwitch' import OrgReauthModal from '../../components/OrgReauthModal' +import { refreshOssQuotaUsage } from '../../store/licenseStore' export default function Profile() { const queryClient = useQueryClient() @@ -159,6 +160,7 @@ export default function Profile() { onSuccess: (data, invitation) => { queryClient.invalidateQueries({ queryKey: ['profile'] }) queryClient.invalidateQueries({ queryKey: ['iam'] }) + void refreshOssQuotaUsage() setJustJoined({ organizationId: invitation.organization_id, organizationName: invitation.organization_name || 'the organization', diff --git a/frontend/src/store/licenseStore.ts b/frontend/src/store/licenseStore.ts index 3ced8036..789f241a 100644 --- a/frontend/src/store/licenseStore.ts +++ b/frontend/src/store/licenseStore.ts @@ -95,3 +95,8 @@ export const useLicenseStore = create((set, get) => ({ return get().usagePolicy.extended_history }, })) + +/** Re-fetch OSS quota usage after resource create/delete mutations. */ +export async function refreshOssQuotaUsage(): Promise { + await useLicenseStore.getState().fetchLicense() +} diff --git a/tests/test_api/test_enterprise_gating.py b/tests/test_api/test_enterprise_gating.py index 42b7aa0b..0fa08a7b 100644 --- a/tests/test_api/test_enterprise_gating.py +++ b/tests/test_api/test_enterprise_gating.py @@ -108,6 +108,114 @@ def test_license_info_includes_oss_quotas(unlicensed_client): assert "quota_usage" in body +def test_update_gateway_aiprovider_name_allowed_without_license( + unlicensed_client, db_session, org_id +): + from app.core.encryption import encrypt_api_key + from app.models.database import AIProvider + from app.services.ai.llm_gateway import GATEWAY_MANAGED_KEY_SENTINEL + + row = AIProvider( + organization_id=org_id, + provider="openai", + api_key=encrypt_api_key(GATEWAY_MANAGED_KEY_SENTINEL), + name="Before", + routing_mode="gateway", + gateway_model="prod-gpt4", + is_active=True, + ) + db_session.add(row) + db_session.commit() + + response = unlicensed_client.put( + f"/api/v1/aiproviders/{row.id}", + json={"name": "After"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["name"] == "After" + assert body["routing_mode"] == "gateway" + assert body["gateway_model"] == "prod-gpt4" + + +def test_update_gateway_aiprovider_api_key_allowed_without_license( + unlicensed_client, db_session, org_id +): + from app.core.encryption import encrypt_api_key + from app.models.database import AIProvider + from app.services.ai.llm_gateway import GATEWAY_MANAGED_KEY_SENTINEL + + row = AIProvider( + organization_id=org_id, + provider="openai", + api_key=encrypt_api_key(GATEWAY_MANAGED_KEY_SENTINEL), + name="Gateway row", + routing_mode="inherit", + is_active=True, + ) + db_session.add(row) + db_session.commit() + + response = unlicensed_client.put( + f"/api/v1/aiproviders/{row.id}", + json={"api_key": "sk-test-direct-key"}, + ) + assert response.status_code == 200 + assert response.json()["routing_mode"] == "inherit" + + +def test_update_aiprovider_gateway_field_blocked_without_license( + unlicensed_client, db_session, org_id +): + from app.core.encryption import encrypt_api_key + from app.models.database import AIProvider + from app.services.ai.llm_gateway import GATEWAY_MANAGED_KEY_SENTINEL + + row = AIProvider( + organization_id=org_id, + provider="openai", + api_key=encrypt_api_key(GATEWAY_MANAGED_KEY_SENTINEL), + name="Gateway row", + routing_mode="gateway", + gateway_model="prod-gpt4", + is_active=True, + ) + db_session.add(row) + db_session.commit() + + response = unlicensed_client.put( + f"/api/v1/aiproviders/{row.id}", + json={"gateway_model": "new-production-model"}, + ) + assert response.status_code == 403 + assert response.json()["detail"]["error"] == "enterprise_license_required" + + +def test_update_aiprovider_routing_mode_gateway_blocked_without_license( + unlicensed_client, db_session, org_id +): + from app.core.encryption import encrypt_api_key + from app.models.database import AIProvider + + row = AIProvider( + organization_id=org_id, + provider="openai", + api_key=encrypt_api_key("sk-existing-direct"), + name="Direct row", + routing_mode="direct", + is_active=True, + ) + db_session.add(row) + db_session.commit() + + response = unlicensed_client.put( + f"/api/v1/aiproviders/{row.id}", + json={"routing_mode": "gateway"}, + ) + assert response.status_code == 403 + assert response.json()["detail"]["error"] == "enterprise_license_required" + + def test_workspace_iam_forbidden_without_license(unlicensed_client, default_workspace): response = unlicensed_client.get( f"/api/v1/workspaces/{default_workspace.id}/members" diff --git a/tests/test_core/test_oss_quotas.py b/tests/test_core/test_oss_quotas.py index 1d9b569d..4d9a5bdc 100644 --- a/tests/test_core/test_oss_quotas.py +++ b/tests/test_core/test_oss_quotas.py @@ -125,6 +125,44 @@ def test_user_metrics_exclude_defaults( assert exc.value.detail["current"] == 5 +def test_enforce_oss_quota_locks_organization_on_postgres( + db_session, org_id, monkeypatch +): + lock_called: list[bool] = [] + + class _FakeQuery: + def filter(self, *_args, **_kwargs): + return self + + def with_for_update(self): + lock_called.append(True) + return self + + def one(self): + return object() + + monkeypatch.setattr( + quotas_module, + "has_enterprise_entitlement", + lambda _org: False, + ) + monkeypatch.setattr( + db_session, + "get_bind", + lambda: type( + "Bind", + (), + {"dialect": type("Dialect", (), {"name": "postgresql"})()}, + )(), + ) + monkeypatch.setattr(db_session, "query", lambda *_args, **_kwargs: _FakeQuery()) + monkeypatch.setattr(quotas_module, "_count_for_resource", lambda *_a, **_k: 0) + + quotas_module.enforce_oss_quota(db_session, org_id, "agents") + + assert lock_called + + def test_workspace_quota_blocks_second_workspace( db_session, org_id, seed_org, default_workspace, monkeypatch ): diff --git a/tests/test_db_sharding/test_sessions.py b/tests/test_db_sharding/test_sessions.py new file mode 100644 index 00000000..007219b4 --- /dev/null +++ b/tests/test_db_sharding/test_sessions.py @@ -0,0 +1,97 @@ +"""Tests for sharding session gating.""" + +from __future__ import annotations + +import pytest + +from app.db_sharding.pool_manager import DatabasePoolManager +from app.db_sharding.sessions import ShardingEntitlementError, is_sharding_enabled + + +@pytest.fixture +def manager(): + m = DatabasePoolManager() + yield m + m.reset() + + +def test_is_sharding_enabled_false_when_pools_not_configured(manager, monkeypatch): + url = "sqlite:///:memory:" + monkeypatch.setattr( + "app.config.settings", + type( + "S", + (), + { + "DATABASE_URL": url, + "DB_SHARDING_ENABLED": False, + "DB_POOL_SIZE": 5, + "DB_MAX_OVERFLOW": 5, + "DB_CATALOG_URL": None, + "DB_SHARD_ROW_CHUNK_SIZE": 500, + "DB_SHARD_ENTRIES": [], + }, + )(), + ) + monkeypatch.setattr("app.db_sharding.sessions.db_pool_manager", manager) + + assert is_sharding_enabled() is False + + +def test_is_sharding_enabled_raises_without_deployment_entitlement(manager, monkeypatch): + url = "sqlite:///:memory:" + monkeypatch.setattr( + "app.config.settings", + type( + "S", + (), + { + "DATABASE_URL": url, + "DB_SHARDING_ENABLED": True, + "DB_CATALOG_URL": url, + "DB_POOL_SIZE": 2, + "DB_MAX_OVERFLOW": 2, + "DB_SHARD_ROW_CHUNK_SIZE": 500, + "DB_SHARD_ENTRIES": [{"id": "data-shard-01", "url": url}], + }, + )(), + ) + monkeypatch.setattr("app.db_sharding.sessions.db_pool_manager", manager) + monkeypatch.setattr( + "app.core.usage_entitlement.deployment_has_entitlement", + lambda: False, + ) + + with pytest.raises(ShardingEntitlementError, match="Refusing catalog fallback"): + is_sharding_enabled() + + +def test_is_sharding_enabled_true_with_entitlement_and_feature(manager, monkeypatch): + url = "sqlite:///:memory:" + monkeypatch.setattr( + "app.config.settings", + type( + "S", + (), + { + "DATABASE_URL": url, + "DB_SHARDING_ENABLED": True, + "DB_CATALOG_URL": url, + "DB_POOL_SIZE": 2, + "DB_MAX_OVERFLOW": 2, + "DB_SHARD_ROW_CHUNK_SIZE": 500, + "DB_SHARD_ENTRIES": [{"id": "data-shard-01", "url": url}], + }, + )(), + ) + monkeypatch.setattr("app.db_sharding.sessions.db_pool_manager", manager) + monkeypatch.setattr( + "app.core.usage_entitlement.deployment_has_entitlement", + lambda: True, + ) + monkeypatch.setattr( + "app.core.license.is_feature_enabled", + lambda _feature: True, + ) + + assert is_sharding_enabled() is True diff --git a/tests/test_db_sharding/test_sharding_postgres_integration.py b/tests/test_db_sharding/test_sharding_postgres_integration.py index f13f2486..81c7ad23 100644 --- a/tests/test_db_sharding/test_sharding_postgres_integration.py +++ b/tests/test_db_sharding/test_sharding_postgres_integration.py @@ -61,9 +61,16 @@ class _Settings: config_module.settings = _Settings() db_pool_manager.reset() - from app.db_sharding.sessions import is_sharding_enabled + from app.db_sharding.sessions import ShardingEntitlementError, is_sharding_enabled - if not is_sharding_enabled(): + try: + sharding_active = is_sharding_enabled() + except ShardingEntitlementError as exc: + config_module.settings = prior_settings + db_pool_manager.reset() + pytest.skip(str(exc)) + + if not sharding_active: config_module.settings = prior_settings db_pool_manager.reset() pytest.skip("pool manager did not enable sharding") From 4d3005909ecba92176f9820979a1870ef8f96663 Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Tue, 22 Sep 2026 18:05:01 +0000 Subject: [PATCH 6/8] feat: updating docs and ent features --- .github/workflows/docs.yml | 2 + docs-fumadocs/CUTOVER.md | 2 +- docs-fumadocs/README.md | 4 +- docs-fumadocs/app/docs/layout.tsx | 5 + .../(docs)/advanced/architecture-guide.mdx | 9 -- .../content/docs/(docs)/advanced/calls.mdx | 9 -- .../docs/(docs)/advanced/cli-commands.mdx | 9 -- .../docs/(docs)/advanced/configuration.mdx | 9 -- .../docs/(docs)/advanced/cron-jobs.mdx | 9 -- .../docs/(docs)/advanced/database-guide.mdx | 9 -- .../(docs)/advanced/development-guide.mdx | 9 -- .../content/docs/(docs)/advanced/usage.mdx | 9 -- .../docs/(docs)/platform/observability.mdx | 4 +- .../content/docs/(docs)/platform/setup.mdx | 2 +- .../content/docs/advanced/architecture.mdx | 52 ------- .../docs/advanced/call-import-sharding.mdx | 53 ------- .../content/docs/advanced/database.mdx | 112 --------------- .../content/docs/advanced/development.mdx | 135 ------------------ .../content/docs/changelog/meta.json | 4 +- .../content/docs/changelog/v1.5.10.mdx | 12 +- .../content/docs/changelog/v1.5.13.mdx | 2 +- .../content/docs/changelog/v1.5.15.mdx | 2 +- .../content/docs/changelog/v1.5.27.mdx | 2 +- .../content/docs/changelog/v1.5.28.mdx | 2 +- .../content/docs/changelog/v1.5.34.mdx | 126 ++++++++++++++++ .../content/docs/changelog/v1.5.4.mdx | 17 --- .../content/docs/changelog/v1.5.5.mdx | 14 +- .../content/docs/changelog/v1.5.6.mdx | 14 +- .../content/docs/changelog/v1.5.7.mdx | 12 +- .../content/docs/changelog/v1.5.8.mdx | 12 +- .../content/docs/changelog/v1.5.9.mdx | 31 +++- .../content/docs/products/call-imports.mdx | 46 ++++++ .../infra/cloudfront-viewer-request.js | 8 +- docs-fumadocs/package.json | 5 +- .../scripts/generate-changelog-nav.mjs | 45 +++--- .../scripts/test-cloudfront-uri-rewrite.mjs | 34 +++++ docs/live-call-storage.md | 2 +- 37 files changed, 351 insertions(+), 482 deletions(-) delete mode 100644 docs-fumadocs/content/docs/(docs)/advanced/architecture-guide.mdx delete mode 100644 docs-fumadocs/content/docs/(docs)/advanced/calls.mdx delete mode 100644 docs-fumadocs/content/docs/(docs)/advanced/cli-commands.mdx delete mode 100644 docs-fumadocs/content/docs/(docs)/advanced/configuration.mdx delete mode 100644 docs-fumadocs/content/docs/(docs)/advanced/cron-jobs.mdx delete mode 100644 docs-fumadocs/content/docs/(docs)/advanced/database-guide.mdx delete mode 100644 docs-fumadocs/content/docs/(docs)/advanced/development-guide.mdx delete mode 100644 docs-fumadocs/content/docs/(docs)/advanced/usage.mdx delete mode 100644 docs-fumadocs/content/docs/advanced/architecture.mdx delete mode 100644 docs-fumadocs/content/docs/advanced/call-import-sharding.mdx delete mode 100644 docs-fumadocs/content/docs/advanced/database.mdx delete mode 100644 docs-fumadocs/content/docs/advanced/development.mdx create mode 100644 docs-fumadocs/content/docs/changelog/v1.5.34.mdx delete mode 100644 docs-fumadocs/content/docs/changelog/v1.5.4.mdx create mode 100644 docs-fumadocs/content/docs/products/call-imports.mdx create mode 100644 docs-fumadocs/scripts/test-cloudfront-uri-rewrite.mjs diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ba48ce4f..211e3f41 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -35,6 +35,8 @@ jobs: - name: Run docs quality checks run: npm run ci:check working-directory: docs-fumadocs + env: + GITHUB_TOKEN: ${{ github.token }} - name: Upload docs site uses: actions/upload-artifact@v4 diff --git a/docs-fumadocs/CUTOVER.md b/docs-fumadocs/CUTOVER.md index 4933c775..edbba738 100644 --- a/docs-fumadocs/CUTOVER.md +++ b/docs-fumadocs/CUTOVER.md @@ -5,7 +5,7 @@ This runbook tracks the Fumadocs rollout strategy. ## Cutover steps 1. Ensure `docs-fumadocs` checks are green in the `Docs` workflow `build` job. -2. Confirm CloudFront has the viewer-request function from [`infra/cloudfront-viewer-request.js`](infra/cloudfront-viewer-request.js) attached and no 404→`/index.html` SPA fallback. +2. Confirm CloudFront has the **latest** viewer-request function from [`infra/cloudfront-viewer-request.js`](infra/cloudfront-viewer-request.js) attached and no 404→`/index.html` SPA fallback. Republish after doc deploys — versioned routes like `/docs/changelog/v1.5.33/` require the updated rewrite logic (do not skip rewrite when a path segment contains dots). 3. Trigger the `Docs` workflow `deploy` job (or push to `main` with docs changes). 4. Verify production routes (direct URL and refresh — not just sidebar clicks): - `https://docs.efficientai.cloud/docs/intro/` diff --git a/docs-fumadocs/README.md b/docs-fumadocs/README.md index 88f7ae7d..9239572e 100644 --- a/docs-fumadocs/README.md +++ b/docs-fumadocs/README.md @@ -36,7 +36,9 @@ The `build` job runs the same checks on pull requests. Only public docs content ### CloudFront routing (required) -Next.js static export with `trailingSlash: true` writes pages as `out/docs//index.html`. S3 REST origins do not resolve directory URLs automatically. Without CloudFront URI rewriting, direct URLs and page refreshes fail and may fall back to `/index.html`, which redirects to `/docs/intro/`. +Next.js static export with `trailingSlash: true` writes pages as `out/docs//index.html`. S3 REST origins do not resolve directory URLs automatically. Without CloudFront URI rewriting, direct URLs and page refreshes fail and may fall back to `/index.html`, which redirects to `/docs/quickstart/`. + +The viewer-request function must **not** treat every `.` in the path as a static file. Versioned routes such as `/docs/changelog/v1.5.33/` contain dots but still require `index.html` rewriting. **Required AWS configuration:** diff --git a/docs-fumadocs/app/docs/layout.tsx b/docs-fumadocs/app/docs/layout.tsx index f17d78dc..484e38ce 100644 --- a/docs-fumadocs/app/docs/layout.tsx +++ b/docs-fumadocs/app/docs/layout.tsx @@ -25,6 +25,8 @@ export default async function Layout({ children }: LayoutProps<'/docs'>) { const tree = source.getPageTree(); const docsRoot = findRootFolder(tree, 'Docs'); const apiRoot = findRootFolder(tree, 'API Reference'); + const changelogRoot = findRootFolder(tree, 'Changelog'); + const blogRoot = findRootFolder(tree, 'Blogs'); const tabs: LayoutTab[] = [ { title: 'Docs', @@ -39,14 +41,17 @@ export default async function Layout({ children }: LayoutProps<'/docs'>) { { title: 'Enterprise', url: '/docs/enterprise/', + urls: new Set(['/docs/enterprise/']), }, { title: 'Changelog', url: '/docs/changelog/', + urls: new Set(changelogRoot ? folderUrls(changelogRoot) : ['/docs/changelog/']), }, { title: 'Blogs', url: '/docs/blog/', + urls: new Set(blogRoot ? folderUrls(blogRoot) : ['/docs/blog/']), }, ]; diff --git a/docs-fumadocs/content/docs/(docs)/advanced/architecture-guide.mdx b/docs-fumadocs/content/docs/(docs)/advanced/architecture-guide.mdx deleted file mode 100644 index 207ab254..00000000 --- a/docs-fumadocs/content/docs/(docs)/advanced/architecture-guide.mdx +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: Architecture ---- - -# Architecture - -Architecture details are now grouped under Advanced in Docs v2. - -See [/docs/advanced/architecture/](/docs/advanced/architecture/). diff --git a/docs-fumadocs/content/docs/(docs)/advanced/calls.mdx b/docs-fumadocs/content/docs/(docs)/advanced/calls.mdx deleted file mode 100644 index 21e76955..00000000 --- a/docs-fumadocs/content/docs/(docs)/advanced/calls.mdx +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: Calls ---- - -# Calls - -Calls monitoring documentation is now grouped under Advanced in Docs v2. - -See [/docs/monitoring/calls/](/docs/monitoring/calls/). diff --git a/docs-fumadocs/content/docs/(docs)/advanced/cli-commands.mdx b/docs-fumadocs/content/docs/(docs)/advanced/cli-commands.mdx deleted file mode 100644 index fa344d6c..00000000 --- a/docs-fumadocs/content/docs/(docs)/advanced/cli-commands.mdx +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: CLI Commands ---- - -# CLI Commands - -This content moved under Advanced in Docs v2. - -See the full reference at [/docs/reference/cli-commands/](/docs/reference/cli-commands/). diff --git a/docs-fumadocs/content/docs/(docs)/advanced/configuration.mdx b/docs-fumadocs/content/docs/(docs)/advanced/configuration.mdx deleted file mode 100644 index 5728b8bf..00000000 --- a/docs-fumadocs/content/docs/(docs)/advanced/configuration.mdx +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: Configuration ---- - -# Configuration - -This content moved under Advanced in Docs v2. - -See the full reference at [/docs/reference/configuration/](/docs/reference/configuration/). diff --git a/docs-fumadocs/content/docs/(docs)/advanced/cron-jobs.mdx b/docs-fumadocs/content/docs/(docs)/advanced/cron-jobs.mdx deleted file mode 100644 index 65843347..00000000 --- a/docs-fumadocs/content/docs/(docs)/advanced/cron-jobs.mdx +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: Cron Jobs ---- - -# Cron Jobs - -Cron-job operational documentation is now grouped under Advanced in Docs v2. - -See [/docs/monitoring/cron-jobs/](/docs/monitoring/cron-jobs/). diff --git a/docs-fumadocs/content/docs/(docs)/advanced/database-guide.mdx b/docs-fumadocs/content/docs/(docs)/advanced/database-guide.mdx deleted file mode 100644 index f799735f..00000000 --- a/docs-fumadocs/content/docs/(docs)/advanced/database-guide.mdx +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: Database ---- - -# Database - -Database internals are now grouped under Advanced in Docs v2. - -See [/docs/advanced/database/](/docs/advanced/database/). diff --git a/docs-fumadocs/content/docs/(docs)/advanced/development-guide.mdx b/docs-fumadocs/content/docs/(docs)/advanced/development-guide.mdx deleted file mode 100644 index f6fe0f92..00000000 --- a/docs-fumadocs/content/docs/(docs)/advanced/development-guide.mdx +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: Development ---- - -# Development - -Developer workflow details are now grouped under Advanced in Docs v2. - -See [/docs/advanced/development/](/docs/advanced/development/). diff --git a/docs-fumadocs/content/docs/(docs)/advanced/usage.mdx b/docs-fumadocs/content/docs/(docs)/advanced/usage.mdx deleted file mode 100644 index 24be2962..00000000 --- a/docs-fumadocs/content/docs/(docs)/advanced/usage.mdx +++ /dev/null @@ -1,9 +0,0 @@ ---- -title: Usage ---- - -# Usage - -Usage monitoring documentation is now grouped under Advanced in Docs v2. - -See [/docs/monitoring/usage/](/docs/monitoring/usage/). diff --git a/docs-fumadocs/content/docs/(docs)/platform/observability.mdx b/docs-fumadocs/content/docs/(docs)/platform/observability.mdx index 3180cad2..71199686 100644 --- a/docs-fumadocs/content/docs/(docs)/platform/observability.mdx +++ b/docs-fumadocs/content/docs/(docs)/platform/observability.mdx @@ -10,5 +10,5 @@ icon: Activity Current usage and operational tracking references: -- [Usage](/docs/advanced/usage/) -- [Calls](/docs/advanced/calls/) +- [Usage](/docs/monitoring/usage/) +- [Calls](/docs/monitoring/calls/) diff --git a/docs-fumadocs/content/docs/(docs)/platform/setup.mdx b/docs-fumadocs/content/docs/(docs)/platform/setup.mdx index 9b9af2d4..9f93d236 100644 --- a/docs-fumadocs/content/docs/(docs)/platform/setup.mdx +++ b/docs-fumadocs/content/docs/(docs)/platform/setup.mdx @@ -52,7 +52,7 @@ Set up object storage for recordings and audio assets: - Google Cloud Storage - Azure Blob Storage -See: [Configuration](/docs/advanced/configuration/) +See: [Configuration](/docs/reference/configuration/) Use cloud storage when you need durable recording retention, larger media throughput, or integration with existing data infrastructure. diff --git a/docs-fumadocs/content/docs/advanced/architecture.mdx b/docs-fumadocs/content/docs/advanced/architecture.mdx deleted file mode 100644 index 9704624f..00000000 --- a/docs-fumadocs/content/docs/advanced/architecture.mdx +++ /dev/null @@ -1,52 +0,0 @@ ---- -id: architecture -title: Architecture -sidebar_position: 3 ---- - -# Architecture - -## Simple Overview - -EfficientAI is built like a modern web application. - -* **The Brain (API Server)**: Controls everything. -* **The Worker**: Does the heavy lifting in the background, like processing audio files so the website stays fast. -* **The Interface (Frontend)**: The website you see and click on. -* **The Memory (Database)**: Where we store all your agents, test results, and user data. - ---- - -## Technical Deep Dive - -EfficientAI is built as a modular, containerized application designed for scalability and extensibility. - -*(See original Architecture documentation below)* - -## System Components - -The platform consists of four primary components: - -1. **API Server (FastAPI)**: The central control plane. -2. **Worker (Celery)**: Handles asynchronous background tasks (transcription, evaluation). -3. **Frontend (React/Vite)**: The user interface. -4. **Data Stores**: PostgreSQL (State) and Redis (Queue/Cache). - -## Core Services - -### 1. API Server (`app/api`) -Built with FastAPI, it provides REST endpoints for: -* **Resource Management**: CRUD for Agents, Personas, Scenarios. -* **Orchestration**: Real-time control of test conversations. -* **Analysis**: Serving evaluation results and dashboards. - -### 2. Asynchronous Workers (`app/workers`) -Powered by Celery and Redis, the workers handle long-running operations: -* **Transcription**: Processing audio files (using Whisper, Deepgram, etc.). -* **Evaluation**: Running metric calculations (WER/CER) on completed conversations. - -### 3. Test Agent Service (`app/services/test_agent_service.py`) -This is the heart of the testing engine. It: -* Manages the state of the conversation. -* Generates accurate system prompts for the Persona. -* Handles the latency-sensitive loop of: `Listen -> Transcribe -> Think (LLM) -> Speak (TTS)`. diff --git a/docs-fumadocs/content/docs/advanced/call-import-sharding.mdx b/docs-fumadocs/content/docs/advanced/call-import-sharding.mdx deleted file mode 100644 index 6531ad26..00000000 --- a/docs-fumadocs/content/docs/advanced/call-import-sharding.mdx +++ /dev/null @@ -1,53 +0,0 @@ ---- -id: call-import-sharding -title: Call Import Sharding -sidebar_position: 4 -description: Multi-database sharding for large call-import batches ---- - -# Call Import Sharding - -For large batches (10k+ rows), call-import row data can be spread across multiple PostgreSQL **data shards** with a **catalog** database for metadata, routing, and parent counters. - -## Architecture - -- **Catalog DB** — `CallImport`, `CallImportEvaluation`, shard slice registry, dispatch metadata -- **Data shards** — `CallImportRow`, `CallImportEvaluationRow` (partitioned by consistent hash on `row_index`) -- **Scatter/gather reads** — API and workers query each shard and merge results -- **Fair dispatch** — import/eval workers respect per-shard pending scans and Redis progress keys - -Enable sharding in `config.yml` under `database.sharding`. See `config.yml.example` and `config.docker.sharding.example.yml` for profiles. - -**Live telephony / evaluator results** use a separate payload sharding path (`evaluator_result_payloads`, `call_recording_payloads`) keyed by workspace — see [`docs/live-call-storage.md`](../../../../docs/live-call-storage.md) in the repo. - -## Operations - -### Connection pools - -When `len(shards) > 1`, use smaller per-process pools (`pool_size` 3–5, `max_overflow` 5–10) so API + workers × shards stay under Postgres `max_connections`. - -### PgBouncer (optional) - -Point `database.url`, `catalog_url`, and each shard `url` at PgBouncer (`:6432`) in transaction pooling mode. Keep SQLAlchemy `pool_pre_ping` enabled. - -### Observability - -Row Celery tasks log `shard_id` on the import worker hot path. Watch Redis eval/import progress keys (`eval:progress`, `import:progress`) alongside catalog parent counters. - -### Rebalance - -Dry-run registry updates: - -```bash -python scripts/rebalance_call_import_shards.py --target-shard data-shard-02 -``` - -Use `--apply` only after pausing the import. The rebalance tool copies rows to the target shard, updates the catalog registry, then removes copies from the source shard. - -## Troubleshooting stalled evaluations - -If evaluation runs stall after recordings import (rows show `completed` import but no diarization/scoring): - -1. Restart API and `worker-imports` after deploying fixes. -2. **Retry evaluation** from the UI (use *Overwrite existing transcripts* if diarization previously failed). -3. Ensure the imports worker consumes **`imports,diarization,eval-control,evaluations`**. diff --git a/docs-fumadocs/content/docs/advanced/database.mdx b/docs-fumadocs/content/docs/advanced/database.mdx deleted file mode 100644 index 20ae69a3..00000000 --- a/docs-fumadocs/content/docs/advanced/database.mdx +++ /dev/null @@ -1,112 +0,0 @@ ---- -id: database -title: Database -sidebar_position: 1 ---- - -# Database Migrations - -The application includes an automatic migration system that runs database schema changes on startup. - -## How It Works - -- **Automatic Execution**: Migrations run automatically when the application starts -- **Version Tracking**: Applied migrations are tracked in the `schema_migrations` table -- **Idempotent**: Each migration only runs once, even if the application restarts -- **Ordered Execution**: Migrations run in alphabetical order (use numbered prefixes like 001_, 002_, etc.) - -## Migration Files - -Migrations are stored in the `migrations/` directory. Each migration file should: - -1. Have a numeric prefix: `001_description.py`, `002_another.py`, etc. -2. Include a `description` variable -3. Have an `upgrade(db)` function that takes a SQLAlchemy Session - -Example migration: - -```python -""" -Migration: Add New Feature -""" - -description = "Add new feature support" - -def upgrade(db): - """Apply this migration.""" - from sqlalchemy import text - - db.execute(text("CREATE TABLE IF NOT EXISTS new_table (...)")) - db.commit() -``` - -## Running Migrations - -**Automatic (Recommended - Default Behavior):** - -- Migrations run automatically when you start the app with `eai start` -- Migrations also run automatically when the application starts (via lifespan handler) -- If migrations fail, the application will NOT start - this ensures database consistency -- API requests are blocked if migrations are pending -- When cloning from main, migrations will run automatically on first startup -- Each migration only runs once (tracked in `schema_migrations` table) - -**Manual:** - -```bash -# Run migrations manually -eai migrate - -# With verbose output -eai migrate --verbose -``` - -**Skip migrations (not recommended):** - -```bash -# Only use this if you know what you're doing -eai start --skip-migrations -``` - -## Creating New Migrations - -1. Create a new file in `migrations/` directory with the next sequential number -2. Follow the format shown above -3. Test the migration on a development database first -4. Use `IF NOT EXISTS` checks for idempotent operations -5. See `migrations/README.md` for detailed documentation. - -# Database ER Diagram - -Generate a visual Entity-Relationship (ER) diagram of your database schema to visualize table structures and relationships. - -## Prerequisites - -Install the required system and Python packages: - -```bash -# Install system graphviz package -sudo apt-get update -sudo apt-get install -y graphviz libgraphviz-dev pkg-config - -# Install Python packagesdl -pip install eralchemy graphviz -``` - -## Generating the ER Diagram - -Run the script to generate a PNG ER diagram: - -```bash -python scripts/generate_er_diagram_simple.py -``` - -This will create `schema_er_diagram.png` in the project root directory, showing: - -- All database tables -- Column names and types -- Primary keys -- Foreign key relationships -- Indexes - -**Note**: The diagram is automatically generated from your current database schema, so make sure your database is running and migrations are up to date. diff --git a/docs-fumadocs/content/docs/advanced/development.mdx b/docs-fumadocs/content/docs/advanced/development.mdx deleted file mode 100644 index 85024c16..00000000 --- a/docs-fumadocs/content/docs/advanced/development.mdx +++ /dev/null @@ -1,135 +0,0 @@ ---- -id: development -title: Development & Troubleshooting -sidebar_position: 2 ---- - -# Development - -## Running Locally - -Start PostgreSQL and Redis: - -```bash -docker compose up -d db redis -``` - -Run the application with hot reload: - -```bash -# Backend auto-reload + Frontend auto-rebuild on file changes -eai start --config config.yml --watch-frontend -``` - -The `--watch-frontend` flag automatically rebuilds the frontend whenever you modify source files (`.tsx`, `.ts`, `.css`, etc.), so you don't need to manually rebuild after each change. - -Run Celery worker (in separate terminal): - -```bash -celery -A app.workers.celery_app worker --loglevel=info -``` - -## Frontend Development - -**Option 1: Using CLI with watch mode (Recommended)** - -```bash -# From project root - automatically rebuilds on changes -eai start --watch-frontend -``` - -**Option 2: Using Vite dev server (for instant hot module replacement)** - -```bash -cd frontend -npm install -npm run dev -``` - -This runs Vite dev server on http://localhost:3000 with instant hot module replacement. - -**Note**: You'll need to run the backend separately on port 8000. - -# Troubleshooting - -## Database Migration Issues - -**Problem**: After cloning the repository, you see errors like: - -``` -psycopg2.errors.UndefinedColumn: column "organization_id" of relation "api_keys" does not exist -``` - -**Cause**: The database schema is out of sync with the code. This happens when: -- The database was created before migrations were added -- Migrations failed to run on startup -- The database was created using an older version of the code - -**Solution**: - -Check migration status: - -```bash -python scripts/check_migrations.py -``` - -This will show which migrations have been applied and identify any schema issues. - -Run migrations manually: - -```bash -# Using CLI (recommended) -eai migrate --verbose - -# Or using Python directly -python -c "from app.core.migrations import run_migrations; run_migrations()" -``` - -**If you're using a fresh database (just created/nuked):** - -- The migration system now handles fresh databases correctly -- If tables don't exist, migrations will skip them and `init_db()` will create them with the correct schema - -However, if you see this error on a fresh DB, try: - -```bash -# Stop the application -# Then run migrations explicitly -eai migrate --verbose -# Then start the application again -eai start -``` - -**If migrations still fail:** - -1. Ensure your database connection is correct in `config.yml` or `.env` -2. Check that you have the necessary permissions on the database -3. Review the migration logs for specific errors -4. You may need to manually add missing columns (see migration files in `migrations/` directory) - -**For fresh databases**: Make sure migrations run BEFORE any tables are created. - -**For Docker setups**: - -```bash -docker compose exec api eai migrate --verbose -``` - -**Important for Docker**: If you nuked the DB container and created a new one: -- The new container starts with an empty database -- Migrations should run automatically on startup -- If they don't, run them manually as shown above - -**Prevention**: Always ensure migrations run successfully before using the application. Check the startup logs for migration status messages. - -# Support - -- Email: tejas@efficientai.cloud -- Book a Demo: Schedule a call -- LinkedIn: Connect with us -- X (Twitter): Follow us -- GitHub: View on GitHub - -# License - -MIT License - see LICENSE file for details diff --git a/docs-fumadocs/content/docs/changelog/meta.json b/docs-fumadocs/content/docs/changelog/meta.json index 22351297..d0c10e9d 100644 --- a/docs-fumadocs/content/docs/changelog/meta.json +++ b/docs-fumadocs/content/docs/changelog/meta.json @@ -2,6 +2,7 @@ "title": "Changelog", "pages": [ "index", + "v1.5.34", "v1.5.33", "v1.5.32", "v1.5.31", @@ -30,7 +31,6 @@ "v1.5.8", "v1.5.7", "v1.5.6", - "v1.5.5", - "v1.5.4" + "v1.5.5" ] } diff --git a/docs-fumadocs/content/docs/changelog/v1.5.10.mdx b/docs-fumadocs/content/docs/changelog/v1.5.10.mdx index c4c44aeb..edad0da7 100644 --- a/docs-fumadocs/content/docs/changelog/v1.5.10.mdx +++ b/docs-fumadocs/content/docs/changelog/v1.5.10.mdx @@ -7,9 +7,19 @@ description: Release notes for v1.5.10. Released Jun 28, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.10). +Primary pull request: [#95](https://github.com/EfficientAI-tech/efficientAI/pull/95) by [@TEJASNARAYANS](https://github.com/TEJASNARAYANS). + ## What changed -- feat: updating azure storage by @TEJASNARAYANS in https://github.com/EfficientAI-tech/efficientAI/pull/95 +Adding Azure blob storage integration option. + +## Why + +Explain the problem this solves and why this approach was chosen. + +## How to test + +List clear steps for reviewers to verify the change. ## Contributors diff --git a/docs-fumadocs/content/docs/changelog/v1.5.13.mdx b/docs-fumadocs/content/docs/changelog/v1.5.13.mdx index 438caad4..e1023c4a 100644 --- a/docs-fumadocs/content/docs/changelog/v1.5.13.mdx +++ b/docs-fumadocs/content/docs/changelog/v1.5.13.mdx @@ -5,7 +5,7 @@ description: Release notes for v1.5.13. # v1.5.13 -Released Jul 9, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.13). +Released Jul 8, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.13). Primary pull request: [#98](https://github.com/EfficientAI-tech/efficientAI/pull/98) by [@TEJASNARAYANS](https://github.com/TEJASNARAYANS). diff --git a/docs-fumadocs/content/docs/changelog/v1.5.15.mdx b/docs-fumadocs/content/docs/changelog/v1.5.15.mdx index 394c1e4e..34886e77 100644 --- a/docs-fumadocs/content/docs/changelog/v1.5.15.mdx +++ b/docs-fumadocs/content/docs/changelog/v1.5.15.mdx @@ -5,7 +5,7 @@ description: Release notes for v1.5.15. # v1.5.15 -Released Jul 11, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.15). +Released Jul 10, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.15). Primary pull request: [#100](https://github.com/EfficientAI-tech/efficientAI/pull/100) by [@TEJASNARAYANS](https://github.com/TEJASNARAYANS). diff --git a/docs-fumadocs/content/docs/changelog/v1.5.27.mdx b/docs-fumadocs/content/docs/changelog/v1.5.27.mdx index 2251e0a4..8aed55f2 100644 --- a/docs-fumadocs/content/docs/changelog/v1.5.27.mdx +++ b/docs-fumadocs/content/docs/changelog/v1.5.27.mdx @@ -5,7 +5,7 @@ description: Release notes for v1.5.27. # v1.5.27 -Released Aug 22, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.27). +Released Aug 21, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.27). Primary pull request: [#116](https://github.com/EfficientAI-tech/efficientAI/pull/116) by [@TEJASNARAYANS](https://github.com/TEJASNARAYANS). diff --git a/docs-fumadocs/content/docs/changelog/v1.5.28.mdx b/docs-fumadocs/content/docs/changelog/v1.5.28.mdx index ec2e62a5..914b99e9 100644 --- a/docs-fumadocs/content/docs/changelog/v1.5.28.mdx +++ b/docs-fumadocs/content/docs/changelog/v1.5.28.mdx @@ -5,7 +5,7 @@ description: Release notes for v1.5.28. # v1.5.28 -Released Aug 28, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.28). +Released Aug 27, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.28). Primary pull request: [#121](https://github.com/EfficientAI-tech/efficientAI/pull/121) by [@TEJASNARAYANS](https://github.com/TEJASNARAYANS). diff --git a/docs-fumadocs/content/docs/changelog/v1.5.34.mdx b/docs-fumadocs/content/docs/changelog/v1.5.34.mdx new file mode 100644 index 00000000..f11bcc8e --- /dev/null +++ b/docs-fumadocs/content/docs/changelog/v1.5.34.mdx @@ -0,0 +1,126 @@ +--- +title: v1.5.34 +description: Release notes for v1.5.34. +--- + +# v1.5.34 + +Released Sep 22, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.34). + +Primary pull request: [#127](https://github.com/EfficientAI-tech/efficientAI/pull/127) by [@aadhar-EAI](https://github.com/aadhar-EAI). + +## What changed + +This PR is a major docs revamp on the `doctor` branch — **520 files changed** (~54k insertions). It replaces the legacy docs layout with a Fumadocs v2 site, adds a full OpenAPI-driven API reference, and ships new product guides with screenshots. + +### Docs site structure (Fumadocs v2) + +- **Quickstart** — end-to-end onboarding guide +- **Platform** — product guides for agents, personas, scenarios, evaluators, evaluation suites, metrics, prompts, playground, observability, and setup (with screenshots) +- **Integrations** — provider-specific pages (ElevenLabs, Plivo, Retell, Smallest, Vapi, Vobiz) +- **Advanced** — new section (expanded by default) with: + - **[IAM](/docs/advanced/iam/)** — org/workspace roles, membership, custom roles, Enterprise IAM + - **[Alerting](/docs/advanced/alerting/)** — threshold rules, Slack/email notifications, alert history (Enterprise feature) +- **API reference** — generated from OpenAPI spec (`efficientai.json`) covering agents, auth, call imports, evaluators, integrations, metrics, observability, personas, scenarios, voice bundles, and workspaces +- **Changelog** — versioned release pages (v1.5.4–v1.5.33) with enriched PR context +- **Enterprise** — BSL licensing overview and open-source vs Enterprise comparison table + +### Enterprise page updates + +- Added ✅ to every Enterprise column cell in the comparison table for quick visual scanning +- Authentication guide links now point to `https://docs.efficientai.cloud/docs/getting-started/authentication/` (not localhost) +- Licensing contact updated to `contact@efficientai.cloud` +- Removed redundant sections: "Open-source limits (EFF-70)" and "Enterprise-only capabilities (any Enterprise tier)" + +### Docs UX & navigation + +- **Community & contact footer** on docs pages (GitHub issues, Discord) +- **Top nav** — GitHub link and light/dark theme toggle moved to the right +- **Sidebar** — Advanced section uses `defaultOpen: true`; trimmed Advanced nav to IAM + Alerting only (removed Reference, Monitoring, Engineering stubs from nav) +- **README** — Discord added to hero quick links and Support section +- New screenshots for Agents, Evaluators, Metrics, Personas, Playground, Prompts, IAM, and Alerts + +### Changelog generation + +Enhanced changelog generation to parse linked PR descriptions and include structured sections when present: + +- `What changed` +- `Why` +- `How to test` + +### Build & dev fixes + +- **`next.config.mjs`** — `output: 'export'` only when `NODE_ENV === 'production'` (fixes dev `generateStaticParams` errors) +- **`ensure-collections.mjs`** — regenerate `.source/` when content is newer than cache +- **`page.tsx`** — guard when `generateParams()` returns empty +- **`docs-shell.tsx`** — `sidebar={{ defaultOpenLevel: 1 }}` +- Fixed MDX hydration errors and removed broken screenshot refs (`create_workspace.png`, `iam_workspaces.png`); replaced with `/screenshots/IAM/iam.png` and `/screenshots/Alerts/alerts.png` + +## Why + +The previous docs site lacked structured product guides, a browsable API reference, and clear Enterprise/IAM/Alerting documentation. Reviewers and users had to dig through scattered pages or the codebase to understand capabilities and licensing boundaries. + +This revamp: + +- **Improves discoverability** — Platform, Integrations, and Advanced guides follow a consistent format with screenshots +- **Documents Enterprise gating** — IAM multi-member limits, alerting, and feature comparison are explicit +- **Enables self-serve API usage** — OpenAPI-generated reference with copy-paste examples +- **Improves release transparency** — Changelog pages preserve PR rationale and test steps +- **Fixes dev ergonomics** — Static export and collection caching no longer break local `npm run dev` + +## How to test + +1. **Run docs checks locally:** + ```bash + cd docs-fumadocs + npm run validate:docs + npm run check:links + ./node_modules/.bin/fumadocs-mdx + ./node_modules/.bin/next typegen + ./node_modules/.bin/tsc --noEmit + npm run build + npm run verify:routes + ``` + +2. **Run changelog generation:** + ```bash + cd docs-fumadocs + npm run changelog:generate + ``` + +3. **Verify navigation & new guides:** + - Open `/docs/` and confirm sidebar shows Quickstart, Platform, Integrations, Advanced (expanded) + - Open `/docs/advanced/iam/` — IAM screenshot renders, org/workspace role tables present + - Open `/docs/advanced/alerting/` — Enterprise callout visible, alerting screenshot renders + - Open `/docs/platform/agent/` and spot-check other Platform pages for screenshots + +4. **Verify Enterprise page:** + - Open `/docs/enterprise/` + - Confirm every Enterprise column cell has ✅ + - Confirm auth links go to `https://docs.efficientai.cloud/docs/getting-started/authentication/` + - Confirm licensing contact is `contact@efficientai.cloud` + - Confirm removed sections ("Open-source limits", "Enterprise-only capabilities") are gone + +5. **Verify docs UI changes:** + - Confirm `Community & contact` footer on docs pages + - Confirm top nav has GitHub + theme toggle on the right + +6. **Verify API reference:** + - Open `/docs/api-reference/` and browse a few endpoints (e.g. agents, evaluators) + - Confirm request/response schemas render + +7. **Verify changelog enrichment:** + - Open `/docs/changelog/v1.5.33/` and confirm detailed PR sections render (`What changed`, `Why`, `How to test`) + +8. **Verify dev mode:** + ```bash + cd docs-fumadocs + npm run dev + ``` + - Confirm no `generateStaticParams` / `output: export` errors in dev + - Navigate to Advanced → IAM and Alerting without 500s + +## Contributors + +- [@aadhar-EAI](https://github.com/aadhar-EAI) + diff --git a/docs-fumadocs/content/docs/changelog/v1.5.4.mdx b/docs-fumadocs/content/docs/changelog/v1.5.4.mdx deleted file mode 100644 index e4590272..00000000 --- a/docs-fumadocs/content/docs/changelog/v1.5.4.mdx +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: v1.5.4 -description: Release notes for v1.5.4. ---- - -# v1.5.4 - -Released Jun 14, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.4). - -## What changed - -- fix: updating entrprise gated docs by @TEJASNARAYANS in https://github.com/EfficientAI-tech/efficientAI/pull/90 - -## Contributors - -- [@TEJASNARAYANS](https://github.com/TEJASNARAYANS) - diff --git a/docs-fumadocs/content/docs/changelog/v1.5.5.mdx b/docs-fumadocs/content/docs/changelog/v1.5.5.mdx index c1eb3a9f..3e4181e9 100644 --- a/docs-fumadocs/content/docs/changelog/v1.5.5.mdx +++ b/docs-fumadocs/content/docs/changelog/v1.5.5.mdx @@ -5,11 +5,21 @@ description: Release notes for v1.5.5. # v1.5.5 -Released Jun 14, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.5). +Released Jun 13, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.5). + +Primary pull request: [#91](https://github.com/EfficientAI-tech/efficientAI/pull/91) by [@TEJASNARAYANS](https://github.com/TEJASNARAYANS). ## What changed -- fix: updating lambda fixes by @TEJASNARAYANS in https://github.com/EfficientAI-tech/efficientAI/pull/91 +Briefly describe what this PR changes. + +## Why + +Explain the problem this solves and why this approach was chosen. + +## How to test + +List clear steps for reviewers to verify the change. ## Contributors diff --git a/docs-fumadocs/content/docs/changelog/v1.5.6.mdx b/docs-fumadocs/content/docs/changelog/v1.5.6.mdx index 1b8a2c83..283b777e 100644 --- a/docs-fumadocs/content/docs/changelog/v1.5.6.mdx +++ b/docs-fumadocs/content/docs/changelog/v1.5.6.mdx @@ -5,11 +5,21 @@ description: Release notes for v1.5.6. # v1.5.6 -Released Jun 14, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.6). +Released Jun 13, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.6). + +Primary pull request: [#92](https://github.com/EfficientAI-tech/efficientAI/pull/92) by [@TEJASNARAYANS](https://github.com/TEJASNARAYANS). ## What changed -- fix: lambda fixes by @TEJASNARAYANS in https://github.com/EfficientAI-tech/efficientAI/pull/92 +Briefly describe what this PR changes. + +## Why + +Explain the problem this solves and why this approach was chosen. + +## How to test + +List clear steps for reviewers to verify the change. ## Contributors diff --git a/docs-fumadocs/content/docs/changelog/v1.5.7.mdx b/docs-fumadocs/content/docs/changelog/v1.5.7.mdx index e4ad90bc..ea61b160 100644 --- a/docs-fumadocs/content/docs/changelog/v1.5.7.mdx +++ b/docs-fumadocs/content/docs/changelog/v1.5.7.mdx @@ -7,9 +7,19 @@ description: Release notes for v1.5.7. Released Jun 15, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.7). +Primary pull request: [#89](https://github.com/EfficientAI-tech/efficientAI/pull/89) by [@TEJASNARAYANS](https://github.com/TEJASNARAYANS). + ## What changed -- feat: Workspace upgrades by @TEJASNARAYANS in https://github.com/EfficientAI-tech/efficientAI/pull/89 +Adding RBAC to workspaces. Adding newer models and providers. + +## Why + +Explain the problem this solves and why this approach was chosen. + +## How to test + +List clear steps for reviewers to verify the change. ## Contributors diff --git a/docs-fumadocs/content/docs/changelog/v1.5.8.mdx b/docs-fumadocs/content/docs/changelog/v1.5.8.mdx index 45fe885e..35a929da 100644 --- a/docs-fumadocs/content/docs/changelog/v1.5.8.mdx +++ b/docs-fumadocs/content/docs/changelog/v1.5.8.mdx @@ -7,9 +7,19 @@ description: Release notes for v1.5.8. Released Jun 22, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.8). +Primary pull request: [#93](https://github.com/EfficientAI-tech/efficientAI/pull/93) by [@TEJASNARAYANS](https://github.com/TEJASNARAYANS). + ## What changed -- fix: updating bugs by @TEJASNARAYANS in https://github.com/EfficientAI-tech/efficientAI/pull/93 +Briefly describe what this PR changes. + +## Why + +Explain the problem this solves and why this approach was chosen. + +## How to test + +List clear steps for reviewers to verify the change. ## Contributors diff --git a/docs-fumadocs/content/docs/changelog/v1.5.9.mdx b/docs-fumadocs/content/docs/changelog/v1.5.9.mdx index d5534ea3..6a906ee4 100644 --- a/docs-fumadocs/content/docs/changelog/v1.5.9.mdx +++ b/docs-fumadocs/content/docs/changelog/v1.5.9.mdx @@ -7,9 +7,38 @@ description: Release notes for v1.5.9. Released Jun 23, 2026. [View on GitHub](https://github.com/EfficientAI-tech/efficientAI/releases/tag/v1.5.9). +Primary pull request: [#94](https://github.com/EfficientAI-tech/efficientAI/pull/94) by [@TEJASNARAYANS](https://github.com/TEJASNARAYANS). + ## What changed -- fix: updating security fixes by @TEJASNARAYANS in https://github.com/EfficientAI-tech/efficientAI/pull/94 +### Docker / CVE-2025-68121 +- Converted `docker/Dockerfile.api` to a **multi-stage build**: + - Stage 1: `node:18-bookworm-slim` builds the frontend + - Stage 2: `python:3.11-slim` runtime copies **only** `frontend/dist/` (no `node_modules`, Node.js, or esbuild) +- Added committed `.dockerignore` and removed it from `.gitignore` +### HTTP security headers +- Extended `app/core/security_headers_middleware.py`: + - `Cache-Control`: no-store for API/SPA; long cache for `/assets/*` + - `Content-Security-Policy-Report-Only` (tuned for Google Fonts, WebSockets, blob/media URLs) +- Added CSP settings to `app/config.py`: `CSP_ENABLED`, `CSP_REPORT_ONLY`, `CSP_POLICY` +- Existing headers unchanged: `X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options` +### Operational endpoints (Actuator false positive) +- New `app/core/operational_access_middleware.py`: + - Default `OPERATIONAL_PUBLIC=false` → anonymous public clients get **404** on `/health` and `/metrics` + - **ALB health checks** allowed via `ELB-HealthChecker` / `kube-probe` User-Agent + - **Trusted IPs** via `OPERATIONAL_TRUSTED_IPS` (CIDR support, uses `X-Forwarded-For`) + - Authenticated callers (API key / Bearer) also allowed +- `app/core/health.py` + `app/main.py`: + - `GET /health` → minimal `{"status":"healthy"}` or `{"status":"degraded"}` (503 when degraded) + - `GET /health/detail` → full migration diagnostics, **org admin only** +- OpenAPI docs disabled when `DEBUG=false` (`/docs`, `/redoc`, `/openapi.json`) +- `app/core/migration_middleware.py` gates doc bypass paths on `DEBUG` +### Config & docs +- `config.yml.example` — new `operational` section +- `docs-fumadocs/content/docs/getting-started/authentication.mdx` — hardening guidance for CSP, docs, and operational endpoints +### Tests +- `tests/test_core/test_security_headers_middleware.py` — 6 tests +- `tests/test_core/test_operational_endpoints.py` — 11 tests ## Contributors diff --git a/docs-fumadocs/content/docs/products/call-imports.mdx b/docs-fumadocs/content/docs/products/call-imports.mdx new file mode 100644 index 00000000..da4ef357 --- /dev/null +++ b/docs-fumadocs/content/docs/products/call-imports.mdx @@ -0,0 +1,46 @@ +--- +id: call-imports +title: Call Imports +sidebar_position: 9 +--- + +# Call Imports + +Call Imports lets you bulk-import production call recordings via CSV and run batch evaluations on them. + +> **Enterprise feature** — requires `call_imports` in your `EFFICIENTAI_LICENSE`. + +## At a glance + +- Upload CSV datasets and optional audio files +- Map columns with reusable schemas +- Run metrics and insights across imported production calls + +Contact the EfficientAI team for a license. + +## Sharded evaluation pipeline (ops) + +If evaluation runs stall after recordings import (rows show `completed` import but no diarization/scoring): + +1. Restart API and `worker-imports` after deploying fixes. +2. **Retry evaluation** from the UI (use *Overwrite existing transcripts* if diarization previously failed). +3. Or clear stale dispatch locks on shard DBs: + +```sql +UPDATE call_import_evaluation_rows er +SET celery_task_id = NULL +FROM call_import_rows sr +WHERE er.call_import_row_id = sr.id + AND er.status = 'pending' + AND er.celery_task_id IS NOT NULL + AND sr.status = 'completed' + AND sr.recording_s3_key IS NOT NULL; +``` + +Abort and force-fail run on the `eval-control` queue (before `evaluations` on `worker-imports`) so they are not blocked behind large scoring backlogs. + +When running locally via `eai start` or `eai worker`, the imports worker must consume **`imports,diarization,eval-control,evaluations`**. If `eval-control` is missing, Run Evaluation will enqueue materialize tasks that never run and recording imports will not start. + +## Database sharding (enterprise scale) + +For large batches (10k+ rows), call-import row data can be spread across multiple PostgreSQL data shards with a catalog database for metadata and routing. See [Call Import Sharding](/docs/operations/call-import-sharding/) for the full system design, configuration, and scaling guide. diff --git a/docs-fumadocs/infra/cloudfront-viewer-request.js b/docs-fumadocs/infra/cloudfront-viewer-request.js index e0adba6c..9236f539 100644 --- a/docs-fumadocs/infra/cloudfront-viewer-request.js +++ b/docs-fumadocs/infra/cloudfront-viewer-request.js @@ -3,6 +3,7 @@ * * Maps pretty URLs to index.html objects, e.g.: * /docs/getting-started/integrations/ -> /docs/getting-started/integrations/index.html + * /docs/changelog/v1.5.33/ -> /docs/changelog/v1.5.33/index.html * /docs/getting-started/integrations -> /docs/getting-started/integrations/index.html * * Deploy: CloudFront -> Functions -> Create -> Publish -> attach to distribution @@ -12,9 +13,12 @@ */ function handler(event) { var request = event.request; - var uri = request.uri; + var uri = request.uri.split('?')[0]; - if (uri.includes('.')) { + // Only pass through URLs that end with a static asset extension. + // Do NOT use uri.includes('.') — version slugs like /docs/changelog/v1.5.33/ + // contain dots but still need index.html rewriting. + if (/\.(html|css|js|json|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|map|txt|xml|pdf|md)$/i.test(uri)) { return request; } diff --git a/docs-fumadocs/package.json b/docs-fumadocs/package.json index 0f2ebf83..5aaf943b 100644 --- a/docs-fumadocs/package.json +++ b/docs-fumadocs/package.json @@ -9,7 +9,7 @@ "openapi:generate": "node scripts/generate-api-docs.mjs", "changelog:generate": "node scripts/generate-changelog-nav.mjs", "prebuild": "npm run openapi:enrich && npm run openapi:generate", - "build": "node scripts/ensure-collections.mjs && npm run sync:logo && npm run prebuild && npm run search:generate && next build", + "build": "npm run changelog:generate && node scripts/ensure-collections.mjs && npm run sync:logo && npm run prebuild && npm run search:generate && next build", "dev:stop": "lsof -ti:3000 | xargs kill 2>/dev/null || true", "dev": "node scripts/ensure-collections.mjs && npm run sync:logo && next dev --hostname 127.0.0.1 --port 3000", "start": "next start", @@ -20,7 +20,8 @@ "validate:docs": "node scripts/validate-docs.mjs", "check:links": "node scripts/check-links.mjs", "verify:routes": "node scripts/verify-static-routes.mjs", - "ci:check": "npm run validate:docs && npm run check:links && npm run types:check && npm run build && npm run verify:routes" + "test:cloudfront-uri": "node scripts/test-cloudfront-uri-rewrite.mjs", + "ci:check": "npm run validate:docs && npm run check:links && npm run test:cloudfront-uri && npm run types:check && npm run build && npm run verify:routes" }, "dependencies": { "fumadocs-core": "16.15.9", diff --git a/docs-fumadocs/scripts/generate-changelog-nav.mjs b/docs-fumadocs/scripts/generate-changelog-nav.mjs index 588a5cbe..cc6dfb3d 100644 --- a/docs-fumadocs/scripts/generate-changelog-nav.mjs +++ b/docs-fumadocs/scripts/generate-changelog-nav.mjs @@ -12,8 +12,8 @@ const githubOwner = 'EfficientAI-tech'; const githubRepo = 'efficientAI'; const pullUrlPattern = /https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/gi; -const RELEASES_URL = - `https://api.github.com/repos/${githubOwner}/${githubRepo}/releases?per_page=30`; +const RELEASES_BASE_URL = + `https://api.github.com/repos/${githubOwner}/${githubRepo}/releases`; function parseReleaseBody(body) { const changes = []; @@ -199,25 +199,36 @@ function hasCommittedReleasePages() { async function fetchReleases() { const token = process.env.GITHUB_TOKEN?.trim(); - const response = await fetch(RELEASES_URL, { - headers: { - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }); + const headers = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; - if (!response.ok) { - if (hasCommittedReleasePages()) { - console.warn( - `Skipping changelog regeneration (GitHub releases request failed with ${response.status}); using committed pages.`, - ); - process.exit(0); + const releases = []; + let page = 1; + + while (true) { + const response = await fetch(`${RELEASES_BASE_URL}?per_page=100&page=${page}`, { headers }); + + if (!response.ok) { + if (hasCommittedReleasePages()) { + console.warn( + `Skipping changelog regeneration (GitHub releases request failed with ${response.status}); using committed pages.`, + ); + process.exit(0); + } + throw new Error(`GitHub releases request failed (${response.status})`); } - throw new Error(`GitHub releases request failed (${response.status})`); + + const batch = await response.json(); + if (!Array.isArray(batch) || batch.length === 0) break; + releases.push(...batch); + if (batch.length < 100) break; + page += 1; } - return response.json(); + return releases; } function cleanupGeneratedReleasePages() { diff --git a/docs-fumadocs/scripts/test-cloudfront-uri-rewrite.mjs b/docs-fumadocs/scripts/test-cloudfront-uri-rewrite.mjs new file mode 100644 index 00000000..16546244 --- /dev/null +++ b/docs-fumadocs/scripts/test-cloudfront-uri-rewrite.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +function rewriteUri(uri) { + const pathOnly = uri.split('?')[0]; + + if (/\.(html|css|js|json|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|map|txt|xml|pdf|md)$/i.test(pathOnly)) { + return uri; + } + + if (pathOnly.endsWith('/')) { + return uri + 'index.html'; + } + + return uri + '/index.html'; +} + +const cases = [ + ['/docs/changelog/v1.5.33/', '/docs/changelog/v1.5.33/index.html'], + ['/docs/changelog/v1.5.4/', '/docs/changelog/v1.5.4/index.html'], + ['/docs/advanced/iam/', '/docs/advanced/iam/index.html'], + ['/_next/static/chunks/app.js', '/_next/static/chunks/app.js'], + ['/efficientai_logo_light.png', '/efficientai_logo_light.png'], + ['/docs/changelog/v1.5.33/index.html', '/docs/changelog/v1.5.33/index.html'], +]; + +for (const [input, expected] of cases) { + const actual = rewriteUri(input); + if (actual !== expected) { + console.error(`FAIL ${input}\n expected: ${expected}\n actual: ${actual}`); + process.exit(1); + } +} + +console.log(`CloudFront URI rewrite: ${cases.length} cases passed.`); diff --git a/docs/live-call-storage.md b/docs/live-call-storage.md index 6df4f3c0..1392e4c8 100644 --- a/docs/live-call-storage.md +++ b/docs/live-call-storage.md @@ -28,7 +28,7 @@ Routing: `SHA256(workspace_id:entity_id) mod N` over configured data shards (see | `call_imports`, `call_import_evaluations`, `call_import_shard_slices` | Catalog | | `call_import_rows`, `call_import_evaluation_rows` | Data shards | -Routing uses `(call_import_id, row_index // chunk_size)` — see [call-import-sharding](../docs-fumadocs/content/docs/advanced/call-import-sharding.mdx). +Routing uses `(call_import_id, row_index // chunk_size)` — see [call-import-sharding](../docs-fumadocs/content/docs/operations/call-import-sharding.mdx). ## Operations From 1845409ff6fc74b0d2035830de2b85e4a30450bf Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Tue, 22 Sep 2026 18:17:14 +0000 Subject: [PATCH 7/8] fix: updating media endpoints --- app/services/media_urls.py | 35 ++++++++++++++++--- tests/test_api/test_enterprise_gating.py | 20 ++++++++--- tests/test_core/test_operational_endpoints.py | 29 ++++++++------- 3 files changed, 59 insertions(+), 25 deletions(-) diff --git a/app/services/media_urls.py b/app/services/media_urls.py index 127f88a7..2106b59f 100644 --- a/app/services/media_urls.py +++ b/app/services/media_urls.py @@ -63,6 +63,15 @@ def ws_base_from_http_host(host: str, *, scheme: str = "http") -> str: return f"{ws_scheme}://{host.rstrip('/')}" +def _host_from_public_url(url: str) -> str | None: + raw = (url or "").strip() + if not raw: + return None + if "://" not in raw: + raw = f"http://{raw}" + return (urlparse(raw).hostname or "").strip().lower() or None + + def resolve_voice_agent_ws_base( *, fallback_host: Optional[str] = None, @@ -72,8 +81,22 @@ def resolve_voice_agent_ws_base( ws_base = media_ws_base_url() if ws_base: return ws_base - if (settings.PUBLIC_BASE_URL or "").strip(): - public = settings.PUBLIC_BASE_URL.strip().rstrip("/") + + fallback_ws = ( + ws_base_from_http_host(fallback_host, scheme=fallback_scheme) + if fallback_host + else None + ) + + public = (settings.PUBLIC_BASE_URL or "").strip() + if public: + public_host = _host_from_public_url(public) + request_host = (fallback_host or "").split(":", 1)[0].strip().lower() or None + if request_host and public_host and request_host != public_host: + # Browser/API host differs from PUBLIC_BASE_URL (TestClient, local proxy). + # Use same-host WS so httpOnly session cookies authenticate the socket. + if fallback_ws: + return fallback_ws if public.startswith("https://"): return "wss://" + public[len("https://") :] if public.startswith("http://"): @@ -81,15 +104,17 @@ def resolve_voice_agent_ws_base( if public.startswith("wss://") or public.startswith("ws://"): return public return f"wss://{public}" - if fallback_host: - return ws_base_from_http_host(fallback_host, scheme=fallback_scheme) + + if fallback_ws: + return fallback_ws return f"ws://localhost:{settings.PORT}" def cross_host_voice_ws(ws_base: str, request: Request) -> bool: """True when the WS host cannot receive the API's host-scoped session cookies.""" ws_hostname = (urlparse(ws_base).hostname or "").lower() - req_hostname = (request.url.hostname or "").lower() + host_header = (request.headers.get("host") or "").split(":", 1)[0].strip().lower() + req_hostname = host_header or (request.url.hostname or "").lower() if not ws_hostname or not req_hostname: return False return ws_hostname != req_hostname diff --git a/tests/test_api/test_enterprise_gating.py b/tests/test_api/test_enterprise_gating.py index 0fa08a7b..d1b7a50a 100644 --- a/tests/test_api/test_enterprise_gating.py +++ b/tests/test_api/test_enterprise_gating.py @@ -4,18 +4,28 @@ import pytest -import app.dependencies as app_dependencies from app.core import license as license_module @pytest.fixture -def unlicensed_client(authenticated_client): +def unlicensed_client(authenticated_client, monkeypatch): """Authenticated client with real license checks and no enterprise JWT.""" + import app.dependencies as app_dependencies + from app.config import settings + + monkeypatch.setattr(settings, "EFFICIENTAI_LICENSE", None, raising=False) + monkeypatch.delenv("EFFICIENTAI_LICENSE", raising=False) license_module.reset_license_cache() - license_module._license_cache = {} - app_dependencies.is_feature_enabled = license_module.is_feature_enabled + # conftest._build_session_api_app() stubs app.dependencies.is_feature_enabled; + # require_enterprise_feature() resolves that name at call time. + monkeypatch.setattr( + app_dependencies, + "is_feature_enabled", + license_module.is_feature_enabled, + ) + yield authenticated_client - app_dependencies.is_feature_enabled = lambda *_args, **_kwargs: True + license_module.reset_license_cache() diff --git a/tests/test_core/test_operational_endpoints.py b/tests/test_core/test_operational_endpoints.py index 7673226f..65c02dc2 100644 --- a/tests/test_core/test_operational_endpoints.py +++ b/tests/test_core/test_operational_endpoints.py @@ -188,11 +188,7 @@ def test_docs_not_registered_when_debug_disabled(monkeypatch): monkeypatch.setattr(settings, "DEBUG", False) monkeypatch.setattr(settings, "SECRET_KEY", "test-operational-secret-key-32chars") - monkeypatch.setattr( - settings, - "TRUSTED_HOSTS", - ["testserver", "localhost", "127.0.0.1"], - ) + _allow_testserver_hosts(monkeypatch) app = create_app() route_paths = {getattr(route, "path", None) for route in app.routes} @@ -225,6 +221,17 @@ def _stub_create_app_startup(monkeypatch) -> None: monkeypatch.setattr("app.app_factory.check_migrations_status", lambda: (True, [])) +def _allow_testserver_hosts(monkeypatch) -> None: + """finalize_security_settings() rebuilds TRUSTED_HOSTS; pin testserver for TestClient.""" + monkeypatch.setattr(settings, "TRUSTED_HOSTS_AUTO_FROM_FRONTEND", False) + monkeypatch.setattr( + settings, + "TRUSTED_HOSTS_FROM_ENV", + ["testserver", "localhost", "127.0.0.1"], + ) + monkeypatch.setattr(settings, "TRUSTED_HOSTS_EXPLICIT", []) + + def test_health_detail_returns_migration_info_for_admin(monkeypatch): _stub_create_app_startup(monkeypatch) monkeypatch.setitem( @@ -254,11 +261,7 @@ def test_health_detail_returns_migration_info_for_admin(monkeypatch): from app.core.auth.rbac import require_admin from app.main import create_app - monkeypatch.setattr( - settings, - "TRUSTED_HOSTS", - ["testserver", "localhost", "127.0.0.1"], - ) + _allow_testserver_hosts(monkeypatch) app = create_app() app.dependency_overrides[require_admin] = lambda: object() @@ -284,11 +287,7 @@ def test_health_detail_requires_authentication_via_create_app(monkeypatch): from app.main import create_app - monkeypatch.setattr( - settings, - "TRUSTED_HOSTS", - ["testserver", "localhost", "127.0.0.1"], - ) + _allow_testserver_hosts(monkeypatch) with TestClient(create_app()) as client: response = client.get("/health/detail") From 1f6b2bae955d74831b7bb0eb0ddf01436fe1af81 Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Tue, 22 Sep 2026 18:39:34 +0000 Subject: [PATCH 8/8] fix: updating test cases --- tests/conftest.py | 40 ++++++++++++++++++++++++ tests/test_api/conftest.py | 14 --------- tests/test_api/test_enterprise_gating.py | 1 + 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3c62d7ae..b2202fad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -138,6 +138,46 @@ def ensure_workers_tasks_package(): ] +def _is_license_behavior_test_module(module_name: str) -> bool: + """Pytest module names are ``test_core.test_license``, not ``tests.test_core...``.""" + leaf = module_name.rsplit(".", 1)[-1] + return leaf in { + "test_enterprise_gating", + "test_license", + "test_license_offerings", + "test_oss_quotas", + "test_oss_quotas_org_members", + "test_usage_entitlement", + } + +_FAKE_DEPLOYMENT_LICENSE = { + "org": "Pytest Enterprise", + "org_id": None, + "features": [ + "voice_playground", + "gepa_optimization", + "call_imports", + "evaluation_clustering", + "oidc_sso", + ], +} + + +@pytest.fixture(autouse=True) +def _deployment_enterprise_license_for_tests(monkeypatch, request): + """CI has no EFFICIENTAI_LICENSE; most tests assume an entitled deployment.""" + if _is_license_behavior_test_module(request.module.__name__): + yield + return + + import app.core.license as license_module + + monkeypatch.setattr(license_module, "get_license_info", lambda: dict(_FAKE_DEPLOYMENT_LICENSE)) + license_module.reset_license_cache() + yield + license_module.reset_license_cache() + + @pytest.fixture def org_id(): """Stable org UUID for auth-related tests.""" diff --git a/tests/test_api/conftest.py b/tests/test_api/conftest.py index 61488308..6ea1935d 100644 --- a/tests/test_api/conftest.py +++ b/tests/test_api/conftest.py @@ -611,17 +611,3 @@ def _make_prompt_optimization_candidate(**overrides): return _make_prompt_optimization_candidate - -@pytest.fixture(autouse=True) -def _enable_enterprise_entitlement_for_api_tests(monkeypatch, request): - """Most API route tests assume an entitled deployment unless testing OSS gates.""" - if request.module.__name__.endswith("test_enterprise_gating"): - return - - import app.core.usage_entitlement as usage_entitlement_module - - monkeypatch.setattr( - usage_entitlement_module, - "has_enterprise_entitlement", - lambda organization_id=None: True, - ) diff --git a/tests/test_api/test_enterprise_gating.py b/tests/test_api/test_enterprise_gating.py index d1b7a50a..43f8af71 100644 --- a/tests/test_api/test_enterprise_gating.py +++ b/tests/test_api/test_enterprise_gating.py @@ -15,6 +15,7 @@ def unlicensed_client(authenticated_client, monkeypatch): monkeypatch.setattr(settings, "EFFICIENTAI_LICENSE", None, raising=False) monkeypatch.delenv("EFFICIENTAI_LICENSE", raising=False) + monkeypatch.setattr(license_module, "get_license_info", lambda: {}) license_module.reset_license_cache() # conftest._build_session_api_app() stubs app.dependencies.is_feature_enabled; # require_enterprise_feature() resolves that name at call time.