Compare commits

..

17 Commits

Author SHA1 Message Date
Nik
6fc7c0a863 feat(admin): expand user search to name, add SCIM sync status, fix table columns
- Backend `q` param now searches both email and personal_name (OR)
- Add `is_scim_synced` field to FullUserSnapshot via batch ScimUserMapping lookup
- Status column shows "SCIM synced" sublabel for SCIM-managed users
- Fix table columns: Name header, larger group pills, role icons, plain text status
- Fix header spacing (Spacer 2.5rem) to prevent content peeking above sticky header
- Simplify UsersSummary to plain static cells (no hover/filter behavior)
2026-03-08 19:41:39 -07:00
Nik
61bce0239a refactor(admin): extract types to interfaces.ts and data fetching to useAdminUsers hook
Move UserRow, PaginatedUsersResponse, and StatusFilter types into a
shared interfaces module. Extract paginated user fetching into a
dedicated useAdminUsers hook. Use Content variant="section" for the
User column to get title + description layout from the component
library instead of hand-rolling with div + Text.
2026-03-08 18:56:02 -07:00
Nik
04327ae3fc feat(admin): wire up enriched user fields in table columns
Add Name (personal_name with email subtitle), Groups (tag pills with
+N overflow), and Last Updated (timeAgo) columns to the users table.
2026-03-08 18:56:02 -07:00
Nik
f961700470 feat(admin): add Users table with DataTable and server-side pagination
- Create UsersTable component using DataTable with server-side mode
- Columns: avatar qualifier (initials from email), email, role, status tag
- Search input with server-side filtering via q param
- Pagination with "Showing X~Y of Z" summary footer
- Wire into UsersPage below the stats bar
2026-03-08 18:56:02 -07:00
Nik
7a9ed6f987 feat(admin): add user timestamps and enrich FullUserSnapshot
- Alembic migration adding created_at/updated_at to user table
- Add personal_name, created_at, updated_at, and groups to FullUserSnapshot
- Batch query for user group memberships to avoid N+1 in list endpoint
- Replace direct FullUserSnapshot construction with from_user_model()
2026-03-08 18:55:43 -07:00
Nik
a5bb2d8130 fix(admin): move Users v2 to Permissions sidebar section and fix stats bar layout
- Move Users v2 sidebar entry from User Management to Permissions section
  (alongside SCIM) to match Figma mocks
- Stats bar: use single card for all stats (not separate cards per stat)
  when SCIM is disabled
- Hide "requests to join" cell when count is 0 (not just null)
- Add onStatClick callback prop for future filter integration
2026-03-08 18:55:25 -07:00
Nik
dbbca1c4e5 refactor(admin): extract useUserCounts hook and rename StatsBar to UsersSummary
Move SWR data fetching out of UsersPage component into a dedicated
useUserCounts hook. Rename StatsBar → UsersSummary for clarity.
2026-03-08 16:05:33 -07:00
Nik
ff92928b31 fix(admin): refine stats bar layout and move to refresh-pages
- Replace LineItem with custom StatCell using Text to fix label truncation
- Use width="full" on StatCell for even distribution when fewer cells render
- Move UsersPage to refresh-pages pattern, route file re-exports
- Delete dead users2/StatsBar.tsx (replaced by UsersPage/StatsBar.tsx)
- Move sidebar entry from Permissions to User Management section
2026-03-08 14:31:09 -07:00
Nik
f452a777cc feat(admin): add Users v2 page shell with stats bar and SCIM card
New /admin/users2 page with SettingsLayouts, stat cards for active
users / pending invites / requests to join (cloud-only), and a
conditional SCIM sync card. Also adds 40px top padding to the
shared SettingsLayouts.Root for all admin pages.

Sidebar entry is commented out until the page is feature-complete.
2026-03-06 12:34:34 -08:00
Wenxi
99e95f8205 chore: rm dead llm provider code and bump claude recs (#9145) 2026-03-06 20:02:38 +00:00
Justin Tahara
e618bf8385 fix(ui): LLM Model selection Cache 1/2 (#9141) 2026-03-06 19:53:53 +00:00
roshan
f4dcd130ba feat(cli): add PyPI wheel packaging for onyx-cli (#8992)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-03-06 19:42:46 +00:00
Jamison Lahman
910718deaa chore(playwright): hide flaky AppInputBar/llm-popover-trigger (#9144) 2026-03-06 19:40:43 +00:00
dependabot[bot]
1a7ca93b93 chore(deps): bump @tootallnate/once and jest-environment-jsdom in /web (#9050)
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-03-06 19:14:33 +00:00
dependabot[bot]
a615a920cb chore(deps): bump express-rate-limit from 8.2.1 to 8.3.0 in /backend/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs/web (#9143)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-06 11:13:21 -08:00
Jamison Lahman
29d8b310b5 chore(deps): upgrade desktop deps (#9140) 2026-03-06 19:04:23 +00:00
Jamison Lahman
d1409ccafa chore(fe): rm redundant alignBubble (#9072) 2026-03-06 18:53:56 +00:00
56 changed files with 2296 additions and 1725 deletions

40
.github/workflows/release-cli.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Release CLI
on:
push:
tags:
- "cli/v*.*.*"
jobs:
pypi:
runs-on: ubuntu-latest
environment:
name: release-cli
permissions:
id-token: write
timeout-minutes: 10
strategy:
matrix:
os-arch:
- { goos: "linux", goarch: "amd64" }
- { goos: "linux", goarch: "arm64" }
- { goos: "windows", goarch: "amd64" }
- { goos: "windows", goarch: "arm64" }
- { goos: "darwin", goarch: "amd64" }
- { goos: "darwin", goarch: "arm64" }
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
with:
persist-credentials: false
fetch-depth: 0
- uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # ratchet:astral-sh/setup-uv@v7
with:
enable-cache: false
version: "0.9.9"
- run: |
GOOS="${{ matrix.os-arch.goos }}" \
GOARCH="${{ matrix.os-arch.goarch }}" \
uv build --wheel
working-directory: cli
- run: uv publish
working-directory: cli

View File

@@ -0,0 +1,43 @@
"""add timestamps to user table
Revision ID: 27fb147a843f
Revises: a3b8d9e2f1c4
Create Date: 2026-03-08 17:18:40.828644
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "27fb147a843f"
down_revision = "a3b8d9e2f1c4"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"user",
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.add_column(
"user",
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
def downgrade() -> None:
op.drop_column("user", "updated_at")
op.drop_column("user", "created_at")

View File

@@ -270,34 +270,10 @@ def upsert_llm_provider(
mc.name for mc in llm_provider_upsert_request.model_configurations
}
default_model = fetch_default_llm_model(db_session)
# Build a lookup of requested visibility by model name
requested_visibility = {
mc.name: mc.is_visible
for mc in llm_provider_upsert_request.model_configurations
}
# Delete removed models
removed_ids = [
mc.id for name, mc in existing_by_name.items() if name not in models_to_exist
]
# Prevent removing and hiding the default model
if default_model:
for name, mc in existing_by_name.items():
if mc.id == default_model.id:
if name not in models_to_exist:
raise ValueError(
f"Cannot remove the default model '{name}'. "
"Please change the default model before removing."
)
if not requested_visibility.get(name, True):
raise ValueError(
f"Cannot hide the default model '{name}'. "
"Please change the default model before hiding."
)
if removed_ids:
db_session.query(ModelConfiguration).filter(
ModelConfiguration.id.in_(removed_ids)
@@ -562,6 +538,7 @@ def fetch_default_model(
.options(selectinload(ModelConfiguration.llm_provider))
.join(LLMModelFlow)
.where(
ModelConfiguration.is_visible == True, # noqa: E712
LLMModelFlow.llm_model_flow_type == flow_type,
LLMModelFlow.is_default == True, # noqa: E712
)

View File

@@ -280,6 +280,16 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
TIMESTAMPAware(timezone=True), nullable=True
)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
default_model: Mapped[str] = mapped_column(Text, nullable=True)
# organized in typical structured fashion
# formatted as `displayName__provider__modelName`

View File

@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from sqlalchemy.sql import expression
from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.elements import KeyedColumnElement
from sqlalchemy.sql.expression import or_
from onyx.auth.invited_users import remove_user_from_invited_users
from onyx.auth.schemas import UserRole
@@ -24,6 +25,7 @@ from onyx.db.models import Persona__User
from onyx.db.models import SamlAccount
from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.db.models import UserGroup
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
@@ -162,7 +164,13 @@ def _get_accepted_user_where_clause(
where_clause.append(User.role != UserRole.EXT_PERM_USER)
if email_filter_string is not None:
where_clause.append(email_col.ilike(f"%{email_filter_string}%"))
personal_name_col: KeyedColumnElement[Any] = User.__table__.c.personal_name
where_clause.append(
or_(
email_col.ilike(f"%{email_filter_string}%"),
personal_name_col.ilike(f"%{email_filter_string}%"),
)
)
if roles_filter:
where_clause.append(User.role.in_(roles_filter))
@@ -358,3 +366,28 @@ def delete_user_from_db(
# NOTE: edge case may exist with race conditions
# with this `invited user` scheme generally.
remove_user_from_invited_users(user_to_delete.email)
def batch_get_user_groups(
db_session: Session,
user_ids: list[UUID],
) -> dict[UUID, list[tuple[int, str]]]:
"""Fetch group memberships for a batch of users in a single query.
Returns a mapping of user_id -> list of (group_id, group_name) tuples."""
if not user_ids:
return {}
rows = db_session.execute(
select(
User__UserGroup.user_id,
UserGroup.id,
UserGroup.name,
)
.join(UserGroup, UserGroup.id == User__UserGroup.user_group_id)
.where(User__UserGroup.user_id.in_(user_ids))
).all()
result: dict[UUID, list[tuple[int, str]]] = {uid: [] for uid in user_ids}
for user_id, group_id, group_name in rows:
result[user_id].append((group_id, group_name))
return result

View File

@@ -1512,6 +1512,10 @@
"display_name": "Claude Opus 4.5",
"model_vendor": "anthropic"
},
"claude-opus-4-6": {
"display_name": "Claude Opus 4.6",
"model_vendor": "anthropic"
},
"claude-opus-4-5-20251101": {
"display_name": "Claude Opus 4.5",
"model_vendor": "anthropic",
@@ -1526,6 +1530,10 @@
"display_name": "Claude Sonnet 4.5",
"model_vendor": "anthropic"
},
"claude-sonnet-4-6": {
"display_name": "Claude Sonnet 4.6",
"model_vendor": "anthropic"
},
"claude-sonnet-4-5-20250929": {
"display_name": "Claude Sonnet 4.5",
"model_vendor": "anthropic",

View File

@@ -1,37 +1,8 @@
from onyx.llm.constants import LlmProviderNames
OPENAI_PROVIDER_NAME = "openai"
# Curated list of OpenAI models to show by default in the UI
OPENAI_VISIBLE_MODEL_NAMES = {
"gpt-5",
"gpt-5-mini",
"o1",
"o3-mini",
"gpt-4o",
"gpt-4o-mini",
}
BEDROCK_PROVIDER_NAME = "bedrock"
BEDROCK_DEFAULT_MODEL = "anthropic.claude-3-5-sonnet-20241022-v2:0"
def _fallback_bedrock_regions() -> list[str]:
# Fall back to a conservative set of well-known Bedrock regions if boto3 data isn't available.
return [
"us-east-1",
"us-east-2",
"us-gov-east-1",
"us-gov-west-1",
"us-west-2",
"ap-northeast-1",
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ap-east-1",
"ca-central-1",
"eu-central-1",
"eu-west-2",
]
OLLAMA_PROVIDER_NAME = "ollama_chat"
@@ -51,13 +22,6 @@ OPENROUTER_PROVIDER_NAME = "openrouter"
ANTHROPIC_PROVIDER_NAME = "anthropic"
# Curated list of Anthropic models to show by default in the UI
ANTHROPIC_VISIBLE_MODEL_NAMES = {
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-haiku-4-5",
}
AZURE_PROVIDER_NAME = "azure"
@@ -65,13 +29,6 @@ VERTEXAI_PROVIDER_NAME = "vertex_ai"
VERTEX_CREDENTIALS_FILE_KWARG = "vertex_credentials"
VERTEX_CREDENTIALS_FILE_KWARG_ENV_VAR_FORMAT = "CREDENTIALS_FILE"
VERTEX_LOCATION_KWARG = "vertex_location"
VERTEXAI_DEFAULT_MODEL = "gemini-2.5-flash"
# Curated list of Vertex AI models to show by default in the UI
VERTEXAI_VISIBLE_MODEL_NAMES = {
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.5-pro",
}
AWS_REGION_NAME_KWARG = "aws_region_name"
AWS_REGION_NAME_KWARG_ENV_VAR_FORMAT = "AWS_REGION_NAME"

View File

@@ -16,6 +16,10 @@
"name": "claude-opus-4-6",
"display_name": "Claude Opus 4.6"
},
{
"name": "claude-sonnet-4-6",
"display_name": "Claude Sonnet 4.6"
},
{
"name": "claude-opus-4-5",
"display_name": "Claude Opus 4.5"

View File

@@ -6758,12 +6758,12 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz",
"integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==",
"license": "MIT",
"dependencies": {
"ip-address": "10.0.1"
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
@@ -7556,9 +7556,9 @@
}
},
"node_modules/ip-address": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"

View File

@@ -65,7 +65,6 @@ from onyx.server.manage.llm.models import LLMProviderUpsertRequest
from onyx.server.manage.llm.models import LLMProviderView
from onyx.server.manage.llm.models import LMStudioFinalModelResponse
from onyx.server.manage.llm.models import LMStudioModelsRequest
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
@@ -446,17 +445,16 @@ def put_llm_provider(
not existing_provider or not existing_provider.is_auto_mode
)
# When transitioning to auto mode, preserve existing model configurations
# so the upsert doesn't try to delete them (which would trip the default
# model protection guard). sync_auto_mode_models will handle the model
# lifecycle afterward — adding new models, hiding removed ones, and
# updating the default. This is safe even if sync fails: the provider
# keeps its old models and default rather than losing them.
if transitioning_to_auto_mode and existing_provider:
llm_provider_upsert_request.model_configurations = [
ModelConfigurationUpsertRequest.from_model(mc)
for mc in existing_provider.model_configurations
]
# Before the upsert, check if this provider currently owns the global
# CHAT default. The upsert may cascade-delete model_configurations
# (and their flow mappings), so we need to remember this beforehand.
was_default_provider = False
if existing_provider and transitioning_to_auto_mode:
current_default = fetch_default_llm_model(db_session)
was_default_provider = (
current_default is not None
and current_default.llm_provider_id == existing_provider.id
)
try:
result = upsert_llm_provider(
@@ -470,6 +468,7 @@ 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_by_id(
id=result.id, db_session=db_session
)
@@ -479,6 +478,20 @@ def put_llm_provider(
updated_provider,
config,
)
# If this provider was the default before the transition,
# restore the default using the recommended model.
if was_default_provider:
recommended = config.get_default_model(
llm_provider_upsert_request.provider
)
if recommended:
update_default_provider(
provider_id=updated_provider.id,
model_name=recommended.name,
db_session=db_session,
)
# Refresh result with synced models
result = LLMProviderView.from_model(updated_provider)

View File

@@ -5,6 +5,7 @@ from datetime import datetime
from datetime import timedelta
from datetime import timezone
from typing import cast
from uuid import UUID
import jwt
from email_validator import EmailNotValidError
@@ -18,6 +19,7 @@ from fastapi import Query
from fastapi import Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from onyx.auth.anonymous_user import fetch_anonymous_user_info
@@ -67,6 +69,7 @@ from onyx.db.user_preferences import update_user_role
from onyx.db.user_preferences import update_user_shortcut_enabled
from onyx.db.user_preferences import update_user_temperature_override_enabled
from onyx.db.user_preferences import update_user_theme_preference
from onyx.db.users import batch_get_user_groups
from onyx.db.users import delete_user_from_db
from onyx.db.users import get_all_users
from onyx.db.users import get_page_of_filtered_users
@@ -98,6 +101,7 @@ from onyx.server.manage.models import UserSpecificAssistantPreferences
from onyx.server.models import FullUserSnapshot
from onyx.server.models import InvitedUserSnapshot
from onyx.server.models import MinimalUserSnapshot
from onyx.server.models import UserGroupInfo
from onyx.server.usage_limits import is_tenant_on_trial_fn
from onyx.server.utils import BasicAuthenticationError
from onyx.utils.logger import setup_logger
@@ -203,9 +207,32 @@ def list_accepted_users(
total_items=0,
)
user_ids = [user.id for user in filtered_accepted_users]
groups_by_user = batch_get_user_groups(db_session, user_ids)
# Batch-fetch SCIM mappings to mark synced users
scim_synced_ids: set[UUID] = set()
try:
from onyx.db.models import ScimUserMapping
scim_mappings = db_session.scalars(
select(ScimUserMapping.user_id).where(ScimUserMapping.user_id.in_(user_ids))
).all()
scim_synced_ids = set(scim_mappings)
except Exception:
pass
return PaginatedReturn(
items=[
FullUserSnapshot.from_user_model(user) for user in filtered_accepted_users
FullUserSnapshot.from_user_model(
user,
groups=[
UserGroupInfo(id=gid, name=gname)
for gid, gname in groups_by_user.get(user.id, [])
],
is_scim_synced=user.id in scim_synced_ids,
)
for user in filtered_accepted_users
],
total_items=total_accepted_users_count,
)
@@ -269,24 +296,10 @@ def list_all_users(
if accepted_page is None or invited_page is None or slack_users_page is None:
return AllUsersResponse(
accepted=[
FullUserSnapshot(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
)
for user in accepted_users
FullUserSnapshot.from_user_model(user) for user in accepted_users
],
slack_users=[
FullUserSnapshot(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
)
for user in slack_users
FullUserSnapshot.from_user_model(user) for user in slack_users
],
invited=[InvitedUserSnapshot(email=email) for email in invited_emails],
accepted_pages=1,
@@ -296,26 +309,10 @@ def list_all_users(
# Otherwise, return paginated results
return AllUsersResponse(
accepted=[
FullUserSnapshot(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
)
for user in accepted_users
][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE],
slack_users=[
FullUserSnapshot(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
)
for user in slack_users
][
accepted=[FullUserSnapshot.from_user_model(user) for user in accepted_users][
accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE
],
slack_users=[FullUserSnapshot.from_user_model(user) for user in slack_users][
slack_users_page
* USERS_PAGE_SIZE : (slack_users_page + 1)
* USERS_PAGE_SIZE

View File

@@ -1,3 +1,4 @@
import datetime
from typing import Generic
from typing import Optional
from typing import TypeVar
@@ -31,21 +32,41 @@ class MinimalUserSnapshot(BaseModel):
email: str
class UserGroupInfo(BaseModel):
id: int
name: str
class FullUserSnapshot(BaseModel):
id: UUID
email: str
role: UserRole
is_active: bool
password_configured: bool
personal_name: str | None
created_at: datetime.datetime
updated_at: datetime.datetime
groups: list[UserGroupInfo]
is_scim_synced: bool
@classmethod
def from_user_model(cls, user: User) -> "FullUserSnapshot":
def from_user_model(
cls,
user: User,
groups: list[UserGroupInfo] | None = None,
is_scim_synced: bool = False,
) -> "FullUserSnapshot":
return cls(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
personal_name=user.personal_name,
created_at=user.created_at,
updated_at=user.updated_at,
groups=groups or [],
is_scim_synced=is_scim_synced,
)

View File

@@ -1152,179 +1152,3 @@ class TestAutoModeTransitionsAndResync:
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)
def test_sync_updates_default_when_recommended_default_changes(
self,
db_session: Session,
provider_name: str,
) -> None:
"""When the provider owns the CHAT default and a sync arrives with a
different recommended default model (both models still in config),
the global default should be updated to the new recommendation.
Steps:
1. Create auto-mode provider with config v1: default=gpt-4o.
2. Set gpt-4o as the global CHAT default.
3. Re-sync with config v2: default=gpt-4o-mini (gpt-4o still present).
4. Verify the CHAT default switched to gpt-4o-mini and both models
remain 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-mini",
additional_models=["gpt-4o"],
)
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,
)
# Set gpt-4o as the global CHAT 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"
# Re-sync with config v2 (recommended default changed)
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, "Sync should report changes when default switches"
# Both models should remain visible
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 True
assert visibility["gpt-4o-mini"] is True
# The CHAT default should now be gpt-4o-mini
default_after = fetch_default_llm_model(db_session)
assert default_after is not None
assert (
default_after.name == "gpt-4o-mini"
), f"Default should be updated to 'gpt-4o-mini', got '{default_after.name}'"
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)
def test_sync_idempotent_when_default_already_matches(
self,
db_session: Session,
provider_name: str,
) -> None:
"""When the provider owns the CHAT default and it already matches the
recommended default, re-syncing should report zero changes.
This is a regression test for the bug where changes was unconditionally
incremented even when the default was already correct.
"""
config = _create_mock_llm_recommendations(
provider=LlmProviderNames.OPENAI,
default_model_name="gpt-4o",
additional_models=["gpt-4o-mini"],
)
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,
)
# Set gpt-4o (the recommended default) as global CHAT 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)
# First sync to stabilize state
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
sync_auto_mode_models(
db_session=db_session,
provider=provider,
llm_recommendations=config,
)
# Second sync — default already matches, should be a no-op
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,
)
assert changes == 0, (
f"Expected 0 changes when default already matches recommended, "
f"got {changes}"
)
# Default should still be gpt-4o
default_model = fetch_default_llm_model(db_session)
assert default_model is not None
assert default_model.name == "gpt-4o"
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)

View File

@@ -1,220 +0,0 @@
"""
This should act as the main point of reference for testing that default model
logic is consisten.
-
"""
from collections.abc import Generator
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from onyx.db.llm import fetch_existing_llm_provider
from onyx.db.llm import remove_llm_provider
from onyx.db.llm import update_default_provider
from onyx.db.llm import update_default_vision_provider
from onyx.db.llm import upsert_llm_provider
from onyx.llm.constants import LlmProviderNames
from onyx.server.manage.llm.models import LLMProviderUpsertRequest
from onyx.server.manage.llm.models import LLMProviderView
from onyx.server.manage.llm.models import ModelConfigurationUpsertRequest
def _create_test_provider(
db_session: Session,
name: str,
models: list[ModelConfigurationUpsertRequest] | None = None,
) -> LLMProviderView:
"""Helper to create a test LLM provider with multiple models."""
if models is None:
models = [
ModelConfigurationUpsertRequest(
name="gpt-4o", is_visible=True, supports_image_input=True
),
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True, supports_image_input=False
),
]
return upsert_llm_provider(
LLMProviderUpsertRequest(
name=name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=models,
),
db_session=db_session,
)
def _cleanup_provider(db_session: Session, name: str) -> None:
"""Helper to clean up a test provider by name."""
provider = fetch_existing_llm_provider(name=name, db_session=db_session)
if provider:
remove_llm_provider(db_session, provider.id)
@pytest.fixture
def provider_name(db_session: Session) -> Generator[str, None, None]:
"""Generate a unique provider name for each test, with automatic cleanup."""
name = f"test-provider-{uuid4().hex[:8]}"
yield name
db_session.rollback()
_cleanup_provider(db_session, name)
class TestDefaultModelProtection:
"""Tests that the default model cannot be removed or hidden."""
def test_cannot_remove_default_text_model(
self,
db_session: Session,
provider_name: str,
) -> None:
"""Removing the default text model from a provider should raise ValueError."""
provider = _create_test_provider(db_session, provider_name)
update_default_provider(provider.id, "gpt-4o", db_session)
# Try to update the provider without the default model
with pytest.raises(ValueError, match="Cannot remove the default model"):
upsert_llm_provider(
LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
),
],
),
db_session=db_session,
)
def test_cannot_hide_default_text_model(
self,
db_session: Session,
provider_name: str,
) -> None:
"""Setting is_visible=False on the default text model should raise ValueError."""
provider = _create_test_provider(db_session, provider_name)
update_default_provider(provider.id, "gpt-4o", db_session)
# Try to hide the default model
with pytest.raises(ValueError, match="Cannot hide the default model"):
upsert_llm_provider(
LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o", is_visible=False
),
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
),
],
),
db_session=db_session,
)
def test_cannot_remove_default_vision_model(
self,
db_session: Session,
provider_name: str,
) -> None:
"""Removing the default vision model from a provider should raise ValueError."""
provider = _create_test_provider(db_session, provider_name)
# Set gpt-4o as both the text and vision default
update_default_provider(provider.id, "gpt-4o", db_session)
update_default_vision_provider(provider.id, "gpt-4o", db_session)
# Try to remove the default vision model
with pytest.raises(ValueError, match="Cannot remove the default model"):
upsert_llm_provider(
LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
),
],
),
db_session=db_session,
)
def test_can_remove_non_default_model(
self,
db_session: Session,
provider_name: str,
) -> None:
"""Removing a non-default model should succeed."""
provider = _create_test_provider(db_session, provider_name)
update_default_provider(provider.id, "gpt-4o", db_session)
# Remove gpt-4o-mini (not default) — should succeed
updated = upsert_llm_provider(
LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o", is_visible=True, supports_image_input=True
),
],
),
db_session=db_session,
)
model_names = {mc.name for mc in updated.model_configurations}
assert "gpt-4o" in model_names
assert "gpt-4o-mini" not in model_names
def test_can_hide_non_default_model(
self,
db_session: Session,
provider_name: str,
) -> None:
"""Hiding a non-default model should succeed."""
provider = _create_test_provider(db_session, provider_name)
update_default_provider(provider.id, "gpt-4o", db_session)
# Hide gpt-4o-mini (not default) — should succeed
updated = upsert_llm_provider(
LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o", is_visible=True, supports_image_input=True
),
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=False
),
],
),
db_session=db_session,
)
model_visibility = {
mc.name: mc.is_visible for mc in updated.model_configurations
}
assert model_visibility["gpt-4o"] is True
assert model_visibility["gpt-4o-mini"] is False

View File

@@ -68,17 +68,17 @@ onyx-cli agents --json
| `ask` | Ask a one-shot question (non-interactive) |
| `agents` | List available agents |
| `configure` | Configure server URL and API key |
| `validate-config` | Validate configuration and test connection |
## Slash Commands (in TUI)
| Command | Description |
|---------|-------------|
| `/help` | Show help message |
| `/new` | Start a new chat session |
| `/clear` | Clear chat and start a new session |
| `/agent` | List and switch agents |
| `/attach <path>` | Attach a file to next message |
| `/sessions` | List recent chat sessions |
| `/clear` | Clear the chat display |
| `/configure` | Re-run connection setup |
| `/connectors` | Open connectors in browser |
| `/settings` | Open settings in browser |
@@ -116,3 +116,43 @@ go build -o onyx-cli .
# Lint
staticcheck ./...
```
## Publishing to PyPI
The CLI is distributed as a Python package via [PyPI](https://pypi.org/project/onyx-cli/). The build system uses [hatchling](https://hatch.pypa.io/) with [manygo](https://github.com/nicholasgasior/manygo) to cross-compile Go binaries into platform-specific wheels.
### CI release (recommended)
Tag a release and push — the `release-cli.yml` workflow builds wheels for all platforms and publishes to PyPI automatically:
```shell
tag --prefix cli
```
To do this manually:
```shell
git tag cli/v0.1.0
git push origin cli/v0.1.0
```
The workflow builds wheels for: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64, windows/arm64.
### Manual release
Build a wheel locally with `uv`. Set `GOOS` and `GOARCH` to cross-compile for other platforms (Go handles this natively — no cross-compiler needed):
```shell
# Build for current platform
uv build --wheel
# Cross-compile for a different platform
GOOS=linux GOARCH=amd64 uv build --wheel
# Upload to PyPI
uv publish
```
### Versioning
Versions are derived from git tags with the `cli/` prefix (e.g. `cli/v0.1.0`). The tag is parsed by `internal/_version.py` and injected into the Go binary via `-ldflags` at build time.

43
cli/hatch_build.py Normal file
View File

@@ -0,0 +1,43 @@
from __future__ import annotations
import os
import subprocess
from typing import Any
import manygo
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomBuildHook(BuildHookInterface):
"""Build hook to compile the Go binary and include it in the wheel."""
def initialize(self, version: Any, build_data: Any) -> None: # noqa: ARG002
"""Build the Go binary before packaging."""
build_data["pure_python"] = False
# Set platform tag for cross-compilation
goos = os.getenv("GOOS")
goarch = os.getenv("GOARCH")
if manygo.is_goos(goos) and manygo.is_goarch(goarch):
build_data["tag"] = "py3-none-" + manygo.get_platform_tag(
goos=goos,
goarch=goarch,
)
# Get config and environment
binary_name = self.config["binary_name"]
tag_prefix = self.config.get("tag_prefix", binary_name)
tag = os.getenv("GITHUB_REF_NAME", "dev").removeprefix(f"{tag_prefix}/")
commit = os.getenv("GITHUB_SHA", "none")
# Build the Go binary if it doesn't exist
# Build the Go binary (always rebuild to ensure correct version injection)
if not os.path.exists(binary_name):
print(f"Building Go binary '{binary_name}'...")
pkg = "github.com/onyx-dot-app/onyx/cli/cmd"
ldflags = f"-X {pkg}.version={tag}" f" -X {pkg}.commit={commit}" " -s -w"
subprocess.check_call( # noqa: S603
["go", "build", f"-ldflags={ldflags}", "-o", binary_name],
)
build_data["shared_scripts"] = {binary_name: binary_name}

11
cli/internal/_version.py Normal file
View File

@@ -0,0 +1,11 @@
from __future__ import annotations
import os
import re
# Must match tag_prefix in pyproject.toml [tool.hatch.build.targets.wheel.hooks.custom]
TAG_PREFIX = "cli"
_tag = os.environ.get("GITHUB_REF_NAME", "v0.0.0-dev").removeprefix(f"{TAG_PREFIX}/")
_match = re.search(r"v?(\d+\.\d+\.\d+)", _tag)
__version__ = _match.group(1) if _match else "0.0.0"

39
cli/pyproject.toml Normal file
View File

@@ -0,0 +1,39 @@
[build-system]
requires = ["hatchling", "go-bin~=1.24.11", "manygo"]
build-backend = "hatchling.build"
[project]
name = "onyx-cli"
readme = "README.md"
description = "Terminal interface for chatting with your Onyx agent"
authors = [{ name = "Onyx AI", email = "founders@onyx.app" }]
requires-python = ">=3.9"
keywords = [
"onyx", "cli", "chat", "ai", "enterprise-search",
]
classifiers = [
"Programming Language :: Go",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
"Operating System :: Microsoft :: Windows",
]
dynamic = ["version"]
[project.urls]
Repository = "https://github.com/onyx-dot-app/onyx"
[tool.hatch.build]
include = ["go.mod", "go.sum", "main.go", "**/*.go", "**/*.py", "README.md"]
[tool.hatch.version]
source = "code"
path = "internal/_version.py"
[tool.hatch.build.targets.wheel.hooks.custom]
path = "hatch_build.py"
binary_name = "onyx-cli"
tag_prefix = "cli"
[tool.uv]
managed = false

View File

@@ -8,16 +8,16 @@
"name": "onyx-desktop",
"version": "0.0.0-dev",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
"@tauri-apps/api": "^2.10.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0"
"@tauri-apps/cli": "^2.10.1"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
@@ -25,9 +25,9 @@
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.6.tgz",
"integrity": "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==",
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz",
"integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
@@ -41,23 +41,23 @@
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.9.6",
"@tauri-apps/cli-darwin-x64": "2.9.6",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6",
"@tauri-apps/cli-linux-arm64-gnu": "2.9.6",
"@tauri-apps/cli-linux-arm64-musl": "2.9.6",
"@tauri-apps/cli-linux-riscv64-gnu": "2.9.6",
"@tauri-apps/cli-linux-x64-gnu": "2.9.6",
"@tauri-apps/cli-linux-x64-musl": "2.9.6",
"@tauri-apps/cli-win32-arm64-msvc": "2.9.6",
"@tauri-apps/cli-win32-ia32-msvc": "2.9.6",
"@tauri-apps/cli-win32-x64-msvc": "2.9.6"
"@tauri-apps/cli-darwin-arm64": "2.10.1",
"@tauri-apps/cli-darwin-x64": "2.10.1",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1",
"@tauri-apps/cli-linux-arm64-gnu": "2.10.1",
"@tauri-apps/cli-linux-arm64-musl": "2.10.1",
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.1",
"@tauri-apps/cli-linux-x64-gnu": "2.10.1",
"@tauri-apps/cli-linux-x64-musl": "2.10.1",
"@tauri-apps/cli-win32-arm64-msvc": "2.10.1",
"@tauri-apps/cli-win32-ia32-msvc": "2.10.1",
"@tauri-apps/cli-win32-x64-msvc": "2.10.1"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.6.tgz",
"integrity": "sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==",
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz",
"integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==",
"cpu": [
"arm64"
],
@@ -72,9 +72,9 @@
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.6.tgz",
"integrity": "sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==",
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz",
"integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==",
"cpu": [
"x64"
],
@@ -89,9 +89,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.6.tgz",
"integrity": "sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==",
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz",
"integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==",
"cpu": [
"arm"
],
@@ -106,9 +106,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.6.tgz",
"integrity": "sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==",
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz",
"integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==",
"cpu": [
"arm64"
],
@@ -123,9 +123,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.6.tgz",
"integrity": "sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==",
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz",
"integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==",
"cpu": [
"arm64"
],
@@ -140,9 +140,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.6.tgz",
"integrity": "sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==",
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz",
"integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==",
"cpu": [
"riscv64"
],
@@ -157,9 +157,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.6.tgz",
"integrity": "sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==",
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz",
"integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==",
"cpu": [
"x64"
],
@@ -174,9 +174,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.6.tgz",
"integrity": "sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==",
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz",
"integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==",
"cpu": [
"x64"
],
@@ -191,9 +191,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.6.tgz",
"integrity": "sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==",
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz",
"integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==",
"cpu": [
"arm64"
],
@@ -208,9 +208,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.6.tgz",
"integrity": "sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==",
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz",
"integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==",
"cpu": [
"ia32"
],
@@ -225,9 +225,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.6.tgz",
"integrity": "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==",
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz",
"integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==",
"cpu": [
"x64"
],

View File

@@ -9,10 +9,10 @@
"build:dmg": "tauri build --target universal-apple-darwin",
"build:linux": "tauri build --bundles deb,rpm"
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0"
"@tauri-apps/api": "^2.10.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2.10.1"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,18 +6,18 @@ authors = ["you"]
edition = "2021"
[build-dependencies]
tauri-build = { version = "2.0", features = [] }
tauri-build = { version = "2.5", features = [] }
[dependencies]
tauri = { version = "2.0", features = ["macos-private-api", "tray-icon", "image-png"] }
tauri-plugin-shell = "2.0"
tauri-plugin-window-state = "2.0"
tauri = { version = "2.10", features = ["macos-private-api", "tray-icon", "image-png"] }
tauri-plugin-shell = "2.3.5"
tauri-plugin-window-state = "2.4.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4"] }
directories = "5.0"
tokio = { version = "1", features = ["time"] }
window-vibrancy = "0.5"
window-vibrancy = "0.7.1"
url = "2.5"
[features]

928
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -132,7 +132,7 @@
"eslint-plugin-unused-imports": "^4.1.4",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-environment-jsdom": "^30.2.0",
"prettier": "3.1.0",
"stats.js": "^0.17.0",
"tailwindcss": "^3.4.17",

View File

@@ -0,0 +1 @@
export { default } from "@/refresh-pages/admin/UsersPage";

View File

@@ -7,15 +7,13 @@ import { InMessageImage } from "@/app/app/components/files/images/InMessageImage
import CsvContent from "@/components/tools/CSVContent";
import TextViewModal from "@/sections/modals/TextViewModal";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import { cn } from "@/lib/utils";
import ExpandableContentWrapper from "@/components/tools/ExpandableContentWrapper";
interface FileDisplayProps {
files: FileDescriptor[];
alignBubble?: boolean;
}
export default function FileDisplay({ files, alignBubble }: FileDisplayProps) {
export default function FileDisplay({ files }: FileDisplayProps) {
const [close, setClose] = useState(true);
const [previewingFile, setPreviewingFile] = useState<FileDescriptor | null>(
null
@@ -43,59 +41,47 @@ export default function FileDisplay({ files, alignBubble }: FileDisplayProps) {
)}
{textFiles.length > 0 && (
<div
id="onyx-file"
className={cn("m-2 auto", alignBubble && "ml-auto")}
>
<div className="flex flex-col items-end gap-2">
{textFiles.map((file) => (
<Attachment
key={file.id}
fileName={file.name || file.id}
open={() => setPreviewingFile(file)}
/>
))}
</div>
<div id="onyx-file" className="flex flex-col items-end gap-2 py-2">
{textFiles.map((file) => (
<Attachment
key={file.id}
fileName={file.name || file.id}
open={() => setPreviewingFile(file)}
/>
))}
</div>
)}
{imageFiles.length > 0 && (
<div
id="onyx-image"
className={cn("m-2 auto", alignBubble && "ml-auto")}
>
<div className="flex flex-col items-end gap-2">
{imageFiles.map((file) => (
<InMessageImage key={file.id} fileId={file.id} />
))}
</div>
<div id="onyx-image" className="flex flex-col items-end gap-2 py-2">
{imageFiles.map((file) => (
<InMessageImage key={file.id} fileId={file.id} />
))}
</div>
)}
{csvFiles.length > 0 && (
<div className={cn("m-2 auto", alignBubble && "ml-auto")}>
<div className="flex flex-col items-end gap-2">
{csvFiles.map((file) => {
return (
<div key={file.id} className="w-fit">
{close ? (
<>
<ExpandableContentWrapper
fileDescriptor={file}
close={() => setClose(false)}
ContentComponent={CsvContent}
/>
</>
) : (
<Attachment
open={() => setClose(true)}
fileName={file.name || file.id}
<div className="flex flex-col items-end gap-2 py-2">
{csvFiles.map((file) => {
return (
<div key={file.id} className="w-fit">
{close ? (
<>
<ExpandableContentWrapper
fileDescriptor={file}
close={() => setClose(false)}
ContentComponent={CsvContent}
/>
)}
</div>
);
})}
</div>
</>
) : (
<Attachment
open={() => setClose(true)}
fileName={file.name || file.id}
/>
)}
</div>
);
})}
</div>
)}
</>

View File

@@ -195,7 +195,7 @@ const HumanMessage = React.memo(function HumanMessage({
id="onyx-human-message"
className="group flex flex-col justify-end w-full relative"
>
<FileDisplay alignBubble files={files || []} />
<FileDisplay files={files || []} />
{isEditing ? (
<MessageEditing
content={content}

View File

@@ -22,9 +22,6 @@ describe("Email/Password Login Workflow", () => {
beforeEach(() => {
jest.clearAllMocks();
fetchSpy = jest.spyOn(global, "fetch");
// Mock window.location.href for redirect testing
delete (window as any).location;
window.location = { href: "" } as any;
});
afterEach(() => {
@@ -53,9 +50,9 @@ describe("Email/Password Login Workflow", () => {
const loginButton = screen.getByRole("button", { name: /sign in/i });
await user.click(loginButton);
// After successful login, user should be redirected to /chat
// Verify success message is shown after login
await waitFor(() => {
expect(window.location.href).toBe("/app");
expect(screen.getByText(/signed in successfully\./i)).toBeInTheDocument();
});
// Verify API was called with correct credentials
@@ -114,9 +111,6 @@ describe("Email/Password Signup Workflow", () => {
beforeEach(() => {
jest.clearAllMocks();
fetchSpy = jest.spyOn(global, "fetch");
// Mock window.location.href
delete (window as any).location;
window.location = { href: "" } as any;
});
afterEach(() => {

View File

@@ -39,7 +39,7 @@ export function ToggleWarningModal({
{/* Message */}
<div className="flex justify-center">
<Text mainUiBody text04 className="text-center">
We recommend using <strong>Claude Opus 4.5</strong> for Crafting.
We recommend using <strong>Claude Opus 4.6</strong> for Crafting.
<br />
Other models may have reduced capabilities for code creation,
<br />

View File

@@ -34,8 +34,8 @@ export const PROVIDERS: ProviderConfig[] = [
providerName: LLMProviderName.ANTHROPIC,
recommended: true,
models: [
{ name: "claude-opus-4-5", label: "Claude Opus 4.5", recommended: true },
{ name: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" },
{ name: "claude-opus-4-6", label: "Claude Opus 4.6", recommended: true },
{ name: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
],
apiKeyPlaceholder: "sk-ant-...",
apiKeyUrl: "https://console.anthropic.com/dashboard",

View File

@@ -5,12 +5,12 @@
export interface BuildLlmSelection {
providerName: string; // e.g., "build-mode-anthropic" (LLMProviderDescriptor.name)
provider: string; // e.g., "anthropic"
modelName: string; // e.g., "claude-opus-4-5"
modelName: string; // e.g., "claude-opus-4-6"
}
// Priority order for smart default LLM selection
const LLM_SELECTION_PRIORITY = [
{ provider: "anthropic", modelName: "claude-opus-4-5" },
{ provider: "anthropic", modelName: "claude-opus-4-6" },
{ provider: "openai", modelName: "gpt-5.2" },
{ provider: "openrouter", modelName: "minimax/minimax-m2.1" },
] as const;
@@ -63,11 +63,11 @@ export function getDefaultLlmSelection(
export const RECOMMENDED_BUILD_MODELS = {
preferred: {
provider: "anthropic",
modelName: "claude-opus-4-5",
displayName: "Claude Opus 4.5",
modelName: "claude-opus-4-6",
displayName: "Claude Opus 4.6",
},
alternatives: [
{ provider: "anthropic", modelName: "claude-sonnet-4-5" },
{ provider: "anthropic", modelName: "claude-sonnet-4-6" },
{ provider: "openai", modelName: "gpt-5.2" },
{ provider: "openai", modelName: "gpt-5.1-codex" },
{ provider: "openrouter", modelName: "minimax/minimax-m2.1" },
@@ -148,8 +148,8 @@ export const BUILD_MODE_PROVIDERS: BuildModeProvider[] = [
providerName: "anthropic",
recommended: true,
models: [
{ name: "claude-opus-4-5", label: "Claude Opus 4.5", recommended: true },
{ name: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" },
{ name: "claude-opus-4-6", label: "Claude Opus 4.6", recommended: true },
{ name: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
],
apiKeyPlaceholder: "sk-ant-...",
apiKeyUrl: "https://console.anthropic.com/dashboard",

View File

@@ -31,6 +31,7 @@ const SETTINGS_LAYOUT_PREFIXES = [
ADMIN_PATHS.LLM_MODELS,
ADMIN_PATHS.AGENTS,
ADMIN_PATHS.USERS,
ADMIN_PATHS.USERS_V2,
ADMIN_PATHS.TOKEN_RATE_LIMITS,
ADMIN_PATHS.SEARCH_SETTINGS,
ADMIN_PATHS.DOCUMENT_PROCESSING,

View File

@@ -0,0 +1,46 @@
"use client";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import type { UserRole } from "@/lib/types";
import type { PaginatedUsersResponse } from "@/refresh-pages/admin/UsersPage/interfaces";
interface UseAdminUsersParams {
pageIndex: number;
pageSize: number;
searchTerm?: string;
roles?: UserRole[];
isActive?: boolean | undefined;
}
export default function useAdminUsers({
pageIndex,
pageSize,
searchTerm,
roles,
isActive,
}: UseAdminUsersParams) {
const queryParams = new URLSearchParams({
page_num: String(pageIndex),
page_size: String(pageSize),
...(searchTerm && { q: searchTerm }),
...(isActive === true && { is_active: "true" }),
...(isActive === false && { is_active: "false" }),
});
for (const role of roles ?? []) {
queryParams.append("roles", role);
}
const { data, isLoading, error, mutate } = useSWR<PaginatedUsersResponse>(
`/api/manage/users/accepted?${queryParams.toString()}`,
errorHandlingFetcher
);
return {
users: data?.items ?? [],
totalItems: data?.total_items ?? 0,
isLoading,
error,
refresh: mutate,
};
}

View File

@@ -0,0 +1,34 @@
"use client";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import type { InvitedUserSnapshot } from "@/lib/types";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
interface PaginatedCountResponse {
total_items: number;
}
export default function useUserCounts() {
// Active user count — lightweight fetch (page_size=1 to minimize payload)
const { data: activeData } = useSWR<PaginatedCountResponse>(
"/api/manage/users/accepted?page_num=0&page_size=1",
errorHandlingFetcher
);
const { data: invitedUsers } = useSWR<InvitedUserSnapshot[]>(
"/api/manage/users/invited",
errorHandlingFetcher
);
const { data: pendingUsers } = useSWR<InvitedUserSnapshot[]>(
NEXT_PUBLIC_CLOUD_ENABLED ? "/api/tenants/users/pending" : null,
errorHandlingFetcher
);
return {
activeCount: activeData?.total_items ?? null,
invitedCount: invitedUsers?.length ?? null,
pendingCount: pendingUsers?.length ?? null,
};
}

View File

@@ -230,7 +230,7 @@ function SettingsHeader({
</div>
)}
<Spacer vertical rem={1} />
<Spacer vertical rem={2.5} />
<div className="flex flex-col gap-6 px-4">
<div className="flex w-full justify-between">

View File

@@ -58,6 +58,7 @@ export const ADMIN_PATHS = {
DOCUMENT_PROCESSING: "/admin/configuration/document-processing",
KNOWLEDGE_GRAPH: "/admin/kg",
USERS: "/admin/users",
USERS_V2: "/admin/users2",
API_KEYS: "/admin/api-key",
TOKEN_RATE_LIMITS: "/admin/token-rate-limits",
USAGE: "/admin/performance/usage",
@@ -190,6 +191,11 @@ export const ADMIN_ROUTE_CONFIG: Record<string, AdminRouteConfig> = {
title: "Manage Users",
sidebarLabel: "Users",
},
[ADMIN_PATHS.USERS_V2]: {
icon: SvgUser,
title: "Users & Requests",
sidebarLabel: "Users v2",
},
[ADMIN_PATHS.API_KEYS]: {
icon: SvgKey,
title: "API Keys",

View File

@@ -0,0 +1,21 @@
import { ScopedMutator } from "swr";
import {
LLM_CHAT_PROVIDERS_URL,
LLM_PROVIDERS_ADMIN_URL,
} from "@/lib/llmConfig/constants";
const PERSONA_PROVIDER_ENDPOINT_PATTERN =
/^\/api\/llm\/persona\/\d+\/providers$/;
export async function refreshLlmProviderCaches(
mutate: ScopedMutator
): Promise<void> {
await Promise.all([
mutate(LLM_PROVIDERS_ADMIN_URL),
mutate(LLM_CHAT_PROVIDERS_URL),
mutate(
(key) =>
typeof key === "string" && PERSONA_PROVIDER_ENDPOINT_PATTERN.test(key)
),
]);
}

View File

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

View File

@@ -20,6 +20,7 @@ import {
getProviderIcon,
getProviderProductName,
} from "@/lib/llmConfig/providers";
import { refreshLlmProviderCaches } from "@/lib/llmConfig/cache";
import { deleteLlmProvider, setDefaultLlmModel } from "@/lib/llmConfig/svc";
import Text from "@/refresh-components/texts/Text";
import { Horizontal as HorizontalInput } from "@/layouts/input-layouts";
@@ -33,7 +34,6 @@ import {
LLMProviderView,
WellKnownLLMProviderDescriptor,
} from "@/interfaces/llm";
import { LLM_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import { getModalForExistingProvider } from "@/sections/modals/llmConfig/getModal";
import { OpenAIModal } from "@/sections/modals/llmConfig/OpenAIModal";
import { AnthropicModal } from "@/sections/modals/llmConfig/AnthropicModal";
@@ -140,7 +140,7 @@ function ExistingProviderCard({
const handleDelete = async () => {
try {
await deleteLlmProvider(provider.id);
mutate(LLM_PROVIDERS_ADMIN_URL);
await refreshLlmProviderCaches(mutate);
deleteModal.toggle(false);
toast.success("Provider deleted successfully!");
} catch (e) {
@@ -345,7 +345,7 @@ export default function LLMConfigurationPage() {
try {
await setDefaultLlmModel(providerId, modelName);
mutate(LLM_PROVIDERS_ADMIN_URL);
await refreshLlmProviderCaches(mutate);
toast.success("Default model updated successfully!");
} catch (e) {
const message = e instanceof Error ? e.message : "Unknown error";

View File

@@ -0,0 +1,59 @@
"use client";
import { SvgUser, SvgUserPlus } from "@opal/icons";
import { Button } from "@opal/components";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { useScimToken } from "@/hooks/useScimToken";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import useUserCounts from "@/hooks/useUserCounts";
import UsersSummary from "./UsersPage/UsersSummary";
import UsersTable from "./UsersPage/UsersTable";
// ---------------------------------------------------------------------------
// Users page content
// ---------------------------------------------------------------------------
function UsersContent() {
const isEe = usePaidEnterpriseFeaturesEnabled();
const { data: scimToken } = useScimToken();
const showScim = isEe && !!scimToken;
const { activeCount, invitedCount, pendingCount } = useUserCounts();
return (
<>
<UsersSummary
activeUsers={activeCount}
pendingInvites={invitedCount}
requests={pendingCount}
showScim={showScim}
/>
<UsersTable />
</>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function UsersPage() {
return (
<SettingsLayouts.Root width="lg">
<SettingsLayouts.Header
title="Users & Requests"
icon={SvgUser}
rightChildren={
// TODO (ENG-3806): Wire up invite modal
<Button icon={SvgUserPlus}>Invite Users</Button>
}
/>
<SettingsLayouts.Body>
<UsersContent />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
);
}

View File

@@ -0,0 +1,104 @@
import { SvgArrowUpRight, SvgUserSync } from "@opal/icons";
import { ContentAction } from "@opal/layouts";
import { Button } from "@opal/components";
import { Section } from "@/layouts/general-layouts";
import Card from "@/refresh-components/cards/Card";
import Text from "@/refresh-components/texts/Text";
import Link from "next/link";
import { ADMIN_PATHS } from "@/lib/admin-routes";
// ---------------------------------------------------------------------------
// Stats cell — number + label
// ---------------------------------------------------------------------------
interface StatCellProps {
value: number | null;
label: string;
}
function StatCell({ value, label }: StatCellProps) {
const display = value === null ? "—" : value.toLocaleString();
return (
<div className="flex flex-col items-start gap-0.5 w-full p-2">
<Text as="span" mainUiAction text04>
{display}
</Text>
<Text as="span" secondaryBody text03>
{label}
</Text>
</div>
);
}
// ---------------------------------------------------------------------------
// SCIM card
// ---------------------------------------------------------------------------
function ScimCard() {
return (
<Card gap={0.5} padding={0.75}>
<ContentAction
icon={SvgUserSync}
title="SCIM Sync"
description="Users are synced from your identity provider."
sizePreset="main-ui"
variant="section"
paddingVariant="fit"
rightChildren={
<Link href={ADMIN_PATHS.SCIM}>
<Button prominence="tertiary" rightIcon={SvgArrowUpRight} size="sm">
Manage
</Button>
</Link>
}
/>
</Card>
);
}
// ---------------------------------------------------------------------------
// Stats bar — layout varies by SCIM status
// ---------------------------------------------------------------------------
interface UsersSummaryProps {
activeUsers: number | null;
pendingInvites: number | null;
requests: number | null;
showScim: boolean;
}
export default function UsersSummary({
activeUsers,
pendingInvites,
requests,
showScim,
}: UsersSummaryProps) {
const showRequests = requests !== null && requests > 0;
const statsCard = (
<Card padding={0.5}>
<Section flexDirection="row" gap={0}>
<StatCell value={activeUsers} label="active users" />
<StatCell value={pendingInvites} label="pending invites" />
{showRequests && <StatCell value={requests} label="requests to join" />}
</Section>
</Card>
);
if (showScim) {
return (
<Section
flexDirection="row"
justifyContent="start"
alignItems="stretch"
gap={0.5}
>
{statsCard}
<ScimCard />
</Section>
);
}
return statsCard;
}

View File

@@ -0,0 +1,203 @@
"use client";
import { useState } from "react";
import type { SortingState } from "@tanstack/react-table";
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
import { Content } from "@opal/layouts";
import { USER_ROLE_LABELS, UserRole } from "@/lib/types";
import { timeAgo } from "@/lib/time";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import useAdminUsers from "@/hooks/useAdminUsers";
import { SvgUser, SvgUsers, SvgSlack } from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import type { UserRow } from "./interfaces";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getInitials(name: string | null, email: string): string {
if (name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
const local = email.split("@")[0];
if (!local) return "?";
const parts = local.split(/[._-]/);
if (parts.length >= 2) {
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
}
return local.slice(0, 2).toUpperCase();
}
const ROLE_ICONS: Record<UserRole, IconFunctionComponent> = {
[UserRole.BASIC]: SvgUser,
[UserRole.ADMIN]: SvgUser,
[UserRole.GLOBAL_CURATOR]: SvgUsers,
[UserRole.CURATOR]: SvgUsers,
[UserRole.LIMITED]: SvgUser,
[UserRole.EXT_PERM_USER]: SvgUser,
[UserRole.SLACK_USER]: SvgSlack,
};
// ---------------------------------------------------------------------------
// Columns (stable reference — defined at module scope)
// ---------------------------------------------------------------------------
const tc = createTableColumns<UserRow>();
const columns = [
tc.qualifier({
content: "avatar-user",
getInitials: (row) => getInitials(row.personal_name, row.email),
selectable: false,
}),
tc.column("email", {
header: "Name",
weight: 25,
minWidth: 180,
cell: (value, row) => (
<Content
sizePreset="main-ui"
variant="section"
title={row.personal_name ?? value}
description={row.personal_name ? value : undefined}
/>
),
}),
tc.column("groups", {
header: "Groups",
weight: 20,
minWidth: 120,
cell: (value) => {
if (!value.length) {
return (
<Text as="span" secondaryBody text03>
</Text>
);
}
const visible = value.slice(0, 2);
const overflow = value.length - visible.length;
return (
<div className="flex items-center gap-1 flex-wrap">
{visible.map((g) => (
<span
key={g.id}
className="inline-flex items-center rounded-md bg-background-tint-02 px-2 py-0.5"
>
<Text as="span" secondaryBody text03>
{g.name}
</Text>
</span>
))}
{overflow > 0 && (
<Text as="span" secondaryBody text03>
+{overflow}
</Text>
)}
</div>
);
},
}),
tc.column("role", {
header: "Account Type",
weight: 18,
minWidth: 120,
cell: (value) => {
const Icon = ROLE_ICONS[value];
return (
<div className="flex items-center gap-1.5">
{Icon && <Icon size={14} className="text-text-03 shrink-0" />}
<Text as="span" mainUiBody text03>
{USER_ROLE_LABELS[value] ?? value}
</Text>
</div>
);
},
}),
tc.column("is_active", {
header: "Status",
weight: 15,
minWidth: 100,
cell: (value, row) => (
<div className="flex flex-col">
<Text as="span" mainUiBody text03>
{value ? "Active" : "Inactive"}
</Text>
{row.is_scim_synced && (
<Text as="span" secondaryBody text03>
SCIM synced
</Text>
)}
</div>
),
}),
tc.column("updated_at", {
header: "Last Updated",
weight: 14,
minWidth: 100,
cell: (value) => (
<Text as="span" secondaryBody text03>
{timeAgo(value) ?? "—"}
</Text>
),
}),
tc.actions(),
];
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const PAGE_SIZE = 10;
export default function UsersTable() {
const [searchTerm, setSearchTerm] = useState("");
const [sorting, setSorting] = useState<SortingState>([]);
const [pageIndex, setPageIndex] = useState(0);
const { users, totalItems, isLoading } = useAdminUsers({
pageIndex,
pageSize: PAGE_SIZE,
searchTerm: searchTerm || undefined,
});
return (
<div className="flex flex-col gap-3">
<InputTypeIn
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setPageIndex(0);
}}
placeholder="Search users..."
leftSearchIcon
/>
<DataTable
data={users}
columns={columns}
getRowId={(row) => row.id}
pageSize={PAGE_SIZE}
searchTerm={searchTerm}
footer={{ mode: "summary" }}
serverSide={{
totalItems,
isLoading,
onSortingChange: setSorting,
onPaginationChange: (idx) => {
setPageIndex(idx);
},
onSearchTermChange: () => {
// search state managed via searchTerm prop above
},
}}
/>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import type { UserRole } from "@/lib/types";
export interface UserGroupInfo {
id: number;
name: string;
}
export interface UserRow {
id: string;
email: string;
role: UserRole;
is_active: boolean;
is_scim_synced: boolean;
personal_name: string | null;
created_at: string;
updated_at: string;
groups: UserGroupInfo[];
}
export interface PaginatedUsersResponse {
items: UserRow[];
total_items: number;
}
export type StatusFilter = "all" | "active" | "inactive";

View File

@@ -32,6 +32,7 @@ import Separator from "@/refresh-components/Separator";
import Text from "@/refresh-components/texts/Text";
import Tabs from "@/refresh-components/Tabs";
import { cn } from "@/lib/utils";
import { ScopedMutator } from "swr";
export const BEDROCK_PROVIDER_NAME = "bedrock";
const BEDROCK_DISPLAY_NAME = "AWS Bedrock";
@@ -83,7 +84,7 @@ interface BedrockModalInternalsProps {
modelConfigurations: ModelConfiguration[];
isTesting: boolean;
testError: string;
mutate: (key: string) => void;
mutate: ScopedMutator;
onClose: () => void;
}

View File

@@ -164,6 +164,18 @@ describe("Custom LLM Provider Configuration Workflow", () => {
// Verify SWR cache was invalidated
expect(mockMutate).toHaveBeenCalledWith("/api/admin/llm/provider");
expect(mockMutate).toHaveBeenCalledWith("/api/llm/provider");
const personaProvidersMutateCall = mockMutate.mock.calls.find(
([key]) => typeof key === "function"
);
expect(personaProvidersMutateCall).toBeDefined();
const personaProviderFilter = personaProvidersMutateCall?.[0] as (
key: unknown
) => boolean;
expect(personaProviderFilter("/api/llm/persona/42/providers")).toBe(true);
expect(personaProviderFilter("/api/llm/provider")).toBe(false);
});
test("shows error when test configuration fails", async () => {

View File

@@ -27,6 +27,7 @@ import { DisplayModels } from "./components/DisplayModels";
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchModels } from "@/app/admin/configuration/llm/utils";
import debounce from "lodash/debounce";
import { ScopedMutator } from "swr";
const DEFAULT_API_BASE = "http://localhost:1234";
@@ -46,7 +47,7 @@ interface LMStudioFormContentProps {
setHasFetched: (value: boolean) => void;
isTesting: boolean;
testError: string;
mutate: () => void;
mutate: ScopedMutator;
onClose: () => void;
isFormValid: boolean;
}

View File

@@ -26,6 +26,7 @@ import { DisplayModels } from "./components/DisplayModels";
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchOllamaModels } from "@/app/admin/configuration/llm/utils";
import debounce from "lodash/debounce";
import { ScopedMutator } from "swr";
export const OLLAMA_PROVIDER_NAME = "ollama_chat";
const DEFAULT_API_BASE = "http://127.0.0.1:11434";
@@ -44,7 +45,7 @@ interface OllamaModalContentProps {
setFetchedModels: (models: ModelConfiguration[]) => void;
isTesting: boolean;
testError: string;
mutate: () => void;
mutate: ScopedMutator;
onClose: () => void;
isFormValid: boolean;
}

View File

@@ -4,14 +4,15 @@ import Button from "@/refresh-components/buttons/Button";
import { Button as OpalButton } from "@opal/components";
import { SvgTrash } from "@opal/icons";
import { LLMProviderView } from "@/interfaces/llm";
import { LLM_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import { refreshLlmProviderCaches } from "@/lib/llmConfig/cache";
import { deleteLlmProvider } from "@/lib/llmConfig/svc";
import { ScopedMutator } from "swr";
interface FormActionButtonsProps {
isTesting: boolean;
testError: string;
existingLlmProvider?: LLMProviderView;
mutate: (key: string) => void;
mutate: ScopedMutator;
onClose: () => void;
isFormValid: boolean;
}
@@ -29,7 +30,7 @@ export function FormActionButtons({
try {
await deleteLlmProvider(existingLlmProvider.id);
mutate(LLM_PROVIDERS_ADMIN_URL);
await refreshLlmProviderCaches(mutate);
onClose();
} catch (e) {
const message = e instanceof Error ? e.message : "Unknown error";

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, ReactNode } from "react";
import useSWR, { useSWRConfig, KeyedMutator } from "swr";
import useSWR, { useSWRConfig, ScopedMutator } from "swr";
import { toast } from "@/hooks/useToast";
import {
LLMProviderView,
@@ -14,12 +14,12 @@ import { Button } from "@opal/components";
import { SvgSettings } from "@opal/icons";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { LLM_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import { refreshLlmProviderCaches } from "@/lib/llmConfig/cache";
import { setDefaultLlmModel } from "@/lib/llmConfig/svc";
export interface ProviderFormContext {
onClose: () => void;
mutate: KeyedMutator<any>;
mutate: ScopedMutator;
isTesting: boolean;
setIsTesting: (testing: boolean) => void;
testError: string;
@@ -95,7 +95,7 @@ export function ProviderFormEntrypointWrapper({
try {
await setDefaultLlmModel(existingLlmProvider.id, firstVisibleModel.name);
await mutate(LLM_PROVIDERS_ADMIN_URL);
await refreshLlmProviderCaches(mutate);
toast.success("Provider set as default successfully!");
} catch (e) {
const message = e instanceof Error ? e.message : "Unknown error";

View File

@@ -7,9 +7,11 @@ import {
LLM_ADMIN_URL,
LLM_PROVIDERS_ADMIN_URL,
} from "@/lib/llmConfig/constants";
import { refreshLlmProviderCaches } from "@/lib/llmConfig/cache";
import { toast } from "@/hooks/useToast";
import * as Yup from "yup";
import isEqual from "lodash/isEqual";
import { ScopedMutator } from "swr";
// Common class names for the Form component across all LLM provider forms
export const LLM_FORM_CLASS_NAME = "flex flex-col gap-y-4 items-stretch mt-6";
@@ -105,7 +107,7 @@ export interface SubmitLLMProviderParams<
hideSuccess?: boolean;
setIsTesting: (testing: boolean) => void;
setTestError: (error: string) => void;
mutate: (key: string) => void;
mutate: ScopedMutator;
onClose: () => void;
setSubmitting: (submitting: boolean) => void;
}
@@ -287,7 +289,7 @@ export const submitLLMProvider = async <T extends BaseLLMFormValues>({
}
}
mutate(LLM_PROVIDERS_ADMIN_URL);
await refreshLlmProviderCaches(mutate);
onClose();
if (!hideSuccess) {

View File

@@ -10,7 +10,6 @@ import {
createMockOnboardingActions,
createMockFetchResponses,
MOCK_PROVIDERS,
ANTHROPIC_DEFAULT_VISIBLE_MODELS,
} from "./testHelpers";
// Mock fetch
@@ -51,8 +50,6 @@ jest.mock("@/components/modals/ProviderModal", () => ({
},
}));
// Mock fetchModels utility - returns the curated Anthropic visible models
// that match ANTHROPIC_VISIBLE_MODEL_NAMES from backend
const mockFetchModels = jest.fn().mockResolvedValue({
models: [
{
@@ -152,71 +149,6 @@ describe("AnthropicOnboardingForm", () => {
});
});
describe("Default Available Models", () => {
/**
* This test verifies that the exact curated list of Anthropic visible models
* matches what's returned from /api/admin/llm/built-in/options.
* The expected models are defined in ANTHROPIC_VISIBLE_MODEL_NAMES in
* backend/onyx/llm/llm_provider_options.py
*/
test("llmDescriptor contains the correct default visible models from built-in options", () => {
const expectedModelNames = [
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-haiku-4-5",
];
// Verify MOCK_PROVIDERS.anthropic has the correct model configurations
const actualModelNames = MOCK_PROVIDERS.anthropic.known_models.map(
(config) => config.name
);
// Check that all expected models are present
expect(actualModelNames).toEqual(
expect.arrayContaining(expectedModelNames)
);
// Check that only the expected models are present (no extras)
expect(actualModelNames).toHaveLength(expectedModelNames.length);
// Verify each model has is_visible set to true
MOCK_PROVIDERS.anthropic.known_models.forEach((config) => {
expect(config.is_visible).toBe(true);
});
});
test("ANTHROPIC_DEFAULT_VISIBLE_MODELS matches backend ANTHROPIC_VISIBLE_MODEL_NAMES", () => {
// These are the exact model names from backend/onyx/llm/llm_provider_options.py
// ANTHROPIC_VISIBLE_MODEL_NAMES = {"claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"}
const backendVisibleModelNames = new Set([
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-haiku-4-5",
]);
const testHelperModelNames = new Set(
ANTHROPIC_DEFAULT_VISIBLE_MODELS.map((m) => m.name)
);
expect(testHelperModelNames).toEqual(backendVisibleModelNames);
});
test("all default models are marked as visible", () => {
ANTHROPIC_DEFAULT_VISIBLE_MODELS.forEach((model) => {
expect(model.is_visible).toBe(true);
});
});
test("default model claude-sonnet-4-5 is set correctly in component", () => {
// The AnthropicOnboardingForm sets DEFAULT_DEFAULT_MODEL_NAME = "claude-sonnet-4-5"
// Verify this model exists in the default visible models
const defaultModelExists = ANTHROPIC_DEFAULT_VISIBLE_MODELS.some(
(m) => m.name === "claude-sonnet-4-5"
);
expect(defaultModelExists).toBe(true);
});
});
describe("Form Validation", () => {
test("submit button is disabled when form is empty", () => {
render(<AnthropicOnboardingForm {...defaultProps} />);

View File

@@ -10,7 +10,6 @@ import {
createMockOnboardingActions,
createMockFetchResponses,
MOCK_PROVIDERS,
OPENAI_DEFAULT_VISIBLE_MODELS,
} from "./testHelpers";
// Mock fetch
@@ -54,8 +53,6 @@ jest.mock("@/components/modals/ProviderModal", () => ({
},
}));
// Mock fetchModels utility - returns the curated OpenAI visible models
// that match OPENAI_VISIBLE_MODEL_NAMES from backend
const mockFetchModels = jest.fn().mockResolvedValue({
models: [
{
@@ -173,77 +170,6 @@ describe("OpenAIOnboardingForm", () => {
});
});
describe("Default Available Models", () => {
/**
* This test verifies that the exact curated list of OpenAI visible models
* matches what's returned from /api/admin/llm/built-in/options.
* The expected models are defined in OPENAI_VISIBLE_MODEL_NAMES in
* backend/onyx/llm/llm_provider_options.py
*/
test("llmDescriptor contains the correct default visible models from built-in options", () => {
const expectedModelNames = [
"gpt-5.2",
"gpt-5-mini",
"o1",
"o3-mini",
"gpt-4o",
"gpt-4o-mini",
];
// Verify MOCK_PROVIDERS.openai has the correct model configurations
const actualModelNames = MOCK_PROVIDERS.openai.known_models.map(
(config) => config.name
);
// Check that all expected models are present
expect(actualModelNames).toEqual(
expect.arrayContaining(expectedModelNames)
);
// Check that only the expected models are present (no extras)
expect(actualModelNames).toHaveLength(expectedModelNames.length);
// Verify each model has is_visible set to true
MOCK_PROVIDERS.openai.known_models.forEach((config) => {
expect(config.is_visible).toBe(true);
});
});
test("OPENAI_DEFAULT_VISIBLE_MODELS matches backend OPENAI_VISIBLE_MODEL_NAMES", () => {
// These are the exact model names from backend/onyx/llm/llm_provider_options.py
// OPENAI_VISIBLE_MODEL_NAMES = {"gpt-5.2", "gpt-5-mini", "o1", "o3-mini", "gpt-4o", "gpt-4o-mini"}
const backendVisibleModelNames = new Set([
"gpt-5.2",
"gpt-5-mini",
"o1",
"o3-mini",
"gpt-4o",
"gpt-4o-mini",
]);
const testHelperModelNames = new Set(
OPENAI_DEFAULT_VISIBLE_MODELS.map((m) => m.name)
);
expect(testHelperModelNames).toEqual(backendVisibleModelNames);
});
test("all default models are marked as visible", () => {
OPENAI_DEFAULT_VISIBLE_MODELS.forEach((model) => {
expect(model.is_visible).toBe(true);
});
});
test("default model gpt-5.2 is set correctly in component", () => {
// The OpenAIOnboardingForm sets DEFAULT_DEFAULT_MODEL_NAME = "gpt-5.2"
// Verify this model exists in the default visible models
const defaultModelExists = OPENAI_DEFAULT_VISIBLE_MODELS.some(
(m) => m.name === "gpt-5.2"
);
expect(defaultModelExists).toBe(true);
});
});
describe("Form Validation", () => {
test("submit button is disabled when form is empty", () => {
render(<OpenAIOnboardingForm {...defaultProps} />);

View File

@@ -10,7 +10,6 @@ import {
createMockOnboardingActions,
createMockFetchResponses,
MOCK_PROVIDERS,
VERTEXAI_DEFAULT_VISIBLE_MODELS,
} from "./testHelpers";
// Mock fetch
@@ -51,8 +50,6 @@ jest.mock("@/components/modals/ProviderModal", () => ({
},
}));
// Mock fetchModels utility - returns the curated Vertex AI visible models
// that match VERTEXAI_VISIBLE_MODEL_NAMES from backend
jest.mock("@/app/admin/configuration/llm/utils", () => ({
canProviderFetchModels: jest.fn().mockReturnValue(true),
fetchModels: jest.fn().mockResolvedValue({
@@ -154,71 +151,6 @@ describe("VertexAIOnboardingForm", () => {
});
});
describe("Default Available Models", () => {
/**
* This test verifies that the exact curated list of Vertex AI visible models
* matches what's returned from /api/admin/llm/built-in/options.
* The expected models are defined in VERTEXAI_VISIBLE_MODEL_NAMES in
* backend/onyx/llm/llm_provider_options.py
*/
test("llmDescriptor contains the correct default visible models from built-in options", () => {
const expectedModelNames = [
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.5-pro",
];
// Verify MOCK_PROVIDERS.vertexAi has the correct model configurations
const actualModelNames = MOCK_PROVIDERS.vertexAi.known_models.map(
(config) => config.name
);
// Check that all expected models are present
expect(actualModelNames).toEqual(
expect.arrayContaining(expectedModelNames)
);
// Check that only the expected models are present (no extras)
expect(actualModelNames).toHaveLength(expectedModelNames.length);
// Verify each model has is_visible set to true
MOCK_PROVIDERS.vertexAi.known_models.forEach((config) => {
expect(config.is_visible).toBe(true);
});
});
test("VERTEXAI_DEFAULT_VISIBLE_MODELS matches backend VERTEXAI_VISIBLE_MODEL_NAMES", () => {
// These are the exact model names from backend/onyx/llm/llm_provider_options.py
// VERTEXAI_VISIBLE_MODEL_NAMES = {"gemini-2.5-flash", "gemini-2.5-flash-lite", "gemini-2.5-pro"}
const backendVisibleModelNames = new Set([
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.5-pro",
]);
const testHelperModelNames = new Set(
VERTEXAI_DEFAULT_VISIBLE_MODELS.map((m) => m.name)
);
expect(testHelperModelNames).toEqual(backendVisibleModelNames);
});
test("all default models are marked as visible", () => {
VERTEXAI_DEFAULT_VISIBLE_MODELS.forEach((model) => {
expect(model.is_visible).toBe(true);
});
});
test("default model gemini-2.5-pro is set correctly in component", () => {
// The VertexAIOnboardingForm sets DEFAULT_DEFAULT_MODEL_NAME = "gemini-2.5-pro"
// Verify this model exists in the default visible models
const defaultModelExists = VERTEXAI_DEFAULT_VISIBLE_MODELS.some(
(m) => m.name === "gemini-2.5-pro"
);
expect(defaultModelExists).toBe(true);
});
});
describe("Form Validation", () => {
test("submit button is disabled when form is empty", () => {
render(<VertexAIOnboardingForm {...defaultProps} />);

View File

@@ -1,7 +1,6 @@
/**
* Shared test helpers and mocks for onboarding form tests
*/
import React from "react";
// Mock Element.prototype.scrollIntoView for JSDOM (not implemented in jsdom)
Element.prototype.scrollIntoView = jest.fn();
@@ -161,11 +160,6 @@ export async function waitForModalOpen(screen: any, waitFor: any) {
/**
* Common provider descriptors for testing
*/
/**
* The curated list of OpenAI visible models that are returned by
* /api/admin/llm/built-in/options. This must match OPENAI_VISIBLE_MODEL_NAMES
* in backend/onyx/llm/llm_provider_options.py
*/
export const OPENAI_DEFAULT_VISIBLE_MODELS = [
{
name: "gpt-5.2",
@@ -211,11 +205,6 @@ export const OPENAI_DEFAULT_VISIBLE_MODELS = [
},
];
/**
* The curated list of Anthropic visible models that are returned by
* /api/admin/llm/built-in/options. This must match ANTHROPIC_VISIBLE_MODEL_NAMES
* in backend/onyx/llm/llm_provider_options.py
*/
export const ANTHROPIC_DEFAULT_VISIBLE_MODELS = [
{
name: "claude-opus-4-5",
@@ -240,11 +229,6 @@ export const ANTHROPIC_DEFAULT_VISIBLE_MODELS = [
},
];
/**
* The curated list of Vertex AI visible models that are returned by
* /api/admin/llm/built-in/options. This must match VERTEXAI_VISIBLE_MODEL_NAMES
* in backend/onyx/llm/llm_provider_options.py
*/
export const VERTEXAI_DEFAULT_VISIBLE_MODELS = [
{
name: "gemini-2.5-flash",

View File

@@ -127,14 +127,13 @@ const collections = (
sidebarItem(ADMIN_PATHS.TOKEN_RATE_LIMITS),
],
},
...(enableEnterprise
? [
{
name: "Permissions",
items: [sidebarItem(ADMIN_PATHS.SCIM)],
},
]
: []),
{
name: "Permissions",
items: [
sidebarItem(ADMIN_PATHS.USERS_V2),
...(enableEnterprise ? [sidebarItem(ADMIN_PATHS.SCIM)] : []),
],
},
...(enableEnterprise
? [
{

View File

@@ -292,7 +292,10 @@ test.describe("Assistant Creation and Edit Verification", () => {
expect(agentIdMatch).toBeTruthy();
const agentId = agentIdMatch ? agentIdMatch[1] : null;
expect(agentId).not.toBeNull();
await expectScreenshot(page, { name: "welcome-page-with-assistant" });
await expectScreenshot(page, {
name: "welcome-page-with-assistant",
hide: ["[data-testid='AppInputBar/llm-popover-trigger']"],
});
// Store assistant ID for cleanup
knowledgeAssistantId = Number(agentId);