mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-04 07:05:46 +00:00
Compare commits
3 Commits
main
...
model_depr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21ca9715dd | ||
|
|
3700bee34f | ||
|
|
55117fccf2 |
@@ -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
|
||||
|
||||
51
.github/workflows/pr-integration-tests.yml
vendored
51
.github/workflows/pr-integration-tests.yml
vendored
@@ -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
|
||||
|
||||
39
AGENTS.md
39
AGENTS.md
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
11
backend/onyx/cache/interface.py
vendored
11
backend/onyx/cache/interface.py
vendored
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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=(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
58
web/lib/opal/src/icons/README.md
Normal file
58
web/lib/opal/src/icons/README.md
Normal 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.
|
||||
72
web/lib/opal/src/icons/scripts/convert-svg.sh
Executable file
72
web/lib/opal/src/icons/scripts/convert-svg.sh
Executable 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
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
/>
|
||||
```
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -11,9 +11,3 @@ export {
|
||||
ContentAction,
|
||||
type ContentActionProps,
|
||||
} from "@opal/layouts/ContentAction/components";
|
||||
|
||||
/* IllustrationContent */
|
||||
export {
|
||||
IllustrationContent,
|
||||
type IllustrationContentProps,
|
||||
} from "@opal/layouts/IllustrationContent/components";
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -187,7 +187,6 @@ export const fetchOllamaModels = async (
|
||||
api_base: apiBase,
|
||||
provider_name: params.provider_name,
|
||||
}),
|
||||
signal: params.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)));
|
||||
};
|
||||
|
||||
@@ -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...");
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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: "/",
|
||||
|
||||
76
web/src/app/ee/admin/theme/ImageUpload.tsx
Normal file
76
web/src/app/ee/admin/theme/ImageUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -118,7 +118,6 @@ export interface BedrockFetchParams {
|
||||
export interface OllamaFetchParams {
|
||||
api_base?: string;
|
||||
provider_name?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface OpenRouterFetchParams {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -78,7 +78,7 @@ const ADMIN_PAGES: AdminPageSnapshot[] = [
|
||||
{
|
||||
name: "Configuration - LLM",
|
||||
path: "configuration/llm",
|
||||
pageTitle: "Language Models",
|
||||
pageTitle: "LLM Models",
|
||||
},
|
||||
{
|
||||
name: "Connectors - Existing Connectors",
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user