mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
feat(craft): show a Business upsell for Craft on Community Edition (#15901)
This commit is contained in:
1 parent
7283da0b62
commit
777b9bf1f7
30 files changed
+394
-41
No files matched your search
@@ -109,8 +109,8 @@ the coding-agent sandbox).
|
||||
| Method | Path | Handler | Permission |
|
||||
|---|---|---|---|
|
||||
| CRUD | `/admin/api-key*` | `server/api_key/api.py` | `Permission.MANAGE_SERVICE_ACCOUNT_API_KEYS`. Creating or updating a key is **admin-equivalent by design**: `group_ids` is uncapped, so a holder can add a key to the Admin group (`api.py:create_api_key` comment). |
|
||||
| GET | `/user/pats/scopes` | `server/pat/api.py:list_selectable_scopes` | `Permission.BASIC_ACCESS`. Drops `use:llm_gateway` from the list while the workspace `llm_gateway_enabled` setting is off. |
|
||||
| GET/POST/DELETE | `/user/pats` | `server/pat/api.py` | Create needs `CREATE_USER_API_KEYS`. The seeded Basic group grants it by default, and a full admin can switch it off or on from the Basic group's page (`auth/permissions.py:DEFAULT_GROUP_TOGGLEABLE_PERMISSIONS`). The same permission gates MCP OAuth consent (`server/oauth_provider/api.py`), so the toggle covers both. List and delete need `BASIC_ACCESS`, and a user only ever sees or revokes their own. Minting a PAT with `use:llm_gateway` while the setting is off is rejected with `INVALID_INPUT` (`server/pat/api.py:_validate_assignable_scopes`). |
|
||||
| GET | `/user/pats/scopes` | `server/pat/api.py:list_selectable_scopes` | `Permission.BASIC_ACCESS`. Drops scopes whose `min_tier` is above the current tier (`use:llm_gateway` is Business+), and drops `use:llm_gateway` while the workspace `llm_gateway_enabled` setting is off. |
|
||||
| GET/POST/DELETE | `/user/pats` | `server/pat/api.py` | Create needs `CREATE_USER_API_KEYS`. The seeded Basic group grants it by default, and a full admin can switch it off or on from the Basic group's page (`auth/permissions.py:DEFAULT_GROUP_TOGGLEABLE_PERMISSIONS`). The same permission gates MCP OAuth consent (`server/oauth_provider/api.py`), so the toggle covers both. List and delete need `BASIC_ACCESS`, and a user only ever sees or revokes their own. Minting a PAT with a scope above the current tier is rejected with `FEATURE_NOT_AVAILABLE`, and minting one with `use:llm_gateway` while the setting is off is rejected with `INVALID_INPUT` (`server/pat/api.py:_validate_assignable_scopes`). |
|
||||
| GET/PUT | `/admin/security`, GET `/admin/security/pinned-fields` | `server/security/api.py` | `FULL_ADMIN_PANEL_ACCESS`. Workspace security overrides: password policy, `password_auth_enabled`, `valid_email_domains`, JWT validation fields, SSRF level, incognito availability, `allow_connector_group_restrictions`, and `allow_connector_check_findings` (on by default; off, capability checks store and return no findings). Env-pinned fields win over stored values. Turning on `allow_connector_group_restrictions` needs the Business tier. |
|
||||
| CRUD | `/admin/user-group*` | `ee/onyx/server/user_group/api.py` | See [[access-control]] §2; this is where group membership, and thus `PermissionGrant`-derived capability, is actually assigned. |
|
||||
| GET/POST/PATCH | `/admin/sso-providers*`, `/admin/sso-providers/{id}/domains` | `server/manage/sso/api.py` | `FULL_ADMIN_PANEL_ACCESS` (exact gating per route). Domain-routing SSO providers beyond the first require business tier (`_require_business_tier_for_additional_enabled_provider`). |
|
||||
@@ -569,8 +569,8 @@ is marked as such.
|
||||
- [[llm-gateway]]: the `use:llm_gateway` PAT scope (`Permission.
|
||||
USE_LLM_GATEWAY`, `db/enums.py`) is a `SELECTABLE_PAT_SCOPES` entry
|
||||
(`server/pat/models.py`) minted and checked entirely by this component's
|
||||
PAT machinery. The workspace `llm_gateway_enabled` setting hides the scope
|
||||
in `list_selectable_scopes` and blocks minting it, but only for third-party
|
||||
PAT machinery. The workspace `llm_gateway_enabled` setting and the scope's
|
||||
Business `min_tier` both hide it in `list_selectable_scopes` and block minting it, but only for third-party
|
||||
PATs; a Craft sandbox's `craft_sandbox`-scoped PAT is unaffected.
|
||||
- [[cc-pairs-and-credentials]]: connector credentials are a separate
|
||||
encrypted-secret concept from user auth; both use the same
|
||||
|
||||
@@ -161,7 +161,9 @@ value the control plane sends.
|
||||
|
||||
`onyx/server/settings/tier_order.py:tier_at_least` gives every caller a total
|
||||
order (`COMMUNITY < BUSINESS < ENTERPRISE`) without depending on EE code, so
|
||||
CE call sites can gate on tier without importing `ee`.
|
||||
CE call sites can gate on tier without importing `ee`. `get_current_tier` in the
|
||||
same module resolves the deployment tier (`ee.onyx.utils.tier.get_tier`, or
|
||||
`Tier.COMMUNITY` when EE code is not loaded) for those call sites.
|
||||
|
||||
### 4.2 License claim (self-hosted)
|
||||
|
||||
|
||||
@@ -132,10 +132,10 @@ require_onyx_craft_enabled server/features/build/api.py
|
||||
└─ is_craft_enabled_for_user server/features/build/utils.py
|
||||
├─ User.craft_enabled override (per-user; short-circuits if False)
|
||||
├─ Settings.craft_default_enabled (workspace default, when override is None)
|
||||
└─ is_craft_available_for_deployment (PostHog flag or ENABLE_CRAFT)
|
||||
└─ is_craft_available_for_deployment (PostHog flag or ENABLE_CRAFT, AND tier >= LLM_GATEWAY_MIN_TIER)
|
||||
```
|
||||
|
||||
`is_craft_enabled_for_user` (`build/utils.py:156`) is the single source of
|
||||
`is_craft_enabled_for_user` (`build/utils.py:177`) is the single source of
|
||||
truth: `AccountType.ANONYMOUS` users never get Craft (it is identity-bound: a
|
||||
per-user sandbox, library, and scheduled tasks), then the per-user override
|
||||
wins if set to `False`, then the workspace default gates `None`, and finally
|
||||
@@ -321,9 +321,14 @@ not cover:
|
||||
entirely; per-user OAuth credentials for external apps depend on the acting
|
||||
user's identity.
|
||||
- [[editions-and-gating]]: `is_craft_available_for_deployment` reads a
|
||||
PostHog feature flag (or `ENABLE_CRAFT`), a deployment-level switch distinct
|
||||
from any billing-tier check; `requiredTier: null` on all three routes
|
||||
confirms Craft admin visibility is not tier-gated at the nav level.
|
||||
PostHog feature flag (or `ENABLE_CRAFT`) and also requires the deployment
|
||||
tier to reach `LLM_GATEWAY_MIN_TIER` (Business), because every Craft turn
|
||||
goes through the tier-gated `/gateway`; Community never gets Craft.
|
||||
When the flag is on but the tier is too low, `is_craft_locked_by_tier` sets
|
||||
`onyx_craft_requires_upgrade` in `/settings`: the sidebar keeps Craft with a
|
||||
Business tag and `/craft/*` renders `CraftUpgradePage`; `/build` stays 403.
|
||||
`requiredTier: null` on all three routes confirms Craft admin visibility is
|
||||
not tier-gated at the nav level.
|
||||
- [[llm-providers]]: the Preferences default-model picker is the `CRAFT`
|
||||
`LLMModelFlowType`; see `llm-providers.md` §9 for why it is a pointer, not a
|
||||
capability check.
|
||||
|
||||
@@ -90,7 +90,7 @@ docstring in `backend/ee/onyx/server/license/api.py`.
|
||||
|
||||
| Method | Path | Handler | Notes |
|
||||
|---|---|---|---|
|
||||
| PATCH | `/admin/settings` | `admin_patch_settings` | Reads `global_version.is_ee_version()` to decide whether to call `ee.onyx.utils.tier.get_tier()` or default to `Tier.COMMUNITY`. Merges only the fields the caller sent (`model_fields_set`), under `settings_write_lock()`. |
|
||||
| PATCH | `/admin/settings` | `admin_patch_settings` | Resolves the tier via `tier_order.get_current_tier()` (`ee.onyx.utils.tier.get_tier()` when EE code is loaded, else `Tier.COMMUNITY`). Merges only the fields the caller sent (`model_fields_set`), under `settings_write_lock()`. |
|
||||
|
||||
### Feature flags
|
||||
|
||||
|
||||
@@ -80,8 +80,9 @@ gates, plus the workspace setting, exist.
|
||||
|
||||
`Permission.USE_LLM_GATEWAY` (`"use:llm_gateway"`,
|
||||
`backend/onyx/db/enums.py`) is a selectable PAT scope
|
||||
(`backend/onyx/server/pat/models.py`, `SELECTABLE_PAT_SCOPES`), gated
|
||||
in the UI at `min_tier=LLM_GATEWAY_MIN_TIER` (Business). It is also implied
|
||||
(`backend/onyx/server/pat/models.py`, `SELECTABLE_PAT_SCOPES`) with
|
||||
`min_tier=LLM_GATEWAY_MIN_TIER` (Business): below that tier `GET /user/pats/scopes`
|
||||
omits it and minting it fails with `FEATURE_NOT_AVAILABLE`. It is also implied
|
||||
by `Permission.BASIC_ACCESS` and by `Permission.CRAFT_SANDBOX`
|
||||
(`backend/onyx/auth/permissions.py`, `IMPLIED_PERMISSIONS`). GATE 1 therefore
|
||||
accepts users whose permissions include `BASIC_ACCESS`, and Craft PATs with the
|
||||
|
||||
@@ -16,7 +16,9 @@ from onyx.server.features.build.configs import (
|
||||
MAX_UPLOAD_FILE_SIZE_BYTES,
|
||||
OPENCODE_DISABLED_TOOLS,
|
||||
)
|
||||
from onyx.server.gateway.configs import LLM_GATEWAY_MIN_TIER
|
||||
from onyx.server.settings.store import load_settings
|
||||
from onyx.server.settings.tier_order import get_current_tier, tier_at_least
|
||||
from onyx.utils.logger import setup_logger
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
|
||||
@@ -118,10 +120,8 @@ OPENCODE_DISABLED_TOOLS_VARIANTS: dict[str, list[str]] = {
|
||||
BUILD_MODE_FEATURE_ID = "build_mode"
|
||||
|
||||
|
||||
def is_craft_available_for_deployment(user: User) -> bool:
|
||||
def _craft_flag_enabled(user: User) -> bool:
|
||||
"""
|
||||
Check whether Onyx Craft (Build Mode) is available at the deployment level.
|
||||
|
||||
Flag logic for "onyx-craft-enabled":
|
||||
- Flag = True → enabled (Onyx Craft is available)
|
||||
- Flag = False → disabled (Onyx Craft is not available)
|
||||
@@ -153,6 +153,27 @@ def is_craft_available_for_deployment(user: User) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def is_craft_available_for_deployment(user: User) -> bool:
|
||||
"""
|
||||
Check whether Onyx Craft (Build Mode) is available at the deployment level:
|
||||
the craft flag must be on and the tier must reach LLM_GATEWAY_MIN_TIER,
|
||||
since every Craft turn goes through the tier-gated LLM gateway.
|
||||
"""
|
||||
if not _craft_flag_enabled(user):
|
||||
return False
|
||||
return tier_at_least(get_current_tier(), LLM_GATEWAY_MIN_TIER)
|
||||
|
||||
|
||||
def is_craft_locked_by_tier(user: User) -> bool:
|
||||
"""Craft is switched on but the tier is below LLM_GATEWAY_MIN_TIER, so the
|
||||
UI shows an upgrade prompt instead of Craft."""
|
||||
if user.account_type == AccountType.ANONYMOUS:
|
||||
return False
|
||||
if not _craft_flag_enabled(user):
|
||||
return False
|
||||
return not tier_at_least(get_current_tier(), LLM_GATEWAY_MIN_TIER)
|
||||
|
||||
|
||||
def is_craft_enabled_for_user(
|
||||
user: User,
|
||||
deployment_available: bool | None = None,
|
||||
|
||||
@@ -18,6 +18,7 @@ from onyx.server.pat.models import (
|
||||
TokenResponse,
|
||||
)
|
||||
from onyx.server.settings.store import load_settings
|
||||
from onyx.server.settings.tier_order import get_current_tier, tier_at_least
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
@@ -40,6 +41,16 @@ def _validate_assignable_scopes(scopes: list[Permission] | None) -> None:
|
||||
OnyxErrorCode.INVALID_INPUT,
|
||||
f"Unsupported token scope(s): {', '.join(s.value for s in unsupported)}",
|
||||
)
|
||||
tier = get_current_tier()
|
||||
below_tier = [
|
||||
s for s in scopes if not tier_at_least(tier, SELECTABLE_PAT_SCOPES[s].min_tier)
|
||||
]
|
||||
if below_tier:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.FEATURE_NOT_AVAILABLE,
|
||||
f"Token scope(s) not available on the {tier.value.title()} plan: "
|
||||
f"{', '.join(s.value for s in below_tier)}",
|
||||
)
|
||||
if (
|
||||
Permission.USE_LLM_GATEWAY in scopes
|
||||
and not load_settings(raise_on_error=True).llm_gateway_enabled
|
||||
@@ -55,7 +66,12 @@ def list_selectable_scopes(
|
||||
_: User = Depends(require_permission(Permission.BASIC_ACCESS)),
|
||||
) -> list[PatScopeOption]:
|
||||
"""The scopes a user may assign when minting a token, with display metadata."""
|
||||
options = list(SELECTABLE_PAT_SCOPES.values())
|
||||
tier = get_current_tier()
|
||||
options = [
|
||||
option
|
||||
for option in SELECTABLE_PAT_SCOPES.values()
|
||||
if tier_at_least(tier, option.min_tier)
|
||||
]
|
||||
if not load_settings(raise_on_error=True).llm_gateway_enabled:
|
||||
options = [
|
||||
option for option in options if option.scope != Permission.USE_LLM_GATEWAY
|
||||
|
||||
@@ -20,8 +20,7 @@ class PatScopeOption(BaseModel):
|
||||
group_label: str
|
||||
label: str
|
||||
description: str
|
||||
# Minimum license tier at which the FE offers this scope. Not enforced at
|
||||
# minting time; the gated API surface itself rejects lower tiers.
|
||||
# Minimum license tier at which this scope is listed and can be minted.
|
||||
min_tier: Tier = Tier.COMMUNITY
|
||||
|
||||
@computed_field
|
||||
|
||||
@@ -36,6 +36,7 @@ from onyx.oauth_provider.config import OAUTH_PROVIDER_SETTINGS
|
||||
from onyx.server.features.build.utils import (
|
||||
is_craft_available_for_deployment,
|
||||
is_craft_enabled_for_user,
|
||||
is_craft_locked_by_tier,
|
||||
)
|
||||
from onyx.server.features.notifications.models import NotificationResponse
|
||||
from onyx.server.manage.llm.provider_cache import invalidate_provider_listing_cache
|
||||
@@ -51,7 +52,7 @@ from onyx.server.settings.store import (
|
||||
settings_write_lock,
|
||||
store_settings,
|
||||
)
|
||||
from onyx.server.settings.tier_order import tier_at_least
|
||||
from onyx.server.settings.tier_order import get_current_tier, tier_at_least
|
||||
from onyx.utils.audit import (
|
||||
AuditAction,
|
||||
AuditOutcome,
|
||||
@@ -80,12 +81,7 @@ def admin_patch_settings(
|
||||
),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> Settings:
|
||||
if global_version.is_ee_version():
|
||||
from ee.onyx.utils.tier import get_tier
|
||||
|
||||
current_tier = get_tier()
|
||||
else:
|
||||
current_tier = Tier.COMMUNITY
|
||||
current_tier = get_current_tier()
|
||||
|
||||
# Serialize the read-modify-write so two concurrent partial patches cannot
|
||||
# each merge onto a stale snapshot and drop the other's field.
|
||||
@@ -229,6 +225,9 @@ def fetch_settings(
|
||||
if user
|
||||
else False
|
||||
)
|
||||
onyx_craft_requires_upgrade = (
|
||||
user is not None and not onyx_craft_available and is_craft_locked_by_tier(user)
|
||||
)
|
||||
|
||||
# Dev/debug flag: tail-the-pod-logs button gated by an env var. Same
|
||||
# check happens on the SSE endpoint so flipping the env var off
|
||||
@@ -242,6 +241,7 @@ def fetch_settings(
|
||||
needs_reindexing=needs_reindexing,
|
||||
onyx_craft_enabled=onyx_craft_enabled_for_user,
|
||||
onyx_craft_available=onyx_craft_available,
|
||||
onyx_craft_requires_upgrade=onyx_craft_requires_upgrade,
|
||||
opencode_debugging_enabled=ENABLE_OPENCODE_DEBUGGING,
|
||||
vector_db_enabled=not DISABLE_VECTOR_DB,
|
||||
hide_onyx_branding=HIDE_ONYX_BRANDING
|
||||
|
||||
@@ -131,6 +131,8 @@ class UserSettings(Settings):
|
||||
# Deployment-level Craft availability, ignoring the per-user admin toggle.
|
||||
# Gates visibility of the admin per-user Craft controls.
|
||||
onyx_craft_available: bool = False
|
||||
# Craft is switched on but the tier is too low: show the upgrade prompt.
|
||||
onyx_craft_requires_upgrade: bool = False
|
||||
# Dev/debug flag: when true, the FE renders a button that streams the
|
||||
# user's sandbox pod's opencode-serve logs. Gated by the
|
||||
# ENABLE_OPENCODE_DEBUGGING env var; never set in prod.
|
||||
|
||||
@@ -4,6 +4,7 @@ Lives in CE so any module can compare tiers without depending on EE code.
|
||||
"""
|
||||
|
||||
from onyx.server.settings.models import Tier
|
||||
from onyx.utils.variable_functionality import global_version
|
||||
|
||||
TIER_RANK: dict[Tier, int] = {
|
||||
Tier.COMMUNITY: 0,
|
||||
@@ -14,3 +15,12 @@ TIER_RANK: dict[Tier, int] = {
|
||||
|
||||
def tier_at_least(current: Tier, required: Tier) -> bool:
|
||||
return TIER_RANK[current] >= TIER_RANK[required]
|
||||
|
||||
|
||||
def get_current_tier() -> Tier:
|
||||
"""The deployment's tier; Community when EE code is not loaded."""
|
||||
if not global_version.is_ee_version():
|
||||
return Tier.COMMUNITY
|
||||
from ee.onyx.utils.tier import get_tier
|
||||
|
||||
return get_tier()
|
||||
@@ -15,6 +15,7 @@ from onyx.server.features.build.api import require_onyx_craft_enabled
|
||||
from onyx.server.features.build.utils import is_craft_enabled_for_user
|
||||
from onyx.server.manage.models import UserCraftAccessUpdateRequest
|
||||
from onyx.server.manage.users import set_user_craft_access
|
||||
from onyx.server.settings.models import Tier
|
||||
from tests.external_dependency_unit.craft.db_helpers import make_user
|
||||
|
||||
|
||||
@@ -26,6 +27,7 @@ def _enable_craft_deployment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"get_default_feature_flag_provider",
|
||||
lambda: NoOpFeatureFlagProvider(),
|
||||
)
|
||||
monkeypatch.setattr(build_utils, "get_current_tier", lambda: Tier.BUSINESS)
|
||||
|
||||
|
||||
def test_admin_batch_disable_and_reset_to_default(
|
||||
|
||||
@@ -19,7 +19,7 @@ from onyx.db.enums import AccountType
|
||||
from onyx.feature_flags.interface import FeatureFlagProvider, NoOpFeatureFlagProvider
|
||||
from onyx.server.features.build import utils as build_utils
|
||||
from onyx.server.features.build.utils import is_craft_enabled_for_user
|
||||
from onyx.server.settings.models import Settings
|
||||
from onyx.server.settings.models import Settings, Tier
|
||||
|
||||
|
||||
class _StubPostHogProvider(FeatureFlagProvider):
|
||||
@@ -44,6 +44,11 @@ class _StubPostHogProvider(FeatureFlagProvider):
|
||||
return self._enabled
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _business_tier(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(build_utils, "get_current_tier", lambda: Tier.BUSINESS)
|
||||
|
||||
|
||||
def _make_user() -> MagicMock:
|
||||
"""Build a minimal stand-in for `User` - only `.id`, `.email`,
|
||||
`.account_type`, and `.craft_enabled` are read."""
|
||||
@@ -225,3 +230,69 @@ def test_posthog_flag_disables_when_false(monkeypatch: pytest.MonkeyPatch) -> No
|
||||
{"tenant_id": "tenant_other", "email": "user@tenant-dev.example"},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tier,expected",
|
||||
[(Tier.COMMUNITY, False), (Tier.BUSINESS, True), (Tier.ENTERPRISE, True)],
|
||||
)
|
||||
def test_craft_requires_gateway_tier(
|
||||
monkeypatch: pytest.MonkeyPatch, tier: Tier, expected: bool
|
||||
) -> None:
|
||||
"""Craft turns go through the tier-gated LLM gateway, so an unlicensed
|
||||
(Community) deployment never gets Craft even with ENABLE_CRAFT=True."""
|
||||
monkeypatch.setattr(build_utils, "ENABLE_CRAFT", True)
|
||||
monkeypatch.setattr(
|
||||
build_utils,
|
||||
"get_default_feature_flag_provider",
|
||||
lambda: NoOpFeatureFlagProvider(),
|
||||
)
|
||||
monkeypatch.setattr(build_utils, "get_current_tier", lambda: tier)
|
||||
|
||||
assert is_craft_enabled_for_user(_make_user()) is expected
|
||||
|
||||
|
||||
def test_tier_not_resolved_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(build_utils, "ENABLE_CRAFT", False)
|
||||
monkeypatch.setattr(
|
||||
build_utils,
|
||||
"get_default_feature_flag_provider",
|
||||
lambda: NoOpFeatureFlagProvider(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
build_utils,
|
||||
"get_current_tier",
|
||||
lambda: pytest.fail("tier should not be resolved when Craft is off"),
|
||||
)
|
||||
|
||||
assert is_craft_enabled_for_user(_make_user()) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tier,expected",
|
||||
[(Tier.COMMUNITY, True), (Tier.BUSINESS, False), (Tier.ENTERPRISE, False)],
|
||||
)
|
||||
def test_craft_locked_by_tier(
|
||||
monkeypatch: pytest.MonkeyPatch, tier: Tier, expected: bool
|
||||
) -> None:
|
||||
monkeypatch.setattr(build_utils, "ENABLE_CRAFT", True)
|
||||
monkeypatch.setattr(
|
||||
build_utils,
|
||||
"get_default_feature_flag_provider",
|
||||
lambda: NoOpFeatureFlagProvider(),
|
||||
)
|
||||
monkeypatch.setattr(build_utils, "get_current_tier", lambda: tier)
|
||||
|
||||
assert build_utils.is_craft_locked_by_tier(_make_user()) is expected
|
||||
|
||||
|
||||
def test_craft_not_locked_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(build_utils, "ENABLE_CRAFT", False)
|
||||
monkeypatch.setattr(
|
||||
build_utils,
|
||||
"get_default_feature_flag_provider",
|
||||
lambda: NoOpFeatureFlagProvider(),
|
||||
)
|
||||
monkeypatch.setattr(build_utils, "get_current_tier", lambda: Tier.COMMUNITY)
|
||||
|
||||
assert build_utils.is_craft_locked_by_tier(_make_user()) is False
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for the PAT scopes listing and scope validation."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -8,10 +9,16 @@ from onyx.db.enums import Permission
|
||||
from onyx.error_handling.error_codes import OnyxErrorCode
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.pat import api as pat_api
|
||||
from onyx.server.settings.models import Settings
|
||||
from onyx.server.settings.models import Settings, Tier
|
||||
|
||||
|
||||
def _listed_scopes(gateway_enabled: bool) -> list[Permission]:
|
||||
@pytest.fixture(autouse=True)
|
||||
def business_tier() -> Iterator[None]:
|
||||
with patch.object(pat_api, "get_current_tier", return_value=Tier.BUSINESS):
|
||||
yield
|
||||
|
||||
|
||||
def _listed_scopes(gateway_enabled: bool = True) -> list[Permission]:
|
||||
with patch.object(
|
||||
pat_api,
|
||||
"load_settings",
|
||||
@@ -49,3 +56,42 @@ def test_validate_scopes_allows_gateway_scope_when_enabled() -> None:
|
||||
MagicMock(return_value=Settings(llm_gateway_enabled=True)),
|
||||
):
|
||||
pat_api._validate_assignable_scopes([Permission.USE_LLM_GATEWAY])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tier", [Tier.BUSINESS, Tier.ENTERPRISE])
|
||||
def test_gateway_scope_listed_and_mintable_on_business_plus(tier: Tier) -> None:
|
||||
with (
|
||||
patch.object(pat_api, "get_current_tier", return_value=tier),
|
||||
patch.object(
|
||||
pat_api,
|
||||
"load_settings",
|
||||
MagicMock(return_value=Settings(llm_gateway_enabled=True)),
|
||||
),
|
||||
):
|
||||
assert Permission.USE_LLM_GATEWAY in [
|
||||
option.scope for option in pat_api.list_selectable_scopes(MagicMock())
|
||||
]
|
||||
pat_api._validate_assignable_scopes([Permission.USE_LLM_GATEWAY])
|
||||
|
||||
|
||||
def test_gateway_scope_hidden_and_rejected_on_community() -> None:
|
||||
with (
|
||||
patch.object(pat_api, "get_current_tier", return_value=Tier.COMMUNITY),
|
||||
patch.object(
|
||||
pat_api,
|
||||
"load_settings",
|
||||
MagicMock(return_value=Settings(llm_gateway_enabled=True)),
|
||||
),
|
||||
):
|
||||
scopes = [
|
||||
option.scope for option in pat_api.list_selectable_scopes(MagicMock())
|
||||
]
|
||||
assert Permission.USE_LLM_GATEWAY not in scopes
|
||||
assert Permission.READ_SEARCH in scopes
|
||||
with pytest.raises(OnyxError) as exc_info:
|
||||
pat_api._validate_assignable_scopes(
|
||||
[Permission.READ_SEARCH, Permission.USE_LLM_GATEWAY]
|
||||
)
|
||||
assert exc_info.value.error_code == OnyxErrorCode.FEATURE_NOT_AVAILABLE
|
||||
pat_api._validate_assignable_scopes([Permission.READ_SEARCH])
|
||||
pat_api._validate_assignable_scopes(None)
|
||||
@@ -0,0 +1,17 @@
|
||||
import pytest
|
||||
|
||||
from onyx.server.settings import tier_order
|
||||
from onyx.server.settings.models import Tier
|
||||
|
||||
|
||||
def test_current_tier_is_community_without_ee(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(tier_order.global_version, "is_ee_version", lambda: False)
|
||||
assert tier_order.get_current_tier() is Tier.COMMUNITY
|
||||
|
||||
|
||||
def test_current_tier_uses_ee_tier(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from ee.onyx.utils import tier as ee_tier
|
||||
|
||||
monkeypatch.setattr(tier_order.global_version, "is_ee_version", lambda: True)
|
||||
monkeypatch.setattr(ee_tier, "get_tier", lambda: Tier.ENTERPRISE)
|
||||
assert tier_order.get_current_tier() is Tier.ENTERPRISE
|
||||
@@ -21,6 +21,8 @@ IMAGE_TAG=latest
|
||||
# CODE_INTERPRETER_IMAGE_TAG=
|
||||
|
||||
## Onyx Craft Configuration (off by default).
|
||||
## Craft requires a Business or Enterprise license. The Community Edition shows an
|
||||
## upgrade page instead, until Craft merges into the main chat.
|
||||
## When enabling Craft through the installer's --include-craft or by manually adding
|
||||
## docker-compose.craft.yml, the sandbox image follows IMAGE_TAG above.
|
||||
# ENABLE_CRAFT=true
|
||||
|
||||
@@ -21,6 +21,8 @@ IMAGE_TAG=latest
|
||||
# CODE_INTERPRETER_IMAGE_TAG=
|
||||
|
||||
## Onyx Craft Configuration (off by default).
|
||||
## Craft requires a Business or Enterprise license. The Community Edition shows an
|
||||
## upgrade page instead, until Craft merges into the main chat.
|
||||
## When enabling Craft through the installer's --include-craft or by manually adding
|
||||
## docker-compose.craft.yml, the sandbox image follows IMAGE_TAG above.
|
||||
# ENABLE_CRAFT=true
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@opal/components";
|
||||
import { IllustrationContent } from "@opal/layouts";
|
||||
import SvgNoAccess from "@opal/illustrations/no-access";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
|
||||
export default function CraftUpgradePage() {
|
||||
const t = useTranslations("craft.upgrade");
|
||||
const { isAdmin } = useUser();
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-col items-center justify-center gap-4 p-8">
|
||||
<IllustrationContent
|
||||
illustration={SvgNoAccess}
|
||||
title={t("title")}
|
||||
description={isAdmin ? t("adminDescription") : t("userDescription")}
|
||||
/>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button prominence="secondary" href="/app">
|
||||
{t("backToChat.label")}
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button href="/admin/billing">{t("viewPlans.label")}</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Route } from "next";
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
import { requireAuth } from "@/lib/auth/svcSS";
|
||||
import { fetchSettingsSS } from "@/lib/settings/svcSS";
|
||||
import CraftUpgradePage from "@/app/craft/components/CraftUpgradePage";
|
||||
|
||||
export interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
@@ -12,7 +13,8 @@ export interface LayoutProps {
|
||||
* Build Layout - Minimal wrapper that handles authentication and feature flag check
|
||||
*
|
||||
* Child routes (/craft and /craft/v1) handle their own UI structure.
|
||||
* Redirects to /app if Onyx Craft is disabled via feature flag.
|
||||
* Shows the upgrade page when Craft is on but the tier is too low, and
|
||||
* redirects to /app if Onyx Craft is otherwise disabled.
|
||||
*/
|
||||
export default async function Layout({ children }: LayoutProps) {
|
||||
noStore();
|
||||
@@ -28,6 +30,9 @@ export default async function Layout({ children }: LayoutProps) {
|
||||
// Only explicit true enables the feature; false or undefined = disabled
|
||||
const settings = await fetchSettingsSS();
|
||||
if (settings?.settings?.onyx_craft_enabled !== true) {
|
||||
if (settings?.settings?.onyx_craft_requires_upgrade === true) {
|
||||
return <CraftUpgradePage />;
|
||||
}
|
||||
redirect("/app");
|
||||
}
|
||||
|
||||
|
||||
@@ -13800,6 +13800,17 @@
|
||||
"app": "تطبيق",
|
||||
"unavailable": "لم يعد متاحًا",
|
||||
"appTitle": "معاينة تطبيقك"
|
||||
},
|
||||
"upgrade": {
|
||||
"title": "يتطلب Craft خطة الأعمال",
|
||||
"adminDescription": "يتوفر Craft في خطتي الأعمال والمؤسسات. لا يتم دعمه في إصدار المجتمع حتى يتم دمج Craft في الدردشة الرئيسية.",
|
||||
"userDescription": "يتوفر Craft في خطتي الأعمال والمؤسسات. اطلب من المسؤول الترقية لاستخدام Craft.",
|
||||
"backToChat": {
|
||||
"label": "العودة إلى الدردشة"
|
||||
},
|
||||
"viewPlans": {
|
||||
"label": "عرض الخطط"
|
||||
}
|
||||
}
|
||||
},
|
||||
"knowledge": {
|
||||
@@ -14841,7 +14852,8 @@
|
||||
"title": "الوكلاء"
|
||||
},
|
||||
"craft": {
|
||||
"label": "Craft"
|
||||
"label": "Craft",
|
||||
"businessTag": "الأعمال"
|
||||
},
|
||||
"exploreAgents": {
|
||||
"label": "استكشاف الوكلاء"
|
||||
|
||||
@@ -13800,6 +13800,17 @@
|
||||
"app": "App",
|
||||
"unavailable": "Nicht mehr verfügbar",
|
||||
"appTitle": "Vorschau deiner App"
|
||||
},
|
||||
"upgrade": {
|
||||
"title": "Craft erfordert einen Business-Plan",
|
||||
"adminDescription": "Craft ist in den Business- und Enterprise-Plänen verfügbar. In der Community Edition wird Craft erst unterstützt, wenn es in den Hauptchat integriert ist.",
|
||||
"userDescription": "Craft ist in den Business- und Enterprise-Plänen verfügbar. Bitten Sie Ihren Administrator um ein Upgrade, um Craft zu nutzen.",
|
||||
"backToChat": {
|
||||
"label": "Zurück zum Chat"
|
||||
},
|
||||
"viewPlans": {
|
||||
"label": "Pläne anzeigen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"knowledge": {
|
||||
@@ -14841,7 +14852,8 @@
|
||||
"title": "Agenten"
|
||||
},
|
||||
"craft": {
|
||||
"label": "Craft"
|
||||
"label": "Craft",
|
||||
"businessTag": "Business"
|
||||
},
|
||||
"exploreAgents": {
|
||||
"label": "Agenten erkunden"
|
||||
|
||||
@@ -13800,6 +13800,17 @@
|
||||
"app": "App",
|
||||
"unavailable": "No longer available",
|
||||
"appTitle": "Preview your app"
|
||||
},
|
||||
"upgrade": {
|
||||
"title": "Craft requires a Business plan",
|
||||
"adminDescription": "Craft is available on the Business and Enterprise plans. It is not supported on the Community Edition until Craft merges into the main chat.",
|
||||
"userDescription": "Craft is available on the Business and Enterprise plans. Ask your admin to upgrade to use Craft.",
|
||||
"backToChat": {
|
||||
"label": "Back to chat"
|
||||
},
|
||||
"viewPlans": {
|
||||
"label": "View plans"
|
||||
}
|
||||
}
|
||||
},
|
||||
"knowledge": {
|
||||
@@ -14841,7 +14852,8 @@
|
||||
"title": "Agents"
|
||||
},
|
||||
"craft": {
|
||||
"label": "Craft"
|
||||
"label": "Craft",
|
||||
"businessTag": "Business"
|
||||
},
|
||||
"exploreAgents": {
|
||||
"label": "Explore Agents"
|
||||
|
||||
@@ -13800,6 +13800,17 @@
|
||||
"app": "Aplicación",
|
||||
"unavailable": "Ya no está disponible",
|
||||
"appTitle": "Vista previa de tu aplicación"
|
||||
},
|
||||
"upgrade": {
|
||||
"title": "Craft requiere un plan Business",
|
||||
"adminDescription": "Craft está disponible en los planes Business y Enterprise. No es compatible con Community Edition hasta que Craft se integre en el chat principal.",
|
||||
"userDescription": "Craft está disponible en los planes Business y Enterprise. Pide a tu administrador que actualice el plan para usar Craft.",
|
||||
"backToChat": {
|
||||
"label": "Volver al chat"
|
||||
},
|
||||
"viewPlans": {
|
||||
"label": "Ver planes"
|
||||
}
|
||||
}
|
||||
},
|
||||
"knowledge": {
|
||||
@@ -14841,7 +14852,8 @@
|
||||
"title": "Agentes"
|
||||
},
|
||||
"craft": {
|
||||
"label": "Craft"
|
||||
"label": "Craft",
|
||||
"businessTag": "Business"
|
||||
},
|
||||
"exploreAgents": {
|
||||
"label": "Explorar agentes"
|
||||
|
||||
@@ -13800,6 +13800,17 @@
|
||||
"app": "Application",
|
||||
"unavailable": "N’est plus disponible",
|
||||
"appTitle": "Aperçu de votre application"
|
||||
},
|
||||
"upgrade": {
|
||||
"title": "Craft nécessite un forfait Business",
|
||||
"adminDescription": "Craft est disponible avec les forfaits Business et Enterprise. Il n’est pas pris en charge dans la Community Edition tant que Craft n’est pas intégré au chat principal.",
|
||||
"userDescription": "Craft est disponible avec les forfaits Business et Enterprise. Demandez à votre administrateur de passer à un forfait supérieur pour utiliser Craft.",
|
||||
"backToChat": {
|
||||
"label": "Retour au chat"
|
||||
},
|
||||
"viewPlans": {
|
||||
"label": "Voir les forfaits"
|
||||
}
|
||||
}
|
||||
},
|
||||
"knowledge": {
|
||||
@@ -14841,7 +14852,8 @@
|
||||
"title": "Agents"
|
||||
},
|
||||
"craft": {
|
||||
"label": "Craft"
|
||||
"label": "Craft",
|
||||
"businessTag": "Business"
|
||||
},
|
||||
"exploreAgents": {
|
||||
"label": "Explorer les agents"
|
||||
|
||||
@@ -13800,6 +13800,17 @@
|
||||
"app": "アプリ",
|
||||
"unavailable": "利用できなくなりました",
|
||||
"appTitle": "アプリをプレビュー"
|
||||
},
|
||||
"upgrade": {
|
||||
"title": "Craft には Business プランが必要です",
|
||||
"adminDescription": "Craft は Business プランと Enterprise プランで利用できます。Craft がメインチャットに統合されるまで、Community Edition ではサポートされません。",
|
||||
"userDescription": "Craft は Business プランと Enterprise プランで利用できます。Craft を使用するには、管理者にアップグレードを依頼してください。",
|
||||
"backToChat": {
|
||||
"label": "チャットに戻る"
|
||||
},
|
||||
"viewPlans": {
|
||||
"label": "プランを表示"
|
||||
}
|
||||
}
|
||||
},
|
||||
"knowledge": {
|
||||
@@ -14841,7 +14852,8 @@
|
||||
"title": "エージェント"
|
||||
},
|
||||
"craft": {
|
||||
"label": "Craft"
|
||||
"label": "Craft",
|
||||
"businessTag": "Business"
|
||||
},
|
||||
"exploreAgents": {
|
||||
"label": "エージェントを探す"
|
||||
|
||||
@@ -13800,6 +13800,17 @@
|
||||
"app": "앱",
|
||||
"unavailable": "더 이상 사용할 수 없음",
|
||||
"appTitle": "앱 미리보기"
|
||||
},
|
||||
"upgrade": {
|
||||
"title": "Craft를 사용하려면 Business 플랜이 필요합니다",
|
||||
"adminDescription": "Craft는 Business 및 Enterprise 플랜에서 사용할 수 있습니다. Craft가 메인 채팅에 통합될 때까지 Community Edition에서는 지원되지 않습니다.",
|
||||
"userDescription": "Craft는 Business 및 Enterprise 플랜에서 사용할 수 있습니다. Craft를 사용하려면 관리자에게 업그레이드를 요청하세요.",
|
||||
"backToChat": {
|
||||
"label": "채팅으로 돌아가기"
|
||||
},
|
||||
"viewPlans": {
|
||||
"label": "플랜 보기"
|
||||
}
|
||||
}
|
||||
},
|
||||
"knowledge": {
|
||||
@@ -14841,7 +14852,8 @@
|
||||
"title": "에이전트"
|
||||
},
|
||||
"craft": {
|
||||
"label": "Craft"
|
||||
"label": "Craft",
|
||||
"businessTag": "Business"
|
||||
},
|
||||
"exploreAgents": {
|
||||
"label": "에이전트 둘러보기"
|
||||
|
||||
@@ -13800,6 +13800,17 @@
|
||||
"app": "Aplicativo",
|
||||
"unavailable": "Não está mais disponível",
|
||||
"appTitle": "Prévia do seu aplicativo"
|
||||
},
|
||||
"upgrade": {
|
||||
"title": "O Craft requer um plano Business",
|
||||
"adminDescription": "O Craft está disponível nos planos Business e Enterprise. Ele não é compatível com a Community Edition até que o Craft seja integrado ao chat principal.",
|
||||
"userDescription": "O Craft está disponível nos planos Business e Enterprise. Peça ao seu administrador para fazer o upgrade e usar o Craft.",
|
||||
"backToChat": {
|
||||
"label": "Voltar ao chat"
|
||||
},
|
||||
"viewPlans": {
|
||||
"label": "Ver planos"
|
||||
}
|
||||
}
|
||||
},
|
||||
"knowledge": {
|
||||
@@ -14841,7 +14852,8 @@
|
||||
"title": "Agentes"
|
||||
},
|
||||
"craft": {
|
||||
"label": "Craft"
|
||||
"label": "Craft",
|
||||
"businessTag": "Business"
|
||||
},
|
||||
"exploreAgents": {
|
||||
"label": "Explorar agentes"
|
||||
|
||||
@@ -13800,6 +13800,17 @@
|
||||
"app": "应用",
|
||||
"unavailable": "已不可用",
|
||||
"appTitle": "预览你的应用"
|
||||
},
|
||||
"upgrade": {
|
||||
"title": "Craft 需要商业版套餐",
|
||||
"adminDescription": "Craft 适用于商业版和企业版套餐。在 Craft 并入主聊天之前,社区版不支持 Craft。",
|
||||
"userDescription": "Craft 适用于商业版和企业版套餐。请联系管理员升级以使用 Craft。",
|
||||
"backToChat": {
|
||||
"label": "返回聊天"
|
||||
},
|
||||
"viewPlans": {
|
||||
"label": "查看套餐"
|
||||
}
|
||||
}
|
||||
},
|
||||
"knowledge": {
|
||||
@@ -14841,7 +14852,8 @@
|
||||
"title": "智能体"
|
||||
},
|
||||
"craft": {
|
||||
"label": "Craft"
|
||||
"label": "Craft",
|
||||
"businessTag": "商业版"
|
||||
},
|
||||
"exploreAgents": {
|
||||
"label": "探索智能体"
|
||||
|
||||
@@ -67,6 +67,9 @@ export interface Settings {
|
||||
// Gates visibility of the admin Craft-access controls.
|
||||
onyx_craft_available?: boolean;
|
||||
|
||||
// Craft is switched on but the tier is too low: show the upgrade prompt.
|
||||
onyx_craft_requires_upgrade?: boolean;
|
||||
|
||||
// Workspace default for Craft access; per-user overrides win.
|
||||
craft_default_enabled?: boolean;
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ import {
|
||||
showErrorNotification,
|
||||
} from "@/lib/sidebar/utils";
|
||||
import { handleMoveOperation } from "@/lib/sidebar/svc";
|
||||
import { SidebarTab } from "@opal/components";
|
||||
import { SidebarTab, Tag } from "@opal/components";
|
||||
import { ChatSession } from "@/app/app/interfaces";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import { getFirstPermittedAdminRoute } from "@/lib/permissions/utils";
|
||||
@@ -265,6 +265,9 @@ export default function AppSidebar() {
|
||||
// Check if Onyx Craft is enabled via settings (backed by PostHog feature flag)
|
||||
// Only explicit true enables the feature; false or undefined = disabled
|
||||
const isOnyxCraftEnabled = combinedSettingsData?.onyx_craft_enabled === true;
|
||||
const isOnyxCraftUpsell =
|
||||
!isOnyxCraftEnabled &&
|
||||
combinedSettingsData?.onyx_craft_requires_upgrade === true;
|
||||
|
||||
// Fetch notifications for build mode intro
|
||||
const { notifications, refresh: mutateNotifications } = useNotifications({
|
||||
@@ -625,12 +628,20 @@ export default function AppSidebar() {
|
||||
</SidebarTab>
|
||||
)}
|
||||
/>
|
||||
{isOnyxCraftEnabled && (
|
||||
{(isOnyxCraftEnabled || isOnyxCraftUpsell) && (
|
||||
<div data-testid="AppSidebar/build">
|
||||
<SidebarTab
|
||||
icon={SvgDevKit}
|
||||
href={CRAFT_PATH}
|
||||
onClick={() => track(AnalyticsEvent.CLICKED_CRAFT_IN_SIDEBAR)}
|
||||
rightChildren={
|
||||
isOnyxCraftUpsell ? (
|
||||
<Tag
|
||||
title={t("appSidebar.craft.businessTag")}
|
||||
color="blue"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{t("appSidebar.craft.label")}
|
||||
</SidebarTab>
|
||||
|
||||
Reference in new issue
Block a user