Compare commits

..

4 Commits

Author SHA1 Message Date
Dane Urban
d7fdbaf772 Make better 2026-03-06 11:22:38 -08:00
Dane Urban
ddb2e069ed . 2026-03-06 11:22:38 -08:00
Dane Urban
4b092cf252 . 2026-03-06 11:22:38 -08:00
Dane Urban
783c8a6481 Prevent the removal and hiding of default model 2026-03-06 11:22:38 -08:00
48 changed files with 1678 additions and 1891 deletions

View File

@@ -1,40 +0,0 @@
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

@@ -270,10 +270,34 @@ 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)
@@ -538,7 +562,6 @@ 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

@@ -1512,10 +1512,6 @@
"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",
@@ -1530,10 +1526,6 @@
"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,8 +1,37 @@
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"
@@ -22,6 +51,13 @@ 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"
@@ -29,6 +65,13 @@ 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,10 +16,6 @@
"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.3.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz",
"integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==",
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
@@ -7556,9 +7556,9 @@
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"

View File

@@ -65,6 +65,7 @@ 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
@@ -445,16 +446,17 @@ def put_llm_provider(
not existing_provider or not existing_provider.is_auto_mode
)
# 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
)
# 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
]
try:
result = upsert_llm_provider(
@@ -468,7 +470,6 @@ 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
)
@@ -478,20 +479,6 @@ 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

@@ -1152,3 +1152,179 @@ 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

@@ -0,0 +1,220 @@
"""
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 |
| `/clear` | Clear chat and start a new session |
| `/new` | Start a new chat 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,43 +116,3 @@ 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.

View File

@@ -1,43 +0,0 @@
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}

View File

@@ -1,11 +0,0 @@
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"

View File

@@ -1,39 +0,0 @@
[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.10.1"
"@tauri-apps/api": "^2.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.10.1"
"@tauri-apps/cli": "^2.0.0"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
@@ -25,9 +25,9 @@
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz",
"integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.6.tgz",
"integrity": "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==",
"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.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"
"@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"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"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==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.6.tgz",
"integrity": "sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==",
"cpu": [
"arm64"
],
@@ -72,9 +72,9 @@
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -89,9 +89,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"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==",
"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==",
"cpu": [
"arm"
],
@@ -106,9 +106,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -123,9 +123,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -140,9 +140,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"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==",
"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==",
"cpu": [
"riscv64"
],
@@ -157,9 +157,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -174,9 +174,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"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==",
"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==",
"cpu": [
"x64"
],
@@ -191,9 +191,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"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==",
"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==",
"cpu": [
"arm64"
],
@@ -208,9 +208,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"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==",
"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==",
"cpu": [
"ia32"
],
@@ -225,9 +225,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"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==",
"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==",
"cpu": [
"x64"
],

View File

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

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.5", features = [] }
tauri-build = { version = "2.0", features = [] }
[dependencies]
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"
tauri = { version = "2.0", features = ["macos-private-api", "tray-icon", "image-png"] }
tauri-plugin-shell = "2.0"
tauri-plugin-window-state = "2.0"
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.7.1"
window-vibrancy = "0.5"
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": "^30.2.0",
"jest-environment-jsdom": "^29.7.0",
"prettier": "3.1.0",
"stats.js": "^0.17.0",
"tailwindcss": "^3.4.17",

View File

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

View File

@@ -7,13 +7,15 @@ 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 }: FileDisplayProps) {
export default function FileDisplay({ files, alignBubble }: FileDisplayProps) {
const [close, setClose] = useState(true);
const [previewingFile, setPreviewingFile] = useState<FileDescriptor | null>(
null
@@ -41,47 +43,59 @@ export default function FileDisplay({ files }: FileDisplayProps) {
)}
{textFiles.length > 0 && (
<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
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>
)}
{imageFiles.length > 0 && (
<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
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>
)}
{csvFiles.length > 0 && (
<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 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}
/>
</>
) : (
<Attachment
open={() => setClose(true)}
fileName={file.name || file.id}
/>
)}
</div>
);
})}
)}
</div>
);
})}
</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 files={files || []} />
<FileDisplay alignBubble files={files || []} />
{isEditing ? (
<MessageEditing
content={content}

View File

@@ -22,6 +22,9 @@ 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(() => {
@@ -50,9 +53,9 @@ describe("Email/Password Login Workflow", () => {
const loginButton = screen.getByRole("button", { name: /sign in/i });
await user.click(loginButton);
// Verify success message is shown after login
// After successful login, user should be redirected to /chat
await waitFor(() => {
expect(screen.getByText(/signed in successfully\./i)).toBeInTheDocument();
expect(window.location.href).toBe("/app");
});
// Verify API was called with correct credentials
@@ -111,6 +114,9 @@ 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.6</strong> for Crafting.
We recommend using <strong>Claude Opus 4.5</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-6", label: "Claude Opus 4.6", recommended: true },
{ name: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ name: "claude-opus-4-5", label: "Claude Opus 4.5", recommended: true },
{ name: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" },
],
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-6"
modelName: string; // e.g., "claude-opus-4-5"
}
// Priority order for smart default LLM selection
const LLM_SELECTION_PRIORITY = [
{ provider: "anthropic", modelName: "claude-opus-4-6" },
{ provider: "anthropic", modelName: "claude-opus-4-5" },
{ 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-6",
displayName: "Claude Opus 4.6",
modelName: "claude-opus-4-5",
displayName: "Claude Opus 4.5",
},
alternatives: [
{ provider: "anthropic", modelName: "claude-sonnet-4-6" },
{ provider: "anthropic", modelName: "claude-sonnet-4-5" },
{ 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-6", label: "Claude Opus 4.6", recommended: true },
{ name: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ name: "claude-opus-4-5", label: "Claude Opus 4.5", recommended: true },
{ name: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" },
],
apiKeyPlaceholder: "sk-ant-...",
apiKeyUrl: "https://console.anthropic.com/dashboard",

View File

@@ -31,7 +31,6 @@ 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

@@ -1,34 +0,0 @@
"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

@@ -86,7 +86,7 @@ function SettingsRoot({ width = "md", ...props }: SettingsRootProps) {
return (
<div
id="page-wrapper-scroll-container"
className="w-full h-full flex flex-col items-center overflow-y-auto pt-10"
className="w-full h-full flex flex-col items-center overflow-y-auto"
>
{/* WARNING: The id="page-wrapper-scroll-container" above is used by SettingsHeader
to detect scroll position and show/hide the scroll shadow.

View File

@@ -58,7 +58,6 @@ 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",
@@ -191,11 +190,6 @@ 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

@@ -1,21 +0,0 @@
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,6 +1,5 @@
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,7 +20,6 @@ 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";
@@ -34,6 +33,7 @@ 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);
await refreshLlmProviderCaches(mutate);
mutate(LLM_PROVIDERS_ADMIN_URL);
deleteModal.toggle(false);
toast.success("Provider deleted successfully!");
} catch (e) {
@@ -345,7 +345,7 @@ export default function LLMConfigurationPage() {
try {
await setDefaultLlmModel(providerId, modelName);
await refreshLlmProviderCaches(mutate);
mutate(LLM_PROVIDERS_ADMIN_URL);
toast.success("Default model updated successfully!");
} catch (e) {
const message = e instanceof Error ? e.message : "Unknown error";

View File

@@ -1,58 +0,0 @@
"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";
// ---------------------------------------------------------------------------
// 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}
/>
{/* Table and filters will be added in subsequent PRs */}
</>
);
}
// ---------------------------------------------------------------------------
// 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

@@ -1,122 +0,0 @@
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, no truncation on label
// ---------------------------------------------------------------------------
interface StatCellProps {
value: number | null;
label: string;
}
function StatCell({ value, label }: StatCellProps) {
const display = value === null ? "—" : value.toLocaleString();
return (
<Section alignItems="start" gap={0.25} width="full" padding={0.5}>
<Text as="span" mainUiAction text04>
{display}
</Text>
<Text as="span" secondaryBody text03>
{label}
</Text>
</Section>
);
}
// ---------------------------------------------------------------------------
// 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) {
if (showScim) {
// With SCIM: one card containing stats + separate SCIM card
return (
<Section
flexDirection="row"
justifyContent="start"
alignItems="stretch"
gap={0.5}
>
<Card padding={0.5}>
<Section flexDirection="row" gap={0}>
<StatCell value={activeUsers} label="active users" />
<StatCell value={pendingInvites} label="pending invites" />
{requests !== null && (
<StatCell value={requests} label="requests to join" />
)}
</Section>
</Card>
<ScimCard />
</Section>
);
}
// Without SCIM: separate cards
return (
<Section
flexDirection="row"
justifyContent="start"
alignItems="stretch"
gap={0.5}
>
<Card padding={0.5}>
<StatCell value={activeUsers} label="active users" />
</Card>
<Card padding={0.5}>
<StatCell value={pendingInvites} label="pending invites" />
</Card>
{requests !== null && (
<Card padding={0.5}>
<StatCell value={requests} label="requests to join" />
</Card>
)}
</Section>
);
}

View File

@@ -32,7 +32,6 @@ 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";
@@ -84,7 +83,7 @@ interface BedrockModalInternalsProps {
modelConfigurations: ModelConfiguration[];
isTesting: boolean;
testError: string;
mutate: ScopedMutator;
mutate: (key: string) => void;
onClose: () => void;
}

View File

@@ -164,18 +164,6 @@ 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,7 +27,6 @@ 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";
@@ -47,7 +46,7 @@ interface LMStudioFormContentProps {
setHasFetched: (value: boolean) => void;
isTesting: boolean;
testError: string;
mutate: ScopedMutator;
mutate: () => void;
onClose: () => void;
isFormValid: boolean;
}

View File

@@ -26,7 +26,6 @@ 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";
@@ -45,7 +44,7 @@ interface OllamaModalContentProps {
setFetchedModels: (models: ModelConfiguration[]) => void;
isTesting: boolean;
testError: string;
mutate: ScopedMutator;
mutate: () => void;
onClose: () => void;
isFormValid: boolean;
}

View File

@@ -4,15 +4,14 @@ 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 { refreshLlmProviderCaches } from "@/lib/llmConfig/cache";
import { LLM_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import { deleteLlmProvider } from "@/lib/llmConfig/svc";
import { ScopedMutator } from "swr";
interface FormActionButtonsProps {
isTesting: boolean;
testError: string;
existingLlmProvider?: LLMProviderView;
mutate: ScopedMutator;
mutate: (key: string) => void;
onClose: () => void;
isFormValid: boolean;
}
@@ -30,7 +29,7 @@ export function FormActionButtons({
try {
await deleteLlmProvider(existingLlmProvider.id);
await refreshLlmProviderCaches(mutate);
mutate(LLM_PROVIDERS_ADMIN_URL);
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, ScopedMutator } from "swr";
import useSWR, { useSWRConfig, KeyedMutator } 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 { refreshLlmProviderCaches } from "@/lib/llmConfig/cache";
import { LLM_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import { setDefaultLlmModel } from "@/lib/llmConfig/svc";
export interface ProviderFormContext {
onClose: () => void;
mutate: ScopedMutator;
mutate: KeyedMutator<any>;
isTesting: boolean;
setIsTesting: (testing: boolean) => void;
testError: string;
@@ -95,7 +95,7 @@ export function ProviderFormEntrypointWrapper({
try {
await setDefaultLlmModel(existingLlmProvider.id, firstVisibleModel.name);
await refreshLlmProviderCaches(mutate);
await mutate(LLM_PROVIDERS_ADMIN_URL);
toast.success("Provider set as default successfully!");
} catch (e) {
const message = e instanceof Error ? e.message : "Unknown error";

View File

@@ -7,11 +7,9 @@ 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";
@@ -107,7 +105,7 @@ export interface SubmitLLMProviderParams<
hideSuccess?: boolean;
setIsTesting: (testing: boolean) => void;
setTestError: (error: string) => void;
mutate: ScopedMutator;
mutate: (key: string) => void;
onClose: () => void;
setSubmitting: (submitting: boolean) => void;
}
@@ -289,7 +287,7 @@ export const submitLLMProvider = async <T extends BaseLLMFormValues>({
}
}
await refreshLlmProviderCaches(mutate);
mutate(LLM_PROVIDERS_ADMIN_URL);
onClose();
if (!hideSuccess) {

View File

@@ -10,6 +10,7 @@ import {
createMockOnboardingActions,
createMockFetchResponses,
MOCK_PROVIDERS,
ANTHROPIC_DEFAULT_VISIBLE_MODELS,
} from "./testHelpers";
// Mock fetch
@@ -50,6 +51,8 @@ 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: [
{
@@ -149,6 +152,71 @@ 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,6 +10,7 @@ import {
createMockOnboardingActions,
createMockFetchResponses,
MOCK_PROVIDERS,
OPENAI_DEFAULT_VISIBLE_MODELS,
} from "./testHelpers";
// Mock fetch
@@ -53,6 +54,8 @@ 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: [
{
@@ -170,6 +173,77 @@ 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,6 +10,7 @@ import {
createMockOnboardingActions,
createMockFetchResponses,
MOCK_PROVIDERS,
VERTEXAI_DEFAULT_VISIBLE_MODELS,
} from "./testHelpers";
// Mock fetch
@@ -50,6 +51,8 @@ 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({
@@ -151,6 +154,71 @@ 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,6 +1,7 @@
/**
* 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();
@@ -160,6 +161,11 @@ 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",
@@ -205,6 +211,11 @@ 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",
@@ -229,6 +240,11 @@ 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

@@ -122,8 +122,6 @@ const collections = (
name: "User Management",
items: [
sidebarItem(ADMIN_PATHS.USERS),
// TODO: Uncomment once Users v2 page is complete
sidebarItem(ADMIN_PATHS.USERS_V2),
...(enableEnterprise ? [sidebarItem(ADMIN_PATHS.GROUPS)] : []),
sidebarItem(ADMIN_PATHS.API_KEYS),
sidebarItem(ADMIN_PATHS.TOKEN_RATE_LIMITS),

View File

@@ -292,10 +292,7 @@ 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",
hide: ["[data-testid='AppInputBar/llm-popover-trigger']"],
});
await expectScreenshot(page, { name: "welcome-page-with-assistant" });
// Store assistant ID for cleanup
knowledgeAssistantId = Number(agentId);