LightRAG/docs/archives/adr/004-api-design.md
Raphael MANSUY 2b292d4924
docs: Enterprise Edition & Multi-tenancy attribution (#5)
* Remove outdated documentation files: Quick Start Guide, Apache AGE Analysis, and Scratchpad.

* Add multi-tenant testing strategy and ADR index documentation

- Introduced ADR 008 detailing the multi-tenant testing strategy for the ./starter environment, covering compatibility and multi-tenant modes, testing scenarios, and implementation details.
- Created a comprehensive ADR index (README.md) summarizing all architecture decision records related to the multi-tenant implementation, including purpose, key sections, and reading paths for different roles.

* feat(docs): Add comprehensive multi-tenancy guide and README for LightRAG Enterprise

- Introduced `0008-multi-tenancy.md` detailing multi-tenancy architecture, key concepts, roles, permissions, configuration, and API endpoints.
- Created `README.md` as the main documentation index, outlining features, quick start, system overview, and deployment options.
- Documented the LightRAG architecture, storage backends, LLM integrations, and query modes.
- Established a task log (`2025-01-21-lightrag-documentation-log.md`) summarizing documentation creation actions, decisions, and insights.
2025-12-04 18:09:15 +08:00

21 KiB

ADR 004: API Design and Routing

Status: Proposed

Overview

This document specifies the API design for the multi-tenant, multi-knowledge-base architecture, including endpoint structure, request/response models, authentication, and error handling.

API Versioning and Structure

Base URL

https://lightrag.example.com/api/v1

URL Path Structure

/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/{resource_type}/{operation}

Example Endpoints

POST   /api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/documents/add
GET    /api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/documents/{doc_id}
POST   /api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/query
DELETE /api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/documents/{doc_id}
GET    /api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/graph
POST   /api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/entities/{entity_id}/delete

Authentication Mechanisms

1. JWT Bearer Token Authentication

Token Creation

class TokenPayload(BaseModel):
    sub: str  # User ID
    tenant_id: str  # Assigned tenant
    knowledge_base_ids: List[str]  # Accessible KBs (or ["*"] for all)
    role: str  # admin | editor | viewer
    permissions: Dict[str, bool]  # Specific permissions
    exp: int  # Expiration time (Unix timestamp)
    iat: int  # Issued at time
    jti: str  # JWT ID (for revocation)

Usage

# Request with JWT token
curl -X POST https://lightrag.example.com/api/v1/tenants/acme/knowledge-bases/docs/query \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
  -H "Content-Type: application/json" \
  -d '{"query": "What is the product roadmap?"}'

Token Validation

async def validate_token(token: str) -> TokenPayload:
    """Validate JWT token and return payload"""
    try:
        payload = jwt.decode(
            token,
            settings.jwt_secret,
            algorithms=[settings.jwt_algorithm]
        )
        # Verify expiration
        exp_time = datetime.fromtimestamp(payload["exp"])
        if datetime.utcnow() > exp_time:
            raise HTTPException(status_code=401, detail="Token expired")
        
        return TokenPayload(**payload)
    except jwt.DecodeError:
        raise HTTPException(status_code=401, detail="Invalid token")

2. API Key Authentication

API Key Format

X-API-Key: sk-tenant_12345_kb_67890_randomstring1234567890

API Key Structure

sk-{tenant_id}_{kb_id}_{random_bytes}

Usage

curl -X POST https://lightrag.example.com/api/v1/tenants/acme/knowledge-bases/docs/query \
  -H "X-API-Key: sk-acme_docs_xyz123..." \
  -H "Content-Type: application/json" \
  -d '{"query": "What is the product roadmap?"}'

API Key Management Endpoints

@router.post("/api/v1/tenants/{tenant_id}/api-keys")
async def create_api_key(
    request: CreateAPIKeyRequest,
    tenant_context: TenantContext = Depends(get_tenant_context),
) -> APIKeyResponse:
    """Create a new API key for a tenant"""
    # Generate hashed key
    api_key = APIKeyService.generate_api_key(
        tenant_id=tenant_context.tenant_id,
        kb_id=request.kb_id,
        permissions=request.permissions
    )
    # Store hashed version
    await api_key_service.store_api_key(api_key)
    # Return key (only once, must be saved by client)
    return APIKeyResponse(
        key_id=api_key.key_id,
        key=api_key.unhashed_key,  # Only returned once
        created_at=api_key.created_at
    )

@router.get("/api/v1/tenants/{tenant_id}/api-keys")
async def list_api_keys(
    tenant_context: TenantContext = Depends(get_tenant_context),
) -> List[APIKeyMetadata]:
    """List API keys (without revealing the key itself)"""
    keys = await api_key_service.list_keys(tenant_context.tenant_id)
    return [
        APIKeyMetadata(
            key_id=k.key_id,
            key_name=k.key_name,
            created_at=k.created_at,
            last_used_at=k.last_used_at,
            permissions=k.permissions
        )
        for k in keys
    ]

@router.delete("/api/v1/tenants/{tenant_id}/api-keys/{key_id}")
async def revoke_api_key(
    key_id: str,
    tenant_context: TenantContext = Depends(get_tenant_context),
) -> dict:
    """Revoke an API key"""
    await api_key_service.revoke_key(key_id)
    return {"status": "success", "message": "API key revoked"}

Tenant Management Endpoints

Create Tenant

@router.post("/api/v1/tenants")
async def create_tenant(
    request: CreateTenantRequest,
    admin_token: str = Depends(validate_admin_token),
) -> TenantResponse:
    """Create a new tenant (admin only)"""
    tenant = await tenant_service.create_tenant(
        tenant_name=request.tenant_name,
        description=request.description,
        config=request.config or TenantConfig()
    )
    return TenantResponse(
        tenant_id=tenant.tenant_id,
        tenant_name=tenant.tenant_name,
        description=tenant.description,
        created_at=tenant.created_at,
        is_active=tenant.is_active
    )

# Request model
class CreateTenantRequest(BaseModel):
    tenant_name: str = Field(..., min_length=1, max_length=255)
    description: Optional[str] = None
    config: Optional[TenantConfigRequest] = None

class TenantConfigRequest(BaseModel):
    llm_model: Optional[str] = "gpt-4o-mini"
    embedding_model: Optional[str] = "bge-m3:latest"
    chunk_size: Optional[int] = 1200
    top_k: Optional[int] = 40

Get Tenant

@router.get("/api/v1/tenants/{tenant_id}")
async def get_tenant(
    tenant_context: TenantContext = Depends(get_tenant_context),
) -> TenantResponse:
    """Get tenant details"""
    tenant = await tenant_service.get_tenant(tenant_context.tenant_id)
    if not tenant:
        raise HTTPException(status_code=404, detail="Tenant not found")
    return TenantResponse.from_tenant(tenant)

Update Tenant

@router.put("/api/v1/tenants/{tenant_id}")
async def update_tenant(
    request: UpdateTenantRequest,
    tenant_context: TenantContext = Depends(get_tenant_context),
) -> TenantResponse:
    """Update tenant configuration"""
    if not has_permission(tenant_context, "tenant:manage"):
        raise HTTPException(status_code=403, detail="Access denied")
    
    tenant = await tenant_service.update_tenant(
        tenant_id=tenant_context.tenant_id,
        **request.dict(exclude_none=True)
    )
    return TenantResponse.from_tenant(tenant)

Knowledge Base Endpoints

Create Knowledge Base

@router.post("/api/v1/tenants/{tenant_id}/knowledge-bases")
async def create_knowledge_base(
    request: CreateKBRequest,
    tenant_context: TenantContext = Depends(get_tenant_context),
) -> KBResponse:
    """Create a knowledge base in a tenant"""
    if not has_permission(tenant_context, "kb:create"):
        raise HTTPException(status_code=403, detail="Access denied")
    
    kb = await tenant_service.create_knowledge_base(
        tenant_id=tenant_context.tenant_id,
        kb_name=request.kb_name,
        description=request.description
    )
    return KBResponse.from_kb(kb)

class CreateKBRequest(BaseModel):
    kb_name: str = Field(..., min_length=1, max_length=255)
    description: Optional[str] = None

List Knowledge Bases

@router.get("/api/v1/tenants/{tenant_id}/knowledge-bases")
async def list_knowledge_bases(
    tenant_context: TenantContext = Depends(get_tenant_context),
    skip: int = Query(0, ge=0),
    limit: int = Query(20, ge=1, le=100),
) -> PaginatedKBResponse:
    """List all KBs accessible to the user"""
    kbs = await tenant_service.list_knowledge_bases(
        tenant_id=tenant_context.tenant_id,
        accessible_kb_ids=tenant_context.knowledge_base_ids,
        skip=skip,
        limit=limit
    )
    return PaginatedKBResponse(
        items=[KBResponse.from_kb(kb) for kb in kbs],
        total=kbs.total,
        skip=skip,
        limit=limit
    )

Delete Knowledge Base

@router.delete("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}")
async def delete_knowledge_base(
    kb_id: str,
    tenant_context: TenantContext = Depends(get_tenant_context),
) -> dict:
    """Delete a knowledge base"""
    if not has_permission(tenant_context, "kb:delete"):
        raise HTTPException(status_code=403, detail="Access denied")
    
    await tenant_service.delete_knowledge_base(
        tenant_id=tenant_context.tenant_id,
        kb_id=kb_id
    )
    return {"status": "success", "message": "Knowledge base deleted"}

Document Endpoints

Add Document

@router.post("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/documents/add")
async def add_document(
    tenant_id: str = Path(...),
    kb_id: str = Path(...),
    file: UploadFile = File(...),
    metadata: Optional[str] = Form(None),  # JSON string
    tenant_context: TenantContext = Depends(get_tenant_context),
    rag_manager = Depends(get_rag_manager),
) -> DocumentAddResponse:
    """
    Add a document to a knowledge base.
    
    Returns a track_id for monitoring progress via websocket or polling.
    """
    if not has_permission(tenant_context, "document:create"):
        raise HTTPException(status_code=403, detail="Access denied")
    
    # Validate file
    if not is_allowed_file(file.filename):
        raise HTTPException(status_code=400, detail="File type not allowed")
    
    # Get tenant-specific RAG instance
    rag = await rag_manager.get_rag_instance(tenant_id, kb_id)
    
    # Start document processing (async)
    track_id = generate_track_id()
    asyncio.create_task(
        process_document(
            rag=rag,
            file=file,
            metadata=metadata,
            track_id=track_id,
            tenant_context=tenant_context
        )
    )
    
    return DocumentAddResponse(
        status="processing",
        track_id=track_id,
        message="Document is being processed"
    )

class DocumentAddResponse(BaseModel):
    status: str  # processing | success | error
    track_id: str
    message: Optional[str] = None
    doc_id: Optional[str] = None

Get Document Status

@router.get("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/documents/{doc_id}/status")
async def get_document_status(
    doc_id: str,
    tenant_context: TenantContext = Depends(get_tenant_context),
) -> DocumentStatusResponse:
    """Get document processing status"""
    status = await doc_status_service.get_status(
        doc_id=doc_id,
        tenant_id=tenant_context.tenant_id,
        kb_id=tenant_context.kb_id
    )
    return DocumentStatusResponse(
        doc_id=doc_id,
        status=status.status,  # ready | processing | error
        chunks_processed=status.chunks_processed,
        entities_extracted=status.entities_extracted,
        relationships_extracted=status.relationships_extracted,
        error_message=status.error_message
    )

Delete Document

@router.delete("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/documents/{doc_id}")
async def delete_document(
    doc_id: str,
    tenant_context: TenantContext = Depends(get_tenant_context),
    rag_manager = Depends(get_rag_manager),
) -> dict:
    """Delete a document from knowledge base"""
    if not has_permission(tenant_context, "document:delete"):
        raise HTTPException(status_code=403, detail="Access denied")
    
    # Verify document belongs to this tenant/KB
    doc = await doc_service.get_document(doc_id, tenant_context.tenant_id, tenant_context.kb_id)
    if not doc:
        raise HTTPException(status_code=404, detail="Document not found")
    
    # Delete from RAG
    rag = await rag_manager.get_rag_instance(
        tenant_context.tenant_id,
        tenant_context.kb_id
    )
    await rag.adelete_by_doc_id(doc_id)
    
    return {"status": "success", "message": "Document deleted"}

Query Endpoints

Standard Query

@router.post("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/query")
async def query_knowledge_base(
    request: QueryRequest,
    tenant_context: TenantContext = Depends(get_tenant_context),
    rag_manager = Depends(get_rag_manager),
) -> QueryResponse:
    """
    Execute a query against a knowledge base.
    
    Returns the generated response with optional references.
    """
    if not has_permission(tenant_context, "query:run"):
        raise HTTPException(status_code=403, detail="Access denied")
    
    # Validate query
    if len(request.query) < 3:
        raise HTTPException(status_code=400, detail="Query too short")
    
    # Get tenant-specific RAG instance
    rag = await rag_manager.get_rag_instance(
        tenant_context.tenant_id,
        tenant_context.kb_id
    )
    
    # Execute query with tenant context
    result = await rag.aquery(
        query=request.query,
        param=QueryParam(
            mode=request.mode or "mix",
            top_k=request.top_k or 40,
            stream=False
        )
    )
    
    return QueryResponse(
        response=result.response,
        references=result.references if request.include_references else None,
        metadata={
            "mode": request.mode,
            "top_k": request.top_k,
            "processing_time_ms": result.processing_time
        }
    )

class QueryRequest(BaseModel):
    query: str = Field(..., min_length=3, max_length=2000)
    mode: Optional[str] = Field("mix", regex="local|global|hybrid|naive|mix|bypass")
    top_k: Optional[int] = Field(None, ge=1, le=100)
    include_references: bool = Field(True)
    stream: bool = Field(False)

class QueryResponse(BaseModel):
    response: str
    references: Optional[List[Dict[str, str]]] = None
    metadata: Dict[str, Any] = {}

Streaming Query

@router.post("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/query/stream")
async def query_knowledge_base_stream(
    request: QueryRequest,
    tenant_context: TenantContext = Depends(get_tenant_context),
    rag_manager = Depends(get_rag_manager),
) -> StreamingResponse:
    """
    Execute a query with streaming response.
    
    Returns Server-Sent Events (SSE) with streamed tokens and metadata.
    """
    if not has_permission(tenant_context, "query:run"):
        raise HTTPException(status_code=403, detail="Access denied")
    
    async def stream_response():
        # Get RAG instance
        rag = await rag_manager.get_rag_instance(
            tenant_context.tenant_id,
            tenant_context.kb_id
        )
        
        # Stream the response
        async for chunk in rag.aquery_stream(
            query=request.query,
            param=QueryParam(
                mode=request.mode or "mix",
                top_k=request.top_k or 40,
                stream=True
            )
        ):
            # Emit Server-Sent Event
            yield f"data: {json.dumps(chunk)}\n\n"
    
    return StreamingResponse(
        stream_response(),
        media_type="text/event-stream"
    )

Query with Data

@router.post("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/query/data")
async def query_knowledge_base_data(
    request: QueryRequest,
    tenant_context: TenantContext = Depends(get_tenant_context),
    rag_manager = Depends(get_rag_manager),
) -> QueryDataResponse:
    """
    Execute a query and return full context data.
    
    Returns entities, relationships, chunks, and references.
    """
    if not has_permission(tenant_context, "query:run"):
        raise HTTPException(status_code=403, detail="Access denied")
    
    rag = await rag_manager.get_rag_instance(
        tenant_context.tenant_id,
        tenant_context.kb_id
    )
    
    result = await rag.aquery_with_data(
        query=request.query,
        param=QueryParam(mode=request.mode or "mix", top_k=request.top_k or 40)
    )
    
    return QueryDataResponse(
        status="success",
        message="Query executed successfully",
        data={
            "entities": result.entities,
            "relationships": result.relationships,
            "chunks": result.chunks,
            "response": result.response
        },
        metadata={
            "mode": request.mode,
            "entity_count": len(result.entities),
            "relationship_count": len(result.relationships),
            "chunk_count": len(result.chunks)
        }
    )

class QueryDataResponse(BaseModel):
    status: str
    message: str
    data: Dict[str, Any]
    metadata: Dict[str, Any]

Graph Endpoints

Get Graph

@router.get("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/graph")
async def get_graph(
    tenant_context: TenantContext = Depends(get_tenant_context),
    rag_manager = Depends(get_rag_manager),
    max_nodes: int = Query(100, ge=10, le=1000),
    entity_type: Optional[str] = None,
) -> GraphResponse:
    """Get knowledge graph visualization data"""
    if not has_permission(tenant_context, "kb:access"):
        raise HTTPException(status_code=403, detail="Access denied")
    
    rag = await rag_manager.get_rag_instance(
        tenant_context.tenant_id,
        tenant_context.kb_id
    )
    
    graph_data = await rag.get_graph(
        max_nodes=max_nodes,
        entity_type=entity_type
    )
    
    return GraphResponse(
        nodes=graph_data.nodes,
        edges=graph_data.edges,
        metadata={
            "node_count": len(graph_data.nodes),
            "edge_count": len(graph_data.edges)
        }
    )

Error Responses

Standard Error Response

class ErrorResponse(BaseModel):
    status: str = "error"
    code: str  # error code for client handling
    message: str
    details: Optional[Dict[str, Any]] = None
    request_id: str  # For tracking

# Example error codes
ERROR_CODES = {
    "INVALID_TENANT": "Specified tenant does not exist",
    "INVALID_KB": "Specified knowledge base does not exist",
    "UNAUTHORIZED": "Authentication failed",
    "FORBIDDEN": "User does not have permission",
    "INVALID_REQUEST": "Request validation failed",
    "INTERNAL_ERROR": "Internal server error",
    "RATE_LIMITED": "Too many requests",
    "QUOTA_EXCEEDED": "Resource quota exceeded"
}

Example Error Response

{
    "status": "error",
    "code": "FORBIDDEN",
    "message": "You do not have permission to access this knowledge base",
    "details": {
        "required_permission": "kb:access",
        "user_permissions": ["query:run"]
    },
    "request_id": "req-12345"
}

Request/Response Headers

Request Headers

Authorization: Bearer <jwt_token>
or
X-API-Key: <api_key>

X-Request-ID: <unique_request_id>  (optional, generated if not provided)
X-Tenant-ID: <tenant_id>           (optional, extracted from path)
X-KB-ID: <kb_id>                   (optional, extracted from path)

Response Headers

X-Request-ID: <unique_request_id>
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1703123456
Content-Type: application/json

Rate Limiting

Per-Tenant Rate Limits

class RateLimitConfig:
    # Per tenant
    QUERIES_PER_MINUTE = 100
    DOCUMENTS_PER_HOUR = 50
    API_CALLS_PER_MONTH = 100000
    
    # Global
    GLOBAL_QPS = 10000  # Queries per second

# Implement with Redis
@router.post("/api/v1/tenants/{tenant_id}/knowledge-bases/{kb_id}/query")
async def query_with_rate_limit(
    request: QueryRequest,
    tenant_context: TenantContext = Depends(get_tenant_context),
    rate_limiter = Depends(get_rate_limiter)
):
    # Check rate limit
    await rate_limiter.check_limit(
        key=f"{tenant_context.tenant_id}:queries",
        limit=RateLimitConfig.QUERIES_PER_MINUTE,
        window=60
    )
    
    # Execute query
    # ...

API Documentation

OpenAPI/Swagger

app = FastAPI(
    title="LightRAG Multi-Tenant API",
    description="API for multi-tenant RAG system",
    version="1.0.0",
    docs_url="/api/docs",
    redoc_url="/api/redoc",
    openapi_url="/api/openapi.json"
)

Example cURL Commands

# Create tenant (admin)
curl -X POST https://lightrag.example.com/api/v1/tenants \
  -H "Authorization: Bearer <admin_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "tenant_name": "Acme Corp",
    "description": "Our main tenant"
  }'

# Create knowledge base
curl -X POST https://lightrag.example.com/api/v1/tenants/acme/knowledge-bases \
  -H "Authorization: Bearer <tenant_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "kb_name": "Product Docs",
    "description": "Product documentation"
  }'

# Add document
curl -X POST https://lightrag.example.com/api/v1/tenants/acme/knowledge-bases/docs/documents/add \
  -H "Authorization: Bearer <tenant_token>" \
  -F "file=@document.pdf"

# Query knowledge base
curl -X POST https://lightrag.example.com/api/v1/tenants/acme/knowledge-bases/docs/query \
  -H "Authorization: Bearer <tenant_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "What is the product roadmap?",
    "mode": "mix",
    "top_k": 10,
    "include_references": true
  }'

# Stream query
curl -X POST https://lightrag.example.com/api/v1/tenants/acme/knowledge-bases/docs/query/stream \
  -H "Authorization: Bearer <tenant_token>" \
  -H "Content-Type: application/json" \
  -d '{"query": "Product roadmap?"}' \
  --stream

Document Version: 1.0
Last Updated: 2025-11-20
Related Files: 001-multi-tenant-architecture-overview.md, 002-implementation-strategy.md