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
60 changes: 60 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Docker CI Pipeline

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
docker-integration-test:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build Docker image
run: |
docker build -t api-ndlocr-lite:latest .

- name: Start API container
run: |
docker run -d --name ndlocr-api -p 8001:8000 api-ndlocr-lite:latest

- name: Wait for API to become healthy
run: |
echo "Waiting for API startup (this may take a few seconds)..."
# Poll the healthcheck endpoint up to 12 times with 5s sleep (1 minute total max)
for i in {1..12}; do
if curl -s -f http://localhost:8001/health; then
echo "API is healthy!"
exit 0
fi
echo "Waiting... (attempt $i/12)"
sleep 5
done
echo "Error: API did not become healthy in time."
docker logs ndlocr-api
exit 1

- name: Run basic API smoke test
run: |
# Query health endpoint
HEALTH_STATUS=$(curl -s http://localhost:8001/health)
echo "Health response: $HEALTH_STATUS"
if [[ "$HEALTH_STATUS" != *"ok"* ]]; then
echo "Unexpected health status response"
exit 1
fi

- name: Stop container
if: always()
run: |
docker stop ndlocr-api || true
docker rm ndlocr-api || true
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y \
libxext6 \
libxrender-dev \
git \
curl \
&& rm -rf /var/lib/apt/lists/*

# Install uv
Expand Down
9 changes: 9 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,12 @@ services:
interval: 30s
timeout: 10s
retries: 3

# Example configuration for integration with LibreChat or other services:
#
# librechat:
# image: ghcr.io/danny-avila/librechat:latest
# ...
# depends_on:
# api:
# condition: service_healthy
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
[project]
name = "api-ndlocr-lite"
version = "0.1.0"
description = "Add your description here"
description = "OpenAI-compatible OCR API server based on NDLOCR-Lite, optimized for LibreChat, Code Interpreter, and secure sandbox environments."
readme = "README.md"
keywords = ["librechat", "code-interpreter", "sandbox", "fastapi"]
requires-python = ">=3.13"
dependencies = [
"dill==0.3.8",
Expand Down
38 changes: 33 additions & 5 deletions src/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,17 @@ async def ocr_endpoint(
if engine is None:
raise HTTPException(status_code=503, detail="Engine not initialized")

if await request.is_disconnected():
raise HTTPException(status_code=499, detail="Client closed connection")

try:
# Run CPU-bound OCR processing in a thread pool to avoid blocking the event loop
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, engine.ocr, img, filename)

if await request.is_disconnected():
raise HTTPException(status_code=499, detail="Client closed connection")

# Convert and return response
page = _engine_result_to_ocr_page(result)
return OCRResponse(model="ndlocr-lite", pages=[page], usage={"pages": 1})
Expand All @@ -182,6 +188,9 @@ async def create_ocr_job(
# Extract image first to ensure it's valid before accepting the job
img, filename = await _get_image_from_request(request, file)

if await request.is_disconnected():
raise HTTPException(status_code=499, detail="Client closed connection")

job_id = str(uuid.uuid4())
job_store.set(job_id, OCRJobResult(job_id=job_id, status="pending"))

Expand All @@ -202,6 +211,7 @@ async def get_ocr_job(job_id: str, job_store: InMemoryJobStore = Depends(get_job

@app.post("/v1/chat/completions", response_model=ChatCompletionResponse)
async def openai_vision_endpoint(
raw_request: Request,
request: ChatCompletionRequest,
engine: Optional[NDLOCREngine] = Depends(get_engine),
):
Expand All @@ -212,6 +222,9 @@ async def openai_vision_endpoint(
if engine is None:
raise HTTPException(status_code=503, detail="Engine not initialized")

if await raw_request.is_disconnected():
raise HTTPException(status_code=499, detail="Client closed connection")

img_data = None
for message in request.messages:
if message.role == "user" and isinstance(message.content, list):
Expand All @@ -235,10 +248,16 @@ async def openai_vision_endpoint(
if img.width * img.height > MAX_PIXELS:
raise HTTPException(status_code=400, detail="Image dimensions too large")

if await raw_request.is_disconnected():
raise HTTPException(status_code=499, detail="Client closed connection")

# Run OCR
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, engine.ocr, img, "openai_image.jpg")

if await raw_request.is_disconnected():
raise HTTPException(status_code=499, detail="Client closed connection")

# Format response
return ChatCompletionResponse(
id=f"chatcmpl-{uuid.uuid4()}",
Expand Down Expand Up @@ -289,12 +308,17 @@ async def _get_image_from_request(request: Request, file: Optional[UploadFile]):

Includes security checks for body size, file size, and image dimensions.
"""
from starlette.requests import ClientDisconnect
img = None
filename = "image.jpg"
try:
if file:
# Handle multipart/form-data
contents = await file.read(MAX_IMAGE_SIZE + 1)
try:
contents = await file.read(MAX_IMAGE_SIZE + 1)
except ClientDisconnect:
logger.warning("Client disconnected during file upload")
raise HTTPException(status_code=499, detail="Client closed connection")
if len(contents) > MAX_IMAGE_SIZE:
raise HTTPException(status_code=413, detail="File too large")
img = Image.open(io.BytesIO(contents))
Expand All @@ -306,10 +330,14 @@ async def _get_image_from_request(request: Request, file: Optional[UploadFile]):
raise HTTPException(status_code=413, detail="Request body too large")

body_bytes = b""
async for chunk in request.stream():
body_bytes += chunk
if len(body_bytes) > MAX_BODY_SIZE:
raise HTTPException(status_code=413, detail="Request body too large")
try:
async for chunk in request.stream():
body_bytes += chunk
if len(body_bytes) > MAX_BODY_SIZE:
raise HTTPException(status_code=413, detail="Request body too large")
except ClientDisconnect:
logger.warning("Client disconnected during body streaming")
raise HTTPException(status_code=499, detail="Client closed connection")

if not body_bytes:
raise HTTPException(status_code=400, detail="Empty request body")
Expand Down
2 changes: 1 addition & 1 deletion src/core/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def _get_recognizer(self, weights_path: str):

def shutdown(self):
"""Shuts down the internal thread pool."""
self.executor.shutdown()
self.executor.shutdown(wait=False, cancel_futures=True)

def _process_cascade(self, alllineobj: List[RecogLine], is_cascade: bool = True) -> List[str]:
"""
Expand Down
Loading
Loading