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/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 6a064850..7007e776 100644 --- a/README.md +++ b/README.md @@ -983,4 +983,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..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 @@ -89,25 +89,117 @@ 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 _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, routing_mode: CredentialRoutingMode, 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 + + 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() + if mode == CredentialRoutingMode.GATEWAY.value: + 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: @@ -152,11 +244,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, @@ -278,24 +397,36 @@ 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, ) + _assert_gateway_update_allowed(organization_id, db_aiprovider, update_data) + skip_fields = { "api_key", "routing_mode", 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..7a9f383e 100644 --- a/app/api/v1/routes/integrations.py +++ b/app/api/v1/routes/integrations.py @@ -72,6 +72,11 @@ 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_credential_routing_allowed + + 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 user_details = None @@ -282,6 +287,10 @@ 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_credential_routing_allowed + + 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/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..c431a1a6 100644 --- a/app/api/v1/routes/settings.py +++ b/app/api/v1/routes/settings.py @@ -27,11 +27,13 @@ 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, + has_valid_license, ) +from app.core.oss_quotas import get_quota_usage, get_quotas_snapshot from app.core.usage_entitlement import get_usage_policy @@ -77,23 +79,33 @@ 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) + + 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(), "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/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/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..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": { @@ -33,11 +34,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 +44,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 +115,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. @@ -147,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 @@ -173,28 +211,63 @@ def get_licensed_org_id() -> Optional[str]: return get_license_info().get("org_id") -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. - """ +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 feature not in get_enabled_features(): + if not info: return False licensed_org = info.get("org_id") if licensed_org is None: return True - # For org-scoped licenses, we require a concrete requesting organization. if organization_id is None: return False 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. + + 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. + """ + if feature in get_enabled_features(): + return _license_applies_to_org(organization_id) + + if feature in DEFAULT_ENTERPRISE_OFFERINGS and _license_applies_to_org( + organization_id + ): + return True + + return False + + +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: """ Check whether an auth-category enterprise feature is enabled. @@ -211,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/core/oss_quotas.py b/app/core/oss_quotas.py new file mode 100644 index 00000000..ddcfde86 --- /dev/null +++ b/app/core/oss_quotas.py @@ -0,0 +1,206 @@ +"""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, Organization, OrganizationMember, Workspace + +OSS_MAX_USER_METRICS = 5 +OSS_MAX_AGENTS = 3 +OSS_MAX_ORG_MEMBERS = 1 +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 member (solo use). Invite additional users with an " + "Enterprise license." + ), + "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 _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, + 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 + + _lock_organization_for_quota(db, organization_id) + + 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/__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 45a44c64..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() @@ -34,4 +39,26 @@ 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 + + if not deployment_has_entitlement(): + message = ( + "DB_SHARDING_ENABLED is true but no deployment-wide enterprise " + "license is present. Refusing catalog fallback to avoid split storage." + ) + logger.error(message) + raise ShardingEntitlementError(message) + + if not is_feature_enabled("db_sharding"): + message = ( + "DB_SHARDING_ENABLED is true but db_sharding is not enabled " + "by the enterprise license. Refusing catalog fallback to avoid split storage." + ) + logger.error(message) + raise ShardingEntitlementError(message) + + return True diff --git a/app/services/ai/llm_gateway.py b/app/services/ai/llm_gateway.py index 2283a3a5..ff64e97f 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 has_valid_license + + if not has_valid_license(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..dd518292 100644 --- a/app/services/ai/llm_gateway_settings.py +++ b/app/services/ai/llm_gateway_settings.py @@ -144,6 +144,55 @@ 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 a valid enterprise license.""" + from app.core.license import has_valid_license + + if has_valid_license(organization_id): + return + + raise HTTPException( + status_code=403, + detail={ + "error": "enterprise_license_required", + "message": ( + "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, @@ -162,6 +211,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/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/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/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/enterprise/index.mdx b/docs-fumadocs/content/docs/enterprise/index.mdx index 4a16bd4a..45f2301e 100644 --- a/docs-fumadocs/content/docs/enterprise/index.mdx +++ b/docs-fumadocs/content/docs/enterprise/index.mdx @@ -1,115 +1,115 @@ ---- -title: Enterprise ---- - -# Enterprise - -> **Enterprise quickstart** -> - Start with: [Authentication guide](https://docs.efficientai.cloud/docs/getting-started/authentication/) -> - Free trial: [Book a demo](https://cal.com/aadhar-singh-bhadauria/30min) -> - Licensing: [contact@efficientai.cloud](mailto:contact@efficientai.cloud) - -## Who is Enterprise for? - -Enterprise is for teams running voice agent evaluation in production who need multi-member IAM, extended analytics, gateway routing, and operational controls beyond the open-source limits. - -## License model (BSL) - -EfficientAI open source is released under the **Business Source License (BSL)**, following the same approach as [LiteLLM](https://github.com/BerriAI/litellm) and [Bifrost](https://github.com/maximhq/bifrost): - -- Core evaluation workflows remain free to self-host under BSL. -- Specific production-scale capabilities require an **Enterprise license key**. -- After the license change date defined in the repository `LICENSE` file, covered code converts to Apache 2.0. - -Enterprise contracts unlock gated features for your organization (or deployment-wide when no `org_id` is set in the license payload). - -## Open source vs Enterprise - -| Capability | Open source (BSL) | Enterprise | -|---|---|---| -| Voice bundles and BYOK integrations | Included | βœ… Included | -| Agents | Up to **3** agents | βœ… Unlimited | -| Custom metrics | Up to **5** metrics | βœ… Unlimited | -| Evaluators, suites, results | Included | βœ… Included + failure clustering | -| Prompt partials | Included | βœ… Included | -| GEPA / prompt optimization | **Included** | βœ… Included | -| Agent playground | Included | βœ… Included | -| Voice playground (blind testing) | Not included | βœ… Included | -| Call imports (post-production analytics) | Not included | βœ… Included | -| Metric Studio | Not included | βœ… Included | -| Alerts | Not included | βœ… Included | -| Usage analytics history | Last **7 days** | βœ… Unlimited | -| Org members | **1 member** per org | βœ… Unlimited | -| Workspaces | **1 default workspace** | βœ… Unlimited | -| Gateway enablement (integrations) | Not included | βœ… Included | -| Authentication | API keys + local email/password | βœ… OIDC, SAML, SCIM, MFA enforcement, audit export | - -## Already gated (Enterprise license required) - -These features are already enforced behind an Enterprise license key: - -| Feature ID | Capability | -|---|---| -| `call_imports` | Post-production call imports and batch analytics | -| `voice_playground` | Voice Playground with blind TTS comparison | - -## Included in open source - -- Voice bundles and integrations (BYOK and platform providers) -- Agents, personas, and scenarios (within agent cap) -- Evaluators, evaluator suites, and evaluation results -- Metrics and categorisation labels (within metric cap) -- Prompt partials, GEPA / prompt optimization, and agent playground -- Traces, observability, and judge alignment -- API key and local password authentication - -## Set up a license - -Provide the Enterprise license key in environment variables or config: - -```bash title=".env" -EFFICIENTAI_LICENSE=eyJhbGciOi... -``` - -```yaml title="config.yml" -license: - key: "eyJhbGciOi..." -``` - -Restart the EfficientAI backend after updating the license. - -License scope behavior: - -- If `org_id` is not set in the license payload, features are enabled deployment-wide. -- If `org_id` is set, features are enabled only for that organization. - -Verify current state: - -- `GET /api/v1/license-info` - -## FAQ - -### What happens without a license? - -Feature-gated routes return `403` with `enterprise_feature_required` or `enterprise_license_required`. Open-source limits (agents, metrics, IAM, usage history) apply automatically. - -### What is the OSS usage analytics limit? - -Open-source deployments retain a **7-day** usage analytics history. - -### Is GEPA / prompt optimization Enterprise-only? - -No. GEPA / prompt optimization is included in the open-source BSL distribution. - -### How do I verify whether my org is licensed? - -Check `GET /api/v1/license-info` and confirm `enabled_features` is populated for your organization. - -### Where do I configure authentication modes? - -See [Authentication guide](https://docs.efficientai.cloud/docs/getting-started/authentication/) and [Configuration reference](/docs/reference/configuration/). - -## Talk to us - -- Sales and licensing: [contact@efficientai.cloud](mailto:contact@efficientai.cloud) -- Book a demo: [cal.com/aadhar-singh-bhadauria/30min](https://cal.com/aadhar-singh-bhadauria/30min) +--- +title: Enterprise +--- + +# Enterprise + +> **Enterprise quickstart** +> - Start with: [Authentication guide](https://docs.efficientai.cloud/docs/getting-started/authentication/) +> - Free trial: [Book a demo](https://cal.com/aadhar-singh-bhadauria/30min) +> - Licensing: [contact@efficientai.cloud](mailto:contact@efficientai.cloud) + +## Who is Enterprise for? + +Enterprise is for teams running voice agent evaluation in production who need multi-member IAM, extended analytics, gateway routing, and operational controls beyond the open-source limits. + +## License model (BSL) + +EfficientAI open source is released under the **Business Source License (BSL)**, following the same approach as [LiteLLM](https://github.com/BerriAI/litellm) and [Bifrost](https://github.com/maximhq/bifrost): + +- Core evaluation workflows remain free to self-host under BSL. +- Specific production-scale capabilities require an **Enterprise license key**. +- After the license change date defined in the repository `LICENSE` file, covered code converts to Apache 2.0. + +Enterprise contracts unlock gated features for your organization (or deployment-wide when no `org_id` is set in the license payload). + +## Open source vs Enterprise + +| Capability | Open source (BSL) | Enterprise | +|---|---|---| +| Voice bundles and BYOK integrations | Included | βœ… Included | +| Agents | Up to **3** agents | βœ… Unlimited | +| Custom metrics | Up to **5** metrics | βœ… Unlimited | +| Evaluators, suites, results | Included | βœ… Included + failure clustering | +| Prompt partials | Included | βœ… Included | +| GEPA / prompt optimization | **Included** | βœ… Included | +| Agent playground | Included | βœ… Included | +| Voice playground (blind testing) | Not included | βœ… Included | +| Call imports (post-production analytics) | Not included | βœ… Included | +| Metric Studio | Not included | βœ… Included | +| Alerts | Not included | βœ… Included | +| Usage analytics history | Last **7 days** | βœ… Unlimited | +| Org members | **1 member** per org | βœ… Unlimited | +| Workspaces | **1 default workspace** | βœ… Unlimited | +| Gateway enablement (integrations) | Not included | βœ… Included | +| Authentication | API keys + local email/password | βœ… OIDC, SAML, SCIM, MFA enforcement, audit export | + +## Already gated (Enterprise license required) + +These features are already enforced behind an Enterprise license key: + +| Feature ID | Capability | +|---|---| +| `call_imports` | Post-production call imports and batch analytics | +| `voice_playground` | Voice Playground with blind TTS comparison | + +## Included in open source + +- Voice bundles and integrations (BYOK and platform providers) +- Agents, personas, and scenarios (within agent cap) +- Evaluators, evaluator suites, and evaluation results +- Metrics and categorisation labels (within metric cap) +- Prompt partials, GEPA / prompt optimization, and agent playground +- Traces, observability, and judge alignment +- API key and local password authentication + +## Set up a license + +Provide the Enterprise license key in environment variables or config: + +```bash title=".env" +EFFICIENTAI_LICENSE=eyJhbGciOi... +``` + +```yaml title="config.yml" +license: + key: "eyJhbGciOi..." +``` + +Restart the EfficientAI backend after updating the license. + +License scope behavior: + +- If `org_id` is not set in the license payload, features are enabled deployment-wide. +- If `org_id` is set, features are enabled only for that organization. + +Verify current state: + +- `GET /api/v1/license-info` + +## FAQ + +### What happens without a license? + +Feature-gated routes return `403` with `enterprise_feature_required` or `enterprise_license_required`. Open-source limits (agents, metrics, IAM, usage history) apply automatically. + +### What is the OSS usage analytics limit? + +Open-source deployments retain a **7-day** usage analytics history. + +### Is GEPA / prompt optimization Enterprise-only? + +No. GEPA / prompt optimization is included in the open-source BSL distribution. + +### How do I verify whether my org is licensed? + +Check `GET /api/v1/license-info` and confirm `enabled_features` is populated for your organization. + +### Where do I configure authentication modes? + +See [Authentication guide](https://docs.efficientai.cloud/docs/getting-started/authentication/) and [Configuration reference](/docs/reference/configuration/). + +## Talk to us + +- Sales and licensing: [aadhar@efficientai.cloud](mailto:aadhar@efficientai.cloud) +- Book a demo: [cal.com/aadhar-singh-bhadauria/30min](https://cal.com/aadhar-singh-bhadauria/30min) 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 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/public/vapiai.jpg b/frontend/public/vapiai.jpg index bfbaf8b0..3db15daa 100644 Binary files a/frontend/public/vapiai.jpg and b/frontend/public/vapiai.jpg differ 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/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/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/components/Layout.tsx b/frontend/src/components/Layout.tsx index 5e5f7d38..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, @@ -91,7 +90,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 +112,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' }, ], }, { @@ -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/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 +335,7 @@ export default function AgentsWorkspace() {

No agents yet

Create your first test agent to get started

-
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/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/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 2cbaddf6..d492552f 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' @@ -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,10 @@ const AI_INTEGRATION_PROVIDERS: ModelProvider[] = [ export default function Integrations() { const queryClient = useQueryClient() const { showToast, ToastContainer } = useToast() + 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) @@ -57,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('') @@ -203,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(() => { @@ -379,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([]) @@ -391,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) @@ -405,7 +439,8 @@ export default function Integrations() { 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(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 || '') @@ -436,8 +471,11 @@ 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 ( + gatewayRoutingAllowed && + effectiveCredentialRoutingMode !== (selectedIntegration.routing_mode || 'inherit') + ) { + updateData.routing_mode = effectiveCredentialRoutingMode } if (Object.keys(updateData).length > 0) updateIntegrationMutation.mutate({ id: selectedIntegration.id, data: updateData }) else resetForm() @@ -448,7 +486,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') { @@ -491,38 +529,43 @@ export default function Integrations() { if (trimmedAzureEndpointUrl !== (selectedAIProvider.endpoint_url || '')) { updateData.endpoint_url = trimmedAzureEndpointUrl || null } - if (credentialRoutingMode !== (selectedAIProvider.routing_mode || 'inherit')) { - updateData.routing_mode = credentialRoutingMode - } - const trimmedGatewayModel = gatewayModel.trim() - if (trimmedGatewayModel !== (selectedAIProvider.gateway_model || '')) { - updateData.gateway_model = trimmedGatewayModel || null - } - if (gatewayInterface !== (selectedAIProvider.gateway_interface || 'inherit')) { - updateData.gateway_interface = gatewayInterface + if ( + gatewayRoutingAllowed && + effectiveCredentialRoutingMode !== (selectedAIProvider.routing_mode || 'inherit') + ) { + updateData.routing_mode = effectiveCredentialRoutingMode } - 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 { @@ -542,15 +585,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, }) } @@ -630,7 +677,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) ) @@ -654,11 +708,19 @@ export default function Integrations() {
- +
@@ -988,6 +1054,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.

+ {!gatewayRoutingAllowed && ( +
+ 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={!gatewayRoutingAllowed && llmGatewayMode !== 'disabled' && llmGatewayMode !== 'inherit'} > - +
@@ -1207,6 +1284,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' @@ -1273,6 +1351,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" @@ -1353,6 +1433,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 && (
setFormData({ ...formData, llm_model: 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" - disabled={!formData.llm_provider} - > - {formData.llm_provider ? ( - getModelOptions(formData.llm_provider).llm.map((model: string) => ( - - )) - ) : ( - - )} - + {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 cbb6375e..83dd47c8 100644 --- a/frontend/src/pages/iam/IAM.tsx +++ b/frontend/src/pages/iam/IAM.tsx @@ -1,9 +1,10 @@ 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 } 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' @@ -13,19 +14,33 @@ 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' +import { refreshOssQuotaUsage } from '../../store/licenseStore' 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 }, ] +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() const isAdmin = useIsAdmin() + const { isAtLimit, limitMessage, isEnterprise } = useOssQuotas() const [searchParams, setSearchParams] = useSearchParams() const tabParam = searchParams.get('tab') const activeTab: IamTab = @@ -41,6 +56,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 }) } @@ -136,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') @@ -325,8 +350,16 @@ export default function IAM() { {isAdmin && activeTab === 'organization' && ( @@ -335,21 +368,42 @@ export default function IAM() {
@@ -655,79 +709,94 @@ export default function IAM() { )} - {activeTab === 'workspace-members' && } + {activeTab === 'workspace-members' && isEnterprise && } - {activeTab === 'workspace-roles' && isAdmin && ( + {activeTab === 'workspace-roles' && isAdmin && isEnterprise && (
)} {/* 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 +

+ +
+
@@ -911,9 +990,9 @@ export default function IAM() {
-
-
- )} +
+
, + )} 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 +687,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) @@ -692,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', @@ -721,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') @@ -789,6 +807,7 @@ export default function MetricsManagement({ : apiClient.createMetricWithChildren(payload), onSuccess: (metric) => { queryClient.invalidateQueries({ queryKey: ['metrics'] }) + void refreshOssQuotaUsage() onMetricCreated?.(metric) closeModal() if (!draftMode) { @@ -1185,6 +1204,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 +1539,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/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/pages/promptPartials/PromptPartials.tsx b/frontend/src/pages/promptPartials/PromptPartials.tsx index 271ffc56..b3c7aec8 100644 --- a/frontend/src/pages/promptPartials/PromptPartials.tsx +++ b/frontend/src/pages/promptPartials/PromptPartials.tsx @@ -11,7 +11,12 @@ import { Edit3, Trash2, Copy, + ClipboardCopy, History, + PanelLeftClose, + PanelLeft, + PanelRightClose, + PanelRight, RotateCcw, X, ChevronRight, @@ -29,9 +34,11 @@ import { Tags, } from 'lucide-react' import { format } from 'date-fns' +import { copyTextToClipboard } from '../../lib/clipboard' import AIProviderModelPicker from '../../components/AIProviderModelPicker' import type { LLMGenerationConfig } from '../../config/llmGenerationParams' import AgentFlowChart from './components/AgentFlowChart' +import FlowchartErrorPanel from './components/FlowchartErrorPanel' import MetricPartialEditor from './components/MetricPartialEditor' import AgentPromptSectionView, { type PromptHighlightRange, @@ -82,6 +89,16 @@ const KIND_TABS: { id: PartialKind; label: string }[] = [ const toolBtn = 'inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 transition-colors' +const PARTIALS_LIST_COLLAPSED_KEY = 'promptPartialsListCollapsed' + +function readListCollapsedPreference(): boolean { + try { + return localStorage.getItem(PARTIALS_LIST_COLLAPSED_KEY) === 'true' + } catch { + return false + } +} + function parseKindParam(value: string | null): PartialKind { if ( value === 'partial' || @@ -150,6 +167,17 @@ export default function PromptPartials() { const [selectedFlowNodeId, setSelectedFlowNodeId] = useState(null) const [promptHighlight, setPromptHighlight] = useState(null) const [nodeMapError, setNodeMapError] = useState(null) + const [listSidebarCollapsed, setListSidebarCollapsed] = useState(readListCollapsedPreference) + const [agentDiagramCollapsed, setAgentDiagramCollapsed] = useState(false) + + const toggleListSidebar = (collapsed: boolean) => { + setListSidebarCollapsed(collapsed) + try { + localStorage.setItem(PARTIALS_LIST_COLLAPSED_KEY, String(collapsed)) + } catch { + // ignore storage failures + } + } useEffect(() => { if (!routePartialId) return @@ -222,8 +250,8 @@ export default function PromptPartials() { setNodeMapError(null) queryClient.invalidateQueries({ queryKey: ['prompt-partial', selectedPartial?.id] }) }, - onError: (e: any) => { - setFlowchartError(e?.response?.data?.detail || 'Failed to generate flowchart.') + onError: (e: unknown) => { + setFlowchartError(getApiErrorMessage(e, 'Failed to generate flowchart.')) }, }) @@ -265,6 +293,21 @@ export default function PromptPartials() { setCreateModalDefaultAgent(false) } + const handleCopyPrompt = () => { + const content = partialDetail?.content || selectedPartial?.content || '' + if (!content.trim()) return + copyTextToClipboard(content, () => + showToast('Prompt copied to clipboard', 'success'), + ) + } + + const startVersionCompare = (version: PromptPartialVersion) => { + setCompareVersion(version) + if (selectedPartial && isImportedAgent(selectedPartial)) { + setAgentDiagramCollapsed(true) + } + } + const handleSelectPartial = (partial: PromptPartial, kind?: PartialKind) => { const effectiveKind = kind ?? kindFilter setSelectedPartial(partial as PromptPartialDetail) @@ -273,6 +316,7 @@ export default function PromptPartials() { setSelectedFlowNodeId(null) setPromptHighlight(null) setNodeMapError(null) + setAgentDiagramCollapsed(false) navigate( `/prompt-partials/${partial.id}${effectiveKind === 'all' ? '' : `?kind=${effectiveKind}`}`, ) @@ -289,9 +333,9 @@ export default function PromptPartials() { setNodeMapError(null) queryClient.invalidateQueries({ queryKey: ['prompt-partial', selectedPartial?.id] }) }, - onError: (e: any) => { + onError: (e: unknown) => { setNodeMapError( - e?.response?.data?.detail || 'Failed to map prompt sections for flowchart nodes.', + getApiErrorMessage(e, 'Failed to map prompt sections for flowchart nodes.'), ) }, }) @@ -433,10 +477,12 @@ export default function PromptPartials() { {/* Main Content - Split View */}
{/* Left Panel - List */} + {!listSidebarCollapsed ? (
{/* Search + kind filter */}
-
+
+
+ +
{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 856ebc53..789f241a 100644 --- a/frontend/src/store/licenseStore.ts +++ b/frontend/src/store/licenseStore.ts @@ -1,18 +1,41 @@ 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: 1, + max_workspaces: 1, +} + +const DEFAULT_QUOTA_USAGE: OssQuotaUsage = { + user_metrics: 0, + agents: 0, + org_members: 0, + workspaces: 0, +} + interface LicenseState { isEnterprise: boolean + gatewayRoutingAllowed: boolean enabledFeatures: string[] allEnterpriseFeatures: string[] featureCatalog: EnterpriseFeatureCatalog usagePolicy: UsagePolicy + quotas: OssQuotas | null + quotaUsage: OssQuotaUsage | null isLoaded: boolean fetchLicense: () => Promise isFeatureEnabled: (feature: string) => boolean @@ -22,10 +45,13 @@ interface LicenseState { export const useLicenseStore = create((set, get) => ({ isEnterprise: false, + gatewayRoutingAllowed: false, enabledFeatures: [], allEnterpriseFeatures: [], featureCatalog: {}, usagePolicy: DEFAULT_USAGE_POLICY, + quotas: DEFAULT_OSS_QUOTAS, + quotaUsage: DEFAULT_QUOTA_USAGE, isLoaded: false, fetchLicense: async () => { @@ -33,19 +59,25 @@ 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 ?? {}, 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 { set({ isEnterprise: false, + gatewayRoutingAllowed: false, enabledFeatures: [], allEnterpriseFeatures: [], featureCatalog: {}, usagePolicy: DEFAULT_USAGE_POLICY, + quotas: DEFAULT_OSS_QUOTAS, + quotaUsage: DEFAULT_QUOTA_USAGE, isLoaded: true, }) } @@ -63,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/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 a6490272..6ea1935d 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), @@ -609,3 +610,4 @@ def _make_prompt_optimization_candidate(**overrides): return candidate return _make_prompt_optimization_candidate + diff --git a/tests/test_api/test_enterprise_gating.py b/tests/test_api/test_enterprise_gating.py new file mode 100644 index 00000000..43f8af71 --- /dev/null +++ b/tests/test_api/test_enterprise_gating.py @@ -0,0 +1,235 @@ +"""API tests for OSS vs enterprise feature gates (unlicensed client).""" + +from __future__ import annotations + +import pytest + +from app.core import license as license_module + + +@pytest.fixture +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) + 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. + monkeypatch.setattr( + app_dependencies, + "is_feature_enabled", + license_module.is_feature_enabled, + ) + + yield authenticated_client + + 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"]["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): + 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 body["quotas"]["max_org_members"] == 1 + 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" + ) + assert response.status_code == 403 + assert response.json()["detail"]["error"] == "enterprise_license_required" diff --git a/tests/test_core/test_license_offerings.py b/tests/test_core/test_license_offerings.py new file mode 100644 index 00000000..ef5167d7 --- /dev/null +++ b/tests/test_core/test_license_offerings.py @@ -0,0 +1,96 @@ +"""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_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( + 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_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") diff --git a/tests/test_core/test_oss_quotas.py b/tests/test_core/test_oss_quotas.py new file mode 100644 index 00000000..4d9a5bdc --- /dev/null +++ b/tests/test_core/test_oss_quotas.py @@ -0,0 +1,188 @@ +"""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_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 +): + 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 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 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")