Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
52 changes: 52 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,58 @@ WEBUI_DESCRIPTION='Simple and Fast Graph Based RAG System'
# TIMEOUT=150
# CORS_ORIGINS=http://localhost:3000,http://localhost:8080

### Path Prefix Configuration (Optional)
### Primary use case: hosting multiple LightRAG instances on one host, with
### a reverse proxy routing by site prefix:
### https://host/site01/... → instance #1 (this .env)
### https://host/site02/... → instance #2 (a separate .env / container)
### Each instance gets its own storage, API keys and WebUI under a unique
### prefix. Empty or "/" disables the prefix; trailing slashes are
### normalized automatically.
###
### How these two variables are interpreted:
### - LIGHTRAG_API_PREFIX is passed to FastAPI as `root_path`. It does NOT
### rewrite route matching — it tells FastAPI "this app is mounted under
### <prefix> by an upstream proxy", so the OpenAPI `servers` URL and
### request.url_for() generate correct absolute URLs. The reverse proxy
### is expected to strip this prefix before forwarding to uvicorn.
###
### nginx example for site01:
### location /site01/ {
### proxy_pass http://127.0.0.1:9621/; # trailing / strips /site01
### proxy_set_header X-Forwarded-Prefix /site01;
### }
###
### - LIGHTRAG_WEBUI_PATH is the in-app mount path: app.mount(<path>, ...).
### It is what the *backend itself* sees AFTER the proxy has stripped
### LIGHTRAG_API_PREFIX, so leave it as `/webui` even when each instance
### has a different site prefix.
###
### IMPORTANT — backend / frontend split:
### The WebUI is a static bundle. Asset URLs (<script src>, <link href>)
### and the in-bundle API base URL are BAKED IN at `bun run build` time
### from VITE_API_PREFIX / VITE_WEBUI_PREFIX. The two LIGHTRAG_* vars below
### only configure the Python server — they have NO effect on already-built
### bundles. Consequence for multi-site deployments: each site that uses a
### different prefix needs its OWN WebUI build (one image / artifact per
### site), until runtime config injection lands in a follow-up PR. See
### `lightrag_webui/.env.example` for the matching frontend variables.
###
### Browser-visible URL (what the user types in the address bar) =
### proxy prefix + in-app path. So at build time:
### VITE_API_PREFIX = LIGHTRAG_API_PREFIX
### VITE_WEBUI_PREFIX = LIGHTRAG_API_PREFIX + LIGHTRAG_WEBUI_PATH + "/"
###
### Example — site01 instance, WebUI at https://host/site01/webui/:
### Backend (this file):
### LIGHTRAG_API_PREFIX=/site01
### LIGHTRAG_WEBUI_PATH=/webui
### Frontend build (lightrag_webui/.env or .env.production):
### VITE_API_PREFIX=/site01
### VITE_WEBUI_PREFIX=/site01/webui/
# LIGHTRAG_API_PREFIX=/site01
# LIGHTRAG_WEBUI_PATH=/webui

### Optional SSL Configuration
### Docker note: generated compose files mount staged certs at /app/data/certs/ inside the container
# SSL=true
Expand Down
14 changes: 14 additions & 0 deletions lightrag/api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,20 @@ def parse_args() -> argparse.Namespace:
help="Default workspace for all storage",
)

# Path prefix configuration
parser.add_argument(
"--api-prefix",
type=str,
default=get_env_value("LIGHTRAG_API_PREFIX", ""),
help="API path prefix (e.g., /api/v1). Prepended to all API routes. Default: none (root).",
)
parser.add_argument(
"--webui-path",
type=str,
default=get_env_value("LIGHTRAG_WEBUI_PATH", "/webui"),
help="Path to mount WebUI static files. Default: /webui",
)

# Server workers configuration
parser.add_argument(
"--workers",
Expand Down
169 changes: 144 additions & 25 deletions lightrag/api/lightrag_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,98 @@ def check_frontend_build():
return (True, False) # Assume assets exist and up-to-date on error


def check_webui_build_prefix(api_prefix: str, webui_path: str) -> None:
"""Warn if the WebUI build's baked-in path prefix doesn't match what
this server will expose.

Background: the WebUI is a Vite-built static bundle. `VITE_WEBUI_PREFIX`
is statically replaced into `index.html` and the JS chunk imports at
`bun run build` time. If an admin later changes `LIGHTRAG_API_PREFIX` /
`LIGHTRAG_WEBUI_PATH` without rebuilding (e.g. when reusing a single
image across multiple sites), `index.html` still loads, but every
`<script src=...>` and `<link href=...>` points at the OLD prefix and
404s — a confusing failure mode that's easy to miss in browser logs.

This check parses one asset reference from the built `index.html`,
derives the prefix that was baked in, and compares it to the
browser-visible path = api_prefix + webui_path + "/" — the same formula
documented in `env.example`. If they disagree it prints a loud warning
with the exact command to rebuild.

Args:
api_prefix: normalized LIGHTRAG_API_PREFIX (e.g. "/site01" or "")
webui_path: normalized LIGHTRAG_WEBUI_PATH (e.g. "/webui")
"""
index_html = Path(__file__).parent / "webui" / "index.html"
if not index_html.exists():
# No build to check — `check_frontend_build` already warned.
return

try:
html = index_html.read_text(encoding="utf-8")
except OSError as e:
logger.warning(f"Could not read WebUI index.html for prefix check: {e}")
return

# Vite emits assets under `<base>assets/...` (see assetFileNames). Pull
# the prefix out of the first such reference. Match either src= or href=
# to cover <script> and <link> tags.
match = re.search(r'(?:src|href)="([^"]*?)/assets/[^"]*"', html)
if not match:
# Build artifact format unexpected — skip silently rather than
# spam a warning the admin can't act on.
logger.debug("Could not infer WebUI baked prefix from index.html")
return

baked_prefix = match.group(1) + "/"
expected_prefix = f"{api_prefix}{webui_path}/"

if baked_prefix == expected_prefix:
logger.info(f"WebUI build prefix matches server config: {expected_prefix}")
return

# Structured warning so log aggregators / tests can pick it up.
rebuild_cmd = (
("VITE_API_PREFIX=" + api_prefix + " " if api_prefix else "")
+ "VITE_WEBUI_PREFIX="
+ expected_prefix
+ " bun run build"
)
logger.warning(
"WebUI build prefix mismatch: built with %s but server will expose"
" at %s (LIGHTRAG_API_PREFIX=%s + LIGHTRAG_WEBUI_PATH=%s + '/')."
" Asset URLs will 404 until the WebUI is rebuilt with: %s",
baked_prefix,
expected_prefix,
api_prefix or "<empty>",
webui_path,
rebuild_cmd,
)

# Banner duplicates the warning for an operator watching the splash —
# easy to miss a single log line among startup chatter.
ASCIIColors.yellow("\n" + "=" * 80)
ASCIIColors.yellow("WARNING: WebUI Build Prefix Mismatch")
ASCIIColors.yellow("=" * 80)
ASCIIColors.yellow(f"WebUI was built with prefix: {baked_prefix}")
ASCIIColors.yellow(f"This server will expose it at: {expected_prefix}")
ASCIIColors.yellow(
f" (LIGHTRAG_API_PREFIX={api_prefix or '<empty>'}"
f' + LIGHTRAG_WEBUI_PATH={webui_path} + "/")\n'
)
ASCIIColors.yellow("The WebUI HTML will load, but its asset URLs (JS/CSS) will 404")
ASCIIColors.yellow(
"because they were baked at build time. Rebuild the WebUI with:\n"
)
ASCIIColors.cyan(" cd lightrag_webui")
if api_prefix:
ASCIIColors.cyan(f" VITE_API_PREFIX={api_prefix} \\")
ASCIIColors.cyan(f" VITE_WEBUI_PREFIX={expected_prefix} \\")
ASCIIColors.cyan(" bun run build")
ASCIIColors.cyan(" cd ..")
ASCIIColors.yellow("=" * 80 + "\n")


def create_app(args):
# Check frontend build first and get status
webui_assets_exist, is_frontend_outdated = check_frontend_build()
Expand Down Expand Up @@ -377,7 +469,6 @@ async def lifespan(app: FastAPI):
"Gunicorn Mode: postpone shared storage finalization to master process"
)

# Initialize FastAPI
base_description = (
"Providing API for LightRAG core, Web UI and Ollama Model Emulation"
)
Expand All @@ -386,13 +477,38 @@ async def lifespan(app: FastAPI):
+ (" (API-Key Enabled)" if api_key else "")
+ "\n\n[View ReDoc documentation](/redoc)"
)

# Normalize API prefix and WebUI mount path. Both accept user input from
# CLI/env, so we strip surrounding whitespace, strip a trailing slash
# (Starlette's app.mount rejects mount paths ending in '/'), and treat
# empty/"/" as "use the default". A leading slash is ensured.
def _normalize_path(value: str | None, default: str) -> str:
if value is None:
return default
value = value.strip()
if not value or value == "/":
return default
if not value.startswith("/"):
value = "/" + value
return value.rstrip("/")

api_prefix = _normalize_path(getattr(args, "api_prefix", None), default="")
webui_path = _normalize_path(getattr(args, "webui_path", None), default="/webui")

# Loud warning at startup if the WebUI build's baked prefix disagrees
# with the prefix this server will expose. Skipped when no build exists
# (check_frontend_build already warned about that).
if webui_assets_exist:
check_webui_build_prefix(api_prefix, webui_path)

app_kwargs = {
"title": "LightRAG Server API",
"description": swagger_description,
"version": __api_version__,
"openapi_url": "/openapi.json", # Explicitly set OpenAPI schema URL
"docs_url": None, # Disable default docs, we'll create custom endpoint
"redoc_url": "/redoc", # Explicitly set redoc URL
"openapi_url": "/openapi.json",
"docs_url": None, # custom endpoint for offline Swagger support
"redoc_url": "/redoc",
"root_path": api_prefix if api_prefix else None,
"lifespan": lifespan,
}

Expand Down Expand Up @@ -1176,13 +1292,9 @@ async def server_rerank_func(
raise

# Add routes
app.include_router(
create_document_routes(
rag,
doc_manager,
api_key,
)
)
# root_path is set on the app for reverse proxy support;
# routes stay at their natural paths and are prefixed by the proxy or uvicorn --root-path
app.include_router(create_document_routes(rag, doc_manager, api_key))
app.include_router(create_query_routes(rag, api_key, args.top_k))
app.include_router(create_graph_routes(rag, api_key))

Expand Down Expand Up @@ -1210,12 +1322,18 @@ async def swagger_ui_redirect():
return get_swagger_ui_oauth2_redirect_html()

@app.get("/")
async def redirect_to_webui():
"""Redirect root path based on WebUI availability"""
async def redirect_to_webui(request: Request):
"""Redirect root path based on WebUI availability.

Prepend the ASGI root_path so that, behind a reverse proxy, the
absolute redirect target keeps the configured prefix instead of
bypassing it.
"""
root = request.scope.get("root_path", "")
if webui_assets_exist:
return RedirectResponse(url="/webui")
return RedirectResponse(url=f"{root}{webui_path}/")
else:
return RedirectResponse(url="/docs")
return RedirectResponse(url=f"{root}/docs")

@app.get("/auth-status")
async def get_auth_status():
Expand Down Expand Up @@ -1431,22 +1549,23 @@ async def get_response(self, path: str, scope):
static_dir = Path(__file__).parent / "webui"
static_dir.mkdir(exist_ok=True)
app.mount(
"/webui",
webui_path,
SmartStaticFiles(
directory=static_dir, html=True, check_dir=True
), # Use SmartStaticFiles
name="webui",
)
logger.info("WebUI assets mounted at /webui")
logger.info(f"WebUI assets mounted at {webui_path}")
else:
logger.info("WebUI assets not available, /webui route not mounted")

# Add redirect for /webui when assets are not available
@app.get("/webui")
@app.get("/webui/")
async def webui_redirect_to_docs():
"""Redirect /webui to /docs when WebUI is not available"""
return RedirectResponse(url="/docs")
logger.info("WebUI assets not available, WebUI route not mounted")

# Add redirect for WebUI path when assets are not available
@app.get(webui_path)
@app.get(f"{webui_path}/")
async def webui_redirect_to_docs(request: Request):
"""Redirect WebUI path to /docs when WebUI is not available."""
root = request.scope.get("root_path", "")
return RedirectResponse(url=f"{root}/docs")

return app

Expand Down
12 changes: 8 additions & 4 deletions lightrag/api/routers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
"""
This module contains all the routers for the LightRAG API.

The document/query/graph routers are intentionally NOT re-exported here:
they are constructed per-app via the `create_*_routes` factory functions
in their respective modules. A module-level singleton would accumulate
duplicate routes if the factory is invoked more than once in the same
process (e.g. across tests), which produced "Duplicate Operation ID"
warnings before the factories were converted to local routers.
"""

from .document_routes import router as document_router
from .query_routes import router as query_router
from .graph_routes import router as graph_router
from .ollama_api import OllamaAPI

__all__ = ["document_router", "query_router", "graph_router", "OllamaAPI"]
__all__ = ["OllamaAPI"]
16 changes: 12 additions & 4 deletions lightrag/api/routers/document_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,12 @@ def format_datetime(dt: Any) -> Optional[str]:
return dt.isoformat()


router = APIRouter(
prefix="/documents",
tags=["documents"],
)
# NOTE: the APIRouter instance is created INSIDE `create_document_routes`
# (not at module scope). A module-level router is shared across processes,
# and re-running the factory — which the test suite does to validate
# create_app for different `--api-prefix` values — would re-decorate the
# same router each time, accumulating duplicate routes and triggering
# FastAPI's "Duplicate Operation ID" warnings.

# Temporary file prefix
temp_prefix = "__tmp__"
Expand Down Expand Up @@ -2087,6 +2089,12 @@ async def background_delete_documents(
def create_document_routes(
rag: LightRAG, doc_manager: DocumentManager, api_key: Optional[str] = None
):
# Fresh router per call — see the note above the temp_prefix constant.
router = APIRouter(
prefix="/documents",
tags=["documents"],
)

# Create combined auth dependency for document routes
combined_auth = get_combined_auth_dependency(api_key)

Expand Down
8 changes: 6 additions & 2 deletions lightrag/api/routers/graph_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@
from lightrag.utils import logger
from ..utils_api import get_combined_auth_dependency

router = APIRouter(tags=["graph"])


class EntityUpdateRequest(BaseModel):
entity_name: str
Expand Down Expand Up @@ -87,6 +85,12 @@ class RelationCreateRequest(BaseModel):


def create_graph_routes(rag, api_key: Optional[str] = None):
# Fresh router per call. A module-level instance would accumulate
# duplicate routes when the factory is invoked more than once in the
# same process (e.g. across tests), which triggers FastAPI's
# "Duplicate Operation ID" warnings.
router = APIRouter(tags=["graph"])

combined_auth = get_combined_auth_dependency(api_key)

@router.get("/graph/label/list", dependencies=[Depends(combined_auth)])
Expand Down
8 changes: 6 additions & 2 deletions lightrag/api/routers/query_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@
from lightrag.utils import logger
from pydantic import BaseModel, Field, field_validator

router = APIRouter(tags=["query"])


class QueryRequest(BaseModel):
query: str = Field(
Expand Down Expand Up @@ -191,6 +189,12 @@ class StreamChunkResponse(BaseModel):


def create_query_routes(rag, api_key: Optional[str] = None, top_k: int = 60):
# Fresh router per call. A module-level instance would accumulate
# duplicate routes when the factory is invoked more than once in the
# same process (e.g. across tests), which triggers FastAPI's
# "Duplicate Operation ID" warnings.
router = APIRouter(tags=["query"])

combined_auth = get_combined_auth_dependency(api_key)

@router.post(
Expand Down
Loading