Compare commits

..

3 Commits

Author SHA1 Message Date
Dane Urban
21ca9715dd . 2026-03-03 10:15:58 -08:00
Dane Urban
3700bee34f . 2026-03-03 10:14:45 -08:00
Dane Urban
55117fccf2 . 2026-03-03 10:07:34 -08:00
97 changed files with 756 additions and 3026 deletions

View File

@@ -15,7 +15,6 @@ permissions:
jobs:
provider-chat-test:
uses: ./.github/workflows/reusable-nightly-llm-provider-chat.yml
secrets: inherit
permissions:
contents: read
id-token: write

View File

@@ -471,13 +471,13 @@ jobs:
path: ${{ github.workspace }}/docker-compose.log
# ------------------------------------------------------------
onyx-lite-tests:
no-vectordb-tests:
needs: [build-backend-image, build-integration-image]
runs-on:
[
runs-on,
runner=4cpu-linux-arm64,
"run-id=${{ github.run_id }}-onyx-lite-tests",
"run-id=${{ github.run_id }}-no-vectordb-tests",
"extras=ecr-cache",
]
timeout-minutes: 45
@@ -495,12 +495,13 @@ jobs:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Create .env file for Onyx Lite Docker Compose
- name: Create .env file for no-vectordb Docker Compose
env:
ECR_CACHE: ${{ env.RUNS_ON_ECR_CACHE }}
RUN_ID: ${{ github.run_id }}
run: |
cat <<EOF > deployment/docker_compose/.env
COMPOSE_PROFILES=s3-filestore
ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true
LICENSE_ENFORCEMENT_ENABLED=false
AUTH_TYPE=basic
@@ -508,23 +509,28 @@ jobs:
POSTGRES_USE_NULL_POOL=true
REQUIRE_EMAIL_VERIFICATION=false
DISABLE_TELEMETRY=true
DISABLE_VECTOR_DB=true
ONYX_BACKEND_IMAGE=${ECR_CACHE}:integration-test-backend-test-${RUN_ID}
INTEGRATION_TESTS_MODE=true
USE_LIGHTWEIGHT_BACKGROUND_WORKER=true
EOF
# Start only the services needed for Onyx Lite (Postgres + API server)
- name: Start Docker containers (onyx-lite)
# Start only the services needed for no-vectordb mode (no Vespa, no model servers)
- name: Start Docker containers (no-vectordb)
run: |
cd deployment/docker_compose
docker compose -f docker-compose.yml -f docker-compose.onyx-lite.yml -f docker-compose.dev.yml up \
docker compose -f docker-compose.yml -f docker-compose.no-vectordb.yml -f docker-compose.dev.yml up \
relational_db \
cache \
minio \
api_server \
background \
-d
id: start_docker_onyx_lite
id: start_docker_no_vectordb
- name: Wait for services to be ready
run: |
echo "Starting wait-for-service script (onyx-lite)..."
echo "Starting wait-for-service script (no-vectordb)..."
start_time=$(date +%s)
timeout=300
while true; do
@@ -546,14 +552,14 @@ jobs:
sleep 5
done
- name: Run Onyx Lite Integration Tests
- name: Run No-VectorDB Integration Tests
uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # ratchet:nick-fields/retry@v3
with:
timeout_minutes: 20
max_attempts: 3
retry_wait_seconds: 10
command: |
echo "Running onyx-lite integration tests..."
echo "Running no-vectordb integration tests..."
docker run --rm --network onyx_default \
--name test-runner \
-e POSTGRES_HOST=relational_db \
@@ -564,38 +570,39 @@ jobs:
-e DB_READONLY_PASSWORD=password \
-e POSTGRES_POOL_PRE_PING=true \
-e POSTGRES_USE_NULL_POOL=true \
-e REDIS_HOST=cache \
-e API_SERVER_HOST=api_server \
-e OPENAI_API_KEY=${OPENAI_API_KEY} \
-e TEST_WEB_HOSTNAME=test-runner \
${{ env.RUNS_ON_ECR_CACHE }}:integration-test-${{ github.run_id }} \
/app/tests/integration/tests/no_vectordb
- name: Dump API server logs (onyx-lite)
- name: Dump API server logs (no-vectordb)
if: always()
run: |
cd deployment/docker_compose
docker compose -f docker-compose.yml -f docker-compose.onyx-lite.yml -f docker-compose.dev.yml \
logs --no-color api_server > $GITHUB_WORKSPACE/api_server_onyx_lite.log || true
docker compose -f docker-compose.yml -f docker-compose.no-vectordb.yml -f docker-compose.dev.yml \
logs --no-color api_server > $GITHUB_WORKSPACE/api_server_no_vectordb.log || true
- name: Dump all-container logs (onyx-lite)
- name: Dump all-container logs (no-vectordb)
if: always()
run: |
cd deployment/docker_compose
docker compose -f docker-compose.yml -f docker-compose.onyx-lite.yml -f docker-compose.dev.yml \
logs --no-color > $GITHUB_WORKSPACE/docker-compose-onyx-lite.log || true
docker compose -f docker-compose.yml -f docker-compose.no-vectordb.yml -f docker-compose.dev.yml \
logs --no-color > $GITHUB_WORKSPACE/docker-compose-no-vectordb.log || true
- name: Upload logs (onyx-lite)
- name: Upload logs (no-vectordb)
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
with:
name: docker-all-logs-onyx-lite
path: ${{ github.workspace }}/docker-compose-onyx-lite.log
name: docker-all-logs-no-vectordb
path: ${{ github.workspace }}/docker-compose-no-vectordb.log
- name: Stop Docker containers (onyx-lite)
- name: Stop Docker containers (no-vectordb)
if: always()
run: |
cd deployment/docker_compose
docker compose -f docker-compose.yml -f docker-compose.onyx-lite.yml -f docker-compose.dev.yml down -v
docker compose -f docker-compose.yml -f docker-compose.no-vectordb.yml -f docker-compose.dev.yml down -v
multitenant-tests:
needs:
@@ -737,7 +744,7 @@ jobs:
# NOTE: Github-hosted runners have about 20s faster queue times and are preferred here.
runs-on: ubuntu-slim
timeout-minutes: 45
needs: [integration-tests, onyx-lite-tests, multitenant-tests]
needs: [integration-tests, no-vectordb-tests, multitenant-tests]
if: ${{ always() }}
steps:
- name: Check job status

View File

@@ -617,45 +617,6 @@ Keep it high level. You can reference certain files or functions though.
Before writing your plan, make sure to do research. Explore the relevant sections in the codebase.
## Error Handling
**Always raise `OnyxError` from `onyx.error_handling.exceptions` instead of `HTTPException`.
Never hardcode status codes or use `starlette.status` / `fastapi.status` constants directly.**
A global FastAPI exception handler converts `OnyxError` into a JSON response with the standard
`{"error_code": "...", "message": "..."}` shape. This eliminates boilerplate and keeps error
handling consistent across the entire backend.
```python
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
# ✅ Good
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Session not found")
# ✅ Good — no extra message needed
raise OnyxError(OnyxErrorCode.UNAUTHENTICATED)
# ✅ Good — upstream service with dynamic status code
raise OnyxError(OnyxErrorCode.BAD_GATEWAY, detail, status_code_override=upstream_status)
# ❌ Bad — using HTTPException directly
raise HTTPException(status_code=404, detail="Session not found")
# ❌ Bad — starlette constant
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
```
Available error codes are defined in `backend/onyx/error_handling/error_codes.py`. If a new error
category is needed, add it there first — do not invent ad-hoc codes.
**Upstream service errors:** When forwarding errors from an upstream service where the HTTP
status code is dynamic (comes from the upstream response), use `status_code_override`:
```python
raise OnyxError(OnyxErrorCode.BAD_GATEWAY, detail, status_code_override=e.response.status_code)
```
## Best Practices
In addition to the other content in this file, best practices for contributing

View File

@@ -11,10 +11,11 @@ from ee.onyx.server.license.models import LicenseMetadata
from ee.onyx.server.license.models import LicensePayload
from ee.onyx.server.license.models import LicenseSource
from onyx.auth.schemas import UserRole
from onyx.cache.factory import get_cache_backend
from onyx.configs.constants import ANONYMOUS_USER_EMAIL
from onyx.db.models import License
from onyx.db.models import User
from onyx.redis.redis_pool import get_redis_client
from onyx.redis.redis_pool import get_redis_replica_client
from onyx.utils.logger import setup_logger
from shared_configs.configs import MULTI_TENANT
from shared_configs.contextvars import get_current_tenant_id
@@ -141,7 +142,7 @@ def get_used_seats(tenant_id: str | None = None) -> int:
def get_cached_license_metadata(tenant_id: str | None = None) -> LicenseMetadata | None:
"""
Get license metadata from cache.
Get license metadata from Redis cache.
Args:
tenant_id: Tenant ID (for multi-tenant deployments)
@@ -149,34 +150,38 @@ def get_cached_license_metadata(tenant_id: str | None = None) -> LicenseMetadata
Returns:
LicenseMetadata if cached, None otherwise
"""
cache = get_cache_backend(tenant_id=tenant_id)
cached = cache.get(LICENSE_METADATA_KEY)
if not cached:
return None
tenant = tenant_id or get_current_tenant_id()
redis_client = get_redis_replica_client(tenant_id=tenant)
try:
cached_str = (
cached.decode("utf-8") if isinstance(cached, bytes) else str(cached)
)
return LicenseMetadata.model_validate_json(cached_str)
except Exception as e:
logger.warning(f"Failed to parse cached license metadata: {e}")
return None
cached = redis_client.get(LICENSE_METADATA_KEY)
if cached:
try:
cached_str: str
if isinstance(cached, bytes):
cached_str = cached.decode("utf-8")
else:
cached_str = str(cached)
return LicenseMetadata.model_validate_json(cached_str)
except Exception as e:
logger.warning(f"Failed to parse cached license metadata: {e}")
return None
return None
def invalidate_license_cache(tenant_id: str | None = None) -> None:
"""
Invalidate the license metadata cache (not the license itself).
Deletes the cached LicenseMetadata. The actual license in the database
is not affected. Delete is idempotent if the key doesn't exist, this
is a no-op.
This deletes the cached LicenseMetadata from Redis. The actual license
in the database is not affected. Redis delete is idempotent - if the
key doesn't exist, this is a no-op.
Args:
tenant_id: Tenant ID (for multi-tenant deployments)
"""
cache = get_cache_backend(tenant_id=tenant_id)
cache.delete(LICENSE_METADATA_KEY)
tenant = tenant_id or get_current_tenant_id()
redis_client = get_redis_client(tenant_id=tenant)
redis_client.delete(LICENSE_METADATA_KEY)
logger.info("License cache invalidated")
@@ -187,7 +192,7 @@ def update_license_cache(
tenant_id: str | None = None,
) -> LicenseMetadata:
"""
Update the cache with license metadata.
Update the Redis cache with license metadata.
We cache all license statuses (ACTIVE, GRACE_PERIOD, GATED_ACCESS) because:
1. Frontend needs status to show appropriate UI/banners
@@ -206,7 +211,7 @@ def update_license_cache(
from ee.onyx.utils.license import get_license_status
tenant = tenant_id or get_current_tenant_id()
cache = get_cache_backend(tenant_id=tenant_id)
redis_client = get_redis_client(tenant_id=tenant)
used_seats = get_used_seats(tenant)
status = get_license_status(payload, grace_period_end)
@@ -225,7 +230,7 @@ def update_license_cache(
stripe_subscription_id=payload.stripe_subscription_id,
)
cache.set(
redis_client.set(
LICENSE_METADATA_KEY,
metadata.model_dump_json(),
ex=LICENSE_CACHE_TTL_SECONDS,

View File

@@ -26,6 +26,7 @@ import asyncio
import httpx
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -41,6 +42,7 @@ from ee.onyx.server.billing.models import SeatUpdateRequest
from ee.onyx.server.billing.models import SeatUpdateResponse
from ee.onyx.server.billing.models import StripePublishableKeyResponse
from ee.onyx.server.billing.models import SubscriptionStatusResponse
from ee.onyx.server.billing.service import BillingServiceError
from ee.onyx.server.billing.service import (
create_checkout_session as create_checkout_service,
)
@@ -56,8 +58,6 @@ from onyx.configs.app_configs import STRIPE_PUBLISHABLE_KEY_OVERRIDE
from onyx.configs.app_configs import STRIPE_PUBLISHABLE_KEY_URL
from onyx.configs.app_configs import WEB_DOMAIN
from onyx.db.engine.sql_engine import get_session
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.redis.redis_pool import get_shared_redis_client
from onyx.utils.logger import setup_logger
from shared_configs.configs import MULTI_TENANT
@@ -169,23 +169,26 @@ async def create_checkout_session(
if seats is not None:
used_seats = get_used_seats(tenant_id)
if seats < used_seats:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
f"Cannot subscribe with fewer seats than current usage. "
raise HTTPException(
status_code=400,
detail=f"Cannot subscribe with fewer seats than current usage. "
f"You have {used_seats} active users/integrations but requested {seats} seats.",
)
# Build redirect URL for after checkout completion
redirect_url = f"{WEB_DOMAIN}/admin/billing?checkout=success"
return await create_checkout_service(
billing_period=billing_period,
seats=seats,
email=email,
license_data=license_data,
redirect_url=redirect_url,
tenant_id=tenant_id,
)
try:
return await create_checkout_service(
billing_period=billing_period,
seats=seats,
email=email,
license_data=license_data,
redirect_url=redirect_url,
tenant_id=tenant_id,
)
except BillingServiceError as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
@router.post("/create-customer-portal-session")
@@ -203,15 +206,18 @@ async def create_customer_portal_session(
# Self-hosted requires license
if not MULTI_TENANT and not license_data:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, "No license found")
raise HTTPException(status_code=400, detail="No license found")
return_url = request.return_url if request else f"{WEB_DOMAIN}/admin/billing"
return await create_portal_service(
license_data=license_data,
return_url=return_url,
tenant_id=tenant_id,
)
try:
return await create_portal_service(
license_data=license_data,
return_url=return_url,
tenant_id=tenant_id,
)
except BillingServiceError as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
@router.get("/billing-information")
@@ -234,9 +240,9 @@ async def get_billing_information(
# Check circuit breaker (self-hosted only)
if _is_billing_circuit_open():
raise OnyxError(
OnyxErrorCode.SERVICE_UNAVAILABLE,
"Stripe connection temporarily disabled. Click 'Connect to Stripe' to retry.",
raise HTTPException(
status_code=503,
detail="Stripe connection temporarily disabled. Click 'Connect to Stripe' to retry.",
)
try:
@@ -244,11 +250,11 @@ async def get_billing_information(
license_data=license_data,
tenant_id=tenant_id,
)
except OnyxError as e:
except BillingServiceError as e:
# Open circuit breaker on connection failures (self-hosted only)
if e.status_code in (502, 503, 504):
_open_billing_circuit()
raise
raise HTTPException(status_code=e.status_code, detail=e.message)
@router.post("/seats/update")
@@ -268,25 +274,31 @@ async def update_seats(
# Self-hosted requires license
if not MULTI_TENANT and not license_data:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, "No license found")
raise HTTPException(status_code=400, detail="No license found")
# Validate that new seat count is not less than current used seats
used_seats = get_used_seats(tenant_id)
if request.new_seat_count < used_seats:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
f"Cannot reduce seats below current usage. "
raise HTTPException(
status_code=400,
detail=f"Cannot reduce seats below current usage. "
f"You have {used_seats} active users/integrations but requested {request.new_seat_count} seats.",
)
# Note: Don't store license here - the control plane may still be processing
# the subscription update. The frontend should call /license/claim after a
# short delay to get the freshly generated license.
return await update_seat_service(
new_seat_count=request.new_seat_count,
license_data=license_data,
tenant_id=tenant_id,
)
try:
result = await update_seat_service(
new_seat_count=request.new_seat_count,
license_data=license_data,
tenant_id=tenant_id,
)
# Note: Don't store license here - the control plane may still be processing
# the subscription update. The frontend should call /license/claim after a
# short delay to get the freshly generated license.
return result
except BillingServiceError as e:
raise HTTPException(status_code=e.status_code, detail=e.message)
@router.get("/stripe-publishable-key")
@@ -317,18 +329,18 @@ async def get_stripe_publishable_key() -> StripePublishableKeyResponse:
if STRIPE_PUBLISHABLE_KEY_OVERRIDE:
key = STRIPE_PUBLISHABLE_KEY_OVERRIDE.strip()
if not key.startswith("pk_"):
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Invalid Stripe publishable key format",
raise HTTPException(
status_code=500,
detail="Invalid Stripe publishable key format",
)
_stripe_publishable_key_cache = key
return StripePublishableKeyResponse(publishable_key=key)
# Fall back to S3 bucket
if not STRIPE_PUBLISHABLE_KEY_URL:
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Stripe publishable key is not configured",
raise HTTPException(
status_code=500,
detail="Stripe publishable key is not configured",
)
try:
@@ -339,17 +351,17 @@ async def get_stripe_publishable_key() -> StripePublishableKeyResponse:
# Validate key format
if not key.startswith("pk_"):
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Invalid Stripe publishable key format",
raise HTTPException(
status_code=500,
detail="Invalid Stripe publishable key format",
)
_stripe_publishable_key_cache = key
return StripePublishableKeyResponse(publishable_key=key)
except httpx.HTTPError:
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Failed to fetch Stripe publishable key",
raise HTTPException(
status_code=500,
detail="Failed to fetch Stripe publishable key",
)

View File

@@ -22,8 +22,6 @@ from ee.onyx.server.billing.models import SeatUpdateResponse
from ee.onyx.server.billing.models import SubscriptionStatusResponse
from ee.onyx.server.tenants.access import generate_data_plane_token
from onyx.configs.app_configs import CONTROL_PLANE_API_BASE_URL
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.utils.logger import setup_logger
from shared_configs.configs import MULTI_TENANT
@@ -33,6 +31,15 @@ logger = setup_logger()
_REQUEST_TIMEOUT = 30.0
class BillingServiceError(Exception):
"""Exception raised for billing service errors."""
def __init__(self, message: str, status_code: int = 500):
self.message = message
self.status_code = status_code
super().__init__(self.message)
def _get_proxy_headers(license_data: str | None) -> dict[str, str]:
"""Build headers for proxy requests (self-hosted).
@@ -94,7 +101,7 @@ async def _make_billing_request(
Response JSON as dict
Raises:
OnyxError: If request fails
BillingServiceError: If request fails
"""
base_url = _get_base_url()
@@ -121,17 +128,11 @@ async def _make_billing_request(
except Exception:
pass
logger.error(f"{error_message}: {e.response.status_code} - {detail}")
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
detail,
status_code_override=e.response.status_code,
)
raise BillingServiceError(detail, e.response.status_code)
except httpx.RequestError:
logger.exception("Failed to connect to billing service")
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY, "Failed to connect to billing service"
)
raise BillingServiceError("Failed to connect to billing service", 502)
async def create_checkout_session(

View File

@@ -14,6 +14,7 @@ import requests
from fastapi import APIRouter
from fastapi import Depends
from fastapi import File
from fastapi import HTTPException
from fastapi import UploadFile
from sqlalchemy.orm import Session
@@ -34,8 +35,6 @@ from ee.onyx.server.license.models import SeatUsageResponse
from ee.onyx.utils.license import verify_license_signature
from onyx.auth.users import User
from onyx.db.engine.sql_engine import get_session
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.utils.logger import setup_logger
from shared_configs.configs import MULTI_TENANT
@@ -128,9 +127,9 @@ async def claim_license(
2. Without session_id: Re-claim using existing license for auth
"""
if MULTI_TENANT:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"License claiming is only available for self-hosted deployments",
raise HTTPException(
status_code=400,
detail="License claiming is only available for self-hosted deployments",
)
try:
@@ -147,16 +146,15 @@ async def claim_license(
# Re-claim using existing license for auth
metadata = get_license_metadata(db_session)
if not metadata or not metadata.tenant_id:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"No license found. Provide session_id after checkout.",
raise HTTPException(
status_code=400,
detail="No license found. Provide session_id after checkout.",
)
license_row = get_license(db_session)
if not license_row or not license_row.license_data:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"No license found in database",
raise HTTPException(
status_code=400, detail="No license found in database"
)
url = f"{CLOUD_DATA_PLANE_URL}/proxy/license/{metadata.tenant_id}"
@@ -175,7 +173,7 @@ async def claim_license(
license_data = data.get("license")
if not license_data:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "No license in response")
raise HTTPException(status_code=404, detail="No license in response")
# Verify signature before persisting
payload = verify_license_signature(license_data)
@@ -201,14 +199,12 @@ async def claim_license(
detail = error_data.get("detail", detail)
except Exception:
pass
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY, detail, status_code_override=status_code
)
raise HTTPException(status_code=status_code, detail=detail)
except ValueError as e:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
raise HTTPException(status_code=400, detail=str(e))
except requests.RequestException:
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY, "Failed to connect to license server"
raise HTTPException(
status_code=502, detail="Failed to connect to license server"
)
@@ -225,9 +221,9 @@ async def upload_license(
The license file must be cryptographically signed by Onyx.
"""
if MULTI_TENANT:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"License upload is only available for self-hosted deployments",
raise HTTPException(
status_code=400,
detail="License upload is only available for self-hosted deployments",
)
try:
@@ -238,14 +234,14 @@ async def upload_license(
# Remove any stray whitespace/newlines from user input
license_data = license_data.strip()
except UnicodeDecodeError:
raise OnyxError(OnyxErrorCode.INVALID_INPUT, "Invalid license file format")
raise HTTPException(status_code=400, detail="Invalid license file format")
# Verify cryptographic signature - this is the only validation needed
# The license's tenant_id identifies the customer in control plane, not locally
try:
payload = verify_license_signature(license_data)
except ValueError as e:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, str(e))
raise HTTPException(status_code=400, detail=str(e))
# Persist to DB and update cache
upsert_license(db_session, license_data)
@@ -301,9 +297,9 @@ async def delete_license(
Admin only - removes license from database and invalidates cache.
"""
if MULTI_TENANT:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"License deletion is only available for self-hosted deployments",
raise HTTPException(
status_code=400,
detail="License deletion is only available for self-hosted deployments",
)
try:

View File

@@ -46,6 +46,7 @@ from fastapi import FastAPI
from fastapi import Request
from fastapi import Response
from fastapi.responses import JSONResponse
from redis.exceptions import RedisError
from sqlalchemy.exc import SQLAlchemyError
from ee.onyx.configs.app_configs import LICENSE_ENFORCEMENT_ENABLED
@@ -55,7 +56,6 @@ from ee.onyx.configs.license_enforcement_config import (
)
from ee.onyx.db.license import get_cached_license_metadata
from ee.onyx.db.license import refresh_license_cache
from onyx.cache.interface import CACHE_TRANSIENT_ERRORS
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.server.settings.models import ApplicationStatus
from shared_configs.contextvars import get_current_tenant_id
@@ -164,9 +164,9 @@ def add_license_enforcement_middleware(
"[license_enforcement] No license, allowing community features"
)
is_gated = False
except CACHE_TRANSIENT_ERRORS as e:
except RedisError as e:
logger.warning(f"Failed to check license metadata: {e}")
# Fail open - don't block users due to cache connectivity issues
# Fail open - don't block users due to Redis connectivity issues
is_gated = False
if is_gated:

View File

@@ -6,7 +6,6 @@ from sqlalchemy.exc import SQLAlchemyError
from ee.onyx.configs.app_configs import LICENSE_ENFORCEMENT_ENABLED
from ee.onyx.db.license import get_cached_license_metadata
from ee.onyx.db.license import refresh_license_cache
from onyx.cache.interface import CACHE_TRANSIENT_ERRORS
from onyx.configs.app_configs import ENTERPRISE_EDITION_ENABLED
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.server.settings.models import ApplicationStatus
@@ -126,7 +125,7 @@ def apply_license_status_to_settings(settings: Settings) -> Settings:
# syncing) means indexed data may need protection.
settings.application_status = _BLOCKING_STATUS
settings.ee_features_enabled = False
except CACHE_TRANSIENT_ERRORS as e:
except RedisError as e:
logger.warning(f"Failed to check license metadata for settings: {e}")
# Fail closed - disable EE features if we can't verify license
settings.ee_features_enabled = False

View File

@@ -21,6 +21,7 @@ import asyncio
import httpx
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from ee.onyx.auth.users import current_admin_user
from ee.onyx.server.tenants.access import control_plane_dep
@@ -42,8 +43,6 @@ from onyx.auth.users import User
from onyx.configs.app_configs import STRIPE_PUBLISHABLE_KEY_OVERRIDE
from onyx.configs.app_configs import STRIPE_PUBLISHABLE_KEY_URL
from onyx.configs.app_configs import WEB_DOMAIN
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.utils.logger import setup_logger
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
from shared_configs.contextvars import get_current_tenant_id
@@ -117,14 +116,9 @@ async def create_customer_portal_session(
try:
portal_url = fetch_customer_portal_session(tenant_id, return_url)
return {"stripe_customer_portal_url": portal_url}
except OnyxError:
raise
except Exception:
except Exception as e:
logger.exception("Failed to create customer portal session")
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Failed to create customer portal session",
)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/create-checkout-session")
@@ -140,14 +134,9 @@ async def create_checkout_session(
try:
checkout_url = fetch_stripe_checkout_session(tenant_id, billing_period, seats)
return {"stripe_checkout_url": checkout_url}
except OnyxError:
raise
except Exception:
except Exception as e:
logger.exception("Failed to create checkout session")
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Failed to create checkout session",
)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/create-subscription-session")
@@ -158,20 +147,15 @@ async def create_subscription_session(
try:
tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get()
if not tenant_id:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, "Tenant ID not found")
raise HTTPException(status_code=400, detail="Tenant ID not found")
billing_period = request.billing_period if request else "monthly"
session_id = fetch_stripe_checkout_session(tenant_id, billing_period)
return SubscriptionSessionResponse(sessionId=session_id)
except OnyxError:
raise
except Exception:
except Exception as e:
logger.exception("Failed to create subscription session")
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Failed to create subscription session",
)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/stripe-publishable-key")
@@ -202,18 +186,18 @@ async def get_stripe_publishable_key() -> StripePublishableKeyResponse:
if STRIPE_PUBLISHABLE_KEY_OVERRIDE:
key = STRIPE_PUBLISHABLE_KEY_OVERRIDE.strip()
if not key.startswith("pk_"):
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Invalid Stripe publishable key format",
raise HTTPException(
status_code=500,
detail="Invalid Stripe publishable key format",
)
_stripe_publishable_key_cache = key
return StripePublishableKeyResponse(publishable_key=key)
# Fall back to S3 bucket
if not STRIPE_PUBLISHABLE_KEY_URL:
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Stripe publishable key is not configured",
raise HTTPException(
status_code=500,
detail="Stripe publishable key is not configured",
)
try:
@@ -224,15 +208,15 @@ async def get_stripe_publishable_key() -> StripePublishableKeyResponse:
# Validate key format
if not key.startswith("pk_"):
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Invalid Stripe publishable key format",
raise HTTPException(
status_code=500,
detail="Invalid Stripe publishable key format",
)
_stripe_publishable_key_cache = key
return StripePublishableKeyResponse(publishable_key=key)
except httpx.HTTPError:
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Failed to fetch Stripe publishable key",
raise HTTPException(
status_code=500,
detail="Failed to fetch Stripe publishable key",
)

View File

@@ -120,6 +120,7 @@ from onyx.db.models import User
from onyx.db.pat import fetch_user_for_pat
from onyx.db.users import get_user_by_email
from onyx.redis.redis_pool import get_async_redis_connection
from onyx.redis.redis_pool import get_redis_client
from onyx.server.settings.store import load_settings
from onyx.server.utils import BasicAuthenticationError
from onyx.utils.logger import setup_logger
@@ -200,14 +201,13 @@ def user_needs_to_be_verified() -> bool:
def anonymous_user_enabled(*, tenant_id: str | None = None) -> bool:
from onyx.cache.factory import get_cache_backend
cache = get_cache_backend(tenant_id=tenant_id)
value = cache.get(OnyxRedisLocks.ANONYMOUS_USER_ENABLED)
redis_client = get_redis_client(tenant_id=tenant_id)
value = redis_client.get(OnyxRedisLocks.ANONYMOUS_USER_ENABLED)
if value is None:
return False
assert isinstance(value, bytes)
return int(value.decode("utf-8")) == 1

View File

@@ -30,7 +30,6 @@ from onyx.background.celery.tasks.opensearch_migration.transformer import (
transform_vespa_chunks_to_opensearch_chunks,
)
from onyx.configs.app_configs import ENABLE_OPENSEARCH_INDEXING_FOR_ONYX
from onyx.configs.app_configs import VESPA_MIGRATION_REQUEST_TIMEOUT_S
from onyx.configs.constants import OnyxCeleryTask
from onyx.configs.constants import OnyxRedisLocks
from onyx.db.engine.sql_engine import get_session_with_current_tenant
@@ -48,7 +47,6 @@ from onyx.document_index.interfaces_new import TenantState
from onyx.document_index.opensearch.opensearch_document_index import (
OpenSearchDocumentIndex,
)
from onyx.document_index.vespa.shared_utils.utils import get_vespa_http_client
from onyx.document_index.vespa.vespa_document_index import VespaDocumentIndex
from onyx.indexing.models import IndexingSetting
from onyx.redis.redis_pool import get_redis_client
@@ -148,12 +146,7 @@ def migrate_chunks_from_vespa_to_opensearch_task(
task_logger.error(err_str)
return False
with (
get_session_with_current_tenant() as db_session,
get_vespa_http_client(
timeout=VESPA_MIGRATION_REQUEST_TIMEOUT_S
) as vespa_client,
):
with get_session_with_current_tenant() as db_session:
try_insert_opensearch_tenant_migration_record_with_commit(db_session)
search_settings = get_current_search_settings(db_session)
tenant_state = TenantState(tenant_id=tenant_id, multitenant=MULTI_TENANT)
@@ -168,7 +161,6 @@ def migrate_chunks_from_vespa_to_opensearch_task(
index_name=search_settings.index_name,
tenant_state=tenant_state,
large_chunks_enabled=False,
httpx_client=vespa_client,
)
sanitized_doc_start_time = time.monotonic()

View File

@@ -1,20 +1,9 @@
import abc
from enum import Enum
from redis.exceptions import RedisError
from sqlalchemy.exc import SQLAlchemyError
TTL_KEY_NOT_FOUND = -2
TTL_NO_EXPIRY = -1
CACHE_TRANSIENT_ERRORS: tuple[type[Exception], ...] = (RedisError, SQLAlchemyError)
"""Exception types that represent transient cache connectivity / operational
failures. Callers that want to fail-open (or fail-closed) on cache errors
should catch this tuple instead of bare ``Exception``.
When adding a new ``CacheBackend`` implementation, add its transient error
base class(es) here so all call-sites pick it up automatically."""
class CacheBackendType(str, Enum):
REDIS = "redis"

View File

@@ -52,7 +52,6 @@ from onyx.tools.built_in_tools import STOPPING_TOOLS_NAMES
from onyx.tools.interface import Tool
from onyx.tools.models import ChatFile
from onyx.tools.models import MemoryToolResponseSnapshot
from onyx.tools.models import PythonToolRichResponse
from onyx.tools.models import ToolCallInfo
from onyx.tools.models import ToolCallKickoff
from onyx.tools.models import ToolResponse
@@ -967,13 +966,6 @@ def run_llm_loop(
):
generated_images = tool_response.rich_response.generated_images
# Extract generated_files if this is a code interpreter response
generated_files = None
if isinstance(tool_response.rich_response, PythonToolRichResponse):
generated_files = (
tool_response.rich_response.generated_files or None
)
# Persist memory if this is a memory tool response
memory_snapshot: MemoryToolResponseSnapshot | None = None
if isinstance(tool_response.rich_response, MemoryToolResponse):
@@ -1025,7 +1017,6 @@ def run_llm_loop(
tool_call_response=saved_response,
search_docs=displayed_docs or search_docs,
generated_images=generated_images,
generated_files=generated_files,
)
# Add to state container for partial save support
state_container.add_tool_call(tool_call_info)

View File

@@ -1,5 +1,4 @@
import json
import mimetypes
from sqlalchemy.orm import Session
@@ -13,41 +12,14 @@ from onyx.db.chat import create_db_search_doc
from onyx.db.models import ChatMessage
from onyx.db.models import ToolCall
from onyx.db.tools import create_tool_call_no_commit
from onyx.file_store.models import FileDescriptor
from onyx.natural_language_processing.utils import BaseTokenizer
from onyx.natural_language_processing.utils import get_tokenizer
from onyx.server.query_and_chat.chat_utils import mime_type_to_chat_file_type
from onyx.tools.models import ToolCallInfo
from onyx.utils.logger import setup_logger
logger = setup_logger()
def _extract_referenced_file_descriptors(
tool_calls: list[ToolCallInfo],
message_text: str,
) -> list[FileDescriptor]:
"""Extract FileDescriptors for code interpreter files referenced in the message text."""
descriptors: list[FileDescriptor] = []
for tool_call_info in tool_calls:
if not tool_call_info.generated_files:
continue
for gen_file in tool_call_info.generated_files:
file_id = (
gen_file.file_link.rsplit("/", 1)[-1] if gen_file.file_link else ""
)
if file_id and file_id in message_text:
mime_type, _ = mimetypes.guess_type(gen_file.filename)
descriptors.append(
FileDescriptor(
id=file_id,
type=mime_type_to_chat_file_type(mime_type),
name=gen_file.filename,
)
)
return descriptors
def _create_and_link_tool_calls(
tool_calls: list[ToolCallInfo],
assistant_message: ChatMessage,
@@ -325,14 +297,5 @@ def save_chat_turn(
citation_number_to_search_doc_id if citation_number_to_search_doc_id else None
)
# 8. Attach code interpreter generated files that the assistant actually
# referenced in its response, so they are available via load_all_chat_files
# on subsequent turns. Files not mentioned are intermediate artifacts.
if message_text:
referenced = _extract_referenced_file_descriptors(tool_calls, message_text)
if referenced:
existing_files = assistant_message.files or []
assistant_message.files = existing_files + referenced
# Finally save the messages, tool calls, and docs
db_session.commit()

View File

@@ -819,9 +819,7 @@ RERANK_COUNT = int(os.environ.get("RERANK_COUNT") or 1000)
# Tool Configs
#####
# Code Interpreter Service Configuration
CODE_INTERPRETER_BASE_URL = os.environ.get(
"CODE_INTERPRETER_BASE_URL", "http://localhost:8000"
)
CODE_INTERPRETER_BASE_URL = os.environ.get("CODE_INTERPRETER_BASE_URL")
CODE_INTERPRETER_DEFAULT_TIMEOUT_MS = int(
os.environ.get("CODE_INTERPRETER_DEFAULT_TIMEOUT_MS") or 60_000
@@ -902,9 +900,6 @@ CUSTOM_ANSWER_VALIDITY_CONDITIONS = json.loads(
)
VESPA_REQUEST_TIMEOUT = int(os.environ.get("VESPA_REQUEST_TIMEOUT") or "15")
VESPA_MIGRATION_REQUEST_TIMEOUT_S = int(
os.environ.get("VESPA_MIGRATION_REQUEST_TIMEOUT_S") or "120"
)
SYSTEM_RECURSION_LIMIT = int(os.environ.get("SYSTEM_RECURSION_LIMIT") or "1000")

View File

@@ -532,7 +532,6 @@ def fetch_default_model(
) -> ModelConfiguration | None:
model_config = db_session.scalar(
select(ModelConfiguration)
.options(selectinload(ModelConfiguration.llm_provider))
.join(LLMModelFlow)
.where(
ModelConfiguration.is_visible == True, # noqa: E712
@@ -866,7 +865,6 @@ def insert_new_model_configuration__no_commit(
is_visible=is_visible,
max_input_tokens=max_input_tokens,
display_name=display_name,
supports_image_input=LLMModelFlowType.VISION in supported_flows,
)
.on_conflict_do_nothing()
.returning(ModelConfiguration.id)
@@ -901,7 +899,6 @@ def update_model_configuration__no_commit(
is_visible=is_visible,
max_input_tokens=max_input_tokens,
display_name=display_name,
supports_image_input=LLMModelFlowType.VISION in supported_flows,
)
.where(ModelConfiguration.id == model_configuration_id)
.returning(ModelConfiguration)

View File

@@ -2882,9 +2882,6 @@ class ModelConfiguration(Base):
# - The end-user is configuring a model and chooses not to set a max-input-tokens limit.
max_input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Deprecated: use LLMModelFlow with VISION flow type instead
supports_image_input: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
# Human-readable display name for the model.
# For dynamic providers (OpenRouter, Bedrock, Ollama), this comes from the source API.
# For static providers (OpenAI, Anthropic), this may be null and will fall back to LiteLLM.

View File

@@ -52,7 +52,7 @@ def create_user_files(
) -> CategorizedFilesResult:
# Categorize the files
categorized_files = categorize_uploaded_files(files, db_session)
categorized_files = categorize_uploaded_files(files)
# NOTE: At the moment, zip metadata is not used for user files.
# Should revisit to decide whether this should be a feature.
upload_response = upload_files(categorized_files.acceptable, FileOrigin.USER_FILE)

View File

@@ -1,6 +1,5 @@
import json
import string
import time
from collections.abc import Callable
from collections.abc import Mapping
from datetime import datetime
@@ -19,7 +18,6 @@ from onyx.background.celery.tasks.opensearch_migration.transformer import (
)
from onyx.configs.app_configs import LOG_VESPA_TIMING_INFORMATION
from onyx.configs.app_configs import VESPA_LANGUAGE_OVERRIDE
from onyx.configs.app_configs import VESPA_MIGRATION_REQUEST_TIMEOUT_S
from onyx.context.search.models import IndexFilters
from onyx.context.search.models import InferenceChunkUncleaned
from onyx.document_index.interfaces import VespaChunkRequest
@@ -340,18 +338,12 @@ def get_all_chunks_paginated(
params["continuation"] = continuation_token
response: httpx.Response | None = None
start_time = time.monotonic()
try:
with get_vespa_http_client(
timeout=VESPA_MIGRATION_REQUEST_TIMEOUT_S
) as http_client:
with get_vespa_http_client() as http_client:
response = http_client.get(url, params=params)
response.raise_for_status()
except httpx.HTTPError as e:
error_base = (
f"Failed to get chunks from Vespa slice {slice_id} with continuation token "
f"{continuation_token} in {time.monotonic() - start_time:.3f} seconds."
)
error_base = f"Failed to get chunks from Vespa slice {slice_id} with continuation token {continuation_token}."
logger.exception(
f"Request URL: {e.request.url}\n"
f"Request Headers: {e.request.headers}\n"

View File

@@ -52,9 +52,7 @@ def replace_invalid_doc_id_characters(text: str) -> str:
return text.replace("'", "_")
def get_vespa_http_client(
no_timeout: bool = False, http2: bool = True, timeout: int | None = None
) -> httpx.Client:
def get_vespa_http_client(no_timeout: bool = False, http2: bool = True) -> httpx.Client:
"""
Configures and returns an HTTP client for communicating with Vespa,
including authentication if needed.
@@ -66,7 +64,7 @@ def get_vespa_http_client(
else None
),
verify=False if not MANAGED_VESPA else True,
timeout=None if no_timeout else (timeout or VESPA_REQUEST_TIMEOUT),
timeout=None if no_timeout else VESPA_REQUEST_TIMEOUT,
http2=http2,
)

View File

@@ -1,101 +0,0 @@
"""
Standardized error codes for the Onyx backend.
Usage:
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
raise OnyxError(OnyxErrorCode.UNAUTHENTICATED, "Token expired")
"""
from enum import Enum
class OnyxErrorCode(Enum):
"""
Each member is a tuple of (error_code_string, http_status_code).
The error_code_string is a stable, machine-readable identifier that
API consumers can match on. The http_status_code is the default HTTP
status to return.
"""
# ------------------------------------------------------------------
# Authentication (401)
# ------------------------------------------------------------------
UNAUTHENTICATED = ("UNAUTHENTICATED", 401)
INVALID_TOKEN = ("INVALID_TOKEN", 401)
TOKEN_EXPIRED = ("TOKEN_EXPIRED", 401)
CSRF_FAILURE = ("CSRF_FAILURE", 403)
# ------------------------------------------------------------------
# Authorization (403)
# ------------------------------------------------------------------
UNAUTHORIZED = ("UNAUTHORIZED", 403)
INSUFFICIENT_PERMISSIONS = ("INSUFFICIENT_PERMISSIONS", 403)
ADMIN_ONLY = ("ADMIN_ONLY", 403)
EE_REQUIRED = ("EE_REQUIRED", 403)
# ------------------------------------------------------------------
# Validation / Bad Request (400)
# ------------------------------------------------------------------
VALIDATION_ERROR = ("VALIDATION_ERROR", 400)
INVALID_INPUT = ("INVALID_INPUT", 400)
MISSING_REQUIRED_FIELD = ("MISSING_REQUIRED_FIELD", 400)
# ------------------------------------------------------------------
# Not Found (404)
# ------------------------------------------------------------------
NOT_FOUND = ("NOT_FOUND", 404)
CONNECTOR_NOT_FOUND = ("CONNECTOR_NOT_FOUND", 404)
CREDENTIAL_NOT_FOUND = ("CREDENTIAL_NOT_FOUND", 404)
PERSONA_NOT_FOUND = ("PERSONA_NOT_FOUND", 404)
DOCUMENT_NOT_FOUND = ("DOCUMENT_NOT_FOUND", 404)
SESSION_NOT_FOUND = ("SESSION_NOT_FOUND", 404)
USER_NOT_FOUND = ("USER_NOT_FOUND", 404)
# ------------------------------------------------------------------
# Conflict (409)
# ------------------------------------------------------------------
CONFLICT = ("CONFLICT", 409)
DUPLICATE_RESOURCE = ("DUPLICATE_RESOURCE", 409)
# ------------------------------------------------------------------
# Rate Limiting / Quotas (429 / 402)
# ------------------------------------------------------------------
RATE_LIMITED = ("RATE_LIMITED", 429)
SEAT_LIMIT_EXCEEDED = ("SEAT_LIMIT_EXCEEDED", 402)
# ------------------------------------------------------------------
# Connector / Credential Errors (400-range)
# ------------------------------------------------------------------
CONNECTOR_VALIDATION_FAILED = ("CONNECTOR_VALIDATION_FAILED", 400)
CREDENTIAL_INVALID = ("CREDENTIAL_INVALID", 400)
CREDENTIAL_EXPIRED = ("CREDENTIAL_EXPIRED", 401)
# ------------------------------------------------------------------
# Server Errors (5xx)
# ------------------------------------------------------------------
INTERNAL_ERROR = ("INTERNAL_ERROR", 500)
NOT_IMPLEMENTED = ("NOT_IMPLEMENTED", 501)
SERVICE_UNAVAILABLE = ("SERVICE_UNAVAILABLE", 503)
BAD_GATEWAY = ("BAD_GATEWAY", 502)
LLM_PROVIDER_ERROR = ("LLM_PROVIDER_ERROR", 502)
GATEWAY_TIMEOUT = ("GATEWAY_TIMEOUT", 504)
def __init__(self, code: str, status_code: int) -> None:
self.code = code
self.status_code = status_code
def detail(self, message: str | None = None) -> dict[str, str]:
"""Build a structured error detail dict.
Returns a dict like:
{"error_code": "UNAUTHENTICATED", "message": "Token expired"}
If no message is supplied, the error code itself is used as the message.
"""
return {
"error_code": self.code,
"message": message or self.code,
}

View File

@@ -1,82 +0,0 @@
"""OnyxError — the single exception type for all Onyx business errors.
Raise ``OnyxError`` instead of ``HTTPException`` in business code. A global
FastAPI exception handler (registered via ``register_onyx_exception_handlers``)
converts it into a JSON response with the standard
``{"error_code": "...", "message": "..."}`` shape.
Usage::
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Session not found")
For upstream errors with a dynamic HTTP status (e.g. billing service),
use ``status_code_override``::
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
detail,
status_code_override=upstream_status,
)
"""
from fastapi import FastAPI
from fastapi import Request
from fastapi.responses import JSONResponse
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.utils.logger import setup_logger
logger = setup_logger()
class OnyxError(Exception):
"""Structured error that maps to a specific ``OnyxErrorCode``.
Attributes:
error_code: The ``OnyxErrorCode`` enum member.
message: Human-readable message (defaults to the error code string).
status_code: HTTP status — either overridden or from the error code.
"""
def __init__(
self,
error_code: OnyxErrorCode,
message: str | None = None,
*,
status_code_override: int | None = None,
) -> None:
self.error_code = error_code
self.message = message or error_code.code
self._status_code_override = status_code_override
super().__init__(self.message)
@property
def status_code(self) -> int:
return self._status_code_override or self.error_code.status_code
def register_onyx_exception_handlers(app: FastAPI) -> None:
"""Register a global handler that converts ``OnyxError`` to JSON responses.
Must be called *after* the app is created but *before* it starts serving.
The handler logs at WARNING for 4xx and ERROR for 5xx.
"""
@app.exception_handler(OnyxError)
async def _handle_onyx_error(
request: Request, # noqa: ARG001
exc: OnyxError,
) -> JSONResponse:
status_code = exc.status_code
if status_code >= 500:
logger.error(f"OnyxError {exc.error_code.code}: {exc.message}")
elif status_code >= 400:
logger.warning(f"OnyxError {exc.error_code.code}: {exc.message}")
return JSONResponse(
status_code=status_code,
content=exc.error_code.detail(exc.message),
)

View File

@@ -59,7 +59,6 @@ from onyx.db.engine.async_sql_engine import get_sqlalchemy_async_engine
from onyx.db.engine.connection_warmup import warm_up_connections
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.engine.sql_engine import SqlEngine
from onyx.error_handling.exceptions import register_onyx_exception_handlers
from onyx.file_store.file_store import get_default_file_store
from onyx.server.api_key.api import router as api_key_router
from onyx.server.auth_check import check_router_auth
@@ -445,8 +444,6 @@ def get_application(lifespan_override: Lifespan | None = None) -> FastAPI:
status.HTTP_500_INTERNAL_SERVER_ERROR, log_http_error
)
register_onyx_exception_handlers(application)
include_router_with_global_prefix_prepended(application, password_router)
include_router_with_global_prefix_prepended(application, chat_router)
include_router_with_global_prefix_prepended(application, query_router)

View File

@@ -92,7 +92,6 @@ from onyx.db.connector_credential_pair import get_connector_credential_pairs_for
from onyx.db.connector_credential_pair import (
get_connector_credential_pairs_for_user_parallel,
)
from onyx.db.connector_credential_pair import verify_user_has_access_to_cc_pair
from onyx.db.credentials import cleanup_gmail_credentials
from onyx.db.credentials import cleanup_google_drive_credentials
from onyx.db.credentials import create_credential
@@ -573,43 +572,6 @@ def _normalize_file_names_for_backwards_compatibility(
return file_names + file_locations[len(file_names) :]
def _fetch_and_check_file_connector_cc_pair_permissions(
connector_id: int,
user: User,
db_session: Session,
require_editable: bool,
) -> ConnectorCredentialPair:
cc_pair = fetch_connector_credential_pair_for_connector(db_session, connector_id)
if cc_pair is None:
raise HTTPException(
status_code=404,
detail="No Connector-Credential Pair found for this connector",
)
has_requested_access = verify_user_has_access_to_cc_pair(
cc_pair_id=cc_pair.id,
db_session=db_session,
user=user,
get_editable=require_editable,
)
if has_requested_access:
return cc_pair
# Special case: global curators should be able to manage files
# for public file connectors even when they are not the creator.
if (
require_editable
and user.role == UserRole.GLOBAL_CURATOR
and cc_pair.access_type == AccessType.PUBLIC
):
return cc_pair
raise HTTPException(
status_code=403,
detail="Access denied. User cannot manage files for this connector.",
)
@router.post("/admin/connector/file/upload", tags=PUBLIC_API_TAGS)
def upload_files_api(
files: list[UploadFile],
@@ -621,7 +583,7 @@ def upload_files_api(
@router.get("/admin/connector/{connector_id}/files", tags=PUBLIC_API_TAGS)
def list_connector_files(
connector_id: int,
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(current_curator_or_admin_user), # noqa: ARG001
db_session: Session = Depends(get_session),
) -> ConnectorFilesResponse:
"""List all files in a file connector."""
@@ -634,13 +596,6 @@ def list_connector_files(
status_code=400, detail="This endpoint only works with file connectors"
)
_ = _fetch_and_check_file_connector_cc_pair_permissions(
connector_id=connector_id,
user=user,
db_session=db_session,
require_editable=False,
)
file_locations = connector.connector_specific_config.get("file_locations", [])
file_names = connector.connector_specific_config.get("file_names", [])
@@ -690,7 +645,7 @@ def update_connector_files(
connector_id: int,
files: list[UploadFile] | None = File(None),
file_ids_to_remove: str = Form("[]"),
user: User = Depends(current_curator_or_admin_user),
user: User = Depends(current_curator_or_admin_user), # noqa: ARG001
db_session: Session = Depends(get_session),
) -> FileUploadResponse:
"""
@@ -708,13 +663,12 @@ def update_connector_files(
)
# Get the connector-credential pair for indexing/pruning triggers
# and validate user permissions for file management.
cc_pair = _fetch_and_check_file_connector_cc_pair_permissions(
connector_id=connector_id,
user=user,
db_session=db_session,
require_editable=True,
)
cc_pair = fetch_connector_credential_pair_for_connector(db_session, connector_id)
if cc_pair is None:
raise HTTPException(
status_code=404,
detail="No Connector-Credential Pair found for this connector",
)
# Parse file IDs to remove
try:

View File

@@ -7,14 +7,13 @@ from PIL import UnidentifiedImageError
from pydantic import BaseModel
from pydantic import ConfigDict
from pydantic import Field
from sqlalchemy.orm import Session
from onyx.configs.app_configs import FILE_TOKEN_COUNT_THRESHOLD
from onyx.db.llm import fetch_default_llm_model
from onyx.file_processing.extract_file_text import extract_file_text
from onyx.file_processing.extract_file_text import get_file_ext
from onyx.file_processing.file_types import OnyxFileExtensions
from onyx.file_processing.password_validation import is_file_password_protected
from onyx.llm.factory import get_default_llm
from onyx.natural_language_processing.utils import get_tokenizer
from onyx.utils.logger import setup_logger
from shared_configs.configs import MULTI_TENANT
@@ -117,9 +116,7 @@ def estimate_image_tokens_for_upload(
pass
def categorize_uploaded_files(
files: list[UploadFile], db_session: Session
) -> CategorizedFiles:
def categorize_uploaded_files(files: list[UploadFile]) -> CategorizedFiles:
"""
Categorize uploaded files based on text extractability and tokenized length.
@@ -131,11 +128,11 @@ def categorize_uploaded_files(
"""
results = CategorizedFiles()
default_model = fetch_default_llm_model(db_session)
llm = get_default_llm()
model_name = default_model.name if default_model else None
provider_type = default_model.llm_provider.provider if default_model else None
tokenizer = get_tokenizer(model_name=model_name, provider_type=provider_type)
tokenizer = get_tokenizer(
model_name=llm.config.model_name, provider_type=llm.config.model_provider
)
# Check if threshold checks should be skipped
skip_threshold = False

View File

@@ -168,18 +168,6 @@ class ModelConfigurationUpsertRequest(BaseModel):
supports_image_input: bool | None = None
display_name: str | None = None # For dynamic providers, from source API
@classmethod
def from_model(
cls, model_configuration_model: "ModelConfigurationModel"
) -> "ModelConfigurationUpsertRequest":
return cls(
name=model_configuration_model.name,
is_visible=model_configuration_model.is_visible,
max_input_tokens=model_configuration_model.max_input_tokens,
supports_image_input=model_configuration_model.supports_image_input,
display_name=model_configuration_model.display_name,
)
class ModelConfigurationView(BaseModel):
name: str

View File

@@ -93,8 +93,6 @@ class ToolResponse(BaseModel):
# | WebContentResponse
# This comes from custom tools, tool result needs to be saved
| CustomToolCallSummary
# This comes from code interpreter, carries generated files
| PythonToolRichResponse
# If the rich response is a string, this is what's saved to the tool call in the DB
| str
| None # If nothing needs to be persisted outside of the string value passed to the LLM
@@ -195,12 +193,6 @@ class ChatFile(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
class PythonToolRichResponse(BaseModel):
"""Rich response from the Python tool carrying generated files."""
generated_files: list[PythonExecutionFile] = []
class PythonToolOverrideKwargs(BaseModel):
"""Override kwargs for the Python/Code Interpreter tool."""
@@ -253,7 +245,6 @@ class ToolCallInfo(BaseModel):
tool_call_response: str
search_docs: list[SearchDoc] | None = None
generated_images: list[GeneratedImage] | None = None
generated_files: list[PythonExecutionFile] | None = None
CHAT_SESSION_ID_PLACEHOLDER = "CHAT_SESSION_ID"

View File

@@ -1,5 +1,4 @@
import json
import time
from collections.abc import Generator
from typing import Literal
from typing import TypedDict
@@ -13,9 +12,6 @@ from onyx.utils.logger import setup_logger
logger = setup_logger()
_HEALTH_CACHE_TTL_SECONDS = 30
_health_cache: dict[str, tuple[float, bool]] = {}
class FileInput(TypedDict):
"""Input file to be staged in execution workspace"""
@@ -102,32 +98,16 @@ class CodeInterpreterClient:
payload["files"] = files
return payload
def health(self, use_cache: bool = False) -> bool:
"""Check if the Code Interpreter service is healthy
Args:
use_cache: When True, return a cached result if available and
within the TTL window. The cache is always populated
after a live request regardless of this flag.
"""
if use_cache:
cached = _health_cache.get(self.base_url)
if cached is not None:
cached_at, cached_result = cached
if time.monotonic() - cached_at < _HEALTH_CACHE_TTL_SECONDS:
return cached_result
def health(self) -> bool:
"""Check if the Code Interpreter service is healthy"""
url = f"{self.base_url}/health"
try:
response = self.session.get(url, timeout=5)
response.raise_for_status()
result = response.json().get("status") == "ok"
return response.json().get("status") == "ok"
except Exception as e:
logger.warning(f"Exception caught when checking health, e={e}")
result = False
_health_cache[self.base_url] = (time.monotonic(), result)
return result
return False
def execute(
self,

View File

@@ -23,7 +23,6 @@ from onyx.tools.interface import Tool
from onyx.tools.models import LlmPythonExecutionResult
from onyx.tools.models import PythonExecutionFile
from onyx.tools.models import PythonToolOverrideKwargs
from onyx.tools.models import PythonToolRichResponse
from onyx.tools.models import ToolCallException
from onyx.tools.models import ToolResponse
from onyx.tools.tool_implementations.python.code_interpreter_client import (
@@ -108,11 +107,7 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
if not CODE_INTERPRETER_BASE_URL:
return False
server = fetch_code_interpreter_server(db_session)
if not server.server_enabled:
return False
client = CodeInterpreterClient()
return client.health(use_cache=True)
return server.server_enabled
def tool_definition(self) -> dict:
return {
@@ -330,9 +325,7 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
llm_response = adapter.dump_json(result).decode()
return ToolResponse(
rich_response=PythonToolRichResponse(
generated_files=generated_files,
),
rich_response=None, # No rich response needed for Python tool
llm_facing_response=llm_response,
)

View File

@@ -1,5 +1,6 @@
import json
from typing import Any
from typing import cast
from sqlalchemy.orm import Session
from typing_extensions import override
@@ -56,30 +57,6 @@ def _sanitize_query(query: str) -> str:
return " ".join(sanitized.split())
def _normalize_queries_input(raw: Any) -> list[str]:
"""Coerce LLM output to a list of sanitized query strings.
Accepts a bare string or a list (possibly with non-string elements).
Sanitizes each query (strip control chars, normalize whitespace) and
drops empty or whitespace-only entries.
"""
if isinstance(raw, str):
raw = raw.strip()
if not raw:
return []
raw = [raw]
elif not isinstance(raw, list):
return []
result: list[str] = []
for q in raw:
if q is None:
continue
sanitized = _sanitize_query(str(q))
if sanitized:
result.append(sanitized)
return result
class WebSearchTool(Tool[WebSearchToolOverrideKwargs]):
NAME = "web_search"
DESCRIPTION = "Search the web for information."
@@ -212,7 +189,13 @@ class WebSearchTool(Tool[WebSearchToolOverrideKwargs]):
f'like: {{"queries": ["your search query here"]}}'
),
)
queries = _normalize_queries_input(llm_kwargs[QUERIES_FIELD])
raw_queries = cast(list[str], llm_kwargs[QUERIES_FIELD])
# Normalize queries:
# - remove control characters (null bytes, etc.) that LLMs sometimes produce
# - collapse whitespace and strip
# - drop empty/whitespace-only queries
queries = [sanitized for q in raw_queries if (sanitized := _sanitize_query(q))]
if not queries:
raise ToolCallException(
message=(

View File

@@ -1027,13 +1027,6 @@ class _MockCIHandler(BaseHTTPRequestHandler):
else:
self._respond_json(404, {"error": "not found"})
def do_GET(self) -> None:
self._capture("GET", b"")
if self.path == "/health":
self._respond_json(200, {"status": "ok"})
else:
self._respond_json(404, {"error": "not found"})
def do_DELETE(self) -> None:
self._capture("DELETE", b"")
self.send_response(200)
@@ -1114,14 +1107,6 @@ def mock_ci_server() -> Generator[MockCodeInterpreterServer, None, None]:
server.shutdown()
@pytest.fixture(autouse=True)
def _clear_health_cache() -> None:
"""Reset the health check cache before every test."""
import onyx.tools.tool_implementations.python.code_interpreter_client as mod
mod._health_cache = {}
@pytest.fixture()
def _attach_python_tool_to_default_persona(db_session: Session) -> None:
"""Ensure the default persona (id=0) has the PythonTool attached."""

View File

@@ -1,234 +0,0 @@
import io
import json
import os
import pytest
import requests
from onyx.db.enums import AccessType
from onyx.db.models import UserRole
from onyx.server.documents.models import DocumentSource
from tests.integration.common_utils.constants import API_SERVER_URL
from tests.integration.common_utils.managers.cc_pair import CCPairManager
from tests.integration.common_utils.managers.connector import ConnectorManager
from tests.integration.common_utils.managers.credential import CredentialManager
from tests.integration.common_utils.managers.user import DATestUser
from tests.integration.common_utils.managers.user import UserManager
from tests.integration.common_utils.managers.user_group import UserGroupManager
def _upload_connector_file(
*,
user_performing_action: DATestUser,
file_name: str,
content: bytes,
) -> tuple[str, str]:
headers = user_performing_action.headers.copy()
headers.pop("Content-Type", None)
response = requests.post(
f"{API_SERVER_URL}/manage/admin/connector/file/upload",
files=[("files", (file_name, io.BytesIO(content), "text/plain"))],
headers=headers,
)
response.raise_for_status()
payload = response.json()
return payload["file_paths"][0], payload["file_names"][0]
def _update_connector_files(
*,
connector_id: int,
user_performing_action: DATestUser,
file_ids_to_remove: list[str],
new_file_name: str,
new_file_content: bytes,
) -> requests.Response:
headers = user_performing_action.headers.copy()
headers.pop("Content-Type", None)
return requests.post(
f"{API_SERVER_URL}/manage/admin/connector/{connector_id}/files/update",
data={"file_ids_to_remove": json.dumps(file_ids_to_remove)},
files=[("files", (new_file_name, io.BytesIO(new_file_content), "text/plain"))],
headers=headers,
)
def _list_connector_files(
*,
connector_id: int,
user_performing_action: DATestUser,
) -> requests.Response:
return requests.get(
f"{API_SERVER_URL}/manage/admin/connector/{connector_id}/files",
headers=user_performing_action.headers,
)
@pytest.mark.skipif(
os.environ.get("ENABLE_PAID_ENTERPRISE_EDITION_FEATURES", "").lower() != "true",
reason="Curator and user group tests are enterprise only",
)
@pytest.mark.usefixtures("reset")
def test_only_global_curator_can_update_public_file_connector_files() -> None:
admin_user = UserManager.create(name="admin_user")
global_curator_creator = UserManager.create(name="global_curator_creator")
global_curator_creator = UserManager.set_role(
user_to_set=global_curator_creator,
target_role=UserRole.GLOBAL_CURATOR,
user_performing_action=admin_user,
)
global_curator_editor = UserManager.create(name="global_curator_editor")
global_curator_editor = UserManager.set_role(
user_to_set=global_curator_editor,
target_role=UserRole.GLOBAL_CURATOR,
user_performing_action=admin_user,
)
curator_user = UserManager.create(name="curator_user")
curator_group = UserGroupManager.create(
name="curator_group",
user_ids=[curator_user.id],
cc_pair_ids=[],
user_performing_action=admin_user,
)
UserGroupManager.wait_for_sync(
user_groups_to_check=[curator_group],
user_performing_action=admin_user,
)
UserGroupManager.set_curator_status(
test_user_group=curator_group,
user_to_set_as_curator=curator_user,
user_performing_action=admin_user,
)
initial_file_id, initial_file_name = _upload_connector_file(
user_performing_action=global_curator_creator,
file_name="initial-file.txt",
content=b"initial file content",
)
connector = ConnectorManager.create(
user_performing_action=global_curator_creator,
name="public_file_connector",
source=DocumentSource.FILE,
connector_specific_config={
"file_locations": [initial_file_id],
"file_names": [initial_file_name],
"zip_metadata_file_id": None,
},
access_type=AccessType.PUBLIC,
groups=[],
)
credential = CredentialManager.create(
user_performing_action=global_curator_creator,
source=DocumentSource.FILE,
curator_public=True,
groups=[],
name="public_file_connector_credential",
)
CCPairManager.create(
connector_id=connector.id,
credential_id=credential.id,
user_performing_action=global_curator_creator,
access_type=AccessType.PUBLIC,
groups=[],
name="public_file_connector_cc_pair",
)
curator_list_response = _list_connector_files(
connector_id=connector.id,
user_performing_action=curator_user,
)
curator_list_response.raise_for_status()
curator_list_payload = curator_list_response.json()
assert any(f["file_id"] == initial_file_id for f in curator_list_payload["files"])
global_curator_list_response = _list_connector_files(
connector_id=connector.id,
user_performing_action=global_curator_editor,
)
global_curator_list_response.raise_for_status()
global_curator_list_payload = global_curator_list_response.json()
assert any(
f["file_id"] == initial_file_id for f in global_curator_list_payload["files"]
)
denied_response = _update_connector_files(
connector_id=connector.id,
user_performing_action=curator_user,
file_ids_to_remove=[initial_file_id],
new_file_name="curator-file.txt",
new_file_content=b"curator updated file",
)
assert denied_response.status_code == 403
allowed_response = _update_connector_files(
connector_id=connector.id,
user_performing_action=global_curator_editor,
file_ids_to_remove=[initial_file_id],
new_file_name="global-curator-file.txt",
new_file_content=b"global curator updated file",
)
allowed_response.raise_for_status()
payload = allowed_response.json()
assert initial_file_id not in payload["file_paths"]
assert "global-curator-file.txt" in payload["file_names"]
creator_group = UserGroupManager.create(
name="creator_group",
user_ids=[global_curator_creator.id],
cc_pair_ids=[],
user_performing_action=admin_user,
)
UserGroupManager.wait_for_sync(
user_groups_to_check=[creator_group],
user_performing_action=admin_user,
)
private_file_id, private_file_name = _upload_connector_file(
user_performing_action=global_curator_creator,
file_name="private-initial-file.txt",
content=b"private initial file content",
)
private_connector = ConnectorManager.create(
user_performing_action=global_curator_creator,
name="private_file_connector",
source=DocumentSource.FILE,
connector_specific_config={
"file_locations": [private_file_id],
"file_names": [private_file_name],
"zip_metadata_file_id": None,
},
access_type=AccessType.PRIVATE,
groups=[creator_group.id],
)
private_credential = CredentialManager.create(
user_performing_action=global_curator_creator,
source=DocumentSource.FILE,
curator_public=False,
groups=[creator_group.id],
name="private_file_connector_credential",
)
CCPairManager.create(
connector_id=private_connector.id,
credential_id=private_credential.id,
user_performing_action=global_curator_creator,
access_type=AccessType.PRIVATE,
groups=[creator_group.id],
name="private_file_connector_cc_pair",
)
private_denied_response = _update_connector_files(
connector_id=private_connector.id,
user_performing_action=global_curator_editor,
file_ids_to_remove=[private_file_id],
new_file_name="global-curator-private-file.txt",
new_file_content=b"global curator private update",
)
assert private_denied_response.status_code == 403

View File

@@ -11,8 +11,7 @@ from ee.onyx.server.billing.models import CreateCheckoutSessionResponse
from ee.onyx.server.billing.models import CreateCustomerPortalSessionResponse
from ee.onyx.server.billing.models import SeatUpdateResponse
from ee.onyx.server.billing.models import SubscriptionStatusResponse
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from ee.onyx.server.billing.service import BillingServiceError
class TestCreateCheckoutSession:
@@ -89,25 +88,22 @@ class TestCreateCheckoutSession:
mock_get_tenant: MagicMock,
mock_service: AsyncMock,
) -> None:
"""Should propagate OnyxError when service fails."""
"""Should raise HTTPException when service fails."""
from fastapi import HTTPException
from ee.onyx.server.billing.api import create_checkout_session
mock_get_license.return_value = None
mock_get_tenant.return_value = "tenant_123"
mock_service.side_effect = OnyxError(
OnyxErrorCode.BAD_GATEWAY,
"Stripe error",
status_code_override=502,
)
mock_service.side_effect = BillingServiceError("Stripe error", 502)
with pytest.raises(OnyxError) as exc_info:
with pytest.raises(HTTPException) as exc_info:
await create_checkout_session(
request=None, _=MagicMock(), db_session=MagicMock()
)
assert exc_info.value.status_code == 502
assert exc_info.value.error_code is OnyxErrorCode.BAD_GATEWAY
assert exc_info.value.message == "Stripe error"
assert "Stripe error" in exc_info.value.detail
class TestCreateCustomerPortalSession:
@@ -125,19 +121,20 @@ class TestCreateCustomerPortalSession:
mock_service: AsyncMock, # noqa: ARG002
) -> None:
"""Should reject self-hosted without license."""
from fastapi import HTTPException
from ee.onyx.server.billing.api import create_customer_portal_session
mock_get_license.return_value = None
mock_get_tenant.return_value = None
with pytest.raises(OnyxError) as exc_info:
with pytest.raises(HTTPException) as exc_info:
await create_customer_portal_session(
request=None, _=MagicMock(), db_session=MagicMock()
)
assert exc_info.value.status_code == 400
assert exc_info.value.error_code is OnyxErrorCode.VALIDATION_ERROR
assert exc_info.value.message == "No license found"
assert "No license found" in exc_info.value.detail
@pytest.mark.asyncio
@patch("ee.onyx.server.billing.api.create_portal_service")
@@ -230,6 +227,8 @@ class TestUpdateSeats:
mock_get_tenant: MagicMock,
) -> None:
"""Should reject self-hosted without license."""
from fastapi import HTTPException
from ee.onyx.server.billing.api import update_seats
from ee.onyx.server.billing.models import SeatUpdateRequest
@@ -238,12 +237,11 @@ class TestUpdateSeats:
request = SeatUpdateRequest(new_seat_count=10)
with pytest.raises(OnyxError) as exc_info:
with pytest.raises(HTTPException) as exc_info:
await update_seats(request=request, _=MagicMock(), db_session=MagicMock())
assert exc_info.value.status_code == 400
assert exc_info.value.error_code is OnyxErrorCode.VALIDATION_ERROR
assert exc_info.value.message == "No license found"
assert "No license found" in exc_info.value.detail
@pytest.mark.asyncio
@patch("ee.onyx.server.billing.api.get_used_seats")
@@ -297,27 +295,26 @@ class TestUpdateSeats:
mock_service: AsyncMock,
mock_get_used_seats: MagicMock,
) -> None:
"""Should propagate OnyxError from service layer."""
"""Should convert BillingServiceError to HTTPException."""
from fastapi import HTTPException
from ee.onyx.server.billing.api import update_seats
from ee.onyx.server.billing.models import SeatUpdateRequest
mock_get_license.return_value = "license_blob"
mock_get_tenant.return_value = None
mock_get_used_seats.return_value = 0
mock_service.side_effect = OnyxError(
OnyxErrorCode.BAD_GATEWAY,
"Cannot reduce below 10 seats",
status_code_override=400,
mock_service.side_effect = BillingServiceError(
"Cannot reduce below 10 seats", 400
)
request = SeatUpdateRequest(new_seat_count=5)
with pytest.raises(OnyxError) as exc_info:
with pytest.raises(HTTPException) as exc_info:
await update_seats(request=request, _=MagicMock(), db_session=MagicMock())
assert exc_info.value.status_code == 400
assert exc_info.value.error_code is OnyxErrorCode.BAD_GATEWAY
assert exc_info.value.message == "Cannot reduce below 10 seats"
assert "Cannot reduce below 10 seats" in exc_info.value.detail
class TestCircuitBreaker:
@@ -335,18 +332,19 @@ class TestCircuitBreaker:
mock_circuit_open: MagicMock,
) -> None:
"""Should return 503 when circuit breaker is open."""
from fastapi import HTTPException
from ee.onyx.server.billing.api import get_billing_information
mock_get_license.return_value = "license_blob"
mock_get_tenant.return_value = None
mock_circuit_open.return_value = True
with pytest.raises(OnyxError) as exc_info:
with pytest.raises(HTTPException) as exc_info:
await get_billing_information(_=MagicMock(), db_session=MagicMock())
assert exc_info.value.status_code == 503
assert exc_info.value.error_code is OnyxErrorCode.SERVICE_UNAVAILABLE
assert "Connect to Stripe" in exc_info.value.message
assert "Connect to Stripe" in exc_info.value.detail
@pytest.mark.asyncio
@patch("ee.onyx.server.billing.api.MULTI_TENANT", False)
@@ -364,18 +362,16 @@ class TestCircuitBreaker:
mock_open_circuit: MagicMock,
) -> None:
"""Should open circuit breaker on 502 error."""
from fastapi import HTTPException
from ee.onyx.server.billing.api import get_billing_information
mock_get_license.return_value = "license_blob"
mock_get_tenant.return_value = None
mock_circuit_open_check.return_value = False
mock_service.side_effect = OnyxError(
OnyxErrorCode.BAD_GATEWAY,
"Connection failed",
status_code_override=502,
)
mock_service.side_effect = BillingServiceError("Connection failed", 502)
with pytest.raises(OnyxError) as exc_info:
with pytest.raises(HTTPException) as exc_info:
await get_billing_information(_=MagicMock(), db_session=MagicMock())
assert exc_info.value.status_code == 502
@@ -397,18 +393,16 @@ class TestCircuitBreaker:
mock_open_circuit: MagicMock,
) -> None:
"""Should open circuit breaker on 503 error."""
from fastapi import HTTPException
from ee.onyx.server.billing.api import get_billing_information
mock_get_license.return_value = "license_blob"
mock_get_tenant.return_value = None
mock_circuit_open_check.return_value = False
mock_service.side_effect = OnyxError(
OnyxErrorCode.BAD_GATEWAY,
"Service unavailable",
status_code_override=503,
)
mock_service.side_effect = BillingServiceError("Service unavailable", 503)
with pytest.raises(OnyxError) as exc_info:
with pytest.raises(HTTPException) as exc_info:
await get_billing_information(_=MagicMock(), db_session=MagicMock())
assert exc_info.value.status_code == 503
@@ -430,18 +424,16 @@ class TestCircuitBreaker:
mock_open_circuit: MagicMock,
) -> None:
"""Should open circuit breaker on 504 error."""
from fastapi import HTTPException
from ee.onyx.server.billing.api import get_billing_information
mock_get_license.return_value = "license_blob"
mock_get_tenant.return_value = None
mock_circuit_open_check.return_value = False
mock_service.side_effect = OnyxError(
OnyxErrorCode.BAD_GATEWAY,
"Gateway timeout",
status_code_override=504,
)
mock_service.side_effect = BillingServiceError("Gateway timeout", 504)
with pytest.raises(OnyxError) as exc_info:
with pytest.raises(HTTPException) as exc_info:
await get_billing_information(_=MagicMock(), db_session=MagicMock())
assert exc_info.value.status_code == 504
@@ -463,18 +455,16 @@ class TestCircuitBreaker:
mock_open_circuit: MagicMock,
) -> None:
"""Should NOT open circuit breaker on 400 error (client error)."""
from fastapi import HTTPException
from ee.onyx.server.billing.api import get_billing_information
mock_get_license.return_value = "license_blob"
mock_get_tenant.return_value = None
mock_circuit_open_check.return_value = False
mock_service.side_effect = OnyxError(
OnyxErrorCode.BAD_GATEWAY,
"Bad request",
status_code_override=400,
)
mock_service.side_effect = BillingServiceError("Bad request", 400)
with pytest.raises(OnyxError) as exc_info:
with pytest.raises(HTTPException) as exc_info:
await get_billing_information(_=MagicMock(), db_session=MagicMock())
assert exc_info.value.status_code == 400

View File

@@ -14,8 +14,7 @@ from ee.onyx.server.billing.models import CreateCheckoutSessionResponse
from ee.onyx.server.billing.models import CreateCustomerPortalSessionResponse
from ee.onyx.server.billing.models import SeatUpdateResponse
from ee.onyx.server.billing.models import SubscriptionStatusResponse
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from ee.onyx.server.billing.service import BillingServiceError
class TestMakeBillingRequest:
@@ -79,7 +78,7 @@ class TestMakeBillingRequest:
mock_base_url: MagicMock,
mock_headers: MagicMock,
) -> None:
"""Should raise OnyxError on HTTP error."""
"""Should raise BillingServiceError on HTTP error."""
from ee.onyx.server.billing.service import _make_billing_request
mock_base_url.return_value = "https://api.example.com"
@@ -92,7 +91,7 @@ class TestMakeBillingRequest:
mock_client = make_mock_http_client("post", side_effect=error)
with patch("httpx.AsyncClient", mock_client):
with pytest.raises(OnyxError) as exc_info:
with pytest.raises(BillingServiceError) as exc_info:
await _make_billing_request(
method="POST",
path="/test",
@@ -100,7 +99,6 @@ class TestMakeBillingRequest:
)
assert exc_info.value.status_code == 400
assert exc_info.value.error_code is OnyxErrorCode.BAD_GATEWAY
assert "Bad request" in exc_info.value.message
@pytest.mark.asyncio
@@ -138,7 +136,7 @@ class TestMakeBillingRequest:
mock_base_url: MagicMock,
mock_headers: MagicMock,
) -> None:
"""Should raise OnyxError on connection error."""
"""Should raise BillingServiceError on connection error."""
from ee.onyx.server.billing.service import _make_billing_request
mock_base_url.return_value = "https://api.example.com"
@@ -147,11 +145,10 @@ class TestMakeBillingRequest:
mock_client = make_mock_http_client("post", side_effect=error)
with patch("httpx.AsyncClient", mock_client):
with pytest.raises(OnyxError) as exc_info:
with pytest.raises(BillingServiceError) as exc_info:
await _make_billing_request(method="POST", path="/test")
assert exc_info.value.status_code == 502
assert exc_info.value.error_code is OnyxErrorCode.BAD_GATEWAY
assert "Failed to connect" in exc_info.value.message

View File

@@ -7,9 +7,6 @@ from unittest.mock import patch
import httpx
import pytest
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
class TestGetStripePublishableKey:
"""Tests for get_stripe_publishable_key endpoint."""
@@ -65,14 +62,15 @@ class TestGetStripePublishableKey:
)
async def test_rejects_invalid_env_var_key_format(self) -> None:
"""Should reject keys that don't start with pk_."""
from fastapi import HTTPException
from ee.onyx.server.tenants.billing_api import get_stripe_publishable_key
with pytest.raises(OnyxError) as exc_info:
with pytest.raises(HTTPException) as exc_info:
await get_stripe_publishable_key()
assert exc_info.value.status_code == 500
assert exc_info.value.error_code is OnyxErrorCode.INTERNAL_ERROR
assert exc_info.value.message == "Invalid Stripe publishable key format"
assert "Invalid Stripe publishable key format" in exc_info.value.detail
@pytest.mark.asyncio
@patch("ee.onyx.server.tenants.billing_api.STRIPE_PUBLISHABLE_KEY_OVERRIDE", None)
@@ -82,6 +80,8 @@ class TestGetStripePublishableKey:
)
async def test_rejects_invalid_s3_key_format(self) -> None:
"""Should reject keys from S3 that don't start with pk_."""
from fastapi import HTTPException
from ee.onyx.server.tenants.billing_api import get_stripe_publishable_key
mock_response = MagicMock()
@@ -92,12 +92,11 @@ class TestGetStripePublishableKey:
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
return_value=mock_response
)
with pytest.raises(OnyxError) as exc_info:
with pytest.raises(HTTPException) as exc_info:
await get_stripe_publishable_key()
assert exc_info.value.status_code == 500
assert exc_info.value.error_code is OnyxErrorCode.INTERNAL_ERROR
assert exc_info.value.message == "Invalid Stripe publishable key format"
assert "Invalid Stripe publishable key format" in exc_info.value.detail
@pytest.mark.asyncio
@patch("ee.onyx.server.tenants.billing_api.STRIPE_PUBLISHABLE_KEY_OVERRIDE", None)
@@ -107,32 +106,34 @@ class TestGetStripePublishableKey:
)
async def test_handles_s3_fetch_error(self) -> None:
"""Should return error when S3 fetch fails."""
from fastapi import HTTPException
from ee.onyx.server.tenants.billing_api import get_stripe_publishable_key
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
side_effect=httpx.HTTPError("Connection failed")
)
with pytest.raises(OnyxError) as exc_info:
with pytest.raises(HTTPException) as exc_info:
await get_stripe_publishable_key()
assert exc_info.value.status_code == 500
assert exc_info.value.error_code is OnyxErrorCode.INTERNAL_ERROR
assert exc_info.value.message == "Failed to fetch Stripe publishable key"
assert "Failed to fetch Stripe publishable key" in exc_info.value.detail
@pytest.mark.asyncio
@patch("ee.onyx.server.tenants.billing_api.STRIPE_PUBLISHABLE_KEY_OVERRIDE", None)
@patch("ee.onyx.server.tenants.billing_api.STRIPE_PUBLISHABLE_KEY_URL", None)
async def test_error_when_no_config(self) -> None:
"""Should return error when neither env var nor S3 URL is configured."""
from fastapi import HTTPException
from ee.onyx.server.tenants.billing_api import get_stripe_publishable_key
with pytest.raises(OnyxError) as exc_info:
with pytest.raises(HTTPException) as exc_info:
await get_stripe_publishable_key()
assert exc_info.value.status_code == 500
assert exc_info.value.error_code is OnyxErrorCode.INTERNAL_ERROR
assert "not configured" in exc_info.value.message
assert "not configured" in exc_info.value.detail
@pytest.mark.asyncio
@patch(

View File

@@ -1,178 +0,0 @@
"""Tests for _extract_referenced_file_descriptors in save_chat.py.
Verifies that only code interpreter generated files actually referenced
in the assistant's message text are extracted as FileDescriptors for
cross-turn persistence.
"""
from onyx.chat.save_chat import _extract_referenced_file_descriptors
from onyx.file_store.models import ChatFileType
from onyx.tools.models import PythonExecutionFile
from onyx.tools.models import ToolCallInfo
def _make_tool_call_info(
generated_files: list[PythonExecutionFile] | None = None,
tool_name: str = "python",
) -> ToolCallInfo:
return ToolCallInfo(
parent_tool_call_id=None,
turn_index=0,
tab_index=0,
tool_name=tool_name,
tool_call_id="tc_1",
tool_id=1,
reasoning_tokens=None,
tool_call_arguments={"code": "print('hi')"},
tool_call_response="{}",
generated_files=generated_files,
)
def test_returns_empty_when_no_generated_files() -> None:
tool_call = _make_tool_call_info(generated_files=None)
result = _extract_referenced_file_descriptors([tool_call], "some message")
assert result == []
def test_returns_empty_when_file_not_referenced() -> None:
files = [
PythonExecutionFile(
filename="chart.png",
file_link="http://localhost/api/chat/file/abc-123",
)
]
tool_call = _make_tool_call_info(generated_files=files)
result = _extract_referenced_file_descriptors([tool_call], "Here is your answer.")
assert result == []
def test_extracts_referenced_file() -> None:
file_id = "abc-123-def"
files = [
PythonExecutionFile(
filename="chart.png",
file_link=f"http://localhost/api/chat/file/{file_id}",
)
]
tool_call = _make_tool_call_info(generated_files=files)
message = (
f"Here is the chart: [chart.png](http://localhost/api/chat/file/{file_id})"
)
result = _extract_referenced_file_descriptors([tool_call], message)
assert len(result) == 1
assert result[0]["id"] == file_id
assert result[0]["type"] == ChatFileType.IMAGE
assert result[0]["name"] == "chart.png"
def test_filters_unreferenced_files() -> None:
referenced_id = "ref-111"
unreferenced_id = "unref-222"
files = [
PythonExecutionFile(
filename="chart.png",
file_link=f"http://localhost/api/chat/file/{referenced_id}",
),
PythonExecutionFile(
filename="data.csv",
file_link=f"http://localhost/api/chat/file/{unreferenced_id}",
),
]
tool_call = _make_tool_call_info(generated_files=files)
message = f"Here is the chart: [chart.png](http://localhost/api/chat/file/{referenced_id})"
result = _extract_referenced_file_descriptors([tool_call], message)
assert len(result) == 1
assert result[0]["id"] == referenced_id
assert result[0]["name"] == "chart.png"
def test_extracts_from_multiple_tool_calls() -> None:
id_1 = "file-aaa"
id_2 = "file-bbb"
tc1 = _make_tool_call_info(
generated_files=[
PythonExecutionFile(
filename="plot.png",
file_link=f"http://localhost/api/chat/file/{id_1}",
)
]
)
tc2 = _make_tool_call_info(
generated_files=[
PythonExecutionFile(
filename="report.csv",
file_link=f"http://localhost/api/chat/file/{id_2}",
)
]
)
message = (
f"[plot.png](http://localhost/api/chat/file/{id_1}) "
f"and [report.csv](http://localhost/api/chat/file/{id_2})"
)
result = _extract_referenced_file_descriptors([tc1, tc2], message)
assert len(result) == 2
ids = {d["id"] for d in result}
assert ids == {id_1, id_2}
def test_csv_file_type() -> None:
file_id = "csv-123"
files = [
PythonExecutionFile(
filename="data.csv",
file_link=f"http://localhost/api/chat/file/{file_id}",
)
]
tool_call = _make_tool_call_info(generated_files=files)
message = f"[data.csv](http://localhost/api/chat/file/{file_id})"
result = _extract_referenced_file_descriptors([tool_call], message)
assert len(result) == 1
assert result[0]["type"] == ChatFileType.CSV
def test_unknown_extension_defaults_to_plain_text() -> None:
file_id = "bin-456"
files = [
PythonExecutionFile(
filename="output.xyz",
file_link=f"http://localhost/api/chat/file/{file_id}",
)
]
tool_call = _make_tool_call_info(generated_files=files)
message = f"[output.xyz](http://localhost/api/chat/file/{file_id})"
result = _extract_referenced_file_descriptors([tool_call], message)
assert len(result) == 1
assert result[0]["type"] == ChatFileType.PLAIN_TEXT
def test_skips_tool_calls_without_generated_files() -> None:
file_id = "img-789"
tc_no_files = _make_tool_call_info(generated_files=None)
tc_empty = _make_tool_call_info(generated_files=[])
tc_with_files = _make_tool_call_info(
generated_files=[
PythonExecutionFile(
filename="result.png",
file_link=f"http://localhost/api/chat/file/{file_id}",
)
]
)
message = f"[result.png](http://localhost/api/chat/file/{file_id})"
result = _extract_referenced_file_descriptors(
[tc_no_files, tc_empty, tc_with_files], message
)
assert len(result) == 1
assert result[0]["id"] == file_id

View File

@@ -1,90 +0,0 @@
"""Tests for OnyxError and the global exception handler."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.error_handling.exceptions import register_onyx_exception_handlers
class TestOnyxError:
"""Unit tests for OnyxError construction and properties."""
def test_basic_construction(self) -> None:
err = OnyxError(OnyxErrorCode.NOT_FOUND, "Session not found")
assert err.error_code is OnyxErrorCode.NOT_FOUND
assert err.message == "Session not found"
assert err.status_code == 404
def test_message_defaults_to_code(self) -> None:
err = OnyxError(OnyxErrorCode.UNAUTHENTICATED)
assert err.message == "UNAUTHENTICATED"
assert str(err) == "UNAUTHENTICATED"
def test_status_code_override(self) -> None:
err = OnyxError(
OnyxErrorCode.BAD_GATEWAY,
"upstream failed",
status_code_override=503,
)
assert err.status_code == 503
# error_code still reports its own default
assert err.error_code.status_code == 502
def test_no_override_uses_error_code_status(self) -> None:
err = OnyxError(OnyxErrorCode.RATE_LIMITED, "slow down")
assert err.status_code == 429
def test_is_exception(self) -> None:
err = OnyxError(OnyxErrorCode.INTERNAL_ERROR)
assert isinstance(err, Exception)
class TestExceptionHandler:
"""Integration test: OnyxError → JSON response via FastAPI TestClient."""
@pytest.fixture()
def client(self) -> TestClient:
app = FastAPI()
register_onyx_exception_handlers(app)
@app.get("/boom")
def _boom() -> None:
raise OnyxError(OnyxErrorCode.NOT_FOUND, "Thing not found")
@app.get("/boom-override")
def _boom_override() -> None:
raise OnyxError(
OnyxErrorCode.BAD_GATEWAY,
"upstream 503",
status_code_override=503,
)
@app.get("/boom-default-msg")
def _boom_default() -> None:
raise OnyxError(OnyxErrorCode.UNAUTHENTICATED)
return TestClient(app, raise_server_exceptions=False)
def test_returns_correct_status_and_body(self, client: TestClient) -> None:
resp = client.get("/boom")
assert resp.status_code == 404
body = resp.json()
assert body["error_code"] == "NOT_FOUND"
assert body["message"] == "Thing not found"
def test_status_code_override_in_response(self, client: TestClient) -> None:
resp = client.get("/boom-override")
assert resp.status_code == 503
body = resp.json()
assert body["error_code"] == "BAD_GATEWAY"
assert body["message"] == "upstream 503"
def test_default_message(self, client: TestClient) -> None:
resp = client.get("/boom-default-msg")
assert resp.status_code == 401
body = resp.json()
assert body["error_code"] == "UNAUTHENTICATED"
assert body["message"] == "UNAUTHENTICATED"

View File

@@ -1,37 +1,25 @@
"""Tests for PythonTool availability based on server_enabled flag and health check.
"""Tests for PythonTool availability based on server_enabled flag.
Verifies that PythonTool reports itself as unavailable when either:
- CODE_INTERPRETER_BASE_URL is not set, or
- CodeInterpreterServer.server_enabled is False in the database, or
- The Code Interpreter service health check fails.
Also verifies that the health check result is cached with a TTL.
- CodeInterpreterServer.server_enabled is False in the database.
"""
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from sqlalchemy.orm import Session
TOOL_MODULE = "onyx.tools.tool_implementations.python.python_tool"
CLIENT_MODULE = "onyx.tools.tool_implementations.python.code_interpreter_client"
@pytest.fixture(autouse=True)
def _clear_health_cache() -> None:
"""Reset the health check cache before every test."""
import onyx.tools.tool_implementations.python.code_interpreter_client as mod
mod._health_cache = {}
# ------------------------------------------------------------------
# Unavailable when CODE_INTERPRETER_BASE_URL is not set
# ------------------------------------------------------------------
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", None)
@patch(
"onyx.tools.tool_implementations.python.python_tool.CODE_INTERPRETER_BASE_URL",
None,
)
def test_python_tool_unavailable_without_base_url() -> None:
from onyx.tools.tool_implementations.python.python_tool import PythonTool
@@ -39,7 +27,10 @@ def test_python_tool_unavailable_without_base_url() -> None:
assert PythonTool.is_available(db_session) is False
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "")
@patch(
"onyx.tools.tool_implementations.python.python_tool.CODE_INTERPRETER_BASE_URL",
"",
)
def test_python_tool_unavailable_with_empty_base_url() -> None:
from onyx.tools.tool_implementations.python.python_tool import PythonTool
@@ -52,8 +43,13 @@ def test_python_tool_unavailable_with_empty_base_url() -> None:
# ------------------------------------------------------------------
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://localhost:8000")
@patch(f"{TOOL_MODULE}.fetch_code_interpreter_server")
@patch(
"onyx.tools.tool_implementations.python.python_tool.CODE_INTERPRETER_BASE_URL",
"http://localhost:8000",
)
@patch(
"onyx.tools.tool_implementations.python.python_tool.fetch_code_interpreter_server",
)
def test_python_tool_unavailable_when_server_disabled(
mock_fetch: MagicMock,
) -> None:
@@ -68,15 +64,18 @@ def test_python_tool_unavailable_when_server_disabled(
# ------------------------------------------------------------------
# Health check determines availability when URL + server are OK
# Available when both conditions are met
# ------------------------------------------------------------------
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://localhost:8000")
@patch(f"{TOOL_MODULE}.fetch_code_interpreter_server")
@patch(f"{TOOL_MODULE}.CodeInterpreterClient")
def test_python_tool_available_when_health_check_passes(
mock_client_cls: MagicMock,
@patch(
"onyx.tools.tool_implementations.python.python_tool.CODE_INTERPRETER_BASE_URL",
"http://localhost:8000",
)
@patch(
"onyx.tools.tool_implementations.python.python_tool.fetch_code_interpreter_server",
)
def test_python_tool_available_when_server_enabled(
mock_fetch: MagicMock,
) -> None:
from onyx.tools.tool_implementations.python.python_tool import PythonTool
@@ -85,120 +84,5 @@ def test_python_tool_available_when_health_check_passes(
mock_server.server_enabled = True
mock_fetch.return_value = mock_server
mock_client = MagicMock()
mock_client.health.return_value = True
mock_client_cls.return_value = mock_client
db_session = MagicMock(spec=Session)
assert PythonTool.is_available(db_session) is True
mock_client.health.assert_called_once_with(use_cache=True)
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://localhost:8000")
@patch(f"{TOOL_MODULE}.fetch_code_interpreter_server")
@patch(f"{TOOL_MODULE}.CodeInterpreterClient")
def test_python_tool_unavailable_when_health_check_fails(
mock_client_cls: MagicMock,
mock_fetch: MagicMock,
) -> None:
from onyx.tools.tool_implementations.python.python_tool import PythonTool
mock_server = MagicMock()
mock_server.server_enabled = True
mock_fetch.return_value = mock_server
mock_client = MagicMock()
mock_client.health.return_value = False
mock_client_cls.return_value = mock_client
db_session = MagicMock(spec=Session)
assert PythonTool.is_available(db_session) is False
mock_client.health.assert_called_once_with(use_cache=True)
# ------------------------------------------------------------------
# Health check is NOT reached when preconditions fail
# ------------------------------------------------------------------
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://localhost:8000")
@patch(f"{TOOL_MODULE}.fetch_code_interpreter_server")
@patch(f"{TOOL_MODULE}.CodeInterpreterClient")
def test_health_check_not_called_when_server_disabled(
mock_client_cls: MagicMock,
mock_fetch: MagicMock,
) -> None:
from onyx.tools.tool_implementations.python.python_tool import PythonTool
mock_server = MagicMock()
mock_server.server_enabled = False
mock_fetch.return_value = mock_server
db_session = MagicMock(spec=Session)
assert PythonTool.is_available(db_session) is False
mock_client_cls.assert_not_called()
# ------------------------------------------------------------------
# Health check caching (tested at the client level)
# ------------------------------------------------------------------
def test_health_check_cached_on_second_call() -> None:
from onyx.tools.tool_implementations.python.code_interpreter_client import (
CodeInterpreterClient,
)
client = CodeInterpreterClient(base_url="http://fake:9000")
mock_response = MagicMock()
mock_response.json.return_value = {"status": "ok"}
with patch.object(client.session, "get", return_value=mock_response) as mock_get:
assert client.health(use_cache=True) is True
assert client.health(use_cache=True) is True
# Only one HTTP call — the second used the cache
mock_get.assert_called_once()
@patch(f"{CLIENT_MODULE}.time")
def test_health_check_refreshed_after_ttl_expires(mock_time: MagicMock) -> None:
from onyx.tools.tool_implementations.python.code_interpreter_client import (
CodeInterpreterClient,
_HEALTH_CACHE_TTL_SECONDS,
)
client = CodeInterpreterClient(base_url="http://fake:9000")
mock_response = MagicMock()
mock_response.json.return_value = {"status": "ok"}
with patch.object(client.session, "get", return_value=mock_response) as mock_get:
# First call at t=0 — cache miss
mock_time.monotonic.return_value = 0.0
assert client.health(use_cache=True) is True
assert mock_get.call_count == 1
# Second call within TTL — cache hit
mock_time.monotonic.return_value = float(_HEALTH_CACHE_TTL_SECONDS - 1)
assert client.health(use_cache=True) is True
assert mock_get.call_count == 1
# Third call after TTL — cache miss, fresh request
mock_time.monotonic.return_value = float(_HEALTH_CACHE_TTL_SECONDS + 1)
assert client.health(use_cache=True) is True
assert mock_get.call_count == 2
def test_health_check_no_cache_by_default() -> None:
from onyx.tools.tool_implementations.python.code_interpreter_client import (
CodeInterpreterClient,
)
client = CodeInterpreterClient(base_url="http://fake:9000")
mock_response = MagicMock()
mock_response.json.return_value = {"status": "ok"}
with patch.object(client.session, "get", return_value=mock_response) as mock_get:
assert client.health() is True
assert client.health() is True
# Both calls hit the network when use_cache=False (default)
assert mock_get.call_count == 2

View File

@@ -1,164 +0,0 @@
from __future__ import annotations
from typing import Any
from typing import cast
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from onyx.server.query_and_chat.placement import Placement
from onyx.tools.models import ToolCallException
from onyx.tools.models import WebSearchToolOverrideKwargs
from onyx.tools.tool_implementations.web_search.models import WebSearchResult
from onyx.tools.tool_implementations.web_search.web_search_tool import (
_normalize_queries_input,
)
from onyx.tools.tool_implementations.web_search.web_search_tool import WebSearchTool
def _make_result(
title: str = "Title", link: str = "https://example.com"
) -> WebSearchResult:
return WebSearchResult(title=title, link=link, snippet="snippet")
def _make_tool(mock_provider: Any) -> WebSearchTool:
"""Instantiate WebSearchTool with all DB/provider deps mocked out."""
provider_model = MagicMock()
provider_model.provider_type = "brave"
provider_model.api_key = MagicMock()
provider_model.api_key.get_value.return_value = "fake-key"
provider_model.config = {}
with (
patch(
"onyx.tools.tool_implementations.web_search.web_search_tool.get_session_with_current_tenant"
) as mock_session_ctx,
patch(
"onyx.tools.tool_implementations.web_search.web_search_tool.fetch_active_web_search_provider",
return_value=provider_model,
),
patch(
"onyx.tools.tool_implementations.web_search.web_search_tool.build_search_provider_from_config",
return_value=mock_provider,
),
):
mock_session_ctx.return_value.__enter__ = MagicMock(return_value=MagicMock())
mock_session_ctx.return_value.__exit__ = MagicMock(return_value=False)
tool = WebSearchTool(tool_id=1, emitter=MagicMock())
return tool
def _run(tool: WebSearchTool, queries: Any) -> list[str]:
"""Call tool.run() and return the list of query strings passed to provider.search."""
placement = Placement(turn_index=0, tab_index=0)
override_kwargs = WebSearchToolOverrideKwargs(starting_citation_num=1)
tool.run(placement=placement, override_kwargs=override_kwargs, queries=queries)
search_mock = cast(MagicMock, tool._provider.search) # noqa: SLF001
return [call.args[0] for call in search_mock.call_args_list]
class TestNormalizeQueriesInput:
"""Unit tests for _normalize_queries_input (coercion + sanitization)."""
def test_bare_string_returns_single_element_list(self) -> None:
assert _normalize_queries_input("hello") == ["hello"]
def test_bare_string_stripped_and_sanitized(self) -> None:
assert _normalize_queries_input(" hello ") == ["hello"]
# Control chars (e.g. null) removed; no space inserted
assert _normalize_queries_input("hello\x00world") == ["helloworld"]
def test_empty_string_returns_empty_list(self) -> None:
assert _normalize_queries_input("") == []
assert _normalize_queries_input(" ") == []
def test_list_of_strings_returned_sanitized(self) -> None:
assert _normalize_queries_input(["a", "b"]) == ["a", "b"]
# Leading/trailing space stripped; control chars (e.g. tab) removed
assert _normalize_queries_input([" a ", "b\tb"]) == ["a", "bb"]
def test_list_none_skipped(self) -> None:
assert _normalize_queries_input(["a", None, "b"]) == ["a", "b"]
def test_list_non_string_coerced(self) -> None:
assert _normalize_queries_input([1, "two"]) == ["1", "two"]
def test_list_whitespace_only_dropped(self) -> None:
assert _normalize_queries_input(["a", "", " ", "b"]) == ["a", "b"]
def test_non_list_non_string_returns_empty_list(self) -> None:
assert _normalize_queries_input(42) == []
assert _normalize_queries_input({}) == []
class TestWebSearchToolRunQueryCoercion:
def test_list_of_strings_dispatches_each_query(self) -> None:
"""Normal case: list of queries → one search call per query."""
mock_provider = MagicMock()
mock_provider.search.return_value = [_make_result()]
mock_provider.supports_site_filter = False
tool = _make_tool(mock_provider)
dispatched = _run(tool, ["python decorators", "python generators"])
# run_functions_tuples_in_parallel uses a thread pool; call_args_list order is non-deterministic.
assert sorted(dispatched) == ["python decorators", "python generators"]
def test_bare_string_dispatches_as_single_query(self) -> None:
"""LLM returns a bare string instead of an array — must NOT be split char-by-char."""
mock_provider = MagicMock()
mock_provider.search.return_value = [_make_result()]
mock_provider.supports_site_filter = False
tool = _make_tool(mock_provider)
dispatched = _run(tool, "what is the capital of France")
assert len(dispatched) == 1
assert dispatched[0] == "what is the capital of France"
def test_bare_string_does_not_search_individual_characters(self) -> None:
"""Regression: single-char searches must not occur."""
mock_provider = MagicMock()
mock_provider.search.return_value = [_make_result()]
mock_provider.supports_site_filter = False
tool = _make_tool(mock_provider)
dispatched = _run(tool, "hi")
for query_arg in dispatched:
assert (
len(query_arg) > 1
), f"Single-character query dispatched: {query_arg!r}"
def test_control_characters_sanitized_before_dispatch(self) -> None:
"""Queries with control chars have those chars removed before dispatch."""
mock_provider = MagicMock()
mock_provider.search.return_value = [_make_result()]
mock_provider.supports_site_filter = False
tool = _make_tool(mock_provider)
dispatched = _run(tool, ["foo\x00bar", "baz\tbaz"])
# run_functions_tuples_in_parallel uses a thread pool; call_args_list is in
# execution order, not submission order, so compare in sorted order.
assert sorted(dispatched) == ["bazbaz", "foobar"]
def test_all_empty_or_whitespace_raises_tool_call_exception(self) -> None:
"""When normalization yields no valid queries, run() raises ToolCallException."""
mock_provider = MagicMock()
mock_provider.supports_site_filter = False
tool = _make_tool(mock_provider)
placement = Placement(turn_index=0, tab_index=0)
override_kwargs = WebSearchToolOverrideKwargs(starting_citation_num=1)
with pytest.raises(ToolCallException) as exc_info:
tool.run(
placement=placement,
override_kwargs=override_kwargs,
queries=" ",
)
assert "No valid" in str(exc_info.value)
cast(MagicMock, mock_provider.search).assert_not_called()

View File

@@ -1,32 +1,30 @@
# =============================================================================
# ONYX LITE — MINIMAL DEPLOYMENT OVERLAY
# ONYX NO-VECTOR-DB OVERLAY
# =============================================================================
# Overlay to run Onyx in a minimal configuration: no vector database (Vespa),
# no Redis, no model servers, and no background workers. Only PostgreSQL is
# required. In this mode, connectors and RAG search are disabled, but the core
# chat experience (LLM conversations, tools, user file uploads, Projects,
# Agent knowledge, code interpreter) still works.
# Overlay to run Onyx without a vector database (Vespa), model servers, or
# code interpreter. In this mode, connectors and RAG search are disabled, but
# the core chat experience (LLM conversations, tools, user file uploads,
# Projects, Agent knowledge) still works.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.onyx-lite.yml up -d
# docker compose -f docker-compose.yml -f docker-compose.no-vectordb.yml up -d
#
# With dev ports:
# docker compose -f docker-compose.yml -f docker-compose.onyx-lite.yml \
# docker compose -f docker-compose.yml -f docker-compose.no-vectordb.yml \
# -f docker-compose.dev.yml up -d --wait
#
# This overlay:
# - Moves Vespa (index), both model servers, code-interpreter, Redis (cache),
# and the background worker to profiles so they do not start by default
# - Makes depends_on references to removed services optional
# - Moves Vespa (index), both model servers, and code-interpreter to profiles
# so they do not start by default
# - Moves the background worker to the "background" profile (the API server
# handles all background work via FastAPI BackgroundTasks)
# - Makes the depends_on references to removed services optional
# - Sets DISABLE_VECTOR_DB=true on the api_server
# - Uses PostgreSQL for caching and auth instead of Redis
# - Uses PostgreSQL for file storage instead of S3/MinIO
#
# To selectively bring services back:
# --profile vectordb Vespa + indexing model server
# --profile inference Inference model server
# --profile background Background worker (Celery) — also needs redis
# --profile redis Redis cache
# --profile background Background worker (Celery)
# --profile code-interpreter Code interpreter
# =============================================================================
@@ -38,9 +36,6 @@ services:
index:
condition: service_started
required: false
cache:
condition: service_started
required: false
inference_model_server:
condition: service_started
required: false
@@ -50,11 +45,9 @@ services:
environment:
- DISABLE_VECTOR_DB=true
- FILE_STORE_BACKEND=postgres
- CACHE_BACKEND=postgres
- AUTH_BACKEND=postgres
# Move the background worker to a profile so it does not start by default.
# The API server handles all background work in lite mode.
# The API server handles all background work in NO_VECTOR_DB mode.
background:
profiles: ["background"]
depends_on:
@@ -68,11 +61,6 @@ services:
condition: service_started
required: false
# Move Redis to a profile so it does not start by default.
# The Postgres cache backend replaces Redis in lite mode.
cache:
profiles: ["redis"]
# Move Vespa and indexing model server to a profile so they do not start.
index:
profiles: ["vectordb"]

View File

@@ -1,31 +0,0 @@
# =============================================================================
# ONYX LITE — MINIMAL DEPLOYMENT VALUES
# =============================================================================
# Minimal Onyx deployment: no vector database, no Redis, no model servers.
# Only PostgreSQL is required. Connectors and RAG search are disabled, but the
# core chat experience (LLM conversations, tools, user file uploads, Projects,
# Agent knowledge) still works.
#
# Usage:
# helm install onyx ./deployment/helm/charts/onyx \
# -f ./deployment/helm/charts/onyx/values-lite.yaml
#
# Or merged with your own overrides:
# helm install onyx ./deployment/helm/charts/onyx \
# -f ./deployment/helm/charts/onyx/values-lite.yaml \
# -f my-overrides.yaml
# =============================================================================
vectorDB:
enabled: false
vespa:
enabled: false
redis:
enabled: false
configMap:
CACHE_BACKEND: "postgres"
AUTH_BACKEND: "postgres"
FILE_STORE_BACKEND: "postgres"

View File

@@ -1,93 +0,0 @@
{
"labels": [],
"comment": "",
"fixWithAI": true,
"hideFooter": false,
"strictness": 2,
"statusCheck": true,
"commentTypes": [
"logic",
"syntax",
"style"
],
"instructions": "",
"disabledLabels": [],
"excludeAuthors": [
"dependabot[bot]",
"renovate[bot]"
],
"ignoreKeywords": "",
"ignorePatterns": "greptile.json\n",
"includeAuthors": [],
"summarySection": {
"included": true,
"collapsible": false,
"defaultOpen": false
},
"excludeBranches": [],
"fileChangeLimit": 300,
"includeBranches": [],
"includeKeywords": "",
"triggerOnUpdates": true,
"updateExistingSummaryComment": true,
"updateSummaryOnly": false,
"issuesTableSection": {
"included": true,
"collapsible": false,
"defaultOpen": false
},
"statusCommentsEnabled": true,
"confidenceScoreSection": {
"included": true,
"collapsible": false
},
"sequenceDiagramSection": {
"included": true,
"collapsible": false,
"defaultOpen": false
},
"shouldUpdateDescription": false,
"customContext": {
"other": [
{
"scope": [],
"content": "Use explicit type annotations for variables to enhance code clarity, especially when moving type hints around in the code."
}
],
"rules": [
{
"scope": [],
"rule": "Whenever a TODO is added, there must always be an associated name or ticket with that TODO in the style of TODO(name): ... or TODO(1234): ..."
},
{
"scope": ["web/**"],
"rule": "For frontend changes (changes that touch the /web directory), make sure to enforce all standards described in the web/STANDARDS.md file."
},
{
"scope": [],
"rule": "Remove temporary debugging code before merging to production, especially tenant-specific debugging logs."
},
{
"scope": [],
"rule": "When hardcoding a boolean variable to a constant value, remove the variable entirely and clean up all places where it's used rather than just setting it to a constant."
},
{
"scope": ["backend/**/*.py"],
"rule": "Never raise HTTPException directly in business code. Use `raise OnyxError(OnyxErrorCode.XXX, \"message\")` from `onyx.error_handling.exceptions`. A global FastAPI exception handler converts OnyxError into structured JSON responses with {\"error_code\": \"...\", \"message\": \"...\"}. Error codes are defined in `onyx.error_handling.error_codes.OnyxErrorCode`. For upstream errors with dynamic HTTP status codes, use `status_code_override`: `raise OnyxError(OnyxErrorCode.BAD_GATEWAY, detail, status_code_override=upstream_status)`."
}
],
"files": [
{
"scope": [],
"path": "contributing_guides/best_practices.md",
"description": "Best practices for contributing to the codebase"
},
{
"scope": [],
"path": "CLAUDE.md",
"description": "Project instructions and coding standards"
}
]
}
}

View File

@@ -18,10 +18,6 @@
"types": "./src/icons/index.ts",
"default": "./src/icons/index.ts"
},
"./illustrations": {
"types": "./src/illustrations/index.ts",
"default": "./src/illustrations/index.ts"
},
"./types": {
"types": "./src/types.ts",
"default": "./src/types.ts"

View File

@@ -1,99 +0,0 @@
# SVG-to-TSX Conversion Scripts
## Overview
Integrating `@svgr/webpack` into the TypeScript compiler was not working via the recommended route (Next.js webpack configuration).
The automatic SVG-to-React component conversion was causing compilation issues and import resolution problems.
Therefore, we manually convert each SVG into a TSX file using SVGR CLI with a custom template.
All scripts in this directory should be run from the **opal package root** (`web/lib/opal/`).
## Directory Layout
```
web/lib/opal/
├── scripts/ # SVG conversion tooling (this directory)
│ ├── convert-svg.sh # Converts SVGs into React components
│ └── icon-template.js # Shared SVGR template (used for both icons and illustrations)
├── src/
│ ├── icons/ # Small, single-colour icons (stroke = currentColor)
│ └── illustrations/ # Larger, multi-colour illustrations (colours preserved)
└── package.json
```
## Icons vs Illustrations
| | Icons | Illustrations |
|---|---|---|
| **Import path** | `@opal/icons` | `@opal/illustrations` |
| **Location** | `src/icons/` | `src/illustrations/` |
| **Colour** | Overridable via `currentColor` | Fixed — original SVG colours preserved |
| **Script flag** | (none) | `--illustration` |
## Files in This Directory
### `icon-template.js`
A custom SVGR template that generates components with the following features:
- Imports `IconProps` from `@opal/types` for consistent typing
- Supports the `size` prop for controlling icon dimensions
- Includes `width` and `height` attributes bound to the `size` prop
- Maintains all standard SVG props (className, color, title, etc.)
### `convert-svg.sh`
Converts an SVG into a React component. Behaviour depends on the mode:
**Icon mode** (default):
- Strips `stroke`, `stroke-opacity`, `width`, and `height` attributes
- Adds `width={size}`, `height={size}`, and `stroke="currentColor"`
- Result is colour-overridable via CSS `color` property
**Illustration mode** (`--illustration`):
- Strips only `width` and `height` attributes (all colours preserved)
- Adds `width={size}` and `height={size}`
- Does **not** add `stroke="currentColor"` — illustrations keep their original colours
Both modes automatically delete the source SVG file after successful conversion.
## Adding New SVGs
### Icons
```sh
# From web/lib/opal/
./scripts/convert-svg.sh src/icons/my-icon.svg
```
Then add the export to `src/icons/index.ts`:
```ts
export { default as SvgMyIcon } from "@opal/icons/my-icon";
```
### Illustrations
```sh
# From web/lib/opal/
./scripts/convert-svg.sh --illustration src/illustrations/my-illustration.svg
```
Then add the export to `src/illustrations/index.ts`:
```ts
export { default as SvgMyIllustration } from "@opal/illustrations/my-illustration";
```
## Manual Conversion
If you prefer to run the SVGR command directly:
**For icons** (strips colours):
```sh
bunx @svgr/cli <file>.svg --typescript --svgo-config '{"plugins":[{"name":"removeAttrs","params":{"attrs":["stroke","stroke-opacity","width","height"]}}]}' --template scripts/icon-template.js > <file>.tsx
```
**For illustrations** (preserves colours):
```sh
bunx @svgr/cli <file>.svg --typescript --svgo-config '{"plugins":[{"name":"removeAttrs","params":{"attrs":["width","height"]}}]}' --template scripts/icon-template.js > <file>.tsx
```
After running either manual command, remember to delete the original SVG file.

View File

@@ -1,123 +0,0 @@
#!/bin/bash
# Convert an SVG file to a TypeScript React component.
#
# By default, converts to a colour-overridable icon (stroke colours stripped, replaced with currentColor).
# With --illustration, converts to a fixed-colour illustration (all original colours preserved).
#
# Usage (from the opal package root — web/lib/opal/):
# ./scripts/convert-svg.sh src/icons/<filename.svg>
# ./scripts/convert-svg.sh --illustration src/illustrations/<filename.svg>
ILLUSTRATION=false
# Parse flags
while [[ "$1" == --* ]]; do
case "$1" in
--illustration)
ILLUSTRATION=true
shift
;;
*)
echo "Unknown flag: $1" >&2
echo "Usage: ./scripts/convert-svg.sh [--illustration] <filename.svg>" >&2
exit 1
;;
esac
done
if [ -z "$1" ]; then
echo "Usage: ./scripts/convert-svg.sh [--illustration] <filename.svg>" >&2
exit 1
fi
SVG_FILE="$1"
# Check if file exists
if [ ! -f "$SVG_FILE" ]; then
echo "Error: File '$SVG_FILE' not found" >&2
exit 1
fi
# Check if it's an SVG file
if [[ ! "$SVG_FILE" == *.svg ]]; then
echo "Error: File must have .svg extension" >&2
exit 1
fi
# Get the base name without extension
BASE_NAME="${SVG_FILE%.svg}"
# Build the SVGO config based on mode
if [ "$ILLUSTRATION" = true ]; then
# Illustrations: only strip width and height (preserve all colours)
SVGO_CONFIG='{"plugins":[{"name":"removeAttrs","params":{"attrs":["width","height"]}}]}'
else
# Icons: strip stroke, stroke-opacity, width, and height
SVGO_CONFIG='{"plugins":[{"name":"removeAttrs","params":{"attrs":["stroke","stroke-opacity","width","height"]}}]}'
fi
# Resolve the template path relative to this script (not the caller's CWD)
SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
# Run the conversion into a temp file so a failed run doesn't destroy an existing .tsx
TMPFILE="${BASE_NAME}.tsx.tmp"
bunx @svgr/cli "$SVG_FILE" --typescript --svgo-config "$SVGO_CONFIG" --template "${SCRIPT_DIR}/icon-template.js" > "$TMPFILE"
if [ $? -eq 0 ]; then
# Verify the temp file has content before replacing the destination
if [ ! -s "$TMPFILE" ]; then
rm -f "$TMPFILE"
echo "Error: Output file was not created or is empty" >&2
exit 1
fi
mv "$TMPFILE" "${BASE_NAME}.tsx" || { echo "Error: Failed to move temp file" >&2; exit 1; }
# Post-process the file to add width and height attributes bound to the size prop
# Using perl for cross-platform compatibility (works on macOS, Linux, Windows with WSL)
# Note: perl -i returns 0 even on some failures, so we validate the output
perl -i -pe 's/<svg/<svg width={size} height={size}/g' "${BASE_NAME}.tsx"
if [ $? -ne 0 ]; then
echo "Error: Failed to add width/height attributes" >&2
exit 1
fi
# Icons additionally get stroke="currentColor"
if [ "$ILLUSTRATION" = false ]; then
perl -i -pe 's/\{\.\.\.props\}/stroke="currentColor" {...props}/g' "${BASE_NAME}.tsx"
if [ $? -ne 0 ]; then
echo "Error: Failed to add stroke attribute" >&2
exit 1
fi
fi
# Verify the file still exists and has content after post-processing
if [ ! -s "${BASE_NAME}.tsx" ]; then
echo "Error: Output file corrupted during post-processing" >&2
exit 1
fi
# Verify required attributes are present in the output
if ! grep -q 'width={size}' "${BASE_NAME}.tsx" || ! grep -q 'height={size}' "${BASE_NAME}.tsx"; then
echo "Error: Post-processing did not add required attributes" >&2
exit 1
fi
# For icons, also verify stroke="currentColor" was added
if [ "$ILLUSTRATION" = false ]; then
if ! grep -q 'stroke="currentColor"' "${BASE_NAME}.tsx"; then
echo "Error: Post-processing did not add stroke=\"currentColor\"" >&2
exit 1
fi
fi
echo "Created ${BASE_NAME}.tsx"
rm "$SVG_FILE"
echo "Deleted $SVG_FILE"
else
rm -f "$TMPFILE"
echo "Error: Conversion failed" >&2
exit 1
fi

View File

@@ -0,0 +1,58 @@
# Compilation of SVGs into TypeScript React Components
## Overview
Integrating `@svgr/webpack` into the TypeScript compiler was not working via the recommended route (Next.js webpack configuration).
The automatic SVG-to-React component conversion was causing compilation issues and import resolution problems.
Therefore, we manually convert each SVG into a TSX file using SVGR CLI with a custom template.
## Files in This Directory
### `scripts/icon-template.js`
A custom SVGR template that generates icon components with the following features:
- Imports `IconProps` from `@opal/types` for consistent typing
- Supports the `size` prop for controlling icon dimensions
- Includes `width` and `height` attributes bound to the `size` prop
- Maintains all standard SVG props (className, color, title, etc.)
This ensures all generated icons have a consistent API and type definitions.
### `scripts/convert-svg.sh`
A convenience script that automates the SVG-to-TSX conversion process. It:
- Validates the input file
- Runs SVGR with the correct configuration and template
- Post-processes the output to add `width`, `height`, and `stroke` attributes using perl (cross-platform compatible)
- Automatically deletes the source SVG file after successful conversion
- Provides error handling and user feedback
**Usage:**
```sh
./scripts/convert-svg.sh <filename.svg>
```
## Adding New SVGs
**Recommended Method:**
Use the conversion script for the easiest experience:
```sh
./scripts/convert-svg.sh my-icon.svg
```
**Manual Method:**
If you prefer to run the command directly:
```sh
bunx @svgr/cli ${SVG_FILE_NAME}.svg --typescript --svgo-config '{"plugins":[{"name":"removeAttrs","params":{"attrs":["stroke","stroke-opacity","width","height"]}}]}' --template scripts/icon-template.js > ${SVG_FILE_NAME}.tsx
```
This command:
- Converts SVG files to TypeScript React components (`--typescript`)
- Removes `stroke`, `stroke-opacity`, `width`, and `height` attributes from SVG elements (`--svgo-config` with `removeAttrs` plugin)
- Uses the custom template (`icon-template.js`) to generate components with `IconProps` and `size` prop support
After running the manual command, remember to delete the original SVG file.

View File

@@ -0,0 +1,72 @@
#!/bin/bash
# Convert an SVG file to a TypeScript React component
# Usage: ./convert-svg.sh <filename.svg>
if [ -z "$1" ]; then
echo "Usage: ./convert-svg.sh <filename.svg>" >&2
exit 1
fi
SVG_FILE="$1"
# Check if file exists
if [ ! -f "$SVG_FILE" ]; then
echo "Error: File '$SVG_FILE' not found" >&2
exit 1
fi
# Check if it's an SVG file
if [[ ! "$SVG_FILE" == *.svg ]]; then
echo "Error: File must have .svg extension" >&2
exit 1
fi
# Get the base name without extension
BASE_NAME="${SVG_FILE%.svg}"
# Run the conversion with relative path to template
bunx @svgr/cli "$SVG_FILE" --typescript --svgo-config '{"plugins":[{"name":"removeAttrs","params":{"attrs":["stroke","stroke-opacity","width","height"]}}]}' --template "scripts/icon-template.js" > "${BASE_NAME}.tsx"
if [ $? -eq 0 ]; then
# Verify the output file was created and has content
if [ ! -s "${BASE_NAME}.tsx" ]; then
echo "Error: Output file was not created or is empty" >&2
exit 1
fi
# Post-process the file to add width, height, and stroke attributes
# Using perl for cross-platform compatibility (works on macOS, Linux, Windows with WSL)
# Note: perl -i returns 0 even on some failures, so we validate the output
perl -i -pe 's/<svg/<svg width={size} height={size}/g' "${BASE_NAME}.tsx"
if [ $? -ne 0 ]; then
echo "Error: Failed to add width/height attributes" >&2
exit 1
fi
perl -i -pe 's/\{\.\.\.props\}/stroke="currentColor" {...props}/g' "${BASE_NAME}.tsx"
if [ $? -ne 0 ]; then
echo "Error: Failed to add stroke attribute" >&2
exit 1
fi
# Verify the file still exists and has content after post-processing
if [ ! -s "${BASE_NAME}.tsx" ]; then
echo "Error: Output file corrupted during post-processing" >&2
exit 1
fi
# Verify required attributes are present in the output
if ! grep -q 'width={size}' "${BASE_NAME}.tsx" || ! grep -q 'stroke="currentColor"' "${BASE_NAME}.tsx"; then
echo "Error: Post-processing did not add required attributes" >&2
exit 1
fi
echo "Created ${BASE_NAME}.tsx"
rm "$SVG_FILE"
echo "Deleted $SVG_FILE"
else
echo "Error: Conversion failed" >&2
exit 1
fi

View File

@@ -1,27 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgBrokenKey = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M54.375 43.125H43.125M69.375 28.125V16.875M58.125 31.875L48.75 22.5"
stroke="#EC5B13"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M108.75 18.75L98.5535 24.6369M98.5535 24.6369L104.044 34.1465L91.7404 41.25L86.25 31.7404M98.5535 24.6369L86.25 31.7404M86.25 31.7404L78.7499 36.0705M49.6599 62.8401C45.5882 58.7684 39.9632 56.25 33.75 56.25C21.3236 56.25 11.25 66.3236 11.25 78.75C11.25 91.1764 21.3236 101.25 33.75 101.25C46.1764 101.25 56.25 91.1764 56.25 78.75C56.25 72.5368 53.7316 66.9118 49.6599 62.8401ZM49.6599 62.8401L49.6406 62.8594M49.6599 62.8401L60 52.5"
stroke="#A4A4A4"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgBrokenKey;

View File

@@ -1,35 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgConnect = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M43.125 86.2644H73.379M73.379 86.2644H90.9447C95.6006 86.2644 99.375 90.0388 99.375 94.6947C99.375 99.3506 95.6006 103.125 90.9447 103.125H89.6455C86.3575 103.125 83.292 101.464 81.4959 98.7104L73.379 86.2644ZM73.379 86.2644L39.1266 33.7441M69.375 33.7372L39.1266 33.7441M39.1266 33.7441L21.5635 33.7481C16.9034 33.7491 13.125 29.9717 13.125 25.3115C13.125 20.6522 16.9022 16.875 21.5616 16.875H22.8545C26.1425 16.875 29.208 18.5356 31.0041 21.2896L39.1266 33.7441Z"
stroke="#286DF8"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M99.3626 50.625V43.125V24.375V16.875L86.2376 16.875C76.9178 16.875 69.3626 24.4302 69.3626 33.75C69.3626 43.0698 76.9178 50.625 86.2376 50.625H99.3626Z"
fill="#E6E6E6"
/>
<path
d="M13.1126 103.125L13.1126 69.3751L26.2376 69.375C35.5574 69.375 43.1126 76.9302 43.1126 86.25C43.1126 95.5698 35.5574 103.125 26.2376 103.125L13.1126 103.125Z"
fill="white"
/>
<path
d="M99.3626 43.125H110.613M99.3626 43.125V24.375M99.3626 43.125V50.625M99.3626 24.375H110.613M99.3626 24.375V16.875M99.3626 50.625H86.2376C76.9178 50.625 69.3626 43.0698 69.3626 33.75C69.3626 24.4302 76.9178 16.875 86.2376 16.875L99.3626 16.875M99.3626 50.625V54.375M99.3626 16.875V13.125M13.1126 103.125L26.2376 103.125C35.5574 103.125 43.1126 95.5698 43.1126 86.25C43.1126 76.9302 35.5574 69.375 26.2376 69.375L13.1126 69.3751M13.1126 103.125L13.1126 69.3751M13.1126 103.125L13.1126 106.875M13.1126 69.3751V65.6251"
stroke="#A4A4A4"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgConnect;

View File

@@ -1,42 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgConnected = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M48.0722 48.0722L53.4375 53.4375L66.5625 66.5625L71.9324 71.9416L82.5 61.3648C89.0901 54.7747 89.0901 44.0901 82.5 37.5C75.9099 30.9099 65.2253 30.9099 58.6352 37.5L48.0722 48.0722Z"
fill="#E6E6E6"
/>
<path
d="M48.0722 48.0722L58.6352 37.5C65.2253 30.9099 75.9099 30.9099 82.5 37.5M48.0722 48.0722L43.125 43.125M48.0722 48.0722L53.4375 53.4375M71.9324 71.9416L82.5 61.3648C89.0901 54.7747 89.0901 44.0901 82.5 37.5M71.9324 71.9416L76.875 76.8842M71.9324 71.9416L66.5625 66.5625M82.5 37.5L105 15M53.4375 53.4375L43.125 63.75M53.4375 53.4375L66.5625 66.5625M66.5625 66.5625L56.25 76.875"
stroke="#A4A4A4"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M71.9278 71.937L48.0676 48.0675L37.5 58.6443C30.9099 65.2344 30.9099 75.9191 37.5 82.5092C44.0901 89.0993 54.7748 89.0993 61.3649 82.5092L71.9278 71.937Z"
fill="white"
/>
<path
d="M71.9278 71.937L61.3649 82.5092C54.7748 89.0993 44.0901 89.0993 37.5 82.5092M71.9278 71.937L48.0676 48.0675M71.9278 71.937L76.875 76.8842M48.0676 48.0675L37.5 58.6443C30.9099 65.2344 30.9099 75.9191 37.5 82.5092M48.0676 48.0675L43.125 43.125M37.5 82.5092L15 105"
stroke="#CCCCCC"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M24.375 24.375L33.75 33.75L52.5 15"
stroke="#286DF8"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgConnected;

View File

@@ -1,42 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgDisconnected = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M60 83.8554L36.1351 59.9906L26.25 69.8849C19.6599 76.475 19.6599 87.1597 26.25 93.7498C32.8401 100.34 43.5248 100.34 50.1149 93.7498L60 83.8554Z"
fill="white"
/>
<path
d="M60 83.8554L50.1149 93.7498C43.5248 100.34 32.8401 100.34 26.25 93.7498M60 83.8554L36.1351 59.9906M60 83.8554L63.75 87.6055M36.1351 59.9906L26.25 69.8849C19.6599 76.475 19.6599 87.1597 26.25 93.7498M36.1351 59.9906L32.3946 56.25M26.25 93.7498L15 105"
stroke="#CCCCCC"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M60 36.1443L65.3033 41.4476L78.5616 54.7059L83.8649 60.0092L93.75 50.1148C100.34 43.5247 100.34 32.8401 93.75 26.25C87.1599 19.6599 76.4752 19.6599 69.8851 26.25L60 36.1443Z"
fill="#E6E6E6"
/>
<path
d="M65.3033 41.4476L56.25 50.5009M65.3033 41.4476L60 36.1443M65.3033 41.4476L78.5616 54.7059M60 36.1443L69.8851 26.25C76.4752 19.6599 87.1599 19.6599 93.75 26.25M60 36.1443L56.25 32.3942M83.8649 60.0092L93.75 50.1148C100.34 43.5247 100.34 32.8401 93.75 26.25M83.8649 60.0092L78.5616 54.7059M83.8649 60.0092L87.6054 63.7498M78.5616 54.7059L69.5177 63.7498M93.75 26.25L105 15"
stroke="#A4A4A4"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M30 45H18.75M45 30V18.75M33.75 33.75L24.375 24.375"
stroke="#EC5B13"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgDisconnected;

View File

@@ -1,32 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgEmpty = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M18.75 71.25V90C18.75 94.1421 22.1079 97.5 26.25 97.5H93.75C97.8921 97.5 101.25 94.1422 101.25 90V71.25H18.75Z"
fill="#E6E6E6"
/>
<path d="M18.75 71.25H101.25L86.25 48.75H33.75L18.75 71.25Z" fill="white" />
<path
d="M18.75 71.25V90C18.75 94.1421 22.1079 97.5 26.25 97.5H93.75C97.8921 97.5 101.25 94.1422 101.25 90V71.25M18.75 71.25H101.25M18.75 71.25L33.75 48.75H86.25L101.25 71.25M54.375 80.625H65.625"
stroke="#A4A4A4"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M43.125 35.625L33.75 26.25M76.875 35.625L86.25 26.25M60 28.125V15"
stroke="#FFC733"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgEmpty;

View File

@@ -1,41 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgEndOfLine = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M67.5 33.75H88.125C93.3027 33.75 97.5 29.5527 97.5 24.375C97.5 19.1973 93.3027 15 88.125 15H76.875C71.6973 15 67.5 19.1973 67.5 24.375V33.75ZM67.5 33.75H15M67.5 33.75V82.5"
stroke="#CCCCCC"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M30 82.5H105"
stroke="#A4A4A4"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M41.25 93.75H93.75"
stroke="#CCCCCC"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M52.5 105H82.5"
stroke="#CCCCCC"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgEndOfLine;

View File

@@ -1,16 +0,0 @@
export { default as SvgBrokenKey } from "@opal/illustrations/broken-key";
export { default as SvgConnect } from "@opal/illustrations/connect";
export { default as SvgConnected } from "@opal/illustrations/connected";
export { default as SvgDisconnected } from "@opal/illustrations/disconnected";
export { default as SvgEmpty } from "@opal/illustrations/empty";
export { default as SvgEndOfLine } from "@opal/illustrations/end-of-line";
export { default as SvgLimitAlert } from "@opal/illustrations/limit-alert";
export { default as SvgLongWait } from "@opal/illustrations/long-wait";
export { default as SvgNoAccess } from "@opal/illustrations/no-access";
export { default as SvgNoResult } from "@opal/illustrations/no-result";
export { default as SvgNotFound } from "@opal/illustrations/not-found";
export { default as SvgOverflow } from "@opal/illustrations/overflow";
export { default as SvgPlugBroken } from "@opal/illustrations/plug-broken";
export { default as SvgTimeout } from "@opal/illustrations/timeout";
export { default as SvgUnPlugged } from "@opal/illustrations/un-plugged";
export { default as SvgUsageAlert } from "@opal/illustrations/usage-alert";

View File

@@ -1,57 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgLimitAlert = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M15 82.5C15 78.3579 18.3579 75 22.5 75L97.5 75C101.642 75 105 78.3579 105 82.5V90C105 94.1421 101.642 97.5 97.5 97.5L22.5 97.5C18.3579 97.5 15 94.1421 15 90V82.5Z"
fill="#FBEAE4"
stroke="#A4A4A4"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M93.75 86.25H78.75"
stroke="#EC5B13"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M67.5 86.2499H26.25"
stroke="#F5A88B"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M15 48.75C15 44.6079 18.3579 41.25 22.5 41.25L52.5 41.25C56.6421 41.25 60 44.6079 60 48.75L60 56.25C60 60.3921 56.6421 63.75 52.5 63.75H22.5C18.3579 63.75 15 60.3921 15 56.25L15 48.75Z"
fill="#F0F0F0"
/>
<path
d="M45 52.5H26.25M52.5 63.75H22.5C18.3579 63.75 15 60.3921 15 56.25L15 48.75C15 44.6079 18.3579 41.25 22.5 41.25L52.5 41.25C56.6421 41.25 60 44.6079 60 48.75L60 56.25C60 60.3921 56.6421 63.75 52.5 63.75Z"
stroke="#CCCCCC"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M86.25 41.25C81.0723 41.25 76.875 45.4473 76.875 50.625L76.875 63.75L86.25 63.75L95.625 63.75V50.625C95.625 45.4473 91.4277 41.25 86.25 41.25Z"
fill="#FBEAE4"
/>
<path
d="M76.875 63.75L76.875 50.625C76.875 45.4473 81.0723 41.25 86.25 41.25C91.4277 41.25 95.625 45.4473 95.625 50.625V63.75M76.875 63.75L86.25 63.75M76.875 63.75L73.125 63.75M95.625 63.75H99.375M95.625 63.75L86.25 63.75M86.25 52.5V63.75M76.875 33.75L71.25 28.125M95.625 33.75L101.25 28.125M86.25 30L86.25 22.5"
stroke="#EC5B13"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgLimitAlert;

View File

@@ -1,44 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgLongWait = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M103.253 47.5404C104.391 51.4971 105 55.6774 105 60C105 84.8528 84.8528 105 60 105C35.1472 105 15 84.8528 15 60C15 35.1472 35.1472 15 60 15C64.3226 15 68.5029 15.6095 72.4596 16.7472C70.4991 20.0854 69.375 23.9739 69.375 28.125C69.375 40.5514 79.4486 50.625 91.875 50.625C96.0261 50.625 99.9146 49.5009 103.253 47.5404Z"
fill="#F0F0F0"
/>
<path
d="M69.375 28.125C69.375 40.5514 79.4486 50.625 91.875 50.625C96.0261 50.625 99.9146 49.5009 103.253 47.5404C109.908 43.6322 114.375 36.4003 114.375 28.125C114.375 15.6986 104.301 5.625 91.875 5.625C83.5997 5.625 76.3678 10.0925 72.4596 16.7472C70.4991 20.0854 69.375 23.9739 69.375 28.125Z"
fill="white"
/>
<path
d="M54.1223 104.615C56.0462 104.866 58.0077 105 60 105C61.9911 105 63.9513 104.866 65.874 104.615M42.7771 101.576C39.1175 100.058 35.7047 98.0716 32.6074 95.6909M87.3889 95.6909C84.2914 98.0715 80.8791 100.058 77.2192 101.576M24.3054 87.3889C21.9251 84.2915 19.9377 80.8789 18.4204 77.2192M101.576 77.2192C100.058 80.8791 98.0715 84.2914 95.6909 87.3889M15.3809 54.1223C15.1299 56.046 15 58.0079 15 60C15 61.9909 15.1302 63.9515 15.3809 65.874M104.615 65.874C104.866 63.9513 105 61.9911 105 60C105 58.0077 104.866 56.0462 104.615 54.1223M18.4204 42.7771C19.9379 39.1177 21.925 35.7046 24.3054 32.6074M32.6074 24.3054C35.7046 21.925 39.1177 19.9379 42.7771 18.4204M65.874 15.3809C63.9515 15.1302 61.9909 15 60 15C58.0079 15 56.046 15.1299 54.1223 15.3809"
stroke="#CCCCCC"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M60 33.0001V60.0001L78 69.0001"
stroke="#A4A4A4"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M84.375 20.625H99.375L84.375 35.625H99.375"
stroke="#CCCCCC"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgLongWait;

View File

@@ -1,31 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgNoAccess = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M18.75 22.5V105L60 105L101.25 105V22.5C101.25 18.3578 97.8921 15 93.75 15H60H26.25C22.1079 15 18.75 18.3578 18.75 22.5Z"
fill="white"
/>
<path
d="M18.75 105V22.5C18.75 18.3578 22.1079 15 26.25 15H60M18.75 105L60 105M18.75 105L11.25 105M101.25 105V22.5C101.25 18.3578 97.8921 15 93.75 15H60M101.25 105L60 105M101.25 105H108.75M60 93.75V105M60 15V26.25"
stroke="#CCCCCC"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M46.875 58.1249V50.625C46.875 43.3762 52.7512 37.5 60 37.5C67.2487 37.5 73.125 43.3762 73.125 50.625V58.125M46.875 58.1249L44.9999 58.1249C42.9289 58.125 41.25 59.8039 41.25 61.8749V78.75C41.25 80.821 42.9289 82.5 45 82.5L75 82.5C77.071 82.5 78.75 80.821 78.75 78.75V61.875C78.75 59.8039 77.071 58.125 75 58.125H73.125M46.875 58.1249L73.125 58.125M60 67.4999V73.1249"
stroke="#A4A4A4"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgNoAccess;

View File

@@ -1,32 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgNoResult = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M91.875 45H28.125L11.25 112.5H108.75L91.875 45Z" fill="white" />
<path
d="M26.25 45L50.0345 23.8582C52.8762 21.3323 56.4381 20.0693 60 20.0693C63.5619 20.0693 67.1238 21.3323 69.9655 23.8582L93.75 45H26.25Z"
fill="#E6E6E6"
/>
<path
d="M60 7.5V20.0693M60 20.0693C56.4381 20.0693 52.8762 21.3323 50.0345 23.8582L26.25 45H93.75L69.9655 23.8582C67.1238 21.3323 63.5619 20.0693 60 20.0693Z"
stroke="#A4A4A4"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M43.125 99.375L33.75 90M60 91.875V78.75M76.875 99.375L86.25 90"
stroke="#FFC733"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgNoResult;

View File

@@ -1,45 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgNotFound = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M61.875 95.625C80.5146 95.625 95.625 80.5146 95.625 61.875C95.625 43.2354 80.5146 28.125 61.875 28.125C43.2354 28.125 28.125 43.2354 28.125 61.875C28.125 80.5146 43.2354 95.625 61.875 95.625Z"
fill="white"
/>
<path
d="M103.125 103.125L85.7109 85.7109M95.625 61.875C95.625 80.5146 80.5146 95.625 61.875 95.625C43.2354 95.625 28.125 80.5146 28.125 61.875C28.125 43.2354 43.2354 28.125 61.875 28.125C80.5146 28.125 95.625 43.2354 95.625 61.875Z"
stroke="#A4A4A4"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M56.713 46.3302C54.7486 47.4847 53.2561 49.2972 52.5 51.4466H51.5625V51.1341C52.3923 48.7766 54.0843 46.7901 56.239 45.5237C58.3943 44.257 60.9272 43.7937 63.3911 44.2163C65.855 44.639 68.091 45.9183 69.7009 47.8308C71.3108 49.7433 72.1912 52.1645 72.1875 54.6643C72.1868 58.3647 69.5002 61.0222 67.0935 62.6734C65.8647 63.5165 64.6397 64.1423 63.728 64.5594C63.2713 64.7682 62.8885 64.9259 62.6184 65.0318V67.5H61.875L61.875 64.3111C61.875 64.3111 71.25 61.095 71.25 54.6628C71.2534 52.3842 70.4503 50.178 68.9829 48.4348C67.5155 46.6917 65.4785 45.5241 63.2328 45.1389C60.987 44.7537 58.6774 45.1757 56.713 46.3302Z"
stroke="#CCCCCC"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M62.8125 76.875H60.9375V78.75H62.8125V76.875Z"
stroke="#CCCCCC"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M20.625 50.625H11.25M30 30L22.5 22.5M50.625 20.625V11.25"
stroke="#FFC733"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgNotFound;

View File

@@ -1,28 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgOverflow = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M22.5 71.2501L25.3301 91.0607C25.8579 94.7555 29.0223 97.5 32.7547 97.5H87.2453C90.9777 97.5 94.1421 94.7555 94.6699 91.0607L97.5 71.2501H22.5Z"
fill="#E6E6E6"
stroke="#A4A4A4"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M18.7501 46.8752L78.5183 52.4717M32.7965 34.583L91.8752 45.0002M45.1839 22.5002L103.125 38.0255M90.0002 61.8752H30.0002"
stroke="#EC5B13"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgOverflow;

View File

@@ -1,31 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgPlugBroken = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M31.875 78.75L24.375 71.25M50.625 78.75L58.125 71.25M41.25 73.125V63.75"
stroke="#EC5B13"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M97.5 30H90H71.25H63.75V43.125C63.75 52.4448 71.3052 60 80.625 60C89.9448 60 97.5 52.4448 97.5 43.125V30Z"
fill="#E6E6E6"
/>
<path
d="M50.625 90H95.625C99.7671 90 103.125 93.3579 103.125 97.5C103.125 101.642 99.7671 105 95.625 105H88.125C83.9829 105 80.625 101.642 80.625 97.5V60M31.875 90H16.875M90 30V18.75M90 30H97.5M90 30H71.25M97.5 30V43.125C97.5 52.4448 89.9448 60 80.625 60M97.5 30H103.125M63.75 30V43.125C63.75 52.4448 71.3052 60 80.625 60M63.75 30H71.25M63.75 30H58.125M71.25 30V18.75"
stroke="#A4A4A4"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgPlugBroken;

View File

@@ -1,45 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgTimeout = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M26.25 101.25H78.75V93.75L62.6392 83.3931C56.4628 79.4225 48.5372 79.4225 42.3608 83.3931L26.25 93.75V101.25Z"
fill="#E6E6E6"
/>
<path
d="M74.4446 77.8572L52.5 63.75L30.5554 77.8572C27.8721 79.5822 26.25 82.5533 26.25 85.7433V93.75L42.3608 83.3931C48.5372 79.4225 56.4628 79.4225 62.6392 83.3931L78.75 93.75V85.7433C78.75 82.5533 77.1279 79.5822 74.4446 77.8572Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M26.25 26.25H68.7803C67.9512 28.5958 67.5 31.1202 67.5 33.75C67.5 40.0285 70.0716 45.7064 74.219 49.7878L52.5 63.75L30.5554 49.6428C27.8721 47.9178 26.25 44.9467 26.25 41.7567V26.25Z"
fill="white"
/>
<path
d="M112.5 33.75C112.5 21.3236 102.426 11.25 90 11.25C80.2034 11.25 71.8691 17.511 68.7803 26.25C67.9512 28.5958 67.5 31.1202 67.5 33.75C67.5 40.0285 70.0716 45.7064 74.219 49.7878C78.2801 53.7843 83.8521 56.25 90 56.25C102.426 56.25 112.5 46.1764 112.5 33.75Z"
fill="#F0F0F0"
/>
<path
d="M52.5 63.75L30.5554 49.6428C27.8721 47.9178 26.25 44.9467 26.25 41.7567V26.25M52.5 63.75L74.4446 77.8572C77.1279 79.5822 78.75 82.5533 78.75 85.7433V101.25M52.5 63.75L30.5554 77.8572C27.8721 79.5822 26.25 82.5533 26.25 85.7433V101.25M52.5 63.75L72.6052 50.8252M26.25 26.25H18.75M26.25 26.25H66.8006M78.75 101.25H26.25M78.75 101.25H86.25M26.25 101.25H18.75"
stroke="#A4A4A4"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M82.5 26.25H97.5L82.5 41.25H97.5"
stroke="#CCCCCC"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgTimeout;

View File

@@ -1,67 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgUnPlugged = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M56.25 16.875C43.8236 16.875 33.75 26.9486 33.75 39.375L33.75 54.375C33.75 66.8014 43.8236 76.875 56.25 76.875H71.25C83.6764 76.875 93.75 66.8014 93.75 54.375V39.375C93.75 26.9486 83.6764 16.875 71.25 16.875H56.25ZM67.5 65.625V60C67.5 56.8934 64.9816 54.375 61.875 54.375C58.7684 54.375 56.25 56.8934 56.25 60V65.625H67.5Z"
fill="#F0F0F0"
/>
<path
d="M67.5 60V65.625H56.25V60C56.25 56.8934 58.7684 54.375 61.875 54.375C64.9816 54.375 67.5 56.8934 67.5 60Z"
fill="white"
/>
<path
d="M48.75 46.875V35.625M75 46.875V35.625M67.5 65.625V60C67.5 56.8934 64.9816 54.375 61.875 54.375C58.7684 54.375 56.25 56.8934 56.25 60V65.625H67.5ZM56.25 76.875H71.25C83.6764 76.875 93.75 66.8014 93.75 54.375V39.375C93.75 26.9486 83.6764 16.875 71.25 16.875H56.25C43.8236 16.875 33.75 26.9486 33.75 39.375L33.75 54.375C33.75 66.8014 43.8236 76.875 56.25 76.875Z"
stroke="#A4A4A4"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M26.25 87.1875V97.5H28.125V87.1875H26.25Z" fill="#F0F0F0" />
<path
d="M52.5 88.125V97.5H50.625V88.125C50.625 87.6072 51.0447 87.1875 51.5625 87.1875C52.0803 87.1875 52.5 87.6072 52.5 88.125Z"
fill="#F0F0F0"
/>
<path
d="M26.25 87.1875V97.5H28.125V87.1875H26.25Z"
stroke="#CCCCCC"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M52.5 88.125V97.5H50.625V88.125C50.625 87.6072 51.0447 87.1875 51.5625 87.1875C52.0803 87.1875 52.5 87.6072 52.5 88.125Z"
stroke="#CCCCCC"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M18.1798 109.239C16.3504 108.958 15 107.384 15 105.533V97.5H63.75V105V108.75C63.75 110.821 62.0711 112.5 60 112.5H39.6618C39.4709 112.5 39.2802 112.485 39.0916 112.456L18.1798 109.239Z"
fill="#E6E6E6"
/>
<path
d="M63.75 105H105M63.75 105V108.75C63.75 110.821 62.0711 112.5 60 112.5H39.6618C39.4709 112.5 39.2802 112.485 39.0916 112.456L18.1798 109.239C16.3504 108.958 15 107.384 15 105.533V97.5H63.75V105Z"
stroke="#A4A4A4"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M93.75 15L95.625 9.37498M103.125 31.875L108.75 33.75M101.25 22.5L106.875 18.75"
stroke="#EC5B13"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgUnPlugged;

View File

@@ -1,42 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgUsageAlert = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M15 90C15 85.8578 18.3579 82.5 22.5 82.5L60 82.5C64.1421 82.5 67.5 85.8578 67.5 90L67.5 97.5C67.5 101.642 64.1421 105 60 105H22.5C18.3579 105 15 101.642 15 97.5L15 90Z"
fill="#F0F0F0"
/>
<path
d="M15 22.5C15 18.3579 18.3579 15 22.5 15H45C49.1421 15 52.5 18.3579 52.5 22.5L52.5 29.9999C52.5 34.1421 49.1421 37.4999 45 37.4999H22.5C18.3579 37.4999 15 34.1421 15 29.9999V22.5Z"
fill="#F0F0F0"
/>
<path
d="M52.5 93.75H26.25M37.5 26.25H26.25M22.5 15H45C49.1421 15 52.5 18.3579 52.5 22.5L52.5 29.9999C52.5 34.1421 49.1421 37.4999 45 37.4999H22.5C18.3579 37.4999 15 34.1421 15 29.9999V22.5C15 18.3579 18.3579 15 22.5 15ZM60 105H22.5C18.3579 105 15 101.642 15 97.5L15 90C15 85.8578 18.3579 82.5 22.5 82.5L60 82.5C64.1421 82.5 67.5 85.8578 67.5 90L67.5 97.5C67.5 101.642 64.1421 105 60 105Z"
stroke="#CCCCCC"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M78.75 60H71.25M90 37.5V30M103.125 50.625H110.625M99.375 41.25L105 35.625M82.5 71.25L22.5 71.25C18.3579 71.25 15 67.8922 15 63.75V56.25C15 52.1079 18.3579 48.75 22.5 48.75L82.5 48.75C86.6421 48.75 90 52.1079 90 56.25V63.75C90 67.8922 86.6421 71.25 82.5 71.25Z"
stroke="#EC5B13"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M60 60H26.25"
stroke="#F5A88B"
strokeWidth={3.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgUsageAlert;

View File

@@ -1,87 +0,0 @@
# IllustrationContent
**Import:** `import { IllustrationContent, type IllustrationContentProps } from "@opal/layouts";`
A vertically-stacked, center-aligned layout for empty states, error pages, and informational placeholders. Pairs a large illustration with a title and optional description.
## Why IllustrationContent?
Empty states and placeholder screens share a recurring pattern: a large illustration centered above a title and description. `IllustrationContent` standardises that pattern so every empty state looks consistent without hand-rolling flex containers and spacing each time.
## Layout Structure
```
┌─────────────────────────────────┐
│ (1.25rem pad) │
│ ┌───────────────────┐ │
│ │ illustration │ │
│ │ 7.5rem × 7.5rem │ │
│ └───────────────────┘ │
│ (0.75rem gap) │
│ title (center) │
│ (0.75rem gap) │
│ description (center) │
│ (1.25rem pad) │
└─────────────────────────────────┘
```
- Outer container: `flex flex-col items-center gap-3 p-5 text-center`.
- Illustration: `w-[7.5rem] h-[7.5rem]` (120px), no extra padding.
- Title: `<p>` with `font-main-content-emphasis text-text-04`.
- Description: `<p>` with `font-secondary-body text-text-03`.
## Props
| Prop | Type | Default | Description |
|---|---|---|---|
| `illustration` | `IconFunctionComponent` | — | Optional illustration component rendered at 7.5rem × 7.5rem, centered. Works with any `@opal/illustrations` SVG. |
| `title` | `string` | **(required)** | Main title text, center-aligned. |
| `description` | `string` | — | Optional description below the title, center-aligned. |
## Usage Examples
### Empty search results
```tsx
import { IllustrationContent } from "@opal/layouts";
import SvgNoResult from "@opal/illustrations/no-result";
<IllustrationContent
illustration={SvgNoResult}
title="No results found"
description="Try adjusting your search or filters."
/>
```
### Not found page
```tsx
import { IllustrationContent } from "@opal/layouts";
import SvgNotFound from "@opal/illustrations/not-found";
<IllustrationContent
illustration={SvgNotFound}
title="Page not found"
description="The page you're looking for doesn't exist or has been moved."
/>
```
### Title only (no illustration, no description)
```tsx
import { IllustrationContent } from "@opal/layouts";
<IllustrationContent title="Nothing here yet" />
```
### Empty state with illustration and title (no description)
```tsx
import { IllustrationContent } from "@opal/layouts";
import SvgEmpty from "@opal/illustrations/empty";
<IllustrationContent
illustration={SvgEmpty}
title="No items"
/>
```

View File

@@ -1,83 +0,0 @@
import type { IconFunctionComponent } from "@opal/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface IllustrationContentProps {
/** Optional illustration rendered at 7.5rem × 7.5rem (120px), centered. */
illustration?: IconFunctionComponent;
/** Main title text, center-aligned. Uses `font-main-content-emphasis`. */
title: string;
/** Optional description below the title, center-aligned. Uses `font-secondary-body`. */
description?: string;
}
// ---------------------------------------------------------------------------
// IllustrationContent
// ---------------------------------------------------------------------------
/**
* A vertically-stacked, center-aligned layout for empty states, error pages,
* and informational placeholders.
*
* Renders an optional illustration on top, followed by a title and an optional
* description — all center-aligned with consistent spacing.
*
* **Layout structure:**
*
* ```
* ┌─────────────────────────────────┐
* │ (1.25rem pad) │
* │ ┌───────────────────┐ │
* │ │ illustration │ │
* │ │ 7.5rem × 7.5rem │ │
* │ └───────────────────┘ │
* │ (0.75rem gap) │
* │ title (center) │
* │ (0.75rem gap) │
* │ description (center) │
* │ (1.25rem pad) │
* └─────────────────────────────────┘
* ```
*
* @example
* ```tsx
* import { IllustrationContent } from "@opal/layouts";
* import SvgNoResult from "@opal/illustrations/no-result";
*
* <IllustrationContent
* illustration={SvgNoResult}
* title="No results found"
* description="Try adjusting your search or filters."
* />
* ```
*/
function IllustrationContent({
illustration: Illustration,
title,
description,
}: IllustrationContentProps) {
return (
<div className="flex flex-col items-center gap-3 p-5 text-center">
{Illustration && (
<Illustration
aria-hidden="true"
className="shrink-0 w-[7.5rem] h-[7.5rem]"
/>
)}
<p className="font-main-content-emphasis text-text-04">{title}</p>
{description && (
<p className="font-secondary-body text-text-03">{description}</p>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export { IllustrationContent, type IllustrationContentProps };

View File

@@ -1,8 +1,8 @@
# @opal/layouts
**Import:** `import { Content, ContentAction, IllustrationContent } from "@opal/layouts";`
**Import:** `import { Content, ContentAction } from "@opal/layouts";`
Layout primitives for composing content blocks. These components handle sizing, font selection, icon alignment, and optional inline editing — things that are tedious to get right by hand and easy to get wrong.
Layout primitives for composing icon + title + description rows. These components handle sizing, font selection, icon alignment, and optional inline editing — things that are tedious to get right by hand and easy to get wrong.
## Components
@@ -10,15 +10,13 @@ Layout primitives for composing content blocks. These components handle sizing,
|---|---|---|
| [`Content`](./Content/README.md) | Icon + title + description row. Routes to an internal layout (`ContentXl`, `ContentLg`, `ContentMd`, or `ContentSm`) based on `sizePreset` and `variant`. | [Content README](./Content/README.md) |
| [`ContentAction`](./ContentAction/README.md) | Wraps `Content` in a flex-row with an optional `rightChildren` slot for action buttons. Adds padding alignment via the shared `SizeVariant` scale. | [ContentAction README](./ContentAction/README.md) |
| [`IllustrationContent`](./IllustrationContent/README.md) | Center-aligned illustration + title + description stack for empty states, error pages, and placeholders. | [IllustrationContent README](./IllustrationContent/README.md) |
## Quick Start
```tsx
import { Content, ContentAction, IllustrationContent } from "@opal/layouts";
import { Content, ContentAction } from "@opal/layouts";
import { Button } from "@opal/components";
import SvgSettings from "@opal/icons/settings";
import SvgNoResult from "@opal/illustrations/no-result";
// Simple heading
<Content
@@ -51,13 +49,6 @@ import SvgNoResult from "@opal/illustrations/no-result";
<Button icon={SvgSettings} prominence="tertiary" />
}
/>
// Empty state with illustration
<IllustrationContent
illustration={SvgNoResult}
title="No results found"
description="Try adjusting your search or filters."
/>
```
## Architecture
@@ -83,12 +74,10 @@ From `@opal/layouts`:
// Components
Content
ContentAction
IllustrationContent
// Types
ContentProps
ContentActionProps
IllustrationContentProps
SizePreset
ContentVariant
```

View File

@@ -11,9 +11,3 @@ export {
ContentAction,
type ContentActionProps,
} from "@opal/layouts/ContentAction/components";
/* IllustrationContent */
export {
IllustrationContent,
type IllustrationContentProps,
} from "@opal/layouts/IllustrationContent/components";

View File

@@ -23,7 +23,6 @@
"test:verbose": "jest --verbose",
"test:ci": "jest --ci --maxWorkers=2 --silent --bail",
"test:changed": "jest --onlyChanged",
"test:diff": "jest --changedSince=main",
"test:debug": "node --inspect-brk node_modules/.bin/jest --runInBand"
},
"dependencies": {

View File

@@ -187,7 +187,6 @@ export const fetchOllamaModels = async (
api_base: apiBase,
provider_name: params.provider_name,
}),
signal: params.signal,
});
if (!response.ok) {

View File

@@ -64,8 +64,6 @@ import { useStatusChange } from "./useStatusChange";
import { useReIndexModal } from "./ReIndexModal";
import Button from "@/refresh-components/buttons/Button";
import { SvgSettings } from "@opal/icons";
import { UserRole } from "@/lib/types";
import { useUser } from "@/providers/UserProvider";
// synchronize these validations with the SQLAlchemy connector class until we have a
// centralized schema for both frontend and backend
const RefreshFrequencySchema = Yup.object().shape({
@@ -91,7 +89,6 @@ const PAGES_PER_BATCH = 8;
function Main({ ccPairId }: { ccPairId: number }) {
const router = useRouter();
const { user } = useUser();
const {
data: ccPair,
@@ -179,12 +176,6 @@ function Main({ ccPairId }: { ccPairId: number }) {
}, [ccPair, refresh]);
const latestIndexAttempt = indexAttempts?.[0];
const canManageInlineFileConnectorFiles =
ccPair?.connector.source === "file" &&
(ccPair.is_editable_for_current_user ||
(user?.role === UserRole.GLOBAL_CURATOR &&
ccPair.access_type === "public"));
const isResolvingErrors =
(latestIndexAttempt?.status === "in_progress" ||
latestIndexAttempt?.status === "not_started") &&
@@ -700,14 +691,15 @@ function Main({ ccPairId }: { ccPairId: number }) {
/>
{/* Inline file management for file connectors */}
{canManageInlineFileConnectorFiles && (
<div className="mt-6">
<InlineFileManagement
connectorId={ccPair.connector.id}
onRefresh={refresh}
/>
</div>
)}
{ccPair.connector.source === "file" &&
ccPair.is_editable_for_current_user && (
<div className="mt-6">
<InlineFileManagement
connectorId={ccPair.connector.id}
onRefresh={refresh}
/>
</div>
)}
</Card>
</>
)}

View File

@@ -20,7 +20,6 @@ import { buildSimilarCredentialInfoURL } from "@/app/admin/connector/[ccPairId]/
import { Credential } from "@/lib/connectors/credentials";
import { useFederatedConnectors } from "@/lib/hooks";
import Text from "@/refresh-components/texts/Text";
import { useToastFromQuery } from "@/hooks/useToast";
export default function ConnectorWrapper({
connector,
@@ -30,13 +29,6 @@ export default function ConnectorWrapper({
const searchParams = useSearchParams();
const mode = searchParams?.get("mode"); // 'federated' or 'regular'
useToastFromQuery({
oauth_failed: {
message: "OAuth authentication failed. Please try again.",
type: "error",
},
});
// Check if the connector is valid
if (!isValidSource(connector)) {
return (

View File

@@ -2,6 +2,10 @@ import { getDomain } from "@/lib/redirectSS";
import { buildUrl } from "@/lib/utilsSS";
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import {
GMAIL_AUTH_IS_ADMIN_COOKIE_NAME,
GOOGLE_DRIVE_AUTH_IS_ADMIN_COOKIE_NAME,
} from "@/lib/constants";
import {
CRAFT_OAUTH_COOKIE_NAME,
CRAFT_CONFIGURE_PATH,
@@ -11,7 +15,6 @@ import { processCookies } from "@/lib/userSS";
export const GET = async (request: NextRequest) => {
const requestCookies = await cookies();
const connector = request.url.includes("gmail") ? "gmail" : "google-drive";
const callbackEndpoint = `/manage/connector/${connector}/callback`;
const url = new URL(buildUrl(callbackEndpoint));
url.search = request.nextUrl.search;
@@ -23,12 +26,7 @@ export const GET = async (request: NextRequest) => {
});
if (!response.ok) {
return NextResponse.redirect(
new URL(
`/admin/connectors/${connector}?message=oauth_failed`,
getDomain(request)
)
);
return NextResponse.redirect(new URL("/auth/error", getDomain(request)));
}
// Check for build mode OAuth flag (redirects to build admin panel)
@@ -42,7 +40,16 @@ export const GET = async (request: NextRequest) => {
return redirectResponse;
}
return NextResponse.redirect(
new URL(`/admin/connectors/${connector}`, getDomain(request))
);
const authCookieName =
connector === "gmail"
? GMAIL_AUTH_IS_ADMIN_COOKIE_NAME
: GOOGLE_DRIVE_AUTH_IS_ADMIN_COOKIE_NAME;
if (requestCookies.get(authCookieName)?.value?.toLowerCase() === "true") {
return NextResponse.redirect(
new URL(`/admin/connectors/${connector}`, getDomain(request))
);
}
return NextResponse.redirect(new URL("/user/connectors", getDomain(request)));
};

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { AdminPageTitle } from "@/components/admin/Title";
import { getSourceMetadata, isValidSource } from "@/lib/sources";
import { ValidSources } from "@/lib/types";
@@ -9,6 +9,7 @@ import CardSection from "@/components/admin/CardSection";
import { handleOAuthAuthorizationResponse } from "@/lib/oauth_utils";
import { SvgKey } from "@opal/icons";
export default function OAuthCallbackPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [statusMessage, setStatusMessage] = useState("Processing...");

View File

@@ -6,7 +6,11 @@ import { useRouter } from "next/navigation";
import type { Route } from "next";
import { adminDeleteCredential } from "@/lib/credential";
import { setupGoogleDriveOAuth } from "@/lib/googleDrive";
import { DOCS_ADMINS_PATH } from "@/lib/constants";
import {
DOCS_ADMINS_PATH,
GOOGLE_DRIVE_AUTH_IS_ADMIN_COOKIE_NAME,
} from "@/lib/constants";
import Cookies from "js-cookie";
import { TextFormField, SectionHeader } from "@/components/Field";
import { Form, Formik } from "formik";
import { User } from "@/lib/types";
@@ -588,6 +592,11 @@ export const DriveAuthSection = ({
onClick={async () => {
setIsAuthenticating(true);
try {
// cookie used by callback to determine where to finally redirect to
Cookies.set(GOOGLE_DRIVE_AUTH_IS_ADMIN_COOKIE_NAME, "true", {
path: "/",
});
const [authUrl, errorMsg] = await setupGoogleDriveOAuth({
isAdmin: true,
name: "OAuth (uploaded)",

View File

@@ -7,7 +7,10 @@ import { useRouter } from "next/navigation";
import type { Route } from "next";
import { adminDeleteCredential } from "@/lib/credential";
import { setupGmailOAuth } from "@/lib/gmail";
import { DOCS_ADMINS_PATH } from "@/lib/constants";
import {
DOCS_ADMINS_PATH,
GMAIL_AUTH_IS_ADMIN_COOKIE_NAME,
} from "@/lib/constants";
import { CRAFT_OAUTH_COOKIE_NAME } from "@/app/craft/v1/constants";
import Cookies from "js-cookie";
import { TextFormField, SectionHeader } from "@/components/Field";
@@ -599,6 +602,9 @@ export const GmailAuthSection = ({
onClick={async () => {
setIsAuthenticating(true);
try {
Cookies.set(GMAIL_AUTH_IS_ADMIN_COOKIE_NAME, "true", {
path: "/",
});
if (buildMode) {
Cookies.set(CRAFT_OAUTH_COOKIE_NAME, "true", {
path: "/",

View File

@@ -0,0 +1,76 @@
import { SubLabel } from "@/components/Field";
import { toast } from "@/hooks/useToast";
import { useEffect, useState } from "react";
import Dropzone from "react-dropzone";
export function ImageUpload({
selectedFile,
setSelectedFile,
}: {
selectedFile: File | null;
setSelectedFile: (file: File) => void;
}) {
const [tmpImageUrl, setTmpImageUrl] = useState<string>("");
useEffect(() => {
if (selectedFile) {
setTmpImageUrl(URL.createObjectURL(selectedFile));
} else {
setTmpImageUrl("");
}
}, [selectedFile]);
const [dragActive, setDragActive] = useState(false);
return (
<Dropzone
onDrop={(acceptedFiles) => {
if (acceptedFiles.length !== 1) {
toast.error("Only one file can be uploaded at a time");
return;
}
const acceptedFile = acceptedFiles[0];
if (acceptedFile === undefined) {
toast.error("acceptedFile cannot be undefined");
return;
}
setTmpImageUrl(URL.createObjectURL(acceptedFile));
setSelectedFile(acceptedFile);
setDragActive(false);
}}
onDragLeave={() => setDragActive(false)}
onDragEnter={() => setDragActive(true)}
>
{({ getRootProps, getInputProps }) => (
<section>
<div
{...getRootProps()}
className={
"flex flex-col items-center w-full px-4 py-12 rounded " +
"shadow-lg tracking-wide border border-border cursor-pointer" +
(dragActive ? " border-accent" : "")
}
>
<input {...getInputProps()} />
<b className="text-text-darker">
Drag and drop a .png or .jpg file, or click to select a file!
</b>
</div>
{tmpImageUrl && (
<div className="mt-4 mb-8">
<SubLabel>Uploaded Image:</SubLabel>
<img
alt="Uploaded Image"
src={tmpImageUrl}
className="mt-4 max-w-xs max-h-64"
/>
</div>
)}
</section>
)}
</Dropzone>
);
}

View File

@@ -86,8 +86,6 @@ export default function OAuthCallbackPage({ config }: OAuthCallbackPageProps) {
]);
useEffect(() => {
const controller = new AbortController();
const handleOAuthCallback = async () => {
// Handle OAuth error from provider
if (error) {
@@ -124,7 +122,6 @@ export default function OAuthCallbackPage({ config }: OAuthCallbackPageProps) {
"Content-Type": "application/json",
},
credentials: "include",
signal: controller.signal,
});
if (!response.ok) {
@@ -184,7 +181,6 @@ export default function OAuthCallbackPage({ config }: OAuthCallbackPageProps) {
setIsError(false);
setIsLoading(false);
} catch (error) {
if (controller.signal.aborted) return;
console.error("OAuth callback error:", error);
setStatusMessage(config.errorMessage || "Something Went Wrong");
setStatusDetails(
@@ -198,7 +194,6 @@ export default function OAuthCallbackPage({ config }: OAuthCallbackPageProps) {
};
handleOAuthCallback();
return () => controller.abort();
}, [code, state, error, errorDescription, searchParams, config]);
const getStatusIcon = () => {

View File

@@ -16,6 +16,8 @@ import { cn } from "@/lib/utils";
const CsvContent: React.FC<ContentComponentProps> = ({
fileDescriptor,
isLoading,
fadeIn,
expanded = false,
}) => {
const [data, setData] = useState<Record<string, string>[]>([]);
@@ -92,7 +94,7 @@ const CsvContent: React.FC<ContentComponentProps> = ({
}
};
if (isFetching) {
if (isLoading || isFetching) {
return (
<div className="flex items-center justify-center h-[300px]">
<SimpleLoader />

View File

@@ -1,5 +1,5 @@
// ExpandableContentWrapper
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import { SvgDownloadCloud, SvgFold, SvgMaximize2, SvgX } from "@opal/icons";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Button } from "@opal/components";
@@ -17,6 +17,8 @@ export interface ExpandableContentWrapperProps {
export interface ContentComponentProps {
fileDescriptor: FileDescriptor;
isLoading: boolean;
fadeIn: boolean;
expanded?: boolean;
}
@@ -26,9 +28,24 @@ export default function ExpandableContentWrapper({
ContentComponent,
}: ExpandableContentWrapperProps) {
const [expanded, setExpanded] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [fadeIn, setFadeIn] = useState(false);
const toggleExpand = () => setExpanded((prev) => !prev);
// Prevent a jarring fade in
useEffect(() => {
setTimeout(() => setIsLoading(false), 300);
}, []);
useEffect(() => {
if (!isLoading) {
setTimeout(() => setFadeIn(true), 50);
} else {
setFadeIn(false);
}
}, [isLoading]);
const downloadFile = () => {
const a = document.createElement("a");
a.href = `api/chat/file/${fileDescriptor.id}`;
@@ -86,6 +103,8 @@ export default function ExpandableContentWrapper({
{!expanded && (
<ContentComponent
fileDescriptor={fileDescriptor}
isLoading={isLoading}
fadeIn={fadeIn}
expanded={expanded}
/>
)}

View File

@@ -118,7 +118,6 @@ export interface BedrockFetchParams {
export interface OllamaFetchParams {
api_base?: string;
provider_name?: string;
signal?: AbortSignal;
}
export interface OpenRouterFetchParams {

View File

@@ -150,8 +150,8 @@ export const ADMIN_ROUTE_CONFIG: Record<string, AdminRouteConfig> = {
},
[ADMIN_PATHS.LLM_MODELS]: {
icon: SvgCpu,
title: "Language Models",
sidebarLabel: "Language Models",
title: "LLM Models",
sidebarLabel: "LLM Models",
},
[ADMIN_PATHS.WEB_SEARCH]: {
icon: SvgGlobe,

View File

@@ -28,6 +28,11 @@ export const NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED =
export const TENANT_ID_COOKIE_NAME = "onyx_tid";
export const GMAIL_AUTH_IS_ADMIN_COOKIE_NAME = "gmail_auth_is_admin";
export const GOOGLE_DRIVE_AUTH_IS_ADMIN_COOKIE_NAME =
"google_drive_auth_is_admin";
export const SEARCH_TYPE_COOKIE_NAME = "search_type";
export const AGENTIC_SEARCH_TYPE_COOKIE_NAME = "agentic_type";

View File

@@ -172,7 +172,6 @@ export default function PasswordInputTypeIn({
}: PasswordInputTypeInProps) {
const [isPasswordVisible, setIsPasswordVisible] = React.useState(false);
const [isFocused, setIsFocused] = React.useState(false);
const containerRef = React.useRef<HTMLDivElement>(null);
// Track selection range before changes occur
const selectionRef = React.useRef<{ start: number; end: number }>({
@@ -193,22 +192,9 @@ export default function PasswordInputTypeIn({
return realValue;
};
const handleContainerFocus = React.useCallback(() => {
setIsFocused(true);
}, []);
const handleContainerBlur = React.useCallback(
(e: React.FocusEvent<HTMLDivElement>) => {
if (containerRef.current?.contains(e.relatedTarget as Node)) {
return;
}
setIsFocused(false);
},
[]
);
const handleFocus = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setIsFocused(true);
onFocus?.(e);
},
[onFocus]
@@ -216,6 +202,7 @@ export default function PasswordInputTypeIn({
const handleBlur = React.useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
setIsFocused(false);
onBlur?.(e);
},
[onBlur]
@@ -285,42 +272,35 @@ export default function PasswordInputTypeIn({
: "Show password";
return (
<div
ref={containerRef}
className="contents"
onFocus={handleContainerFocus}
onBlur={handleContainerBlur}
>
<InputTypeIn
ref={ref}
value={getDisplayValue()}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onSelect={captureSelection}
onKeyDown={captureSelection}
variant={disabled ? "disabled" : error ? "error" : undefined}
showClearButton={showClearButton}
autoComplete="off"
data-ph-no-capture
rightSection={
showToggleButton ? (
<Button
icon={isRevealed ? SvgEye : SvgEyeClosed}
disabled={disabled || effectiveNonRevealable}
onClick={noProp(() => setIsPasswordVisible((v) => !v))}
type="button"
variant={isRevealed ? "action" : undefined}
prominence="tertiary"
size="sm"
tooltipSide="left"
tooltip={toggleLabel}
aria-label={toggleLabel}
/>
) : undefined
}
{...props}
/>
</div>
<InputTypeIn
ref={ref}
value={getDisplayValue()}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onSelect={captureSelection}
onKeyDown={captureSelection}
variant={disabled ? "disabled" : error ? "error" : undefined}
showClearButton={showClearButton}
autoComplete="off"
data-ph-no-capture
rightSection={
showToggleButton ? (
<Button
icon={isRevealed ? SvgEye : SvgEyeClosed}
disabled={disabled || effectiveNonRevealable}
onClick={noProp(() => setIsPasswordVisible((v) => !v))}
type="button"
variant={isRevealed ? "action" : undefined}
prominence="tertiary"
size="sm"
tooltipSide="left"
tooltip={toggleLabel}
aria-label={toggleLabel}
/>
) : undefined
}
{...props}
/>
);
}

View File

@@ -9,7 +9,6 @@ import { Button as OpalButton } from "@opal/components";
import InputAvatar from "@/refresh-components/inputs/InputAvatar";
import { cn } from "@/lib/utils";
import { SvgCheckCircle, SvgEdit, SvgUser, SvgX } from "@opal/icons";
import { ContentAction } from "@opal/layouts";
export default function NonAdminStep() {
const inputRef = useRef<HTMLInputElement>(null);
@@ -55,26 +54,17 @@ export default function NonAdminStep() {
className="flex items-center justify-between w-full min-h-11 py-1 pl-3 pr-2 bg-background-tint-00 rounded-16 shadow-01 mb-2"
aria-label="non-admin-confirmation"
>
<ContentAction
icon={({ className, ...props }) => (
<SvgCheckCircle
className={cn(className, "stroke-status-success-05")}
{...props}
/>
)}
title="You're all set!"
sizePreset="main-ui"
variant="body"
prominence="muted"
paddingVariant="fit"
rightChildren={
<OpalButton
prominence="tertiary"
size="sm"
icon={SvgX}
onClick={handleDismissConfirmation}
/>
}
<div className="flex items-center gap-1">
<SvgCheckCircle className="w-4 h-4 stroke-status-success-05" />
<Text as="p" text03 mainUiBody>
You're all set!
</Text>
</div>
<OpalButton
prominence="tertiary"
size="sm"
icon={SvgX}
onClick={handleDismissConfirmation}
/>
</div>
)}
@@ -85,36 +75,39 @@ export default function NonAdminStep() {
role="group"
aria-label="non-admin-name-prompt"
>
<ContentAction
icon={SvgUser}
title="What should Onyx call you?"
description="We will display this name in the app."
sizePreset="main-ui"
variant="section"
paddingVariant="fit"
rightChildren={
<div className="flex items-center justify-end gap-2">
<InputTypeIn
ref={inputRef}
placeholder="Your name"
value={name || ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setName(e.target.value)
}
onKeyDown={(e) => {
if (e.key === "Enter" && name && name.trim().length > 0) {
e.preventDefault();
handleSave();
}
}}
className="w-[26%] min-w-40"
/>
<Button disabled={name === ""} onClick={handleSave}>
Save
</Button>
</div>
}
/>
<div className="flex items-center gap-1 h-full">
<div className="h-full p-0.5">
<SvgUser className="w-4 h-4 stroke-text-03" />
</div>
<div>
<Text as="p" text04 mainUiAction>
What should Onyx call you?
</Text>
<Text as="p" text03 secondaryBody>
We will display this name in the app.
</Text>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<InputTypeIn
ref={inputRef}
placeholder="Your name"
value={name || ""}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setName(e.target.value)
}
onKeyDown={(e) => {
if (e.key === "Enter" && name && name.trim().length > 0) {
e.preventDefault();
handleSave();
}
}}
className="w-[26%] min-w-40"
/>
<Button disabled={name === ""} onClick={handleSave}>
Save
</Button>
</div>
</div>
) : (
<div

View File

@@ -1,5 +1,3 @@
"use client";
import { memo, useState, useCallback } from "react";
import Text from "@/refresh-components/texts/Text";
import Button from "@/refresh-components/buttons/Button";
@@ -14,7 +12,6 @@ import {
import { Disabled } from "@/refresh-components/Disabled";
import { ProviderIcon } from "@/app/admin/configuration/llm/ProviderIcon";
import { SvgCheckCircle, SvgCpu, SvgExternalLink } from "@opal/icons";
import { ContentAction } from "@opal/layouts";
type LLMStepProps = {
state: OnboardingState;
@@ -125,14 +122,21 @@ const LLMStepInner = ({
className="flex flex-col items-center justify-between w-full p-1 rounded-16 border border-border-01 bg-background-tint-00"
aria-label="onboarding-llm-step"
>
<ContentAction
icon={SvgCpu}
title="Connect your LLM models"
description="Onyx supports both self-hosted models and popular providers."
sizePreset="main-ui"
variant="section"
paddingVariant="lg"
rightChildren={
<div className="flex gap-2 justify-between h-full w-full">
<div className="flex mx-2 mt-2 gap-1">
<div className="h-full p-0.5">
<SvgCpu className="w-4 h-4 stroke-text-03" />
</div>
<div>
<Text as="p" text04 mainUiAction>
Connect your LLM models
</Text>
<Text as="p" text03 secondaryBody>
Onyx supports both self-hosted models and popular providers.
</Text>
</div>
</div>
<div className="p-0.5">
<Button
tertiary
rightIcon={SvgExternalLink}
@@ -141,8 +145,8 @@ const LLMStepInner = ({
>
View in Admin Panel
</Button>
}
/>
</div>
</div>
<Separator />
<div className="flex flex-wrap gap-1 [&>*:last-child:nth-child(odd)]:basis-full">
{isLoading ? (

View File

@@ -8,7 +8,6 @@ import InputAvatar from "@/refresh-components/inputs/InputAvatar";
import { cn } from "@/lib/utils";
import IconButton from "@/refresh-components/buttons/IconButton";
import { SvgCheckCircle, SvgEdit, SvgUser } from "@opal/icons";
import { ContentAction } from "@opal/layouts";
export interface NameStepProps {
state: OnboardingState;
@@ -41,23 +40,26 @@ const NameStep = React.memo(
role="group"
aria-label="onboarding-name-step"
>
<ContentAction
icon={SvgUser}
title="What should Onyx call you?"
description="We will display this name in the app."
sizePreset="main-ui"
variant="section"
paddingVariant="fit"
rightChildren={
<InputTypeIn
ref={inputRef}
placeholder="Your name"
value={userName || ""}
onChange={(e) => updateName(e.target.value)}
onKeyDown={handleKeyDown}
className="max-w-60"
/>
}
<div className="flex items-center gap-1 h-full">
<div className="h-full p-0.5">
<SvgUser className="w-4 h-4 stroke-text-03" />
</div>
<div>
<Text as="p" text04 mainUiAction>
What should Onyx call you?
</Text>
<Text as="p" text03 secondaryBody>
We will display this name in the app.
</Text>
</div>
</div>
<InputTypeIn
ref={inputRef}
placeholder="Your name"
value={userName || ""}
onChange={(e) => updateName(e.target.value)}
onKeyDown={handleKeyDown}
className="max-w-60"
/>
</div>
) : (

View File

@@ -25,7 +25,7 @@ export interface ActionItemProps {
disabled: boolean;
isForced: boolean;
isUnavailable?: boolean;
tooltip?: string;
unavailableReason?: string;
showAdminConfigure?: boolean;
adminConfigureHref?: string;
adminConfigureTooltip?: string;
@@ -47,7 +47,7 @@ export default function ActionLineItem({
disabled,
isForced,
isUnavailable = false,
tooltip,
unavailableReason,
showAdminConfigure = false,
adminConfigureHref,
adminConfigureTooltip = "Configure",
@@ -88,7 +88,7 @@ export default function ActionLineItem({
sourceCounts.enabled > 0 &&
sourceCounts.enabled < sourceCounts.total;
const tooltipText = tooltip || tool?.description;
const tooltipText = isUnavailable ? unavailableReason : tool?.description;
return (
<SimpleTooltip tooltip={tooltipText} className="max-w-[30rem]">

View File

@@ -42,45 +42,26 @@ import { useProjectsContext } from "@/providers/ProjectsContext";
import { SvgActions, SvgChevronRight, SvgKey, SvgSliders } from "@opal/icons";
import { Button } from "@opal/components";
function buildTooltipMessage(
actionDescription: string,
isConfigured: boolean,
canManageAction: boolean
) {
const _CONFIGURE_MESSAGE = "Press the settings cog to enable.";
const _USER_NOT_ADMIN_MESSAGE = "Ask an admin to configure.";
if (isConfigured) {
return actionDescription;
}
if (canManageAction) {
return actionDescription + " " + _CONFIGURE_MESSAGE;
}
return actionDescription + " " + _USER_NOT_ADMIN_MESSAGE;
}
const TOOL_DESCRIPTIONS: Record<string, string> = {
[SEARCH_TOOL_ID]: "Search through connected knowledge to inform the answer.",
[IMAGE_GENERATION_TOOL_ID]: "Generate images based on a prompt.",
[WEB_SEARCH_TOOL_ID]: "Search the web for up-to-date information.",
[PYTHON_TOOL_ID]: "Execute code for complex analysis.",
const UNAVAILABLE_TOOL_TOOLTIP_FALLBACK =
"This action is not configured yet. Ask an admin to enable it.";
const UNAVAILABLE_TOOL_TOOLTIP_ADMIN_FALLBACK =
"This action is not configured yet. If you have access, enable it in the admin panel.";
const UNAVAILABLE_TOOL_TOOLTIPS: Record<string, string> = {
[IMAGE_GENERATION_TOOL_ID]:
"Image generation requires a configured model. If you have access, set one up under Settings > Image Generation, or ask an admin.",
[WEB_SEARCH_TOOL_ID]:
"Web search requires a configured provider. If you have access, set one up under Settings > Web Search, or ask an admin.",
[PYTHON_TOOL_ID]:
"Code Interpreter requires the service to be configured with a valid base URL. If you have access, configure it in the admin panel, or ask an admin.",
};
const DEFAULT_TOOL_DESCRIPTION = "This action is not configured yet.";
function getToolTooltip(
tool: ToolSnapshot,
isConfigured: boolean,
canManageAction: boolean
): string {
const description =
(tool.in_code_tool_id && TOOL_DESCRIPTIONS[tool.in_code_tool_id]) ||
tool.description ||
DEFAULT_TOOL_DESCRIPTION;
return buildTooltipMessage(description, isConfigured, canManageAction);
}
const getUnavailableToolTooltip = (
inCodeToolId?: string | null,
canAdminConfigure?: boolean
) =>
(inCodeToolId && UNAVAILABLE_TOOL_TOOLTIPS[inCodeToolId]) ??
(canAdminConfigure
? UNAVAILABLE_TOOL_TOOLTIP_ADMIN_FALLBACK
: UNAVAILABLE_TOOL_TOOLTIP_FALLBACK);
const ADMIN_CONFIG_LINKS: Record<string, { href: string; tooltip: string }> = {
[IMAGE_GENERATION_TOOL_ID]: {
@@ -91,10 +72,6 @@ const ADMIN_CONFIG_LINKS: Record<string, { href: string; tooltip: string }> = {
href: "/admin/configuration/web-search",
tooltip: "Configure Web Search",
},
[PYTHON_TOOL_ID]: {
href: "/admin/configuration/code-interpreter",
tooltip: "Configure Code Interpreter",
},
KnowledgeGraphTool: {
href: "/admin/kg",
tooltip: "Configure Knowledge Graph",
@@ -915,11 +892,14 @@ export default function ActionsPopover({
disabled={disabledToolIds.includes(tool.id)}
isForced={forcedToolIds.includes(tool.id)}
isUnavailable={isUnavailable}
tooltip={getToolTooltip(
tool,
isToolAvailable,
canAdminConfigure
)}
unavailableReason={
isUnavailable
? getUnavailableToolTooltip(
tool.in_code_tool_id,
canAdminConfigure
)
: undefined
}
showAdminConfigure={!!adminConfigureInfo}
adminConfigureHref={adminConfigureInfo?.href}
adminConfigureTooltip={adminConfigureInfo?.tooltip}

View File

@@ -30,7 +30,7 @@ export const markdownVariant: PreviewVariant = {
<ScrollIndicatorDiv className="flex-1 min-h-0 p-4" variant="shadow">
<MinimalMarkdown
content={ctx.fileContent}
className="w-full pb-4 text-lg break-words"
className="w-full pb-4 h-full text-lg break-words"
/>
</ScrollIndicatorDiv>
),

View File

@@ -23,9 +23,8 @@ import {
} from "./formUtils";
import { AdvancedOptions } from "./components/AdvancedOptions";
import { DisplayModels } from "./components/DisplayModels";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { fetchOllamaModels } from "@/app/admin/configuration/llm/utils";
import debounce from "lodash/debounce";
export const OLLAMA_PROVIDER_NAME = "ollama_chat";
const DEFAULT_API_BASE = "http://127.0.0.1:11434";
@@ -62,16 +61,14 @@ function OllamaModalContent({
}: OllamaModalContentProps) {
const [isLoadingModels, setIsLoadingModels] = useState(true);
const fetchModels = useCallback(
(apiBase: string, signal: AbortSignal) => {
useEffect(() => {
if (formikProps.values.api_base) {
setIsLoadingModels(true);
fetchOllamaModels({
api_base: apiBase,
api_base: formikProps.values.api_base,
provider_name: existingLlmProvider?.name,
signal,
})
.then((data) => {
if (signal.aborted) return;
if (data.error) {
console.error("Error fetching models:", data.error);
setFetchedModels([]);
@@ -80,32 +77,14 @@ function OllamaModalContent({
setFetchedModels(data.models);
})
.finally(() => {
if (!signal.aborted) {
setIsLoadingModels(false);
}
setIsLoadingModels(false);
});
},
[existingLlmProvider?.name, setFetchedModels]
);
const debouncedFetchModels = useMemo(
() => debounce(fetchModels, 500),
[fetchModels]
);
useEffect(() => {
if (formikProps.values.api_base) {
const controller = new AbortController();
debouncedFetchModels(formikProps.values.api_base, controller.signal);
return () => {
debouncedFetchModels.cancel();
controller.abort();
};
} else {
setIsLoadingModels(false);
setFetchedModels([]);
}
}, [formikProps.values.api_base, debouncedFetchModels, setFetchedModels]);
}, [
formikProps.values.api_base,
existingLlmProvider?.name,
setFetchedModels,
]);
const currentModels =
fetchedModels.length > 0

View File

@@ -78,7 +78,7 @@ const ADMIN_PAGES: AdminPageSnapshot[] = [
{
name: "Configuration - LLM",
path: "configuration/llm",
pageTitle: "Language Models",
pageTitle: "LLM Models",
},
{
name: "Connectors - Existing Connectors",

View File

@@ -121,9 +121,7 @@ test.describe("LLM Provider Setup @exclusive", () => {
await loginAs(page, "admin");
await page.goto(LLM_SETUP_URL);
await page.waitForLoadState("networkidle");
await expect(page.getByLabel("admin-page-title")).toHaveText(
/^Language Models/
);
await expect(page.getByLabel("admin-page-title")).toHaveText(/^LLM Models/);
});
test.afterEach(async ({ page }) => {