Compare commits

..

9 Commits

Author SHA1 Message Date
Evan Lohn
de7fc36fc5 test: no vector db user file processing (#8854) 2026-02-28 04:19:59 +00:00
Evan Lohn
7f9e37450d fix: non vector db tasks (#8849) 2026-02-28 03:51:57 +00:00
Evan Lohn
c7ef85b733 chore: narrow no_vector_db supported scope (#8847) 2026-02-28 02:54:15 +00:00
Danelegend
bd9319e592 feat: LLM Provider Rework (#8761)
Co-authored-by: Raunak Bhagat <r@rabh.io>
2026-02-28 01:29:49 +00:00
Nikolas Garza
db5955d6f2 fix(ee): show Access Restricted page when seat limit exceeded (#8877) 2026-02-28 01:26:00 +00:00
Raunak Bhagat
5e447440ea refactor(Suggestions): migrate to opal Interactive + Content (#8881) 2026-02-27 23:39:20 +00:00
Justin Tahara
78c6ca39b8 fix(minio): No cURL in minio container (#8876) 2026-02-27 22:37:42 +00:00
Raunak Bhagat
71a7cf09b3 refactor(opal): migrate LineItemLayout to Content/ContentAction (#8824) 2026-02-27 22:27:09 +00:00
dependabot[bot]
91d30a0156 chore(deps): bump actions/download-artifact from 4.2.1 to 7.0.0 (#8474)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-02-27 22:11:03 +00:00
156 changed files with 4179 additions and 2002 deletions

View File

@@ -114,8 +114,10 @@ jobs:
- name: Mark workflow as failed if cherry-pick failed
if: steps.gate.outputs.should_cherrypick == 'true' && steps.run_cherry_pick.outputs.status == 'failure'
env:
CHERRY_PICK_REASON: ${{ steps.run_cherry_pick.outputs.reason }}
run: |
echo "::error::Automated cherry-pick failed (${{ steps.run_cherry_pick.outputs.reason }})."
echo "::error::Automated cherry-pick failed (${CHERRY_PICK_REASON})."
exit 1
notify-slack-on-cherry-pick-failure:

View File

@@ -603,7 +603,7 @@ jobs:
pull-requests: write
steps:
- name: Download visual diff summaries
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # ratchet:actions/download-artifact@v4
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
with:
pattern: screenshot-diff-summary-*
path: summaries/

View File

@@ -20,6 +20,7 @@ from ee.onyx.server.enterprise_settings.store import (
from ee.onyx.server.enterprise_settings.store import upload_logo
from onyx.context.search.enums import RecencyBiasSetting
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.llm import fetch_existing_llm_provider
from onyx.db.llm import update_default_provider
from onyx.db.llm import upsert_llm_provider
from onyx.db.models import Tool
@@ -117,15 +118,38 @@ def _seed_custom_tools(db_session: Session, tools: List[CustomToolSeed]) -> None
def _seed_llms(
db_session: Session, llm_upsert_requests: list[LLMProviderUpsertRequest]
) -> None:
if llm_upsert_requests:
logger.notice("Seeding LLMs")
seeded_providers = [
upsert_llm_provider(llm_upsert_request, db_session)
for llm_upsert_request in llm_upsert_requests
]
update_default_provider(
provider_id=seeded_providers[0].id, db_session=db_session
)
if not llm_upsert_requests:
return
logger.notice("Seeding LLMs")
for request in llm_upsert_requests:
existing = fetch_existing_llm_provider(name=request.name, db_session=db_session)
if existing:
request.id = existing.id
seeded_providers = [
upsert_llm_provider(llm_upsert_request, db_session)
for llm_upsert_request in llm_upsert_requests
]
default_provider = next(
(p for p in seeded_providers if p.model_configurations), None
)
if not default_provider:
return
visible_configs = [
mc for mc in default_provider.model_configurations if mc.is_visible
]
default_config = (
visible_configs[0]
if visible_configs
else default_provider.model_configurations[0]
)
update_default_provider(
provider_id=default_provider.id,
model_name=default_config.name,
db_session=db_session,
)
def _seed_personas(db_session: Session, personas: list[PersonaUpsertRequest]) -> None:

View File

@@ -109,6 +109,12 @@ def apply_license_status_to_settings(settings: Settings) -> Settings:
if metadata.status == _BLOCKING_STATUS:
settings.application_status = metadata.status
settings.ee_features_enabled = False
elif metadata.used_seats > metadata.seats:
# License is valid but seat limit exceeded
settings.application_status = ApplicationStatus.SEAT_LIMIT_EXCEEDED
settings.seat_count = metadata.seats
settings.used_seats = metadata.used_seats
settings.ee_features_enabled = True
else:
# Has a valid license (GRACE_PERIOD/PAYMENT_REMINDER still allow EE features)
settings.ee_features_enabled = True

View File

@@ -33,6 +33,7 @@ from onyx.configs.constants import MilestoneRecordType
from onyx.db.engine.sql_engine import get_session_with_shared_schema
from onyx.db.engine.sql_engine import get_session_with_tenant
from onyx.db.image_generation import create_default_image_gen_config_from_api_key
from onyx.db.llm import fetch_existing_llm_provider
from onyx.db.llm import update_default_provider
from onyx.db.llm import upsert_cloud_embedding_provider
from onyx.db.llm import upsert_llm_provider
@@ -302,12 +303,17 @@ def configure_default_api_keys(db_session: Session) -> None:
has_set_default_provider = False
def _upsert(request: LLMProviderUpsertRequest) -> None:
def _upsert(request: LLMProviderUpsertRequest, default_model: str) -> None:
nonlocal has_set_default_provider
try:
existing = fetch_existing_llm_provider(
name=request.name, db_session=db_session
)
if existing:
request.id = existing.id
provider = upsert_llm_provider(request, db_session)
if not has_set_default_provider:
update_default_provider(provider.id, db_session)
update_default_provider(provider.id, default_model, db_session)
has_set_default_provider = True
except Exception as e:
logger.error(f"Failed to configure {request.provider} provider: {e}")
@@ -325,14 +331,13 @@ def configure_default_api_keys(db_session: Session) -> None:
name="OpenAI",
provider=OPENAI_PROVIDER_NAME,
api_key=OPENAI_DEFAULT_API_KEY,
default_model_name=default_model_name,
model_configurations=_build_model_configuration_upsert_requests(
OPENAI_PROVIDER_NAME, recommendations
),
api_key_changed=True,
is_auto_mode=True,
)
_upsert(openai_provider)
_upsert(openai_provider, default_model_name)
# Create default image generation config using the OpenAI API key
try:
@@ -361,14 +366,13 @@ def configure_default_api_keys(db_session: Session) -> None:
name="Anthropic",
provider=ANTHROPIC_PROVIDER_NAME,
api_key=ANTHROPIC_DEFAULT_API_KEY,
default_model_name=default_model_name,
model_configurations=_build_model_configuration_upsert_requests(
ANTHROPIC_PROVIDER_NAME, recommendations
),
api_key_changed=True,
is_auto_mode=True,
)
_upsert(anthropic_provider)
_upsert(anthropic_provider, default_model_name)
else:
logger.info(
"ANTHROPIC_DEFAULT_API_KEY not set, skipping Anthropic provider configuration"
@@ -393,14 +397,13 @@ def configure_default_api_keys(db_session: Session) -> None:
name="Google Vertex AI",
provider=VERTEXAI_PROVIDER_NAME,
custom_config=custom_config,
default_model_name=default_model_name,
model_configurations=_build_model_configuration_upsert_requests(
VERTEXAI_PROVIDER_NAME, recommendations
),
api_key_changed=True,
is_auto_mode=True,
)
_upsert(vertexai_provider)
_upsert(vertexai_provider, default_model_name)
else:
logger.info(
"VERTEXAI_DEFAULT_CREDENTIALS not set, skipping Vertex AI provider configuration"
@@ -432,12 +435,11 @@ def configure_default_api_keys(db_session: Session) -> None:
name="OpenRouter",
provider=OPENROUTER_PROVIDER_NAME,
api_key=OPENROUTER_DEFAULT_API_KEY,
default_model_name=default_model_name,
model_configurations=model_configurations,
api_key_changed=True,
is_auto_mode=True,
)
_upsert(openrouter_provider)
_upsert(openrouter_provider, default_model_name)
else:
logger.info(
"OPENROUTER_DEFAULT_API_KEY not set, skipping OpenRouter provider configuration"

View File

@@ -241,8 +241,7 @@ _VECTOR_DB_BEAT_TASK_NAMES: set[str] = {
"check-for-index-attempt-cleanup",
"check-for-doc-permissions-sync",
"check-for-external-group-sync",
"check-for-documents-for-opensearch-migration",
"migrate-documents-from-vespa-to-opensearch",
"migrate-chunks-from-vespa-to-opensearch",
}
if DISABLE_VECTOR_DB:

View File

@@ -414,34 +414,31 @@ def _process_user_file_with_indexing(
raise RuntimeError(f"Indexing pipeline failed for user file {user_file_id}")
@shared_task(
name=OnyxCeleryTask.PROCESS_SINGLE_USER_FILE,
bind=True,
ignore_result=True,
)
def process_single_user_file(
self: Task, *, user_file_id: str, tenant_id: str # noqa: ARG001
def _process_user_file_impl(
*, user_file_id: str, tenant_id: str, redis_locking: bool
) -> None:
task_logger.info(f"process_single_user_file - Starting id={user_file_id}")
"""Core implementation for processing a single user file.
When redis_locking=True, acquires a per-file Redis lock and clears the
queued-key guard (Celery path). When redis_locking=False, skips all Redis
operations (BackgroundTask path).
"""
task_logger.info(f"_process_user_file_impl - Starting id={user_file_id}")
start = time.monotonic()
redis_client = get_redis_client(tenant_id=tenant_id)
# Clear the "queued" guard set by the beat generator so that the next beat
# cycle can re-enqueue this file if it is still in PROCESSING state after
# this task completes or fails.
redis_client.delete(_user_file_queued_key(user_file_id))
file_lock: RedisLock = redis_client.lock(
_user_file_lock_key(user_file_id),
timeout=CELERY_USER_FILE_PROCESSING_LOCK_TIMEOUT,
)
if not file_lock.acquire(blocking=False):
task_logger.info(
f"process_single_user_file - Lock held, skipping user_file_id={user_file_id}"
file_lock: RedisLock | None = None
if redis_locking:
redis_client = get_redis_client(tenant_id=tenant_id)
redis_client.delete(_user_file_queued_key(user_file_id))
file_lock = redis_client.lock(
_user_file_lock_key(user_file_id),
timeout=CELERY_USER_FILE_PROCESSING_LOCK_TIMEOUT,
)
return None
if file_lock is not None and not file_lock.acquire(blocking=False):
task_logger.info(
f"_process_user_file_impl - Lock held, skipping user_file_id={user_file_id}"
)
return
documents: list[Document] = []
try:
@@ -449,15 +446,15 @@ def process_single_user_file(
uf = db_session.get(UserFile, _as_uuid(user_file_id))
if not uf:
task_logger.warning(
f"process_single_user_file - UserFile not found id={user_file_id}"
f"_process_user_file_impl - UserFile not found id={user_file_id}"
)
return None
return
if uf.status != UserFileStatus.PROCESSING:
task_logger.info(
f"process_single_user_file - Skipping id={user_file_id} status={uf.status}"
f"_process_user_file_impl - Skipping id={user_file_id} status={uf.status}"
)
return None
return
connector = LocalFileConnector(
file_locations=[uf.file_id],
@@ -471,7 +468,6 @@ def process_single_user_file(
[doc for doc in batch if not isinstance(doc, HierarchyNode)]
)
# update the document id to userfile id in the documents
for document in documents:
document.id = str(user_file_id)
document.source = DocumentSource.USER_FILE
@@ -493,9 +489,8 @@ def process_single_user_file(
except Exception as e:
task_logger.exception(
f"process_single_user_file - Error processing file id={user_file_id} - {e.__class__.__name__}"
f"_process_user_file_impl - Error processing file id={user_file_id} - {e.__class__.__name__}"
)
# don't update the status if the user file is being deleted
current_user_file = db_session.get(UserFile, _as_uuid(user_file_id))
if (
current_user_file
@@ -504,33 +499,42 @@ def process_single_user_file(
uf.status = UserFileStatus.FAILED
db_session.add(uf)
db_session.commit()
return None
return
elapsed = time.monotonic() - start
task_logger.info(
f"process_single_user_file - Finished id={user_file_id} docs={len(documents)} elapsed={elapsed:.2f}s"
f"_process_user_file_impl - Finished id={user_file_id} docs={len(documents)} elapsed={elapsed:.2f}s"
)
return None
except Exception as e:
# Attempt to mark the file as failed
with get_session_with_current_tenant() as db_session:
uf = db_session.get(UserFile, _as_uuid(user_file_id))
if uf:
# don't update the status if the user file is being deleted
if uf.status != UserFileStatus.DELETING:
uf.status = UserFileStatus.FAILED
db_session.add(uf)
db_session.commit()
task_logger.exception(
f"process_single_user_file - Error processing file id={user_file_id} - {e.__class__.__name__}"
f"_process_user_file_impl - Error processing file id={user_file_id} - {e.__class__.__name__}"
)
return None
finally:
if file_lock.owned():
if file_lock is not None and file_lock.owned():
file_lock.release()
@shared_task(
name=OnyxCeleryTask.PROCESS_SINGLE_USER_FILE,
bind=True,
ignore_result=True,
)
def process_single_user_file(
self: Task, *, user_file_id: str, tenant_id: str # noqa: ARG001
) -> None:
_process_user_file_impl(
user_file_id=user_file_id, tenant_id=tenant_id, redis_locking=True
)
@shared_task(
name=OnyxCeleryTask.CHECK_FOR_USER_FILE_DELETE,
soft_time_limit=300,
@@ -581,36 +585,38 @@ def check_for_user_file_delete(self: Task, *, tenant_id: str) -> None:
return None
@shared_task(
name=OnyxCeleryTask.DELETE_SINGLE_USER_FILE,
bind=True,
ignore_result=True,
)
def process_single_user_file_delete(
self: Task, *, user_file_id: str, tenant_id: str # noqa: ARG001
def _delete_user_file_impl(
*, user_file_id: str, tenant_id: str, redis_locking: bool
) -> None:
"""Process a single user file delete."""
task_logger.info(f"process_single_user_file_delete - Starting id={user_file_id}")
redis_client = get_redis_client(tenant_id=tenant_id)
file_lock: RedisLock = redis_client.lock(
_user_file_delete_lock_key(user_file_id),
timeout=CELERY_GENERIC_BEAT_LOCK_TIMEOUT,
)
if not file_lock.acquire(blocking=False):
task_logger.info(
f"process_single_user_file_delete - Lock held, skipping user_file_id={user_file_id}"
"""Core implementation for deleting a single user file.
When redis_locking=True, acquires a per-file Redis lock (Celery path).
When redis_locking=False, skips Redis operations (BackgroundTask path).
"""
task_logger.info(f"_delete_user_file_impl - Starting id={user_file_id}")
file_lock: RedisLock | None = None
if redis_locking:
redis_client = get_redis_client(tenant_id=tenant_id)
file_lock = redis_client.lock(
_user_file_delete_lock_key(user_file_id),
timeout=CELERY_GENERIC_BEAT_LOCK_TIMEOUT,
)
return None
if file_lock is not None and not file_lock.acquire(blocking=False):
task_logger.info(
f"_delete_user_file_impl - Lock held, skipping user_file_id={user_file_id}"
)
return
try:
with get_session_with_current_tenant() as db_session:
user_file = db_session.get(UserFile, _as_uuid(user_file_id))
if not user_file:
task_logger.info(
f"process_single_user_file_delete - User file not found id={user_file_id}"
f"_delete_user_file_impl - User file not found id={user_file_id}"
)
return None
return
# 1) Delete vector DB chunks (skip when disabled)
if not DISABLE_VECTOR_DB:
if MANAGED_VESPA:
httpx_init_vespa_pool(
@@ -648,7 +654,6 @@ def process_single_user_file_delete(
chunk_count=chunk_count,
)
# 2) Delete the user-uploaded file content from filestore (blob + metadata)
file_store = get_default_file_store()
try:
file_store.delete_file(user_file.file_id)
@@ -656,26 +661,33 @@ def process_single_user_file_delete(
user_file_id_to_plaintext_file_name(user_file.id)
)
except Exception as e:
# This block executed only if the file is not found in the filestore
task_logger.exception(
f"process_single_user_file_delete - Error deleting file id={user_file.id} - {e.__class__.__name__}"
f"_delete_user_file_impl - Error deleting file id={user_file.id} - {e.__class__.__name__}"
)
# 3) Finally, delete the UserFile row
db_session.delete(user_file)
db_session.commit()
task_logger.info(
f"process_single_user_file_delete - Completed id={user_file_id}"
)
task_logger.info(f"_delete_user_file_impl - Completed id={user_file_id}")
except Exception as e:
task_logger.exception(
f"process_single_user_file_delete - Error processing file id={user_file_id} - {e.__class__.__name__}"
f"_delete_user_file_impl - Error processing file id={user_file_id} - {e.__class__.__name__}"
)
return None
finally:
if file_lock.owned():
if file_lock is not None and file_lock.owned():
file_lock.release()
return None
@shared_task(
name=OnyxCeleryTask.DELETE_SINGLE_USER_FILE,
bind=True,
ignore_result=True,
)
def process_single_user_file_delete(
self: Task, *, user_file_id: str, tenant_id: str # noqa: ARG001
) -> None:
_delete_user_file_impl(
user_file_id=user_file_id, tenant_id=tenant_id, redis_locking=True
)
@shared_task(
@@ -747,32 +759,30 @@ def check_for_user_file_project_sync(self: Task, *, tenant_id: str) -> None:
return None
@shared_task(
name=OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
bind=True,
ignore_result=True,
)
def process_single_user_file_project_sync(
self: Task, *, user_file_id: str, tenant_id: str # noqa: ARG001
def _project_sync_user_file_impl(
*, user_file_id: str, tenant_id: str, redis_locking: bool
) -> None:
"""Process a single user file project sync."""
task_logger.info(
f"process_single_user_file_project_sync - Starting id={user_file_id}"
)
"""Core implementation for syncing a user file's project/persona metadata.
redis_client = get_redis_client(tenant_id=tenant_id)
redis_client.delete(_user_file_project_sync_queued_key(user_file_id))
When redis_locking=True, acquires a per-file Redis lock and clears the
queued-key guard (Celery path). When redis_locking=False, skips Redis
operations (BackgroundTask path).
"""
task_logger.info(f"_project_sync_user_file_impl - Starting id={user_file_id}")
file_lock: RedisLock = redis_client.lock(
user_file_project_sync_lock_key(user_file_id),
timeout=CELERY_USER_FILE_PROJECT_SYNC_LOCK_TIMEOUT,
)
if not file_lock.acquire(blocking=False):
task_logger.info(
f"process_single_user_file_project_sync - Lock held, skipping user_file_id={user_file_id}"
file_lock: RedisLock | None = None
if redis_locking:
redis_client = get_redis_client(tenant_id=tenant_id)
redis_client.delete(_user_file_project_sync_queued_key(user_file_id))
file_lock = redis_client.lock(
user_file_project_sync_lock_key(user_file_id),
timeout=CELERY_USER_FILE_PROJECT_SYNC_LOCK_TIMEOUT,
)
return None
if file_lock is not None and not file_lock.acquire(blocking=False):
task_logger.info(
f"_project_sync_user_file_impl - Lock held, skipping user_file_id={user_file_id}"
)
return
try:
with get_session_with_current_tenant() as db_session:
@@ -783,11 +793,10 @@ def process_single_user_file_project_sync(
).scalar_one_or_none()
if not user_file:
task_logger.info(
f"process_single_user_file_project_sync - User file not found id={user_file_id}"
f"_project_sync_user_file_impl - User file not found id={user_file_id}"
)
return None
return
# Sync project metadata to vector DB (skip when disabled)
if not DISABLE_VECTOR_DB:
if MANAGED_VESPA:
httpx_init_vespa_pool(
@@ -822,7 +831,7 @@ def process_single_user_file_project_sync(
)
task_logger.info(
f"process_single_user_file_project_sync - User file id={user_file_id}"
f"_project_sync_user_file_impl - User file id={user_file_id}"
)
user_file.needs_project_sync = False
@@ -835,11 +844,21 @@ def process_single_user_file_project_sync(
except Exception as e:
task_logger.exception(
f"process_single_user_file_project_sync - Error syncing project for file id={user_file_id} - {e.__class__.__name__}"
f"_project_sync_user_file_impl - Error syncing project for file id={user_file_id} - {e.__class__.__name__}"
)
return None
finally:
if file_lock.owned():
if file_lock is not None and file_lock.owned():
file_lock.release()
return None
@shared_task(
name=OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
bind=True,
ignore_result=True,
)
def process_single_user_file_project_sync(
self: Task, *, user_file_id: str, tenant_id: str # noqa: ARG001
) -> None:
_project_sync_user_file_impl(
user_file_id=user_file_id, tenant_id=tenant_id, redis_locking=True
)

View File

@@ -202,7 +202,6 @@ def create_default_image_gen_config_from_api_key(
api_key=api_key,
api_base=None,
api_version=None,
default_model_name=model_name,
deployment_name=None,
is_public=True,
)

View File

@@ -213,11 +213,29 @@ def upsert_llm_provider(
llm_provider_upsert_request: LLMProviderUpsertRequest,
db_session: Session,
) -> LLMProviderView:
existing_llm_provider = fetch_existing_llm_provider(
name=llm_provider_upsert_request.name, db_session=db_session
)
existing_llm_provider: LLMProviderModel | None = None
if llm_provider_upsert_request.id:
existing_llm_provider = fetch_existing_llm_provider_by_id(
id=llm_provider_upsert_request.id, db_session=db_session
)
if not existing_llm_provider:
raise ValueError(
f"LLM provider with id {llm_provider_upsert_request.id} not found"
)
if not existing_llm_provider:
if existing_llm_provider.name != llm_provider_upsert_request.name:
raise ValueError(
f"LLM provider with id {llm_provider_upsert_request.id} name change not allowed"
)
else:
existing_llm_provider = fetch_existing_llm_provider(
name=llm_provider_upsert_request.name, db_session=db_session
)
if existing_llm_provider:
raise ValueError(
f"LLM provider with name '{llm_provider_upsert_request.name}'"
" already exists"
)
existing_llm_provider = LLMProviderModel(name=llm_provider_upsert_request.name)
db_session.add(existing_llm_provider)
@@ -238,11 +256,7 @@ def upsert_llm_provider(
existing_llm_provider.api_base = api_base
existing_llm_provider.api_version = llm_provider_upsert_request.api_version
existing_llm_provider.custom_config = custom_config
# TODO: Remove default model name on api change
# Needed due to /provider/{id}/default endpoint not disclosing the default model name
existing_llm_provider.default_model_name = (
llm_provider_upsert_request.default_model_name
)
existing_llm_provider.is_public = llm_provider_upsert_request.is_public
existing_llm_provider.is_auto_mode = llm_provider_upsert_request.is_auto_mode
existing_llm_provider.deployment_name = llm_provider_upsert_request.deployment_name
@@ -306,15 +320,6 @@ def upsert_llm_provider(
display_name=model_config.display_name,
)
default_model = fetch_default_model(db_session, LLMModelFlowType.CHAT)
if default_model and default_model.llm_provider_id == existing_llm_provider.id:
_update_default_model(
db_session=db_session,
provider_id=existing_llm_provider.id,
model=existing_llm_provider.default_model_name,
flow_type=LLMModelFlowType.CHAT,
)
# Make sure the relationship table stays up to date
update_group_llm_provider_relationships__no_commit(
llm_provider_id=existing_llm_provider.id,
@@ -488,6 +493,22 @@ def fetch_existing_llm_provider(
return provider_model
def fetch_existing_llm_provider_by_id(
id: int, db_session: Session
) -> LLMProviderModel | None:
provider_model = db_session.scalar(
select(LLMProviderModel)
.where(LLMProviderModel.id == id)
.options(
selectinload(LLMProviderModel.model_configurations),
selectinload(LLMProviderModel.groups),
selectinload(LLMProviderModel.personas),
)
)
return provider_model
def fetch_embedding_provider(
db_session: Session, provider_type: EmbeddingProvider
) -> CloudEmbeddingProviderModel | None:
@@ -604,22 +625,13 @@ def remove_llm_provider__no_commit(db_session: Session, provider_id: int) -> Non
db_session.flush()
def update_default_provider(provider_id: int, db_session: Session) -> None:
# Attempt to get the default_model_name from the provider first
# TODO: Remove default_model_name check
provider = db_session.scalar(
select(LLMProviderModel).where(
LLMProviderModel.id == provider_id,
)
)
if provider is None:
raise ValueError(f"LLM Provider with id={provider_id} does not exist")
def update_default_provider(
provider_id: int, model_name: str, db_session: Session
) -> None:
_update_default_model(
db_session,
provider_id,
provider.default_model_name, # type: ignore[arg-type]
model_name,
LLMModelFlowType.CHAT,
)
@@ -805,12 +817,6 @@ def sync_auto_mode_models(
)
changes += 1
# In Auto mode, default model is always set from GitHub config
default_model = llm_recommendations.get_default_model(provider.provider)
if default_model and provider.default_model_name != default_model.name:
provider.default_model_name = default_model.name
changes += 1
db_session.commit()
return changes

View File

@@ -37,6 +37,7 @@ from onyx.configs.app_configs import APP_HOST
from onyx.configs.app_configs import APP_PORT
from onyx.configs.app_configs import AUTH_RATE_LIMITING_ENABLED
from onyx.configs.app_configs import AUTH_TYPE
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.configs.app_configs import LOG_ENDPOINT_LATENCY
from onyx.configs.app_configs import OAUTH_CLIENT_ID
from onyx.configs.app_configs import OAUTH_CLIENT_SECRET
@@ -254,8 +255,38 @@ def include_auth_router_with_prefix(
)
def validate_no_vector_db_settings() -> None:
"""Validate that DISABLE_VECTOR_DB is not combined with incompatible settings.
Raises RuntimeError if DISABLE_VECTOR_DB is set alongside MULTI_TENANT or ENABLE_CRAFT,
since these modes require infrastructure that is removed in no-vector-DB deployments.
"""
if not DISABLE_VECTOR_DB:
return
if MULTI_TENANT:
raise RuntimeError(
"DISABLE_VECTOR_DB cannot be used with MULTI_TENANT. "
"Multi-tenant deployments require the vector database for "
"per-tenant document indexing and search. Run in single-tenant "
"mode when disabling the vector database."
)
from onyx.server.features.build.configs import ENABLE_CRAFT
if ENABLE_CRAFT:
raise RuntimeError(
"DISABLE_VECTOR_DB cannot be used with ENABLE_CRAFT. "
"Onyx Craft requires background workers for sandbox lifecycle "
"management, which are removed in no-vector-DB deployments. "
"Disable Craft (ENABLE_CRAFT=false) when disabling the vector database."
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # noqa: ARG001
validate_no_vector_db_settings()
# Set recursion limit
if SYSTEM_RECURSION_LIMIT is not None:
sys.setrecursionlimit(SYSTEM_RECURSION_LIMIT)

View File

@@ -97,7 +97,6 @@ def _build_llm_provider_request(
), # Only this from source
api_base=api_base, # From request
api_version=api_version, # From request
default_model_name=model_name,
deployment_name=deployment_name, # From request
is_public=True,
groups=[],
@@ -136,7 +135,6 @@ def _build_llm_provider_request(
api_key=api_key,
api_base=api_base,
api_version=api_version,
default_model_name=model_name,
deployment_name=deployment_name,
is_public=True,
groups=[],
@@ -168,7 +166,6 @@ def _create_image_gen_llm_provider__no_commit(
api_key=provider_request.api_key,
api_base=provider_request.api_base,
api_version=provider_request.api_version,
default_model_name=provider_request.default_model_name,
deployment_name=provider_request.deployment_name,
is_public=provider_request.is_public,
custom_config=provider_request.custom_config,

View File

@@ -22,7 +22,10 @@ from onyx.auth.users import current_chat_accessible_user
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import LLMModelFlowType
from onyx.db.llm import can_user_access_llm_provider
from onyx.db.llm import fetch_default_llm_model
from onyx.db.llm import fetch_default_vision_model
from onyx.db.llm import fetch_existing_llm_provider
from onyx.db.llm import fetch_existing_llm_provider_by_id
from onyx.db.llm import fetch_existing_llm_providers
from onyx.db.llm import fetch_existing_models
from onyx.db.llm import fetch_persona_with_groups
@@ -52,11 +55,12 @@ from onyx.llm.well_known_providers.llm_provider_options import (
)
from onyx.server.manage.llm.models import BedrockFinalModelResponse
from onyx.server.manage.llm.models import BedrockModelsRequest
from onyx.server.manage.llm.models import DefaultModel
from onyx.server.manage.llm.models import LLMCost
from onyx.server.manage.llm.models import LLMProviderDescriptor
from onyx.server.manage.llm.models import LLMProviderResponse
from onyx.server.manage.llm.models import LLMProviderUpsertRequest
from onyx.server.manage.llm.models import LLMProviderView
from onyx.server.manage.llm.models import ModelConfigurationUpsertRequest
from onyx.server.manage.llm.models import OllamaFinalModelResponse
from onyx.server.manage.llm.models import OllamaModelDetails
from onyx.server.manage.llm.models import OllamaModelsRequest
@@ -233,12 +237,9 @@ def test_llm_configuration(
test_api_key = test_llm_request.api_key
test_custom_config = test_llm_request.custom_config
if test_llm_request.name:
# NOTE: we are querying by name. we probably should be querying by an invariant id, but
# as it turns out the name is not editable in the UI and other code also keys off name,
# so we won't rock the boat just yet.
existing_provider = fetch_existing_llm_provider(
name=test_llm_request.name, db_session=db_session
if test_llm_request.id:
existing_provider = fetch_existing_llm_provider_by_id(
id=test_llm_request.id, db_session=db_session
)
if existing_provider:
test_custom_config = _restore_masked_custom_config_values(
@@ -268,7 +269,7 @@ def test_llm_configuration(
llm = get_llm(
provider=test_llm_request.provider,
model=test_llm_request.default_model_name,
model=test_llm_request.model,
api_key=test_api_key,
api_base=test_llm_request.api_base,
api_version=test_llm_request.api_version,
@@ -303,7 +304,7 @@ def list_llm_providers(
include_image_gen: bool = Query(False),
_: User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> list[LLMProviderView]:
) -> LLMProviderResponse[LLMProviderView]:
start_time = datetime.now(timezone.utc)
logger.debug("Starting to fetch LLM providers")
@@ -328,7 +329,15 @@ def list_llm_providers(
duration = (end_time - start_time).total_seconds()
logger.debug(f"Completed fetching LLM providers in {duration:.2f} seconds")
return llm_provider_list
return LLMProviderResponse[LLMProviderView].from_models(
providers=llm_provider_list,
default_text=DefaultModel.from_model_config(
fetch_default_llm_model(db_session)
),
default_vision=DefaultModel.from_model_config(
fetch_default_vision_model(db_session)
),
)
@admin_router.put("/provider")
@@ -344,18 +353,44 @@ def put_llm_provider(
# validate request (e.g. if we're intending to create but the name already exists we should throw an error)
# NOTE: may involve duplicate fetching to Postgres, but we're assuming SQLAlchemy is smart enough to cache
# the result
existing_provider = fetch_existing_llm_provider(
existing_provider = None
if llm_provider_upsert_request.id:
existing_provider = fetch_existing_llm_provider_by_id(
id=llm_provider_upsert_request.id, db_session=db_session
)
# Check name constraints
# TODO: Once port from name to id is complete, unique name will no longer be required
if existing_provider and llm_provider_upsert_request.name != existing_provider.name:
raise HTTPException(
status_code=400,
detail="Renaming providers is not currently supported",
)
found_provider = fetch_existing_llm_provider(
name=llm_provider_upsert_request.name, db_session=db_session
)
if found_provider is not None and found_provider is not existing_provider:
raise HTTPException(
status_code=400,
detail=f"Provider with name={llm_provider_upsert_request.name} already exists",
)
if existing_provider and is_creation:
raise HTTPException(
status_code=400,
detail=f"LLM Provider with name {llm_provider_upsert_request.name} already exists",
detail=(
f"LLM Provider with name {llm_provider_upsert_request.name} and "
f"id={llm_provider_upsert_request.id} already exists"
),
)
elif not existing_provider and not is_creation:
raise HTTPException(
status_code=400,
detail=f"LLM Provider with name {llm_provider_upsert_request.name} does not exist",
detail=(
f"LLM Provider with name {llm_provider_upsert_request.name} and "
f"id={llm_provider_upsert_request.id} does not exist"
),
)
# SSRF Protection: Validate api_base and custom_config match stored values
@@ -393,22 +428,6 @@ def put_llm_provider(
deduplicated_personas.append(persona_id)
llm_provider_upsert_request.personas = deduplicated_personas
default_model_found = False
for model_configuration in llm_provider_upsert_request.model_configurations:
if model_configuration.name == llm_provider_upsert_request.default_model_name:
model_configuration.is_visible = True
default_model_found = True
# TODO: Remove this logic on api change
# Believed to be a dead pathway but we want to be safe for now
if not default_model_found:
llm_provider_upsert_request.model_configurations.append(
ModelConfigurationUpsertRequest(
name=llm_provider_upsert_request.default_model_name, is_visible=True
)
)
# the llm api key is sanitized when returned to clients, so the only time we
# should get a real key is when it is explicitly changed
if existing_provider and not llm_provider_upsert_request.api_key_changed:
@@ -438,8 +457,8 @@ def put_llm_provider(
config = fetch_llm_recommendations_from_github()
if config and llm_provider_upsert_request.provider in config.providers:
# Refetch the provider to get the updated model
updated_provider = fetch_existing_llm_provider(
name=llm_provider_upsert_request.name, db_session=db_session
updated_provider = fetch_existing_llm_provider_by_id(
id=result.id, db_session=db_session
)
if updated_provider:
sync_auto_mode_models(
@@ -469,28 +488,29 @@ def delete_llm_provider(
raise HTTPException(status_code=404, detail=str(e))
@admin_router.post("/provider/{provider_id}/default")
@admin_router.post("/default")
def set_provider_as_default(
provider_id: int,
default_model_request: DefaultModel,
_: User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> None:
update_default_provider(provider_id=provider_id, db_session=db_session)
update_default_provider(
provider_id=default_model_request.provider_id,
model_name=default_model_request.model_name,
db_session=db_session,
)
@admin_router.post("/provider/{provider_id}/default-vision")
@admin_router.post("/default-vision")
def set_provider_as_default_vision(
provider_id: int,
vision_model: str | None = Query(
None, description="The default vision model to use"
),
default_model: DefaultModel,
_: User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> None:
if vision_model is None:
raise HTTPException(status_code=404, detail="Vision model not provided")
update_default_vision_provider(
provider_id=provider_id, vision_model=vision_model, db_session=db_session
provider_id=default_model.provider_id,
vision_model=default_model.model_name,
db_session=db_session,
)
@@ -516,7 +536,7 @@ def get_auto_config(
def get_vision_capable_providers(
_: User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> list[VisionProviderResponse]:
) -> LLMProviderResponse[VisionProviderResponse]:
"""Return a list of LLM providers and their models that support image input"""
vision_models = fetch_existing_models(
db_session=db_session, flow_types=[LLMModelFlowType.VISION]
@@ -545,7 +565,13 @@ def get_vision_capable_providers(
]
logger.debug(f"Found {len(vision_provider_response)} vision-capable providers")
return vision_provider_response
return LLMProviderResponse[VisionProviderResponse].from_models(
providers=vision_provider_response,
default_vision=DefaultModel.from_model_config(
fetch_default_vision_model(db_session)
),
)
"""Endpoints for all"""
@@ -555,7 +581,7 @@ def get_vision_capable_providers(
def list_llm_provider_basics(
user: User = Depends(current_chat_accessible_user),
db_session: Session = Depends(get_session),
) -> list[LLMProviderDescriptor]:
) -> LLMProviderResponse[LLMProviderDescriptor]:
"""Get LLM providers accessible to the current user.
Returns:
@@ -592,7 +618,15 @@ def list_llm_provider_basics(
f"Completed fetching {len(accessible_providers)} user-accessible providers in {duration:.2f} seconds"
)
return accessible_providers
return LLMProviderResponse[LLMProviderDescriptor].from_models(
providers=accessible_providers,
default_text=DefaultModel.from_model_config(
fetch_default_llm_model(db_session)
),
default_vision=DefaultModel.from_model_config(
fetch_default_vision_model(db_session)
),
)
def get_valid_model_names_for_persona(
@@ -635,7 +669,7 @@ def list_llm_providers_for_persona(
persona_id: int,
user: User = Depends(current_chat_accessible_user),
db_session: Session = Depends(get_session),
) -> list[LLMProviderDescriptor]:
) -> LLMProviderResponse[LLMProviderDescriptor]:
"""Get LLM providers for a specific persona.
Returns providers that the user can access when using this persona:
@@ -682,7 +716,51 @@ def list_llm_providers_for_persona(
f"Completed fetching {len(llm_provider_list)} LLM providers for persona {persona_id} in {duration:.2f} seconds"
)
return llm_provider_list
# Get the default model and vision model for the persona
# TODO: Port persona's over to use ID
persona_default_provider = persona.llm_model_provider_override
persona_default_model = persona.llm_model_version_override
default_text_model = fetch_default_llm_model(db_session)
default_vision_model = fetch_default_vision_model(db_session)
# Build default_text and default_vision using persona overrides when available,
# falling back to the global defaults.
default_text = DefaultModel.from_model_config(default_text_model)
default_vision = DefaultModel.from_model_config(default_vision_model)
if persona_default_provider:
provider = fetch_existing_llm_provider(persona_default_provider, db_session)
if provider and can_user_access_llm_provider(
provider, user_group_ids, persona, is_admin=is_admin
):
if persona_default_model:
# Persona specifies both provider and model — use them directly
default_text = DefaultModel(
provider_id=provider.id,
model_name=persona_default_model,
)
else:
# Persona specifies only the provider — pick a visible (public) model,
# falling back to any model on this provider
visible_model = next(
(mc for mc in provider.model_configurations if mc.is_visible),
None,
)
fallback_model = visible_model or next(
iter(provider.model_configurations), None
)
if fallback_model:
default_text = DefaultModel(
provider_id=provider.id,
model_name=fallback_model.name,
)
return LLMProviderResponse[LLMProviderDescriptor].from_models(
providers=llm_provider_list,
default_text=default_text,
default_vision=default_vision,
)
@admin_router.get("/provider-contextual-cost")

View File

@@ -1,5 +1,9 @@
from __future__ import annotations
from typing import Any
from typing import Generic
from typing import TYPE_CHECKING
from typing import TypeVar
from pydantic import BaseModel
from pydantic import Field
@@ -21,50 +25,22 @@ if TYPE_CHECKING:
ModelConfiguration as ModelConfigurationModel,
)
# TODO: Clear this up on api refactor
# There is still logic that requires sending each providers default model name
# There is no logic that requires sending the providers default vision model name
# We only send for the one that is actually the default
def get_default_llm_model_name(llm_provider_model: "LLMProviderModel") -> str:
"""Find the default conversation model name for a provider.
Returns the model name if found, otherwise returns empty string.
"""
for model_config in llm_provider_model.model_configurations:
for flow in model_config.llm_model_flows:
if flow.is_default and flow.llm_model_flow_type == LLMModelFlowType.CHAT:
return model_config.name
return ""
def get_default_vision_model_name(llm_provider_model: "LLMProviderModel") -> str | None:
"""Find the default vision model name for a provider.
Returns the model name if found, otherwise returns None.
"""
for model_config in llm_provider_model.model_configurations:
for flow in model_config.llm_model_flows:
if flow.is_default and flow.llm_model_flow_type == LLMModelFlowType.VISION:
return model_config.name
return None
T = TypeVar("T", "LLMProviderDescriptor", "LLMProviderView", "VisionProviderResponse")
class TestLLMRequest(BaseModel):
# provider level
name: str | None = None
id: int | None = None
provider: str
model: str
api_key: str | None = None
api_base: str | None = None
api_version: str | None = None
custom_config: dict[str, str] | None = None
# model level
default_model_name: str
deployment_name: str | None = None
model_configurations: list["ModelConfigurationUpsertRequest"]
# if try and use the existing API/custom config key
api_key_changed: bool
custom_config_changed: bool
@@ -80,13 +56,10 @@ class LLMProviderDescriptor(BaseModel):
"""A descriptor for an LLM provider that can be safely viewed by
non-admin users. Used when giving a list of available LLMs."""
id: int
name: str
provider: str
provider_display_name: str # Human-friendly name like "Claude (Anthropic)"
default_model_name: str
is_default_provider: bool | None
is_default_vision_provider: bool | None
default_vision_model: str | None
model_configurations: list["ModelConfigurationView"]
@classmethod
@@ -99,24 +72,12 @@ class LLMProviderDescriptor(BaseModel):
)
provider = llm_provider_model.provider
default_model_name = get_default_llm_model_name(llm_provider_model)
default_vision_model = get_default_vision_model_name(llm_provider_model)
is_default_provider = bool(default_model_name)
is_default_vision_provider = default_vision_model is not None
default_model_name = (
default_model_name or llm_provider_model.default_model_name or ""
)
return cls(
id=llm_provider_model.id,
name=llm_provider_model.name,
provider=provider,
provider_display_name=get_provider_display_name(provider),
default_model_name=default_model_name,
is_default_provider=is_default_provider,
is_default_vision_provider=is_default_vision_provider,
default_vision_model=default_vision_model,
model_configurations=filter_model_configurations(
llm_provider_model.model_configurations, provider
),
@@ -130,18 +91,17 @@ class LLMProvider(BaseModel):
api_base: str | None = None
api_version: str | None = None
custom_config: dict[str, str] | None = None
default_model_name: str
is_public: bool = True
is_auto_mode: bool = False
groups: list[int] = Field(default_factory=list)
personas: list[int] = Field(default_factory=list)
deployment_name: str | None = None
default_vision_model: str | None = None
class LLMProviderUpsertRequest(LLMProvider):
# should only be used for a "custom" provider
# for default providers, the built-in model names are used
id: int | None = None
api_key_changed: bool = False
custom_config_changed: bool = False
model_configurations: list["ModelConfigurationUpsertRequest"] = []
@@ -157,8 +117,6 @@ class LLMProviderView(LLMProvider):
"""Stripped down representation of LLMProvider for display / limited access info only"""
id: int
is_default_provider: bool | None = None
is_default_vision_provider: bool | None = None
model_configurations: list["ModelConfigurationView"]
@classmethod
@@ -180,16 +138,6 @@ class LLMProviderView(LLMProvider):
provider = llm_provider_model.provider
default_model_name = get_default_llm_model_name(llm_provider_model)
default_vision_model = get_default_vision_model_name(llm_provider_model)
is_default_provider = bool(default_model_name)
is_default_vision_provider = default_vision_model is not None
default_model_name = (
default_model_name or llm_provider_model.default_model_name or ""
)
return cls(
id=llm_provider_model.id,
name=llm_provider_model.name,
@@ -202,10 +150,6 @@ class LLMProviderView(LLMProvider):
api_base=llm_provider_model.api_base,
api_version=llm_provider_model.api_version,
custom_config=llm_provider_model.custom_config,
default_model_name=default_model_name,
is_default_provider=is_default_provider,
is_default_vision_provider=is_default_vision_provider,
default_vision_model=default_vision_model,
is_public=llm_provider_model.is_public,
is_auto_mode=llm_provider_model.is_auto_mode,
groups=groups,
@@ -425,3 +369,38 @@ class OpenRouterFinalModelResponse(BaseModel):
int | None
) # From OpenRouter API context_length (may be missing for some models)
supports_image_input: bool
class DefaultModel(BaseModel):
provider_id: int
model_name: str
@classmethod
def from_model_config(
cls, model_config: ModelConfigurationModel | None
) -> DefaultModel | None:
if not model_config:
return None
return cls(
provider_id=model_config.llm_provider_id,
model_name=model_config.name,
)
class LLMProviderResponse(BaseModel, Generic[T]):
providers: list[T]
default_text: DefaultModel | None = None
default_vision: DefaultModel | None = None
@classmethod
def from_models(
cls,
providers: list[T],
default_text: DefaultModel | None = None,
default_vision: DefaultModel | None = None,
) -> LLMProviderResponse[T]:
return cls(
providers=providers,
default_text=default_text,
default_vision=default_vision,
)

View File

@@ -19,6 +19,7 @@ class ApplicationStatus(str, Enum):
PAYMENT_REMINDER = "payment_reminder"
GRACE_PERIOD = "grace_period"
GATED_ACCESS = "gated_access"
SEAT_LIMIT_EXCEEDED = "seat_limit_exceeded"
class Notification(BaseModel):
@@ -82,6 +83,10 @@ class Settings(BaseModel):
# Default Assistant settings
disable_default_assistant: bool | None = False
# Seat usage - populated by license enforcement when seat limit is exceeded
seat_count: int | None = None
used_seats: int | None = None
# OpenSearch migration
opensearch_indexing_enabled: bool = False

View File

@@ -25,6 +25,7 @@ from onyx.db.enums import EmbeddingPrecision
from onyx.db.index_attempt import cancel_indexing_attempts_past_model
from onyx.db.index_attempt import expire_index_attempts
from onyx.db.llm import fetch_default_llm_model
from onyx.db.llm import fetch_existing_llm_provider
from onyx.db.llm import update_default_provider
from onyx.db.llm import upsert_llm_provider
from onyx.db.search_settings import get_active_search_settings
@@ -254,14 +255,18 @@ def setup_postgres(db_session: Session) -> None:
logger.notice("Setting up default OpenAI LLM for dev.")
llm_model = GEN_AI_MODEL_VERSION or "gpt-4o-mini"
provider_name = "DevEnvPresetOpenAI"
existing = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
model_req = LLMProviderUpsertRequest(
name="DevEnvPresetOpenAI",
id=existing.id if existing else None,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key=GEN_AI_API_KEY,
api_base=None,
api_version=None,
custom_config=None,
default_model_name=llm_model,
is_public=True,
groups=[],
model_configurations=[
@@ -273,7 +278,9 @@ def setup_postgres(db_session: Session) -> None:
new_llm_provider = upsert_llm_provider(
llm_provider_upsert_request=model_req, db_session=db_session
)
update_default_provider(provider_id=new_llm_provider.id, db_session=db_session)
update_default_provider(
provider_id=new_llm_provider.id, model_name=llm_model, db_session=db_session
)
def update_default_multipass_indexing(db_session: Session) -> None:

View File

@@ -17,7 +17,7 @@ def test_bedrock_llm_configuration(client: TestClient) -> None:
# Prepare the test request payload
test_request: dict[str, Any] = {
"provider": LlmProviderNames.BEDROCK,
"default_model_name": _DEFAULT_BEDROCK_MODEL,
"model": _DEFAULT_BEDROCK_MODEL,
"api_key": None,
"api_base": None,
"api_version": None,
@@ -44,7 +44,7 @@ def test_bedrock_llm_configuration_invalid_key(client: TestClient) -> None:
# Prepare the test request payload with invalid credentials
test_request: dict[str, Any] = {
"provider": LlmProviderNames.BEDROCK,
"default_model_name": _DEFAULT_BEDROCK_MODEL,
"model": _DEFAULT_BEDROCK_MODEL,
"api_key": None,
"api_base": None,
"api_version": None,

View File

@@ -28,7 +28,6 @@ def ensure_default_llm_provider(db_session: Session) -> None:
provider=LlmProviderNames.OPENAI,
api_key=os.environ.get("OPENAI_API_KEY", "test"),
is_public=True,
default_model_name="gpt-4o-mini",
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o-mini",
@@ -41,7 +40,7 @@ def ensure_default_llm_provider(db_session: Session) -> None:
llm_provider_upsert_request=llm_provider_request,
db_session=db_session,
)
update_default_provider(provider.id, db_session)
update_default_provider(provider.id, "gpt-4o-mini", db_session)
except Exception as exc: # pragma: no cover - only hits on duplicate setup issues
# Rollback to clear the pending transaction state
db_session.rollback()

View File

@@ -47,7 +47,6 @@ def test_answer_with_only_anthropic_provider(
name=provider_name,
provider=LlmProviderNames.ANTHROPIC,
api_key=anthropic_api_key,
default_model_name=anthropic_model,
is_public=True,
groups=[],
model_configurations=[
@@ -59,7 +58,7 @@ def test_answer_with_only_anthropic_provider(
)
try:
update_default_provider(anthropic_provider.id, db_session)
update_default_provider(anthropic_provider.id, anthropic_model, db_session)
test_user = create_test_user(db_session, email_prefix="anthropic_only")
chat_session = create_chat_session(

View File

@@ -29,6 +29,7 @@ from onyx.server.manage.llm.api import (
test_llm_configuration as run_test_llm_configuration,
)
from onyx.server.manage.llm.models import LLMProviderUpsertRequest
from onyx.server.manage.llm.models import LLMProviderView
from onyx.server.manage.llm.models import ModelConfigurationUpsertRequest
from onyx.server.manage.llm.models import TestLLMRequest as LLMTestRequest
@@ -44,15 +45,14 @@ def _create_test_provider(
db_session: Session,
name: str,
api_key: str = "sk-test-key-00000000000000000000000000000000000",
) -> None:
) -> LLMProviderView:
"""Helper to create a test LLM provider in the database."""
upsert_llm_provider(
return upsert_llm_provider(
LLMProviderUpsertRequest(
name=name,
provider=LlmProviderNames.OPENAI,
api_key=api_key,
api_key_changed=True,
default_model_name="gpt-4o-mini",
model_configurations=[
ModelConfigurationUpsertRequest(name="gpt-4o-mini", is_visible=True)
],
@@ -102,17 +102,11 @@ class TestLLMConfigurationEndpoint:
# This should complete without exception
run_test_llm_configuration(
test_llm_request=LLMTestRequest(
name=None, # New provider (not in DB)
provider=LlmProviderNames.OPENAI,
api_key="sk-new-test-key-0000000000000000000000000000",
api_key_changed=True,
custom_config_changed=False,
default_model_name="gpt-4o-mini",
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
)
],
model="gpt-4o-mini",
),
_=_create_mock_admin(),
db_session=db_session,
@@ -152,17 +146,11 @@ class TestLLMConfigurationEndpoint:
with pytest.raises(HTTPException) as exc_info:
run_test_llm_configuration(
test_llm_request=LLMTestRequest(
name=None,
provider=LlmProviderNames.OPENAI,
api_key="sk-invalid-key-00000000000000000000000000",
api_key_changed=True,
custom_config_changed=False,
default_model_name="gpt-4o-mini",
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
)
],
model="gpt-4o-mini",
),
_=_create_mock_admin(),
db_session=db_session,
@@ -194,7 +182,9 @@ class TestLLMConfigurationEndpoint:
try:
# First, create the provider in the database
_create_test_provider(db_session, provider_name, api_key=original_api_key)
provider = _create_test_provider(
db_session, provider_name, api_key=original_api_key
)
with patch(
"onyx.server.manage.llm.api.test_llm", side_effect=mock_test_llm_capture
@@ -202,17 +192,12 @@ class TestLLMConfigurationEndpoint:
# Test with api_key_changed=False - should use stored key
run_test_llm_configuration(
test_llm_request=LLMTestRequest(
name=provider_name, # Existing provider
id=provider.id,
provider=LlmProviderNames.OPENAI,
api_key=None, # Not providing a new key
api_key_changed=False, # Using existing key
custom_config_changed=False,
default_model_name="gpt-4o-mini",
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
)
],
model="gpt-4o-mini",
),
_=_create_mock_admin(),
db_session=db_session,
@@ -246,7 +231,9 @@ class TestLLMConfigurationEndpoint:
try:
# First, create the provider in the database
_create_test_provider(db_session, provider_name, api_key=original_api_key)
provider = _create_test_provider(
db_session, provider_name, api_key=original_api_key
)
with patch(
"onyx.server.manage.llm.api.test_llm", side_effect=mock_test_llm_capture
@@ -254,17 +241,12 @@ class TestLLMConfigurationEndpoint:
# Test with api_key_changed=True - should use new key
run_test_llm_configuration(
test_llm_request=LLMTestRequest(
name=provider_name, # Existing provider
id=provider.id,
provider=LlmProviderNames.OPENAI,
api_key=new_api_key, # Providing a new key
api_key_changed=True, # Key is being changed
custom_config_changed=False,
default_model_name="gpt-4o-mini",
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
)
],
model="gpt-4o-mini",
),
_=_create_mock_admin(),
db_session=db_session,
@@ -297,7 +279,7 @@ class TestLLMConfigurationEndpoint:
try:
# First, create the provider in the database with custom_config
upsert_llm_provider(
provider = upsert_llm_provider(
LLMProviderUpsertRequest(
name=provider_name,
provider=LlmProviderNames.OPENAI,
@@ -305,7 +287,6 @@ class TestLLMConfigurationEndpoint:
api_key_changed=True,
custom_config=original_custom_config,
custom_config_changed=True,
default_model_name="gpt-4o-mini",
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
@@ -321,18 +302,13 @@ class TestLLMConfigurationEndpoint:
# Test with custom_config_changed=False - should use stored config
run_test_llm_configuration(
test_llm_request=LLMTestRequest(
name=provider_name,
id=provider.id,
provider=LlmProviderNames.OPENAI,
api_key=None,
api_key_changed=False,
custom_config=None, # Not providing new config
custom_config_changed=False, # Using existing config
default_model_name="gpt-4o-mini",
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
)
],
model="gpt-4o-mini",
),
_=_create_mock_admin(),
db_session=db_session,
@@ -368,17 +344,11 @@ class TestLLMConfigurationEndpoint:
for model_name in test_models:
run_test_llm_configuration(
test_llm_request=LLMTestRequest(
name=None,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
custom_config_changed=False,
default_model_name=model_name,
model_configurations=[
ModelConfigurationUpsertRequest(
name=model_name, is_visible=True
)
],
model=model_name,
),
_=_create_mock_admin(),
db_session=db_session,
@@ -442,7 +412,6 @@ class TestDefaultProviderEndpoint:
provider=LlmProviderNames.OPENAI,
api_key=provider_1_api_key,
api_key_changed=True,
default_model_name=provider_1_initial_model,
model_configurations=[
ModelConfigurationUpsertRequest(name="gpt-4", is_visible=True),
ModelConfigurationUpsertRequest(name="gpt-4o", is_visible=True),
@@ -452,7 +421,7 @@ class TestDefaultProviderEndpoint:
)
# Set provider 1 as the default provider explicitly
update_default_provider(provider_1.id, db_session)
update_default_provider(provider_1.id, provider_1_initial_model, db_session)
# Step 2: Call run_test_default_provider - should use provider 1's default model
with patch(
@@ -472,7 +441,6 @@ class TestDefaultProviderEndpoint:
provider=LlmProviderNames.OPENAI,
api_key=provider_2_api_key,
api_key_changed=True,
default_model_name=provider_2_default_model,
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
@@ -499,11 +467,11 @@ class TestDefaultProviderEndpoint:
# Step 5: Update provider 1's default model
upsert_llm_provider(
LLMProviderUpsertRequest(
id=provider_1.id,
name=provider_1_name,
provider=LlmProviderNames.OPENAI,
api_key=provider_1_api_key,
api_key_changed=True,
default_model_name=provider_1_updated_model, # Changed
model_configurations=[
ModelConfigurationUpsertRequest(name="gpt-4", is_visible=True),
ModelConfigurationUpsertRequest(name="gpt-4o", is_visible=True),
@@ -512,6 +480,9 @@ class TestDefaultProviderEndpoint:
db_session=db_session,
)
# Set provider 1's default model to the updated model
update_default_provider(provider_1.id, provider_1_updated_model, db_session)
# Step 6: Call run_test_default_provider - should use new model on provider 1
with patch(
"onyx.server.manage.llm.api.test_llm", side_effect=mock_test_llm_capture
@@ -524,7 +495,7 @@ class TestDefaultProviderEndpoint:
captured_llms.clear()
# Step 7: Change the default provider to provider 2
update_default_provider(provider_2.id, db_session)
update_default_provider(provider_2.id, provider_2_default_model, db_session)
# Step 8: Call run_test_default_provider - should use provider 2
with patch(
@@ -596,7 +567,6 @@ class TestDefaultProviderEndpoint:
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
default_model_name="gpt-4o-mini",
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
@@ -605,7 +575,7 @@ class TestDefaultProviderEndpoint:
),
db_session=db_session,
)
update_default_provider(provider.id, db_session)
update_default_provider(provider.id, "gpt-4o-mini", db_session)
# Test should fail
with patch(

View File

@@ -49,7 +49,6 @@ def _create_test_provider(
api_key_changed=True,
api_base=api_base,
custom_config=custom_config,
default_model_name="gpt-4o-mini",
model_configurations=[
ModelConfigurationUpsertRequest(name="gpt-4o-mini", is_visible=True)
],
@@ -91,14 +90,14 @@ class TestLLMProviderChanges:
the API key should be blocked.
"""
try:
_create_test_provider(db_session, provider_name)
provider = _create_test_provider(db_session, provider_name)
with patch("onyx.server.manage.llm.api.MULTI_TENANT", True):
update_request = LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_base="https://attacker.example.com",
default_model_name="gpt-4o-mini",
)
with pytest.raises(HTTPException) as exc_info:
@@ -125,16 +124,16 @@ class TestLLMProviderChanges:
Changing api_base IS allowed when the API key is also being changed.
"""
try:
_create_test_provider(db_session, provider_name)
provider = _create_test_provider(db_session, provider_name)
with patch("onyx.server.manage.llm.api.MULTI_TENANT", True):
update_request = LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-new-key-00000000000000000000000000000000000",
api_key_changed=True,
api_base="https://custom-endpoint.example.com/v1",
default_model_name="gpt-4o-mini",
)
result = put_llm_provider(
@@ -159,14 +158,16 @@ class TestLLMProviderChanges:
original_api_base = "https://original.example.com/v1"
try:
_create_test_provider(db_session, provider_name, api_base=original_api_base)
provider = _create_test_provider(
db_session, provider_name, api_base=original_api_base
)
with patch("onyx.server.manage.llm.api.MULTI_TENANT", True):
update_request = LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_base=original_api_base,
default_model_name="gpt-4o-mini",
)
result = put_llm_provider(
@@ -190,14 +191,14 @@ class TestLLMProviderChanges:
changes. This allows model-only updates when provider has no custom base URL.
"""
try:
_create_test_provider(db_session, provider_name, api_base=None)
view = _create_test_provider(db_session, provider_name, api_base=None)
with patch("onyx.server.manage.llm.api.MULTI_TENANT", True):
update_request = LLMProviderUpsertRequest(
id=view.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_base="",
default_model_name="gpt-4o-mini",
)
result = put_llm_provider(
@@ -223,14 +224,16 @@ class TestLLMProviderChanges:
original_api_base = "https://original.example.com/v1"
try:
_create_test_provider(db_session, provider_name, api_base=original_api_base)
provider = _create_test_provider(
db_session, provider_name, api_base=original_api_base
)
with patch("onyx.server.manage.llm.api.MULTI_TENANT", True):
update_request = LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_base=None,
default_model_name="gpt-4o-mini",
)
with pytest.raises(HTTPException) as exc_info:
@@ -259,14 +262,14 @@ class TestLLMProviderChanges:
users have full control over their deployment.
"""
try:
_create_test_provider(db_session, provider_name)
provider = _create_test_provider(db_session, provider_name)
with patch("onyx.server.manage.llm.api.MULTI_TENANT", False):
update_request = LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_base="https://custom.example.com/v1",
default_model_name="gpt-4o-mini",
)
result = put_llm_provider(
@@ -297,7 +300,6 @@ class TestLLMProviderChanges:
api_key="sk-new-key-00000000000000000000000000000000000",
api_key_changed=True,
api_base="https://custom.example.com/v1",
default_model_name="gpt-4o-mini",
)
result = put_llm_provider(
@@ -322,7 +324,7 @@ class TestLLMProviderChanges:
redirect LLM API requests).
"""
try:
_create_test_provider(
provider = _create_test_provider(
db_session,
provider_name,
custom_config={"SOME_CONFIG": "original_value"},
@@ -330,11 +332,11 @@ class TestLLMProviderChanges:
with patch("onyx.server.manage.llm.api.MULTI_TENANT", True):
update_request = LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
custom_config={"OPENAI_API_BASE": "https://attacker.example.com"},
custom_config_changed=True,
default_model_name="gpt-4o-mini",
)
with pytest.raises(HTTPException) as exc_info:
@@ -362,15 +364,15 @@ class TestLLMProviderChanges:
without changing the API key.
"""
try:
_create_test_provider(db_session, provider_name)
provider = _create_test_provider(db_session, provider_name)
with patch("onyx.server.manage.llm.api.MULTI_TENANT", True):
update_request = LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
custom_config={"OPENAI_API_BASE": "https://attacker.example.com"},
custom_config_changed=True,
default_model_name="gpt-4o-mini",
)
with pytest.raises(HTTPException) as exc_info:
@@ -399,7 +401,7 @@ class TestLLMProviderChanges:
new_config = {"AWS_REGION_NAME": "us-west-2"}
try:
_create_test_provider(
provider = _create_test_provider(
db_session,
provider_name,
custom_config={"AWS_REGION_NAME": "us-east-1"},
@@ -407,13 +409,13 @@ class TestLLMProviderChanges:
with patch("onyx.server.manage.llm.api.MULTI_TENANT", True):
update_request = LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-new-key-00000000000000000000000000000000000",
api_key_changed=True,
custom_config_changed=True,
custom_config=new_config,
default_model_name="gpt-4o-mini",
)
result = put_llm_provider(
@@ -438,17 +440,17 @@ class TestLLMProviderChanges:
original_config = {"AWS_REGION_NAME": "us-east-1"}
try:
_create_test_provider(
provider = _create_test_provider(
db_session, provider_name, custom_config=original_config
)
with patch("onyx.server.manage.llm.api.MULTI_TENANT", True):
update_request = LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
custom_config=original_config,
custom_config_changed=True,
default_model_name="gpt-4o-mini",
)
result = put_llm_provider(
@@ -474,7 +476,7 @@ class TestLLMProviderChanges:
new_config = {"AWS_REGION_NAME": "eu-west-1"}
try:
_create_test_provider(
provider = _create_test_provider(
db_session,
provider_name,
custom_config={"AWS_REGION_NAME": "us-east-1"},
@@ -482,10 +484,10 @@ class TestLLMProviderChanges:
with patch("onyx.server.manage.llm.api.MULTI_TENANT", False):
update_request = LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
custom_config=new_config,
default_model_name="gpt-4o-mini",
custom_config_changed=True,
)
@@ -530,14 +532,8 @@ def test_upload_with_custom_config_then_change(
with patch("onyx.server.manage.llm.api.test_llm", side_effect=capture_test_llm):
run_llm_config_test(
LLMTestRequest(
name=name,
provider=provider_name,
default_model_name=default_model_name,
model_configurations=[
ModelConfigurationUpsertRequest(
name=default_model_name, is_visible=True
)
],
model=default_model_name,
api_key_changed=False,
custom_config_changed=True,
custom_config=custom_config,
@@ -546,11 +542,10 @@ def test_upload_with_custom_config_then_change(
db_session=db_session,
)
put_llm_provider(
provider = put_llm_provider(
llm_provider_upsert_request=LLMProviderUpsertRequest(
name=name,
provider=provider_name,
default_model_name=default_model_name,
custom_config=custom_config,
model_configurations=[
ModelConfigurationUpsertRequest(
@@ -569,14 +564,9 @@ def test_upload_with_custom_config_then_change(
# Turn auto mode off
run_llm_config_test(
LLMTestRequest(
name=name,
id=provider.id,
provider=provider_name,
default_model_name=default_model_name,
model_configurations=[
ModelConfigurationUpsertRequest(
name=default_model_name, is_visible=True
)
],
model=default_model_name,
api_key_changed=False,
custom_config_changed=False,
),
@@ -586,9 +576,9 @@ def test_upload_with_custom_config_then_change(
put_llm_provider(
llm_provider_upsert_request=LLMProviderUpsertRequest(
id=provider.id,
name=name,
provider=provider_name,
default_model_name=default_model_name,
model_configurations=[
ModelConfigurationUpsertRequest(
name=default_model_name, is_visible=True
@@ -616,13 +606,13 @@ def test_upload_with_custom_config_then_change(
)
# Check inside the database and check that custom_config is the same as the original
provider = fetch_existing_llm_provider(name=name, db_session=db_session)
if not provider:
db_provider = fetch_existing_llm_provider(name=name, db_session=db_session)
if not db_provider:
assert False, "Provider not found in the database"
assert provider.custom_config == custom_config, (
assert db_provider.custom_config == custom_config, (
f"Expected custom_config {custom_config}, "
f"but got {provider.custom_config}"
f"but got {db_provider.custom_config}"
)
finally:
db_session.rollback()
@@ -642,11 +632,10 @@ def test_preserves_masked_sensitive_custom_config_on_provider_update(
}
try:
put_llm_provider(
view = put_llm_provider(
llm_provider_upsert_request=LLMProviderUpsertRequest(
name=name,
provider=provider,
default_model_name=default_model_name,
custom_config=original_custom_config,
model_configurations=[
ModelConfigurationUpsertRequest(
@@ -665,9 +654,9 @@ def test_preserves_masked_sensitive_custom_config_on_provider_update(
with patch("onyx.server.manage.llm.api.MULTI_TENANT", False):
put_llm_provider(
llm_provider_upsert_request=LLMProviderUpsertRequest(
id=view.id,
name=name,
provider=provider,
default_model_name=default_model_name,
custom_config={
"vertex_credentials": _mask_string(
original_custom_config["vertex_credentials"]
@@ -706,7 +695,7 @@ def test_preserves_masked_sensitive_custom_config_on_test_request(
) -> None:
"""LLM test should restore masked sensitive custom config values before invocation."""
name = f"test-provider-vertex-test-{uuid4().hex[:8]}"
provider = LlmProviderNames.VERTEX_AI.value
provider_name = LlmProviderNames.VERTEX_AI.value
default_model_name = "gemini-2.5-pro"
original_custom_config = {
"vertex_credentials": '{"type":"service_account","private_key":"REAL_PRIVATE_KEY"}',
@@ -719,11 +708,10 @@ def test_preserves_masked_sensitive_custom_config_on_test_request(
return ""
try:
put_llm_provider(
provider = put_llm_provider(
llm_provider_upsert_request=LLMProviderUpsertRequest(
name=name,
provider=provider,
default_model_name=default_model_name,
provider=provider_name,
custom_config=original_custom_config,
model_configurations=[
ModelConfigurationUpsertRequest(
@@ -742,14 +730,9 @@ def test_preserves_masked_sensitive_custom_config_on_test_request(
with patch("onyx.server.manage.llm.api.test_llm", side_effect=capture_test_llm):
run_llm_config_test(
LLMTestRequest(
name=name,
provider=provider,
default_model_name=default_model_name,
model_configurations=[
ModelConfigurationUpsertRequest(
name=default_model_name, is_visible=True
)
],
id=provider.id,
provider=provider_name,
model=default_model_name,
api_key_changed=False,
custom_config_changed=True,
custom_config={

View File

@@ -15,9 +15,11 @@ import pytest
from sqlalchemy.orm import Session
from onyx.db.enums import LLMModelFlowType
from onyx.db.llm import fetch_auto_mode_providers
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 fetch_llm_provider_view
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
@@ -135,7 +137,6 @@ class TestAutoModeSyncFeature:
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
is_auto_mode=True,
default_model_name=expected_default_model,
model_configurations=[], # No model configs provided
),
is_creation=True,
@@ -163,13 +164,8 @@ class TestAutoModeSyncFeature:
if mc.name in all_expected_models:
assert mc.is_visible is True, f"Model '{mc.name}' should be visible"
# Verify the default model was set correctly
assert (
provider.default_model_name == expected_default_model
), f"Default model should be '{expected_default_model}'"
# Step 4: Set the provider as default
update_default_provider(provider.id, db_session)
update_default_provider(provider.id, expected_default_model, db_session)
# Step 5: Fetch the default provider and verify
default_model = fetch_default_llm_model(db_session)
@@ -238,7 +234,6 @@ class TestAutoModeSyncFeature:
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
is_auto_mode=True,
default_model_name="gpt-4o",
model_configurations=[],
),
is_creation=True,
@@ -317,7 +312,6 @@ class TestAutoModeSyncFeature:
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
is_auto_mode=False, # Not in auto mode initially
default_model_name="gpt-4",
model_configurations=initial_models,
),
is_creation=True,
@@ -326,13 +320,13 @@ class TestAutoModeSyncFeature:
)
# Verify initial state: all models are visible
provider = fetch_existing_llm_provider(
initial_provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
assert provider.is_auto_mode is False
assert initial_provider is not None
assert initial_provider.is_auto_mode is False
for mc in provider.model_configurations:
for mc in initial_provider.model_configurations:
assert (
mc.is_visible is True
), f"Initial model '{mc.name}' should be visible"
@@ -344,12 +338,12 @@ class TestAutoModeSyncFeature:
):
put_llm_provider(
llm_provider_upsert_request=LLMProviderUpsertRequest(
id=initial_provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key=None, # Not changing API key
api_key_changed=False,
is_auto_mode=True, # Now enabling auto mode
default_model_name=auto_mode_default,
model_configurations=[], # Auto mode will sync from config
),
is_creation=False, # This is an update
@@ -360,15 +354,15 @@ class TestAutoModeSyncFeature:
# Step 3: Verify model visibility after auto mode transition
# Expire session cache to force fresh fetch after sync_auto_mode_models committed
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
provider_view = fetch_llm_provider_view(
provider_name=provider_name, db_session=db_session
)
assert provider is not None
assert provider.is_auto_mode is True
assert provider_view is not None
assert provider_view.is_auto_mode is True
# Build a map of model name -> visibility
model_visibility = {
mc.name: mc.is_visible for mc in provider.model_configurations
mc.name: mc.is_visible for mc in provider_view.model_configurations
}
# Models in auto mode config should be visible
@@ -388,9 +382,6 @@ class TestAutoModeSyncFeature:
model_visibility[model_name] is False
), f"Model '{model_name}' not in auto config should NOT be visible"
# Verify the default model was updated
assert provider.default_model_name == auto_mode_default
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)
@@ -432,8 +423,12 @@ class TestAutoModeSyncFeature:
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
is_auto_mode=True,
default_model_name="gpt-4o",
model_configurations=[],
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o",
is_visible=True,
)
],
),
is_creation=True,
_=_create_mock_admin(),
@@ -535,7 +530,6 @@ class TestAutoModeSyncFeature:
api_key=provider_1_api_key,
api_key_changed=True,
is_auto_mode=True,
default_model_name=provider_1_default_model,
model_configurations=[],
),
is_creation=True,
@@ -549,7 +543,7 @@ class TestAutoModeSyncFeature:
name=provider_1_name, db_session=db_session
)
assert provider_1 is not None
update_default_provider(provider_1.id, db_session)
update_default_provider(provider_1.id, provider_1_default_model, db_session)
with patch(
"onyx.server.manage.llm.api.fetch_llm_recommendations_from_github",
@@ -563,7 +557,6 @@ class TestAutoModeSyncFeature:
api_key=provider_2_api_key,
api_key_changed=True,
is_auto_mode=True,
default_model_name=provider_2_default_model,
model_configurations=[],
),
is_creation=True,
@@ -584,7 +577,7 @@ class TestAutoModeSyncFeature:
name=provider_2_name, db_session=db_session
)
assert provider_2 is not None
update_default_provider(provider_2.id, db_session)
update_default_provider(provider_2.id, provider_2_default_model, db_session)
# Step 5: Verify provider 2 is now the default
db_session.expire_all()
@@ -644,7 +637,6 @@ class TestAutoModeMissingFlows:
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
is_auto_mode=True,
default_model_name="gpt-4o",
model_configurations=[],
),
is_creation=True,
@@ -701,3 +693,364 @@ class TestAutoModeMissingFlows:
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)
class TestAutoModeTransitionsAndResync:
"""Tests for auto/manual transitions, config evolution, and sync idempotency."""
def test_auto_to_manual_mode_preserves_models_and_stops_syncing(
self,
db_session: Session,
provider_name: str,
) -> None:
"""Disabling auto mode should preserve the current model list and
prevent future syncs from altering visibility.
Steps:
1. Create provider in auto mode — models synced from config.
2. Update provider to manual mode (is_auto_mode=False).
3. Verify all models remain with unchanged visibility.
4. Call sync_auto_mode_models with a *different* config.
5. Verify fetch_auto_mode_providers excludes this provider, so the
periodic task would never call sync on it.
"""
initial_config = _create_mock_llm_recommendations(
provider=LlmProviderNames.OPENAI,
default_model_name="gpt-4o",
additional_models=["gpt-4o-mini"],
)
try:
# Step 1: Create in auto mode
with patch(
"onyx.server.manage.llm.api.fetch_llm_recommendations_from_github",
return_value=initial_config,
):
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,
model_configurations=[],
),
is_creation=True,
_=_create_mock_admin(),
db_session=db_session,
)
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
visibility_before = {
mc.name: mc.is_visible for mc in provider.model_configurations
}
assert visibility_before == {"gpt-4o": True, "gpt-4o-mini": True}
# Step 2: Switch to manual mode
put_llm_provider(
llm_provider_upsert_request=LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key=None,
api_key_changed=False,
is_auto_mode=False,
model_configurations=[
ModelConfigurationUpsertRequest(name="gpt-4o", is_visible=True),
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
),
],
),
is_creation=False,
_=_create_mock_admin(),
db_session=db_session,
)
# Step 3: Models unchanged
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
assert provider.is_auto_mode is False
visibility_after = {
mc.name: mc.is_visible for mc in provider.model_configurations
}
assert visibility_after == visibility_before
# Step 4-5: Provider excluded from auto mode queries
auto_providers = fetch_auto_mode_providers(db_session)
auto_provider_ids = {p.id for p in auto_providers}
assert provider.id not in auto_provider_ids
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)
def test_resync_adds_new_and_hides_removed_models(
self,
db_session: Session,
provider_name: str,
) -> None:
"""When the GitHub config changes between syncs, a subsequent sync
should add newly listed models and hide models that were removed.
Steps:
1. Create provider in auto mode with config v1: [gpt-4o, gpt-4o-mini].
2. Sync with config v2: [gpt-4o, gpt-4-turbo] (gpt-4o-mini removed,
gpt-4-turbo added).
3. Verify gpt-4o still visible, gpt-4o-mini hidden, gpt-4-turbo added
and visible.
"""
config_v1 = _create_mock_llm_recommendations(
provider=LlmProviderNames.OPENAI,
default_model_name="gpt-4o",
additional_models=["gpt-4o-mini"],
)
config_v2 = _create_mock_llm_recommendations(
provider=LlmProviderNames.OPENAI,
default_model_name="gpt-4o",
additional_models=["gpt-4-turbo"],
)
try:
# Step 1: Create with config v1
with patch(
"onyx.server.manage.llm.api.fetch_llm_recommendations_from_github",
return_value=config_v1,
):
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,
model_configurations=[],
),
is_creation=True,
_=_create_mock_admin(),
db_session=db_session,
)
# Step 2: Re-sync with config v2
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
changes = sync_auto_mode_models(
db_session=db_session,
provider=provider,
llm_recommendations=config_v2,
)
assert changes > 0
# Step 3: Verify
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
visibility = {
mc.name: mc.is_visible for mc in provider.model_configurations
}
# gpt-4o: still in config -> visible
assert visibility["gpt-4o"] is True
# gpt-4o-mini: removed from config -> hidden (not deleted)
assert "gpt-4o-mini" in visibility, "Removed model should still exist in DB"
assert visibility["gpt-4o-mini"] is False
# gpt-4-turbo: newly added -> visible
assert visibility["gpt-4-turbo"] is True
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)
def test_sync_is_idempotent(
self,
db_session: Session,
provider_name: str,
) -> None:
"""Running sync twice with the same config should produce zero
changes on the second call."""
config = _create_mock_llm_recommendations(
provider=LlmProviderNames.OPENAI,
default_model_name="gpt-4o",
additional_models=["gpt-4o-mini", "gpt-4-turbo"],
)
try:
with patch(
"onyx.server.manage.llm.api.fetch_llm_recommendations_from_github",
return_value=config,
):
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,
model_configurations=[],
),
is_creation=True,
_=_create_mock_admin(),
db_session=db_session,
)
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
# First explicit sync (may report changes if creation already synced)
sync_auto_mode_models(
db_session=db_session,
provider=provider,
llm_recommendations=config,
)
# Snapshot state after first sync
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
snapshot = {
mc.name: (mc.is_visible, mc.display_name)
for mc in provider.model_configurations
}
# Second sync — should be a no-op
changes = sync_auto_mode_models(
db_session=db_session,
provider=provider,
llm_recommendations=config,
)
assert (
changes == 0
), f"Expected 0 changes on idempotent re-sync, got {changes}"
# State should be identical
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
current = {
mc.name: (mc.is_visible, mc.display_name)
for mc in provider.model_configurations
}
assert current == snapshot
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)
def test_default_model_hidden_when_removed_from_config(
self,
db_session: Session,
provider_name: str,
) -> None:
"""When the current default model is removed from the config, sync
should hide it. The default model flow row should still exist (it
points at the ModelConfiguration), but the model is no longer visible.
Steps:
1. Create provider with config: default=gpt-4o, additional=[gpt-4o-mini].
2. Set gpt-4o as the global default.
3. Re-sync with config: default=gpt-4o-mini (gpt-4o removed entirely).
4. Verify gpt-4o is hidden, gpt-4o-mini is visible, and
fetch_default_llm_model still returns a result (the flow row persists).
"""
config_v1 = _create_mock_llm_recommendations(
provider=LlmProviderNames.OPENAI,
default_model_name="gpt-4o",
additional_models=["gpt-4o-mini"],
)
config_v2 = _create_mock_llm_recommendations(
provider=LlmProviderNames.OPENAI,
default_model_name="gpt-4o-mini",
additional_models=[],
)
try:
with patch(
"onyx.server.manage.llm.api.fetch_llm_recommendations_from_github",
return_value=config_v1,
):
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,
model_configurations=[],
),
is_creation=True,
_=_create_mock_admin(),
db_session=db_session,
)
# Step 2: Set gpt-4o as global default
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
update_default_provider(provider.id, "gpt-4o", db_session)
default_before = fetch_default_llm_model(db_session)
assert default_before is not None
assert default_before.name == "gpt-4o"
# Step 3: Re-sync with config v2 (gpt-4o removed)
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
changes = sync_auto_mode_models(
db_session=db_session,
provider=provider,
llm_recommendations=config_v2,
)
assert changes > 0
# Step 4: Verify visibility
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
visibility = {
mc.name: mc.is_visible for mc in provider.model_configurations
}
assert visibility["gpt-4o"] is False, "Removed default should be hidden"
assert visibility["gpt-4o-mini"] is True, "New default should be visible"
# The LLMModelFlow row for gpt-4o still exists (is_default=True),
# but the model is hidden. fetch_default_llm_model filters on
# is_visible=True, so it should NOT return gpt-4o.
db_session.expire_all()
default_after = fetch_default_llm_model(db_session)
assert (
default_after is None or default_after.name != "gpt-4o"
), "Hidden model should not be returned as the default"
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)

View File

@@ -64,7 +64,6 @@ def _create_provider(
name=name,
provider=provider,
api_key="sk-ant-api03-...",
default_model_name="claude-3-5-sonnet-20240620",
is_public=is_public,
model_configurations=[
ModelConfigurationUpsertRequest(
@@ -154,7 +153,9 @@ def test_user_sends_message_to_private_provider(
)
_create_provider(db_session, LlmProviderNames.GOOGLE, "private-provider", False)
update_default_provider(public_provider_id, db_session)
update_default_provider(
public_provider_id, "claude-3-5-sonnet-20240620", db_session
)
try:
# Create chat session

View File

@@ -42,7 +42,6 @@ def _create_llm_provider_and_model(
name=provider_name,
provider="openai",
api_key="test-api-key",
default_model_name=model_name,
model_configurations=[
ModelConfigurationUpsertRequest(
name=model_name,

View File

@@ -434,7 +434,6 @@ class TestSlackBotFederatedSearch:
name=f"test-llm-provider-{uuid4().hex[:8]}",
provider=LlmProviderNames.OPENAI,
api_key=api_key,
default_model_name="gpt-4o",
is_public=True,
model_configurations=[
ModelConfigurationUpsertRequest(
@@ -448,7 +447,7 @@ class TestSlackBotFederatedSearch:
db_session=db_session,
)
update_default_provider(provider_view.id, db_session)
update_default_provider(provider_view.id, "gpt-4o", db_session)
def _teardown_common_mocks(self, patches: list) -> None:
"""Stop all patches"""

View File

@@ -4,10 +4,12 @@ from uuid import uuid4
import requests
from onyx.llm.constants import LlmProviderNames
from onyx.server.manage.llm.models import DefaultModel
from onyx.server.manage.llm.models import LLMProviderUpsertRequest
from onyx.server.manage.llm.models import LLMProviderView
from onyx.server.manage.llm.models import ModelConfigurationUpsertRequest
from tests.integration.common_utils.constants import API_SERVER_URL
from tests.integration.common_utils.constants import GENERAL_HEADERS
from tests.integration.common_utils.test_models import DATestLLMProvider
from tests.integration.common_utils.test_models import DATestUser
@@ -32,7 +34,6 @@ class LLMProviderManager:
llm_provider = LLMProviderUpsertRequest(
name=name or f"test-provider-{uuid4()}",
provider=provider or LlmProviderNames.OPENAI,
default_model_name=default_model_name or "gpt-4o-mini",
api_key=api_key or os.environ["OPENAI_API_KEY"],
api_base=api_base,
api_version=api_version,
@@ -65,7 +66,7 @@ class LLMProviderManager:
name=response_data["name"],
provider=response_data["provider"],
api_key=response_data["api_key"],
default_model_name=response_data["default_model_name"],
default_model_name=default_model_name or "gpt-4o-mini",
is_public=response_data["is_public"],
is_auto_mode=response_data.get("is_auto_mode", False),
groups=response_data["groups"],
@@ -75,9 +76,19 @@ class LLMProviderManager:
)
if set_as_default:
if default_model_name is None:
default_model_name = "gpt-4o-mini"
set_default_response = requests.post(
f"{API_SERVER_URL}/admin/llm/provider/{llm_response.json()['id']}/default",
headers=user_performing_action.headers,
f"{API_SERVER_URL}/admin/llm/default",
json={
"provider_id": response_data["id"],
"model_name": default_model_name,
},
headers=(
user_performing_action.headers
if user_performing_action
else GENERAL_HEADERS
),
)
set_default_response.raise_for_status()
@@ -104,7 +115,7 @@ class LLMProviderManager:
headers=user_performing_action.headers,
)
response.raise_for_status()
return [LLMProviderView(**ug) for ug in response.json()]
return [LLMProviderView(**p) for p in response.json()["providers"]]
@staticmethod
def verify(
@@ -113,7 +124,11 @@ class LLMProviderManager:
verify_deleted: bool = False,
) -> None:
all_llm_providers = LLMProviderManager.get_all(user_performing_action)
default_model = LLMProviderManager.get_default_model(user_performing_action)
for fetched_llm_provider in all_llm_providers:
model_names = [
model.name for model in fetched_llm_provider.model_configurations
]
if llm_provider.id == fetched_llm_provider.id:
if verify_deleted:
raise ValueError(
@@ -126,11 +141,30 @@ class LLMProviderManager:
if (
fetched_llm_groups == llm_provider_groups
and llm_provider.provider == fetched_llm_provider.provider
and llm_provider.default_model_name
== fetched_llm_provider.default_model_name
and (
default_model is None or default_model.model_name in model_names
)
and llm_provider.is_public == fetched_llm_provider.is_public
and set(fetched_llm_provider.personas) == set(llm_provider.personas)
):
return
if not verify_deleted:
raise ValueError(f"LLM Provider {llm_provider.id} not found")
@staticmethod
def get_default_model(
user_performing_action: DATestUser | None = None,
) -> DefaultModel | None:
response = requests.get(
f"{API_SERVER_URL}/admin/llm/provider",
headers=(
user_performing_action.headers
if user_performing_action
else GENERAL_HEADERS
),
)
response.raise_for_status()
default_text = response.json().get("default_text")
if default_text is None:
return None
return DefaultModel(**default_text)

View File

@@ -128,7 +128,7 @@ class DATestLLMProvider(BaseModel):
name: str
provider: str
api_key: str
default_model_name: str
default_model_name: str | None = None
is_public: bool
is_auto_mode: bool = False
groups: list[int]

View File

@@ -42,12 +42,10 @@ def _create_provider_with_api(
llm_provider_data = {
"name": name,
"provider": provider_type,
"default_model_name": default_model,
"api_key": "test-api-key-for-auto-mode-testing",
"api_base": None,
"api_version": None,
"custom_config": None,
"fast_default_model_name": default_model,
"is_public": True,
"is_auto_mode": is_auto_mode,
"groups": [],
@@ -72,7 +70,7 @@ def _get_provider_by_id(admin_user: DATestUser, provider_id: int) -> dict:
headers=admin_user.headers,
)
response.raise_for_status()
for provider in response.json():
for provider in response.json()["providers"]:
if provider["id"] == provider_id:
return provider
raise ValueError(f"Provider with id {provider_id} not found")
@@ -219,15 +217,6 @@ def test_auto_mode_provider_gets_synced_from_github_config(
"is_visible"
], "Outdated model should not be visible after sync"
# Verify default model was set from GitHub config
expected_default = (
default_model["name"] if isinstance(default_model, dict) else default_model
)
assert synced_provider["default_model_name"] == expected_default, (
f"Default model should be {expected_default}, "
f"got {synced_provider['default_model_name']}"
)
def test_manual_mode_provider_not_affected_by_auto_sync(
reset: None, # noqa: ARG001
@@ -273,7 +262,3 @@ def test_manual_mode_provider_not_affected_by_auto_sync(
f"Manual mode provider models should not change. "
f"Initial: {initial_models}, Current: {current_models}"
)
assert (
updated_provider["default_model_name"] == custom_model
), f"Manual mode default model should remain {custom_model}"

View File

@@ -6,20 +6,21 @@ from sqlalchemy.orm import Session
from onyx.context.search.enums import RecencyBiasSetting
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.enums import LLMModelFlowType
from onyx.db.llm import can_user_access_llm_provider
from onyx.db.llm import fetch_user_group_ids
from onyx.db.models import LLMModelFlow
from onyx.db.llm import update_default_provider
from onyx.db.llm import upsert_llm_provider
from onyx.db.models import LLMProvider as LLMProviderModel
from onyx.db.models import LLMProvider__Persona
from onyx.db.models import LLMProvider__UserGroup
from onyx.db.models import ModelConfiguration
from onyx.db.models import Persona
from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.db.models import UserGroup
from onyx.llm.constants import LlmProviderNames
from onyx.llm.factory import get_llm_for_persona
from onyx.server.manage.llm.models import LLMProviderUpsertRequest
from onyx.server.manage.llm.models import ModelConfigurationUpsertRequest
from tests.integration.common_utils.constants import API_SERVER_URL
from tests.integration.common_utils.managers.llm_provider import LLMProviderManager
from tests.integration.common_utils.managers.persona import PersonaManager
@@ -41,24 +42,30 @@ def _create_llm_provider(
is_public: bool,
is_default: bool,
) -> LLMProviderModel:
provider = LLMProviderModel(
name=name,
provider=LlmProviderNames.OPENAI,
api_key=None,
api_base=None,
api_version=None,
custom_config=None,
default_model_name=default_model_name,
deployment_name=None,
is_public=is_public,
# Use None instead of False to avoid unique constraint violation
# The is_default_provider column has unique=True, so only one True and one False allowed
is_default_provider=is_default if is_default else None,
is_default_vision_provider=False,
default_vision_model=None,
_provider = upsert_llm_provider(
llm_provider_upsert_request=LLMProviderUpsertRequest(
name=name,
provider=LlmProviderNames.OPENAI,
api_key=None,
api_base=None,
api_version=None,
custom_config=None,
is_public=is_public,
model_configurations=[
ModelConfigurationUpsertRequest(
name=default_model_name,
is_visible=True,
)
],
),
db_session=db_session,
)
db_session.add(provider)
db_session.flush()
if is_default:
update_default_provider(_provider.id, default_model_name, db_session)
provider = db_session.get(LLMProviderModel, _provider.id)
if not provider:
raise ValueError(f"Provider {name} not found")
return provider
@@ -270,24 +277,6 @@ def test_get_llm_for_persona_falls_back_when_access_denied(
provider_name=restricted_provider.name,
)
# Set up ModelConfiguration + LLMModelFlow so get_default_llm() can
# resolve the default provider when the fallback path is triggered.
default_model_config = ModelConfiguration(
llm_provider_id=default_provider.id,
name=default_provider.default_model_name,
is_visible=True,
)
db_session.add(default_model_config)
db_session.flush()
db_session.add(
LLMModelFlow(
model_configuration_id=default_model_config.id,
llm_model_flow_type=LLMModelFlowType.CHAT,
is_default=True,
)
)
db_session.flush()
access_group = UserGroup(name="persona-group")
db_session.add(access_group)
db_session.flush()
@@ -321,13 +310,19 @@ def test_get_llm_for_persona_falls_back_when_access_denied(
persona=persona,
user=admin_model,
)
assert allowed_llm.config.model_name == restricted_provider.default_model_name
assert (
allowed_llm.config.model_name
== restricted_provider.model_configurations[0].name
)
fallback_llm = get_llm_for_persona(
persona=persona,
user=basic_model,
)
assert fallback_llm.config.model_name == default_provider.default_model_name
assert (
fallback_llm.config.model_name
== default_provider.model_configurations[0].name
)
def test_list_llm_provider_basics_excludes_non_public_unrestricted(
@@ -346,6 +341,7 @@ def test_list_llm_provider_basics_excludes_non_public_unrestricted(
name="public-provider",
is_public=True,
set_as_default=True,
default_model_name="gpt-4o",
user_performing_action=admin_user,
)
@@ -365,7 +361,7 @@ def test_list_llm_provider_basics_excludes_non_public_unrestricted(
headers=basic_user.headers,
)
assert response.status_code == 200
providers = response.json()
providers = response.json()["providers"]
provider_names = [p["name"] for p in providers]
# Public provider should be visible
@@ -380,7 +376,7 @@ def test_list_llm_provider_basics_excludes_non_public_unrestricted(
headers=admin_user.headers,
)
assert admin_response.status_code == 200
admin_providers = admin_response.json()
admin_providers = admin_response.json()["providers"]
admin_provider_names = [p["name"] for p in admin_providers]
assert public_provider.name in admin_provider_names
@@ -396,6 +392,7 @@ def test_provider_delete_clears_persona_references(reset: None) -> None: # noqa
name="default-provider",
is_public=True,
set_as_default=True,
default_model_name="gpt-4o",
user_performing_action=admin_user,
)

View File

@@ -107,7 +107,7 @@ def test_authorized_persona_access_returns_filtered_providers(
# Should succeed
assert response.status_code == 200
providers = response.json()
providers = response.json()["providers"]
# Should include the restricted provider since basic_user can access the persona
provider_names = [p["name"] for p in providers]
@@ -140,7 +140,7 @@ def test_persona_id_zero_applies_rbac(
# Should succeed (persona_id=0 refers to default persona, which is public)
assert response.status_code == 200
providers = response.json()
providers = response.json()["providers"]
# Should NOT include the restricted provider since basic_user is not in group2
provider_names = [p["name"] for p in providers]
@@ -182,7 +182,7 @@ def test_admin_can_query_any_persona(
# Should succeed - admins can access any persona
assert response.status_code == 200
providers = response.json()
providers = response.json()["providers"]
# Should include the restricted provider
provider_names = [p["name"] for p in providers]
@@ -223,7 +223,7 @@ def test_public_persona_accessible_to_all(
# Should succeed
assert response.status_code == 200
providers = response.json()
providers = response.json()["providers"]
# Should return the public provider
assert len(providers) > 0

View File

@@ -9,6 +9,19 @@ from redis.exceptions import RedisError
from onyx.server.settings.models import ApplicationStatus
from onyx.server.settings.models import Settings
# Fields we assert on across all tests
_ASSERT_FIELDS = {
"application_status",
"ee_features_enabled",
"seat_count",
"used_seats",
}
def _pick(settings: Settings) -> dict:
"""Extract only the fields under test from a Settings object."""
return settings.model_dump(include=_ASSERT_FIELDS)
@pytest.fixture
def base_settings() -> Settings:
@@ -27,17 +40,17 @@ class TestApplyLicenseStatusToSettings:
def test_enforcement_disabled_enables_ee_features(
self, base_settings: Settings
) -> None:
"""When LICENSE_ENFORCEMENT_ENABLED=False, EE features are enabled.
If we're running the EE apply function, EE code was loaded via
ENABLE_PAID_ENTERPRISE_EDITION_FEATURES, so features should be on.
"""
"""When LICENSE_ENFORCEMENT_ENABLED=False, EE features are enabled."""
from ee.onyx.server.settings.api import apply_license_status_to_settings
assert base_settings.ee_features_enabled is False
result = apply_license_status_to_settings(base_settings)
assert result.application_status == ApplicationStatus.ACTIVE
assert result.ee_features_enabled is True
assert _pick(result) == {
"application_status": ApplicationStatus.ACTIVE,
"ee_features_enabled": True,
"seat_count": None,
"used_seats": None,
}
@patch("ee.onyx.server.settings.api.LICENSE_ENFORCEMENT_ENABLED", True)
@patch("ee.onyx.server.settings.api.MULTI_TENANT", True)
@@ -46,13 +59,60 @@ class TestApplyLicenseStatusToSettings:
from ee.onyx.server.settings.api import apply_license_status_to_settings
result = apply_license_status_to_settings(base_settings)
assert result.ee_features_enabled is True
assert _pick(result) == {
"application_status": ApplicationStatus.ACTIVE,
"ee_features_enabled": True,
"seat_count": None,
"used_seats": None,
}
@pytest.mark.parametrize(
"license_status,expected_app_status,expected_ee_enabled",
"license_status,used_seats,seats,expected",
[
(ApplicationStatus.GATED_ACCESS, ApplicationStatus.GATED_ACCESS, False),
(ApplicationStatus.ACTIVE, ApplicationStatus.ACTIVE, True),
(
ApplicationStatus.GATED_ACCESS,
3,
10,
{
"application_status": ApplicationStatus.GATED_ACCESS,
"ee_features_enabled": False,
"seat_count": None,
"used_seats": None,
},
),
(
ApplicationStatus.ACTIVE,
3,
10,
{
"application_status": ApplicationStatus.ACTIVE,
"ee_features_enabled": True,
"seat_count": None,
"used_seats": None,
},
),
(
ApplicationStatus.ACTIVE,
10,
10,
{
"application_status": ApplicationStatus.ACTIVE,
"ee_features_enabled": True,
"seat_count": None,
"used_seats": None,
},
),
(
ApplicationStatus.GRACE_PERIOD,
3,
10,
{
"application_status": ApplicationStatus.ACTIVE,
"ee_features_enabled": True,
"seat_count": None,
"used_seats": None,
},
),
],
)
@patch("ee.onyx.server.settings.api.LICENSE_ENFORCEMENT_ENABLED", True)
@@ -63,25 +123,80 @@ class TestApplyLicenseStatusToSettings:
self,
mock_get_metadata: MagicMock,
mock_get_tenant: MagicMock,
license_status: ApplicationStatus | None,
expected_app_status: ApplicationStatus,
expected_ee_enabled: bool,
license_status: ApplicationStatus,
used_seats: int,
seats: int,
expected: dict,
base_settings: Settings,
) -> None:
"""Self-hosted: license status controls both application_status and ee_features_enabled."""
from ee.onyx.server.settings.api import apply_license_status_to_settings
mock_get_tenant.return_value = "test_tenant"
if license_status is None:
mock_get_metadata.return_value = None
else:
mock_metadata = MagicMock()
mock_metadata.status = license_status
mock_get_metadata.return_value = mock_metadata
mock_metadata = MagicMock()
mock_metadata.status = license_status
mock_metadata.used_seats = used_seats
mock_metadata.seats = seats
mock_get_metadata.return_value = mock_metadata
result = apply_license_status_to_settings(base_settings)
assert result.application_status == expected_app_status
assert result.ee_features_enabled is expected_ee_enabled
assert _pick(result) == expected
@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")
@patch("ee.onyx.server.settings.api.get_cached_license_metadata")
def test_seat_limit_exceeded_sets_status_and_counts(
self,
mock_get_metadata: MagicMock,
mock_get_tenant: MagicMock,
base_settings: Settings,
) -> None:
"""Seat limit exceeded sets SEAT_LIMIT_EXCEEDED with counts, keeps EE enabled."""
from ee.onyx.server.settings.api import apply_license_status_to_settings
mock_get_tenant.return_value = "test_tenant"
mock_metadata = MagicMock()
mock_metadata.status = ApplicationStatus.ACTIVE
mock_metadata.used_seats = 15
mock_metadata.seats = 10
mock_get_metadata.return_value = mock_metadata
result = apply_license_status_to_settings(base_settings)
assert _pick(result) == {
"application_status": ApplicationStatus.SEAT_LIMIT_EXCEEDED,
"ee_features_enabled": True,
"seat_count": 10,
"used_seats": 15,
}
@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")
@patch("ee.onyx.server.settings.api.get_cached_license_metadata")
def test_expired_license_takes_precedence_over_seat_limit(
self,
mock_get_metadata: MagicMock,
mock_get_tenant: MagicMock,
base_settings: Settings,
) -> None:
"""Expired license (GATED_ACCESS) takes precedence over seat limit exceeded."""
from ee.onyx.server.settings.api import apply_license_status_to_settings
mock_get_tenant.return_value = "test_tenant"
mock_metadata = MagicMock()
mock_metadata.status = ApplicationStatus.GATED_ACCESS
mock_metadata.used_seats = 15
mock_metadata.seats = 10
mock_get_metadata.return_value = mock_metadata
result = apply_license_status_to_settings(base_settings)
assert _pick(result) == {
"application_status": ApplicationStatus.GATED_ACCESS,
"ee_features_enabled": False,
"seat_count": None,
"used_seats": None,
}
@patch("ee.onyx.server.settings.api.ENTERPRISE_EDITION_ENABLED", True)
@patch("ee.onyx.server.settings.api.LICENSE_ENFORCEMENT_ENABLED", True)
@@ -105,8 +220,12 @@ class TestApplyLicenseStatusToSettings:
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
assert _pick(result) == {
"application_status": ApplicationStatus.GATED_ACCESS,
"ee_features_enabled": False,
"seat_count": None,
"used_seats": None,
}
@patch("ee.onyx.server.settings.api.ENTERPRISE_EDITION_ENABLED", False)
@patch("ee.onyx.server.settings.api.LICENSE_ENFORCEMENT_ENABLED", True)
@@ -130,8 +249,12 @@ class TestApplyLicenseStatusToSettings:
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
assert _pick(result) == {
"application_status": ApplicationStatus.ACTIVE,
"ee_features_enabled": False,
"seat_count": None,
"used_seats": None,
}
@patch("ee.onyx.server.settings.api.LICENSE_ENFORCEMENT_ENABLED", True)
@patch("ee.onyx.server.settings.api.MULTI_TENANT", False)
@@ -150,8 +273,12 @@ class TestApplyLicenseStatusToSettings:
mock_get_metadata.side_effect = RedisError("Connection failed")
result = apply_license_status_to_settings(base_settings)
assert result.application_status == ApplicationStatus.ACTIVE
assert result.ee_features_enabled is False
assert _pick(result) == {
"application_status": ApplicationStatus.ACTIVE,
"ee_features_enabled": False,
"seat_count": None,
"used_seats": None,
}
class TestSettingsDefaultEEDisabled:

View File

@@ -0,0 +1,291 @@
"""Tests for the _impl functions' redis_locking parameter.
Verifies that:
- redis_locking=True acquires/releases Redis locks and clears queued keys
- redis_locking=False skips all Redis operations entirely
- Both paths execute the same business logic (DB lookup, status check)
"""
from unittest.mock import MagicMock
from unittest.mock import patch
from uuid import uuid4
from onyx.background.celery.tasks.user_file_processing.tasks import (
_delete_user_file_impl,
)
from onyx.background.celery.tasks.user_file_processing.tasks import (
_process_user_file_impl,
)
from onyx.background.celery.tasks.user_file_processing.tasks import (
_project_sync_user_file_impl,
)
TASKS_MODULE = "onyx.background.celery.tasks.user_file_processing.tasks"
def _mock_session_returning_none() -> MagicMock:
"""Return a mock session whose .get() returns None (file not found)."""
session = MagicMock()
session.get.return_value = None
session.execute.return_value.scalar_one_or_none.return_value = None
return session
# ------------------------------------------------------------------
# _process_user_file_impl
# ------------------------------------------------------------------
class TestProcessUserFileImpl:
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
@patch(f"{TASKS_MODULE}.get_redis_client")
def test_redis_locking_true_acquires_and_releases_lock(
self,
mock_get_redis: MagicMock,
mock_get_session: MagicMock,
) -> None:
redis_client = MagicMock()
lock = MagicMock()
lock.acquire.return_value = True
lock.owned.return_value = True
redis_client.lock.return_value = lock
mock_get_redis.return_value = redis_client
session = _mock_session_returning_none()
mock_get_session.return_value.__enter__.return_value = session
user_file_id = str(uuid4())
_process_user_file_impl(
user_file_id=user_file_id,
tenant_id="test-tenant",
redis_locking=True,
)
mock_get_redis.assert_called_once_with(tenant_id="test-tenant")
redis_client.delete.assert_called_once()
lock.acquire.assert_called_once_with(blocking=False)
lock.release.assert_called_once()
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
@patch(f"{TASKS_MODULE}.get_redis_client")
def test_redis_locking_true_skips_when_lock_held(
self,
mock_get_redis: MagicMock,
mock_get_session: MagicMock,
) -> None:
redis_client = MagicMock()
lock = MagicMock()
lock.acquire.return_value = False
redis_client.lock.return_value = lock
mock_get_redis.return_value = redis_client
_process_user_file_impl(
user_file_id=str(uuid4()),
tenant_id="test-tenant",
redis_locking=True,
)
lock.acquire.assert_called_once()
mock_get_session.assert_not_called()
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
@patch(f"{TASKS_MODULE}.get_redis_client")
def test_redis_locking_false_skips_redis_entirely(
self,
mock_get_redis: MagicMock,
mock_get_session: MagicMock,
) -> None:
session = _mock_session_returning_none()
mock_get_session.return_value.__enter__.return_value = session
_process_user_file_impl(
user_file_id=str(uuid4()),
tenant_id="test-tenant",
redis_locking=False,
)
mock_get_redis.assert_not_called()
mock_get_session.assert_called_once()
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
@patch(f"{TASKS_MODULE}.get_redis_client")
def test_both_paths_call_db_get(
self,
mock_get_redis: MagicMock,
mock_get_session: MagicMock,
) -> None:
"""Both redis_locking=True and False should call db_session.get(UserFile, ...)."""
redis_client = MagicMock()
lock = MagicMock()
lock.acquire.return_value = True
lock.owned.return_value = True
redis_client.lock.return_value = lock
mock_get_redis.return_value = redis_client
session = _mock_session_returning_none()
mock_get_session.return_value.__enter__.return_value = session
uid = str(uuid4())
_process_user_file_impl(user_file_id=uid, tenant_id="t", redis_locking=True)
call_count_true = session.get.call_count
session.reset_mock()
mock_get_session.reset_mock()
mock_get_session.return_value.__enter__.return_value = session
_process_user_file_impl(user_file_id=uid, tenant_id="t", redis_locking=False)
call_count_false = session.get.call_count
assert call_count_true == call_count_false == 1
# ------------------------------------------------------------------
# _delete_user_file_impl
# ------------------------------------------------------------------
class TestDeleteUserFileImpl:
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
@patch(f"{TASKS_MODULE}.get_redis_client")
def test_redis_locking_true_acquires_and_releases_lock(
self,
mock_get_redis: MagicMock,
mock_get_session: MagicMock,
) -> None:
redis_client = MagicMock()
lock = MagicMock()
lock.acquire.return_value = True
lock.owned.return_value = True
redis_client.lock.return_value = lock
mock_get_redis.return_value = redis_client
session = _mock_session_returning_none()
mock_get_session.return_value.__enter__.return_value = session
_delete_user_file_impl(
user_file_id=str(uuid4()),
tenant_id="test-tenant",
redis_locking=True,
)
mock_get_redis.assert_called_once()
lock.acquire.assert_called_once_with(blocking=False)
lock.release.assert_called_once()
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
@patch(f"{TASKS_MODULE}.get_redis_client")
def test_redis_locking_true_skips_when_lock_held(
self,
mock_get_redis: MagicMock,
mock_get_session: MagicMock,
) -> None:
redis_client = MagicMock()
lock = MagicMock()
lock.acquire.return_value = False
redis_client.lock.return_value = lock
mock_get_redis.return_value = redis_client
_delete_user_file_impl(
user_file_id=str(uuid4()),
tenant_id="test-tenant",
redis_locking=True,
)
lock.acquire.assert_called_once()
mock_get_session.assert_not_called()
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
@patch(f"{TASKS_MODULE}.get_redis_client")
def test_redis_locking_false_skips_redis_entirely(
self,
mock_get_redis: MagicMock,
mock_get_session: MagicMock,
) -> None:
session = _mock_session_returning_none()
mock_get_session.return_value.__enter__.return_value = session
_delete_user_file_impl(
user_file_id=str(uuid4()),
tenant_id="test-tenant",
redis_locking=False,
)
mock_get_redis.assert_not_called()
mock_get_session.assert_called_once()
# ------------------------------------------------------------------
# _project_sync_user_file_impl
# ------------------------------------------------------------------
class TestProjectSyncUserFileImpl:
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
@patch(f"{TASKS_MODULE}.get_redis_client")
def test_redis_locking_true_acquires_and_releases_lock(
self,
mock_get_redis: MagicMock,
mock_get_session: MagicMock,
) -> None:
redis_client = MagicMock()
lock = MagicMock()
lock.acquire.return_value = True
lock.owned.return_value = True
redis_client.lock.return_value = lock
mock_get_redis.return_value = redis_client
session = _mock_session_returning_none()
mock_get_session.return_value.__enter__.return_value = session
_project_sync_user_file_impl(
user_file_id=str(uuid4()),
tenant_id="test-tenant",
redis_locking=True,
)
mock_get_redis.assert_called_once()
redis_client.delete.assert_called_once()
lock.acquire.assert_called_once_with(blocking=False)
lock.release.assert_called_once()
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
@patch(f"{TASKS_MODULE}.get_redis_client")
def test_redis_locking_true_skips_when_lock_held(
self,
mock_get_redis: MagicMock,
mock_get_session: MagicMock,
) -> None:
redis_client = MagicMock()
lock = MagicMock()
lock.acquire.return_value = False
redis_client.lock.return_value = lock
mock_get_redis.return_value = redis_client
_project_sync_user_file_impl(
user_file_id=str(uuid4()),
tenant_id="test-tenant",
redis_locking=True,
)
lock.acquire.assert_called_once()
mock_get_session.assert_not_called()
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
@patch(f"{TASKS_MODULE}.get_redis_client")
def test_redis_locking_false_skips_redis_entirely(
self,
mock_get_redis: MagicMock,
mock_get_session: MagicMock,
) -> None:
session = _mock_session_returning_none()
mock_get_session.return_value.__enter__.return_value = session
_project_sync_user_file_impl(
user_file_id=str(uuid4()),
tenant_id="test-tenant",
redis_locking=False,
)
mock_get_redis.assert_not_called()
mock_get_session.assert_called_once()

View File

@@ -0,0 +1,421 @@
"""Tests for no-vector-DB user file processing paths.
Verifies that when DISABLE_VECTOR_DB is True:
- _process_user_file_impl calls _process_user_file_without_vector_db (not indexing)
- _process_user_file_without_vector_db extracts text, counts tokens, stores plaintext,
sets status=COMPLETED and chunk_count=0
- _delete_user_file_impl skips vector DB chunk deletion
- _project_sync_user_file_impl skips vector DB metadata update
"""
from unittest.mock import MagicMock
from unittest.mock import patch
from uuid import uuid4
from onyx.background.celery.tasks.user_file_processing.tasks import (
_delete_user_file_impl,
)
from onyx.background.celery.tasks.user_file_processing.tasks import (
_process_user_file_impl,
)
from onyx.background.celery.tasks.user_file_processing.tasks import (
_process_user_file_without_vector_db,
)
from onyx.background.celery.tasks.user_file_processing.tasks import (
_project_sync_user_file_impl,
)
from onyx.configs.constants import DocumentSource
from onyx.connectors.models import Document
from onyx.connectors.models import TextSection
from onyx.db.enums import UserFileStatus
TASKS_MODULE = "onyx.background.celery.tasks.user_file_processing.tasks"
LLM_FACTORY_MODULE = "onyx.llm.factory"
def _make_documents(texts: list[str]) -> list[Document]:
"""Build a list of Document objects with the given section texts."""
return [
Document(
id=str(uuid4()),
source=DocumentSource.USER_FILE,
sections=[TextSection(text=t)],
semantic_identifier=f"test-doc-{i}",
metadata={},
)
for i, t in enumerate(texts)
]
def _make_user_file(
*,
status: UserFileStatus = UserFileStatus.PROCESSING,
file_id: str = "test-file-id",
name: str = "test.txt",
) -> MagicMock:
"""Return a MagicMock mimicking a UserFile ORM instance."""
uf = MagicMock()
uf.id = uuid4()
uf.file_id = file_id
uf.name = name
uf.status = status
uf.token_count = None
uf.chunk_count = None
uf.last_project_sync_at = None
uf.projects = []
uf.assistants = []
uf.needs_project_sync = True
uf.needs_persona_sync = True
return uf
# ------------------------------------------------------------------
# _process_user_file_without_vector_db — direct tests
# ------------------------------------------------------------------
class TestProcessUserFileWithoutVectorDb:
@patch(f"{TASKS_MODULE}.store_user_file_plaintext")
@patch(f"{LLM_FACTORY_MODULE}.get_llm_tokenizer_encode_func")
@patch(f"{LLM_FACTORY_MODULE}.get_default_llm")
def test_extracts_and_combines_text(
self,
mock_get_llm: MagicMock, # noqa: ARG002
mock_get_encode: MagicMock,
mock_store_plaintext: MagicMock,
) -> None:
mock_encode = MagicMock(return_value=[1, 2, 3, 4, 5])
mock_get_encode.return_value = mock_encode
uf = _make_user_file()
docs = _make_documents(["hello world", "foo bar"])
db_session = MagicMock()
_process_user_file_without_vector_db(uf, docs, db_session)
stored_text = mock_store_plaintext.call_args.kwargs["plaintext_content"]
assert "hello world" in stored_text
assert "foo bar" in stored_text
@patch(f"{TASKS_MODULE}.store_user_file_plaintext")
@patch(f"{LLM_FACTORY_MODULE}.get_llm_tokenizer_encode_func")
@patch(f"{LLM_FACTORY_MODULE}.get_default_llm")
def test_computes_token_count(
self,
mock_get_llm: MagicMock, # noqa: ARG002
mock_get_encode: MagicMock,
mock_store_plaintext: MagicMock, # noqa: ARG002
) -> None:
mock_encode = MagicMock(return_value=list(range(42)))
mock_get_encode.return_value = mock_encode
uf = _make_user_file()
docs = _make_documents(["some text content"])
db_session = MagicMock()
_process_user_file_without_vector_db(uf, docs, db_session)
assert uf.token_count == 42
@patch(f"{TASKS_MODULE}.store_user_file_plaintext")
@patch(f"{LLM_FACTORY_MODULE}.get_llm_tokenizer_encode_func")
@patch(f"{LLM_FACTORY_MODULE}.get_default_llm")
def test_token_count_falls_back_to_none_on_error(
self,
mock_get_llm: MagicMock,
mock_get_encode: MagicMock, # noqa: ARG002
mock_store_plaintext: MagicMock, # noqa: ARG002
) -> None:
mock_get_llm.side_effect = RuntimeError("No LLM configured")
uf = _make_user_file()
docs = _make_documents(["text"])
db_session = MagicMock()
_process_user_file_without_vector_db(uf, docs, db_session)
assert uf.token_count is None
@patch(f"{TASKS_MODULE}.store_user_file_plaintext")
@patch(f"{LLM_FACTORY_MODULE}.get_llm_tokenizer_encode_func")
@patch(f"{LLM_FACTORY_MODULE}.get_default_llm")
def test_stores_plaintext(
self,
mock_get_llm: MagicMock, # noqa: ARG002
mock_get_encode: MagicMock,
mock_store_plaintext: MagicMock,
) -> None:
mock_get_encode.return_value = MagicMock(return_value=[1])
uf = _make_user_file()
docs = _make_documents(["content to store"])
db_session = MagicMock()
_process_user_file_without_vector_db(uf, docs, db_session)
mock_store_plaintext.assert_called_once_with(
user_file_id=uf.id,
plaintext_content="content to store",
)
@patch(f"{TASKS_MODULE}.store_user_file_plaintext")
@patch(f"{LLM_FACTORY_MODULE}.get_llm_tokenizer_encode_func")
@patch(f"{LLM_FACTORY_MODULE}.get_default_llm")
def test_sets_completed_status_and_zero_chunk_count(
self,
mock_get_llm: MagicMock, # noqa: ARG002
mock_get_encode: MagicMock,
mock_store_plaintext: MagicMock, # noqa: ARG002
) -> None:
mock_get_encode.return_value = MagicMock(return_value=[1])
uf = _make_user_file()
docs = _make_documents(["text"])
db_session = MagicMock()
_process_user_file_without_vector_db(uf, docs, db_session)
assert uf.status == UserFileStatus.COMPLETED
assert uf.chunk_count == 0
assert uf.last_project_sync_at is not None
db_session.add.assert_called_once_with(uf)
db_session.commit.assert_called_once()
@patch(f"{TASKS_MODULE}.store_user_file_plaintext")
@patch(f"{LLM_FACTORY_MODULE}.get_llm_tokenizer_encode_func")
@patch(f"{LLM_FACTORY_MODULE}.get_default_llm")
def test_preserves_deleting_status(
self,
mock_get_llm: MagicMock, # noqa: ARG002
mock_get_encode: MagicMock,
mock_store_plaintext: MagicMock, # noqa: ARG002
) -> None:
mock_get_encode.return_value = MagicMock(return_value=[1])
uf = _make_user_file(status=UserFileStatus.DELETING)
docs = _make_documents(["text"])
db_session = MagicMock()
_process_user_file_without_vector_db(uf, docs, db_session)
assert uf.status == UserFileStatus.DELETING
assert uf.chunk_count == 0
# ------------------------------------------------------------------
# _process_user_file_impl — branching on DISABLE_VECTOR_DB
# ------------------------------------------------------------------
class TestProcessImplBranching:
@patch(f"{TASKS_MODULE}._process_user_file_without_vector_db")
@patch(f"{TASKS_MODULE}._process_user_file_with_indexing")
@patch(f"{TASKS_MODULE}.DISABLE_VECTOR_DB", True)
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
def test_calls_without_vector_db_when_disabled(
self,
mock_get_session: MagicMock,
mock_with_indexing: MagicMock,
mock_without_vdb: MagicMock,
) -> None:
uf = _make_user_file()
session = MagicMock()
session.get.return_value = uf
mock_get_session.return_value.__enter__.return_value = session
connector_mock = MagicMock()
connector_mock.load_from_state.return_value = [_make_documents(["hello"])]
with patch(f"{TASKS_MODULE}.LocalFileConnector", return_value=connector_mock):
_process_user_file_impl(
user_file_id=str(uf.id),
tenant_id="test-tenant",
redis_locking=False,
)
mock_without_vdb.assert_called_once()
mock_with_indexing.assert_not_called()
@patch(f"{TASKS_MODULE}._process_user_file_without_vector_db")
@patch(f"{TASKS_MODULE}._process_user_file_with_indexing")
@patch(f"{TASKS_MODULE}.DISABLE_VECTOR_DB", False)
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
def test_calls_with_indexing_when_vector_db_enabled(
self,
mock_get_session: MagicMock,
mock_with_indexing: MagicMock,
mock_without_vdb: MagicMock,
) -> None:
uf = _make_user_file()
session = MagicMock()
session.get.return_value = uf
mock_get_session.return_value.__enter__.return_value = session
connector_mock = MagicMock()
connector_mock.load_from_state.return_value = [_make_documents(["hello"])]
with patch(f"{TASKS_MODULE}.LocalFileConnector", return_value=connector_mock):
_process_user_file_impl(
user_file_id=str(uf.id),
tenant_id="test-tenant",
redis_locking=False,
)
mock_with_indexing.assert_called_once()
mock_without_vdb.assert_not_called()
@patch(f"{TASKS_MODULE}.run_indexing_pipeline")
@patch(f"{TASKS_MODULE}.store_user_file_plaintext")
@patch(f"{TASKS_MODULE}.DISABLE_VECTOR_DB", True)
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
def test_indexing_pipeline_not_called_when_disabled(
self,
mock_get_session: MagicMock,
mock_store_plaintext: MagicMock, # noqa: ARG002
mock_run_pipeline: MagicMock,
) -> None:
"""End-to-end: verify run_indexing_pipeline is never invoked."""
uf = _make_user_file()
session = MagicMock()
session.get.return_value = uf
mock_get_session.return_value.__enter__.return_value = session
connector_mock = MagicMock()
connector_mock.load_from_state.return_value = [_make_documents(["content"])]
with (
patch(f"{TASKS_MODULE}.LocalFileConnector", return_value=connector_mock),
patch(f"{LLM_FACTORY_MODULE}.get_default_llm"),
patch(
f"{LLM_FACTORY_MODULE}.get_llm_tokenizer_encode_func",
return_value=MagicMock(return_value=[1, 2, 3]),
),
):
_process_user_file_impl(
user_file_id=str(uf.id),
tenant_id="test-tenant",
redis_locking=False,
)
mock_run_pipeline.assert_not_called()
# ------------------------------------------------------------------
# _delete_user_file_impl — vector DB skip
# ------------------------------------------------------------------
class TestDeleteImplNoVectorDb:
@patch(f"{TASKS_MODULE}.DISABLE_VECTOR_DB", True)
@patch(f"{TASKS_MODULE}.get_default_file_store")
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
def test_skips_vector_db_deletion(
self,
mock_get_session: MagicMock,
mock_get_file_store: MagicMock,
) -> None:
uf = _make_user_file(status=UserFileStatus.DELETING)
session = MagicMock()
session.get.return_value = uf
mock_get_session.return_value.__enter__.return_value = session
mock_get_file_store.return_value = MagicMock()
with (
patch(f"{TASKS_MODULE}.get_all_document_indices") as mock_get_indices,
patch(f"{TASKS_MODULE}.get_active_search_settings") as mock_get_ss,
patch(f"{TASKS_MODULE}.httpx_init_vespa_pool") as mock_vespa_pool,
):
_delete_user_file_impl(
user_file_id=str(uf.id),
tenant_id="test-tenant",
redis_locking=False,
)
mock_get_indices.assert_not_called()
mock_get_ss.assert_not_called()
mock_vespa_pool.assert_not_called()
session.delete.assert_called_once_with(uf)
session.commit.assert_called_once()
@patch(f"{TASKS_MODULE}.DISABLE_VECTOR_DB", True)
@patch(f"{TASKS_MODULE}.get_default_file_store")
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
def test_still_deletes_file_store_and_db_record(
self,
mock_get_session: MagicMock,
mock_get_file_store: MagicMock,
) -> None:
uf = _make_user_file(status=UserFileStatus.DELETING)
session = MagicMock()
session.get.return_value = uf
mock_get_session.return_value.__enter__.return_value = session
file_store = MagicMock()
mock_get_file_store.return_value = file_store
_delete_user_file_impl(
user_file_id=str(uf.id),
tenant_id="test-tenant",
redis_locking=False,
)
assert file_store.delete_file.call_count == 2
session.delete.assert_called_once_with(uf)
session.commit.assert_called_once()
# ------------------------------------------------------------------
# _project_sync_user_file_impl — vector DB skip
# ------------------------------------------------------------------
class TestProjectSyncImplNoVectorDb:
@patch(f"{TASKS_MODULE}.DISABLE_VECTOR_DB", True)
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
def test_skips_vector_db_update(
self,
mock_get_session: MagicMock,
) -> None:
uf = _make_user_file(status=UserFileStatus.COMPLETED)
session = MagicMock()
session.execute.return_value.scalar_one_or_none.return_value = uf
mock_get_session.return_value.__enter__.return_value = session
with (
patch(f"{TASKS_MODULE}.get_all_document_indices") as mock_get_indices,
patch(f"{TASKS_MODULE}.get_active_search_settings") as mock_get_ss,
patch(f"{TASKS_MODULE}.httpx_init_vespa_pool") as mock_vespa_pool,
):
_project_sync_user_file_impl(
user_file_id=str(uf.id),
tenant_id="test-tenant",
redis_locking=False,
)
mock_get_indices.assert_not_called()
mock_get_ss.assert_not_called()
mock_vespa_pool.assert_not_called()
@patch(f"{TASKS_MODULE}.DISABLE_VECTOR_DB", True)
@patch(f"{TASKS_MODULE}.get_session_with_current_tenant")
def test_still_clears_sync_flags(
self,
mock_get_session: MagicMock,
) -> None:
uf = _make_user_file(status=UserFileStatus.COMPLETED)
session = MagicMock()
session.execute.return_value.scalar_one_or_none.return_value = uf
mock_get_session.return_value.__enter__.return_value = session
_project_sync_user_file_impl(
user_file_id=str(uf.id),
tenant_id="test-tenant",
redis_locking=False,
)
assert uf.needs_project_sync is False
assert uf.needs_persona_sync is False
assert uf.last_project_sync_at is not None
session.add.assert_called_once_with(uf)
session.commit.assert_called_once()

View File

@@ -44,7 +44,6 @@ def _build_provider_view(
id=1,
name="test-provider",
provider=provider,
default_model_name="test-model",
model_configurations=[
ModelConfigurationView(
name="test-model",
@@ -62,7 +61,6 @@ def _build_provider_view(
groups=[],
personas=[],
deployment_name=None,
default_vision_model=None,
)

View File

@@ -0,0 +1,52 @@
"""Tests for startup validation in no-vector-DB mode.
Verifies that DISABLE_VECTOR_DB raises RuntimeError when combined with
incompatible settings (MULTI_TENANT, ENABLE_CRAFT).
"""
from unittest.mock import patch
import pytest
class TestValidateNoVectorDbSettings:
@patch("onyx.main.DISABLE_VECTOR_DB", False)
def test_no_error_when_vector_db_enabled(self) -> None:
from onyx.main import validate_no_vector_db_settings
validate_no_vector_db_settings()
@patch("onyx.main.DISABLE_VECTOR_DB", True)
@patch("onyx.main.MULTI_TENANT", False)
@patch("onyx.server.features.build.configs.ENABLE_CRAFT", False)
def test_no_error_when_no_conflicts(self) -> None:
from onyx.main import validate_no_vector_db_settings
validate_no_vector_db_settings()
@patch("onyx.main.DISABLE_VECTOR_DB", True)
@patch("onyx.main.MULTI_TENANT", True)
def test_raises_on_multi_tenant(self) -> None:
from onyx.main import validate_no_vector_db_settings
with pytest.raises(RuntimeError, match="MULTI_TENANT"):
validate_no_vector_db_settings()
@patch("onyx.main.DISABLE_VECTOR_DB", True)
@patch("onyx.main.MULTI_TENANT", False)
@patch("onyx.server.features.build.configs.ENABLE_CRAFT", True)
def test_raises_on_enable_craft(self) -> None:
from onyx.main import validate_no_vector_db_settings
with pytest.raises(RuntimeError, match="ENABLE_CRAFT"):
validate_no_vector_db_settings()
@patch("onyx.main.DISABLE_VECTOR_DB", True)
@patch("onyx.main.MULTI_TENANT", True)
@patch("onyx.server.features.build.configs.ENABLE_CRAFT", True)
def test_multi_tenant_checked_before_craft(self) -> None:
"""MULTI_TENANT is checked first, so it should be the error raised."""
from onyx.main import validate_no_vector_db_settings
with pytest.raises(RuntimeError, match="MULTI_TENANT"):
validate_no_vector_db_settings()

View File

@@ -468,7 +468,7 @@ services:
- minio_data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
test: ["CMD", "mc", "ready", "local"]
interval: 30s
timeout: 20s
retries: 3

View File

@@ -293,7 +293,7 @@ services:
- minio_data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
test: ["CMD", "mc", "ready", "local"]
interval: 30s
timeout: 20s
retries: 3

View File

@@ -298,7 +298,7 @@ services:
- minio_data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
test: ["CMD", "mc", "ready", "local"]
interval: 30s
timeout: 20s
retries: 3

View File

@@ -335,7 +335,7 @@ services:
- minio_data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
test: ["CMD", "mc", "ready", "local"]
interval: 30s
timeout: 20s
retries: 3

View File

@@ -232,7 +232,7 @@ services:
- minio_data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
test: ["CMD", "mc", "ready", "local"]
interval: 30s
timeout: 20s
retries: 3

View File

@@ -520,7 +520,7 @@ services:
- minio_data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
test: ["CMD", "mc", "ready", "local"]
interval: 30s
timeout: 20s
retries: 3

View File

@@ -1,6 +1,6 @@
/* Hoverable — item transitions */
.hoverable-item {
transition: opacity 200ms ease-in-out;
transition: opacity 150ms ease-in-out;
}
.hoverable-item[data-hoverable-variant="opacity-on-hover"] {

View File

@@ -58,7 +58,7 @@ function ContentAction({
return (
<div className="flex flex-row items-stretch w-full">
<div className={cn("flex-1 min-w-0", padding)}>
<div className={cn("flex-1 min-w-0 self-center", padding)}>
<Content {...contentProps} />
</div>
{rightChildren && (

View File

@@ -2,7 +2,8 @@
import { useState } from "react";
import Link from "next/link";
import { Section, LineItemLayout } from "@/layouts/general-layouts";
import { Section } from "@/layouts/general-layouts";
import { Content } from "@opal/layouts";
import * as InputLayouts from "@/layouts/input-layouts";
import Card from "@/refresh-components/cards/Card";
import Button from "@/refresh-components/buttons/Button";
@@ -372,9 +373,11 @@ function SeatsCard({
padding={1}
height="auto"
>
<LineItemLayout
<Content
title="Update Seats"
description="Add or remove seats to reflect your team size."
sizePreset="main-content"
variant="section"
/>
<Button main secondary onClick={handleCancel} disabled={isSubmitting}>
Cancel

View File

@@ -2,7 +2,7 @@
import { ModalCreationInterface } from "@/refresh-components/contexts/ModalContext";
import { ImageProvider } from "@/app/admin/configuration/image-generation/constants";
import { LLMProviderView } from "@/app/admin/configuration/llm/interfaces";
import { LLMProviderView } from "@/interfaces/llm";
import { ImageGenerationConfigView } from "@/lib/configuration/imageConfigurationService";
import { getImageGenForm } from "./forms";

View File

@@ -7,7 +7,7 @@ import { Select } from "@/refresh-components/cards";
import { useCreateModal } from "@/refresh-components/contexts/ModalContext";
import { toast } from "@/hooks/useToast";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { LLMProviderView } from "@/app/admin/configuration/llm/interfaces";
import { LLMProviderResponse, LLMProviderView } from "@/interfaces/llm";
import {
IMAGE_PROVIDER_GROUPS,
ImageProvider,
@@ -23,13 +23,14 @@ import Message from "@/refresh-components/messages/Message";
export default function ImageGenerationContent() {
const {
data: llmProviders = [],
data: llmProviderResponse,
error: llmError,
mutate: refetchProviders,
} = useSWR<LLMProviderView[]>(
} = useSWR<LLMProviderResponse<LLMProviderView>>(
"/api/admin/llm/provider?include_image_gen=true",
errorHandlingFetcher
);
const llmProviders = llmProviderResponse?.providers ?? [];
const {
data: configs = [],

View File

@@ -1,6 +1,6 @@
import { FormikProps } from "formik";
import { ImageProvider } from "../constants";
import { LLMProviderView } from "@/app/admin/configuration/llm/interfaces";
import { LLMProviderView } from "@/interfaces/llm";
import {
ImageGenerationConfigView,
ImageGenerationCredentials,

View File

@@ -1,84 +0,0 @@
"use client";
import { errorHandlingFetcher } from "@/lib/fetcher";
import useSWR from "swr";
import { Callout } from "@/components/ui/callout";
import Text from "@/refresh-components/texts/Text";
import Title from "@/components/ui/title";
import { ThreeDotsLoader } from "@/components/Loading";
import { LLMProviderView } from "./interfaces";
import { LLM_PROVIDERS_ADMIN_URL } from "./constants";
import { OpenAIForm } from "./forms/OpenAIForm";
import { AnthropicForm } from "./forms/AnthropicForm";
import { OllamaForm } from "./forms/OllamaForm";
import { AzureForm } from "./forms/AzureForm";
import { BedrockForm } from "./forms/BedrockForm";
import { VertexAIForm } from "./forms/VertexAIForm";
import { OpenRouterForm } from "./forms/OpenRouterForm";
import { getFormForExistingProvider } from "./forms/getForm";
import { CustomForm } from "./forms/CustomForm";
export function LLMConfiguration() {
const { data: existingLlmProviders } = useSWR<LLMProviderView[]>(
LLM_PROVIDERS_ADMIN_URL,
errorHandlingFetcher
);
if (!existingLlmProviders) {
return <ThreeDotsLoader />;
}
const isFirstProvider = existingLlmProviders.length === 0;
return (
<>
<Title className="mb-2">Enabled LLM Providers</Title>
{existingLlmProviders.length > 0 ? (
<>
<Text as="p" className="mb-4">
If multiple LLM providers are enabled, the default provider will be
used for all &quot;Default&quot; Assistants. For user-created
Assistants, you can select the LLM provider/model that best fits the
use case!
</Text>
<div className="flex flex-col gap-y-4">
{[...existingLlmProviders]
.sort((a, b) => {
if (a.is_default_provider && !b.is_default_provider) return -1;
if (!a.is_default_provider && b.is_default_provider) return 1;
return 0;
})
.map((llmProvider) => (
<div key={llmProvider.id}>
{getFormForExistingProvider(llmProvider)}
</div>
))}
</div>
</>
) : (
<Callout type="warning" title="No LLM providers configured yet">
Please set one up below in order to start using Onyx!
</Callout>
)}
<Title className="mb-2 mt-6">Add LLM Provider</Title>
<Text as="p" className="mb-4">
Add a new LLM provider by either selecting from one of the default
providers or by specifying your own custom LLM provider.
</Text>
<div className="flex flex-col gap-y-4">
<OpenAIForm shouldMarkAsDefault={isFirstProvider} />
<AnthropicForm shouldMarkAsDefault={isFirstProvider} />
<OllamaForm shouldMarkAsDefault={isFirstProvider} />
<AzureForm shouldMarkAsDefault={isFirstProvider} />
<BedrockForm shouldMarkAsDefault={isFirstProvider} />
<VertexAIForm shouldMarkAsDefault={isFirstProvider} />
<OpenRouterForm shouldMarkAsDefault={isFirstProvider} />
<CustomForm shouldMarkAsDefault={isFirstProvider} />
</div>
</>
);
}

View File

@@ -1,13 +1,14 @@
"use client";
import { ArrayHelpers, FieldArray, FormikProps, useField } from "formik";
import { ModelConfiguration } from "./interfaces";
import { ModelConfiguration } from "@/interfaces/llm";
import { ManualErrorMessage, TextFormField } from "@/components/Field";
import { useEffect, useState } from "react";
import CreateButton from "@/refresh-components/buttons/CreateButton";
import { Button } from "@opal/components";
import { SvgX } from "@opal/icons";
import Text from "@/refresh-components/texts/Text";
function ModelConfigurationRow({
name,
index,

View File

@@ -1,44 +0,0 @@
import { LLMProviderName, LLMProviderView } from "../interfaces";
import { AnthropicForm } from "./AnthropicForm";
import { OpenAIForm } from "./OpenAIForm";
import { OllamaForm } from "./OllamaForm";
import { AzureForm } from "./AzureForm";
import { VertexAIForm } from "./VertexAIForm";
import { OpenRouterForm } from "./OpenRouterForm";
import { CustomForm } from "./CustomForm";
import { BedrockForm } from "./BedrockForm";
export function detectIfRealOpenAIProvider(provider: LLMProviderView) {
return (
provider.provider === LLMProviderName.OPENAI &&
provider.api_key &&
!provider.api_base &&
Object.keys(provider.custom_config || {}).length === 0
);
}
export const getFormForExistingProvider = (provider: LLMProviderView) => {
switch (provider.provider) {
case LLMProviderName.OPENAI:
// "openai" as a provider name can be used for litellm proxy / any OpenAI-compatible provider
if (detectIfRealOpenAIProvider(provider)) {
return <OpenAIForm existingLlmProvider={provider} />;
} else {
return <CustomForm existingLlmProvider={provider} />;
}
case LLMProviderName.ANTHROPIC:
return <AnthropicForm existingLlmProvider={provider} />;
case LLMProviderName.OLLAMA_CHAT:
return <OllamaForm existingLlmProvider={provider} />;
case LLMProviderName.AZURE:
return <AzureForm existingLlmProvider={provider} />;
case LLMProviderName.VERTEX_AI:
return <VertexAIForm existingLlmProvider={provider} />;
case LLMProviderName.BEDROCK:
return <BedrockForm existingLlmProvider={provider} />;
case LLMProviderName.OPENROUTER:
return <OpenRouterForm existingLlmProvider={provider} />;
default:
return <CustomForm existingLlmProvider={provider} />;
}
};

View File

@@ -1,14 +1,7 @@
"use client";
import { AdminPageTitle } from "@/components/admin/Title";
import { LLMConfiguration } from "./LLMConfiguration";
import { SvgCpu } from "@opal/icons";
export default function Page() {
return (
<>
<AdminPageTitle title="LLM Setup" icon={SvgCpu} />
import LLMConfigurationPage from "@/refresh-pages/admin/LLMConfigurationPage";
<LLMConfiguration />
</>
);
export default function Page() {
return <LLMConfigurationPage />;
}

View File

@@ -24,7 +24,7 @@ import {
BedrockFetchParams,
OllamaFetchParams,
OpenRouterFetchParams,
} from "./interfaces";
} from "@/interfaces/llm";
import { SvgAws, SvgOpenrouter } from "@opal/icons";
// Aggregator providers that host models from multiple vendors
@@ -106,8 +106,9 @@ export const getProviderIcon = (
return CPUIcon;
};
export const isAnthropic = (provider: string, modelName: string) =>
provider === "anthropic" || modelName.toLowerCase().includes("claude");
export const isAnthropic = (provider: string, modelName?: string) =>
provider === LLMProviderName.ANTHROPIC ||
!!modelName?.toLowerCase().includes("claude");
/**
* Fetches Bedrock models directly without any form state dependencies.
@@ -153,6 +154,7 @@ export const fetchBedrockModels = async (
is_visible: false,
max_input_tokens: modelData.max_input_tokens,
supports_image_input: modelData.supports_image_input,
supports_reasoning: false,
}));
return { models };
@@ -205,6 +207,7 @@ export const fetchOllamaModels = async (
is_visible: true,
max_input_tokens: modelData.max_input_tokens,
supports_image_input: modelData.supports_image_input,
supports_reasoning: false,
}));
return { models };
@@ -262,6 +265,7 @@ export const fetchOpenRouterModels = async (
is_visible: true,
max_input_tokens: modelData.max_input_tokens,
supports_image_input: modelData.supports_image_input,
supports_reasoning: false,
}));
return { models };

View File

@@ -12,6 +12,7 @@ import Tabs from "@/refresh-components/Tabs";
import { useFormikContext } from "formik";
import * as GeneralLayouts from "@/layouts/general-layouts";
import * as InputLayouts from "@/layouts/input-layouts";
import { Content } from "@opal/layouts";
import CheckboxField from "@/refresh-components/form/LabeledCheckboxField";
import InputTextAreaField from "@/refresh-components/form/InputTextAreaField";
import Text from "@/refresh-components/texts/Text";
@@ -46,9 +47,11 @@ const TabsField: FC<TabsFieldProps> = ({
return (
<GeneralLayouts.Section gap={0.5} alignItems="start">
{tabField.label && (
<InputLayouts.Title
<Content
title={resolvedLabel ?? ""}
description={resolvedDescription}
sizePreset="main-content"
variant="section"
/>
)}

View File

@@ -5,7 +5,8 @@ import { cn } from "@/lib/utils";
import { ThreeDotsLoader } from "@/components/Loading";
import { ErrorCallout } from "@/components/ErrorCallout";
import { toast } from "@/hooks/useToast";
import { Section, LineItemLayout } from "@/layouts/general-layouts";
import { Section } from "@/layouts/general-layouts";
import { ContentAction } from "@opal/layouts";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import Text from "@/refresh-components/texts/Text";
import Card from "@/refresh-components/cards/Card";
@@ -89,9 +90,11 @@ function GuildDetailContent({
)}
<Card variant={disabled ? "disabled" : "primary"}>
<LineItemLayout
<ContentAction
title="Channel Configuration"
description="Run !sync-channels in Discord to update the channel list."
sizePreset="main-content"
variant="section"
rightChildren={
isRegistered && !channelsLoading && !channelsError ? (
<Section
@@ -339,9 +342,11 @@ export default function Page({ params }: Props) {
<SettingsLayouts.Body>
{/* Default Persona Selector */}
<Card variant={!guild?.enabled ? "disabled" : "primary"}>
<LineItemLayout
<ContentAction
title="Default Agent"
description="The agent used by the bot in all channels unless overridden."
sizePreset="main-content"
variant="section"
rightChildren={
<InputSelect
value={guild?.default_persona_id?.toString() ?? "default"}

View File

@@ -5,7 +5,7 @@ import useSWR from "swr";
import { SvgArrowExchange } from "@opal/icons";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import Card from "@/refresh-components/cards/Card";
import { LineItemLayout } from "@/layouts/general-layouts";
import { Content, ContentAction } from "@opal/layouts";
import Text from "@/refresh-components/texts/Text";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import Button from "@/refresh-components/buttons/Button";
@@ -72,9 +72,10 @@ function MigrationStatusSection() {
<Card>
<Text headingH3>Migration Status</Text>
<LineItemLayout
<ContentAction
title="Started"
variant="secondary"
sizePreset="main-ui"
variant="section"
rightChildren={
<Text mainUiBody>
{hasStarted ? formatTimestamp(data.created_at!) : "Not started"}
@@ -82,9 +83,10 @@ function MigrationStatusSection() {
}
/>
<LineItemLayout
<ContentAction
title="Chunks Migrated"
variant="secondary"
sizePreset="main-ui"
variant="section"
rightChildren={
<Text mainUiBody>
{progressPercentage !== null
@@ -96,9 +98,10 @@ function MigrationStatusSection() {
}
/>
<LineItemLayout
<ContentAction
title="Completed"
variant="secondary"
sizePreset="main-ui"
variant="section"
rightChildren={
<Text mainUiBody>
{hasCompleted
@@ -174,10 +177,11 @@ function RetrievalSourceSection() {
return (
<Card>
<LineItemLayout
<Content
title="Retrieval Source"
description="Controls which document index is used for retrieval."
variant="secondary"
sizePreset="main-ui"
variant="section"
/>
<InputSelect

View File

@@ -25,7 +25,7 @@ import { ModelOption } from "@/components/embedding/ModelSelector";
import {
EMBEDDING_MODELS_ADMIN_URL,
EMBEDDING_PROVIDERS_ADMIN_URL,
} from "@/app/admin/configuration/llm/constants";
} from "@/lib/llmConfig/constants";
import { AdvancedSearchConfiguration } from "@/app/admin/embeddings/interfaces";
import Button from "@/refresh-components/buttons/Button";

View File

@@ -14,7 +14,7 @@ import {
import {
EMBEDDING_PROVIDERS_ADMIN_URL,
LLM_PROVIDERS_ADMIN_URL,
} from "@/app/admin/configuration/llm/constants";
} from "@/lib/llmConfig/constants";
import { mutate } from "swr";
import { testEmbedding } from "@/app/admin/embeddings/pages/utils";
import { SvgSettings } from "@opal/icons";

View File

@@ -11,7 +11,7 @@ import {
EmbeddingProvider,
getFormattedProviderName,
} from "@/components/embedding/interfaces";
import { EMBEDDING_PROVIDERS_ADMIN_URL } from "@/app/admin/configuration/llm/constants";
import { EMBEDDING_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import Modal from "@/refresh-components/Modal";
import { SvgSettings } from "@opal/icons";
export interface ProviderCreationModalProps {

View File

@@ -15,7 +15,7 @@ import {
import NumberInput from "../../connectors/[connector]/pages/ConnectorInput/NumberInput";
import { StringOrNumberOption } from "@/components/Dropdown";
import useSWR from "swr";
import { LLM_CONTEXTUAL_COST_ADMIN_URL } from "../../configuration/llm/constants";
import { LLM_CONTEXTUAL_COST_ADMIN_URL } from "@/lib/llmConfig/constants";
import { errorHandlingFetcher } from "@/lib/fetcher";
import Button from "@/refresh-components/buttons/Button";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";

View File

@@ -18,7 +18,7 @@ import SourceTag from "@/refresh-components/buttons/source-tag/SourceTag";
import { citationsToSourceInfoArray } from "@/refresh-components/buttons/source-tag/sourceTagUtils";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import LLMPopover from "@/refresh-components/popovers/LLMPopover";
import { parseLlmDescriptor } from "@/lib/llm/utils";
import { parseLlmDescriptor } from "@/lib/llmConfig/utils";
import { LlmManager } from "@/lib/hooks";
import { Message } from "@/app/app/interfaces";
import { SvgThumbsDown, SvgThumbsUp } from "@opal/icons";

View File

@@ -6,7 +6,8 @@ import { Button } from "@opal/components";
import Tag from "@/refresh-components/buttons/Tag";
import Text from "@/refresh-components/texts/Text";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import { Section, LineItemLayout } from "@/layouts/general-layouts";
import { Section } from "@/layouts/general-layouts";
import { ContentAction } from "@opal/layouts";
import { formatDurationSeconds } from "@/lib/time";
import { noProp } from "@/lib/utils";
import MemoriesModal from "@/refresh-components/modals/MemoriesModal";
@@ -61,10 +62,12 @@ function MemoryTagWithTooltip({
{memoryText}
</Text>
</div>
<LineItemLayout
variant="mini"
<ContentAction
icon={SvgAddLines}
title={operationLabel}
sizePreset="secondary"
variant="body"
prominence="muted"
rightChildren={
<Button
prominence="tertiary"

View File

@@ -11,7 +11,7 @@ import Text from "@/refresh-components/texts/Text";
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
import Switch from "@/refresh-components/inputs/Switch";
import LineItem from "@/refresh-components/buttons/LineItem";
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
import { LLMProviderDescriptor } from "@/interfaces/llm";
import {
BuildLlmSelection,
BUILD_MODE_PROVIDERS,

View File

@@ -9,7 +9,8 @@ import type { SharingScope } from "@/app/craft/types/streamingTypes";
import { cn } from "@/lib/utils";
import Popover from "@/refresh-components/Popover";
import Truncated from "@/refresh-components/texts/Truncated";
import { Section, LineItemLayout } from "@/layouts/general-layouts";
import { Section } from "@/layouts/general-layouts";
import { ContentAction } from "@opal/layouts";
interface ShareButtonProps {
sessionId: string;
@@ -133,11 +134,12 @@ export default function ShareButton({
: "hover:bg-background-tint-02"
)}
>
<LineItemLayout
<ContentAction
title={opt.label}
description={opt.description}
variant="tertiary"
reducedPadding
sizePreset="main-ui"
variant="section"
paddingVariant="sm"
/>
</div>
))}

View File

@@ -1,5 +1,5 @@
import { useMemo, useState, useCallback } from "react";
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
import { LLMProviderDescriptor } from "@/interfaces/llm";
import {
BuildLlmSelection,
getBuildLlmSelection,

View File

@@ -7,7 +7,7 @@ import { usePreProvisionPolling } from "@/app/craft/hooks/usePreProvisionPolling
import { CRAFT_SEARCH_PARAM_NAMES } from "@/app/craft/services/searchParams";
import { CRAFT_PATH } from "@/app/craft/v1/constants";
import { getBuildUserPersona } from "@/app/craft/onboarding/constants";
import { useLLMProviders } from "@/lib/hooks/useLLMProviders";
import { useLLMProviders } from "@/hooks/useLLMProviders";
import { checkPreProvisionedSession } from "@/app/craft/services/apiServices";
interface UseBuildSessionControllerProps {

View File

@@ -18,8 +18,8 @@ import {
getBuildLlmSelection,
getDefaultLlmSelection,
} from "@/app/craft/onboarding/constants";
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
import { LLM_PROVIDERS_ADMIN_URL } from "@/app/admin/configuration/llm/constants";
import { LLMProviderDescriptor } from "@/interfaces/llm";
import { LLM_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import {
buildInitialValues,
testApiKeyHelper,

View File

@@ -5,10 +5,7 @@ import { cn } from "@/lib/utils";
import { Disabled } from "@/refresh-components/Disabled";
import Text from "@/refresh-components/texts/Text";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import {
LLMProviderName,
LLMProviderDescriptor,
} from "@/app/admin/configuration/llm/interfaces";
import { LLMProviderName, LLMProviderDescriptor } from "@/interfaces/llm";
// Provider configurations
export type ProviderKey = "anthropic" | "openai" | "openrouter";

View File

@@ -19,13 +19,12 @@ const LLM_SELECTION_PRIORITY = [
interface MinimalLlmProvider {
name: string;
provider: string;
default_model_name: string;
is_default_provider: boolean | null;
model_configurations: { name: string; is_visible: boolean }[];
}
/**
* Get the best default LLM selection based on available providers.
* Priority: Anthropic > OpenAI > OpenRouter > system default > first available
* Priority: Anthropic > OpenAI > OpenRouter > first available
*/
export function getDefaultLlmSelection(
llmProviders: MinimalLlmProvider[] | undefined
@@ -44,23 +43,16 @@ export function getDefaultLlmSelection(
}
}
// Fallback: use the default provider's default model
const defaultProvider = llmProviders.find((p) => p.is_default_provider);
if (defaultProvider) {
return {
providerName: defaultProvider.name,
provider: defaultProvider.provider,
modelName: defaultProvider.default_model_name,
};
}
// Final fallback: first available provider
// Fallback: first available provider, use its first visible model
const firstProvider = llmProviders[0];
if (firstProvider) {
const firstModel = firstProvider.model_configurations.find(
(m) => m.is_visible
);
return {
providerName: firstProvider.name,
provider: firstProvider.provider,
modelName: firstProvider.default_model_name,
modelName: firstModel?.name ?? "",
};
}

View File

@@ -2,8 +2,8 @@
import { useCallback, useState, useMemo, useEffect } from "react";
import { useUser } from "@/providers/UserProvider";
import { useLLMProviders } from "@/lib/hooks/useLLMProviders";
import { LLMProviderName } from "@/app/admin/configuration/llm/interfaces";
import { useLLMProviders } from "@/hooks/useLLMProviders";
import { LLMProviderName } from "@/interfaces/llm";
import {
OnboardingModalMode,
OnboardingModalController,
@@ -18,9 +18,7 @@ import { useBuildSessionStore } from "@/app/craft/hooks/useBuildSessionStore";
// Check if all 3 build mode providers are configured (anthropic, openai, openrouter)
function checkAllProvidersConfigured(
llmProviders:
| import("@/app/admin/configuration/llm/interfaces").LLMProviderDescriptor[]
| undefined
llmProviders: import("@/interfaces/llm").LLMProviderDescriptor[] | undefined
): boolean {
if (!llmProviders || llmProviders.length === 0) {
return false;
@@ -35,9 +33,7 @@ function checkAllProvidersConfigured(
// Check if at least one provider is configured
function checkHasAnyProvider(
llmProviders:
| import("@/app/admin/configuration/llm/interfaces").LLMProviderDescriptor[]
| undefined
llmProviders: import("@/interfaces/llm").LLMProviderDescriptor[] | undefined
): boolean {
return !!(llmProviders && llmProviders.length > 0);
}

View File

@@ -1,4 +1,8 @@
import { WorkArea, Level } from "./constants";
import type {
LLMProviderDescriptor,
LLMProviderResponse,
} from "@/interfaces/llm";
export interface BuildUserInfo {
firstName: string;
@@ -33,9 +37,7 @@ export interface OnboardingModalController {
close: () => void;
// Data needed for modal
llmProviders:
| import("@/app/admin/configuration/llm/interfaces").LLMProviderDescriptor[]
| undefined;
llmProviders: LLMProviderDescriptor[] | undefined;
initialValues: {
firstName: string;
lastName: string;
@@ -54,7 +56,6 @@ export interface OnboardingModalController {
completeUserInfo: (info: BuildUserInfo) => Promise<void>;
completeLlmSetup: () => Promise<void>;
refetchLlmProviders: () => Promise<
| import("@/app/admin/configuration/llm/interfaces").LLMProviderDescriptor[]
| undefined
LLMProviderResponse<LLMProviderDescriptor> | undefined
>;
}

View File

@@ -3,7 +3,7 @@
import { useState } from "react";
import Card from "@/refresh-components/cards/Card";
import Text from "@/refresh-components/texts/Text";
import { LineItemLayout } from "@/layouts/general-layouts";
import { Content } from "@opal/layouts";
import Separator from "@/refresh-components/Separator";
import { ValidSources } from "@/lib/types";
import { getSourceMetadata } from "@/lib/sources";
@@ -99,14 +99,15 @@ export default function ComingSoonConnectors() {
const card = (
<div key={type} className="opacity-60">
<Card variant="secondary">
<LineItemLayout
<Content
icon={
type === ValidSources.Imap
? OutlookIcon
: sourceMetadata.icon
}
title={displayName}
center
sizePreset="main-ui"
variant="body"
/>
</Card>
</div>
@@ -118,7 +119,12 @@ export default function ComingSoonConnectors() {
card,
<div key="onedrive" className="opacity-60">
<Card variant="secondary">
<LineItemLayout icon={OneDriveIcon} title="OneDrive" center />
<Content
icon={OneDriveIcon}
title="OneDrive"
sizePreset="main-ui"
variant="body"
/>
</Card>
</div>,
];
@@ -130,7 +136,12 @@ export default function ComingSoonConnectors() {
card,
<div key="box" className="opacity-60">
<Card variant="secondary">
<LineItemLayout icon={BoxIcon} title="Box" center />
<Content
icon={BoxIcon}
title="Box"
sizePreset="main-ui"
variant="body"
/>
</Card>
</div>,
];
@@ -141,13 +152,23 @@ export default function ComingSoonConnectors() {
{/* Enterprise/ERP */}
<div className="opacity-60">
<Card variant="secondary">
<LineItemLayout icon={ServiceNowIcon} title="ServiceNow" center />
<Content
icon={ServiceNowIcon}
title="ServiceNow"
sizePreset="main-ui"
variant="body"
/>
</Card>
</div>
{/* Project Management */}
<div className="opacity-60">
<Card variant="secondary">
<LineItemLayout icon={TrelloIcon} title="Trello" center />
<Content
icon={TrelloIcon}
title="Trello"
sizePreset="main-ui"
variant="body"
/>
</Card>
</div>
</div>

View File

@@ -4,8 +4,7 @@ import { useState } from "react";
import Card from "@/refresh-components/cards/Card";
import Popover from "@/refresh-components/Popover";
import LineItem from "@/refresh-components/buttons/LineItem";
import Text from "@/refresh-components/texts/Text";
import { Section, LineItemLayout } from "@/layouts/general-layouts";
import { ContentAction } from "@opal/layouts";
import { ValidSources } from "@/lib/types";
import { getSourceMetadata } from "@/lib/sources";
import { SvgMoreHorizontal, SvgPlug, SvgSettings, SvgTrash } from "@opal/icons";
@@ -41,15 +40,6 @@ interface ConnectorCardProps {
onDelete: () => void;
}
const STATUS_COLORS: Record<ConnectorStatus, string> = {
connected: "bg-status-success-05",
connected_with_errors: "bg-status-warning-05",
indexing: "bg-status-warning-05 animate-pulse",
error: "bg-status-error-05",
deleting: "bg-status-error-05 animate-pulse",
not_connected: "bg-background-neutral-03",
};
function getStatusText(status: ConnectorStatus, docsIndexed: number): string {
switch (status) {
case "connected":
@@ -72,29 +62,6 @@ function getStatusText(status: ConnectorStatus, docsIndexed: number): string {
}
}
function StatusDescription({
status,
docsIndexed,
}: {
status: ConnectorStatus;
docsIndexed: number;
}) {
return (
<Section
flexDirection="row"
alignItems="center"
gap={0.375}
width="fit"
height="fit"
>
<div className={cn(STATUS_COLORS[status], "w-2 h-2 rounded-full")} />
<Text secondaryBody text03>
{getStatusText(status, docsIndexed)}
</Text>
</Section>
);
}
export default function ConnectorCard({
connectorType,
config,
@@ -180,17 +147,8 @@ export default function ConnectorCard({
const cardVariant =
isAlwaysConnected || isConnected ? "primary" : "secondary";
// Use custom description if provided, otherwise show status
const descriptionContent = customDescription ? (
<Text secondaryBody text03>
{customDescription}
</Text>
) : (
<StatusDescription
status={status}
docsIndexed={config?.docs_indexed || 0}
/>
);
const descriptionText =
customDescription ?? getStatusText(status, config?.docs_indexed || 0);
return (
<div
@@ -198,12 +156,13 @@ export default function ConnectorCard({
onClick={handleCardClick}
>
<Card variant={cardVariant}>
<LineItemLayout
<ContentAction
icon={sourceMetadata.icon}
title={sourceMetadata.displayName}
description={descriptionContent}
description={descriptionText}
sizePreset="main-content"
variant="section"
rightChildren={rightContent}
center
/>
</Card>
</div>

View File

@@ -46,7 +46,7 @@ import Switch from "@/refresh-components/inputs/Switch";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import NotAllowedModal from "@/app/craft/onboarding/components/NotAllowedModal";
import { useOnboarding } from "@/app/craft/onboarding/BuildOnboardingProvider";
import { useLLMProviders } from "@/lib/hooks/useLLMProviders";
import { useLLMProviders } from "@/hooks/useLLMProviders";
import { useUser } from "@/providers/UserProvider";
import { getProviderIcon } from "@/app/admin/configuration/llm/utils";
import {

View File

@@ -150,7 +150,8 @@ export default async function RootLayout({
// middleware returns 402 for all non-allowlisted API calls, preventing data
// leakage. The user sees a brief loading state before being redirected.
const content =
productGating === ApplicationStatus.GATED_ACCESS ? (
productGating === ApplicationStatus.GATED_ACCESS ||
productGating === ApplicationStatus.SEAT_LIMIT_EXCEEDED ? (
<GatedContentWrapper>{children}</GatedContentWrapper>
) : (
children

View File

@@ -1,6 +1,6 @@
import { ConnectorStatus } from "@/lib/types";
import { ConnectorTitle } from "@/components/admin/connectors/ConnectorTitle";
import * as InputLayouts from "@/layouts/input-layouts";
import { Content } from "@opal/layouts";
import Text from "@/refresh-components/texts/Text";
import { SvgLock } from "@opal/icons";
interface NonSelectableConnectorsProps {
@@ -20,7 +20,12 @@ export const NonSelectableConnectors = ({
return (
<div className="mt-6 mb-4">
<InputLayouts.Title title={title} description={description} />
<Content
title={title}
description={description}
sizePreset="main-content"
variant="section"
/>
<div className="p-3 border border-dashed border-border-02 rounded-12 bg-background-neutral-01">
<div className="mb-2 flex items-center gap-1.5">

View File

@@ -27,6 +27,7 @@ const SETTINGS_LAYOUT_PREFIXES = [
"/admin/document-index-migration",
"/admin/discord-bot",
"/admin/theme",
"/admin/configuration/llm",
];
export function ClientLayout({

View File

@@ -9,7 +9,7 @@ import { getSourceMetadata } from "@/lib/sources";
import useFederatedOAuthStatus from "@/hooks/useFederatedOAuthStatus";
import { SvgLink } from "@opal/icons";
import { Card } from "@/refresh-components/cards";
import { LineItemLayout } from "@/layouts/general-layouts";
import { ContentAction } from "@opal/layouts";
export interface FederatedConnectorOAuthStatus {
federated_connector_id: number;
@@ -136,10 +136,12 @@ export default function FederatedOAuthModal() {
return (
<Card key={connector.federated_connector_id}>
<LineItemLayout
<ContentAction
icon={sourceMetadata.icon}
title={sourceMetadata.displayName}
description={sourceMetadata.category}
sizePreset="main-content"
variant="section"
rightChildren={
<Button
secondary
@@ -149,7 +151,6 @@ export default function FederatedOAuthModal() {
Connect
</Button>
}
center
/>
</Card>
);

View File

@@ -2,7 +2,7 @@
import {
WellKnownLLMProviderDescriptor,
LLMProviderDescriptor,
} from "@/app/admin/configuration/llm/interfaces";
} from "@/interfaces/llm";
import React, {
createContext,
useContext,
@@ -11,9 +11,9 @@ import React, {
useCallback,
} from "react";
import { useUser } from "@/providers/UserProvider";
import { useLLMProviders } from "@/lib/hooks/useLLMProviders";
import { useLLMProviders } from "@/hooks/useLLMProviders";
import { useLLMProviderOptions } from "@/lib/hooks/useLLMProviderOptions";
import { testDefaultProvider as testDefaultProviderSvc } from "@/lib/llm/svc";
import { testDefaultProvider as testDefaultProviderSvc } from "@/lib/llmConfig/svc";
interface ProviderContextType {
shouldShowConfigurationNeeded: boolean;

View File

@@ -9,10 +9,12 @@ 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 { useSettingsContext } from "@/providers/SettingsProvider";
import { ApplicationStatus } from "@/interfaces/settings";
import Text from "@/refresh-components/texts/Text";
import { SvgLock } from "@opal/icons";
const linkClassName = "text-action-link-05 hover:text-action-link-06";
const linkClassName = "text-action-link-05 hover:text-action-link-06 underline";
const fetchStripePublishableKey = async (): Promise<string> => {
const response = await fetch("/api/tenants/stripe-publishable-key");
@@ -40,15 +42,30 @@ export default function AccessRestricted() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: license } = useLicense();
const settings = useSettingsContext();
const isSeatLimitExceeded =
settings.settings.application_status ===
ApplicationStatus.SEAT_LIMIT_EXCEEDED;
const hadPreviousLicense = license?.has_license === true;
const showRenewalMessage = NEXT_PUBLIC_CLOUD_ENABLED || hadPreviousLicense;
const initialModalMessage = showRenewalMessage
? NEXT_PUBLIC_CLOUD_ENABLED
? "Your access to Onyx has been temporarily suspended due to a lapse in your subscription."
: "Your access to Onyx has been temporarily suspended due to a lapse in your license."
: "An Enterprise license is required to use Onyx. Your data is protected and will be available once a license is activated.";
function getSeatLimitMessage() {
const { used_seats, seat_count } = settings.settings;
const counts =
used_seats != null && seat_count != null
? ` (${used_seats} users / ${seat_count} seats)`
: "";
return `Your organization has exceeded its licensed seat count${counts}. Access is restricted until the number of users is reduced or your license is upgraded.`;
}
const initialModalMessage = isSeatLimitExceeded
? getSeatLimitMessage()
: showRenewalMessage
? NEXT_PUBLIC_CLOUD_ENABLED
? "Your access to Onyx has been temporarily suspended due to a lapse in your subscription."
: "Your access to Onyx has been temporarily suspended due to a lapse in your license."
: "An Enterprise license is required to use Onyx. Your data is protected and will be available once a license is activated.";
const handleResubscribe = async () => {
setIsLoading(true);
@@ -80,7 +97,32 @@ export default function AccessRestricted() {
<Text text03>{initialModalMessage}</Text>
{NEXT_PUBLIC_CLOUD_ENABLED ? (
{isSeatLimitExceeded ? (
<>
<Text text03>
If you are an administrator, you can manage users on the{" "}
<Link className={linkClassName} href="/admin/users">
User Management
</Link>{" "}
page or upgrade your license on the{" "}
<Link className={linkClassName} href="/admin/billing">
Admin Billing
</Link>{" "}
page.
</Text>
<div className="flex flex-row gap-2">
<Button
onClick={async () => {
await logout();
window.location.reload();
}}
>
Log out
</Button>
</div>
</>
) : NEXT_PUBLIC_CLOUD_ENABLED ? (
<>
<Text text03>
To reinstate your access and continue benefiting from Onyx&apos;s
@@ -127,7 +169,7 @@ export default function AccessRestricted() {
sign up through Stripe or reach out to{" "}
<a className={linkClassName} href="mailto:support@onyx.app">
support@onyx.app
</a>
</a>{" "}
for billing assistance.
</Text>

View File

@@ -1,8 +1,8 @@
"use client";
import { useMemo } from "react";
import { parseLlmDescriptor, structureValue } from "@/lib/llm/utils";
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
import { parseLlmDescriptor, structureValue } from "@/lib/llmConfig/utils";
import { DefaultModel, LLMProviderDescriptor } from "@/interfaces/llm";
import { getProviderIcon } from "@/app/admin/configuration/llm/utils";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import { createIcon } from "@/components/icons/icons";
@@ -23,6 +23,7 @@ export interface LLMSelectorProps {
name?: string;
userSettings?: boolean;
llmProviders: LLMProviderDescriptor[];
defaultText?: DefaultModel | null;
currentLlm: string | null;
onSelect: (value: string | null) => void;
requiresImageGeneration?: boolean;
@@ -33,6 +34,7 @@ export default function LLMSelector({
name,
userSettings,
llmProviders,
defaultText,
currentLlm,
onSelect,
requiresImageGeneration,
@@ -139,11 +141,11 @@ export default function LLMSelector({
});
}, [llmOptions]);
const defaultProvider = llmProviders.find(
(llmProvider) => llmProvider.is_default_provider
);
const defaultProvider = defaultText
? llmProviders.find((p) => p.id === defaultText.provider_id)
: undefined;
const defaultModelName = defaultProvider?.default_model_name;
const defaultModelName = defaultText?.model_name;
const defaultModelConfig = defaultProvider?.model_configurations.find(
(m) => m.name === defaultModelName
);

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import Button from "@/refresh-components/buttons/Button";
import { useProjectsContext } from "@/providers/ProjectsContext";
import { useKeyPress } from "@/hooks/useKeyPress";
import * as InputLayouts from "@/layouts/input-layouts";
import { useAppRouter } from "@/hooks/appNavigation";
import { useModal } from "@/refresh-components/contexts/ModalContext";
@@ -41,6 +42,8 @@ export default function CreateProjectModal({
}
}
useKeyPress(handleSubmit, "Enter");
return (
<>
<Modal open={modal.isOpen} onOpenChange={modal.toggle}>

View File

@@ -42,7 +42,7 @@ import {
getFinalLLM,
modelSupportsImageInput,
structureValue,
} from "@/lib/llm/utils";
} from "@/lib/llmConfig/utils";
import {
CurrentMessageFIFO,
updateCurrentMessageFIFO,

View File

@@ -0,0 +1,150 @@
"use client";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import {
LLMProviderDescriptor,
LLMProviderResponse,
LLMProviderView,
WellKnownLLMProviderDescriptor,
} from "@/interfaces/llm";
import { LLM_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
/**
* Fetches configured LLM providers accessible to the current user.
*
* Hits the **non-admin** endpoints which return `LLMProviderDescriptor`
* (no `id` or sensitive fields like `api_key`). Use this hook in
* user-facing UI (chat, popovers, onboarding) where you need the list
* of providers and their visible models but don't need admin-level details.
*
* The backend wraps the provider list in an `LLMProviderResponse` envelope
* that also carries the global default text and vision models. This hook
* unwraps `.providers` for convenience while still exposing the defaults.
*
* **Endpoints:**
* - No `personaId` → `GET /api/llm/provider`
* Returns all public providers plus restricted providers the user can
* access via group membership.
* - With `personaId` → `GET /api/llm/persona/{personaId}/providers`
* Returns providers scoped to a specific persona, respecting RBAC
* restrictions. Use this when displaying model options for a particular
* assistant.
*
* @param personaId - Optional persona ID for RBAC-scoped providers.
*
* @returns
* - `llmProviders` — The array of provider descriptors, or `undefined`
* while loading.
* - `defaultText` — The global (or persona-overridden) default text model.
* - `defaultVision` — The global (or persona-overridden) default vision model.
* - `isLoading` — `true` until the first successful response or error.
* - `error` — The SWR error object, if any.
* - `refetch` — SWR `mutate` function to trigger a revalidation.
*/
export function useLLMProviders(personaId?: number) {
const url =
personaId !== undefined
? `/api/llm/persona/${personaId}/providers`
: "/api/llm/provider";
const { data, error, mutate } = useSWR<
LLMProviderResponse<LLMProviderDescriptor>
>(url, errorHandlingFetcher, {
revalidateOnFocus: false,
dedupingInterval: 60000,
});
return {
llmProviders: data?.providers,
defaultText: data?.default_text ?? null,
defaultVision: data?.default_vision ?? null,
isLoading: !error && !data,
error,
refetch: mutate,
};
}
/**
* Fetches configured LLM providers via the **admin** endpoint.
*
* Hits `GET /api/admin/llm/provider` which returns `LLMProviderView` —
* the full provider object including `id`, `api_key` (masked),
* group/persona assignments, and all other admin-visible fields.
*
* Use this hook on admin pages (e.g. the LLM Configuration page) where
* you need provider IDs for mutations (setting defaults, editing, deleting)
* or need to display admin-only metadata. **Do not use in user-facing UI**
* — use `useLLMProviders` instead.
*
* @returns
* - `llmProviders` — The array of full provider views, or `undefined`
* while loading.
* - `defaultText` — The global default text model.
* - `defaultVision` — The global default vision model.
* - `isLoading` — `true` until the first successful response or error.
* - `error` — The SWR error object, if any.
* - `refetch` — SWR `mutate` function to trigger a revalidation.
*/
export function useAdminLLMProviders() {
const { data, error, mutate } = useSWR<LLMProviderResponse<LLMProviderView>>(
LLM_PROVIDERS_ADMIN_URL,
errorHandlingFetcher,
{
revalidateOnFocus: false,
dedupingInterval: 60000,
}
);
return {
llmProviders: data?.providers,
defaultText: data?.default_text ?? null,
defaultVision: data?.default_vision ?? null,
isLoading: !error && !data,
error,
refetch: mutate,
};
}
/**
* Fetches the catalog of well-known (built-in) LLM providers.
*
* Hits `GET /api/admin/llm/built-in/options` which returns the static
* list of provider descriptors that Onyx ships with out of the box
* (OpenAI, Anthropic, Vertex AI, Bedrock, Azure, Ollama, OpenRouter,
* etc.). Each descriptor includes the provider's known models and the
* recommended default model.
*
* Used primarily on the LLM Configuration page and onboarding flows
* to show which providers are available to set up, and to pre-populate
* model lists before the user has entered credentials.
*
* @returns
* - `wellKnownLLMProviders` — The array of built-in provider descriptors,
* or `null` while loading.
* - `isLoading` — `true` until the first successful response or error.
* - `error` — The SWR error object, if any.
* - `mutate` — SWR `mutate` function to trigger a revalidation.
*/
export function useWellKnownLLMProviders() {
const {
data: wellKnownLLMProviders,
error,
isLoading,
mutate,
} = useSWR<WellKnownLLMProviderDescriptor[]>(
"/api/admin/llm/built-in/options",
errorHandlingFetcher,
{
revalidateOnFocus: false,
dedupingInterval: 60000,
}
);
return {
wellKnownLLMProviders: wellKnownLLMProviders ?? null,
isLoading,
error,
mutate,
};
}

View File

@@ -13,8 +13,8 @@ export interface ModelConfiguration {
name: string;
is_visible: boolean;
max_input_tokens: number | null;
supports_image_input: boolean | null;
supports_reasoning?: boolean;
supports_image_input: boolean;
supports_reasoning: boolean;
display_name?: string;
provider_display_name?: string;
vendor?: string;
@@ -30,7 +30,6 @@ export interface SimpleKnownModel {
export interface WellKnownLLMProviderDescriptor {
name: string;
known_models: ModelConfiguration[];
recommended_default_model: SimpleKnownModel | null;
}
@@ -40,44 +39,31 @@ export interface LLMModelDescriptor {
maxTokens: number;
}
export interface LLMProvider {
export interface LLMProviderView {
id: number;
name: string;
provider: string;
api_key: string | null;
api_base: string | null;
api_version: string | null;
custom_config: { [key: string]: string } | null;
default_model_name: string;
is_public: boolean;
is_auto_mode: boolean;
groups: number[];
personas: number[];
deployment_name: string | null;
default_vision_model: string | null;
is_default_vision_provider: boolean | null;
model_configurations: ModelConfiguration[];
}
export interface LLMProviderView extends LLMProvider {
id: number;
is_default_provider: boolean | null;
}
export interface VisionProvider extends LLMProviderView {
vision_models: string[];
}
export interface LLMProviderDescriptor {
id: number;
name: string;
provider: string;
provider_display_name?: string;
default_model_name: string;
is_default_provider: boolean | null;
is_default_vision_provider?: boolean | null;
default_vision_model?: string | null;
is_public?: boolean;
groups?: number[];
personas?: number[];
provider_display_name: string;
model_configurations: ModelConfiguration[];
}
@@ -102,9 +88,22 @@ export interface BedrockModelResponse {
supports_image_input: boolean;
}
export interface DefaultModel {
provider_id: number;
model_name: string;
}
export interface LLMProviderResponse<T> {
providers: T[];
default_text: DefaultModel | null;
default_vision: DefaultModel | null;
}
export interface LLMProviderFormProps {
existingLlmProvider?: LLMProviderView;
shouldMarkAsDefault?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
// Param types for model fetching functions - use snake_case to match API structure

View File

@@ -2,6 +2,7 @@ export enum ApplicationStatus {
PAYMENT_REMINDER = "payment_reminder",
GATED_ACCESS = "gated_access",
ACTIVE = "active",
SEAT_LIMIT_EXCEEDED = "seat_limit_exceeded",
}
export enum QueryHistoryType {
@@ -49,6 +50,10 @@ export interface Settings {
// True when user has a valid license, False for community edition
ee_features_enabled?: boolean;
// Seat usage - populated when seat limit is exceeded
seat_count?: number | null;
used_seats?: number | null;
// OpenSearch migration
opensearch_indexing_enabled?: boolean;

View File

@@ -56,7 +56,7 @@
import React, { HtmlHTMLAttributes } from "react";
import type { IconProps } from "@opal/types";
import { WithoutStyles } from "@/types";
import { LineItemLayout } from "@/layouts/general-layouts";
import { ContentAction } from "@opal/layouts";
import * as ExpandableCard from "@/layouts/expandable-card-layouts";
import { Card } from "@/refresh-components/cards";
import Label from "@/refresh-components/form/Label";
@@ -119,11 +119,14 @@ function ActionsHeader({
<div className="flex flex-col gap-2 pt-4 pb-2">
<div className="px-4">
<Label name={name}>
<LineItemLayout
<ContentAction
icon={Icon}
title={title}
description={description}
sizePreset="section"
variant="section"
rightChildren={rightChildren}
paddingVariant="fit"
/>
</Label>
</div>
@@ -237,12 +240,14 @@ function ActionsTool({
return (
<Card padding={0.75} variant={disabled ? "disabled" : undefined}>
<Label name={name} disabled={disabled}>
<LineItemLayout
<ContentAction
icon={icon}
title={title}
description={description}
sizePreset="main-ui"
variant="section"
rightChildren={rightChildren}
variant="secondary"
paddingVariant="fit"
/>
</Label>
</Card>

View File

@@ -48,7 +48,6 @@ import { PopoverSearchInput } from "@/sections/sidebar/ChatButton";
import SimplePopover from "@/refresh-components/SimplePopover";
import { Interactive } from "@opal/core";
import { Button, OpenButton } from "@opal/components";
import { LineItemLayout } from "@/layouts/general-layouts";
import { useAppSidebarContext } from "@/providers/AppSidebarProvider";
import useScreenSize from "@/hooks/useScreenSize";
import {

View File

@@ -2,6 +2,7 @@ import { cn } from "@/lib/utils";
import Text from "@/refresh-components/texts/Text";
import Truncated from "@/refresh-components/texts/Truncated";
import { WithoutStyles } from "@/types";
import { Content } from "@opal/layouts";
import { IconProps } from "@opal/types";
import React from "react";
@@ -160,152 +161,6 @@ function Section({
);
}
/**
* LineItemLayout - A layout for icon + title + description rows
*
* Structure:
* Flexbox Row [
* Grid [
* [Icon] [Title ]
* [ ] [Description]
* ],
* rightChildren
* ]
*
* - Icon column auto-sizes to icon width
* - Icon vertically centers with title
* - Description aligns with title's left edge (both in grid column 2)
* - rightChildren is outside the grid, in the outer flexbox
*
* Variants:
* - `primary`: Standard size (20px icon) with emphasized text. The default for prominent list items.
* - `secondary`: Compact size (16px icon) with standard text. Use for denser lists or nested items.
* - `tertiary`: Compact size (16px icon) with standard text. Use for less prominent items in tight layouts.
* - `tertiary-muted`: Compact size (16px icon) with muted text styling. Use for de-emphasized or secondary information.
* - `mini`: Smallest size (12px icon) with muted secondary text. Use for metadata labels (e.g., owner, action count).
*
* @param icon - Optional icon component to display on the left
* @param title - The main title text (required)
* @param description - Optional description content below the title (string or ReactNode)
* @param rightChildren - Optional content to render on the right side
* @param variant - Visual variant. Default: "primary"
* @param strikethrough - If true, applies line-through style to title. Default: false
* @param loading - If true, renders skeleton placeholders instead of content. Default: false
* @param center - If true, vertically centers items; otherwise aligns to start. Default: false
*/
type LineItemLayoutVariant =
| "primary"
| "secondary"
| "tertiary"
| "tertiary-muted"
| "mini";
export interface LineItemLayoutProps {
icon?: React.FunctionComponent<IconProps>;
title: string;
description?: React.ReactNode;
middleText?: string;
rightChildren?: React.ReactNode;
variant?: LineItemLayoutVariant;
width?: Length;
strikethrough?: boolean;
loading?: boolean;
center?: boolean;
reducedPadding?: boolean;
}
function LineItemLayout({
icon: Icon,
title,
description,
middleText,
rightChildren,
variant = "primary",
width,
strikethrough,
loading,
center,
reducedPadding,
}: LineItemLayoutProps) {
// Derive styling from variant
const isMini = variant === "mini";
const isCompact =
variant === "secondary" ||
variant === "tertiary" ||
variant === "tertiary-muted";
const isMuted = variant === "tertiary-muted" || isMini;
// Determine icon size: mini=12px, compact=16px, primary=20px
const iconSize = isMini ? 12 : isCompact ? 16 : 20;
// Determine gap: mini=0.25rem, others=1.5rem
const gap = isMini ? 0.25 : 1.5;
return (
<Section
flexDirection="row"
justifyContent="between"
alignItems={center || isMini ? "center" : "start"}
gap={gap}
width={width}
>
<div
className="line-item-layout"
data-variant={variant}
data-has-icon={Icon ? "true" : undefined}
data-loading={loading ? "true" : undefined}
data-strikethrough={strikethrough ? "true" : undefined}
data-reduced-padding={reducedPadding ? "true" : undefined}
>
{/* Row 1: Icon, Title */}
{Icon && <Icon size={iconSize} className="line-item-layout-icon" />}
{loading ? (
<div className="line-item-layout-skeleton-title" />
) : (
<Truncated
mainContentEmphasis={!isCompact && !isMini}
secondaryBody={isMini}
mainUiAction={variant === "secondary"}
text03={isMuted}
className="line-item-layout-title"
>
{title}
</Truncated>
)}
{/* Row 2: Description (column 2, or column 1 if no icon) */}
{loading && description ? (
<div className="line-item-layout-skeleton-description" />
) : description ? (
<div className="line-item-layout-description">
{typeof description === "string" ? (
<Text secondaryBody text03>
{description}
</Text>
) : (
description
)}
</div>
) : undefined}
</div>
{!loading && middleText && (
<div className="flex-1">
<Truncated text03 secondaryBody>
{middleText}
</Truncated>
</div>
)}
{loading && rightChildren ? (
<div className="line-item-layout-skeleton-right" />
) : rightChildren ? (
<div className="flex-shrink-0">{rightChildren}</div>
) : undefined}
</Section>
);
}
export interface AttachmentItemLayoutProps {
title: string;
description: string;
@@ -329,18 +184,29 @@ function AttachmentItemLayout({
</div>
</Section>
</div>
<LineItemLayout
title={title}
description={description}
middleText={middleText}
rightChildren={
rightChildren ? (
<div className="px-1">{rightChildren}</div>
) : undefined
}
center
variant="secondary"
/>
<Section
flexDirection="row"
justifyContent="between"
alignItems="center"
gap={1.5}
>
<Content
title={title}
description={description}
sizePreset="main-ui"
variant="section"
/>
{middleText && (
<div className="flex-1">
<Truncated text03 secondaryBody>
{middleText}
</Truncated>
</div>
)}
{rightChildren && (
<div className="flex-shrink-0 px-1">{rightChildren}</div>
)}
</Section>
</Section>
);
}
@@ -413,4 +279,4 @@ function CardItemLayout({
</div>
);
}
export { Section, LineItemLayout, CardItemLayout, AttachmentItemLayout };
export { Section, CardItemLayout, AttachmentItemLayout };

View File

@@ -4,13 +4,18 @@ import Text from "@/refresh-components/texts/Text";
import { SvgXOctagon, SvgAlertCircle } from "@opal/icons";
import { useField, useFormikContext } from "formik";
import { Section } from "@/layouts/general-layouts";
import { Content } from "@opal/layouts";
import Label from "@/refresh-components/form/Label";
interface OrientationLayoutProps extends TitleLayoutProps {
interface OrientationLayoutProps {
name?: string;
disabled?: boolean;
nonInteractive?: boolean;
children?: React.ReactNode;
title: string;
description?: string;
optional?: boolean;
sizePreset?: "main-content" | "main-ui";
}
/**
@@ -44,11 +49,20 @@ function VerticalInputLayout({
nonInteractive,
children,
subDescription,
...titleLayoutProps
title,
description,
optional,
sizePreset = "main-content",
}: VerticalLayoutProps) {
const content = (
<Section gap={0.25} alignItems="start">
<TitleLayout {...titleLayoutProps} />
<Content
title={title}
description={description}
optional={optional}
sizePreset={sizePreset}
variant="section"
/>
{children}
{name && <ErrorLayout name={name} />}
{subDescription && (
@@ -110,7 +124,10 @@ function HorizontalInputLayout({
nonInteractive,
children,
center,
...titleLayoutProps
title,
description,
optional,
sizePreset = "main-content",
}: HorizontalLayoutProps) {
const content = (
<Section gap={0.25} alignItems="start">
@@ -120,7 +137,13 @@ function HorizontalInputLayout({
alignItems={center ? "center" : "start"}
>
<div className="flex flex-col flex-1 self-stretch">
<TitleLayout {...titleLayoutProps} />
<Content
title={title}
description={description}
optional={optional}
sizePreset={sizePreset}
variant="section"
/>
</div>
<div className="flex flex-col items-end">{children}</div>
</Section>
@@ -136,80 +159,6 @@ function HorizontalInputLayout({
);
}
/**
* TitleLayout - A reusable title/description component for form fields
*
* Renders a title with an optional description and "Optional" indicator.
* This is a pure presentational component — it does not render a `<label>`
* element. Label semantics are handled by the parent orientation layout
* (Vertical/Horizontal) or by the caller.
*
* Exported as `Title` for convenient usage.
*
* @param title - The main label text
* @param description - Additional helper text shown below the title
* @param optional - Whether to show "(Optional)" indicator
* @param center - If true, centers the title and description text. Default: false
*
* @example
* ```tsx
* import { Title } from "@/layouts/input-layouts";
*
* <Title
* name="username"
* title="Username"
* description="Choose a unique username"
* optional
* />
* ```
*/
type TitleLayoutVariants = "primary" | "secondary";
export interface TitleLayoutProps {
title: string;
description?: string;
optional?: boolean;
center?: boolean;
variant?: TitleLayoutVariants;
}
function TitleLayout({
title,
description,
optional,
center,
variant = "primary",
}: TitleLayoutProps) {
return (
<Section gap={0} height="fit">
<Section
flexDirection="row"
justifyContent={center ? "center" : "start"}
gap={0.25}
>
<Text
mainContentEmphasis={variant === "primary"}
mainUiAction={variant === "secondary"}
text04
>
{title}
</Text>
{optional && (
<Text text03 mainContentMuted>
(Optional)
</Text>
)}
</Section>
{description && (
<Section alignItems={center ? "center" : "start"}>
<Text secondaryBody text03>
{description}
</Text>
</Section>
)}
</Section>
);
}
/**
* ErrorLayout - Displays Formik field validation errors
*
@@ -280,7 +229,6 @@ function ErrorTextLayout({ children, type = "error" }: ErrorTextLayoutProps) {
export {
VerticalInputLayout as Vertical,
HorizontalInputLayout as Horizontal,
TitleLayout as Title,
ErrorLayout as Error,
ErrorTextLayout,
};

View File

@@ -18,7 +18,8 @@ export type ApplicationStatus =
| "active"
| "payment_reminder"
| "gated_access"
| "expired";
| "expired"
| "seat_limit_exceeded";
/**
* Billing status from Stripe subscription.

View File

@@ -2,8 +2,8 @@ import {
getDefaultLlmDescriptor,
getValidLlmDescriptorForProviders,
} from "@/lib/hooks";
import { structureValue } from "@/lib/llm/utils";
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
import { structureValue } from "@/lib/llmConfig/utils";
import { LLMProviderDescriptor } from "@/interfaces/llm";
import { makeProvider } from "@tests/setup/llmProviderTestUtils";
describe("LLM resolver helpers", () => {
@@ -11,29 +11,30 @@ describe("LLM resolver helpers", () => {
const sharedModel = "shared-runtime-model";
const providers: LLMProviderDescriptor[] = [
makeProvider({
id: 1,
name: "OpenAI Provider",
provider: "openai",
default_model_name: sharedModel,
is_default_provider: true,
model_configurations: [
{
name: sharedModel,
is_visible: true,
max_input_tokens: null,
supports_image_input: false,
supports_reasoning: false,
},
],
}),
makeProvider({
id: 2,
name: "Anthropic Provider",
provider: "anthropic",
default_model_name: sharedModel,
model_configurations: [
{
name: sharedModel,
is_visible: true,
max_input_tokens: null,
supports_image_input: false,
supports_reasoning: false,
},
],
}),
@@ -54,29 +55,30 @@ describe("LLM resolver helpers", () => {
test("falls back to default provider when model is unavailable", () => {
const providers: LLMProviderDescriptor[] = [
makeProvider({
id: 10,
name: "Default OpenAI",
provider: "openai",
default_model_name: "gpt-4o-mini",
is_default_provider: true,
model_configurations: [
{
name: "gpt-4o-mini",
is_visible: true,
max_input_tokens: null,
supports_image_input: true,
supports_reasoning: false,
},
],
}),
makeProvider({
id: 20,
name: "Anthropic Backup",
provider: "anthropic",
default_model_name: "claude-3-5-sonnet",
model_configurations: [
{
name: "claude-3-5-sonnet",
is_visible: true,
max_input_tokens: null,
supports_image_input: true,
supports_reasoning: false,
},
],
}),
@@ -97,30 +99,30 @@ describe("LLM resolver helpers", () => {
test("uses first provider with models when no explicit default exists", () => {
const providers: LLMProviderDescriptor[] = [
makeProvider({
id: 30,
name: "First Provider",
provider: "openai",
default_model_name: "gpt-first",
is_default_provider: false,
model_configurations: [
{
name: "gpt-first",
is_visible: true,
max_input_tokens: null,
supports_image_input: false,
supports_reasoning: false,
},
],
}),
makeProvider({
id: 40,
name: "Second Provider",
provider: "anthropic",
default_model_name: "claude-second",
is_default_provider: false,
model_configurations: [
{
name: "claude-second",
is_visible: true,
max_input_tokens: null,
supports_image_input: false,
supports_reasoning: false,
},
],
}),

View File

@@ -23,23 +23,22 @@ import {
} from "react";
import { DateRangePickerValue } from "@/components/dateRangeSelectors/AdminDateRangeSelector";
import { SourceMetadata } from "./search/interfaces";
import { parseLlmDescriptor } from "./llm/utils";
import { parseLlmDescriptor } from "./llmConfig/utils";
import { ChatSession } from "@/app/app/interfaces";
import { AllUsersResponse } from "./types";
import { Credential } from "./connectors/credentials";
import { SettingsContext } from "@/providers/SettingsProvider";
import {
MinimalPersonaSnapshot,
PersonaLabel,
} from "@/app/admin/assistants/interfaces";
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
import { DefaultModel, LLMProviderDescriptor } from "@/interfaces/llm";
import { isAnthropic } from "@/app/admin/configuration/llm/utils";
import { getSourceMetadataForSources } from "./sources";
import { AuthType, NEXT_PUBLIC_CLOUD_ENABLED } from "./constants";
import { useUser } from "@/providers/UserProvider";
import { SEARCH_TOOL_ID } from "@/app/app/components/tools/constants";
import { updateTemperatureOverrideForChatSession } from "@/app/app/services/lib";
import { useLLMProviders } from "./hooks/useLLMProviders";
import { useLLMProviders } from "@/hooks/useLLMProviders";
const CREDENTIAL_URL = "/api/manage/admin/credential";
@@ -533,26 +532,31 @@ providing appropriate defaults for new conversations based on the available tool
*/
export function getDefaultLlmDescriptor(
llmProviders: LLMProviderDescriptor[]
llmProviders: LLMProviderDescriptor[],
defaultText?: DefaultModel | null
): LlmDescriptor | null {
const defaultProvider = llmProviders.find(
(provider) => provider.is_default_provider
);
if (defaultProvider) {
return {
name: defaultProvider.name,
provider: defaultProvider.provider,
modelName: defaultProvider.default_model_name,
};
if (defaultText) {
const provider = llmProviders.find((p) => p.id === defaultText.provider_id);
if (provider) {
return {
name: provider.name,
provider: provider.provider,
modelName: defaultText.model_name,
};
}
}
// Fallback: first provider with visible models
const firstLlmProvider = llmProviders.find(
(provider) => provider.model_configurations.length > 0
);
if (firstLlmProvider) {
const firstModel = firstLlmProvider.model_configurations.find(
(m) => m.is_visible
);
return {
name: firstLlmProvider.name,
provider: firstLlmProvider.provider,
modelName: firstLlmProvider.default_model_name,
modelName: firstModel?.name ?? "",
};
}
return null;
@@ -637,19 +641,25 @@ export function useLlmManager(
// Get all user-accessible providers via SWR (general providers - no persona filter)
// This includes public + all restricted providers user can access via groups
const { llmProviders: allUserProviders, isLoading: isLoadingAllProviders } =
useLLMProviders();
const {
llmProviders: allUserProviders,
defaultText: allUserDefaultText,
isLoading: isLoadingAllProviders,
} = useLLMProviders();
// Fetch persona-specific providers to enforce RBAC restrictions per assistant
// Only fetch if we have an assistant selected
const personaId =
liveAssistant?.id !== undefined ? liveAssistant.id : undefined;
const {
llmProviders: personaProviders,
defaultText: personaDefaultText,
isLoading: isLoadingPersonaProviders,
} = useLLMProviders(personaId);
const llmProviders =
personaProviders !== undefined ? personaProviders : allUserProviders;
const defaultText =
personaProviders !== undefined ? personaDefaultText : allUserDefaultText;
const [userHasManuallyOverriddenLLM, setUserHasManuallyOverriddenLLM] =
useState(false);
@@ -708,7 +718,7 @@ export function useLlmManager(
} else if (user?.preferences?.default_model) {
setCurrentLlm(getValidLlmDescriptor(user.preferences.default_model));
} else {
const defaultLlm = getDefaultLlmDescriptor(llmProviders);
const defaultLlm = getDefaultLlmDescriptor(llmProviders, defaultText);
if (defaultLlm) {
setCurrentLlm(defaultLlm);
}

View File

@@ -1,5 +1,5 @@
import useSWR from "swr";
import { WellKnownLLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
import { WellKnownLLMProviderDescriptor } from "@/interfaces/llm";
import { errorHandlingFetcher } from "@/lib/fetcher";
export function useLLMProviderOptions() {

View File

@@ -1,5 +1,5 @@
import useSWR from "swr";
import { useLLMProviders } from "./useLLMProviders";
import { useLLMProviders } from "@/hooks/useLLMProviders";
import { errorHandlingFetcher } from "@/lib/fetcher";
jest.mock("swr", () => ({
@@ -45,7 +45,7 @@ describe("useLLMProviders", () => {
const mockMutate = jest.fn();
const providers = [{ name: "Persona Provider" }];
mockUseSWR.mockReturnValue({
data: providers,
data: { providers, default_text: null, default_vision: null },
error: undefined,
mutate: mockMutate,
isValidating: false,

View File

@@ -1,30 +0,0 @@
import useSWR from "swr";
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
import { errorHandlingFetcher } from "@/lib/fetcher";
export function useLLMProviders(personaId?: number) {
// personaId can be:
// - undefined: public providers only (/api/llm/provider)
// - number (personaId): persona-specific providers with RBAC enforcement
const url =
typeof personaId === "number"
? `/api/llm/persona/${personaId}/providers`
: "/api/llm/provider";
const { data, error, mutate } = useSWR<LLMProviderDescriptor[] | undefined>(
url,
errorHandlingFetcher,
{
revalidateOnFocus: false, // Cache aggressively for performance
dedupingInterval: 60000, // Dedupe requests within 1 minute
}
);
return {
llmProviders: data,
isLoading: !error && !data,
error,
refetch: mutate,
};
}

View File

@@ -1,10 +0,0 @@
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
import { fetchSS } from "../utilsSS";
export async function fetchLLMProvidersSS() {
const response = await fetchSS("/llm/provider");
if (response.ok) {
return (await response.json()) as LLMProviderDescriptor[];
}
return [];
}

View File

@@ -1,23 +0,0 @@
/**
* LLM action functions for mutations.
*
* These are async functions for one-off actions that don't need SWR caching.
*
* Endpoints:
* - /api/admin/llm/test/default - Test the default LLM provider connection
*/
/**
* Test the default LLM provider.
* Returns true if the default provider is configured and working, false otherwise.
*/
export async function testDefaultProvider(): Promise<boolean> {
try {
const response = await fetch("/api/admin/llm/test/default", {
method: "POST",
});
return response?.ok || false;
} catch {
return false;
}
}

View File

@@ -1,4 +1,5 @@
export const LLM_PROVIDERS_ADMIN_URL = "/api/admin/llm/provider";
export const LLM_ADMIN_URL = "/api/admin/llm";
export const LLM_PROVIDERS_ADMIN_URL = `${LLM_ADMIN_URL}/provider`;
export const LLM_CONTEXTUAL_COST_ADMIN_URL =
"/api/admin/llm/provider-contextual-cost";

View File

@@ -0,0 +1,69 @@
import type { IconFunctionComponent } from "@opal/types";
import {
SvgCpu,
SvgOpenai,
SvgClaude,
SvgOllama,
SvgCloud,
SvgAws,
SvgOpenrouter,
SvgServer,
SvgAzure,
SvgGemini,
SvgLitellm,
} from "@opal/icons";
import { LLMProviderName } from "@/interfaces/llm";
const PROVIDER_ICONS: Record<string, IconFunctionComponent> = {
[LLMProviderName.OPENAI]: SvgOpenai,
[LLMProviderName.ANTHROPIC]: SvgClaude,
[LLMProviderName.VERTEX_AI]: SvgGemini,
[LLMProviderName.BEDROCK]: SvgAws,
[LLMProviderName.AZURE]: SvgAzure,
litellm: SvgLitellm,
[LLMProviderName.OLLAMA_CHAT]: SvgOllama,
[LLMProviderName.OPENROUTER]: SvgOpenrouter,
// fallback
[LLMProviderName.CUSTOM]: SvgServer,
};
const PROVIDER_PRODUCT_NAMES: Record<string, string> = {
[LLMProviderName.OPENAI]: "GPT",
[LLMProviderName.ANTHROPIC]: "Claude",
[LLMProviderName.VERTEX_AI]: "Gemini",
[LLMProviderName.BEDROCK]: "Amazon Bedrock",
[LLMProviderName.AZURE]: "Azure OpenAI",
litellm: "LiteLLM",
[LLMProviderName.OLLAMA_CHAT]: "Ollama",
[LLMProviderName.OPENROUTER]: "OpenRouter",
// fallback
[LLMProviderName.CUSTOM]: "Custom Models",
};
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
[LLMProviderName.OPENAI]: "OpenAI",
[LLMProviderName.ANTHROPIC]: "Anthropic",
[LLMProviderName.VERTEX_AI]: "Google Cloud Vertex AI",
[LLMProviderName.BEDROCK]: "AWS",
[LLMProviderName.AZURE]: "Microsoft Azure",
litellm: "LiteLLM",
[LLMProviderName.OLLAMA_CHAT]: "Ollama",
[LLMProviderName.OPENROUTER]: "OpenRouter",
// fallback
[LLMProviderName.CUSTOM]: "Other providers or self-hosted",
};
export function getProviderProductName(providerName: string): string {
return PROVIDER_PRODUCT_NAMES[providerName] ?? providerName;
}
export function getProviderDisplayName(providerName: string): string {
return PROVIDER_DISPLAY_NAMES[providerName] ?? providerName;
}
export function getProviderIcon(providerName: string): IconFunctionComponent {
return PROVIDER_ICONS[providerName] ?? SvgCpu;
}

View File

@@ -0,0 +1,71 @@
/**
* LLM action functions for mutations.
*
* These are async functions for one-off actions that don't need SWR caching.
*
* Endpoints:
* - /api/admin/llm/test/default - Test the default LLM provider connection
* - /api/admin/llm/default - Set the default LLM model
* - /api/admin/llm/provider/{id} - Delete an LLM provider
*/
import {
LLM_ADMIN_URL,
LLM_PROVIDERS_ADMIN_URL,
} from "@/lib/llmConfig/constants";
/**
* Test the default LLM provider.
* Returns true if the default provider is configured and working, false otherwise.
*/
export async function testDefaultProvider(): Promise<boolean> {
try {
const response = await fetch(`${LLM_ADMIN_URL}/test/default`, {
method: "POST",
});
return response?.ok || false;
} catch {
return false;
}
}
/**
* Set the default LLM model.
* @param providerId - The provider ID
* @param modelName - The model name within that provider
* @throws Error with the detail message from the API on failure
*/
export async function setDefaultLlmModel(
providerId: number,
modelName: string
): Promise<void> {
const response = await fetch(`${LLM_ADMIN_URL}/default`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider_id: providerId,
model_name: modelName,
}),
});
if (!response.ok) {
const errorMsg = (await response.json()).detail;
throw new Error(errorMsg);
}
}
/**
* Delete an LLM provider.
* @param providerId - The provider ID to delete
* @throws Error with the detail message from the API on failure
*/
export async function deleteLlmProvider(providerId: number): Promise<void> {
const response = await fetch(`${LLM_PROVIDERS_ADMIN_URL}/${providerId}`, {
method: "DELETE",
});
if (!response.ok) {
const errorMsg = (await response.json()).detail;
throw new Error(errorMsg);
}
}

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