Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ if [[ "${ENABLE_RAY_SERVE}" == "true" ]]; then
uv run $ENV_ARG -m api.main
else
echo "🚀 Starting with Uvicorn..."
uv run --no-dev $ENV_ARG uvicorn api.main:app --host 0.0.0.0 --port ${APP_PORT:-8080} --reload --workers ${API_NUM_WORKERS:-1}
uv run --no-dev $ENV_ARG uvicorn api.main:app --host 0.0.0.0 --port ${APP_iPORT:-8080} --reload --workers ${API_NUM_WORKERS:-1}
fi
12 changes: 12 additions & 0 deletions openrag/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Compatibility entrypoint for ASGI loaders using ``api:app``."""


def __getattr__(name: str):
if name == "app":
from api.main import app

return app
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


__all__ = ["app"]
11 changes: 5 additions & 6 deletions openrag/api/dependencies/auth.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import os

from config import load_config
from core.utils.exceptions import OpenRAGError
from di.providers import get_auth_service, get_job_service, get_partition_service
from di.providers import get_auth_service, get_config, get_job_service, get_partition_service
from fastapi import Depends, HTTPException, Request, status
from utils.logger import get_logger

config = load_config()
logger = get_logger()

SUPER_ADMIN_MODE = os.getenv("SUPER_ADMIN_MODE", "false").lower() == "true"
DEFAULT_FILE_QUOTA = config.rdb.default_file_quota


def current_user(request: Request):
Expand Down Expand Up @@ -236,11 +233,13 @@ async def check_user_file_quota(
user=Depends(current_user),
auth_service=Depends(get_auth_service),
job_service=Depends(get_job_service),
config=Depends(get_config),
):
"""Check if user has reached their file quota."""
default_file_quota = config.rdb.default_file_quota
if user.get("is_admin", False):
return user
if DEFAULT_FILE_QUOTA < 0:
if default_file_quota < 0:
return user
user_quota = user.get("file_quota")
if user_quota is not None and user_quota < 0:
Expand All @@ -259,7 +258,7 @@ async def check_user_file_quota(
auth_service.validate_file_quota(
user,
pending_task_count=pending_count,
default_quota=DEFAULT_FILE_QUOTA,
default_quota=default_file_quota,
)
except OpenRAGError as exc:
raise HTTPException(
Expand Down
15 changes: 6 additions & 9 deletions openrag/api/dependencies/files.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,10 @@
from pathlib import Path
from typing import Any

from config import load_config
from core.indexing import validators as core_validators
from di.providers import get_config
from fastapi import Depends, Form, UploadFile

config = load_config()

FORBIDDEN_CHARS_IN_FILE_ID = set("/")
LOG_FILE = Path(config.paths.log_dir or "logs") / "app.json"
ACCEPTED_FILE_FORMATS = config.loader.file_loaders.model_dump().keys()
DICT_MIMETYPES = config.loader.mimetypes.to_dict()


async def validate_file_id(file_id: str):
Expand All @@ -24,11 +18,14 @@ async def validate_metadata(metadata: Any | None = Form(None)):
async def validate_file_format(
file: UploadFile,
metadata: dict = Depends(validate_metadata),
config=Depends(get_config),
):
accepted_file_formats = config.loader.file_loaders.model_dump().keys()
mimetypes = config.loader.mimetypes.to_dict()
core_validators.validate_file_format(
filename=file.filename,
accepted_formats=ACCEPTED_FILE_FORMATS,
accepted_mimetypes=DICT_MIMETYPES.keys(),
accepted_formats=accepted_file_formats,
accepted_mimetypes=mimetypes.keys(),
mimetype=metadata.get("mimetype"),
)
return file
7 changes: 3 additions & 4 deletions openrag/api/dependencies/llm.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import consts
import openai
from api.dependencies.auth import SUPER_ADMIN_MODE
from config import load_config
from fastapi import HTTPException, status
from di.providers import get_config
from fastapi import Depends, HTTPException, status
from openai import AsyncOpenAI
from utils.logger import get_logger

config = load_config()
logger = get_logger()


Expand All @@ -16,7 +15,7 @@ async def get_openai_models(base_url: str, api_key: str, timeout: int = 30):
return models_response.data


async def check_llm_model_availability():
async def check_llm_model_availability(config=Depends(get_config)):
llm_param = config.llm
base_url = llm_param.base_url
model = llm_param.model
Expand Down
17 changes: 11 additions & 6 deletions openrag/api/dependencies/test_auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from types import SimpleNamespace

import pytest
from api.dependencies import auth as api_auth
from api.dependencies.auth import check_user_file_quota, ensure_partition_role, require_task_owner
from core.utils.exceptions import AuthError
from fastapi import HTTPException
Expand Down Expand Up @@ -85,6 +86,10 @@ async def get_user_pending_task_count(self, user_id: int | None) -> int:
return self.pending_count


def _config(default_file_quota: int):
return SimpleNamespace(rdb=SimpleNamespace(default_file_quota=default_file_quota))


@pytest.mark.asyncio
async def test_ensure_partition_role_allows_unknown_partition_without_membership():
partition_service = FakePartitionService(existing=set())
Expand Down Expand Up @@ -154,29 +159,28 @@ async def test_require_task_owner_reads_task_details_through_job_service():


@pytest.mark.asyncio
async def test_check_user_file_quota_reads_pending_count_through_job_service(monkeypatch):
monkeypatch.setattr(api_auth, "DEFAULT_FILE_QUOTA", 10)
async def test_check_user_file_quota_reads_pending_count_through_job_service():
job_service = FakeJobService(pending_count=2)

user = await check_user_file_quota(
user={"id": 7, "file_count": 1, "file_quota": 5},
auth_service=FakeAuthService,
job_service=job_service,
config=_config(default_file_quota=10),
)

assert user["id"] == 7
assert job_service.pending_checks == [7]


@pytest.mark.asyncio
async def test_check_user_file_quota_default_zero_enforces_zero(monkeypatch):
monkeypatch.setattr(api_auth, "DEFAULT_FILE_QUOTA", 0)

async def test_check_user_file_quota_default_zero_enforces_zero():
allowed_job_service = FakeJobService(pending_count=0)
user = await check_user_file_quota(
user={"id": 7, "file_count": 0, "file_quota": 1},
auth_service=EnforcingAuthService,
job_service=allowed_job_service,
config=_config(default_file_quota=0),
)
assert user["id"] == 7
assert allowed_job_service.pending_checks == [7]
Expand All @@ -187,6 +191,7 @@ async def test_check_user_file_quota_default_zero_enforces_zero(monkeypatch):
user={"id": 8, "file_count": 1, "file_quota": None},
auth_service=EnforcingAuthService,
job_service=denied_job_service,
config=_config(default_file_quota=0),
)
assert exc.value.status_code == 403
assert exc.value.detail == "File quota exceeded"
Expand Down
53 changes: 42 additions & 11 deletions openrag/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from __future__ import annotations

import asyncio
import os
import warnings
from contextlib import asynccontextmanager
Expand Down Expand Up @@ -52,6 +53,7 @@
from api.routers.user.search import router as search_router
from config import load_config
from di.container import ServiceContainer
from di.providers import set_container
from di.workers import ensure_worker_bootstrap
from dotenv import dotenv_values
from fastapi import Depends, FastAPI
Expand All @@ -70,8 +72,11 @@
# ---------------------------------------------------------------------------

logger = get_logger()
config = load_config()
DATA_DIR = Path(config.paths.data_dir)
settings = load_config()
DATA_DIR = Path(settings.paths.data_dir)
CONTAINER_STARTUP_TIMEOUT = float(
os.getenv("OPENRAG_CONTAINER_STARTUP_TIMEOUT", max(60, settings.rdb.command_timeout * 4))
)

SHARED_ENV = os.environ.get("SHARED_ENV", None)
env_vars = dotenv_values(SHARED_ENV) if SHARED_ENV else {}
Expand Down Expand Up @@ -133,20 +138,29 @@ async def lifespan(app: FastAPI):
container is absent.
4. ``prime_max_model_tokens`` — caches the vLLM ``max_model_len`` so
per-request validation in the chat router stays synchronous.
5. ``set_container`` — registers the resolved container as the
process-level singleton for callers that resolve it without a
request; cleared on shutdown.
"""
if not ray.is_initialized():
logger.info("Startup: initializing Ray")
ray.init(dashboard_host="0.0.0.0", ignore_reinit_error=True)
logger.info("Startup: Ray is initialized")

# ``ensure_worker_bootstrap`` imports ``services.workers.bootstrap``
# for its side effect: creating the long-lived detached worker
# actors (TaskStateManager, DocSerializer, MarkerPool, semaphores).
# The indirection through :mod:`di.workers` keeps API code free of
# direct ``services.workers`` imports.
ensure_worker_bootstrap()
logger.info("Startup: initializing worker bootstrap")
ensure_worker_bootstrap(settings)
logger.info("Startup: worker bootstrap initialized")

container: ServiceContainer | None
try:
container = ServiceContainer(config)
logger.info("Startup: wiring ServiceContainer")
container = ServiceContainer(settings)
logger.info("Startup: ServiceContainer wired")
except Exception: # pragma: no cover - defensive boot guard
logger.exception("ServiceContainer wiring skipped")
container = None
Expand All @@ -155,7 +169,13 @@ async def lifespan(app: FastAPI):

if container is not None:
try:
await container.initialize()
logger.info("Startup: initializing ServiceContainer", timeout=CONTAINER_STARTUP_TIMEOUT)
await asyncio.wait_for(container.initialize(), timeout=CONTAINER_STARTUP_TIMEOUT)
logger.info("Startup: ServiceContainer initialized")
except TimeoutError: # pragma: no cover - defensive boot guard
logger.exception("ServiceContainer.initialize timed out; serving degraded (503)")
app.state.container = None
container = None
except Exception: # pragma: no cover - defensive boot guard
# A half-initialised container (asyncpg pool never opened)
# would route requests into broken repos and 500. Drop it so
Expand All @@ -165,10 +185,20 @@ async def lifespan(app: FastAPI):
container = None

try:
await prime_max_model_tokens()
logger.info("Startup: priming max model token cache")
await prime_max_model_tokens(settings)
logger.info("Startup: max model token cache primed")
except Exception: # pragma: no cover - defensive cache guard
logger.exception("max_model_tokens cache priming failed; falling back to config")

# Mirror the resolved boot state into the process-level singleton so
# callers without a request (Chainlit, background tasks) resolve the
# same container the HTTP routes get via request.app.state. None on a
# degraded boot keeps di.providers serving the intended 503.
logger.info("Startup: registering process container", available=getattr(app.state, "container", None) is not None)
set_container(getattr(app.state, "container", None))
logger.info("Startup: complete")

try:
yield
finally:
Expand All @@ -178,6 +208,7 @@ async def lifespan(app: FastAPI):
await live.shutdown()
except Exception: # pragma: no cover - defensive shutdown guard
logger.exception("ServiceContainer.shutdown skipped")
set_container(None)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -266,7 +297,7 @@ def root_redirect():

@app.get("/config", summary="Get current configuration", tags=["Configuration"], dependencies=[Depends(require_admin)])
def get_config():
return config
return settings


# Router mounts. Phase 10F finished moving these into
Expand Down Expand Up @@ -301,20 +332,20 @@ def get_config():


if __name__ == "__main__":
if config.ray.serve.enable:
if settings.ray.serve.enable:
from ray import serve

@serve.deployment(num_replicas=config.ray.serve.num_replicas)
@serve.deployment(num_replicas=settings.ray.serve.num_replicas)
@serve.ingress(app)
class OpenRagAPI:
pass

serve.start(http_options={"host": config.ray.serve.host, "port": config.ray.serve.port})
serve.start(http_options={"host": settings.ray.serve.host, "port": settings.ray.serve.port})
if WITH_CHAINLIT_UI:
from chainlit_api import app as chainlit_app

serve.run(OpenRagAPI.bind(), route_prefix="/")
uvicorn.run(chainlit_app, host="0.0.0.0", port=config.ray.serve.chainlit_port)
uvicorn.run(chainlit_app, host="0.0.0.0", port=settings.ray.serve.chainlit_port)
else:
serve.run(OpenRagAPI.bind(), route_prefix="/", blocking=True)

Expand Down
Loading
Loading