Compare commits

...

33 Commits

Author SHA1 Message Date
justin-tahara
2032b76fbf chore(release): Fixing Release Branch 2026-02-20 14:45:30 -08:00
Jamison Lahman
055b30b00e chore(fe): fix drop-down overflow in API Key modal (#8574) 2026-02-20 14:26:31 -08:00
Jamison Lahman
360a4cf591 chore(fe): remove close button from image gen tooltip (#8585) 2026-02-20 14:13:16 -08:00
Jamison Lahman
3d3cab9f91 fix(fe): popover width can fit trigger element (#8624) 2026-02-20 14:13:16 -08:00
Justin Tahara
6120d012ba feat(web): FE Changes for Brave Web Search 3/3 (#8597) 2026-02-20 11:29:02 -08:00
Evan Lohn
3e7e2e93f2 fix: search tool enabled when nothing selected 2026-02-20 11:05:46 -08:00
Justin Tahara
ccf482fa3b hotfix/web 2026-02-20 11:03:32 -08:00
Justin Tahara
fd45a612da feat(web): Initial Framework for Brave Web Search 1/3 (#8594) 2026-02-20 10:58:41 -08:00
Danelegend
c444d8883b fix: /llm/provider route returns all providers (#8545) 2026-02-20 10:48:56 -08:00
SubashMohan
9947837f9f fix: update SourceTag component to use variant prop for sizing (#8582) 2026-02-20 11:54:18 +05:30
SubashMohan
bc324a8070 fix(ui): fix few common ui bugs (#8425) 2026-02-20 11:54:04 +05:30
SubashMohan
26f648c24a fix(chatpage): Improve agent message layout, sidebar nesting, and icon fixes (#8224) 2026-02-20 10:49:23 +05:30
SubashMohan
638f20f5f3 fix(timeline): reduce agent message re-renders with referential stability in usePacedTurnGroups (#8265) 2026-02-20 10:49:04 +05:30
Jamison Lahman
f6ee57f523 chore(gha): rm nightly license scan workflow (#8541) 2026-02-19 20:03:58 -08:00
Justin Tahara
aae6fc7aac fix(desktop): Link clicking within App (#8493) 2026-02-19 17:44:32 -08:00
Justin Tahara
5d7a664250 fix(bedrock): Fixing toolConfig call (#8342) 2026-02-19 17:44:11 -08:00
Wenxi
e7386490bf fix(manage-users): exclude slack users from /users list (#8602) 2026-02-19 17:09:47 -08:00
Wenxi
106e10a143 fix: open_url broken on non-normalized urls and enable web crawl tests (#8508) 2026-02-19 17:09:47 -08:00
Wenxi
513f430a1b refactor: connector config refresh elements/cleanup (#8428) 2026-02-19 17:09:47 -08:00
Wenxi
696d73822f fix: remove log error when authtype is not set (#8399) 2026-02-19 17:09:47 -08:00
Wenxi
bfcc5a20a2 chore: make chatbackgrounds local assets for air-gapped envs (#8381) 2026-02-19 17:09:47 -08:00
Wenxi
efe3613354 fix: allow basic users to share agents (#8269) 2026-02-19 17:09:47 -08:00
Nikolas Garza
62405bdc42 fix(ee): small ux fixes for licensing (#8498) 2026-02-19 14:32:28 -08:00
Yuhong Sun
8f505dc45f chore: License update (No change, just touchup) (#8460) 2026-02-19 14:32:28 -08:00
Jessica Singh
75f0db4fe5 chore(bulk invite): free trial limit (#8378) 2026-02-19 14:32:28 -08:00
Nikolas Garza
f0a5c579a3 feat(auth): enforce seat limits on all user creation paths (#8401) 2026-02-19 14:32:28 -08:00
Nikolas Garza
293bf30847 fix(billing): exclude inactive users from seat counts and allow users page when gated (#8397) 2026-02-19 14:32:28 -08:00
Nikolas Garza
8774ca3b0f feat(ee): gate access only when legacy EE flag is set and no license exists (#8368) 2026-02-19 14:32:28 -08:00
Nikolas Garza
016a73f85f fix(ee): follow HTTP→HTTPS redirects in forward_to_control_plane (#8360) 2026-02-19 14:32:28 -08:00
Wenxi
2eddb4e23e fix: upgrade plan page nits (#8346) 2026-02-19 14:32:28 -08:00
Nikolas Garza
0a61660a59 fix(ee): copy license public key into Docker image (#8322) 2026-02-19 14:32:28 -08:00
Danelegend
a10599e76e fix: model config not populating flow during sync (#8542) 2026-02-18 17:11:52 -08:00
Nikolas Garza
b3d3f7af76 feat(ee): Enable license enforcement by default (#8270) 2026-02-09 20:43:33 -08:00
104 changed files with 2581 additions and 720 deletions

View File

@@ -1,151 +0,0 @@
# Scan for problematic software licenses
# trivy has their own rate limiting issues causing this action to flake
# we worked around it by hardcoding to different db repos in env
# can re-enable when they figure it out
# https://github.com/aquasecurity/trivy/discussions/7538
# https://github.com/aquasecurity/trivy-action/issues/389
name: 'Nightly - Scan licenses'
on:
# schedule:
# - cron: '0 14 * * *' # Runs every day at 6 AM PST / 7 AM PDT / 2 PM UTC
workflow_dispatch: # Allows manual triggering
permissions:
actions: read
contents: read
jobs:
scan-licenses:
# See https://runs-on.com/runners/linux/
runs-on: [runs-on,runner=2cpu-linux-x64,"run-id=${{ github.run_id }}-scan-licenses"]
timeout-minutes: 45
permissions:
actions: read
contents: read
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # ratchet:actions/setup-python@v6
with:
python-version: '3.11'
cache: 'pip'
cache-dependency-path: |
backend/requirements/default.txt
backend/requirements/dev.txt
backend/requirements/model_server.txt
- name: Get explicit and transitive dependencies
run: |
python -m pip install --upgrade pip
pip install --retries 5 --timeout 30 -r backend/requirements/default.txt
pip install --retries 5 --timeout 30 -r backend/requirements/dev.txt
pip install --retries 5 --timeout 30 -r backend/requirements/model_server.txt
pip freeze > requirements-all.txt
- name: Check python
id: license_check_report
uses: pilosus/action-pip-license-checker@e909b0226ff49d3235c99c4585bc617f49fff16a # ratchet:pilosus/action-pip-license-checker@v3
with:
requirements: 'requirements-all.txt'
fail: 'Copyleft'
exclude: '(?i)^(pylint|aio[-_]*).*'
- name: Print report
if: always()
env:
REPORT: ${{ steps.license_check_report.outputs.report }}
run: echo "$REPORT"
- name: Install npm dependencies
working-directory: ./web
run: npm ci
# be careful enabling the sarif and upload as it may spam the security tab
# with a huge amount of items. Work out the issues before enabling upload.
# - name: Run Trivy vulnerability scanner in repo mode
# if: always()
# uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # ratchet:aquasecurity/trivy-action@0.33.1
# with:
# scan-type: fs
# scan-ref: .
# scanners: license
# format: table
# severity: HIGH,CRITICAL
# # format: sarif
# # output: trivy-results.sarif
#
# # - name: Upload Trivy scan results to GitHub Security tab
# # uses: github/codeql-action/upload-sarif@v3
# # with:
# # sarif_file: trivy-results.sarif
scan-trivy:
# See https://runs-on.com/runners/linux/
runs-on: [runs-on,runner=2cpu-linux-x64,"run-id=${{ github.run_id }}-scan-trivy"]
timeout-minutes: 45
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # ratchet:docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # ratchet:docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
# Backend
- name: Pull backend docker image
run: docker pull onyxdotapp/onyx-backend:latest
- name: Run Trivy vulnerability scanner on backend
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # ratchet:aquasecurity/trivy-action@0.33.1
env:
TRIVY_DB_REPOSITORY: 'public.ecr.aws/aquasecurity/trivy-db:2'
TRIVY_JAVA_DB_REPOSITORY: 'public.ecr.aws/aquasecurity/trivy-java-db:1'
with:
image-ref: onyxdotapp/onyx-backend:latest
scanners: license
severity: HIGH,CRITICAL
vuln-type: library
exit-code: 0 # Set to 1 if we want a failed scan to fail the workflow
# Web server
- name: Pull web server docker image
run: docker pull onyxdotapp/onyx-web-server:latest
- name: Run Trivy vulnerability scanner on web server
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # ratchet:aquasecurity/trivy-action@0.33.1
env:
TRIVY_DB_REPOSITORY: 'public.ecr.aws/aquasecurity/trivy-db:2'
TRIVY_JAVA_DB_REPOSITORY: 'public.ecr.aws/aquasecurity/trivy-java-db:1'
with:
image-ref: onyxdotapp/onyx-web-server:latest
scanners: license
severity: HIGH,CRITICAL
vuln-type: library
exit-code: 0
# Model server
- name: Pull model server docker image
run: docker pull onyxdotapp/onyx-model-server:latest
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # ratchet:aquasecurity/trivy-action@0.33.1
env:
TRIVY_DB_REPOSITORY: 'public.ecr.aws/aquasecurity/trivy-db:2'
TRIVY_JAVA_DB_REPOSITORY: 'public.ecr.aws/aquasecurity/trivy-java-db:1'
with:
image-ref: onyxdotapp/onyx-model-server:latest
scanners: license
severity: HIGH,CRITICAL
vuln-type: library
exit-code: 0

View File

@@ -40,6 +40,9 @@ jobs:
- name: Generate OpenAPI schema and Python client
shell: bash
# TODO(Nik): https://linear.app/onyx-app/issue/ENG-1/update-test-infra-to-use-test-license
env:
LICENSE_ENFORCEMENT_ENABLED: "false"
run: |
ods openapi all

View File

@@ -302,6 +302,8 @@ jobs:
cat <<EOF > deployment/docker_compose/.env
COMPOSE_PROFILES=s3-filestore
ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true
# TODO(Nik): https://linear.app/onyx-app/issue/ENG-1/update-test-infra-to-use-test-license
LICENSE_ENFORCEMENT_ENABLED=false
AUTH_TYPE=basic
POSTGRES_POOL_PRE_PING=true
POSTGRES_USE_NULL_POOL=true
@@ -478,6 +480,7 @@ jobs:
run: |
cd deployment/docker_compose
ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true \
LICENSE_ENFORCEMENT_ENABLED=false \
MULTI_TENANT=true \
AUTH_TYPE=cloud \
REQUIRE_EMAIL_VERIFICATION=false \

View File

@@ -22,6 +22,9 @@ env:
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
GEN_AI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
EXA_API_KEY: ${{ secrets.EXA_API_KEY }}
FIRECRAWL_API_KEY: ${{ secrets.FIRECRAWL_API_KEY }}
GOOGLE_PSE_API_KEY: ${{ secrets.GOOGLE_PSE_API_KEY }}
GOOGLE_PSE_SEARCH_ENGINE_ID: ${{ secrets.GOOGLE_PSE_SEARCH_ENGINE_ID }}
# for federated slack tests
SLACK_CLIENT_ID: ${{ secrets.SLACK_CLIENT_ID }}
@@ -291,6 +294,8 @@ jobs:
cat <<EOF > deployment/docker_compose/.env
COMPOSE_PROFILES=s3-filestore
ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true
# TODO(Nik): https://linear.app/onyx-app/issue/ENG-1/update-test-infra-to-use-test-license
LICENSE_ENFORCEMENT_ENABLED=false
AUTH_TYPE=basic
GEN_AI_API_KEY=${OPENAI_API_KEY_VALUE}
EXA_API_KEY=${EXA_API_KEY_VALUE}

View File

@@ -42,6 +42,9 @@ jobs:
- name: Generate OpenAPI schema and Python client
shell: bash
# TODO(Nik): https://linear.app/onyx-app/issue/ENG-1/update-test-infra-to-use-test-license
env:
LICENSE_ENFORCEMENT_ENABLED: "false"
run: |
ods openapi all

View File

@@ -27,6 +27,8 @@ jobs:
PYTHONPATH: ./backend
REDIS_CLOUD_PYTEST_PASSWORD: ${{ secrets.REDIS_CLOUD_PYTEST_PASSWORD }}
DISABLE_TELEMETRY: "true"
# TODO(Nik): https://linear.app/onyx-app/issue/ENG-1/update-test-infra-to-use-test-license
LICENSE_ENFORCEMENT_ENABLED: "false"
steps:
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2

View File

@@ -2,7 +2,10 @@ Copyright (c) 2023-present DanswerAI, Inc.
Portions of this software are licensed as follows:
- All content that resides under "ee" directories of this repository, if that directory exists, is licensed under the license defined in "backend/ee/LICENSE". Specifically all content under "backend/ee" and "web/src/app/ee" is licensed under the license defined in "backend/ee/LICENSE".
- All content that resides under "ee" directories of this repository is licensed under the Onyx Enterprise License. Each ee directory contains an identical copy of this license at its root:
- backend/ee/LICENSE
- web/src/app/ee/LICENSE
- web/src/ee/LICENSE
- All third party components incorporated into the Onyx Software are licensed under the original license provided by the owner of the applicable component.
- Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.

View File

@@ -134,6 +134,7 @@ COPY --chown=onyx:onyx ./alembic_tenants /app/alembic_tenants
COPY --chown=onyx:onyx ./alembic.ini /app/alembic.ini
COPY supervisord.conf /usr/etc/supervisord.conf
COPY --chown=onyx:onyx ./static /app/static
COPY --chown=onyx:onyx ./keys /app/keys
# Escape hatch scripts
COPY --chown=onyx:onyx ./scripts/debugging /app/scripts/debugging

View File

@@ -1,20 +1,20 @@
The DanswerAI Enterprise license (the Enterprise License)
The Onyx Enterprise License (the "Enterprise License")
Copyright (c) 2023-present DanswerAI, Inc.
With regard to the Onyx Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the DanswerAI Subscription Terms of Service, available
at https://onyx.app/terms (the Enterprise Terms), or other
and are in compliance with, the Onyx Subscription Terms of Service, available
at https://www.onyx.app/legal/self-host (the "Enterprise Terms"), or other
agreement governing the use of the Software, as agreed by you and DanswerAI,
and otherwise have a valid Onyx Enterprise license for the
and otherwise have a valid Onyx Enterprise License for the
correct number of user seats. Subject to the foregoing sentence, you are free to
modify this Software and publish patches to the Software. You agree that DanswerAI
and/or its licensors (as applicable) retain all right, title and interest in and
to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid Onyx Enterprise license for the correct
exploited with a valid Onyx Enterprise License for the correct
number of user seats. Notwithstanding the foregoing, you may copy and modify
the Software for development and testing purposes, without requiring a
subscription. You agree that DanswerAI and/or its licensors (as applicable) retain

View File

@@ -134,7 +134,7 @@ GATED_TENANTS_KEY = "gated_tenants"
# License enforcement - when True, blocks API access for gated/expired licenses
LICENSE_ENFORCEMENT_ENABLED = (
os.environ.get("LICENSE_ENFORCEMENT_ENABLED", "").lower() == "true"
os.environ.get("LICENSE_ENFORCEMENT_ENABLED", "true").lower() == "true"
)
# Cloud data plane URL - self-hosted instances call this to reach cloud proxy endpoints

View File

@@ -263,9 +263,15 @@ def refresh_license_cache(
try:
payload = verify_license_signature(license_record.license_data)
# Derive source from payload: manual licenses lack stripe_customer_id
source: LicenseSource = (
LicenseSource.AUTO_FETCH
if payload.stripe_customer_id
else LicenseSource.MANUAL_UPLOAD
)
return update_license_cache(
payload,
source=LicenseSource.AUTO_FETCH,
source=source,
tenant_id=tenant_id,
)
except ValueError as e:

View File

@@ -109,7 +109,9 @@ async def _make_billing_request(
headers = _get_headers(license_data)
try:
async with httpx.AsyncClient(timeout=_REQUEST_TIMEOUT) as client:
async with httpx.AsyncClient(
timeout=_REQUEST_TIMEOUT, follow_redirects=True
) as client:
if method == "GET":
response = await client.get(url, headers=headers, params=params)
else:

View File

@@ -1,9 +1,13 @@
"""EE Settings API - provides license-aware settings override."""
from redis.exceptions import RedisError
from sqlalchemy.exc import SQLAlchemyError
from ee.onyx.configs.app_configs import LICENSE_ENFORCEMENT_ENABLED
from ee.onyx.db.license import get_cached_license_metadata
from ee.onyx.db.license import refresh_license_cache
from onyx.configs.app_configs import ENTERPRISE_EDITION_ENABLED
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.server.settings.models import ApplicationStatus
from onyx.server.settings.models import Settings
from onyx.utils.logger import setup_logger
@@ -40,6 +44,14 @@ def check_ee_features_enabled() -> bool:
tenant_id = get_current_tenant_id()
try:
metadata = get_cached_license_metadata(tenant_id)
if not metadata:
# Cache miss — warm from DB so cold-start doesn't block EE features
try:
with get_session_with_current_tenant() as db_session:
metadata = refresh_license_cache(db_session, tenant_id)
except SQLAlchemyError as db_error:
logger.warning(f"Failed to load license from DB: {db_error}")
if metadata and metadata.status != _BLOCKING_STATUS:
# Has a valid license (GRACE_PERIOD/PAYMENT_REMINDER still allow EE features)
return True
@@ -81,6 +93,18 @@ def apply_license_status_to_settings(settings: Settings) -> Settings:
tenant_id = get_current_tenant_id()
try:
metadata = get_cached_license_metadata(tenant_id)
if not metadata:
# Cache miss (e.g. after TTL expiry). Fall back to DB so
# the /settings request doesn't falsely return GATED_ACCESS
# while the cache is cold.
try:
with get_session_with_current_tenant() as db_session:
metadata = refresh_license_cache(db_session, tenant_id)
except SQLAlchemyError as db_error:
logger.warning(
f"Failed to load license from DB for settings: {db_error}"
)
if metadata:
if metadata.status == _BLOCKING_STATUS:
settings.application_status = metadata.status
@@ -89,7 +113,11 @@ def apply_license_status_to_settings(settings: Settings) -> Settings:
# Has a valid license (GRACE_PERIOD/PAYMENT_REMINDER still allow EE features)
settings.ee_features_enabled = True
else:
# No license = community edition, disable EE features
# No license found in cache or DB.
if ENTERPRISE_EDITION_ENABLED:
# Legacy EE flag is set → prior EE usage (e.g. permission
# syncing) means indexed data may need protection.
settings.application_status = _BLOCKING_STATUS
settings.ee_features_enabled = False
except RedisError as e:
logger.warning(f"Failed to check license metadata for settings: {e}")

View File

@@ -177,7 +177,7 @@ async def forward_to_control_plane(
url = f"{CONTROL_PLANE_API_BASE_URL}{path}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
if method == "GET":
response = await client.get(url, headers=headers, params=params)
elif method == "POST":

View File

@@ -12,12 +12,14 @@ from ee.onyx.db.user_group import prepare_user_group_for_deletion
from ee.onyx.db.user_group import update_user_curator_relationship
from ee.onyx.db.user_group import update_user_group
from ee.onyx.server.user_group.models import AddUsersToUserGroupRequest
from ee.onyx.server.user_group.models import MinimalUserGroupSnapshot
from ee.onyx.server.user_group.models import SetCuratorRequest
from ee.onyx.server.user_group.models import UserGroup
from ee.onyx.server.user_group.models import UserGroupCreate
from ee.onyx.server.user_group.models import UserGroupUpdate
from onyx.auth.users import current_admin_user
from onyx.auth.users import current_curator_or_admin_user
from onyx.auth.users import current_user
from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
@@ -45,6 +47,23 @@ def list_user_groups(
return [UserGroup.from_model(user_group) for user_group in user_groups]
@router.get("/user-groups/minimal")
def list_minimal_user_groups(
user: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> list[MinimalUserGroupSnapshot]:
if user.role == UserRole.ADMIN:
user_groups = fetch_user_groups(db_session, only_up_to_date=False)
else:
user_groups = fetch_user_groups_for_user(
db_session=db_session,
user_id=user.id,
)
return [
MinimalUserGroupSnapshot.from_model(user_group) for user_group in user_groups
]
@router.post("/admin/user-group")
def create_user_group(
user_group: UserGroupCreate,

View File

@@ -76,6 +76,18 @@ class UserGroup(BaseModel):
)
class MinimalUserGroupSnapshot(BaseModel):
id: int
name: str
@classmethod
def from_model(cls, user_group_model: UserGroupModel) -> "MinimalUserGroupSnapshot":
return cls(
id=user_group_model.id,
name=user_group_model.name,
)
class UserGroupCreate(BaseModel):
name: str
user_ids: list[UUID]

View File

@@ -60,6 +60,7 @@ from sqlalchemy import nulls_last
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from onyx.auth.api_key import get_hashed_api_key_from_request
from onyx.auth.disposable_email_validator import is_disposable_email
@@ -110,6 +111,7 @@ from onyx.db.auth import get_user_db
from onyx.db.auth import SQLAlchemyUserAdminDB
from onyx.db.engine.async_sql_engine import get_async_session
from onyx.db.engine.async_sql_engine import get_async_session_context_manager
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.engine.sql_engine import get_session_with_tenant
from onyx.db.models import AccessToken
from onyx.db.models import OAuthAccount
@@ -272,6 +274,22 @@ def verify_email_domain(email: str) -> None:
)
def enforce_seat_limit(db_session: Session, seats_needed: int = 1) -> None:
"""Raise HTTPException(402) if adding users would exceed the seat limit.
No-op for multi-tenant or CE deployments.
"""
if MULTI_TENANT:
return
result = fetch_ee_implementation_or_noop(
"onyx.db.license", "check_seat_availability", None
)(db_session, seats_needed=seats_needed)
if result is not None and not result.available:
raise HTTPException(status_code=402, detail=result.error_message)
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret = USER_AUTH_SECRET
verification_token_secret = USER_AUTH_SECRET
@@ -401,6 +419,12 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
):
user_create.role = UserRole.ADMIN
# Check seat availability for new users (single-tenant only)
with get_session_with_current_tenant() as sync_db:
existing = get_user_by_email(user_create.email, sync_db)
if existing is None:
enforce_seat_limit(sync_db)
user_created = False
try:
user = await super().create(user_create, safe=safe, request=request)
@@ -610,6 +634,10 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
raise exceptions.UserNotExists()
except exceptions.UserNotExists:
# Check seat availability before creating (single-tenant only)
with get_session_with_current_tenant() as sync_db:
enforce_seat_limit(sync_db)
password = self.password_helper.generate()
user_dict = {
"email": account_email,

View File

@@ -28,6 +28,7 @@ from onyx.configs.constants import MessageType
from onyx.context.search.models import SearchDoc
from onyx.context.search.models import SearchDocsResponse
from onyx.db.models import Persona
from onyx.llm.constants import LlmProviderNames
from onyx.llm.interfaces import LLM
from onyx.llm.interfaces import LLMUserIdentity
from onyx.llm.interfaces import ToolChoiceOptions
@@ -59,6 +60,28 @@ from shared_configs.contextvars import get_current_tenant_id
logger = setup_logger()
def _should_keep_bedrock_tool_definitions(
llm: object, simple_chat_history: list[ChatMessageSimple]
) -> bool:
"""Bedrock requires tool config when history includes toolUse/toolResult blocks."""
model_provider = getattr(getattr(llm, "config", None), "model_provider", None)
if model_provider not in {
LlmProviderNames.BEDROCK,
LlmProviderNames.BEDROCK_CONVERSE,
}:
return False
return any(
(
msg.message_type == MessageType.ASSISTANT
and msg.tool_calls
and len(msg.tool_calls) > 0
)
or msg.message_type == MessageType.TOOL_CALL_RESPONSE
for msg in simple_chat_history
)
def _try_fallback_tool_extraction(
llm_step_result: LlmStepResult,
tool_choice: ToolChoiceOptions,
@@ -454,7 +477,12 @@ def run_llm_loop(
elif out_of_cycles or ran_image_gen:
# Last cycle, no tools allowed, just answer!
tool_choice = ToolChoiceOptions.NONE
final_tools = []
# Bedrock requires tool config in requests that include toolUse/toolResult history.
final_tools = (
tools
if _should_keep_bedrock_tool_definitions(llm, simple_chat_history)
else []
)
else:
tool_choice = ToolChoiceOptions.AUTO
final_tools = tools

View File

@@ -75,7 +75,7 @@ WEB_DOMAIN = os.environ.get("WEB_DOMAIN") or "http://localhost:3000"
# Auth Configs
#####
# Upgrades users from disabled auth to basic auth and shows warning.
_auth_type_str = (os.environ.get("AUTH_TYPE") or "").lower()
_auth_type_str = (os.environ.get("AUTH_TYPE") or "basic").lower()
if _auth_type_str == "disabled":
logger.warning(
"AUTH_TYPE='disabled' is no longer supported. "
@@ -900,6 +900,9 @@ MANAGED_VESPA = os.environ.get("MANAGED_VESPA", "").lower() == "true"
ENABLE_EMAIL_INVITES = os.environ.get("ENABLE_EMAIL_INVITES", "").lower() == "true"
# Limit on number of users a free trial tenant can invite (cloud only)
NUM_FREE_TRIAL_USER_INVITES = int(os.environ.get("NUM_FREE_TRIAL_USER_INVITES", "10"))
# Security and authentication
DATA_PLANE_SECRET = os.environ.get(
"DATA_PLANE_SECRET", ""

View File

@@ -428,7 +428,7 @@ def fetch_existing_models(
def fetch_existing_llm_providers(
db_session: Session,
flow_types: list[LLMModelFlowType],
flow_type_filter: list[LLMModelFlowType],
only_public: bool = False,
exclude_image_generation_providers: bool = True,
) -> list[LLMProviderModel]:
@@ -436,30 +436,27 @@ def fetch_existing_llm_providers(
Args:
db_session: Database session
flow_types: List of flow types to filter by
flow_type_filter: List of flow types to filter by, empty list for no filter
only_public: If True, only return public providers
exclude_image_generation_providers: If True, exclude providers that are
used for image generation configs
"""
providers_with_flows = (
select(ModelConfiguration.llm_provider_id)
.join(LLMModelFlow)
.where(LLMModelFlow.llm_model_flow_type.in_(flow_types))
.distinct()
)
stmt = select(LLMProviderModel)
if flow_type_filter:
providers_with_flows = (
select(ModelConfiguration.llm_provider_id)
.join(LLMModelFlow)
.where(LLMModelFlow.llm_model_flow_type.in_(flow_type_filter))
.distinct()
)
stmt = stmt.where(LLMProviderModel.id.in_(providers_with_flows))
if exclude_image_generation_providers:
stmt = select(LLMProviderModel).where(
LLMProviderModel.id.in_(providers_with_flows)
)
else:
image_gen_provider_ids = select(ModelConfiguration.llm_provider_id).join(
ImageGenerationConfig
)
stmt = select(LLMProviderModel).where(
LLMProviderModel.id.in_(providers_with_flows)
| LLMProviderModel.id.in_(image_gen_provider_ids)
)
stmt = stmt.where(~LLMProviderModel.id.in_(image_gen_provider_ids))
stmt = stmt.options(
selectinload(LLMProviderModel.model_configurations),
@@ -722,13 +719,15 @@ def sync_auto_mode_models(
changes += 1
else:
# Add new model - all models from GitHub config are visible
new_model = ModelConfiguration(
insert_new_model_configuration__no_commit(
db_session=db_session,
llm_provider_id=provider.id,
name=model_config.name,
display_name=model_config.display_name,
model_name=model_config.name,
supported_flows=[LLMModelFlowType.CHAT],
is_visible=True,
max_input_tokens=None,
display_name=model_config.display_name,
)
db_session.add(new_model)
changes += 1
# In Auto mode, default model is always set from GitHub config

View File

@@ -255,7 +255,7 @@ def list_llm_providers(
llm_provider_list: list[LLMProviderView] = []
for llm_provider_model in fetch_existing_llm_providers(
db_session=db_session,
flow_types=[LLMModelFlowType.CHAT, LLMModelFlowType.VISION],
flow_type_filter=[],
exclude_image_generation_providers=not include_image_gen,
):
from_model_start = datetime.now(timezone.utc)
@@ -503,9 +503,7 @@ def list_llm_provider_basics(
start_time = datetime.now(timezone.utc)
logger.debug("Starting to fetch user-accessible LLM providers")
all_providers = fetch_existing_llm_providers(
db_session, [LLMModelFlowType.CHAT, LLMModelFlowType.VISION]
)
all_providers = fetch_existing_llm_providers(db_session, [])
user_group_ids = fetch_user_group_ids(db_session, user)
is_admin = user.role == UserRole.ADMIN

View File

@@ -30,12 +30,14 @@ from onyx.auth.users import anonymous_user_enabled
from onyx.auth.users import current_admin_user
from onyx.auth.users import current_curator_or_admin_user
from onyx.auth.users import current_user
from onyx.auth.users import enforce_seat_limit
from onyx.auth.users import optional_user
from onyx.configs.app_configs import AUTH_BACKEND
from onyx.configs.app_configs import AUTH_TYPE
from onyx.configs.app_configs import AuthBackend
from onyx.configs.app_configs import DEV_MODE
from onyx.configs.app_configs import ENABLE_EMAIL_INVITES
from onyx.configs.app_configs import NUM_FREE_TRIAL_USER_INVITES
from onyx.configs.app_configs import REDIS_AUTH_KEY_PREFIX
from onyx.configs.app_configs import SESSION_EXPIRE_TIME_SECONDS
from onyx.configs.app_configs import USER_AUTH_SECRET
@@ -90,6 +92,7 @@ from onyx.server.manage.models import UserSpecificAssistantPreferences
from onyx.server.models import FullUserSnapshot
from onyx.server.models import InvitedUserSnapshot
from onyx.server.models import MinimalUserSnapshot
from onyx.server.usage_limits import is_tenant_on_trial_fn
from onyx.server.utils import BasicAuthenticationError
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
@@ -391,14 +394,20 @@ def bulk_invite_users(
if e not in existing_users and e not in already_invited
]
# Limit bulk invites for trial tenants to prevent email spam
# Only count new invites, not re-invites of existing users
if MULTI_TENANT and is_tenant_on_trial_fn(tenant_id):
current_invited = len(already_invited)
if current_invited + len(emails_needing_seats) > NUM_FREE_TRIAL_USER_INVITES:
raise HTTPException(
status_code=403,
detail="You have hit your invite limit. "
"Please upgrade for unlimited invites.",
)
# Check seat availability for new users
# Only for self-hosted (non-multi-tenant) deployments
if not MULTI_TENANT and emails_needing_seats:
result = fetch_ee_implementation_or_noop(
"onyx.db.license", "check_seat_availability", None
)(db_session, seats_needed=len(emails_needing_seats))
if result is not None and not result.available:
raise HTTPException(status_code=402, detail=result.error_message)
if emails_needing_seats:
enforce_seat_limit(db_session, seats_needed=len(emails_needing_seats))
if MULTI_TENANT:
try:
@@ -414,10 +423,10 @@ def bulk_invite_users(
all_emails = list(set(new_invited_emails) | set(initial_invited_users))
number_of_invited_users = write_invited_users(all_emails)
# send out email invitations if enabled
# send out email invitations only to new users (not already invited or existing)
if ENABLE_EMAIL_INVITES:
try:
for email in new_invited_emails:
for email in emails_needing_seats:
send_user_email_invite(email, current_user, AUTH_TYPE)
except Exception as e:
logger.error(f"Error sending email invite to invited users: {e}")
@@ -564,12 +573,7 @@ def activate_user_api(
# Check seat availability before activating
# Only for self-hosted (non-multi-tenant) deployments
if not MULTI_TENANT:
result = fetch_ee_implementation_or_noop(
"onyx.db.license", "check_seat_availability", None
)(db_session, seats_needed=1)
if result is not None and not result.available:
raise HTTPException(status_code=402, detail=result.error_message)
enforce_seat_limit(db_session)
activate_user(user_to_activate, db_session)
@@ -593,11 +597,17 @@ def get_valid_domains(
@router.get("/users", tags=PUBLIC_API_TAGS)
def list_all_users_basic_info(
include_api_keys: bool = False,
_: User = Depends(current_user),
db_session: Session = Depends(get_session),
) -> list[MinimalUserSnapshot]:
users = get_all_users(db_session)
return [MinimalUserSnapshot(id=user.id, email=user.email) for user in users]
return [
MinimalUserSnapshot(id=user.id, email=user.email)
for user in users
if user.role != UserRole.SLACK_USER
and (include_api_keys or not is_api_key_email_address(user.email))
]
@router.get("/get-user-role", tags=PUBLIC_API_TAGS)

View File

@@ -47,6 +47,7 @@ from onyx.tools.tool_implementations.web_search.utils import (
from onyx.tools.tool_implementations.web_search.utils import MAX_CHARS_PER_URL
from onyx.utils.logger import setup_logger
from onyx.utils.threadpool_concurrency import run_functions_tuples_in_parallel
from onyx.utils.url import normalize_url as normalize_web_content_url
from shared_configs.configs import MULTI_TENANT
from shared_configs.contextvars import get_current_tenant_id
@@ -791,7 +792,9 @@ class OpenURLTool(Tool[OpenURLToolOverrideKwargs]):
for url in all_urls:
doc_id = url_to_doc_id.get(url)
indexed_section = indexed_by_doc_id.get(doc_id) if doc_id else None
crawled_section = crawled_by_url.get(url)
# WebContent.link is normalized (query/fragment stripped). Match on the
# same normalized form to avoid dropping successful crawl results.
crawled_section = crawled_by_url.get(normalize_web_content_url(url))
if indexed_section and indexed_section.combined_content:
# Prefer indexed

View File

@@ -0,0 +1,260 @@
from __future__ import annotations
from typing import Any
import requests
from fastapi import HTTPException
from onyx.tools.tool_implementations.web_search.models import (
WebSearchProvider,
)
from onyx.tools.tool_implementations.web_search.models import WebSearchResult
from onyx.utils.logger import setup_logger
from onyx.utils.retry_wrapper import retry_builder
logger = setup_logger()
BRAVE_WEB_SEARCH_URL = "https://api.search.brave.com/res/v1/web/search"
BRAVE_MAX_RESULTS_PER_REQUEST = 20
BRAVE_SAFESEARCH_OPTIONS = {"off", "moderate", "strict"}
BRAVE_FRESHNESS_OPTIONS = {"pd", "pw", "pm", "py"}
class RetryableBraveSearchError(Exception):
"""Error type used to trigger retry for transient Brave search failures."""
class BraveClient(WebSearchProvider):
def __init__(
self,
api_key: str,
*,
num_results: int = 10,
timeout_seconds: int = 10,
country: str | None = None,
search_lang: str | None = None,
ui_lang: str | None = None,
safesearch: str | None = None,
freshness: str | None = None,
) -> None:
if timeout_seconds <= 0:
raise ValueError("Brave provider config 'timeout_seconds' must be > 0.")
self._headers = {
"Accept": "application/json",
"X-Subscription-Token": api_key,
}
logger.debug(f"Count of results passed to BraveClient: {num_results}")
self._num_results = max(1, min(num_results, BRAVE_MAX_RESULTS_PER_REQUEST))
self._timeout_seconds = timeout_seconds
self._country = _normalize_country(country)
self._search_lang = _normalize_language_code(
search_lang, field_name="search_lang"
)
self._ui_lang = _normalize_language_code(ui_lang, field_name="ui_lang")
self._safesearch = _normalize_option(
safesearch,
field_name="safesearch",
allowed_values=BRAVE_SAFESEARCH_OPTIONS,
)
self._freshness = _normalize_option(
freshness,
field_name="freshness",
allowed_values=BRAVE_FRESHNESS_OPTIONS,
)
def _build_search_params(self, query: str) -> dict[str, str]:
params = {
"q": query,
"count": str(self._num_results),
}
if self._country:
params["country"] = self._country
if self._search_lang:
params["search_lang"] = self._search_lang
if self._ui_lang:
params["ui_lang"] = self._ui_lang
if self._safesearch:
params["safesearch"] = self._safesearch
if self._freshness:
params["freshness"] = self._freshness
return params
@retry_builder(
tries=3,
delay=1,
backoff=2,
exceptions=(RetryableBraveSearchError,),
)
def _search_with_retries(self, query: str) -> list[WebSearchResult]:
params = self._build_search_params(query)
try:
response = requests.get(
BRAVE_WEB_SEARCH_URL,
headers=self._headers,
params=params,
timeout=self._timeout_seconds,
)
except requests.RequestException as exc:
raise RetryableBraveSearchError(
f"Brave search request failed: {exc}"
) from exc
try:
response.raise_for_status()
except requests.HTTPError as exc:
error_msg = _build_error_message(response)
if _is_retryable_status(response.status_code):
raise RetryableBraveSearchError(error_msg) from exc
raise ValueError(error_msg) from exc
data = response.json()
web_results = (data.get("web") or {}).get("results") or []
results: list[WebSearchResult] = []
for result in web_results:
if not isinstance(result, dict):
continue
link = _clean_string(result.get("url"))
if not link:
continue
title = _clean_string(result.get("title"))
description = _clean_string(result.get("description"))
results.append(
WebSearchResult(
title=title,
link=link,
snippet=description,
author=None,
published_date=None,
)
)
return results
def search(self, query: str) -> list[WebSearchResult]:
try:
return self._search_with_retries(query)
except RetryableBraveSearchError as exc:
raise ValueError(str(exc)) from exc
def test_connection(self) -> dict[str, str]:
try:
test_results = self.search("test")
if not test_results or not any(result.link for result in test_results):
raise HTTPException(
status_code=400,
detail="Brave API key validation failed: search returned no results.",
)
except HTTPException:
raise
except (ValueError, requests.RequestException) as e:
error_msg = str(e)
lower = error_msg.lower()
if (
"status 401" in lower
or "status 403" in lower
or "api key" in lower
or "auth" in lower
):
raise HTTPException(
status_code=400,
detail=f"Invalid Brave API key: {error_msg}",
) from e
if "status 429" in lower or "rate limit" in lower:
raise HTTPException(
status_code=400,
detail=f"Brave API rate limit exceeded: {error_msg}",
) from e
raise HTTPException(
status_code=400,
detail=f"Brave API key validation failed: {error_msg}",
) from e
logger.info("Web search provider test succeeded for Brave.")
return {"status": "ok"}
def _build_error_message(response: requests.Response) -> str:
return (
"Brave search failed "
f"(status {response.status_code}): {_extract_error_detail(response)}"
)
def _extract_error_detail(response: requests.Response) -> str:
try:
payload: Any = response.json()
except Exception:
text = response.text.strip()
return text[:200] if text else "No error details"
if isinstance(payload, dict):
error = payload.get("error")
if isinstance(error, dict):
detail = error.get("detail") or error.get("message")
if isinstance(detail, str):
return detail
if isinstance(error, str):
return error
message = payload.get("message")
if isinstance(message, str):
return message
return str(payload)[:200]
def _is_retryable_status(status_code: int) -> bool:
return status_code == 429 or status_code >= 500
def _clean_string(value: Any) -> str:
return value.strip() if isinstance(value, str) else ""
def _normalize_country(country: str | None) -> str | None:
if country is None:
return None
normalized = country.strip().upper()
if not normalized:
return None
if len(normalized) != 2 or not normalized.isalpha():
raise ValueError(
"Brave provider config 'country' must be a 2-letter ISO country code."
)
return normalized
def _normalize_language_code(value: str | None, *, field_name: str) -> str | None:
if value is None:
return None
normalized = value.strip()
if not normalized:
return None
if len(normalized) > 20:
raise ValueError(f"Brave provider config '{field_name}' is too long.")
return normalized
def _normalize_option(
value: str | None,
*,
field_name: str,
allowed_values: set[str],
) -> str | None:
if value is None:
return None
normalized = value.strip().lower()
if not normalized:
return None
if normalized not in allowed_values:
allowed = ", ".join(sorted(allowed_values))
raise ValueError(
f"Brave provider config '{field_name}' must be one of: {allowed}."
)
return normalized

View File

@@ -13,6 +13,9 @@ from onyx.tools.tool_implementations.open_url.onyx_web_crawler import (
DEFAULT_MAX_PDF_SIZE_BYTES,
)
from onyx.tools.tool_implementations.open_url.onyx_web_crawler import OnyxWebCrawler
from onyx.tools.tool_implementations.web_search.clients.brave_client import (
BraveClient,
)
from onyx.tools.tool_implementations.web_search.clients.exa_client import (
ExaClient,
)
@@ -35,16 +38,76 @@ from shared_configs.enums import WebSearchProviderType
logger = setup_logger()
def _parse_positive_int_config(
*,
raw_value: str | None,
default: int,
provider_name: str,
config_key: str,
) -> int:
if not raw_value:
return default
try:
value = int(raw_value)
except ValueError as exc:
raise ValueError(
f"{provider_name} provider config '{config_key}' must be an integer."
) from exc
if value <= 0:
raise ValueError(
f"{provider_name} provider config '{config_key}' must be greater than 0."
)
return value
def provider_requires_api_key(provider_type: WebSearchProviderType) -> bool:
"""Return True if the given provider type requires an API key.
This list is most likely just going to contain SEARXNG. The way it works is that it uses public search engines that do not
require an API key. You can also set it up in a way which requires a key but SearXNG itself does not require a key.
"""
return provider_type != WebSearchProviderType.SEARXNG
def build_search_provider_from_config(
provider_type: WebSearchProviderType,
api_key: str,
api_key: str | None,
config: dict[str, str] | None, # TODO use a typed object
) -> WebSearchProvider:
config = config or {}
num_results = int(config.get("num_results") or DEFAULT_MAX_RESULTS)
# SearXNG does not require an API key
if provider_type == WebSearchProviderType.SEARXNG:
searxng_base_url = config.get("searxng_base_url")
if not searxng_base_url:
raise ValueError("Please provide a URL for your private SearXNG instance.")
return SearXNGClient(
searxng_base_url,
num_results=num_results,
)
# All other providers require an API key
if not api_key:
raise ValueError(f"API key is required for {provider_type.value} provider.")
if provider_type == WebSearchProviderType.EXA:
return ExaClient(api_key=api_key, num_results=num_results)
if provider_type == WebSearchProviderType.BRAVE:
return BraveClient(
api_key=api_key,
num_results=num_results,
timeout_seconds=_parse_positive_int_config(
raw_value=config.get("timeout_seconds"),
default=10,
provider_name="Brave",
config_key="timeout_seconds",
),
country=config.get("country"),
search_lang=config.get("search_lang"),
ui_lang=config.get("ui_lang"),
safesearch=config.get("safesearch"),
freshness=config.get("freshness"),
)
if provider_type == WebSearchProviderType.SERPER:
return SerperClient(api_key=api_key, num_results=num_results)
if provider_type == WebSearchProviderType.GOOGLE_PSE:
@@ -64,20 +127,13 @@ def build_search_provider_from_config(
num_results=num_results,
timeout_seconds=int(config.get("timeout_seconds") or 10),
)
if provider_type == WebSearchProviderType.SEARXNG:
searxng_base_url = config.get("searxng_base_url")
if not searxng_base_url:
raise ValueError("Please provide a URL for your private SearXNG instance.")
return SearXNGClient(
searxng_base_url,
num_results=num_results,
)
raise ValueError(f"Unknown provider type: {provider_type.value}")
def _build_search_provider(provider_model: InternetSearchProvider) -> WebSearchProvider:
return build_search_provider_from_config(
provider_type=WebSearchProviderType(provider_model.provider_type),
api_key=provider_model.api_key or "",
api_key=provider_model.api_key,
config=provider_model.config or {},
)

View File

@@ -36,7 +36,7 @@ global_version = OnyxVersion()
# Eventually, ENABLE_PAID_ENTERPRISE_EDITION_FEATURES will be removed
# and license enforcement will be the only mechanism for EE features.
_LICENSE_ENFORCEMENT_ENABLED = (
os.environ.get("LICENSE_ENFORCEMENT_ENABLED", "").lower() == "true"
os.environ.get("LICENSE_ENFORCEMENT_ENABLED", "true").lower() == "true"
)

View File

@@ -26,6 +26,7 @@ class WebSearchProviderType(str, Enum):
SERPER = "serper"
EXA = "exa"
SEARXNG = "searxng"
BRAVE = "brave"
class WebContentProviderType(str, Enum):

View File

@@ -553,7 +553,7 @@ class TestDefaultProviderEndpoint:
try:
existing_providers = fetch_existing_llm_providers(
db_session, flow_types=[LLMModelFlowType.CHAT]
db_session, flow_type_filter=[LLMModelFlowType.CHAT]
)
provider_names_to_restore: list[str] = []

View File

@@ -14,9 +14,12 @@ from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from onyx.db.enums import LLMModelFlowType
from onyx.db.llm import fetch_default_llm_model
from onyx.db.llm import fetch_existing_llm_provider
from onyx.db.llm import fetch_existing_llm_providers
from onyx.db.llm import remove_llm_provider
from onyx.db.llm import sync_auto_mode_models
from onyx.db.llm import update_default_provider
from onyx.db.models import UserRole
from onyx.llm.constants import LlmProviderNames
@@ -606,3 +609,95 @@ class TestAutoModeSyncFeature:
db_session.rollback()
_cleanup_provider(db_session, provider_1_name)
_cleanup_provider(db_session, provider_2_name)
class TestAutoModeMissingFlows:
"""Regression test: sync_auto_mode_models must create LLMModelFlow rows
for every ModelConfiguration it inserts, otherwise the provider vanishes
from listing queries that join through LLMModelFlow."""
def test_sync_auto_mode_creates_flow_rows(
self,
db_session: Session,
provider_name: str,
) -> None:
"""
Steps:
1. Create a provider with no model configs (empty shell).
2. Call sync_auto_mode_models to add models from a mock config.
3. Assert every new ModelConfiguration has at least one LLMModelFlow.
4. Assert fetch_existing_llm_providers (which joins through
LLMModelFlow) returns the provider.
"""
mock_recommendations = _create_mock_llm_recommendations(
provider=LlmProviderNames.OPENAI,
default_model_name="gpt-4o",
additional_models=["gpt-4o-mini"],
)
try:
# Step 1: Create provider with no model configs
put_llm_provider(
llm_provider_upsert_request=LLMProviderUpsertRequest(
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
is_auto_mode=True,
default_model_name="gpt-4o",
model_configurations=[],
),
is_creation=True,
_=_create_mock_admin(),
db_session=db_session,
)
# Step 2: Run sync_auto_mode_models (simulating the periodic sync)
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
sync_auto_mode_models(
db_session=db_session,
provider=provider,
llm_recommendations=mock_recommendations,
)
# Step 3: Every ModelConfiguration must have at least one LLMModelFlow
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
synced_model_names = {mc.name for mc in provider.model_configurations}
assert "gpt-4o" in synced_model_names
assert "gpt-4o-mini" in synced_model_names
for mc in provider.model_configurations:
assert len(mc.llm_model_flows) > 0, (
f"ModelConfiguration '{mc.name}' (id={mc.id}) has no "
f"LLMModelFlow rows — it will be invisible to listing queries"
)
flow_types = {f.llm_model_flow_type for f in mc.llm_model_flows}
assert (
LLMModelFlowType.CHAT in flow_types
), f"ModelConfiguration '{mc.name}' is missing a CHAT flow"
# Step 4: The provider must appear in fetch_existing_llm_providers
listed_providers = fetch_existing_llm_providers(
db_session=db_session,
flow_type_filter=[LLMModelFlowType.CHAT],
)
listed_provider_names = {p.name for p in listed_providers}
assert provider_name in listed_provider_names, (
f"Provider '{provider_name}' not returned by "
f"fetch_existing_llm_providers — models are missing flow rows"
)
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)

View File

@@ -17,7 +17,8 @@ COPY ./tests/* /app/tests/
FROM base AS openapi-schema
COPY ./scripts/onyx_openapi_schema.py /app/scripts/onyx_openapi_schema.py
RUN python scripts/onyx_openapi_schema.py --filename openapi.json
# TODO(Nik): https://linear.app/onyx-app/issue/ENG-1/update-test-infra-to-use-test-license
RUN LICENSE_ENFORCEMENT_ENABLED=false python scripts/onyx_openapi_schema.py --filename openapi.json
FROM openapitools/openapi-generator-cli:latest AS openapi-client
WORKDIR /local

View File

@@ -0,0 +1,155 @@
"""Integration tests for seat limit enforcement on user creation paths.
Verifies that when a license with a seat limit is active, new user
creation (registration, invite, reactivation) is blocked with HTTP 402.
"""
from datetime import datetime
from datetime import timedelta
import redis
import requests
from ee.onyx.server.license.models import LicenseMetadata
from ee.onyx.server.license.models import LicenseSource
from ee.onyx.server.license.models import PlanType
from onyx.configs.app_configs import REDIS_DB_NUMBER
from onyx.configs.app_configs import REDIS_HOST
from onyx.configs.app_configs import REDIS_PORT
from onyx.server.settings.models import ApplicationStatus
from tests.integration.common_utils.constants import API_SERVER_URL
from tests.integration.common_utils.constants import GENERAL_HEADERS
from tests.integration.common_utils.managers.user import UserManager
# TenantRedis prefixes every key with "{tenant_id}:".
# Single-tenant deployments use "public" as the tenant id.
_LICENSE_REDIS_KEY = "public:license:metadata"
def _seed_license(r: redis.Redis, seats: int) -> None:
"""Write a LicenseMetadata entry into Redis with the given seat cap."""
now = datetime.utcnow()
metadata = LicenseMetadata(
tenant_id="public",
organization_name="Test Org",
seats=seats,
used_seats=0, # check_seat_availability recalculates from DB
plan_type=PlanType.ANNUAL,
issued_at=now,
expires_at=now + timedelta(days=365),
status=ApplicationStatus.ACTIVE,
source=LicenseSource.MANUAL_UPLOAD,
)
r.set(_LICENSE_REDIS_KEY, metadata.model_dump_json(), ex=300)
def _clear_license(r: redis.Redis) -> None:
r.delete(_LICENSE_REDIS_KEY)
def _redis() -> redis.Redis:
return redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB_NUMBER)
# ------------------------------------------------------------------
# Registration
# ------------------------------------------------------------------
def test_registration_blocked_when_seats_full(reset: None) -> None: # noqa: ARG001
"""POST /auth/register returns 402 when the seat limit is reached."""
r = _redis()
# First user is admin — occupies 1 seat
UserManager.create(name="admin_user")
# License allows exactly 1 seat → already full
_seed_license(r, seats=1)
try:
response = requests.post(
url=f"{API_SERVER_URL}/auth/register",
json={
"email": "blocked@example.com",
"username": "blocked@example.com",
"password": "TestPassword123!",
},
headers=GENERAL_HEADERS,
)
assert response.status_code == 402
finally:
_clear_license(r)
# ------------------------------------------------------------------
# Invitation
# ------------------------------------------------------------------
def test_invite_blocked_when_seats_full(reset: None) -> None: # noqa: ARG001
"""PUT /manage/admin/users returns 402 when the seat limit is reached."""
r = _redis()
admin_user = UserManager.create(name="admin_user")
_seed_license(r, seats=1)
try:
response = requests.put(
url=f"{API_SERVER_URL}/manage/admin/users",
json={"emails": ["newuser@example.com"]},
headers=admin_user.headers,
)
assert response.status_code == 402
finally:
_clear_license(r)
# ------------------------------------------------------------------
# Reactivation
# ------------------------------------------------------------------
def test_reactivation_blocked_when_seats_full(reset: None) -> None: # noqa: ARG001
"""PATCH /manage/admin/activate-user returns 402 when seats are full."""
r = _redis()
admin_user = UserManager.create(name="admin_user")
basic_user = UserManager.create(name="basic_user")
# Deactivate the basic user (frees a seat in the DB count)
UserManager.set_status(
basic_user, target_status=False, user_performing_action=admin_user
)
# Set license to 1 seat — only admin counts now
_seed_license(r, seats=1)
try:
response = requests.patch(
url=f"{API_SERVER_URL}/manage/admin/activate-user",
json={"user_email": basic_user.email},
headers=admin_user.headers,
)
assert response.status_code == 402
finally:
_clear_license(r)
# ------------------------------------------------------------------
# No license → no enforcement
# ------------------------------------------------------------------
def test_registration_allowed_without_license(reset: None) -> None: # noqa: ARG001
"""Without a license in Redis, registration is unrestricted."""
r = _redis()
# Make sure there is no cached license
_clear_license(r)
UserManager.create(name="admin_user")
# Second user should register without issue
second_user = UserManager.create(name="second_user")
assert second_user is not None

View File

@@ -17,6 +17,7 @@ class TestOnyxWebCrawler:
content from public websites correctly.
"""
@pytest.mark.skip(reason="Temporarily disabled")
def test_fetches_public_url_successfully(self, admin_user: DATestUser) -> None:
"""Test that the crawler can fetch content from a public URL."""
response = requests.post(
@@ -40,6 +41,7 @@ class TestOnyxWebCrawler:
assert "This domain is for use in" in content
assert "documentation" in content or "illustrative" in content
@pytest.mark.skip(reason="Temporarily disabled")
def test_fetches_multiple_urls(self, admin_user: DATestUser) -> None:
"""Test that the crawler can fetch multiple URLs in one request."""
response = requests.post(

View File

@@ -101,6 +101,33 @@ class TestMakeBillingRequest:
assert exc_info.value.status_code == 400
assert "Bad request" in exc_info.value.message
@pytest.mark.asyncio
@patch("ee.onyx.server.billing.service._get_headers")
@patch("ee.onyx.server.billing.service._get_base_url")
async def test_follows_redirects(
self,
mock_base_url: MagicMock,
mock_headers: MagicMock,
) -> None:
"""AsyncClient must be created with follow_redirects=True.
The target server (cloud data plane for self-hosted, control
plane for cloud) may sit behind nginx that returns 308
(HTTP→HTTPS). httpx does not follow redirects by default,
so we must explicitly opt in.
"""
from ee.onyx.server.billing.service import _make_billing_request
mock_base_url.return_value = "http://api.example.com"
mock_headers.return_value = {"Authorization": "Bearer token"}
mock_response = make_mock_response({"ok": True})
mock_client = make_mock_http_client("get", response=mock_response)
with patch("httpx.AsyncClient", mock_client):
await _make_billing_request(method="GET", path="/test")
mock_client.assert_called_once_with(timeout=30.0, follow_redirects=True)
@pytest.mark.asyncio
@patch("ee.onyx.server.billing.service._get_headers")
@patch("ee.onyx.server.billing.service._get_base_url")

View File

@@ -51,7 +51,6 @@ class TestApplyLicenseStatusToSettings:
@pytest.mark.parametrize(
"license_status,expected_app_status,expected_ee_enabled",
[
(None, ApplicationStatus.ACTIVE, False),
(ApplicationStatus.GATED_ACCESS, ApplicationStatus.GATED_ACCESS, False),
(ApplicationStatus.ACTIVE, ApplicationStatus.ACTIVE, True),
],
@@ -84,6 +83,56 @@ class TestApplyLicenseStatusToSettings:
assert result.application_status == expected_app_status
assert result.ee_features_enabled is expected_ee_enabled
@patch("ee.onyx.server.settings.api.ENTERPRISE_EDITION_ENABLED", True)
@patch("ee.onyx.server.settings.api.LICENSE_ENFORCEMENT_ENABLED", True)
@patch("ee.onyx.server.settings.api.MULTI_TENANT", False)
@patch("ee.onyx.server.settings.api.refresh_license_cache", return_value=None)
@patch("ee.onyx.server.settings.api.get_session_with_current_tenant")
@patch("ee.onyx.server.settings.api.get_current_tenant_id")
@patch("ee.onyx.server.settings.api.get_cached_license_metadata")
def test_no_license_with_ee_flag_gates_access(
self,
mock_get_metadata: MagicMock,
mock_get_tenant: MagicMock,
_mock_get_session: MagicMock,
_mock_refresh: MagicMock,
base_settings: Settings,
) -> None:
"""No license + ENTERPRISE_EDITION_ENABLED=true → GATED_ACCESS."""
from ee.onyx.server.settings.api import apply_license_status_to_settings
mock_get_tenant.return_value = "test_tenant"
mock_get_metadata.return_value = None
result = apply_license_status_to_settings(base_settings)
assert result.application_status == ApplicationStatus.GATED_ACCESS
assert result.ee_features_enabled is False
@patch("ee.onyx.server.settings.api.ENTERPRISE_EDITION_ENABLED", False)
@patch("ee.onyx.server.settings.api.LICENSE_ENFORCEMENT_ENABLED", True)
@patch("ee.onyx.server.settings.api.MULTI_TENANT", False)
@patch("ee.onyx.server.settings.api.refresh_license_cache", return_value=None)
@patch("ee.onyx.server.settings.api.get_session_with_current_tenant")
@patch("ee.onyx.server.settings.api.get_current_tenant_id")
@patch("ee.onyx.server.settings.api.get_cached_license_metadata")
def test_no_license_without_ee_flag_allows_community(
self,
mock_get_metadata: MagicMock,
mock_get_tenant: MagicMock,
_mock_get_session: MagicMock,
_mock_refresh: MagicMock,
base_settings: Settings,
) -> None:
"""No license + ENTERPRISE_EDITION_ENABLED=false → community mode (no gating)."""
from ee.onyx.server.settings.api import apply_license_status_to_settings
mock_get_tenant.return_value = "test_tenant"
mock_get_metadata.return_value = None
result = apply_license_status_to_settings(base_settings)
assert result.application_status == ApplicationStatus.ACTIVE
assert result.ee_features_enabled is False
@patch("ee.onyx.server.settings.api.LICENSE_ENFORCEMENT_ENABLED", True)
@patch("ee.onyx.server.settings.api.MULTI_TENANT", False)
@patch("ee.onyx.server.settings.api.get_current_tenant_id")

View File

@@ -427,6 +427,37 @@ class TestForwardToControlPlane:
assert exc_info.value.status_code == 502
assert "Failed to connect to control plane" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_follows_redirects(self) -> None:
"""Test that AsyncClient is created with follow_redirects=True.
The control plane may sit behind a reverse proxy that returns
308 (HTTP→HTTPS). httpx does not follow redirects by default,
so we must explicitly opt in.
"""
mock_response = MagicMock()
mock_response.json.return_value = {"ok": True}
mock_response.raise_for_status = MagicMock()
with (
patch(
"ee.onyx.server.tenants.proxy.generate_data_plane_token"
) as mock_token,
patch("ee.onyx.server.tenants.proxy.httpx.AsyncClient") as mock_client,
patch(
"ee.onyx.server.tenants.proxy.CONTROL_PLANE_API_BASE_URL",
"http://control.example.com",
),
):
mock_token.return_value = "cp_token"
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
return_value=mock_response
)
await forward_to_control_plane("GET", "/test")
mock_client.assert_called_once_with(timeout=30.0, follow_redirects=True)
@pytest.mark.asyncio
async def test_unsupported_method(self) -> None:
"""Test that unsupported HTTP methods raise ValueError."""

View File

@@ -384,6 +384,29 @@ class TestWhitelistBehavior:
verify_email_is_invited("Allowed@Example.Com")
class TestSeatLimitEnforcement:
"""Seat limits block new user creation on self-hosted deployments."""
def test_adding_user_fails_when_seats_full(self) -> None:
from onyx.auth.users import enforce_seat_limit
seat_result = MagicMock(available=False, error_message="Seat limit reached")
with patch(
"onyx.auth.users.fetch_ee_implementation_or_noop",
return_value=lambda *_a, **_kw: seat_result,
):
with pytest.raises(HTTPException) as exc:
enforce_seat_limit(MagicMock())
assert exc.value.status_code == 402
def test_seat_limit_only_enforced_for_self_hosted(self) -> None:
from onyx.auth.users import enforce_seat_limit
with patch("onyx.auth.users.MULTI_TENANT", True):
enforce_seat_limit(MagicMock()) # should not raise
class TestCaseInsensitiveEmailMatching:
"""Test case-insensitive email matching for existing user checks."""

View File

@@ -2,6 +2,7 @@
import pytest
from onyx.chat.llm_loop import _should_keep_bedrock_tool_definitions
from onyx.chat.llm_loop import construct_message_history
from onyx.chat.models import ChatLoadedFile
from onyx.chat.models import ChatMessageSimple
@@ -10,6 +11,17 @@ from onyx.chat.models import ProjectFileMetadata
from onyx.chat.models import ToolCallSimple
from onyx.configs.constants import MessageType
from onyx.file_store.models import ChatFileType
from onyx.llm.constants import LlmProviderNames
class _StubConfig:
def __init__(self, model_provider: str) -> None:
self.model_provider = model_provider
class _StubLLM:
def __init__(self, model_provider: str) -> None:
self.config = _StubConfig(model_provider=model_provider)
def create_message(
@@ -568,3 +580,34 @@ class TestConstructMessageHistory:
assert '"contents"' in project_message.message
assert "Project file 0 content" in project_message.message
assert "Project file 1 content" in project_message.message
class TestBedrockToolConfigGuard:
def test_bedrock_with_tool_history_keeps_tool_definitions(self) -> None:
llm = _StubLLM(LlmProviderNames.BEDROCK)
history = [
create_message("Question", MessageType.USER, 5),
create_assistant_with_tool_call("tc_1", "search", 5),
create_tool_response("tc_1", "Tool output", 5),
]
assert _should_keep_bedrock_tool_definitions(llm, history) is True
def test_bedrock_without_tool_history_does_not_keep_tool_definitions(self) -> None:
llm = _StubLLM(LlmProviderNames.BEDROCK)
history = [
create_message("Question", MessageType.USER, 5),
create_message("Answer", MessageType.ASSISTANT, 5),
]
assert _should_keep_bedrock_tool_definitions(llm, history) is False
def test_non_bedrock_with_tool_history_does_not_keep_tool_definitions(self) -> None:
llm = _StubLLM(LlmProviderNames.OPENAI)
history = [
create_message("Question", MessageType.USER, 5),
create_assistant_with_tool_call("tc_1", "search", 5),
create_tool_response("tc_1", "Tool output", 5),
]
assert _should_keep_bedrock_tool_definitions(llm, history) is False

View File

@@ -0,0 +1,47 @@
"""Test bulk invite limit for free trial tenants."""
from unittest.mock import patch
import pytest
from fastapi import HTTPException
from onyx.server.manage.users import bulk_invite_users
@patch("onyx.server.manage.users.MULTI_TENANT", True)
@patch("onyx.server.manage.users.is_tenant_on_trial_fn", return_value=True)
@patch("onyx.server.manage.users.get_current_tenant_id", return_value="test_tenant")
@patch("onyx.server.manage.users.get_invited_users", return_value=[])
@patch("onyx.server.manage.users.get_all_users", return_value=[])
@patch("onyx.server.manage.users.NUM_FREE_TRIAL_USER_INVITES", 5)
def test_trial_tenant_cannot_exceed_invite_limit(*_mocks: None) -> None:
"""Trial tenants cannot invite more users than the configured limit."""
emails = [f"user{i}@example.com" for i in range(6)]
with pytest.raises(HTTPException) as exc_info:
bulk_invite_users(emails=emails)
assert exc_info.value.status_code == 403
assert "invite limit" in exc_info.value.detail.lower()
@patch("onyx.server.manage.users.MULTI_TENANT", True)
@patch("onyx.server.manage.users.DEV_MODE", True)
@patch("onyx.server.manage.users.ENABLE_EMAIL_INVITES", False)
@patch("onyx.server.manage.users.is_tenant_on_trial_fn", return_value=True)
@patch("onyx.server.manage.users.get_current_tenant_id", return_value="test_tenant")
@patch("onyx.server.manage.users.get_invited_users", return_value=[])
@patch("onyx.server.manage.users.get_all_users", return_value=[])
@patch("onyx.server.manage.users.write_invited_users", return_value=3)
@patch("onyx.server.manage.users.NUM_FREE_TRIAL_USER_INVITES", 5)
@patch(
"onyx.server.manage.users.fetch_ee_implementation_or_noop",
return_value=lambda *_args: None,
)
def test_trial_tenant_can_invite_within_limit(*_mocks: None) -> None:
"""Trial tenants can invite users when under the limit."""
emails = ["user1@example.com", "user2@example.com", "user3@example.com"]
result = bulk_invite_users(emails=emails)
assert result == 3

View File

@@ -0,0 +1,206 @@
from __future__ import annotations
from typing import Any
from typing import cast
import pytest
import requests
from fastapi import HTTPException
import onyx.tools.tool_implementations.web_search.clients.brave_client as brave_module
from onyx.tools.tool_implementations.web_search.clients.brave_client import (
BraveClient,
)
class DummyResponse:
def __init__(
self,
*,
status_code: int,
payload: dict[str, Any] | None = None,
text: str = "",
) -> None:
self.status_code = status_code
self._payload = payload
self.text = text
def raise_for_status(self) -> None:
if self.status_code >= 400:
http_error = requests.HTTPError(f"{self.status_code} Client Error")
http_error.response = cast(requests.Response, self)
raise http_error
def json(self) -> dict[str, Any]:
if self._payload is None:
raise ValueError("No JSON payload")
return self._payload
def test_search_maps_brave_response(monkeypatch: pytest.MonkeyPatch) -> None:
client = BraveClient(api_key="test-key", num_results=5)
def _mock_get(*args: Any, **kwargs: Any) -> DummyResponse: # noqa: ARG001
return DummyResponse(
status_code=200,
payload={
"web": {
"results": [
{
"title": "Result 1",
"url": "https://example.com/one",
"description": "Snippet 1",
},
{
"title": "Result without URL",
"description": "Should be skipped",
},
]
}
},
)
monkeypatch.setattr(brave_module.requests, "get", _mock_get)
results = client.search("onyx")
assert len(results) == 1
assert results[0].title == "Result 1"
assert results[0].link == "https://example.com/one"
assert results[0].snippet == "Snippet 1"
def test_search_caps_count_to_brave_max(monkeypatch: pytest.MonkeyPatch) -> None:
client = BraveClient(api_key="test-key", num_results=100)
captured_count: str | None = None
def _mock_get(*args: Any, **kwargs: Any) -> DummyResponse: # noqa: ARG001
nonlocal captured_count
captured_count = kwargs["params"]["count"]
return DummyResponse(status_code=200, payload={"web": {"results": []}})
monkeypatch.setattr(brave_module.requests, "get", _mock_get)
client.search("onyx")
assert captured_count == "20"
def test_search_includes_optional_params(monkeypatch: pytest.MonkeyPatch) -> None:
client = BraveClient(
api_key="test-key",
num_results=5,
country="us",
search_lang="en",
ui_lang="en-US",
safesearch="moderate",
freshness="pw",
)
captured_params: dict[str, str] | None = None
def _mock_get(*args: Any, **kwargs: Any) -> DummyResponse: # noqa: ARG001
nonlocal captured_params
captured_params = kwargs["params"]
return DummyResponse(status_code=200, payload={"web": {"results": []}})
monkeypatch.setattr(brave_module.requests, "get", _mock_get)
client.search("onyx")
assert captured_params is not None
assert captured_params["country"] == "US"
assert captured_params["search_lang"] == "en"
assert captured_params["ui_lang"] == "en-US"
assert captured_params["safesearch"] == "moderate"
assert captured_params["freshness"] == "pw"
def test_search_raises_descriptive_error_on_http_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client = BraveClient(api_key="test-key", num_results=5)
def _mock_get(*args: Any, **kwargs: Any) -> DummyResponse: # noqa: ARG001
return DummyResponse(
status_code=401,
payload={"error": {"message": "Unauthorized"}},
)
monkeypatch.setattr(brave_module.requests, "get", _mock_get)
with pytest.raises(ValueError, match="status 401"):
client.search("onyx")
def test_search_does_not_retry_non_retryable_http_errors(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client = BraveClient(api_key="test-key", num_results=5)
calls = 0
def _mock_get(*args: Any, **kwargs: Any) -> DummyResponse: # noqa: ARG001
nonlocal calls
calls += 1
return DummyResponse(
status_code=401,
payload={"error": {"message": "Unauthorized"}},
)
monkeypatch.setattr(brave_module.requests, "get", _mock_get)
with pytest.raises(ValueError, match="status 401"):
client.search("onyx")
assert calls == 1
@pytest.mark.parametrize(
("kwargs", "expected_error"),
[
({"country": "USA"}, "country"),
({"safesearch": "invalid"}, "safesearch"),
({"freshness": "invalid"}, "freshness"),
({"timeout_seconds": 0}, "timeout_seconds"),
],
)
def test_constructor_rejects_invalid_config_values(
kwargs: dict[str, Any],
expected_error: str,
) -> None:
with pytest.raises(ValueError, match=expected_error):
BraveClient(api_key="test-key", **kwargs)
def test_test_connection_maps_invalid_key_errors() -> None:
client = BraveClient(api_key="test-key")
def _mock_search(query: str) -> list[Any]: # noqa: ARG001
raise ValueError("Brave search failed (status 401): Unauthorized")
client.search = _mock_search # type: ignore[method-assign]
with pytest.raises(HTTPException, match="Invalid Brave API key"):
client.test_connection()
def test_test_connection_maps_rate_limit_errors() -> None:
client = BraveClient(api_key="test-key")
def _mock_search(query: str) -> list[Any]: # noqa: ARG001
raise ValueError("Brave search failed (status 429): Too many requests")
client.search = _mock_search # type: ignore[method-assign]
with pytest.raises(HTTPException, match="rate limit exceeded"):
client.test_connection()
def test_test_connection_propagates_unexpected_errors() -> None:
client = BraveClient(api_key="test-key")
def _mock_search(query: str) -> list[Any]: # noqa: ARG001
raise RuntimeError("unexpected parsing bug")
client.search = _mock_search # type: ignore[method-assign]
with pytest.raises(RuntimeError, match="unexpected parsing bug"):
client.test_connection()

View File

@@ -0,0 +1,122 @@
import pytest
from onyx.tools.tool_implementations.web_search.clients.brave_client import (
BraveClient,
)
from onyx.tools.tool_implementations.web_search.providers import (
build_search_provider_from_config,
)
from onyx.tools.tool_implementations.web_search.providers import (
provider_requires_api_key,
)
from shared_configs.enums import WebSearchProviderType
def test_provider_requires_api_key() -> None:
"""Test that provider_requires_api_key correctly identifies which providers need API keys."""
assert provider_requires_api_key(WebSearchProviderType.EXA) is True
assert provider_requires_api_key(WebSearchProviderType.BRAVE) is True
assert provider_requires_api_key(WebSearchProviderType.SERPER) is True
assert provider_requires_api_key(WebSearchProviderType.GOOGLE_PSE) is True
assert provider_requires_api_key(WebSearchProviderType.SEARXNG) is False
def test_build_searxng_provider_without_api_key() -> None:
"""Test that SearXNG provider can be built without an API key."""
provider = build_search_provider_from_config(
provider_type=WebSearchProviderType.SEARXNG,
api_key=None,
config={"searxng_base_url": "http://localhost:8080"},
)
assert provider is not None
def test_build_searxng_provider_requires_base_url() -> None:
"""Test that SearXNG provider requires a base URL."""
with pytest.raises(ValueError, match="Please provide a URL"):
build_search_provider_from_config(
provider_type=WebSearchProviderType.SEARXNG,
api_key=None,
config={},
)
def test_build_exa_provider_requires_api_key() -> None:
"""Test that Exa provider requires an API key."""
with pytest.raises(ValueError, match="API key is required"):
build_search_provider_from_config(
provider_type=WebSearchProviderType.EXA,
api_key=None,
config={},
)
def test_build_brave_provider_requires_api_key() -> None:
"""Test that Brave provider requires an API key."""
with pytest.raises(ValueError, match="API key is required"):
build_search_provider_from_config(
provider_type=WebSearchProviderType.BRAVE,
api_key=None,
config={},
)
def test_build_brave_provider_with_optional_config() -> None:
provider = build_search_provider_from_config(
provider_type=WebSearchProviderType.BRAVE,
api_key="test-api-key",
config={
"country": "us",
"search_lang": "en",
"ui_lang": "en-US",
"safesearch": "strict",
"freshness": "pm",
"timeout_seconds": "12",
},
)
assert isinstance(provider, BraveClient)
assert provider._country == "US" # noqa: SLF001
assert provider._search_lang == "en" # noqa: SLF001
assert provider._ui_lang == "en-US" # noqa: SLF001
assert provider._safesearch == "strict" # noqa: SLF001
assert provider._freshness == "pm" # noqa: SLF001
assert provider._timeout_seconds == 12 # noqa: SLF001
def test_build_brave_provider_rejects_invalid_timeout() -> None:
with pytest.raises(ValueError, match="timeout_seconds"):
build_search_provider_from_config(
provider_type=WebSearchProviderType.BRAVE,
api_key="test-api-key",
config={"timeout_seconds": "not-an-int"},
)
def test_build_serper_provider_requires_api_key() -> None:
"""Test that Serper provider requires an API key."""
with pytest.raises(ValueError, match="API key is required"):
build_search_provider_from_config(
provider_type=WebSearchProviderType.SERPER,
api_key=None,
config={},
)
def test_build_google_pse_provider_requires_api_key() -> None:
"""Test that Google PSE provider requires an API key."""
with pytest.raises(ValueError, match="API key is required"):
build_search_provider_from_config(
provider_type=WebSearchProviderType.GOOGLE_PSE,
api_key=None,
config={"search_engine_id": "test-cx"},
)
def test_build_google_pse_provider_requires_search_engine_id() -> None:
"""Test that Google PSE provider requires a search engine ID."""
with pytest.raises(ValueError, match="search engine id"):
build_search_provider_from_config(
provider_type=WebSearchProviderType.GOOGLE_PSE,
api_key="test-api-key",
config={},
)

View File

@@ -29,6 +29,8 @@ services:
- MODEL_SERVER_PORT=${MODEL_SERVER_PORT:-}
- ENV_SEED_CONFIGURATION=${ENV_SEED_CONFIGURATION:-}
- ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=True
# TODO(Nik): https://linear.app/onyx-app/issue/ENG-1/update-test-infra-to-use-test-license
- LICENSE_ENFORCEMENT_ENABLED=false
# MinIO configuration
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
- S3_AWS_ACCESS_KEY_ID=${S3_AWS_ACCESS_KEY_ID:-minioadmin}
@@ -66,6 +68,8 @@ services:
- INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server}
- ENV_SEED_CONFIGURATION=${ENV_SEED_CONFIGURATION:-}
- ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=True
# TODO(Nik): https://linear.app/onyx-app/issue/ENG-1/update-test-infra-to-use-test-license
- LICENSE_ENFORCEMENT_ENABLED=false
# MinIO configuration
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
- S3_AWS_ACCESS_KEY_ID=${S3_AWS_ACCESS_KEY_ID:-minioadmin}

View File

@@ -20,9 +20,9 @@ use tauri::Wry;
use tauri::{
webview::PageLoadPayload, AppHandle, Manager, Webview, WebviewUrl, WebviewWindowBuilder,
};
use url::Url;
#[cfg(target_os = "macos")]
use tokio::time::sleep;
use url::Url;
#[cfg(target_os = "macos")]
use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial};
@@ -40,6 +40,136 @@ const TRAY_MENU_OPEN_APP_ID: &str = "tray_open_app";
const TRAY_MENU_OPEN_CHAT_ID: &str = "tray_open_chat";
const TRAY_MENU_SHOW_IN_BAR_ID: &str = "tray_show_in_menu_bar";
const TRAY_MENU_QUIT_ID: &str = "tray_quit";
const CHAT_LINK_INTERCEPT_SCRIPT: &str = r##"
(() => {
if (window.__ONYX_CHAT_LINK_INTERCEPT_INSTALLED__) {
return;
}
window.__ONYX_CHAT_LINK_INTERCEPT_INSTALLED__ = true;
function isChatSessionPage() {
try {
const currentUrl = new URL(window.location.href);
return (
currentUrl.pathname.startsWith("/app") &&
currentUrl.searchParams.has("chatId")
);
} catch {
return false;
}
}
function getAllowedNavigationUrl(rawUrl) {
try {
const parsed = new URL(String(rawUrl), window.location.href);
const scheme = parsed.protocol.toLowerCase();
if (!["http:", "https:", "mailto:", "tel:"].includes(scheme)) {
return null;
}
return parsed;
} catch {
return null;
}
}
async function openWithTauri(url) {
try {
const invoke =
window.__TAURI__?.core?.invoke || window.__TAURI_INTERNALS__?.invoke;
if (typeof invoke !== "function") {
return false;
}
await invoke("open_in_browser", { url });
return true;
} catch {
return false;
}
}
function handleChatNavigation(rawUrl) {
const parsedUrl = getAllowedNavigationUrl(rawUrl);
if (!parsedUrl) {
return false;
}
const safeUrl = parsedUrl.toString();
const scheme = parsedUrl.protocol.toLowerCase();
if (scheme === "mailto:" || scheme === "tel:") {
void openWithTauri(safeUrl).then((opened) => {
if (!opened) {
window.location.assign(safeUrl);
}
});
return true;
}
window.location.assign(safeUrl);
return true;
}
document.addEventListener(
"click",
(event) => {
if (!isChatSessionPage() || event.defaultPrevented) {
return;
}
const element = event.target;
if (!(element instanceof Element)) {
return;
}
const anchor = element.closest("a");
if (!(anchor instanceof HTMLAnchorElement)) {
return;
}
const target = (anchor.getAttribute("target") || "").toLowerCase();
if (target !== "_blank") {
return;
}
const href = anchor.getAttribute("href");
if (!href || href.startsWith("#")) {
return;
}
if (!handleChatNavigation(href)) {
return;
}
event.preventDefault();
event.stopPropagation();
},
true
);
const nativeWindowOpen = window.open;
window.open = function(url, target, features) {
const resolvedTarget = typeof target === "string" ? target.toLowerCase() : "";
const shouldNavigateInPlace = resolvedTarget === "" || resolvedTarget === "_blank";
if (
isChatSessionPage() &&
shouldNavigateInPlace &&
url != null &&
String(url).length > 0
) {
if (!handleChatNavigation(url)) {
return null;
}
return null;
}
if (typeof nativeWindowOpen === "function") {
return nativeWindowOpen.call(window, url, target, features);
}
return null;
};
})();
"##;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
@@ -177,22 +307,7 @@ fn trigger_new_window(app: &AppHandle) {
}
fn open_docs() {
let url = "https://docs.onyx.app";
#[cfg(target_os = "macos")]
{
let _ = Command::new("open").arg(url).status();
}
#[cfg(target_os = "linux")]
{
let _ = Command::new("xdg-open").arg(url).status();
}
#[cfg(target_os = "windows")]
{
let _ = Command::new("rundll32")
.arg("url.dll,FileProtocolHandler")
.arg(url)
.status();
}
let _ = open_in_default_browser("https://docs.onyx.app");
}
fn open_settings(app: &AppHandle) {
@@ -219,6 +334,68 @@ fn open_settings(app: &AppHandle) {
}
}
fn same_origin(left: &Url, right: &Url) -> bool {
left.scheme() == right.scheme()
&& left.host_str() == right.host_str()
&& left.port_or_known_default() == right.port_or_known_default()
}
fn is_chat_session_url(url: &Url) -> bool {
url.path().starts_with("/app") && url.query_pairs().any(|(key, _)| key == "chatId")
}
fn should_open_in_external_browser(current_url: &Url, destination_url: &Url) -> bool {
if !is_chat_session_url(current_url) {
return false;
}
match destination_url.scheme() {
"mailto" | "tel" => true,
"http" | "https" => !same_origin(current_url, destination_url),
_ => false,
}
}
fn open_in_default_browser(url: &str) -> bool {
#[cfg(target_os = "macos")]
{
return Command::new("open").arg(url).status().is_ok();
}
#[cfg(target_os = "linux")]
{
return Command::new("xdg-open").arg(url).status().is_ok();
}
#[cfg(target_os = "windows")]
{
return Command::new("rundll32")
.arg("url.dll,FileProtocolHandler")
.arg(url)
.status()
.is_ok();
}
#[allow(unreachable_code)]
false
}
#[tauri::command]
fn open_in_browser(url: String) -> Result<(), String> {
let parsed_url = Url::parse(&url).map_err(|_| "Invalid URL".to_string())?;
match parsed_url.scheme() {
"http" | "https" | "mailto" | "tel" => {}
_ => return Err("Unsupported URL scheme".to_string()),
}
if open_in_default_browser(parsed_url.as_str()) {
Ok(())
} else {
Err("Failed to open URL in default browser".to_string())
}
}
fn inject_chat_link_intercept(webview: &Webview) {
let _ = webview.eval(CHAT_LINK_INTERCEPT_SCRIPT);
}
// ============================================================================
// Tauri Commands
// ============================================================================
@@ -240,8 +417,8 @@ struct BootstrapState {
fn get_bootstrap_state(state: tauri::State<ConfigState>) -> BootstrapState {
let server_url = state.config.read().unwrap().server_url.clone();
let config_initialized = *state.config_initialized.read().unwrap();
let config_exists = config_initialized
&& get_config_path().map(|path| path.exists()).unwrap_or(false);
let config_exists =
config_initialized && get_config_path().map(|path| path.exists()).unwrap_or(false);
BootstrapState {
server_url,
@@ -462,7 +639,13 @@ fn setup_app_menu(app: &AppHandle) -> tauri::Result<()> {
true,
Some("CmdOrCtrl+Shift+N"),
)?;
let settings_item = MenuItem::with_id(app, "open_settings", "Settings...", true, Some("CmdOrCtrl+Comma"))?;
let settings_item = MenuItem::with_id(
app,
"open_settings",
"Settings...",
true,
Some("CmdOrCtrl+Comma"),
)?;
let docs_item = MenuItem::with_id(app, "open_docs", "Onyx Documentation", true, None::<&str>)?;
if let Some(file_menu) = menu
@@ -501,13 +684,7 @@ fn setup_app_menu(app: &AppHandle) -> tauri::Result<()> {
}
fn build_tray_menu(app: &AppHandle) -> tauri::Result<Menu<Wry>> {
let open_app = MenuItem::with_id(
app,
TRAY_MENU_OPEN_APP_ID,
"Open Onyx",
true,
None::<&str>,
)?;
let open_app = MenuItem::with_id(app, TRAY_MENU_OPEN_APP_ID, "Open Onyx", true, None::<&str>)?;
let open_chat = MenuItem::with_id(
app,
TRAY_MENU_OPEN_CHAT_ID,
@@ -598,6 +775,27 @@ fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(
tauri::plugin::Builder::<Wry>::new("chat-external-navigation-handler")
.on_navigation(|webview, destination_url| {
let Ok(current_url) = webview.url() else {
return true;
};
if should_open_in_external_browser(&current_url, destination_url) {
if !open_in_default_browser(destination_url.as_str()) {
eprintln!(
"Failed to open external URL in default browser: {}",
destination_url
);
}
return false;
}
true
})
.build(),
)
.plugin(tauri_plugin_window_state::Builder::default().build())
.manage(ConfigState {
config: RwLock::new(config),
@@ -609,6 +807,7 @@ fn main() {
get_bootstrap_state,
set_server_url,
get_config_path_cmd,
open_in_browser,
open_config_file,
open_config_directory,
navigate_to,
@@ -661,10 +860,12 @@ fn main() {
Ok(())
})
.on_page_load(|_webview: &Webview, _payload: &PageLoadPayload| {
.on_page_load(|webview: &Webview, _payload: &PageLoadPayload| {
inject_chat_link_intercept(webview);
// Re-inject titlebar after every navigation/page load (macOS only)
#[cfg(target_os = "macos")]
let _ = _webview.eval(TITLEBAR_SCRIPT);
let _ = webview.eval(TITLEBAR_SCRIPT);
})
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -10,7 +10,7 @@ const SvgCircle = ({ size, ...props }: IconProps) => (
stroke="currentColor"
{...props}
>
<circle cx="8" cy="8" r="6" strokeWidth={1.5} />
<circle cx="8" cy="8" r="4" strokeWidth={1.5} />
</svg>
);
export default SvgCircle;

View File

@@ -0,0 +1,21 @@
import type { IconProps } from "@opal/types";
const SvgDashboard = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M14 6V3.33333C14 2.59695 13.403 2 12.6667 2H3.33333C2.59695 2 2 2.59695 2 3.33333V6M14 6V12.6667C14 13.403 13.403 14 12.6667 14H6M14 6H6M2 6V12.6667C2 13.403 2.59695 14 3.33333 14H6M2 6H6M6 6V14"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgDashboard;

View File

@@ -0,0 +1,21 @@
import type { IconProps } from "@opal/types";
const SvgHistory = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M7.99998 4.00001V8.00001L11 9.50003M1.33332 1.40151V5.23535M1.33332 5.23535H4.99998M1.33332 5.23535L3.28593 3.28597C4.49236 2.07954 6.15903 1.33334 7.99998 1.33334C11.6819 1.33334 14.6667 4.31811 14.6667 8.00001C14.6667 11.6819 11.6819 14.6667 7.99998 14.6667C4.83386 14.6667 2.18324 12.4596 1.50274 9.50003"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgHistory;

View File

@@ -50,6 +50,7 @@ export { default as SvgCode } from "@opal/icons/code";
export { default as SvgCopy } from "@opal/icons/copy";
export { default as SvgCornerRightUpDot } from "@opal/icons/corner-right-up-dot";
export { default as SvgCpu } from "@opal/icons/cpu";
export { default as SvgDashboard } from "@opal/icons/dashboard";
export { default as SvgDevKit } from "@opal/icons/dev-kit";
export { default as SvgDownload } from "@opal/icons/download";
export { default as SvgDiscordMono } from "@opal/icons/DiscordMono";
@@ -76,6 +77,7 @@ export { default as SvgHardDrive } from "@opal/icons/hard-drive";
export { default as SvgHashSmall } from "@opal/icons/hash-small";
export { default as SvgHash } from "@opal/icons/hash";
export { default as SvgHeadsetMic } from "@opal/icons/headset-mic";
export { default as SvgHistory } from "@opal/icons/history";
export { default as SvgHourglass } from "@opal/icons/hourglass";
export { default as SvgImage } from "@opal/icons/image";
export { default as SvgImageSmall } from "@opal/icons/image-small";
@@ -155,6 +157,7 @@ export { default as SvgTwoLineSmall } from "@opal/icons/two-line-small";
export { default as SvgUnplug } from "@opal/icons/unplug";
export { default as SvgUploadCloud } from "@opal/icons/upload-cloud";
export { default as SvgUser } from "@opal/icons/user";
export { default as SvgUserManage } from "@opal/icons/user-manage";
export { default as SvgUserPlus } from "@opal/icons/user-plus";
export { default as SvgUsers } from "@opal/icons/users";
export { default as SvgWallet } from "@opal/icons/wallet";

View File

@@ -0,0 +1,21 @@
import type { IconProps } from "@opal/types";
const SvgUserManage = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 15 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M0.75 12.75C0.75 12.4167 0.75 12.0833 0.75 11.75C0.750002 10.0931 2.09316 8.75 3.75002 8.75L5.75 8.75M12.25 11.25L13.2981 12.2981M12.25 11.25C12.5916 10.9084 12.7499 10.4481 12.75 10.0004M12.25 11.25C11.9083 11.5917 11.4479 11.75 11 11.75M9.75 11.25L8.7019 12.2981M9.75 11.25C10.0917 11.5917 10.5521 11.75 11 11.75M9.75 11.25C9.4084 10.9084 9.25011 10.4481 9.25 10.0004M9.75 8.75L8.7019 7.70193M9.75 8.75C10.0917 8.40829 10.5521 8.25 11 8.25M9.75 8.75C9.40818 9.09182 9.24989 9.55242 9.25 10.0004M12.25 8.75L13.2981 7.70193M12.25 8.75C12.5918 9.09182 12.7501 9.55242 12.75 10.0004M12.25 8.75C11.9083 8.40829 11.4479 8.25 11 8.25M12.75 10.0004L14.25 10M11 13.25V11.75M11 6.75V8.25M7.75 10L9.25 10.0004M8.5 3.5C8.5 5.01878 7.26878 6.25 5.75 6.25C4.23122 6.25 3 5.01878 3 3.5C3 1.98122 4.23122 0.75 5.75 0.75C7.26878 0.75 8.5 1.98122 8.5 3.5Z"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgUserManage;

33
web/public/Brave.svg Normal file
View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="218px" height="256px" viewBox="0 0 218 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 51.2 (57519) - http://www.bohemiancoding.com/sketch -->
<title>build-icons/Stable Copy 3</title>
<desc>Created with Sketch.</desc>
<defs>
<linearGradient x1="0%" y1="50.7055163%" x2="100%" y2="50.7055163%" id="linearGradient-1">
<stop stop-color="#FF5500" offset="0%"></stop>
<stop stop-color="#FF5500" offset="40.9877232%"></stop>
<stop stop-color="#FF2000" offset="58.1981215%"></stop>
<stop stop-color="#FF2000" offset="100%"></stop>
</linearGradient>
<linearGradient x1="2.1484375%" y1="50.7055163%" x2="100%" y2="50.7055163%" id="linearGradient-2">
<stop stop-color="#FF452A" offset="0%"></stop>
<stop stop-color="#FF2000" offset="100%"></stop>
</linearGradient>
<path d="M170.272109,25.3359387 L147.968109,0.00010893617 L108.800109,0.00010893617 L69.6321088,0.00010893617 L47.3281088,25.3359387 C47.3281088,25.3359387 27.7441088,19.8891302 18.4961088,29.1487047 C18.4961088,29.1487047 44.6081088,26.7886026 53.5841088,41.4040238 C53.5841088,41.4040238 77.7921088,46.0338111 81.0561088,46.0338111 C84.3201088,46.0338111 91.3921088,43.3104068 97.9201088,41.1316834 C104.448109,38.95296 108.800109,38.9371643 108.800109,38.9371643 C108.800109,38.9371643 113.152109,38.95296 119.680109,41.1316834 C126.208109,43.3104068 133.280109,46.0338111 136.544109,46.0338111 C139.808109,46.0338111 164.016109,41.4040238 164.016109,41.4040238 C172.992109,26.7886026 199.104109,29.1487047 199.104109,29.1487047 C189.856109,19.8891302 170.272109,25.3359387 170.272109,25.3359387" id="path-3"></path>
</defs>
<g id="starting-collection" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Build-Icons" transform="translate(-70.000000, -350.000000)">
<g id="build-icons/Stable" transform="translate(50.000000, 350.000000)">
<g id="Logo" transform="translate(20.114286, 0.000000)">
<path d="M209.984109,61.2848749 L215.968109,46.5784919 C215.968109,46.5784919 208.352109,38.4082791 199.104109,29.1487047 C189.856109,19.8891302 170.272109,25.3359387 170.272109,25.3359387 L147.968109,0.00010893617 L108.800109,0.00010893617 L69.6321088,0.00010893617 L47.3281088,25.3359387 C47.3281088,25.3359387 27.7441088,19.8891302 18.4961088,29.1487047 C9.2481088,38.4082791 1.6321088,46.5784919 1.6321088,46.5784919 L7.6161088,61.2848749 L0.0001088,83.0721089 C0.0001088,83.0721089 22.3993088,168.017811 25.0241088,178.391258 C30.1921088,198.81679 33.7281088,206.714662 48.4161088,217.063598 C63.1041088,227.412534 89.7601088,245.387003 94.1121088,248.110407 C98.4641088,250.833811 103.904109,255.472858 108.800109,255.472858 C113.696109,255.472858 119.136109,250.833811 123.488109,248.110407 C127.840109,245.387003 154.496109,227.412534 169.184109,217.063598 C183.872109,206.714662 187.408109,198.81679 192.576109,178.391258 C195.200365,168.017811 217.600109,83.0721089 217.600109,83.0721089 L209.984109,61.2848749 Z" id="Head" fill="url(#linearGradient-1)"></path>
<path d="M164.016109,41.4040238 C164.016109,41.4040238 192.704493,76.1274281 192.704493,83.5487047 C192.704493,90.9699813 189.095597,92.9286536 185.467117,96.7866281 C181.838637,100.644603 165.991373,117.49376 163.956269,119.657232 C161.921165,121.821249 157.684493,125.101862 160.176557,131.006747 C162.668621,136.911632 166.344973,144.425505 162.256813,152.046679 C158.168109,159.667854 151.164109,164.754628 146.676109,163.913641 C142.188109,163.072109 131.648109,157.557215 127.772109,155.038066 C123.896109,152.518917 111.611501,142.374781 111.611501,138.493386 C111.611501,134.612534 124.310093,127.643888 126.655821,126.0605 C129.002637,124.477658 139.703117,118.349454 139.922349,115.944143 C140.141037,113.538288 140.057805,112.832926 136.899885,106.889369 C133.741965,100.945266 128.054989,93.0136238 129.001549,87.7362111 C129.948109,82.459343 139.119949,79.7152409 145.665357,77.2402111 C152.209677,74.765726 164.811437,70.0918196 166.385229,69.3652153 C167.960109,68.6380664 167.553197,67.945777 162.783949,67.4931472 C158.015789,67.0405174 144.483245,65.2419813 138.382285,66.9446536 C132.281325,68.647326 121.858285,71.2378281 121.013997,72.6115132 C120.169709,73.9851983 119.424973,74.0314962 120.292109,78.7702196 C121.158701,83.508943 125.622765,106.24719 126.055789,110.285998 C126.489357,114.324807 127.336909,116.994832 122.987629,117.990509 C118.637805,118.986186 111.316109,120.715003 108.800109,120.715003 C106.284109,120.715003 98.9618688,118.986186 94.6125888,117.990509 C90.2627648,116.994832 91.1103168,114.324807 91.5438848,110.285998 C91.9774528,106.24719 96.4409728,83.508943 97.3081088,78.7702196 C98.1747008,74.0314962 97.4299648,73.9851983 96.5862208,72.6115132 C95.7419328,71.2378281 85.3183488,68.647326 79.2173888,66.9446536 C73.1164288,65.2419813 59.5844288,67.0405174 54.8157248,67.4931472 C50.0470208,67.945777 49.6401088,68.6380664 51.2144448,69.3652153 C52.7887808,70.0918196 65.3905408,74.765726 71.9348608,77.2402111 C78.4797248,79.7152409 87.6521088,82.459343 88.5986688,87.7362111 C89.5452288,93.0136238 83.8577088,100.945266 80.7003328,106.889369 C77.5424128,112.832926 77.4586368,113.538288 77.6778688,115.944143 C77.8965568,118.349454 88.5975808,124.477658 90.9438528,126.0605 C93.2901248,127.643888 105.988173,134.612534 105.988173,138.493386 C105.988173,142.374781 93.7041088,152.518917 89.8281088,155.038066 C85.9521088,157.557215 75.4121088,163.072109 70.9241088,163.913641 C66.4361088,164.754628 59.4321088,159.667854 55.3434048,152.046679 C51.2552448,144.425505 54.9315968,136.911632 57.4231168,131.006747 C59.9151808,125.101862 55.6790528,121.821249 53.6434048,119.657232 C51.6088448,117.49376 35.7610368,100.644603 32.1325568,96.7866281 C28.5040768,92.9286536 24.8957248,90.9699813 24.8957248,83.5487047 C24.8957248,76.1274281 53.5841088,41.4040238 53.5841088,41.4040238 C53.5841088,41.4040238 77.7921088,46.0338111 81.0561088,46.0338111 C84.3201088,46.0338111 91.3921088,43.3104068 97.9201088,41.1316834 C104.448109,38.95296 108.800109,38.9371643 108.800109,38.9371643 C108.800109,38.9371643 113.152109,38.95296 119.680109,41.1316834 C126.208109,43.3104068 133.280109,46.0338111 136.544109,46.0338111 C139.808109,46.0338111 164.016109,41.4040238 164.016109,41.4040238 Z M142.509504,174.227935 C144.28512,175.341263 143.202016,177.439918 141.584704,178.584837 C139.966848,179.729757 118.228064,196.584361 116.118432,198.447169 C114.008256,200.310523 110.908,203.387425 108.8,203.387425 C106.692,203.387425 103.5912,200.310523 101.481568,198.447169 C99.371392,196.584361 77.633152,179.729757 76.015296,178.584837 C74.39744,177.439918 73.31488,175.341263 75.090496,174.227935 C76.866656,173.115152 82.422528,170.306233 90.08912,166.333876 C97.754624,162.362063 107.308896,158.985042 108.8,158.985042 C110.291104,158.985042 119.844832,162.362063 127.511424,166.333876 C135.177472,170.306233 140.733344,173.115152 142.509504,174.227935 Z" id="Face" fill="#FFFFFF"></path>
<mask id="mask-4" fill="white">
<use xlink:href="#path-3"></use>
</mask>
<use id="Top-Head" fill="url(#linearGradient-2)" xlink:href="#path-3"></use>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -1,13 +1,17 @@
import { Form, Formik } from "formik";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { SelectorFormField, TextFormField } from "@/components/Field";
import { createApiKey, updateApiKey } from "./lib";
import Modal from "@/refresh-components/Modal";
import Button from "@/refresh-components/buttons/Button";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import InputComboBox from "@/refresh-components/inputs/InputComboBox";
import { FormikField } from "@/refresh-components/form/FormikField";
import { FormField } from "@/refresh-components/form/FormField";
import { USER_ROLE_LABELS, UserRole } from "@/lib/types";
import { APIKey } from "./types";
import { SvgKey } from "@opal/icons";
export interface OnyxApiKeyFormProps {
onClose: () => void;
setPopup: (popupSpec: PopupSpec | null) => void;
@@ -31,91 +35,120 @@ export default function OnyxApiKeyForm({
title={isUpdate ? "Update API Key" : "Create a new API Key"}
onClose={onClose}
/>
<Modal.Body>
<Formik
initialValues={{
name: apiKey?.api_key_name || "",
role: apiKey?.api_key_role || UserRole.BASIC.toString(),
}}
onSubmit={async (values, formikHelpers) => {
formikHelpers.setSubmitting(true);
<Formik
initialValues={{
name: apiKey?.api_key_name || "",
role: apiKey?.api_key_role || UserRole.BASIC.toString(),
}}
onSubmit={async (values, formikHelpers) => {
formikHelpers.setSubmitting(true);
// Prepare the payload with the UserRole
const payload = {
...values,
role: values.role as UserRole, // Assign the role directly as a UserRole type
};
// Prepare the payload with the UserRole
const payload = {
...values,
role: values.role as UserRole, // Assign the role directly as a UserRole type
};
let response;
if (isUpdate) {
response = await updateApiKey(apiKey.api_key_id, payload);
} else {
response = await createApiKey(payload);
let response;
if (isUpdate) {
response = await updateApiKey(apiKey.api_key_id, payload);
} else {
response = await createApiKey(payload);
}
formikHelpers.setSubmitting(false);
if (response.ok) {
setPopup({
message: isUpdate
? "Successfully updated API key!"
: "Successfully created API key!",
type: "success",
});
if (!isUpdate) {
onCreateApiKey(await response.json());
}
formikHelpers.setSubmitting(false);
if (response.ok) {
setPopup({
message: isUpdate
? "Successfully updated API key!"
: "Successfully created API key!",
type: "success",
});
if (!isUpdate) {
onCreateApiKey(await response.json());
}
onClose();
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
setPopup({
message: isUpdate
? `Error updating API key - ${errorMsg}`
: `Error creating API key - ${errorMsg}`,
type: "error",
});
}
}}
>
{({ isSubmitting }) => (
<Form className="w-full overflow-visible">
onClose();
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
setPopup({
message: isUpdate
? `Error updating API key - ${errorMsg}`
: `Error creating API key - ${errorMsg}`,
type: "error",
});
}
}}
>
{({ isSubmitting }) => (
<Form className="w-full overflow-visible">
<Modal.Body>
<Text as="p">
Choose a memorable name for your API key. This is optional and
can be added or changed later!
</Text>
<TextFormField name="name" label="Name (optional):" />
<SelectorFormField
// defaultValue is managed by Formik
label="Role:"
subtext="Select the role for this API key.
Limited has access to simple public API's.
Basic has access to regular user API's.
Admin has access to admin level APIs."
name="role"
options={[
{
name: USER_ROLE_LABELS[UserRole.LIMITED],
value: UserRole.LIMITED.toString(),
},
{
name: USER_ROLE_LABELS[UserRole.BASIC],
value: UserRole.BASIC.toString(),
},
{
name: USER_ROLE_LABELS[UserRole.ADMIN],
value: UserRole.ADMIN.toString(),
},
]}
<FormikField<string>
name="name"
render={(field, helper, _meta, state) => (
<FormField name="name" state={state} className="w-full">
<FormField.Label>Name (optional):</FormField.Label>
<FormField.Control>
<InputTypeIn
{...field}
placeholder=""
onClear={() => helper.setValue("")}
showClearButton={false}
/>
</FormField.Control>
</FormField>
)}
/>
<FormikField<string>
name="role"
render={(field, helper, _meta, state) => (
<FormField name="role" state={state} className="w-full">
<FormField.Label>Role:</FormField.Label>
<FormField.Control>
<InputComboBox
value={field.value}
onValueChange={(value) => helper.setValue(value)}
options={[
{
label: USER_ROLE_LABELS[UserRole.LIMITED],
value: UserRole.LIMITED.toString(),
},
{
label: USER_ROLE_LABELS[UserRole.BASIC],
value: UserRole.BASIC.toString(),
},
{
label: USER_ROLE_LABELS[UserRole.ADMIN],
value: UserRole.ADMIN.toString(),
},
]}
placeholder="Select a role"
strict
/>
</FormField.Control>
<FormField.Description>
Select the role for this API key. Limited has access to
simple public APIs. Basic has access to regular user
APIs. Admin has access to admin level APIs.
</FormField.Description>
</FormField>
)}
/>
</Modal.Body>
<Modal.Footer>
<Button type="submit" disabled={isSubmitting}>
{isUpdate ? "Update" : "Create"}
</Button>
</Form>
)}
</Formik>
</Modal.Body>
</Modal.Footer>
</Form>
)}
</Formik>
</Modal.Content>
</Modal>
);

View File

@@ -281,8 +281,10 @@ function SeatsCard({
});
const totalSeats = billing?.seats ?? license?.seats ?? 0;
const acceptedUsers = usersData?.accepted?.length ?? 0;
const slackUsers = usersData?.slack_users?.length ?? 0;
const acceptedUsers =
usersData?.accepted?.filter((u) => u.is_active).length ?? 0;
const slackUsers =
usersData?.slack_users?.filter((u) => u.is_active).length ?? 0;
const usedSeats = acceptedUsers + slackUsers;
const pendingSeats = usersData?.invited?.length ?? 0;
const remainingSeats = Math.max(0, totalSeats - usedSeats - pendingSeats);

View File

@@ -99,9 +99,11 @@ export default function CheckoutView({ onAdjustPlan }: CheckoutViewProps) {
const { user } = useUser();
const { data: usersData } = useUsers({ includeApiKeys: false });
// Calculate minimum required seats based on current users
const acceptedUsers = usersData?.accepted?.length ?? 0;
const slackUsers = usersData?.slack_users?.length ?? 0;
// Calculate minimum required seats based on current active users
const acceptedUsers =
usersData?.accepted?.filter((u) => u.is_active).length ?? 0;
const slackUsers =
usersData?.slack_users?.filter((u) => u.is_active).length ?? 0;
const minRequiredSeats = Math.max(1, acceptedUsers + slackUsers);
const [billingPeriod, setBillingPeriod] = useState<PlanType>("annual");

View File

@@ -1,20 +1,23 @@
"use client";
import {
SvgArrowUpCircle,
SvgBarChart,
SvgFileText,
SvgDashboard,
SvgHistory,
SvgFiles,
SvgGlobe,
SvgHardDrive,
SvgHeadsetMic,
SvgKey,
SvgLock,
SvgOrganization,
SvgPaintBrush,
SvgSearch,
SvgOrganization,
SvgServer,
SvgShield,
SvgSliders,
SvgUserManage,
SvgUsers,
} from "@opal/icons";
import "./billing.css";
import "@/app/admin/billing/billing.css";
import type { IconProps } from "@opal/types";
import Card from "@/refresh-components/cards/Card";
import Button from "@/refresh-components/buttons/Button";
@@ -52,20 +55,23 @@ interface PlanConfig {
// ----------------------------------------------------------------------------
const BUSINESS_FEATURES: PlanFeature[] = [
{ icon: SvgSearch, text: "Enterprise Search" },
{ icon: SvgBarChart, text: "Query History & Usage Dashboard" },
{ icon: SvgServer, text: "On-Premise Deployments" },
{ icon: SvgGlobe, text: "Region-Specific Deployments" },
{ icon: SvgUsers, text: "RBAC Support" },
{ icon: SvgOrganization, text: "Permission Inheritance" },
{ icon: SvgKey, text: "OIDC/SAML SSO" },
{ icon: SvgFiles, text: "Inherit Document Permissions" },
{ icon: SvgHistory, text: "Query History and Usage Dashboard" },
{ icon: SvgShield, text: "Role Based Access Control (RBAC)" },
{ icon: SvgLock, text: "Encryption of Secrets" },
{ icon: SvgKey, text: "Service Account API Keys" },
{ icon: SvgHardDrive, text: "Self-hosting (Optional)" },
{ icon: SvgPaintBrush, text: "Custom Theming" },
];
const ENTERPRISE_FEATURES: PlanFeature[] = [
{ icon: SvgHeadsetMic, text: "Priority Support" },
{ icon: SvgPaintBrush, text: "White-labeling" },
{ icon: SvgFileText, text: "Enterprise SLAs" },
{ icon: SvgUsers, text: "SCIM / Group Sync" },
{ icon: SvgDashboard, text: "Full White-labeling" },
{ icon: SvgUserManage, text: "Custom Roles and Permissions" },
{ icon: SvgSliders, text: "Configurable Usage Limits" },
{ icon: SvgServer, text: "Custom Deployments" },
{ icon: SvgGlobe, text: "Region-Specific Data Processing" },
{ icon: SvgHeadsetMic, text: "Enterprise SLAs and Priority Support" },
];
// ----------------------------------------------------------------------------
@@ -126,7 +132,13 @@ function PlanCard({
{pricing}
</Text>
)}
<Text mainUiBody text03>
<Text
secondaryBody
text03
className={
pricing ? "whitespace-pre-line" : "whitespace-pre-line min-h-9"
}
>
{description}
</Text>
</Section>
@@ -149,10 +161,16 @@ function PlanCard({
>
{buttonLabel}
</Button>
) : (
) : onClick ? (
<Button main primary onClick={onClick} leftIcon={ButtonIcon}>
{buttonLabel}
</Button>
) : (
<Button tertiary transient className="pointer-events-none">
<Text mainUiAction text03>
Included in your plan
</Text>
</Button>
)}
</div>
</Section>
@@ -189,7 +207,7 @@ function PlanCard({
height="auto"
>
<div className="plan-card-feature-icon">
<feature.icon size={16} />
<feature.icon size={16} className="stroke-text-03" />
</div>
<Text mainUiBody text03>
{feature.text}
@@ -208,21 +226,18 @@ function PlanCard({
// ----------------------------------------------------------------------------
interface PlansViewProps {
currentPlan?: string;
hasSubscription?: boolean;
hasLicense?: boolean;
onCheckout: () => void;
hideFeatures?: boolean;
}
export default function PlansView({
currentPlan,
hasSubscription,
hasLicense,
onCheckout,
hideFeatures,
}: PlansViewProps) {
const isBusinessPlan =
currentPlan?.toLowerCase() === "business" || hasSubscription;
const plans: PlanConfig[] = [
{
icon: SvgUsers,
@@ -230,24 +245,24 @@ export default function PlansView({
pricing: "$20",
description:
"per seat/month billed annually\nor $25 per seat if billed monthly",
buttonLabel: isBusinessPlan ? "Get Business Plan" : "Upgrade Plan",
buttonLabel: "Get Business Plan",
buttonVariant: "primary",
buttonIcon: isBusinessPlan ? undefined : SvgArrowUpCircle,
onClick: onCheckout,
onClick: hasLicense ? undefined : onCheckout,
features: BUSINESS_FEATURES,
featuresPrefix: "Get more work done with AI for your team.",
isCurrentPlan: isBusinessPlan,
isCurrentPlan: !!hasSubscription,
},
{
icon: SvgOrganization,
title: "Enterprise",
description:
"Flexible pricing & deployment options for large organizations",
"Flexible pricing & deployment options\nfor large organizations",
buttonLabel: "Contact Sales",
buttonVariant: "secondary",
href: SALES_URL,
features: ENTERPRISE_FEATURES,
featuresPrefix: "Everything in Business Plan, plus:",
isCurrentPlan: !!hasLicense && !hasSubscription,
},
];

View File

@@ -6,7 +6,8 @@ import * as SettingsLayouts from "@/layouts/settings-layouts";
import { Section } from "@/layouts/general-layouts";
import Button from "@/refresh-components/buttons/Button";
import Text from "@/refresh-components/texts/Text";
import { SvgWallet } from "@opal/icons";
import { SvgArrowUpCircle, SvgWallet } from "@opal/icons";
import type { IconProps } from "@opal/types";
import {
useBillingInformation,
useLicense,
@@ -30,8 +31,8 @@ import "./billing.css";
type BillingView = "plans" | "details" | "checkout" | null;
interface ViewConfig {
icon: React.FunctionComponent<IconProps>;
title: string;
description: string;
showBackButton: boolean;
}
@@ -66,7 +67,7 @@ function FooterLinks({
Have a license key?
</Text>
<Button action tertiary onClick={onActivateLicense}>
<Text secondaryBody text03 className="underline">
<Text secondaryBody text05 className="underline">
{licenseText}
</Text>
</Button>
@@ -118,7 +119,6 @@ export default function BillingPage() {
const isLoading = billingLoading || licenseLoading;
const hasSubscription = billingData && hasActiveSubscription(billingData);
const billing = hasSubscription ? (billingData as BillingInformation) : null;
const currentPlan = billing?.plan_type ?? licenseData?.plan_type ?? undefined;
const isSelfHosted = !NEXT_PUBLIC_CLOUD_ENABLED;
// User is only air-gapped if they have a manual license AND Stripe is not connected
@@ -227,30 +227,28 @@ export default function BillingPage() {
const getViewConfig = (): ViewConfig => {
if (isLoading || view === null) {
return {
icon: SvgWallet,
title: "Plans & Billing",
description: "Loading billing information...",
showBackButton: false,
};
}
switch (view) {
case "checkout":
return {
icon: SvgArrowUpCircle,
title: "Upgrade Plan",
description: "Configure your Business Plan subscription",
showBackButton: false,
};
case "plans":
return {
icon: hasSubscription ? SvgWallet : SvgArrowUpCircle,
title: hasSubscription ? "View Plans" : "Upgrade Plan",
description: hasSubscription
? "Compare and manage your subscription plan"
: "Choose a plan to unlock premium features",
showBackButton: !!hasSubscription,
};
case "details":
return {
icon: SvgWallet,
title: "Plans & Billing",
description: "Manage your subscription and billing settings",
showBackButton: false,
};
}
@@ -294,8 +292,8 @@ export default function BillingPage() {
checkout: <CheckoutView onAdjustPlan={() => changeView("plans")} />,
plans: (
<PlansView
currentPlan={currentPlan}
hasSubscription={!!hasSubscription}
hasLicense={!!licenseData?.has_license}
onCheckout={() => changeView("checkout")}
hideFeatures={showLicenseActivationInput}
/>
@@ -355,9 +353,8 @@ export default function BillingPage() {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgWallet}
icon={viewConfig.icon}
title={viewConfig.title}
description={viewConfig.description}
backButton={viewConfig.showBackButton}
onBack={handleBack}
separator

View File

@@ -160,6 +160,7 @@ export default function ImageGenerationContent() {
info
static
large
close={false}
text="Connect an image generation model to use in chat."
className="w-full"
/>

View File

@@ -125,6 +125,7 @@ export const WebProviderSetupModal = memo(
<FormField.Label>API Key</FormField.Label>
<FormField.Control asChild>
<PasswordInputTypeIn
data-testid="web-provider-api-key-input"
placeholder="Enter API key"
value={apiKeyValue}
autoFocus={apiKeyAutoFocus}

View File

@@ -1,4 +1,9 @@
export type WebSearchProviderType = "google_pse" | "serper" | "exa" | "searxng";
export type WebSearchProviderType =
| "google_pse"
| "serper"
| "exa"
| "searxng"
| "brave";
export const SEARCH_PROVIDERS_URL = "/api/admin/web-search/search-providers";
@@ -26,6 +31,14 @@ export const SEARCH_PROVIDER_DETAILS: Record<
logoSrc: "/Serper.svg",
apiKeyUrl: "https://serper.dev/api-key",
},
brave: {
label: "Brave",
subtitle: "Brave Search API",
helper: "Connect to Brave Search API to set up web search.",
logoSrc: "/Brave.svg",
apiKeyUrl:
"https://api-dashboard.search.brave.com/app/documentation/web-search/get-started",
},
google_pse: {
label: "Google PSE",
subtitle: "Google",
@@ -93,6 +106,10 @@ const SEARCH_PROVIDER_CAPABILITIES: Record<
requiresApiKey: true,
requiredConfigKeys: [],
},
brave: {
requiresApiKey: true,
requiredConfigKeys: [],
},
google_pse: {
requiresApiKey: true,
requiredConfigKeys: ["search_engine_id"],

View File

@@ -1,12 +1,17 @@
import { ValidSources } from "@/lib/types";
import { EditIcon } from "@/components/icons/icons";
"use client";
import { useState } from "react";
import { ChevronUpIcon } from "lucide-react";
import { ChevronDownIcon } from "@/components/icons/icons";
import { ValidSources } from "@/lib/types";
import { Section } from "@/layouts/general-layouts";
import Text from "@/refresh-components/texts/Text";
import IconButton from "@/refresh-components/buttons/IconButton";
import Button from "@/refresh-components/buttons/Button";
import Separator from "@/refresh-components/Separator";
import { SvgChevronUp, SvgChevronDown, SvgEdit } from "@opal/icons";
import Truncated from "@/refresh-components/texts/Truncated";
function convertObjectToString(obj: any): string | any {
// Check if obj is an object and not an array or null
if (typeof obj === "object" && obj !== null) {
if (!Array.isArray(obj)) {
return JSON.stringify(obj);
@@ -28,8 +33,6 @@ export function buildConfigEntries(
sourceType: ValidSources
): { [key: string]: string } {
if (sourceType === ValidSources.File) {
// File connectors show files in the InlineFileManagement component
// Don't show file_names or file_locations in the config display
return {};
} else if (sourceType === ValidSources.GoogleSites) {
return {
@@ -39,15 +42,13 @@ export function buildConfigEntries(
return obj;
}
function ConfigItem({
label,
value,
onEdit,
}: {
interface ConfigItemProps {
label: string;
value: any;
onEdit?: () => void;
}) {
}
function ConfigItem({ label, value, onEdit }: ConfigItemProps) {
const [isExpanded, setIsExpanded] = useState(false);
const isExpandable = Array.isArray(value) && value.length > 5;
@@ -55,69 +56,82 @@ function ConfigItem({
if (Array.isArray(value)) {
const displayedItems = isExpanded ? value : value.slice(0, 5);
return (
<ul className="list-disc pl-4 overflow-x-auto">
{displayedItems.map((item, index) => (
<li
key={index}
className="mb-1 overflow-hidden text-ellipsis whitespace-nowrap"
>
{convertObjectToString(item)}
</li>
))}
</ul>
<Section
flexDirection="row"
gap={0.25}
justifyContent="end"
alignItems="center"
height="fit"
>
<Text secondaryBody text03 className="break-words">
{displayedItems
.map((item) => convertObjectToString(item))
.join(", ")}
</Text>
</Section>
);
} else if (typeof value === "object" && value !== null) {
return (
<div className="overflow-x-auto">
<Section gap={0.25} alignItems="end" height="fit">
{Object.entries(value).map(([key, val]) => (
<div key={key} className="mb-1">
<span className="font-semibold">{key}:</span>{" "}
<Text key={key} secondaryBody text03 className="break-words">
<Text mainContentEmphasis text03>
{key}:
</Text>{" "}
{convertObjectToString(val)}
</div>
</Text>
))}
</div>
</Section>
);
} else if (typeof value === "boolean") {
return (
<Text secondaryBody text03 className="text-right">
{value ? "True" : "False"}
</Text>
);
}
// TODO: figure out a nice way to display boolean values
else if (typeof value === "boolean") {
return value ? "True" : "False";
}
return convertObjectToString(value) || "-";
return (
<Truncated secondaryBody text03 className="text-right">
{convertObjectToString(value) || "-"}
</Truncated>
);
};
return (
<li className="w-full py-4 px-1">
<div className="flex items-center w-full">
<span className="text-sm">{label}</span>
<div className="text-right overflow-x-auto max-w-[60%] text-sm font-normal ml-auto">
{renderValue()}
<Section
flexDirection="row"
justifyContent="between"
alignItems="center"
gap={1}
>
<Section alignItems="start">
<Text mainUiBody text04>
{label}
</Text>
</Section>
<Section
flexDirection="row"
justifyContent="end"
alignItems="center"
gap={0.5}
>
{renderValue()}
{isExpandable && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="mt-2 text-sm text-text-600 hover:text-text-800 flex items-center ml-auto"
>
{isExpanded ? (
<>
<ChevronUpIcon className="h-4 w-4 mr-1" />
Show less
</>
) : (
<>
<ChevronDownIcon className="h-4 w-4 mr-1" />
Show all ({value.length} items)
</>
)}
</button>
)}
</div>
{onEdit && (
<button onClick={onEdit} className="ml-4">
<EditIcon size={12} />
</button>
{isExpandable && (
<Button
tertiary
size="md"
leftIcon={isExpanded ? SvgChevronUp : SvgChevronDown}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? "Show less" : `Show all (${value.length} items)`}
</Button>
)}
</div>
</li>
{onEdit && (
<IconButton icon={SvgEdit} tertiary onClick={onEdit} tooltip="Edit" />
)}
</Section>
</Section>
);
}
@@ -182,31 +196,38 @@ export function AdvancedConfigDisplay({
});
};
const items = [
pruneFreq !== null && {
label: "Pruning Frequency",
value: formatPruneFrequency(pruneFreq),
onEdit: onPruningEdit,
},
refreshFreq && {
label: "Refresh Frequency",
value: formatRefreshFrequency(refreshFreq),
onEdit: onRefreshEdit,
},
indexingStart && {
label: "Indexing Start",
value: formatDate(indexingStart),
},
].filter(Boolean) as ConfigItemProps[];
return (
<div>
<ul className="w-full divide-y divide-neutral-200 dark:divide-neutral-700">
{pruneFreq !== null && (
<ConfigItem
label="Pruning Frequency"
value={formatPruneFrequency(pruneFreq)}
onEdit={onPruningEdit}
/>
)}
{refreshFreq && (
<ConfigItem
label="Refresh Frequency"
value={formatRefreshFrequency(refreshFreq)}
onEdit={onRefreshEdit}
/>
)}
{indexingStart && (
<ConfigItem
label="Indexing Start"
value={formatDate(indexingStart)}
/>
)}
</ul>
</div>
<Section gap={0} height="fit">
{items.map((item, index) => (
<div key={item.label} className="w-full">
<div className="py-4">
<ConfigItem
label={item.label}
value={item.value}
onEdit={item.onEdit}
/>
</div>
{index < items.length - 1 && <Separator noPadding />}
</div>
))}
</Section>
);
}
@@ -217,16 +238,22 @@ export function ConfigDisplay({
configEntries: { [key: string]: string };
onEdit?: (key: string) => void;
}) {
const entries = Object.entries(configEntries);
return (
<ul className="w-full divide-y divide-background-200 dark:divide-background-700">
{Object.entries(configEntries).map(([key, value]) => (
<ConfigItem
key={key}
label={key}
value={value}
onEdit={onEdit ? () => onEdit(key) : undefined}
/>
<Section gap={0} height="fit">
{entries.map(([key, value], index) => (
<div key={key} className="w-full">
<div className="py-4">
<ConfigItem
label={key}
value={value}
onEdit={onEdit ? () => onEdit(key) : undefined}
/>
</div>
{index < entries.length - 1 && <Separator noPadding />}
</div>
))}
</ul>
</Section>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
import Text from "@/refresh-components/texts/Text";
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
export interface AgentDescriptionProps {
agent?: MinimalPersonaSnapshot;
}
export default function AgentDescription({ agent }: AgentDescriptionProps) {
if (!agent?.description) return null;
return (
<Text
as="p"
secondaryBody
text03
className="w-full min-w-0 text-center break-words"
>
{agent.description}
</Text>
);
}

View File

@@ -55,6 +55,7 @@ import ChatScrollContainer, {
} from "@/components/chat/ChatScrollContainer";
import MessageList from "@/components/chat/MessageList";
import WelcomeMessage from "@/app/app/components/WelcomeMessage";
import AgentDescription from "@/app/app/components/AgentDescription";
import ProjectContextPanel from "@/app/app/components/projects/ProjectContextPanel";
import { useProjectsContext } from "@/providers/ProjectsContext";
import {
@@ -746,6 +747,15 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
}
/>
{/* Agent description below input */}
{(appFocus.isNewSession() || appFocus.isAgent()) &&
!isDefaultAgent && (
<>
<AgentDescription agent={liveAssistant} />
<Spacer rem={1.5} />
</>
)}
{/* ProjectChatSessionsUI */}
{appFocus.isProject() && <ProjectChatSessionList />}
</div>

View File

@@ -1,56 +0,0 @@
import Truncated from "@/refresh-components/texts/Truncated";
import { XIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
interface SourceChip2Props {
icon?: React.ReactNode;
title: string;
onRemove?: () => void;
onClick?: () => void;
includeAnimation?: boolean;
}
export function SourceChip2({
icon,
title,
onRemove,
onClick,
includeAnimation,
}: SourceChip2Props) {
const [isNew, setIsNew] = useState(true);
useEffect(() => {
const timer = setTimeout(() => setIsNew(false), 300);
return () => clearTimeout(timer);
}, []);
return (
<button
onClick={onClick}
className={cn(
"max-w-[15rem] p-1 bg-background-tint-03 hover:bg-background-tint-04 rounded-full justify-center items-center flex flex-row transition-colors duration-200",
includeAnimation && isNew && "animate-fade-in-scale"
)}
>
{icon && (
<div className="w-[17px] h-4 p-[3px] flex-col justify-center items-center gap-2.5 inline-flex">
{icon}
</div>
)}
<Truncated text03 secondaryBody>
{title}
</Truncated>
{onRemove && (
<XIcon
size={12}
className="ml-2 cursor-pointer"
onClick={(e: React.MouseEvent<SVGSVGElement>) => {
e.stopPropagation();
onRemove();
}}
/>
)}
</button>
);
}

View File

@@ -58,11 +58,6 @@ export default function WelcomeMessage({
{agent.name}
</Text>
</div>
{agent.description && (
<Text as="p" secondaryBody text03>
{agent.description}
</Text>
)}
</>
);
}

View File

@@ -165,7 +165,7 @@ export const MemoizedLink = memo(
return (
<SourceTag
inlineCitation
variant="inlineCitation"
displayName={displayName}
sources={[sourceInfo]}
onSourceClick={handleSourceClick}

View File

@@ -77,6 +77,7 @@ const SourcesTagWrapper = React.memo(function SourcesTagWrapper({
return (
<SourceTag
variant="button"
displayName="Sources"
sources={sources}
onSourceClick={handleSourceClick}

View File

@@ -58,6 +58,7 @@ export const ParallelStreamingHeader = React.memo(
/>
) : undefined
}
className="bg-transparent"
>
{steps.map((step) => (
<Tabs.Trigger

View File

@@ -414,6 +414,177 @@ describe("usePacedTurnGroups", () => {
});
});
describe("tool-after-message transition", () => {
test("resets toolPacingComplete when finalAnswerComing goes true → false with new tool step", () => {
const displayGroup = createDisplayGroup(0);
// Step 1: Render with finalAnswerComing=true, no tool steps
// No tools = pacing complete immediately → display groups shown
const { result, rerender } = renderHook(
({ turnGroups, finalAnswerComing }) =>
usePacedTurnGroups(
turnGroups,
[displayGroup],
false,
1,
finalAnswerComing
),
{
initialProps: {
turnGroups: [] as TurnGroup[],
finalAnswerComing: true,
},
}
);
expect(result.current.pacedDisplayGroups.length).toBe(1);
expect(result.current.pacedFinalAnswerComing).toBe(true);
// Step 2: finalAnswerComing goes false + new tool step arrives
// This simulates the agent switching from message streaming back to tools
const step1 = createStep(0, 0);
rerender({
turnGroups: [createTurnGroup([step1])],
finalAnswerComing: false,
});
// toolPacingComplete was reset, so display groups should be hidden
// (first tool step is revealed immediately, but pacing just re-started)
expect(result.current.pacedTurnGroups.length).toBe(1);
expect(result.current.pacedDisplayGroups.length).toBe(0);
// Step 3: Add a second tool step so pacing is not yet complete
const step2 = createStep(1, 0);
rerender({
turnGroups: [createTurnGroup([step1]), createTurnGroup([step2])],
finalAnswerComing: false,
});
// Display groups still hidden (pacing incomplete)
expect(result.current.pacedDisplayGroups.length).toBe(0);
// Step 4: Advance timer to complete pacing
act(() => {
jest.advanceTimersByTime(200);
});
// Now pacing is complete → display groups shown again
expect(result.current.pacedTurnGroups.length).toBe(2);
expect(result.current.pacedDisplayGroups.length).toBe(1);
});
});
describe("referential stability", () => {
test("returns same array reference when turn groups have not changed", () => {
const step1 = createStep(0, 0);
const { result, rerender } = renderHook(
({ turnGroups }) => usePacedTurnGroups(turnGroups, [], false, 1, false),
{ initialProps: { turnGroups: [createTurnGroup([step1])] } }
);
// First step revealed immediately
expect(result.current.pacedTurnGroups.length).toBe(1);
// Add second step and reveal it via pacing
const step2 = createStep(1, 0);
rerender({
turnGroups: [createTurnGroup([step1]), createTurnGroup([step2])],
});
act(() => {
jest.advanceTimersByTime(200);
});
expect(result.current.pacedTurnGroups.length).toBe(2);
const stableRef = result.current.pacedTurnGroups;
// Re-render with new array containing structurally identical turn groups
rerender({
turnGroups: [createTurnGroup([step1]), createTurnGroup([step2])],
});
// Should be the exact same array reference (nothing changed)
expect(result.current.pacedTurnGroups).toBe(stableRef);
});
test("preserves completed group references when streaming group changes", () => {
const step1 = createStep(0, 0);
const { result, rerender } = renderHook(
({ turnGroups, stopPacketSeen }) =>
usePacedTurnGroups(turnGroups, [], stopPacketSeen, 1, false),
{
initialProps: {
turnGroups: [createTurnGroup([step1])],
stopPacketSeen: false,
},
}
);
// First step revealed immediately
expect(result.current.pacedTurnGroups.length).toBe(1);
// Add second step and advance timer to reveal it
const step2 = createStep(1, 0);
rerender({
turnGroups: [createTurnGroup([step1]), createTurnGroup([step2])],
stopPacketSeen: false,
});
act(() => {
jest.advanceTimersByTime(200);
});
expect(result.current.pacedTurnGroups.length).toBe(2);
const firstGroupRef = result.current.pacedTurnGroups[0];
// Simulate streaming: step2 gets more packets (new object with longer packets array)
const step2Updated: TransformedStep = {
...step2,
packets: [
...step2.packets,
{
placement: { turn_index: 1, tab_index: 0 },
obj: { type: PacketType.SEARCH_TOOL_START },
} as Packet,
],
};
rerender({
turnGroups: [createTurnGroup([step1]), createTurnGroup([step2Updated])],
stopPacketSeen: false,
});
// First group (completed) should keep the same object reference
expect(result.current.pacedTurnGroups[0]).toBe(firstGroupRef);
// Second group changed (packets.length differs) — new reference
expect(result.current.pacedTurnGroups.length).toBe(2);
});
test("returns new array reference when a new step is revealed", () => {
const step1 = createStep(0, 0);
const { result, rerender } = renderHook(
({ turnGroups }) => usePacedTurnGroups(turnGroups, [], false, 1, false),
{ initialProps: { turnGroups: [createTurnGroup([step1])] } }
);
const firstResult = result.current.pacedTurnGroups;
expect(firstResult.length).toBe(1);
// Add second step and reveal it
const step2 = createStep(1, 0);
rerender({
turnGroups: [createTurnGroup([step1]), createTurnGroup([step2])],
});
act(() => {
jest.advanceTimersByTime(200);
});
// Array reference must differ (length changed)
expect(result.current.pacedTurnGroups).not.toBe(firstResult);
expect(result.current.pacedTurnGroups.length).toBe(2);
});
});
describe("timer cleanup", () => {
test("clears timer on unmount", () => {
const step1 = createStep(0, 0);

View File

@@ -100,6 +100,10 @@ export function usePacedTurnGroups(
// Track previous finalAnswerComing to detect tool-after-message transitions
const prevFinalAnswerComingRef = useRef(finalAnswerComing);
// Cache previous pacedTurnGroups to preserve referential equality
// for completed turn groups that haven't changed
const prevPacedRef = useRef<TurnGroup[]>([]);
// Trigger re-render when content should update
// Used in useMemo dependencies since state.revealedStepKeys is stored in a ref
const [revealTrigger, setRevealTrigger] = useState(0);
@@ -114,6 +118,7 @@ export function usePacedTurnGroups(
}
stateRef.current = createInitialPacingState();
stateRef.current.nodeId = nodeIdStr;
prevPacedRef.current = [];
}
const state = stateRef.current;
@@ -293,6 +298,38 @@ export function usePacedTurnGroups(
});
}
}
// Stabilize: reuse previous TurnGroup objects when their content hasn't changed.
// This preserves referential equality for completed groups, preventing
// unnecessary re-renders in downstream components (e.g. SearchChipList).
const prev = prevPacedRef.current;
if (prev.length === result.length) {
let allMatch = true;
for (let i = 0; i < result.length; i++) {
const oldGroup = prev[i]!;
const newGroup = result[i]!;
if (
oldGroup.turnIndex === newGroup.turnIndex &&
oldGroup.steps.length === newGroup.steps.length &&
oldGroup.steps.every(
(s, j) =>
s.key === newGroup.steps[j]!.key &&
s.packets.length === newGroup.steps[j]!.packets.length
)
) {
// Reuse old object reference for this group
result[i] = oldGroup;
} else {
allMatch = false;
}
}
if (allMatch) {
// Every group matched — return the exact same array reference
return prev;
}
}
prevPacedRef.current = result;
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toolTurnGroups, revealTrigger, shouldBypassPacing]);

View File

@@ -1,4 +1,4 @@
import React, { JSX, useState, useEffect, useRef } from "react";
import React, { JSX, useState, useEffect, useRef, useMemo } from "react";
import { SourceTag, SourceInfo } from "@/refresh-components/buttons/source-tag";
import { cn } from "@/lib/utils";
@@ -35,55 +35,33 @@ export function SearchChipList<T>({
showDetailsCard,
isQuery,
}: SearchChipListProps<T>): JSX.Element {
const [displayList, setDisplayList] = useState<DisplayEntry<T>[]>([]);
const [batchId, setBatchId] = useState(0);
const [visibleCount, setVisibleCount] = useState(initialCount);
const animatedKeysRef = useRef<Set<string>>(new Set());
const getEntryKey = (entry: DisplayEntry<T>): string => {
if (entry.type === "more") return `more-button-${entry.batchId}`;
if (entry.type === "more") return `more-button`;
return String(getKey(entry.item, entry.index));
};
useEffect(() => {
const initial: DisplayEntry<T>[] = items
.slice(0, initialCount)
const effectiveCount = Math.min(visibleCount, items.length);
const displayList: DisplayEntry<T>[] = useMemo(() => {
const chips: DisplayEntry<T>[] = items
.slice(0, effectiveCount)
.map((item, i) => ({ type: "chip" as const, item, index: i }));
if (items.length > initialCount) {
initial.push({ type: "more", batchId: 0 });
if (effectiveCount < items.length) {
chips.push({ type: "more", batchId: 0 });
}
return chips;
}, [items, effectiveCount]);
setDisplayList(initial);
setBatchId(0);
}, [items, initialCount]);
const chipCount = displayList.filter((e) => e.type === "chip").length;
const chipCount = effectiveCount;
const remainingCount = items.length - chipCount;
const remainingItems = items.slice(chipCount);
const handleShowMore = () => {
const nextBatchId = batchId + 1;
setDisplayList((prev) => {
const withoutButton = prev.filter((e) => e.type !== "more");
const currentCount = withoutButton.length;
const newCount = Math.min(currentCount + expansionCount, items.length);
const newItems: DisplayEntry<T>[] = items
.slice(currentCount, newCount)
.map((item, i) => ({
type: "chip" as const,
item,
index: currentCount + i,
}));
const updated = [...withoutButton, ...newItems];
if (newCount < items.length) {
updated.push({ type: "more", batchId: nextBatchId });
}
return updated;
});
setBatchId(nextBatchId);
setVisibleCount((prev) => prev + expansionCount);
};
useEffect(() => {

View File

@@ -189,7 +189,7 @@ export const SettingsPanel = ({
key={bg.id}
thumbnailUrl={bg.thumbnail}
label={bg.label}
isNone={bg.url === CHAT_BACKGROUND_NONE}
isNone={bg.src === CHAT_BACKGROUND_NONE}
isSelected={currentBackgroundId === bg.id}
onClick={() => handleBackgroundChange(bg.id)}
/>

View File

@@ -1,20 +1,20 @@
The DanswerAI Enterprise license (the Enterprise License)
The Onyx Enterprise License (the "Enterprise License")
Copyright (c) 2023-present DanswerAI, Inc.
With regard to the Onyx Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the DanswerAI Subscription Terms of Service, available
at https://onyx.app/terms (the Enterprise Terms), or other
and are in compliance with, the Onyx Subscription Terms of Service, available
at https://www.onyx.app/legal/self-host (the "Enterprise Terms"), or other
agreement governing the use of the Software, as agreed by you and DanswerAI,
and otherwise have a valid Onyx Enterprise license for the
and otherwise have a valid Onyx Enterprise License for the
correct number of user seats. Subject to the foregoing sentence, you are free to
modify this Software and publish patches to the Software. You agree that DanswerAI
and/or its licensors (as applicable) retain all right, title and interest in and
to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid Onyx Enterprise license for the correct
exploited with a valid Onyx Enterprise License for the correct
number of user seats. Notwithstanding the foregoing, you may copy and modify
the Software for development and testing purposes, without requiring a
subscription. You agree that DanswerAI and/or its licensors (as applicable) retain

View File

@@ -24,6 +24,13 @@ export default async function AdminLayout({
if (settingsResponse?.ok) {
const settings = await settingsResponse.json();
if (settings.ee_features_enabled === false) {
// When the app is in GATED_ACCESS (expired or missing license), defer
// to the root layout's GatedContentWrapper which handles path-based
// exemptions (e.g. allowing /admin/billing for license management).
if (settings.application_status === "gated_access") {
return children;
}
return (
<div className="flex h-screen">
<div className="mx-auto my-auto text-lg font-bold text-red-500">

View File

@@ -186,7 +186,12 @@ export const DefaultDropdown = forwardRef<HTMLDivElement, DefaultDropdownProps>(
<FiChevronDown className="my-auto ml-auto" />
</div>
</Popover.Trigger>
<Popover.Content align="start" side={side} sideOffset={5}>
<Popover.Content
align="start"
side={side}
sideOffset={5}
width="trigger"
>
<div
ref={ref}
className={`

View File

@@ -3,8 +3,8 @@
import { usePathname } from "next/navigation";
import AccessRestrictedPage from "@/components/errorPages/AccessRestrictedPage";
// Paths accessible even when gated - allows users to manage billing/license
const ALLOWED_GATED_PATHS = ["/admin/billing", "/ee/admin/billing"];
// Paths accessible even when gated - allows users to manage billing updates and seat counts
const ALLOWED_GATED_PATHS = ["/admin/billing", "/admin/users"];
/**
* Check if pathname matches an allowed path exactly or is a subpath.

View File

@@ -8,6 +8,7 @@ import InlineExternalLink from "@/refresh-components/InlineExternalLink";
import { logout } from "@/lib/user";
import { loadStripe } from "@stripe/stripe-js";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
import { useLicense } from "@/hooks/useLicense";
import Text from "@/refresh-components/texts/Text";
import { SvgLock } from "@opal/icons";
@@ -38,6 +39,10 @@ const fetchResubscriptionSession = async () => {
export default function AccessRestricted() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: license } = useLicense();
// Distinguish between "never had a license" vs "license lapsed"
const hasLicenseLapsed = license?.has_license === true;
const handleResubscribe = async () => {
setIsLoading(true);
@@ -68,8 +73,9 @@ export default function AccessRestricted() {
</div>
<Text text03>
Your access to Onyx has been temporarily suspended due to a lapse in
your subscription.
{hasLicenseLapsed
? "Your access to Onyx has been temporarily suspended due to a lapse in your subscription."
: "An Enterprise license is required to use Onyx. Your data is protected and will be available once a license is activated."}
</Text>
{NEXT_PUBLIC_CLOUD_ENABLED ? (
@@ -105,20 +111,22 @@ export default function AccessRestricted() {
) : (
<>
<Text text03>
To reinstate your access and continue using Onyx, please contact
your system administrator to renew your license.
{hasLicenseLapsed
? "To reinstate your access and continue using Onyx, please contact your system administrator to renew your license."
: "To get started, please contact your system administrator to obtain an Enterprise license."}
</Text>
<Text text03>
If you are the administrator, please visit the{" "}
<Link className={linkClassName} href="/ee/admin/billing">
<Link className={linkClassName} href="/admin/billing">
Admin Billing
</Link>{" "}
page to update your license, or reach out to{" "}
page to {hasLicenseLapsed ? "renew" : "activate"} your license, sign
up through Stripe or reach out to{" "}
<a className={linkClassName} href="mailto:support@onyx.app">
support@onyx.app
</a>{" "}
to renew your subscription.
</a>
for billing assistance.
</Text>
<div className="flex flex-row gap-2">

View File

@@ -82,7 +82,7 @@ export function AnnouncementBanner() {
Your trial is ending soon - submit your billing information to
continue using Onyx.{" "}
<Link
href={"/ee/admin/billing" as Route}
href={"/admin/billing" as Route}
className="ml-2 underline cursor-pointer"
>
Update here

View File

@@ -24,7 +24,7 @@ export default function EditPropertyModal({
}: EditPropertyModalProps) {
return (
<Modal open onOpenChange={onClose}>
<Modal.Content>
<Modal.Content width="sm">
<Modal.Header
icon={SvgEdit}
title={`Edit ${propertyTitle}`}
@@ -43,7 +43,7 @@ export default function EditPropertyModal({
}}
>
{({ isSubmitting, isValid, values }) => (
<Form className="items-stretch">
<Form className="w-full">
<TextFormField
vertical
label={propertyDetails || ""}

36
web/src/ee/LICENSE Normal file
View File

@@ -0,0 +1,36 @@
The Onyx Enterprise License (the "Enterprise License")
Copyright (c) 2023-present DanswerAI, Inc.
With regard to the Onyx Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Onyx Subscription Terms of Service, available
at https://www.onyx.app/legal/self-host (the "Enterprise Terms"), or other
agreement governing the use of the Software, as agreed by you and DanswerAI,
and otherwise have a valid Onyx Enterprise License for the
correct number of user seats. Subject to the foregoing sentence, you are free to
modify this Software and publish patches to the Software. You agree that DanswerAI
and/or its licensors (as applicable) retain all right, title and interest in and
to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid Onyx Enterprise License for the correct
number of user seats. Notwithstanding the foregoing, you may copy and modify
the Software for development and testing purposes, without requiring a
subscription. You agree that DanswerAI and/or its licensors (as applicable) retain
all right, title and interest in and to all such modifications. You are not
granted any other rights beyond what is expressly stated herein. Subject to the
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Onyx Software, those
components are licensed under the original license provided by the owner of the
applicable component.

View File

@@ -0,0 +1,41 @@
"use client";
import useSWR from "swr";
import { useContext } from "react";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { SettingsContext } from "@/providers/SettingsProvider";
export interface MinimalUserGroupSnapshot {
id: number;
name: string;
}
// TODO (@raunakab):
// Refactor this hook to live inside of a special `ee` directory.
export default function useShareableGroups() {
const combinedSettings = useContext(SettingsContext);
const isPaidEnterpriseFeaturesEnabled =
combinedSettings && combinedSettings.enterpriseSettings !== null;
const { data, error, mutate, isLoading } = useSWR<MinimalUserGroupSnapshot[]>(
isPaidEnterpriseFeaturesEnabled ? "/api/manage/user-groups/minimal" : null,
errorHandlingFetcher
);
if (!isPaidEnterpriseFeaturesEnabled) {
return {
data: [],
isLoading: false,
error: undefined,
refreshShareableGroups: () => {},
};
}
return {
data,
isLoading,
error,
refreshShareableGroups: mutate,
};
}

View File

@@ -0,0 +1,25 @@
"use client";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { MinimalUserSnapshot } from "@/lib/types";
export interface UseShareableUsersParams {
includeApiKeys: boolean;
}
export default function useShareableUsers({
includeApiKeys,
}: UseShareableUsersParams) {
const { data, error, mutate, isLoading } = useSWR<MinimalUserSnapshot[]>(
`/api/users?include_api_keys=${includeApiKeys}`,
errorHandlingFetcher
);
return {
data,
isLoading,
error,
refreshShareableUsers: mutate,
};
}

View File

@@ -1,60 +1,50 @@
// Default chat background images
// Using high-quality Unsplash images optimized for different themes
export const CHAT_BACKGROUND_NONE = "none";
export interface ChatBackgroundOption {
id: string;
url: string;
src: string;
thumbnail: string;
label: string;
}
// Unsplash URL parameters:
// - Full images: w=1920, q=80, auto=format (webp when supported)
// - Thumbnails: w=200, h=150, fit=crop, q=70, auto=format
// Curated collection of scenic backgrounds that work well as chat backgrounds
export const CHAT_BACKGROUND_OPTIONS: ChatBackgroundOption[] = [
{
id: "none",
url: CHAT_BACKGROUND_NONE,
src: CHAT_BACKGROUND_NONE,
thumbnail: CHAT_BACKGROUND_NONE,
label: "None",
},
{
id: "clouds",
url: "https://images.unsplash.com/photo-1610888814579-ff6913173733?w=1920&q=80&auto=format",
thumbnail:
"https://images.unsplash.com/photo-1610888814579-ff6913173733?w=200&h=150&fit=crop&q=70&auto=format",
src: "/chat-backgrounds/clouds.jpg",
thumbnail: "/chat-backgrounds/thumbnails/clouds.jpg",
label: "Clouds",
},
{
id: "hills",
url: "https://images.unsplash.com/photo-1532019333101-b0f43c16a912?w=1920&q=80&auto=format",
thumbnail:
"https://images.unsplash.com/photo-1532019333101-b0f43c16a912?w=200&h=150&fit=crop&q=70&auto=format",
src: "/chat-backgrounds/hills.jpg",
thumbnail: "/chat-backgrounds/thumbnails/hills.jpg",
label: "Hills",
},
{
id: "plant",
url: "https://images.unsplash.com/photo-1692520883599-d543cfe6d43d?w=1920&q=80&auto=format",
thumbnail:
"https://images.unsplash.com/photo-1692520883599-d543cfe6d43d?w=200&h=150&fit=crop&q=70&auto=format",
src: "/chat-backgrounds/plant.jpg",
thumbnail: "/chat-backgrounds/thumbnails/plant.jpg",
label: "Plants",
},
{
id: "mountains",
url: "https://images.unsplash.com/photo-1496361751588-bdd9a3fcdd6f?w=1920&q=80&auto=format",
thumbnail:
"https://images.unsplash.com/photo-1496361751588-bdd9a3fcdd6f?w=200&h=150&fit=crop&q=70&auto=format",
src: "/chat-backgrounds/mountains.jpg",
thumbnail: "/chat-backgrounds/thumbnails/mountains.jpg",
label: "Mountains",
},
{
id: "night",
url: "https://images.unsplash.com/photo-1520330461350-508fab483d6a?w=1920&q=80&auto=format",
thumbnail:
"https://images.unsplash.com/photo-1520330461350-508fab483d6a?w=200&h=150&fit=crop&q=70&auto=format",
src: "/chat-backgrounds/night.jpg",
thumbnail: "/chat-backgrounds/thumbnails/night.jpg",
label: "Night",
},
];

View File

@@ -32,8 +32,8 @@ export function AppBackgroundProvider({
const chatBackgroundId = user?.preferences?.chat_background;
const appBackground = getBackgroundById(chatBackgroundId ?? null);
const hasBackground =
!!appBackground && appBackground.url !== CHAT_BACKGROUND_NONE;
const appBackgroundUrl = hasBackground ? appBackground.url : null;
!!appBackground && appBackground.src !== CHAT_BACKGROUND_NONE;
const appBackgroundUrl = hasBackground ? appBackground.src : null;
return {
appBackground,

View File

@@ -107,12 +107,13 @@ const PopoverClose = PopoverPrimitive.Close;
* </Popover.Content>
* ```
*/
type PopoverWidths = "fit" | "md" | "lg" | "xl";
type PopoverWidths = "fit" | "md" | "lg" | "xl" | "trigger";
const widthClasses: Record<PopoverWidths, string> = {
fit: "w-fit",
md: "w-[12rem]",
lg: "w-[15rem]",
xl: "w-[18rem]",
trigger: "w-[var(--radix-popover-trigger-width)]",
};
interface PopoverContentProps
extends WithoutStyles<

View File

@@ -615,7 +615,13 @@ const TabsTrigger = React.forwardRef<
<Icon size={14} className={cn(iconVariants[variant])} />
</div>
)}
{typeof children === "string" ? <Text>{children}</Text> : children}
{typeof children === "string" ? (
<div className="px-0.5">
<Text>{children}</Text>
</div>
) : (
children
)}
{isLoading && (
<span
className="inline-block w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin ml-1"

View File

@@ -14,6 +14,7 @@ export interface SidebarTabProps {
transient?: boolean;
focused?: boolean;
lowlight?: boolean;
nested?: boolean;
// Button properties:
onClick?: React.MouseEventHandler<HTMLDivElement>;
@@ -29,6 +30,7 @@ export default function SidebarTab({
transient,
focused,
lowlight,
nested,
onClick,
href,
@@ -65,6 +67,9 @@ export default function SidebarTab({
!focused && "pointer-events-none"
)}
>
{nested && !LeftIcon && (
<div className="w-4 shrink-0" aria-hidden="true" />
)}
{LeftIcon && (
<div className="w-[1rem] h-[1rem] flex items-center justify-center pointer-events-auto">
<LeftIcon

View File

@@ -34,6 +34,9 @@ const sizeClasses = {
tag: {
container: "rounded-08 p-1 gap-1",
},
button: {
container: "rounded-08 h-[2.25rem] min-w-[2.25rem] p-2 gap-1",
},
} as const;
/**
@@ -271,8 +274,8 @@ const QueryText = ({
* Props for the SourceTag component.
*/
export interface SourceTagProps {
/** Use inline citation size (smaller, for use within text) */
inlineCitation?: boolean;
/** Sizing variant: "inlineCitation" for compact in-text use, "button" for interactive contexts, "tag" (default) for standard display */
variant?: "inlineCitation" | "tag" | "button";
/** Display name shown on the tag (e.g., "Google Drive", "Business Insider") */
displayName: string;
@@ -314,7 +317,7 @@ export interface SourceTagProps {
* - Shows stacked source icons + display name
* - Hovering opens a details card with source navigation
*
* **Inline Citation** (`inlineCitation`):
* **Inline Citation** (`variant="inlineCitation"`):
* - Compact size for use within text content
* - Shows "+N" count for multiple sources
*
@@ -341,7 +344,7 @@ export interface SourceTagProps {
*
* // Inline citation within text
* <SourceTag
* inlineCitation
* variant="inlineCitation"
* displayName="Source 1"
* sources={multipleSources}
* />
@@ -355,7 +358,7 @@ export interface SourceTagProps {
* ```
*/
const SourceTagInner = ({
inlineCitation,
variant = "tag",
displayName,
displayUrl,
sources,
@@ -367,6 +370,8 @@ const SourceTagInner = ({
toggleSource,
tooltipText,
}: SourceTagProps) => {
const inlineCitation = variant === "inlineCitation";
const [currentIndex, setCurrentIndex] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [expanded, setExpanded] = useState(false);
@@ -383,7 +388,7 @@ const SourceTagInner = ({
const extraCount = sources.length - 1;
const size = inlineCitation ? "inlineCitation" : "tag";
const size = variant;
const styles = sizeClasses[size];
// Shared text styling props

View File

@@ -171,9 +171,11 @@ const SourceTagDetailsCardInner = ({
{/* Description */}
{currentSource.description && (
<Text secondaryBody text03 as="span" className="line-clamp-4">
{currentSource.description}
</Text>
<div className="px-1.5 pb-1">
<Text secondaryBody text03 as="span" className="line-clamp-4">
{currentSource.description}
</Text>
</div>
)}
</div>
</div>

View File

@@ -58,7 +58,7 @@ const NameStep = React.memo(
value={userName || ""}
onChange={(e) => updateName(e.target.value)}
onKeyDown={handleKeyDown}
className="w-[26%] min-w-40"
className="max-w-60"
/>
</div>
) : (

View File

@@ -32,7 +32,6 @@ export interface ActionItemProps {
onForceToggle: () => void;
onSourceManagementOpen?: () => void;
hasNoConnectors?: boolean;
hasNoKnowledgeSources?: boolean;
toolAuthStatus?: ToolAuthStatus;
onOAuthAuthenticate?: () => void;
onClose?: () => void;
@@ -55,7 +54,6 @@ export default function ActionLineItem({
onForceToggle,
onSourceManagementOpen,
hasNoConnectors = false,
hasNoKnowledgeSources = false,
toolAuthStatus,
onOAuthAuthenticate,
onClose,
@@ -77,11 +75,6 @@ export default function ActionLineItem({
tool?.in_code_tool_id === SEARCH_TOOL_ID &&
hasNoConnectors;
const isSearchToolWithNoKnowledgeSources =
!currentProjectId &&
tool?.in_code_tool_id === SEARCH_TOOL_ID &&
hasNoKnowledgeSources;
const isSearchToolAndNotInProject =
tool?.in_code_tool_id === SEARCH_TOOL_ID && !currentProjectId;
@@ -94,22 +87,14 @@ export default function ActionLineItem({
sourceCounts.enabled > 0 &&
sourceCounts.enabled < sourceCounts.total;
const tooltipText = isSearchToolWithNoKnowledgeSources
? "No knowledge sources are available. Contact your admin to add a knowledge source to this agent."
: isUnavailable
? unavailableReason
: tool?.description;
const tooltipText = isUnavailable ? unavailableReason : tool?.description;
return (
<SimpleTooltip tooltip={tooltipText} className="max-w-[30rem]">
<div data-testid={`tool-option-${toolName}`}>
<LineItem
onClick={() => {
if (
isSearchToolWithNoConnectors ||
isSearchToolWithNoKnowledgeSources
)
return;
if (isSearchToolWithNoConnectors) return;
if (isUnavailable) {
if (isForced) onForceToggle();
return;
@@ -122,10 +107,7 @@ export default function ActionLineItem({
}}
selected={isForced}
strikethrough={
disabled ||
isSearchToolWithNoConnectors ||
isSearchToolWithNoKnowledgeSources ||
isUnavailable
disabled || isSearchToolWithNoConnectors || isUnavailable
}
icon={Icon}
rightChildren={
@@ -198,31 +180,28 @@ export default function ActionLineItem({
</span>
)}
{isSearchToolAndNotInProject &&
!isSearchToolWithNoKnowledgeSources && (
<IconButton
icon={
isSearchToolWithNoConnectors
? SvgSettings
: SvgChevronRight
}
onClick={noProp(() => {
if (isSearchToolWithNoConnectors)
router.push("/admin/add-connector");
else onSourceManagementOpen?.();
})}
internal
className={cn(
isSearchToolWithNoConnectors &&
"invisible group-hover/LineItem:visible"
)}
tooltip={
isSearchToolWithNoConnectors
? "Add Connectors"
: "Configure Connectors"
}
/>
)}
{isSearchToolAndNotInProject && (
<IconButton
icon={
isSearchToolWithNoConnectors ? SvgSettings : SvgChevronRight
}
onClick={noProp(() => {
if (isSearchToolWithNoConnectors)
router.push("/admin/add-connector");
else onSourceManagementOpen?.();
})}
internal
className={cn(
isSearchToolWithNoConnectors &&
"invisible group-hover/LineItem:visible"
)}
tooltip={
isSearchToolWithNoConnectors
? "Add Connectors"
: "Configure Connectors"
}
/>
)}
</Section>
}
>

View File

@@ -209,6 +209,9 @@ export default function ActionsPopover({
sourceSet.add(normalized);
});
// No specific sources selected means everything is searchable
if (sourceSet.size === 0) return null;
return sourceSet;
}, [
isDefaultAgent,
@@ -216,16 +219,6 @@ export default function ActionsPopover({
selectedAssistant.knowledge_sources,
]);
// Check if non-default agent has no knowledge sources (Internal Search should be disabled)
// Knowledge sources include document sets and hierarchy nodes (folders, spaces, channels)
// Check if non-default agent has no knowledge sources (Internal Search should be disabled)
// Knowledge sources include document sets, hierarchy nodes, and attached documents
const hasNoKnowledgeSources =
!isDefaultAgent &&
selectedAssistant.document_sets.length === 0 &&
(selectedAssistant.hierarchy_node_count ?? 0) === 0 &&
(selectedAssistant.attached_document_count ?? 0) === 0;
// Store MCP server auth/loading state (tools are part of selectedAssistant.tools)
const [mcpServerData, setMcpServerData] = useState<{
[serverId: number]: {
@@ -894,7 +887,6 @@ export default function ActionsPopover({
setSecondaryView({ type: "sources" })
}
hasNoConnectors={hasNoConnectors}
hasNoKnowledgeSources={hasNoKnowledgeSources}
toolAuthStatus={getToolAuthStatus(tool)}
onOAuthAuthenticate={() => authenticateTool(tool)}
onClose={() => setOpen(false)}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo, useRef, useLayoutEffect, useEffect } from "react";
import { useState, useMemo, useRef, useEffect, useLayoutEffect } from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import Modal from "@/refresh-components/Modal";
import IconButton from "@/refresh-components/buttons/IconButton";
@@ -81,6 +81,34 @@ function downloadAsTxt(content: string, filename: string) {
}
}
/** Block-level HTML tags used by the snap algorithm to recurse into containers. */
const CONTAINER_TAGS = new Set([
"UL",
"OL",
"LI",
"BLOCKQUOTE",
"DIV",
"DL",
"DD",
"TABLE",
"TBODY",
"THEAD",
"TR",
"TH",
"TD",
"SECTION",
"DETAILS",
"PRE",
"FIGURE",
"FIGCAPTION",
"ARTICLE",
"ASIDE",
"HEADER",
"FOOTER",
"MAIN",
"NAV",
]);
export default function ExpandableTextDisplay({
title,
content,
@@ -94,45 +122,89 @@ export default function ExpandableTextDisplay({
const [isModalOpen, setIsModalOpen] = useState(false);
const [isTruncated, setIsTruncated] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const prevIsStreamingRef = useRef(isStreaming);
const contentInnerRef = useRef<HTMLDivElement>(null);
const lineCount = useMemo(() => getLineCount(content), [content]);
const contentSize = useMemo(() => getContentSize(content), [content]);
const displaySubtitle = subtitle ?? contentSize;
// Detect truncation for renderContent mode, streaming, and plain text static
useLayoutEffect(() => {
// Truncation detection (read-only, doesn't need to block paint)
useEffect(() => {
if (renderContent && scrollRef.current) {
// For renderContent mode (streaming or static), use scroll-based detection
// CSS line-clamp handles visual truncation, we just need to detect if it happened
setIsTruncated(
scrollRef.current.scrollHeight > scrollRef.current.clientHeight
);
} else if (isStreaming) {
// For plain text streaming, use line-based detection (still works for plain text)
const textToCheck = displayContent ?? content;
const lineCount = getLineCount(textToCheck);
setIsTruncated(lineCount > maxLines);
setIsTruncated(getLineCount(textToCheck) > maxLines);
} else if (scrollRef.current) {
// For plain text static, use scroll-based detection with line-clamp
setIsTruncated(
scrollRef.current.scrollHeight > scrollRef.current.clientHeight
);
}
}, [isStreaming, renderContent, content, displayContent, maxLines]);
// Scroll to bottom during streaming for renderContent mode
// This creates a "scrolling from bottom" effect showing the latest content
// Shift content upward during streaming for renderContent mode,
// snapping to element boundaries so blocks are never partially clipped.
// Must block paint to avoid flicker.
useLayoutEffect(() => {
if (isStreaming && renderContent && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
if (
!isStreaming ||
!renderContent ||
!scrollRef.current ||
!contentInnerRef.current
) {
return;
}
}, [isStreaming, renderContent, content, displayContent]);
// Track streaming state transitions (no longer need scroll management with top-truncation)
useEffect(() => {
prevIsStreamingRef.current = isStreaming;
}, [isStreaming]);
const containerHeight = scrollRef.current.clientHeight;
const contentHeight = contentInnerRef.current.scrollHeight;
let overflow = Math.max(0, contentHeight - containerHeight);
if (overflow > 0) {
let blockParent: Element = contentInnerRef.current;
while (
blockParent.children.length === 1 &&
blockParent.children[0]!.children.length > 0
) {
blockParent = blockParent.children[0]!;
}
contentInnerRef.current.style.transform = "translateY(0)";
const refTop = contentInnerRef.current.getBoundingClientRect().top;
let snapParent: Element = blockParent;
let snap = overflow;
while (true) {
let found = false;
for (let i = 0; i < snapParent.children.length; i++) {
const child = snapParent.children[i] as HTMLElement;
const rect = child.getBoundingClientRect();
const top = rect.top - refTop;
const bottom = top + rect.height;
if (top < snap && snap < bottom) {
if (
child.children.length > 0 &&
CONTAINER_TAGS.has(child.tagName)
) {
snapParent = child;
found = true;
break;
}
snap = bottom;
found = true;
break;
}
}
if (!found) break;
if (snap !== overflow) break;
}
overflow = snap;
}
contentInnerRef.current.style.transform =
overflow > 0 ? `translateY(-${overflow}px)` : "translateY(0)";
}, [isStreaming, renderContent, content, displayContent, maxLines]);
const handleDownload = () => {
const sanitizedTitle = title.replace(/[^a-z0-9]/gi, "_").toLowerCase();
@@ -160,25 +232,25 @@ export default function ExpandableTextDisplay({
const textToDisplay = displayContent ?? content;
if (isStreaming) {
// During streaming: use max-height with overflow-auto to create scrollable container,
// then scroll to bottom to show latest content (handled by useLayoutEffect above).
// We can't use line-clamp here because it sets overflow:hidden and shows from top,
// but we need scrollable overflow to show the latest (bottom) content.
// During streaming: use max-height with overflow-hidden and CSS transform to shift
// content upward, showing the latest content from the bottom without scroll jitter.
// Line height is approximately 1.5rem (24px) for body text.
// We show a top ellipsis indicator when content is truncated.
return (
<div>
{isTruncated && (
<Text as="span" mainUiMuted text03>
<Text as="p" text03 mainUiMuted className="!my-0">
</Text>
)}
<div
ref={scrollRef}
className="overflow-auto no-scrollbar"
className="overflow-hidden"
style={{ maxHeight: `calc(${maxLines} * 1.5rem)` }}
>
{renderContent!(textToDisplay, false)}
<div ref={contentInnerRef}>
{renderContent!(textToDisplay, false)}
</div>
</div>
</div>
);
@@ -234,7 +306,7 @@ export default function ExpandableTextDisplay({
{/* Expand button - only show when content is truncated */}
<div className="flex justify-end items-end mt-1 w-8">
<div className="flex justify-end self-end mt-1 w-8">
{isTruncated && (
<IconButton
internal

View File

@@ -426,7 +426,7 @@ export default function AgentsNavigationPage() {
<SettingsLayouts.Header
icon={SvgOnyxOctagon}
title="Agents & Assistants"
description="Customize AI behavior and knowledge for you and your teams use cases."
description="Customize AI behavior and knowledge for you and your team's use cases."
rightChildren={
<div data-testid="AgentsPage/new-agent-button">
<Button href="/app/agents/create" leftIcon={SvgPlus}>

Some files were not shown because too many files have changed in this diff Show More