Compare commits

..

15 Commits

Author SHA1 Message Date
Dane Urban
2036847a2d Lookup by name 2026-02-27 12:40:18 -08:00
Dane Urban
9ff1ac862e . 2026-02-27 12:35:38 -08:00
Dane Urban
e4179629ae Test 2026-02-27 10:29:10 -08:00
Dane Urban
4077c20def Add python tool to default persona 2026-02-27 10:14:53 -08:00
Yuhong Sun
4d256c5666 chore: remove instance of Assistant from frontend (#8848)
Co-authored-by: Nik <nikolas.garza5@gmail.com>
2026-02-27 04:22:28 +00:00
Danelegend
2e53496f46 feat: Code interpreter admin page visuals (#8729) 2026-02-27 04:01:02 +00:00
acaprau
63a206706a docs(best practices): Add comment about import-time side effects and main.py files (#8820)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-02-27 01:29:56 +00:00
Nikolas Garza
28427b3e5f fix(metrics): restore default HTTP request counter and histogram metrics (#8842) 2026-02-27 00:53:22 +00:00
Justin Tahara
3cafcd8a5e chore(llm): add OpenRouter nightly tests (#8818) 2026-02-26 23:54:25 +00:00
Justin Tahara
f2c50b7bb5 chore(llm): add Ollama nightly tests (#8817) 2026-02-26 23:28:40 +00:00
Jamison Lahman
6b28c6bbfc fix(fe): Search Actions popover has consistent hover states (#8826) 2026-02-26 23:16:09 +00:00
Justin Tahara
226e801665 chore(llm): add Azure nightly tests (#8816) 2026-02-26 23:05:03 +00:00
Justin Tahara
be13aa1310 chore(llm): add Vertex AI nightly tests (#8813) 2026-02-26 22:38:05 +00:00
Nikolas Garza
45d38c4906 feat(metrics): add per-tenant Prometheus metrics (#8822) 2026-02-26 22:37:35 +00:00
Danelegend
8aab518532 fix: Admin page modal centering excludes sidebar (#8823) 2026-02-26 22:27:58 +00:00
43 changed files with 1091 additions and 864 deletions

View File

@@ -18,6 +18,14 @@ inputs:
description: "Optional NIGHTLY_LLM_API_BASE"
required: false
default: ""
api-version:
description: "Optional NIGHTLY_LLM_API_VERSION"
required: false
default: ""
deployment-name:
description: "Optional NIGHTLY_LLM_DEPLOYMENT_NAME"
required: false
default: ""
custom-config-json:
description: "Optional NIGHTLY_LLM_CUSTOM_CONFIG_JSON"
required: false
@@ -84,6 +92,8 @@ runs:
NIGHTLY_LLM_PROVIDER: ${{ inputs.provider }}
NIGHTLY_LLM_API_KEY: ${{ inputs.provider-api-key }}
NIGHTLY_LLM_API_BASE: ${{ inputs.api-base }}
NIGHTLY_LLM_API_VERSION: ${{ inputs.api-version }}
NIGHTLY_LLM_DEPLOYMENT_NAME: ${{ inputs.deployment-name }}
NIGHTLY_LLM_CUSTOM_CONFIG_JSON: ${{ inputs.custom-config-json }}
NIGHTLY_LLM_STRICT: ${{ inputs.strict }}
RUNS_ON_ECR_CACHE: ${{ inputs.runs-on-ecr-cache }}
@@ -112,6 +122,8 @@ runs:
-e NIGHTLY_LLM_MODELS="${MODELS}" \
-e NIGHTLY_LLM_API_KEY="${NIGHTLY_LLM_API_KEY}" \
-e NIGHTLY_LLM_API_BASE="${NIGHTLY_LLM_API_BASE}" \
-e NIGHTLY_LLM_API_VERSION="${NIGHTLY_LLM_API_VERSION}" \
-e NIGHTLY_LLM_DEPLOYMENT_NAME="${NIGHTLY_LLM_DEPLOYMENT_NAME}" \
-e NIGHTLY_LLM_CUSTOM_CONFIG_JSON="${NIGHTLY_LLM_CUSTOM_CONFIG_JSON}" \
-e NIGHTLY_LLM_STRICT="${NIGHTLY_LLM_STRICT}" \
${RUNS_ON_ECR_CACHE}:nightly-llm-it-${RUN_ID} \

View File

@@ -19,11 +19,20 @@ jobs:
openai_models: ${{ vars.NIGHTLY_LLM_OPENAI_MODELS }}
anthropic_models: ${{ vars.NIGHTLY_LLM_ANTHROPIC_MODELS }}
bedrock_models: ${{ vars.NIGHTLY_LLM_BEDROCK_MODELS }}
vertex_ai_models: ${{ vars.NIGHTLY_LLM_VERTEX_AI_MODELS }}
azure_models: ${{ vars.NIGHTLY_LLM_AZURE_MODELS }}
azure_api_base: ${{ vars.NIGHTLY_LLM_AZURE_API_BASE }}
ollama_models: ${{ vars.NIGHTLY_LLM_OLLAMA_MODELS }}
openrouter_models: ${{ vars.NIGHTLY_LLM_OPENROUTER_MODELS }}
strict: true
secrets:
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
bedrock_api_key: ${{ secrets.BEDROCK_API_KEY }}
vertex_ai_custom_config_json: ${{ secrets.NIGHTLY_LLM_VERTEX_AI_CUSTOM_CONFIG_JSON }}
azure_api_key: ${{ secrets.AZURE_API_KEY }}
ollama_api_key: ${{ secrets.OLLAMA_API_KEY }}
openrouter_api_key: ${{ secrets.OPENROUTER_API_KEY }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}

View File

@@ -18,6 +18,31 @@ on:
required: false
default: ""
type: string
vertex_ai_models:
description: "Comma-separated models for vertex_ai"
required: false
default: ""
type: string
azure_models:
description: "Comma-separated models for azure"
required: false
default: ""
type: string
ollama_models:
description: "Comma-separated models for ollama_chat"
required: false
default: ""
type: string
openrouter_models:
description: "Comma-separated models for openrouter"
required: false
default: ""
type: string
azure_api_base:
description: "API base for azure provider"
required: false
default: ""
type: string
strict:
description: "Default NIGHTLY_LLM_STRICT passed to tests"
required: false
@@ -30,6 +55,14 @@ on:
required: false
bedrock_api_key:
required: false
vertex_ai_custom_config_json:
required: false
azure_api_key:
required: false
ollama_api_key:
required: false
openrouter_api_key:
required: false
DOCKER_USERNAME:
required: true
DOCKER_TOKEN:
@@ -138,12 +171,59 @@ jobs:
- provider: openai
models: ${{ inputs.openai_models }}
api_key_secret: openai_api_key
custom_config_secret: ""
api_base: ""
api_version: ""
deployment_name: ""
required: true
- provider: anthropic
models: ${{ inputs.anthropic_models }}
api_key_secret: anthropic_api_key
custom_config_secret: ""
api_base: ""
api_version: ""
deployment_name: ""
required: true
- provider: bedrock
models: ${{ inputs.bedrock_models }}
api_key_secret: bedrock_api_key
custom_config_secret: ""
api_base: ""
api_version: ""
deployment_name: ""
required: false
- provider: vertex_ai
models: ${{ inputs.vertex_ai_models }}
api_key_secret: ""
custom_config_secret: vertex_ai_custom_config_json
api_base: ""
api_version: ""
deployment_name: ""
required: false
- provider: azure
models: ${{ inputs.azure_models }}
api_key_secret: azure_api_key
custom_config_secret: ""
api_base: ${{ inputs.azure_api_base }}
api_version: "2025-04-01-preview"
deployment_name: ""
required: false
- provider: ollama_chat
models: ${{ inputs.ollama_models }}
api_key_secret: ollama_api_key
custom_config_secret: ""
api_base: "https://ollama.com"
api_version: ""
deployment_name: ""
required: false
- provider: openrouter
models: ${{ inputs.openrouter_models }}
api_key_secret: openrouter_api_key
custom_config_secret: ""
api_base: "https://openrouter.ai/api/v1"
api_version: ""
deployment_name: ""
required: false
runs-on:
- runs-on
- runner=4cpu-linux-arm64
@@ -165,6 +245,10 @@ jobs:
models: ${{ matrix.models }}
provider-api-key: ${{ matrix.api_key_secret && secrets[matrix.api_key_secret] || '' }}
strict: ${{ inputs.strict && 'true' || 'false' }}
api-base: ${{ matrix.api_base }}
api-version: ${{ matrix.api_version }}
deployment-name: ${{ matrix.deployment_name }}
custom-config-json: ${{ matrix.custom_config_secret && secrets[matrix.custom_config_secret] || '' }}
runs-on-ecr-cache: ${{ env.RUNS_ON_ECR_CACHE }}
run-id: ${{ github.run_id }}
docker-username: ${{ secrets.DOCKER_USERNAME }}

View File

@@ -0,0 +1,69 @@
"""add python tool on default
Revision ID: 57122d037335
Revises: c0c937d5c9e5
Create Date: 2026-02-27 10:10:40.124925
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "57122d037335"
down_revision = "c0c937d5c9e5"
branch_labels = None
depends_on = None
PYTHON_TOOL_NAME = "python"
def upgrade() -> None:
conn = op.get_bind()
# Look up the PythonTool id
result = conn.execute(
sa.text("SELECT id FROM tool WHERE name = :name"),
{"name": PYTHON_TOOL_NAME},
).fetchone()
if not result:
return
tool_id = result[0]
# Attach to the default persona (id=0) if not already attached
conn.execute(
sa.text(
"""
INSERT INTO persona__tool (persona_id, tool_id)
VALUES (0, :tool_id)
ON CONFLICT DO NOTHING
"""
),
{"tool_id": tool_id},
)
def downgrade() -> None:
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT id FROM tool WHERE name = :name"),
{"name": PYTHON_TOOL_NAME},
).fetchone()
if not result:
return
conn.execute(
sa.text(
"""
DELETE FROM persona__tool
WHERE persona_id = 0 AND tool_id = :tool_id
"""
),
{"tool_id": result[0]},
)

View File

@@ -0,0 +1,27 @@
"""Per-tenant request counter metric.
Increments a counter on every request, labelled by tenant, so Grafana can
answer "which tenant is generating the most traffic?"
"""
from prometheus_client import Counter
from prometheus_fastapi_instrumentator.metrics import Info
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
_requests_by_tenant = Counter(
"onyx_api_requests_by_tenant_total",
"Total API requests by tenant",
["tenant_id", "method", "handler", "status"],
)
def per_tenant_request_callback(info: Info) -> None:
"""Increment per-tenant request counter for every request."""
tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get() or "unknown"
_requests_by_tenant.labels(
tenant_id=tenant_id,
method=info.method,
handler=info.modified_handler,
status=info.modified_status,
).inc()

View File

@@ -32,6 +32,7 @@ from sqlalchemy.pool import QueuePool
from onyx.utils.logger import setup_logger
from shared_configs.contextvars import CURRENT_ENDPOINT_CONTEXTVAR
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
logger = setup_logger()
@@ -72,7 +73,7 @@ _checkout_timeout_total = Counter(
_connections_held = Gauge(
"onyx_db_connections_held_by_endpoint",
"Number of DB connections currently held, by endpoint and engine",
["handler", "engine"],
["handler", "engine", "tenant_id"],
)
_hold_seconds = Histogram(
@@ -163,10 +164,14 @@ def _register_pool_events(engine: Engine, label: str) -> None:
conn_proxy: PoolProxiedConnection, # noqa: ARG001
) -> None:
handler = CURRENT_ENDPOINT_CONTEXTVAR.get() or "unknown"
tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get() or "unknown"
conn_record.info["_metrics_endpoint"] = handler
conn_record.info["_metrics_tenant_id"] = tenant_id
conn_record.info["_metrics_checkout_time"] = time.monotonic()
_checkout_total.labels(engine=label).inc()
_connections_held.labels(handler=handler, engine=label).inc()
_connections_held.labels(
handler=handler, engine=label, tenant_id=tenant_id
).inc()
@event.listens_for(engine, "checkin")
def on_checkin(
@@ -174,9 +179,12 @@ def _register_pool_events(engine: Engine, label: str) -> None:
conn_record: ConnectionPoolEntry,
) -> None:
handler = conn_record.info.pop("_metrics_endpoint", "unknown")
tenant_id = conn_record.info.pop("_metrics_tenant_id", "unknown")
start = conn_record.info.pop("_metrics_checkout_time", None)
_checkin_total.labels(engine=label).inc()
_connections_held.labels(handler=handler, engine=label).dec()
_connections_held.labels(
handler=handler, engine=label, tenant_id=tenant_id
).dec()
if start is not None:
_hold_seconds.labels(handler=handler, engine=label).observe(
time.monotonic() - start
@@ -199,9 +207,12 @@ def _register_pool_events(engine: Engine, label: str) -> None:
# Defensively clean up the held-connections gauge in case checkin
# doesn't fire after invalidation (e.g. hard pool shutdown).
handler = conn_record.info.pop("_metrics_endpoint", None)
tenant_id = conn_record.info.pop("_metrics_tenant_id", "unknown")
start = conn_record.info.pop("_metrics_checkout_time", None)
if handler:
_connections_held.labels(handler=handler, engine=label).dec()
_connections_held.labels(
handler=handler, engine=label, tenant_id=tenant_id
).dec()
if start is not None:
_hold_seconds.labels(handler=handler or "unknown", engine=label).observe(
time.monotonic() - start

View File

@@ -11,9 +11,11 @@ SQLAlchemy connection pool metrics are registered separately via
"""
from prometheus_fastapi_instrumentator import Instrumentator
from prometheus_fastapi_instrumentator.metrics import default as default_metrics
from sqlalchemy.exc import TimeoutError as SATimeoutError
from starlette.applications import Starlette
from onyx.server.metrics.per_tenant import per_tenant_request_callback
from onyx.server.metrics.postgres_connection_pool import pool_timeout_handler
from onyx.server.metrics.slow_requests import slow_request_callback
@@ -59,6 +61,15 @@ def setup_prometheus_metrics(app: Starlette) -> None:
excluded_handlers=_EXCLUDED_HANDLERS,
)
# Explicitly create the default metrics (http_requests_total,
# http_request_duration_seconds, etc.) and add them first. The library
# skips creating defaults when ANY custom instrumentations are registered
# via .add(), so we must include them ourselves.
default_callback = default_metrics(latency_lowr_buckets=_LATENCY_BUCKETS)
if default_callback:
instrumentator.add(default_callback)
instrumentator.add(slow_request_callback)
instrumentator.add(per_tenant_request_callback)
instrumentator.instrument(app, latency_lowr_buckets=_LATENCY_BUCKETS).expose(app)

View File

@@ -1,66 +0,0 @@
import requests
from tests.integration.common_utils.constants import API_SERVER_URL
from tests.integration.common_utils.constants import GENERAL_HEADERS
class ScimClient:
"""HTTP client for making authenticated SCIM v2 requests."""
@staticmethod
def _headers(raw_token: str) -> dict[str, str]:
return {
**GENERAL_HEADERS,
"Authorization": f"Bearer {raw_token}",
}
@staticmethod
def get(path: str, raw_token: str) -> requests.Response:
return requests.get(
f"{API_SERVER_URL}/scim/v2{path}",
headers=ScimClient._headers(raw_token),
timeout=60,
)
@staticmethod
def post(path: str, raw_token: str, json: dict) -> requests.Response:
return requests.post(
f"{API_SERVER_URL}/scim/v2{path}",
json=json,
headers=ScimClient._headers(raw_token),
timeout=60,
)
@staticmethod
def put(path: str, raw_token: str, json: dict) -> requests.Response:
return requests.put(
f"{API_SERVER_URL}/scim/v2{path}",
json=json,
headers=ScimClient._headers(raw_token),
timeout=60,
)
@staticmethod
def patch(path: str, raw_token: str, json: dict) -> requests.Response:
return requests.patch(
f"{API_SERVER_URL}/scim/v2{path}",
json=json,
headers=ScimClient._headers(raw_token),
timeout=60,
)
@staticmethod
def delete(path: str, raw_token: str) -> requests.Response:
return requests.delete(
f"{API_SERVER_URL}/scim/v2{path}",
headers=ScimClient._headers(raw_token),
timeout=60,
)
@staticmethod
def get_no_auth(path: str) -> requests.Response:
return requests.get(
f"{API_SERVER_URL}/scim/v2{path}",
headers=GENERAL_HEADERS,
timeout=60,
)

View File

@@ -1,6 +1,7 @@
import requests
from tests.integration.common_utils.constants import API_SERVER_URL
from tests.integration.common_utils.constants import GENERAL_HEADERS
from tests.integration.common_utils.test_models import DATestScimToken
from tests.integration.common_utils.test_models import DATestUser
@@ -50,3 +51,29 @@ class ScimTokenManager:
created_at=data["created_at"],
last_used_at=data.get("last_used_at"),
)
@staticmethod
def get_scim_headers(raw_token: str) -> dict[str, str]:
return {
**GENERAL_HEADERS,
"Authorization": f"Bearer {raw_token}",
}
@staticmethod
def scim_get(
path: str,
raw_token: str,
) -> requests.Response:
return requests.get(
f"{API_SERVER_URL}/scim/v2{path}",
headers=ScimTokenManager.get_scim_headers(raw_token),
timeout=60,
)
@staticmethod
def scim_get_no_auth(path: str) -> requests.Response:
return requests.get(
f"{API_SERVER_URL}/scim/v2{path}",
headers=GENERAL_HEADERS,
timeout=60,
)

View File

@@ -23,6 +23,8 @@ _ENV_PROVIDER = "NIGHTLY_LLM_PROVIDER"
_ENV_MODELS = "NIGHTLY_LLM_MODELS"
_ENV_API_KEY = "NIGHTLY_LLM_API_KEY"
_ENV_API_BASE = "NIGHTLY_LLM_API_BASE"
_ENV_API_VERSION = "NIGHTLY_LLM_API_VERSION"
_ENV_DEPLOYMENT_NAME = "NIGHTLY_LLM_DEPLOYMENT_NAME"
_ENV_CUSTOM_CONFIG_JSON = "NIGHTLY_LLM_CUSTOM_CONFIG_JSON"
_ENV_STRICT = "NIGHTLY_LLM_STRICT"
@@ -34,6 +36,8 @@ class NightlyProviderConfig(BaseModel):
model_names: list[str]
api_key: str | None
api_base: str | None
api_version: str | None
deployment_name: str | None
custom_config: dict[str, str] | None
strict: bool
@@ -66,6 +70,8 @@ def _load_provider_config() -> NightlyProviderConfig:
model_names = _parse_models_env(_ENV_MODELS)
api_key = os.environ.get(_ENV_API_KEY) or None
api_base = os.environ.get(_ENV_API_BASE) or None
api_version = os.environ.get(_ENV_API_VERSION) or None
deployment_name = os.environ.get(_ENV_DEPLOYMENT_NAME) or None
strict = _env_true(_ENV_STRICT, default=False)
custom_config: dict[str, str] | None = None
@@ -84,6 +90,8 @@ def _load_provider_config() -> NightlyProviderConfig:
model_names=model_names,
api_key=api_key,
api_base=api_base,
api_version=api_version,
deployment_name=deployment_name,
custom_config=custom_config,
strict=strict,
)
@@ -124,6 +132,22 @@ def _validate_provider_config(config: NightlyProviderConfig) -> None:
message=(f"{_ENV_API_BASE} is required for provider '{config.provider}'"),
)
if config.provider == "azure":
if not config.api_base:
_skip_or_fail(
strict=config.strict,
message=(
f"{_ENV_API_BASE} is required for provider '{config.provider}'"
),
)
if not config.api_version:
_skip_or_fail(
strict=config.strict,
message=(
f"{_ENV_API_VERSION} is required for provider '{config.provider}'"
),
)
def _assert_integration_mode_enabled() -> None:
assert (
@@ -162,6 +186,8 @@ def _create_provider_payload(
model_name: str,
api_key: str | None,
api_base: str | None,
api_version: str | None,
deployment_name: str | None,
custom_config: dict[str, str] | None,
) -> dict:
return {
@@ -169,6 +195,8 @@ def _create_provider_payload(
"provider": provider,
"api_key": api_key,
"api_base": api_base,
"api_version": api_version,
"deployment_name": deployment_name,
"custom_config": custom_config,
"default_model_name": model_name,
"is_public": True,
@@ -270,6 +298,8 @@ def _create_and_test_provider_for_model(
model_name=model_name,
api_key=config.api_key,
api_base=resolved_api_base,
api_version=config.api_version,
deployment_name=config.deployment_name,
custom_config=config.custom_config,
)

View File

@@ -72,6 +72,9 @@ def test_cold_startup_default_assistant() -> None:
assert (
"read_file" in tool_names
), "Default assistant should have FileReaderTool attached"
assert (
"python" in tool_names
), "Default assistant should have PythonTool attached"
# Also verify by display names for clarity
assert (
@@ -86,8 +89,11 @@ def test_cold_startup_default_assistant() -> None:
assert (
"File Reader" in tool_display_names
), "Default assistant should have File Reader tool"
# Should have exactly 5 tools
assert (
len(tool_associations) == 5
), f"Default assistant should have exactly 5 tools attached, got {len(tool_associations)}"
"Code Interpreter" in tool_display_names
), "Default assistant should have Code Interpreter tool"
# Should have exactly 6 tools
assert (
len(tool_associations) == 6
), f"Default assistant should have exactly 6 tools attached, got {len(tool_associations)}"

View File

@@ -15,7 +15,6 @@ import time
import requests
from tests.integration.common_utils.constants import API_SERVER_URL
from tests.integration.common_utils.managers.scim_client import ScimClient
from tests.integration.common_utils.managers.scim_token import ScimTokenManager
from tests.integration.common_utils.managers.user import UserManager
from tests.integration.common_utils.test_models import DATestUser
@@ -40,7 +39,7 @@ def test_scim_token_lifecycle(admin_user: DATestUser) -> None:
assert active == token.model_copy(update={"raw_token": None})
# Token works for SCIM requests
response = ScimClient.get("/Users", token.raw_token)
response = ScimTokenManager.scim_get("/Users", token.raw_token)
assert response.status_code == 200
body = response.json()
assert "Resources" in body
@@ -55,7 +54,7 @@ def test_scim_token_rotation_revokes_previous(admin_user: DATestUser) -> None:
)
assert first.raw_token is not None
response = ScimClient.get("/Users", first.raw_token)
response = ScimTokenManager.scim_get("/Users", first.raw_token)
assert response.status_code == 200
# Create second token — should revoke first
@@ -70,22 +69,25 @@ def test_scim_token_rotation_revokes_previous(admin_user: DATestUser) -> None:
assert active == second.model_copy(update={"raw_token": None})
# First token rejected, second works
assert ScimClient.get("/Users", first.raw_token).status_code == 401
assert ScimClient.get("/Users", second.raw_token).status_code == 200
assert ScimTokenManager.scim_get("/Users", first.raw_token).status_code == 401
assert ScimTokenManager.scim_get("/Users", second.raw_token).status_code == 200
def test_scim_request_without_token_rejected(
admin_user: DATestUser, # noqa: ARG001
) -> None:
"""SCIM endpoints reject requests with no Authorization header."""
assert ScimClient.get_no_auth("/Users").status_code == 401
assert ScimTokenManager.scim_get_no_auth("/Users").status_code == 401
def test_scim_request_with_bad_token_rejected(
admin_user: DATestUser, # noqa: ARG001
) -> None:
"""SCIM endpoints reject requests with an invalid token."""
assert ScimClient.get("/Users", "onyx_scim_bogus_token_value").status_code == 401
assert (
ScimTokenManager.scim_get("/Users", "onyx_scim_bogus_token_value").status_code
== 401
)
def test_non_admin_cannot_create_token(
@@ -137,7 +139,7 @@ def test_service_discovery_no_auth_required(
) -> None:
"""Service discovery endpoints work without any authentication."""
for path in ["/ServiceProviderConfig", "/ResourceTypes", "/Schemas"]:
response = ScimClient.get_no_auth(path)
response = ScimTokenManager.scim_get_no_auth(path)
assert response.status_code == 200, f"{path} returned {response.status_code}"
@@ -156,7 +158,7 @@ def test_last_used_at_updated_after_scim_request(
assert active.last_used_at is None
# Make a SCIM request, then verify last_used_at is set
assert ScimClient.get("/Users", token.raw_token).status_code == 200
assert ScimTokenManager.scim_get("/Users", token.raw_token).status_code == 200
time.sleep(0.5)
active_after = ScimTokenManager.get_active(user_performing_action=admin_user)

View File

@@ -1,517 +0,0 @@
"""Integration tests for SCIM user provisioning endpoints.
Covers the full user lifecycle as driven by an IdP (Okta / Azure AD):
1. Create a user via POST /Users
2. Retrieve a user via GET /Users/{id}
3. List, filter, and paginate users via GET /Users
4. Replace a user via PUT /Users/{id}
5. Patch a user (deactivate/reactivate) via PATCH /Users/{id}
6. Delete a user via DELETE /Users/{id}
7. Error cases: missing externalId, duplicate email, not-found, seat limit
All tests are parameterized across IdP request styles:
- **Okta**: lowercase PATCH ops, minimal payloads (core schema only).
- **Entra**: capitalized ops (``"Replace"``), enterprise extension data
(department, manager), and structured email arrays.
The server normalizes both — these tests verify that all IdP-specific fields
are accepted and round-tripped correctly.
Auth, revoked-token, and service-discovery tests live in test_scim_tokens.py.
"""
from datetime import datetime
from datetime import timedelta
from datetime import timezone
import pytest
import redis
import requests
from ee.onyx.server.license.models import LicenseMetadata
from ee.onyx.server.license.models import LicenseSource
from ee.onyx.server.license.models import PlanType
from onyx.auth.schemas import UserRole
from onyx.configs.app_configs import REDIS_DB_NUMBER
from onyx.configs.app_configs import REDIS_HOST
from onyx.configs.app_configs import REDIS_PORT
from onyx.server.settings.models import ApplicationStatus
from tests.integration.common_utils.managers.scim_client import ScimClient
from tests.integration.common_utils.managers.scim_token import ScimTokenManager
SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"
SCIM_ENTERPRISE_USER_SCHEMA = (
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
)
SCIM_PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
_LICENSE_REDIS_KEY = "public:license:metadata"
@pytest.fixture(scope="module", params=["okta", "entra"])
def idp_style(request: pytest.FixtureRequest) -> str:
"""Parameterized IdP style — runs every test with both Okta and Entra request formats."""
return request.param
@pytest.fixture(scope="module")
def scim_token(idp_style: str) -> str:
"""Create a single SCIM token shared across all tests in this module.
Creating a new token revokes the previous one, so we create exactly once
per IdP-style run and reuse. Uses UserManager directly to avoid
fixture-scope conflicts with the function-scoped admin_user fixture.
"""
from tests.integration.common_utils.constants import ADMIN_USER_NAME
from tests.integration.common_utils.constants import GENERAL_HEADERS
from tests.integration.common_utils.managers.user import build_email
from tests.integration.common_utils.managers.user import DEFAULT_PASSWORD
from tests.integration.common_utils.managers.user import UserManager
from tests.integration.common_utils.test_models import DATestUser
try:
admin = UserManager.create(name=ADMIN_USER_NAME)
except Exception:
admin = UserManager.login_as_user(
DATestUser(
id="",
email=build_email(ADMIN_USER_NAME),
password=DEFAULT_PASSWORD,
headers=GENERAL_HEADERS,
role=UserRole.ADMIN,
is_active=True,
)
)
token = ScimTokenManager.create(
name=f"scim-user-tests-{idp_style}",
user_performing_action=admin,
).raw_token
assert token is not None
return token
def _make_user_resource(
email: str,
external_id: str,
given_name: str = "Test",
family_name: str = "User",
active: bool = True,
idp_style: str = "okta",
department: str | None = None,
manager_id: str | None = None,
) -> dict:
"""Build a SCIM UserResource payload appropriate for the IdP style.
Entra sends richer payloads including enterprise extension data (department,
manager), structured email arrays, and the enterprise schema URN. Okta sends
minimal payloads with just core user fields.
"""
resource: dict = {
"schemas": [SCIM_USER_SCHEMA],
"userName": email,
"externalId": external_id,
"name": {
"givenName": given_name,
"familyName": family_name,
},
"active": active,
}
if idp_style == "entra":
dept = department or "Engineering"
mgr = manager_id or "mgr-ext-001"
resource["schemas"].append(SCIM_ENTERPRISE_USER_SCHEMA)
resource[SCIM_ENTERPRISE_USER_SCHEMA] = {
"department": dept,
"manager": {"value": mgr},
}
resource["emails"] = [
{"value": email, "type": "work", "primary": True},
]
return resource
def _make_patch_request(operations: list[dict], idp_style: str = "okta") -> dict:
"""Build a SCIM PatchOp payload, applying IdP-specific operation casing.
Entra sends capitalized operations (e.g. ``"Replace"`` instead of
``"replace"``). The server's ``normalize_operation`` validator lowercases
them — these tests verify that both casings are accepted.
"""
cased_operations = []
for operation in operations:
cased = dict(operation)
if idp_style == "entra":
cased["op"] = operation["op"].capitalize()
cased_operations.append(cased)
return {
"schemas": [SCIM_PATCH_SCHEMA],
"Operations": cased_operations,
}
def _create_scim_user(
token: str,
email: str,
external_id: str,
idp_style: str = "okta",
) -> requests.Response:
return ScimClient.post(
"/Users",
token,
json=_make_user_resource(email, external_id, idp_style=idp_style),
)
def _assert_entra_extension(
body: dict,
expected_department: str = "Engineering",
expected_manager: str = "mgr-ext-001",
) -> None:
"""Assert that Entra enterprise extension fields round-tripped correctly."""
assert SCIM_ENTERPRISE_USER_SCHEMA in body["schemas"]
ext = body[SCIM_ENTERPRISE_USER_SCHEMA]
assert ext["department"] == expected_department
assert ext["manager"]["value"] == expected_manager
def _assert_entra_emails(body: dict, expected_email: str) -> None:
"""Assert that structured email metadata round-tripped correctly."""
emails = body["emails"]
assert len(emails) >= 1
work_email = next(e for e in emails if e.get("type") == "work")
assert work_email["value"] == expected_email
assert work_email["primary"] is True
# ------------------------------------------------------------------
# Lifecycle: create -> get -> list -> replace -> patch -> delete
# ------------------------------------------------------------------
def test_create_user(scim_token: str, idp_style: str) -> None:
"""POST /Users creates a provisioned user and returns 201."""
email = f"scim_create_{idp_style}@example.com"
ext_id = f"ext-create-{idp_style}"
resp = _create_scim_user(scim_token, email, ext_id, idp_style)
assert resp.status_code == 201
body = resp.json()
assert body["userName"] == email
assert body["externalId"] == ext_id
assert body["active"] is True
assert body["id"] # UUID assigned by server
assert body["meta"]["resourceType"] == "User"
assert body["name"]["givenName"] == "Test"
assert body["name"]["familyName"] == "User"
if idp_style == "entra":
_assert_entra_extension(body)
_assert_entra_emails(body, email)
def test_get_user(scim_token: str, idp_style: str) -> None:
"""GET /Users/{id} returns the user resource with all stored fields."""
email = f"scim_get_{idp_style}@example.com"
ext_id = f"ext-get-{idp_style}"
created = _create_scim_user(scim_token, email, ext_id, idp_style).json()
resp = ScimClient.get(f"/Users/{created['id']}", scim_token)
assert resp.status_code == 200
body = resp.json()
assert body["id"] == created["id"]
assert body["userName"] == email
assert body["externalId"] == ext_id
assert body["name"]["givenName"] == "Test"
assert body["name"]["familyName"] == "User"
if idp_style == "entra":
_assert_entra_extension(body)
_assert_entra_emails(body, email)
def test_list_users(scim_token: str, idp_style: str) -> None:
"""GET /Users returns a ListResponse containing provisioned users."""
email = f"scim_list_{idp_style}@example.com"
_create_scim_user(scim_token, email, f"ext-list-{idp_style}", idp_style)
resp = ScimClient.get("/Users", scim_token)
assert resp.status_code == 200
body = resp.json()
assert body["totalResults"] >= 1
emails = [r["userName"] for r in body["Resources"]]
assert email in emails
def test_list_users_pagination(scim_token: str, idp_style: str) -> None:
"""GET /Users with startIndex and count returns correct pagination."""
_create_scim_user(
scim_token,
f"scim_page1_{idp_style}@example.com",
f"ext-page-1-{idp_style}",
idp_style,
)
_create_scim_user(
scim_token,
f"scim_page2_{idp_style}@example.com",
f"ext-page-2-{idp_style}",
idp_style,
)
resp = ScimClient.get("/Users?startIndex=1&count=1", scim_token)
assert resp.status_code == 200
body = resp.json()
assert body["startIndex"] == 1
assert body["itemsPerPage"] == 1
assert body["totalResults"] >= 2
assert len(body["Resources"]) == 1
def test_filter_users_by_username(scim_token: str, idp_style: str) -> None:
"""GET /Users?filter=userName eq '...' returns only matching users."""
email = f"scim_filter_{idp_style}@example.com"
_create_scim_user(scim_token, email, f"ext-filter-{idp_style}", idp_style)
resp = ScimClient.get(f'/Users?filter=userName eq "{email}"', scim_token)
assert resp.status_code == 200
body = resp.json()
assert body["totalResults"] == 1
assert body["Resources"][0]["userName"] == email
def test_replace_user(scim_token: str, idp_style: str) -> None:
"""PUT /Users/{id} replaces the user resource including enterprise fields."""
email = f"scim_replace_{idp_style}@example.com"
ext_id = f"ext-replace-{idp_style}"
created = _create_scim_user(scim_token, email, ext_id, idp_style).json()
updated_resource = _make_user_resource(
email=email,
external_id=ext_id,
given_name="Updated",
family_name="Name",
idp_style=idp_style,
department="Product",
)
resp = ScimClient.put(f"/Users/{created['id']}", scim_token, json=updated_resource)
assert resp.status_code == 200
body = resp.json()
assert body["name"]["givenName"] == "Updated"
assert body["name"]["familyName"] == "Name"
if idp_style == "entra":
_assert_entra_extension(body, expected_department="Product")
_assert_entra_emails(body, email)
def test_patch_deactivate_user(scim_token: str, idp_style: str) -> None:
"""PATCH /Users/{id} with active=false deactivates the user."""
created = _create_scim_user(
scim_token,
f"scim_deactivate_{idp_style}@example.com",
f"ext-deactivate-{idp_style}",
idp_style,
).json()
assert created["active"] is True
resp = ScimClient.patch(
f"/Users/{created['id']}",
scim_token,
json=_make_patch_request(
[{"op": "replace", "path": "active", "value": False}], idp_style
),
)
assert resp.status_code == 200
assert resp.json()["active"] is False
# Confirm via GET
get_resp = ScimClient.get(f"/Users/{created['id']}", scim_token)
assert get_resp.json()["active"] is False
def test_patch_reactivate_user(scim_token: str, idp_style: str) -> None:
"""PATCH active=true reactivates a previously deactivated user."""
created = _create_scim_user(
scim_token,
f"scim_reactivate_{idp_style}@example.com",
f"ext-reactivate-{idp_style}",
idp_style,
).json()
# Deactivate
deactivate_resp = ScimClient.patch(
f"/Users/{created['id']}",
scim_token,
json=_make_patch_request(
[{"op": "replace", "path": "active", "value": False}], idp_style
),
)
assert deactivate_resp.status_code == 200
assert deactivate_resp.json()["active"] is False
# Reactivate
resp = ScimClient.patch(
f"/Users/{created['id']}",
scim_token,
json=_make_patch_request(
[{"op": "replace", "path": "active", "value": True}], idp_style
),
)
assert resp.status_code == 200
assert resp.json()["active"] is True
def test_delete_user(scim_token: str, idp_style: str) -> None:
"""DELETE /Users/{id} deactivates and removes the SCIM mapping."""
created = _create_scim_user(
scim_token,
f"scim_delete_{idp_style}@example.com",
f"ext-delete-{idp_style}",
idp_style,
).json()
resp = ScimClient.delete(f"/Users/{created['id']}", scim_token)
assert resp.status_code == 204
# Second DELETE returns 404 per RFC 7644 §3.6 (mapping removed)
resp2 = ScimClient.delete(f"/Users/{created['id']}", scim_token)
assert resp2.status_code == 404
# ------------------------------------------------------------------
# Error cases
# ------------------------------------------------------------------
def test_create_user_missing_external_id(scim_token: str) -> None:
"""POST /Users without externalId returns 400."""
resp = ScimClient.post(
"/Users",
scim_token,
json={
"schemas": [SCIM_USER_SCHEMA],
"userName": "scim_no_extid@example.com",
"active": True,
},
)
assert resp.status_code == 400
assert "externalId" in resp.json()["detail"]
def test_create_user_duplicate_email(scim_token: str, idp_style: str) -> None:
"""POST /Users with an already-taken email returns 409."""
email = f"scim_dup_{idp_style}@example.com"
resp1 = _create_scim_user(scim_token, email, f"ext-dup-1-{idp_style}", idp_style)
assert resp1.status_code == 201
resp2 = _create_scim_user(scim_token, email, f"ext-dup-2-{idp_style}", idp_style)
assert resp2.status_code == 409
def test_get_nonexistent_user(scim_token: str) -> None:
"""GET /Users/{bad-id} returns 404."""
resp = ScimClient.get("/Users/00000000-0000-0000-0000-000000000000", scim_token)
assert resp.status_code == 404
def test_filter_users_by_external_id(scim_token: str, idp_style: str) -> None:
"""GET /Users?filter=externalId eq '...' returns the matching user."""
ext_id = f"ext-unique-filter-id-{idp_style}"
_create_scim_user(
scim_token, f"scim_extfilter_{idp_style}@example.com", ext_id, idp_style
)
resp = ScimClient.get(f'/Users?filter=externalId eq "{ext_id}"', scim_token)
assert resp.status_code == 200
body = resp.json()
assert body["totalResults"] == 1
assert body["Resources"][0]["externalId"] == ext_id
# ------------------------------------------------------------------
# Seat-limit enforcement
# ------------------------------------------------------------------
def _seed_license(r: redis.Redis, seats: int) -> None:
"""Write a LicenseMetadata entry into Redis with the given seat cap."""
now = datetime.now(timezone.utc)
metadata = LicenseMetadata(
tenant_id="public",
organization_name="Test Org",
seats=seats,
used_seats=0, # check_seat_availability recalculates from DB
plan_type=PlanType.ANNUAL,
issued_at=now,
expires_at=now + timedelta(days=365),
status=ApplicationStatus.ACTIVE,
source=LicenseSource.MANUAL_UPLOAD,
)
r.set(_LICENSE_REDIS_KEY, metadata.model_dump_json(), ex=300)
def test_create_user_seat_limit(scim_token: str, idp_style: str) -> None:
"""POST /Users returns 403 when the seat limit is reached."""
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB_NUMBER)
# admin_user already occupies 1 seat; cap at 1 -> full
_seed_license(r, seats=1)
try:
resp = _create_scim_user(
scim_token,
f"scim_blocked_{idp_style}@example.com",
f"ext-blocked-{idp_style}",
idp_style,
)
assert resp.status_code == 403
assert "seat" in resp.json()["detail"].lower()
finally:
r.delete(_LICENSE_REDIS_KEY)
def test_reactivate_user_seat_limit(scim_token: str, idp_style: str) -> None:
"""PATCH active=true returns 403 when the seat limit is reached."""
# Create and deactivate a user (before license is seeded)
created = _create_scim_user(
scim_token,
f"scim_reactivate_blocked_{idp_style}@example.com",
f"ext-reactivate-blocked-{idp_style}",
idp_style,
).json()
assert created["active"] is True
deactivate_resp = ScimClient.patch(
f"/Users/{created['id']}",
scim_token,
json=_make_patch_request(
[{"op": "replace", "path": "active", "value": False}], idp_style
),
)
assert deactivate_resp.status_code == 200
assert deactivate_resp.json()["active"] is False
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB_NUMBER)
# Seed license capped at current active users -> reactivation should fail
_seed_license(r, seats=1)
try:
resp = ScimClient.patch(
f"/Users/{created['id']}",
scim_token,
json=_make_patch_request(
[{"op": "replace", "path": "active", "value": True}], idp_style
),
)
assert resp.status_code == 403
assert "seat" in resp.json()["detail"].lower()
finally:
r.delete(_LICENSE_REDIS_KEY)

View File

@@ -1,20 +1,11 @@
"""Tests for license database CRUD operations."""
from datetime import datetime
from datetime import timedelta
from datetime import timezone
from unittest.mock import MagicMock
from unittest.mock import patch
from ee.onyx.db.license import check_seat_availability
from ee.onyx.db.license import delete_license
from ee.onyx.db.license import get_license
from ee.onyx.db.license import upsert_license
from ee.onyx.server.license.models import LicenseMetadata
from ee.onyx.server.license.models import LicenseSource
from ee.onyx.server.license.models import PlanType
from onyx.db.models import License
from onyx.server.settings.models import ApplicationStatus
class TestGetLicense:
@@ -109,108 +100,3 @@ class TestDeleteLicense:
assert result is False
mock_session.delete.assert_not_called()
mock_session.commit.assert_not_called()
def _make_license_metadata(seats: int = 10) -> LicenseMetadata:
now = datetime.now(timezone.utc)
return LicenseMetadata(
tenant_id="public",
seats=seats,
used_seats=0,
plan_type=PlanType.ANNUAL,
issued_at=now,
expires_at=now + timedelta(days=365),
status=ApplicationStatus.ACTIVE,
source=LicenseSource.MANUAL_UPLOAD,
)
class TestCheckSeatAvailabilitySelfHosted:
"""Seat checks for self-hosted (MULTI_TENANT=False)."""
@patch("ee.onyx.db.license.get_license_metadata", return_value=None)
def test_no_license_means_unlimited(self, _mock_meta: MagicMock) -> None:
result = check_seat_availability(MagicMock(), seats_needed=1)
assert result.available is True
@patch("ee.onyx.db.license.get_used_seats", return_value=5)
@patch("ee.onyx.db.license.get_license_metadata")
def test_seats_available(self, mock_meta: MagicMock, _mock_used: MagicMock) -> None:
mock_meta.return_value = _make_license_metadata(seats=10)
result = check_seat_availability(MagicMock(), seats_needed=1)
assert result.available is True
@patch("ee.onyx.db.license.get_used_seats", return_value=10)
@patch("ee.onyx.db.license.get_license_metadata")
def test_seats_full_blocks_creation(
self, mock_meta: MagicMock, _mock_used: MagicMock
) -> None:
mock_meta.return_value = _make_license_metadata(seats=10)
result = check_seat_availability(MagicMock(), seats_needed=1)
assert result.available is False
assert result.error_message is not None
assert "10 of 10" in result.error_message
@patch("ee.onyx.db.license.get_used_seats", return_value=10)
@patch("ee.onyx.db.license.get_license_metadata")
def test_exactly_at_capacity_allows_no_more(
self, mock_meta: MagicMock, _mock_used: MagicMock
) -> None:
"""Filling to 100% is allowed; exceeding is not."""
mock_meta.return_value = _make_license_metadata(seats=10)
result = check_seat_availability(MagicMock(), seats_needed=1)
assert result.available is False
@patch("ee.onyx.db.license.get_used_seats", return_value=9)
@patch("ee.onyx.db.license.get_license_metadata")
def test_filling_to_capacity_is_allowed(
self, mock_meta: MagicMock, _mock_used: MagicMock
) -> None:
mock_meta.return_value = _make_license_metadata(seats=10)
result = check_seat_availability(MagicMock(), seats_needed=1)
assert result.available is True
class TestCheckSeatAvailabilityMultiTenant:
"""Seat checks for multi-tenant cloud (MULTI_TENANT=True).
Verifies that get_used_seats takes the MULTI_TENANT branch
and delegates to get_tenant_count.
"""
@patch("ee.onyx.db.license.MULTI_TENANT", True)
@patch(
"ee.onyx.server.tenants.user_mapping.get_tenant_count",
return_value=5,
)
@patch("ee.onyx.db.license.get_license_metadata")
def test_seats_available_multi_tenant(
self,
mock_meta: MagicMock,
mock_tenant_count: MagicMock,
) -> None:
mock_meta.return_value = _make_license_metadata(seats=10)
result = check_seat_availability(
MagicMock(), seats_needed=1, tenant_id="tenant-abc"
)
assert result.available is True
mock_tenant_count.assert_called_once_with("tenant-abc")
@patch("ee.onyx.db.license.MULTI_TENANT", True)
@patch(
"ee.onyx.server.tenants.user_mapping.get_tenant_count",
return_value=10,
)
@patch("ee.onyx.db.license.get_license_metadata")
def test_seats_full_multi_tenant(
self,
mock_meta: MagicMock,
mock_tenant_count: MagicMock,
) -> None:
mock_meta.return_value = _make_license_metadata(seats=10)
result = check_seat_availability(
MagicMock(), seats_needed=1, tenant_id="tenant-abc"
)
assert result.available is False
assert result.error_message is not None
mock_tenant_count.assert_called_once_with("tenant-abc")

View File

@@ -19,7 +19,6 @@ from ee.onyx.server.scim.models import ScimListResponse
from ee.onyx.server.scim.models import ScimName
from ee.onyx.server.scim.models import ScimUserResource
from ee.onyx.server.scim.providers.base import ScimProvider
from ee.onyx.server.scim.providers.entra import EntraProvider
from ee.onyx.server.scim.providers.okta import OktaProvider
from onyx.db.models import ScimToken
from onyx.db.models import ScimUserMapping
@@ -27,10 +26,6 @@ from onyx.db.models import User
from onyx.db.models import UserGroup
from onyx.db.models import UserRole
# Every supported SCIM provider must appear here so that all endpoint tests
# run against it. When adding a new provider, add its class to this list.
SCIM_PROVIDERS: list[type[ScimProvider]] = [OktaProvider, EntraProvider]
@pytest.fixture
def mock_db_session() -> MagicMock:
@@ -46,10 +41,10 @@ def mock_token() -> MagicMock:
return token
@pytest.fixture(params=SCIM_PROVIDERS, ids=[p.__name__ for p in SCIM_PROVIDERS])
def provider(request: pytest.FixtureRequest) -> ScimProvider:
"""Parameterized provider — runs each test with every provider in SCIM_PROVIDERS."""
return request.param()
@pytest.fixture
def provider() -> ScimProvider:
"""An OktaProvider instance for endpoint tests."""
return OktaProvider()
@pytest.fixture

View File

@@ -106,6 +106,9 @@ def test_checkout_event_stores_endpoint_and_increments_gauge() -> None:
patch(
"onyx.server.metrics.postgres_connection_pool.CURRENT_ENDPOINT_CONTEXTVAR"
) as mock_ctx,
patch(
"onyx.server.metrics.postgres_connection_pool.CURRENT_TENANT_ID_CONTEXTVAR"
) as mock_tenant_ctx,
patch(
"onyx.server.metrics.postgres_connection_pool._connections_held"
) as mock_gauge,
@@ -114,12 +117,14 @@ def test_checkout_event_stores_endpoint_and_increments_gauge() -> None:
mock_labels = MagicMock()
mock_gauge.labels.return_value = mock_labels
mock_ctx.get.return_value = "/api/chat/send-message"
mock_tenant_ctx.get.return_value = "tenant_xyz"
listeners["checkout"](None, conn_record, None)
assert conn_record.info["_metrics_endpoint"] == "/api/chat/send-message"
assert conn_record.info["_metrics_tenant_id"] == "tenant_xyz"
assert "_metrics_checkout_time" in conn_record.info
mock_gauge.labels.assert_called_with(
handler="/api/chat/send-message", engine="sync"
handler="/api/chat/send-message", engine="sync", tenant_id="tenant_xyz"
)
mock_labels.inc.assert_called_once()
@@ -144,6 +149,7 @@ def test_checkin_event_observes_hold_duration() -> None:
conn_record = _make_conn_record()
conn_record.info["_metrics_endpoint"] = "/api/search"
conn_record.info["_metrics_tenant_id"] = "tenant_abc"
conn_record.info["_metrics_checkout_time"] = time.monotonic() - 0.5
with (
@@ -162,7 +168,9 @@ def test_checkin_event_observes_hold_duration() -> None:
listeners["checkin"](None, conn_record)
mock_gauge.labels.assert_called_with(handler="/api/search", engine="sync")
mock_gauge.labels.assert_called_with(
handler="/api/search", engine="sync", tenant_id="tenant_abc"
)
mock_labels.dec.assert_called_once()
mock_hist.labels.assert_called_with(handler="/api/search", engine="sync")
mock_hist_labels.observe.assert_called_once()
@@ -172,11 +180,12 @@ def test_checkin_event_observes_hold_duration() -> None:
# conn_record.info should be cleaned up
assert "_metrics_endpoint" not in conn_record.info
assert "_metrics_tenant_id" not in conn_record.info
assert "_metrics_checkout_time" not in conn_record.info
def test_checkin_with_missing_endpoint_uses_unknown() -> None:
"""Verify checkin gracefully handles missing endpoint info."""
"""Verify checkin gracefully handles missing endpoint and tenant info."""
engine = MagicMock()
engine.pool = MagicMock()
listeners: dict[str, Any] = {}
@@ -207,7 +216,9 @@ def test_checkin_with_missing_endpoint_uses_unknown() -> None:
listeners["checkin"](None, conn_record)
mock_gauge.labels.assert_called_with(handler="unknown", engine="sync")
mock_gauge.labels.assert_called_with(
handler="unknown", engine="sync", tenant_id="unknown"
)
# --- setup_postgres_connection_pool_metrics tests ---

View File

@@ -10,6 +10,7 @@ from fastapi.testclient import TestClient
from prometheus_client import CollectorRegistry
from prometheus_client import Gauge
from onyx.server.metrics.per_tenant import per_tenant_request_callback
from onyx.server.metrics.prometheus_setup import setup_prometheus_metrics
from onyx.server.metrics.slow_requests import slow_request_callback
@@ -81,7 +82,7 @@ def test_setup_attaches_instrumentator_to_app() -> None:
inprogress_labels=True,
excluded_handlers=["/health", "/metrics", "/openapi.json"],
)
mock_instance.add.assert_called_once()
assert mock_instance.add.call_count == 3
mock_instance.instrument.assert_called_once_with(
app,
latency_lowr_buckets=(
@@ -100,6 +101,56 @@ def test_setup_attaches_instrumentator_to_app() -> None:
mock_instance.expose.assert_called_once_with(app)
def test_per_tenant_callback_increments_with_tenant_id() -> None:
"""Verify per-tenant callback reads tenant from contextvar and increments."""
with (
patch(
"onyx.server.metrics.per_tenant.CURRENT_TENANT_ID_CONTEXTVAR"
) as mock_ctx,
patch("onyx.server.metrics.per_tenant._requests_by_tenant") as mock_counter,
):
mock_labels = MagicMock()
mock_counter.labels.return_value = mock_labels
mock_ctx.get.return_value = "tenant_abc"
info = _make_info(
duration=0.1, method="POST", handler="/api/chat", status="200"
)
per_tenant_request_callback(info)
mock_counter.labels.assert_called_once_with(
tenant_id="tenant_abc",
method="POST",
handler="/api/chat",
status="200",
)
mock_labels.inc.assert_called_once()
def test_per_tenant_callback_falls_back_to_unknown() -> None:
"""Verify per-tenant callback uses 'unknown' when contextvar is None."""
with (
patch(
"onyx.server.metrics.per_tenant.CURRENT_TENANT_ID_CONTEXTVAR"
) as mock_ctx,
patch("onyx.server.metrics.per_tenant._requests_by_tenant") as mock_counter,
):
mock_labels = MagicMock()
mock_counter.labels.return_value = mock_labels
mock_ctx.get.return_value = None
info = _make_info(duration=0.1)
per_tenant_request_callback(info)
mock_counter.labels.assert_called_once_with(
tenant_id="unknown",
method="GET",
handler="/api/test",
status="200",
)
mock_labels.inc.assert_called_once()
def test_inprogress_gauge_increments_during_request() -> None:
"""Verify the in-progress gauge goes up while a request is in flight."""
registry = CollectorRegistry()

View File

@@ -163,3 +163,16 @@ Add clear comments:
- Any TODOs you add in the code must be accompanied by either the name/username
of the owner of that TODO, or an issue number for an issue referencing that
piece of work.
- Avoid module-level logic that runs on import, which leads to import-time side
effects. Essentially every piece of meaningful logic should exist within some
function that has to be explicitly invoked. Acceptable exceptions to this may
include loading environment variables or setting up loggers.
- If you find yourself needing something like this, you may want that logic to
exist in a file dedicated for manual execution (contains `if __name__ ==
"__main__":`) which should not be imported by anything else.
- Related to the above, do not conflate Python scripts you intend to run from
the command line (contains `if __name__ == "__main__":`) with modules you
intend to import from elsewhere. If for some unlikely reason they have to be
the same file, any logic specific to executing the file (including imports)
should be contained in the `if __name__ == "__main__":` block.
- Generally these executable files exist in `backend/scripts/`.

View File

@@ -10,7 +10,7 @@ export default function Main() {
<SettingsLayouts.Header
icon={SvgMcp}
title="MCP Actions"
description="Connect MCP (Model Context Protocol) servers to add custom actions and tools for your assistants."
description="Connect MCP (Model Context Protocol) servers to add custom actions and tools for your agents."
separator
/>
<SettingsLayouts.Body>

View File

@@ -10,7 +10,7 @@ export default function Main() {
<SettingsLayouts.Header
icon={SvgActions}
title="OpenAPI Actions"
description="Connect OpenAPI servers to add custom actions and tools for your assistants."
description="Connect OpenAPI servers to add custom actions and tools for your agents."
separator
/>
<SettingsLayouts.Body>

View File

@@ -170,7 +170,7 @@ export function PersonasTable({
{deleteModalOpen && personaToDelete && (
<ConfirmationModalLayout
icon={SvgAlertCircle}
title="Delete Assistant"
title="Delete Agent"
onClose={closeDeleteModal}
submit={<Button onClick={handleDeletePersona}>Delete</Button>}
>
@@ -183,15 +183,15 @@ export function PersonasTable({
const isDefault = personaToToggleDefault.is_default_persona;
const title = isDefault
? "Remove Featured Assistant"
: "Set Featured Assistant";
? "Remove Featured Agent"
: "Set Featured Agent";
const buttonText = isDefault ? "Remove Feature" : "Set as Featured";
const text = isDefault
? `Are you sure you want to remove the featured status of ${personaToToggleDefault.name}?`
: `Are you sure you want to set the featured status of ${personaToToggleDefault.name}?`;
const additionalText = isDefault
? `Removing "${personaToToggleDefault.name}" as a featured assistant will not affect its visibility or accessibility.`
: `Setting "${personaToToggleDefault.name}" as a featured assistant will make it public and visible to all users. This action cannot be undone.`;
? `Removing "${personaToToggleDefault.name}" as a featured agent will not affect its visibility or accessibility.`
: `Setting "${personaToToggleDefault.name}" as a featured agent will make it public and visible to all users. This action cannot be undone.`;
return (
<ConfirmationModalLayout
@@ -217,7 +217,7 @@ export function PersonasTable({
"Name",
"Description",
"Type",
"Featured Assistant",
"Featured Agent",
"Is Visible",
"Delete",
]}

View File

@@ -47,8 +47,8 @@ function MainContent({
return (
<div>
<Text className="mb-2">
Assistants are a way to build custom search/question-answering
experiences for different use cases.
Agents are a way to build custom search/question-answering experiences
for different use cases.
</Text>
<Text className="mt-2">They allow you to customize:</Text>
<div className="text-sm">
@@ -63,21 +63,21 @@ function MainContent({
<div>
<Separator />
<Title>Create an Assistant</Title>
<Title>Create an Agent</Title>
<CreateButton href="/app/agents/create?admin=true">
New Assistant
New Agent
</CreateButton>
<Separator />
<Title>Existing Assistants</Title>
<Title>Existing Agents</Title>
{totalItems > 0 ? (
<>
<SubLabel>
Assistants will be displayed as options on the Chat / Search
interfaces in the order they are displayed below. Assistants
marked as hidden will not be displayed. Editable assistants are
shown at the top.
Agents will be displayed as options on the Chat / Search
interfaces in the order they are displayed below. Agents marked as
hidden will not be displayed. Editable agents are shown at the
top.
</SubLabel>
<PersonasTable
personas={customPersonas}
@@ -96,21 +96,21 @@ function MainContent({
) : (
<div className="mt-6 p-8 border border-border rounded-lg bg-background-weak text-center">
<Text className="text-lg font-medium mb-2">
No custom assistants yet
No custom agents yet
</Text>
<Text className="text-subtle mb-3">
Create your first assistant to:
Create your first agent to:
</Text>
<ul className="text-subtle text-sm list-disc text-left inline-block mb-3">
<li>Build department-specific knowledge bases</li>
<li>Create specialized research assistants</li>
<li>Create specialized research agents</li>
<li>Set up compliance and policy advisors</li>
</ul>
<Text className="text-subtle text-sm mb-4">
...and so much more!
</Text>
<CreateButton href="/app/agents/create?admin=true">
Create Your First Assistant
Create Your First Agent
</CreateButton>
</div>
)}
@@ -128,13 +128,13 @@ export default function Page() {
return (
<>
<AdminPageTitle icon={SvgOnyxOctagon} title="Assistants" />
<AdminPageTitle icon={SvgOnyxOctagon} title="Agents" />
{isLoading && <ThreeDotsLoader />}
{error && (
<ErrorCallout
errorTitle="Failed to load assistants"
errorTitle="Failed to load agents"
errorMsg={
error?.info?.message ||
error?.info?.detail ||

View File

@@ -156,7 +156,7 @@ export const SlackChannelConfigCreationForm = ({
is: "assistant",
then: (schema) =>
schema.required(
"A persona is required when using the'Assistant' knowledge source"
"An agent is required when using the 'Agent' knowledge source"
),
}),
standard_answer_categories: Yup.array(),

View File

@@ -224,14 +224,14 @@ export function SlackChannelConfigFormFields({
<RadioGroupItemField
value="assistant"
id="assistant"
label="Search Assistant"
label="Search Agent"
sublabel="Control both the documents and the prompt to use for answering questions"
/>
<RadioGroupItemField
value="non_search_assistant"
id="non_search_assistant"
label="Non-Search Assistant"
sublabel="Chat with an assistant that does not use documents"
label="Non-Search Agent"
sublabel="Chat with an agent that does not use documents"
/>
</RadioGroup>
</div>
@@ -327,15 +327,15 @@ export function SlackChannelConfigFormFields({
<div className="mt-4">
<SubLabel>
<>
Select the search-enabled assistant OnyxBot will use while
answering questions in Slack.
Select the search-enabled agent OnyxBot will use while answering
questions in Slack.
{syncEnabledAssistants.length > 0 && (
<>
<br />
<span className="text-sm text-text-dark/80">
Note: Some of your assistants have auto-synced connectors
in their document sets. You cannot select these assistants
as they will not be able to answer questions in Slack.{" "}
Note: Some of your agents have auto-synced connectors in
their document sets. You cannot select these agents as
they will not be able to answer questions in Slack.{" "}
<button
type="button"
onClick={() =>
@@ -349,7 +349,7 @@ export function SlackChannelConfigFormFields({
{viewSyncEnabledAssistants
? "Hide un-selectable "
: "View all "}
assistants
agents
</button>
</span>
</>
@@ -367,7 +367,7 @@ export function SlackChannelConfigFormFields({
{viewSyncEnabledAssistants && syncEnabledAssistants.length > 0 && (
<div className="mt-4">
<p className="text-sm text-text-dark/80">
Un-selectable assistants:
Un-selectable agents:
</p>
<div className="mb-3 mt-2 flex gap-2 flex-wrap text-sm">
{syncEnabledAssistants.map(
@@ -394,15 +394,15 @@ export function SlackChannelConfigFormFields({
<div className="mt-4">
<SubLabel>
<>
Select the non-search assistant OnyxBot will use while answering
Select the non-search agent OnyxBot will use while answering
questions in Slack.
{syncEnabledAssistants.length > 0 && (
<>
<br />
<span className="text-sm text-text-dark/80">
Note: Some of your assistants have auto-synced connectors
in their document sets. You cannot select these assistants
as they will not be able to answer questions in Slack.{" "}
Note: Some of your agents have auto-synced connectors in
their document sets. You cannot select these agents as
they will not be able to answer questions in Slack.{" "}
<button
type="button"
onClick={() =>
@@ -416,7 +416,7 @@ export function SlackChannelConfigFormFields({
{viewSyncEnabledAssistants
? "Hide un-selectable "
: "View all "}
assistants
agents
</button>
</span>
</>
@@ -524,7 +524,7 @@ export function SlackChannelConfigFormFields({
name="is_ephemeral"
label="Respond to user in a private (ephemeral) message"
tooltip="If set, OnyxBot will respond only to the user in a private (ephemeral) message. If you also
chose 'Search' Assistant above, selecting this option will make documents that are private to the user
chose 'Search' Agent above, selecting this option will make documents that are private to the user
available for their queries."
/>

View File

@@ -0,0 +1,7 @@
"use client";
import CodeInterpreterPage from "@/refresh-pages/admin/CodeInterpreterPage";
export default function Page() {
return <CodeInterpreterPage />;
}

View File

@@ -39,10 +39,10 @@ export function AdvancedOptions({
agents={agents}
isLoading={agentsLoading}
error={agentsError}
label="Assistant Whitelist"
subtext="Restrict this provider to specific assistants."
label="Agent Whitelist"
subtext="Restrict this provider to specific agents."
disabled={formikProps.values.is_public}
disabledMessage="This LLM Provider is public and available to all assistants."
disabledMessage="This LLM Provider is public and available to all agents."
/>
</div>
</>

View File

@@ -299,11 +299,11 @@ export default function Page({ params }: Props) {
});
refreshGuild();
toast.success(
personaId ? "Default assistant updated" : "Default assistant cleared"
personaId ? "Default agent updated" : "Default agent cleared"
);
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to update assistant"
err instanceof Error ? err.message : "Failed to update agent"
);
} finally {
setIsUpdating(false);
@@ -355,7 +355,7 @@ export default function Page({ params }: Props) {
<InputSelect.Trigger placeholder="Select agent" />
<InputSelect.Content>
<InputSelect.Item value="default">
Default Assistant
Default Agent
</InputSelect.Item>
{personas.map((persona) => (
<InputSelect.Item

View File

@@ -427,7 +427,7 @@ export const GroupDisplay = ({
<Separator />
<h2 className="text-xl font-bold mt-8 mb-2">Assistants</h2>
<h2 className="text-xl font-bold mt-8 mb-2">Agents</h2>
<div>
{userGroup.document_sets.length > 0 ? (
@@ -445,7 +445,7 @@ export const GroupDisplay = ({
</div>
) : (
<>
<Text>No Assistants in this group...</Text>
<Text>No Agents in this group...</Text>
</>
)}
</div>

View File

@@ -152,14 +152,14 @@ export function PersonaMessagesChart({
} else if (selectedPersonaId === undefined) {
content = (
<div className="h-80 text-text-500 flex flex-col">
<p className="m-auto">Select an assistant to view analytics</p>
<p className="m-auto">Select an agent to view analytics</p>
</div>
);
} else if (!personaMessagesData?.length) {
content = (
<div className="h-80 text-text-500 flex flex-col">
<p className="m-auto">
No data found for selected assistant in the specified time range
No data found for selected agent in the specified time range
</p>
</div>
);
@@ -178,11 +178,9 @@ export function PersonaMessagesChart({
return (
<CardSection className="mt-8">
<Title>Assistant Analytics</Title>
<Title>Agent Analytics</Title>
<div className="flex flex-col gap-4">
<Text>
Messages and unique users per day for the selected assistant
</Text>
<Text>Messages and unique users per day for the selected agent</Text>
<div className="flex items-center gap-4">
<Select
value={selectedPersonaId?.toString() ?? ""}
@@ -191,14 +189,14 @@ export function PersonaMessagesChart({
}}
>
<SelectTrigger className="flex w-full max-w-xs">
<SelectValue placeholder="Select an assistant to display" />
<SelectValue placeholder="Select an agent to display" />
</SelectTrigger>
<SelectContent>
<div className="flex items-center px-2 pb-2 sticky top-0 bg-background border-b">
<Search className="h-4 w-4 mr-2 shrink-0 opacity-50" />
<input
className="flex h-8 w-full rounded-sm bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Search assistants..."
placeholder="Search agents..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onClick={(e) => e.stopPropagation()}

View File

@@ -146,7 +146,7 @@ export function AssistantStats({ assistantId }: { assistantId: number }) {
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<p className="text-base font-normal text-2xl">Assistant Analytics</p>
<p className="text-base font-normal text-2xl">Agent Analytics</p>
<AdminDateRangeSelector
value={dateRange}
onValueChange={setDateRange}

View File

@@ -72,6 +72,7 @@ export function ClientLayout({
enableEnterpriseSS={enableEnterprise}
/>
<div
data-main-container
className={cn(
"flex flex-1 flex-col min-w-0 min-h-0 overflow-y-auto",
!hasOwnLayout && "py-10 px-4 md:px-12"

View File

@@ -12,17 +12,17 @@ export default function NoAssistantModal() {
return (
<Modal open>
<Modal.Content width="sm" height="sm">
<Modal.Header icon={SvgUser} title="No Assistant Available" />
<Modal.Header icon={SvgUser} title="No Agent Available" />
<Modal.Body>
<Text as="p">
You currently have no assistant configured. To use this feature, you
You currently have no agent configured. To use this feature, you
need to take action.
</Text>
{isAdmin ? (
<>
<Text as="p">
As an administrator, you can create a new assistant by visiting
the admin panel.
As an administrator, you can create a new agent by visiting the
admin panel.
</Text>
<Button className="w-full" href="/admin/assistants">
Go to Admin Panel
@@ -30,8 +30,7 @@ export default function NoAssistantModal() {
</>
) : (
<Text as="p">
Please contact your administrator to configure an assistant for
you.
Please contact your administrator to configure an agent for you.
</Text>
)}
</Modal.Body>

View File

@@ -0,0 +1,44 @@
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
const HEALTH_ENDPOINT = "/api/admin/code-interpreter/health";
const STATUS_ENDPOINT = "/api/admin/code-interpreter";
interface CodeInterpreterHealth {
healthy: boolean;
}
interface CodeInterpreterStatus {
enabled: boolean;
}
export default function useCodeInterpreter() {
const {
data: healthData,
error: healthError,
isLoading: isHealthLoading,
mutate: refetchHealth,
} = useSWR<CodeInterpreterHealth>(HEALTH_ENDPOINT, errorHandlingFetcher, {
refreshInterval: 30000,
});
const {
data: statusData,
error: statusError,
isLoading: isStatusLoading,
mutate: refetchStatus,
} = useSWR<CodeInterpreterStatus>(STATUS_ENDPOINT, errorHandlingFetcher);
function refetch() {
refetchHealth();
refetchStatus();
}
return {
isHealthy: healthData?.healthy ?? false,
isEnabled: statusData?.enabled ?? false,
isLoading: isHealthLoading || isStatusLoading,
error: healthError || statusError,
refetch,
};
}

View File

@@ -0,0 +1,15 @@
const UPDATE_ENDPOINT = "/api/admin/code-interpreter";
interface CodeInterpreterUpdateRequest {
enabled: boolean;
}
export async function updateCodeInterpreter(
request: CodeInterpreterUpdateRequest
): Promise<Response> {
return fetch(UPDATE_ENDPOINT, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
}

View File

@@ -179,7 +179,7 @@ export default function ActionLineItem({
)}
{isSearchToolAndNotInProject && (
<IconButton
<Button
icon={
isSearchToolWithNoConnectors ? SvgSettings : SvgChevronRight
}
@@ -188,11 +188,8 @@ export default function ActionLineItem({
router.push("/admin/add-connector");
else onSourceManagementOpen?.();
})}
internal
className={cn(
isSearchToolWithNoConnectors &&
"invisible group-hover/LineItem:visible"
)}
prominence="tertiary"
size="sm"
tooltip={
isSearchToolWithNoConnectors
? "Add Connectors"

View File

@@ -425,7 +425,7 @@ export default function AgentsNavigationPage() {
>
<SettingsLayouts.Header
icon={SvgOnyxOctagon}
title="Agents & Assistants"
title="Agents"
description="Customize AI behavior and knowledge for you and your team's use cases."
rightChildren={
<Button

View File

@@ -0,0 +1,241 @@
"use client";
import React, { useState } from "react";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { Card, type CardProps } from "@/refresh-components/cards";
import {
SvgArrowExchange,
SvgCheckCircle,
SvgRefreshCw,
SvgTerminal,
SvgUnplug,
SvgXOctagon,
} from "@opal/icons";
import { Section } from "@/layouts/general-layouts";
import { Button } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
import useCodeInterpreter from "@/hooks/useCodeInterpreter";
import { updateCodeInterpreter } from "@/lib/admin/code-interpreter/svc";
import { ContentAction } from "@opal/layouts";
import { toast } from "@/hooks/useToast";
interface CodeInterpreterCardProps {
variant?: CardProps["variant"];
title: string;
middleText?: string;
strikethrough?: boolean;
rightContent: React.ReactNode;
}
function CodeInterpreterCard({
variant,
title,
middleText,
strikethrough,
rightContent,
}: CodeInterpreterCardProps) {
return (
// TODO (@raunakab): Allow Content to accept strikethrough and middleText
<Card variant={variant} padding={0.5}>
<ContentAction
icon={SvgTerminal}
title={middleText ? `${title} ${middleText}` : title}
description="Built-in Python runtime"
variant="section"
sizePreset="main-ui"
rightChildren={rightContent}
/>
</Card>
);
}
function CheckingStatus() {
return (
<Section
flexDirection="row"
justifyContent="end"
alignItems="center"
gap={0.25}
padding={0.5}
>
<Text mainUiAction text03>
Checking...
</Text>
<SimpleLoader />
</Section>
);
}
interface ConnectionStatusProps {
healthy: boolean;
isLoading: boolean;
}
function ConnectionStatus({ healthy, isLoading }: ConnectionStatusProps) {
if (isLoading) {
return <CheckingStatus />;
}
const label = healthy ? "Connected" : "Connection Lost";
const Icon = healthy ? SvgCheckCircle : SvgXOctagon;
const iconColor = healthy ? "text-status-success-05" : "text-status-error-05";
return (
<Section
flexDirection="row"
justifyContent="end"
alignItems="center"
gap={0.25}
padding={0.5}
>
<Text mainUiAction text03>
{label}
</Text>
<Icon size={16} className={iconColor} />
</Section>
);
}
interface ActionButtonsProps {
onDisconnect: () => void;
onRefresh: () => void;
disabled?: boolean;
}
function ActionButtons({
onDisconnect,
onRefresh,
disabled,
}: ActionButtonsProps) {
return (
<Section
flexDirection="row"
justifyContent="end"
alignItems="center"
gap={0.25}
padding={0.25}
>
<Button
prominence="tertiary"
size="sm"
icon={SvgUnplug}
onClick={onDisconnect}
tooltip="Disconnect"
disabled={disabled}
/>
<Button
prominence="tertiary"
size="sm"
icon={SvgRefreshCw}
onClick={onRefresh}
tooltip="Refresh"
disabled={disabled}
/>
</Section>
);
}
export default function CodeInterpreterPage() {
const { isHealthy, isEnabled, isLoading, refetch } = useCodeInterpreter();
const [showDisconnectModal, setShowDisconnectModal] = useState(false);
const [isReconnecting, setIsReconnecting] = useState(false);
async function handleToggle(enabled: boolean) {
const action = enabled ? "reconnect" : "disconnect";
setIsReconnecting(enabled);
try {
const response = await updateCodeInterpreter({ enabled });
if (!response.ok) {
toast.error(`Failed to ${action} Code Interpreter`);
return;
}
setShowDisconnectModal(false);
refetch();
} finally {
setIsReconnecting(false);
}
}
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgTerminal}
title="Code Interpreter"
description="Safe and sandboxed Python runtime available to your LLM. See docs for more details."
separator
/>
<SettingsLayouts.Body>
{isEnabled || isLoading ? (
<CodeInterpreterCard
title="Code Interpreter"
variant={isHealthy ? "primary" : "secondary"}
strikethrough={!isHealthy}
rightContent={
<Section
flexDirection="column"
justifyContent="center"
alignItems="end"
gap={0}
padding={0}
>
<ConnectionStatus healthy={isHealthy} isLoading={isLoading} />
<ActionButtons
onDisconnect={() => setShowDisconnectModal(true)}
onRefresh={refetch}
disabled={isLoading}
/>
</Section>
}
/>
) : (
<CodeInterpreterCard
variant="secondary"
title="Code Interpreter"
middleText="(Disconnected)"
strikethrough={true}
rightContent={
<Section flexDirection="row" alignItems="center" padding={0.5}>
{isReconnecting ? (
<CheckingStatus />
) : (
<Button
prominence="tertiary"
rightIcon={SvgArrowExchange}
onClick={() => handleToggle(true)}
>
Reconnect
</Button>
)}
</Section>
}
/>
)}
</SettingsLayouts.Body>
{showDisconnectModal && (
<ConfirmationModalLayout
icon={SvgUnplug}
title="Disconnect Code Interpreter"
onClose={() => setShowDisconnectModal(false)}
submit={
<Button variant="danger" onClick={() => handleToggle(false)}>
Disconnect
</Button>
}
>
<Text as="p" text03>
All running sessions connected to{" "}
<Text as="span" mainContentEmphasis text03>
Code Interpreter
</Text>{" "}
will stop working. Note that this will not remove any data from your
runtime. You can reconnect to this runtime later if needed.
</Text>
</ConfirmationModalLayout>
)}
</SettingsLayouts.Root>
);
}

View File

@@ -119,7 +119,7 @@ export default function NewTenantModal({
: `Your request to join ${tenantInfo.number_of_users} other users of ${APP_DOMAIN} has been approved.`;
const description = isInvite
? `By accepting this invitation, you will join the existing ${APP_DOMAIN} team and lose access to your current team. Note: you will lose access to your current assistants, prompts, chats, and connected sources.`
? `By accepting this invitation, you will join the existing ${APP_DOMAIN} team and lose access to your current team. Note: you will lose access to your current agents, prompts, chats, and connected sources.`
: `To finish joining your team, please reauthenticate with ${user?.email}.`;
return (

View File

@@ -50,6 +50,7 @@ import {
SvgPaintBrush,
SvgDiscordMono,
SvgWallet,
SvgTerminal,
} from "@opal/icons";
import SvgMcp from "@opal/icons/mcp";
import UserAvatarPopover from "@/sections/sidebar/UserAvatarPopover";
@@ -91,7 +92,7 @@ const custom_assistants_items = (
) => {
const items = [
{
name: "Assistants",
name: "Agents",
icon: SvgOnyxOctagon,
link: "/admin/assistants",
},
@@ -165,7 +166,7 @@ const collections = (
]
: []),
{
name: "Custom Assistants",
name: "Custom Agents",
items: custom_assistants_items(isCurator, enableEnterprise),
},
...(isCurator && enableEnterprise
@@ -207,6 +208,11 @@ const collections = (
icon: SvgImage,
link: "/admin/configuration/image-generation",
},
{
name: "Code Interpreter",
icon: SvgTerminal,
link: "/admin/configuration/code-interpreter",
},
...(!enableCloud && vectorDbEnabled
? [
{

View File

@@ -29,12 +29,12 @@ const ADMIN_PAGES: AdminPageSnapshot[] = [
pageTitle: "Add Connector",
},
{
name: "Custom Assistants - Assistants",
name: "Custom Agents - Agents",
path: "assistants",
pageTitle: "Assistants",
pageTitle: "Agents",
options: {
paragraphText:
"Assistants are a way to build custom search/question-answering experiences for different use cases.",
"Agents are a way to build custom search/question-answering experiences for different use cases.",
},
},
{
@@ -52,7 +52,7 @@ const ADMIN_PAGES: AdminPageSnapshot[] = [
},
},
{
name: "Custom Assistants - Slack Bots",
name: "Custom Agents - Slack Bots",
path: "bots",
pageTitle: "Slack Bots",
options: {
@@ -61,7 +61,7 @@ const ADMIN_PAGES: AdminPageSnapshot[] = [
},
},
{
name: "Custom Assistants - Standard Answers",
name: "Custom Agents - Standard Answers",
path: "standard-answer",
pageTitle: "Standard Answers",
},
@@ -101,12 +101,12 @@ const ADMIN_PAGES: AdminPageSnapshot[] = [
pageTitle: "Search Settings",
},
{
name: "Custom Assistants - MCP Actions",
name: "Custom Agents - MCP Actions",
path: "actions/mcp",
pageTitle: "MCP Actions",
},
{
name: "Custom Assistants - OpenAPI Actions",
name: "Custom Agents - OpenAPI Actions",
path: "actions/open-api",
pageTitle: "OpenAPI Actions",
},

View File

@@ -0,0 +1,268 @@
import { test, expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import { loginAs } from "@tests/e2e/utils/auth";
const CODE_INTERPRETER_URL = "/admin/configuration/code-interpreter";
const API_STATUS_URL = "**/api/admin/code-interpreter";
const API_HEALTH_URL = "**/api/admin/code-interpreter/health";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Intercept the status (GET /) and health (GET /health) endpoints with the
* given values so the page renders deterministically.
*
* Also handles PUT requests — by default they succeed (200). Pass
* `putStatus` to simulate failures.
*/
async function mockCodeInterpreterApi(
page: Page,
opts: { enabled: boolean; healthy: boolean; putStatus?: number }
) {
const putStatus = opts.putStatus ?? 200;
await page.route(API_HEALTH_URL, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ healthy: opts.healthy }),
});
});
await page.route(API_STATUS_URL, async (route) => {
if (route.request().method() === "PUT") {
await route.fulfill({
status: putStatus,
contentType: "application/json",
body:
putStatus >= 400
? JSON.stringify({ detail: "Server Error" })
: JSON.stringify(null),
});
} else {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ enabled: opts.enabled }),
});
}
});
}
/**
* The disconnect icon button is an icon-only opal Button whose tooltip text
* is not exposed as an accessible name. Locate it by finding the first
* icon-only button (no label span) inside the card area.
*/
function getDisconnectIconButton(page: Page) {
return page
.locator("button:has(.opal-button):not(:has(.opal-button-label))")
.first();
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test.describe("Code Interpreter Admin Page", () => {
test.beforeEach(async ({ page }) => {
await page.context().clearCookies();
await loginAs(page, "admin");
});
test("page loads with header and description", async ({ page }) => {
await mockCodeInterpreterApi(page, { enabled: true, healthy: true });
await page.goto(CODE_INTERPRETER_URL);
await expect(page.locator('[aria-label="admin-page-title"]')).toHaveText(
/^Code Interpreter/,
{ timeout: 10000 }
);
await expect(page.getByText("Built-in Python runtime")).toBeVisible();
});
test("shows Connected status when enabled and healthy", async ({ page }) => {
await mockCodeInterpreterApi(page, { enabled: true, healthy: true });
await page.goto(CODE_INTERPRETER_URL);
await expect(page.getByText("Connected")).toBeVisible({ timeout: 10000 });
});
test("shows Connection Lost when enabled but unhealthy", async ({ page }) => {
await mockCodeInterpreterApi(page, { enabled: true, healthy: false });
await page.goto(CODE_INTERPRETER_URL);
await expect(page.getByText("Connection Lost")).toBeVisible({
timeout: 10000,
});
});
test("shows Reconnect button when disabled", async ({ page }) => {
await mockCodeInterpreterApi(page, { enabled: false, healthy: false });
await page.goto(CODE_INTERPRETER_URL);
await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("(Disconnected)")).toBeVisible();
});
test("disconnect flow opens modal and sends PUT request", async ({
page,
}) => {
await mockCodeInterpreterApi(page, { enabled: true, healthy: true });
await page.goto(CODE_INTERPRETER_URL);
await expect(page.getByText("Connected")).toBeVisible({ timeout: 10000 });
// Click the disconnect icon button
await getDisconnectIconButton(page).click();
// Modal should appear
await expect(page.getByText("Disconnect Code Interpreter")).toBeVisible();
await expect(
page.getByText("All running sessions connected to")
).toBeVisible();
// Click the danger Disconnect button in the modal
const modal = page.getByRole("dialog");
await modal.getByRole("button", { name: "Disconnect" }).click();
// Modal should close after successful disconnect
await expect(page.getByText("Disconnect Code Interpreter")).not.toBeVisible(
{ timeout: 5000 }
);
});
test("disconnect modal can be closed without disconnecting", async ({
page,
}) => {
await mockCodeInterpreterApi(page, { enabled: true, healthy: true });
await page.goto(CODE_INTERPRETER_URL);
await expect(page.getByText("Connected")).toBeVisible({ timeout: 10000 });
// Open modal
await getDisconnectIconButton(page).click();
await expect(page.getByText("Disconnect Code Interpreter")).toBeVisible();
// Close modal via Cancel button
const modal = page.getByRole("dialog");
await modal.getByRole("button", { name: "Cancel" }).click();
// Modal should be gone, page still shows Connected
await expect(
page.getByText("Disconnect Code Interpreter")
).not.toBeVisible();
await expect(page.getByText("Connected")).toBeVisible();
});
test("reconnect flow sends PUT with enabled=true", async ({ page }) => {
await mockCodeInterpreterApi(page, { enabled: false, healthy: false });
await page.goto(CODE_INTERPRETER_URL);
await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible({
timeout: 10000,
});
// Intercept the PUT and verify the payload
const putPromise = page.waitForRequest(
(req) =>
req.url().includes("/api/admin/code-interpreter") &&
req.method() === "PUT"
);
await page.getByRole("button", { name: "Reconnect" }).click();
const putReq = await putPromise;
expect(putReq.postDataJSON()).toEqual({ enabled: true });
});
test("shows Checking... while reconnect is in progress", async ({ page }) => {
// Use a single route handler that delays PUT responses
await page.route(API_HEALTH_URL, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ healthy: false }),
});
});
await page.route(API_STATUS_URL, async (route) => {
if (route.request().method() === "PUT") {
await new Promise((resolve) => setTimeout(resolve, 2000));
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(null),
});
} else {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ enabled: false }),
});
}
});
await page.goto(CODE_INTERPRETER_URL);
await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible({
timeout: 10000,
});
await page.getByRole("button", { name: "Reconnect" }).click();
// Should show Checking... while the request is in flight
await expect(page.getByText("Checking...")).toBeVisible({ timeout: 3000 });
});
test("shows error toast when disconnect fails", async ({ page }) => {
await mockCodeInterpreterApi(page, {
enabled: true,
healthy: true,
putStatus: 500,
});
await page.goto(CODE_INTERPRETER_URL);
await expect(page.getByText("Connected")).toBeVisible({ timeout: 10000 });
// Open modal and click disconnect
await getDisconnectIconButton(page).click();
const modal = page.getByRole("dialog");
await modal.getByRole("button", { name: "Disconnect" }).click();
// Error toast should appear
await expect(
page.getByText("Failed to disconnect Code Interpreter")
).toBeVisible({ timeout: 5000 });
});
test("shows error toast when reconnect fails", async ({ page }) => {
await mockCodeInterpreterApi(page, {
enabled: false,
healthy: false,
putStatus: 500,
});
await page.goto(CODE_INTERPRETER_URL);
await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible({
timeout: 10000,
});
await page.getByRole("button", { name: "Reconnect" }).click();
// Error toast should appear
await expect(
page.getByText("Failed to reconnect Code Interpreter")
).toBeVisible({ timeout: 5000 });
// Reconnect button should reappear (not stuck in Checking...)
await expect(page.getByRole("button", { name: "Reconnect" })).toBeVisible({
timeout: 5000,
});
});
});

View File

@@ -46,7 +46,7 @@ test.skip("User changes password and logs in with new password", async ({
// Verify successful login
await expect(page).toHaveURL("http://localhost:3000/app");
await expect(page.getByText("Explore Assistants")).toBeVisible();
await expect(page.getByText("Explore Agents")).toBeVisible();
});
test.use({ storageState: "admin2_auth.json" });
@@ -115,5 +115,5 @@ test.skip("Admin resets own password and logs in with new password", async ({
// Verify successful login
await expect(page).toHaveURL("http://localhost:3000/app");
await expect(page.getByText("Explore Assistants")).toBeVisible();
await expect(page.getByText("Explore Agents")).toBeVisible();
});

View File

@@ -16,7 +16,7 @@ import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
// Tool-related test selectors now imported from shared utils
test.describe("Default Assistant Tests", () => {
test.describe("Default Agent Tests", () => {
let imageGenConfigId: string | null = null;
test.beforeAll(async ({ browser }) => {
@@ -69,7 +69,7 @@ test.describe("Default Assistant Tests", () => {
});
test.describe("Greeting Message Display", () => {
test("should display greeting message when opening new chat with default assistant", async ({
test("should display greeting message when opening new chat with default agent", async ({
page,
}) => {
// Look for greeting message - should be one from the predefined list
@@ -95,23 +95,21 @@ test.describe("Default Assistant Tests", () => {
expect(GREETING_MESSAGES).toContain(greetingAfterReload?.trim());
});
test("greeting should only appear for default assistant", async ({
page,
}) => {
// First verify greeting appears for default assistant
test("greeting should only appear for default agent", async ({ page }) => {
// First verify greeting appears for default agent
const greetingElement = await page.waitForSelector(
'[data-testid="onyx-logo"]',
{ timeout: 5000 }
);
expect(greetingElement).toBeTruthy();
// Create a custom assistant to test non-default behavior
// Create a custom agent to test non-default behavior
await page.getByTestId("AppSidebar/more-agents").click();
await page.getByLabel("AgentsPage/new-agent-button").click();
await page
.locator('input[name="name"]')
.waitFor({ state: "visible", timeout: 10000 });
await page.locator('input[name="name"]').fill("Custom Test Assistant");
await page.locator('input[name="name"]').fill("Custom Test Agent");
await page
.locator('textarea[name="description"]')
.fill("Test Description");
@@ -120,17 +118,17 @@ test.describe("Default Assistant Tests", () => {
.fill("Test Instructions");
await page.getByRole("button", { name: "Create" }).click();
// Wait for assistant to be created and selected
await verifyAssistantIsChosen(page, "Custom Test Assistant");
// Wait for agent to be created and selected
await verifyAssistantIsChosen(page, "Custom Test Agent");
// Greeting should NOT appear for custom assistant
// Greeting should NOT appear for custom agent
const customGreeting = await page.$('[data-testid="onyx-logo"]');
expect(customGreeting).toBeNull();
});
});
test.describe("Default Assistant Branding", () => {
test("should display Onyx logo for default assistant", async ({ page }) => {
test.describe("Default Agent Branding", () => {
test("should display Onyx logo for default agent", async ({ page }) => {
// Look for Onyx logo
const logoElement = await page.waitForSelector(
'[data-testid="onyx-logo"]',
@@ -138,23 +136,23 @@ test.describe("Default Assistant Tests", () => {
);
expect(logoElement).toBeTruthy();
// Should NOT show assistant name for default assistant
// Should NOT show agent name for default agent
const assistantNameElement = await page.$(
'[data-testid="assistant-name-display"]'
);
expect(assistantNameElement).toBeNull();
});
test("custom assistants should show name and icon instead of logo", async ({
test("custom agents should show name and icon instead of logo", async ({
page,
}) => {
// Create a custom assistant
// Create a custom agent
await page.getByTestId("AppSidebar/more-agents").click();
await page.getByLabel("AgentsPage/new-agent-button").click();
await page
.locator('input[name="name"]')
.waitFor({ state: "visible", timeout: 10000 });
await page.locator('input[name="name"]').fill("Custom Assistant");
await page.locator('input[name="name"]').fill("Custom Agent");
await page
.locator('textarea[name="description"]')
.fill("Test Description");
@@ -163,16 +161,16 @@ test.describe("Default Assistant Tests", () => {
.fill("Test Instructions");
await page.getByRole("button", { name: "Create" }).click();
// Wait for assistant to be created and selected
await verifyAssistantIsChosen(page, "Custom Assistant");
// Wait for agent to be created and selected
await verifyAssistantIsChosen(page, "Custom Agent");
// Should show assistant name and icon, not Onyx logo
// Should show agent name and icon, not Onyx logo
const assistantNameElement = await page.waitForSelector(
'[data-testid="assistant-name-display"]',
{ timeout: 5000 }
);
const nameText = await assistantNameElement.textContent();
expect(nameText).toContain("Custom Assistant");
expect(nameText).toContain("Custom Agent");
// Onyx logo should NOT be shown
const logoElement = await page.$('[data-testid="onyx-logo"]');
@@ -181,10 +179,8 @@ test.describe("Default Assistant Tests", () => {
});
test.describe("Starter Messages", () => {
test("default assistant should NOT have starter messages", async ({
page,
}) => {
// Check that starter messages container does not exist for default assistant
test("default agent should NOT have starter messages", async ({ page }) => {
// Check that starter messages container does not exist for default agent
const starterMessagesContainer = await page.$(
'[data-testid="starter-messages"]'
);
@@ -195,18 +191,14 @@ test.describe("Default Assistant Tests", () => {
expect(starterButtons.length).toBe(0);
});
test("custom assistants should display starter messages", async ({
page,
}) => {
// Create a custom assistant with starter messages
test("custom agents should display starter messages", async ({ page }) => {
// Create a custom agent with starter messages
await page.getByTestId("AppSidebar/more-agents").click();
await page.getByLabel("AgentsPage/new-agent-button").click();
await page
.locator('input[name="name"]')
.waitFor({ state: "visible", timeout: 10000 });
await page
.locator('input[name="name"]')
.fill("Test Assistant with Starters");
await page.locator('input[name="name"]').fill("Test Agent with Starters");
await page
.locator('textarea[name="description"]')
.fill("Test Description");
@@ -219,9 +211,9 @@ test.describe("Default Assistant Tests", () => {
await page.getByRole("button", { name: "Create" }).click();
// Wait for assistant to be created and selected
await verifyAssistantIsChosen(page, "Test Assistant with Starters");
await verifyAssistantIsChosen(page, "Test Agent with Starters");
// Starter messages container might exist but be empty for custom assistants
// Starter messages container might exist but be empty for custom agents
const starterMessagesContainer = await page.$(
'[data-testid="starter-messages"]'
);
@@ -230,24 +222,22 @@ test.describe("Default Assistant Tests", () => {
const starterButtons = await page.$$(
'[data-testid^="starter-message-"]'
);
// Custom assistant without configured starter messages should have none
// Custom agent without configured starter messages should have none
expect(starterButtons.length).toBe(0);
}
});
});
test.describe("Assistant Selection", () => {
test("default assistant should be selected for new chats", async ({
page,
}) => {
// Verify the input placeholder indicates default assistant (Onyx)
test.describe("Agent Selection", () => {
test("default agent should be selected for new chats", async ({ page }) => {
// Verify the input placeholder indicates default agent (Onyx)
await verifyDefaultAssistantIsChosen(page);
});
test("default assistant should NOT appear in assistant selector", async ({
test("default agent should NOT appear in agent selector", async ({
page,
}) => {
// Open assistant selector
// Open agent selector
await page.getByTestId("AppSidebar/more-agents").click();
// Wait for modal or assistant list to appear
@@ -256,13 +246,13 @@ test.describe("Default Assistant Tests", () => {
.getByLabel("AgentsPage/new-agent-button")
.waitFor({ state: "visible", timeout: 5000 });
// Look for default assistant by name - it should NOT be there
// Look for default agent by name - it should NOT be there
const assistantElements = await page.$$('[data-testid^="assistant-"]');
const assistantTexts = await Promise.all(
assistantElements.map((el) => el.textContent())
);
// Check that "Assistant" (the default assistant name) is not in the list
// Check that the default agent is not in the list
const hasDefaultAssistant = assistantTexts.some(
(text) =>
text?.includes("Assistant") &&
@@ -275,16 +265,16 @@ test.describe("Default Assistant Tests", () => {
await page.keyboard.press("Escape");
});
test("should be able to switch from default to custom assistant", async ({
test("should be able to switch from default to custom agent", async ({
page,
}) => {
// Create a custom assistant
// Create a custom agent
await page.getByTestId("AppSidebar/more-agents").click();
await page.getByLabel("AgentsPage/new-agent-button").click();
await page
.locator('input[name="name"]')
.waitFor({ state: "visible", timeout: 10000 });
await page.locator('input[name="name"]').fill("Switch Test Assistant");
await page.locator('input[name="name"]').fill("Switch Test Agent");
await page
.locator('textarea[name="description"]')
.fill("Test Description");
@@ -293,13 +283,13 @@ test.describe("Default Assistant Tests", () => {
.fill("Test Instructions");
await page.getByRole("button", { name: "Create" }).click();
// Verify switched to custom assistant
await verifyAssistantIsChosen(page, "Switch Test Assistant");
// Verify switched to custom agent
await verifyAssistantIsChosen(page, "Switch Test Agent");
// Start new chat to go back to default
await startNewChat(page);
// Should be back to default assistant
// Should be back to default agent
await verifyDefaultAssistantIsChosen(page);
});
});
@@ -379,7 +369,7 @@ test.describe("Default Assistant Tests", () => {
);
}
// Enable the tools in default assistant config via API
// Enable the tools in default agent config via API
// Get current tools to find their IDs
const toolsListResp = await page.request.get(
"http://localhost:3000/api/tool"
@@ -542,7 +532,7 @@ test.describe("Default Assistant Tests", () => {
});
});
test.describe("End-to-End Default Assistant Flow", () => {
test.describe("End-to-End Default Agent Flow", () => {
let imageGenConfigId: string | null = null;
test.beforeAll(async ({ browser }) => {
@@ -584,7 +574,7 @@ test.describe("End-to-End Default Assistant Flow", () => {
}
});
test("complete user journey with default assistant", async ({ page }) => {
test("complete user journey with default agent", async ({ page }) => {
// Clear cookies and log in as a random user
await page.context().clearCookies();
await loginAsRandomUser(page);
@@ -611,7 +601,7 @@ test.describe("End-to-End Default Assistant Flow", () => {
// Start a new chat
await startNewChat(page);
// Verify we're back to default assistant with greeting
// Verify we're back to default agent with greeting
await expect(page.locator('[data-testid="onyx-logo"]')).toBeVisible();
});
});