diff --git a/Containerfile b/Containerfile
index 37f5a83..28468c0 100644
--- a/Containerfile
+++ b/Containerfile
@@ -10,7 +10,8 @@ FROM rocm/pytorch:rocm7.2.4_ubuntu24.04_py3.12_pytorch_release_2.9.1
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl ca-certificates tini procps git gcc g++ zlib1g-dev libjpeg-dev \
- libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0 && \
+ libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0 \
+ poppler-utils pandoc && \
apt-get remove -y python3-blinker || true && \
rm -rf /var/lib/apt/lists/*
@@ -38,34 +39,42 @@ RUN mkdir -p /app/marker/static/fonts && \
chown marker:marker /app/marker/static/fonts/GoNotoCurrent-Regular.ttf
# ---- final image ----
-COPY app.py /app/
+COPY app.py /app/
+COPY request_store.py /app/
+COPY deepseek_ocr.py /app/
COPY gunicorn.conf.py /app/
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
-ENV \
- HOME=/app \
- PYTHONUNBUFFERED=1 \
- PYTHONDONTWRITEBYTECODE=1 \
- MARKER_OUTPUT_DIR=/app/conversion_results \
- UPLOAD_DIR=/app/uploads \
- OLLAMA_HOST=http://localhost:11435 \
- DEESEEK_OCR_MODEL=deepseek-ocr \
- AMD_COMPUTE=true \
- TORCH_DEVICE= \
- MODEL_DTYPE=float32 \
- PORT=8000 \
- HOST=0.0.0.0 \
- LLM_SERVICE=marker.services.openai.OpenAIService \
- USE_LLM=false \
- OPENAI_BASE_URL=http://localhost:11435/v1 \
- OPENAI_MODEL= \
- GUNICORN_WORKERS=1
+ENV HOME=/app
+ENV PYTHONUNBUFFERED=1
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV MARKER_OUTPUT_DIR=/app/conversion_results
+ENV UPLOAD_DIR=/app/uploads
+ENV RESULTS_DIR=/app/conversion_results
+ENV SELF_URL=http://localhost:8001
+ENV OLLAMA_HOST=http://localhost:11435
+ENV DEESEEK_OCR_MODEL=deepseek-ocr
+ENV AMD_COMPUTE=true
+ENV TORCH_DEVICE=
+ENV MODEL_DTYPE=float32
+ENV PORT=8001
+ENV HOST=0.0.0.0
+ENV LLM_SERVICE=marker.services.openai.OpenAIService
+ENV USE_LLM=false
+ENV OPENAI_BASE_URL=http://localhost:11435/v1
+ENV OPENAI_MODEL=
+ENV OCR_BACKEND=marker
+ENV DEEPSEEK_OLLAMA_HOST=http://localhost:11434
+ENV DEEPSEEK_OCR_MODEL=deepseek-ocr:latest
+ENV DEEPSEEK_OCR_PROMPT="<|grounding|>Free OCR."
+ENV API_KEY=
+ENV GUNICORN_WORKERS=3
USER marker
-EXPOSE 8000
+EXPOSE 8001
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["gunicorn", "-c", "gunicorn.conf.py", "app:app_instance"]
diff --git a/app.py b/app.py
index b16f7de..9f27aa0 100644
--- a/app.py
+++ b/app.py
@@ -1,19 +1,22 @@
from __future__ import annotations
import base64
+import functools
import io
import json
import os
import tempfile
+import threading
+import time
import traceback
import uuid
+from pathlib import Path
from typing import Any, Dict, Optional
import torch
from flask import Flask, jsonify, request, Response
import PIL.Image
-# Marker imports
from marker.config.parser import ConfigParser
from marker.converters.pdf import PdfConverter
from marker.models import create_model_dict
@@ -21,32 +24,36 @@ from marker.output import text_from_rendered
from marker.providers.registry import load_extensions
from marker.settings import settings as marker_settings
-# Ollama / OCR fallback configuration
+import request_store
+import deepseek_ocr as dsocr
+
+# ── Env configuration ────────────────────────────────────────────────
+API_KEY = os.environ.get("API_KEY", "")
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11435")
DEESEEK_OCR_MODEL = os.environ.get("DEESEEK_OCR_MODEL", "deepseek-ocr")
-
+OCR_BACKEND = os.environ.get("OCR_BACKEND", "marker") # "marker" | "deepseek"
AMD_COMPUTE = os.environ.get("AMD_COMPUTE", "false").lower() in ("true", "1", "yes")
TORCH_DEVICE = os.environ.get("TORCH_DEVICE", "")
MODEL_DTYPE = os.environ.get("MODEL_DTYPE", "float32")
-_marker_dict: Optional[Dict[str, Any]] = None
-
-# Collect supported file extensions
-SUPPORTED_EXTENSIONS = set()
-for provider_type in ("image", "pdf", "epub", "doc", "xls", "ppt"):
- SUPPORTED_EXTENSIONS.update(load_extensions(provider_type))
-SUPPORTED_DISPLAY = sorted({ext.lstrip(".") for ext in SUPPORTED_EXTENSIONS})
-
-# Env-var overrides for default LLM service
DEFAULT_LLM_SERVICE = os.environ.get("LLM_SERVICE", "marker.services.ollama.OllamaService")
DEFAULT_USE_LLM = os.environ.get("USE_LLM", "false").lower() in ("true", "1", "yes")
DEFAULT_OPENAI_BASE_URL = os.environ.get("OPENAI_BASE_URL", "")
DEFAULT_OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
DEFAULT_OPENAI_MODEL = os.environ.get("OPENAI_MODEL", "")
+SUPPORTED_EXTENSIONS = set()
+for provider_type in ("image", "pdf", "epub", "doc", "xls", "ppt"):
+ SUPPORTED_EXTENSIONS.update(load_extensions(provider_type))
+SUPPORTED_DISPLAY = sorted({ext.lstrip(".") for ext in SUPPORTED_EXTENSIONS})
+
+_marker_dict: Optional[Dict[str, Any]] = None
+_lock = threading.Lock()
+
+BASE_URL = os.environ.get("SELF_URL", "http://localhost:8001")
+
def _configure_env():
- """Apply AMD GPU / environment overrides before torch loads."""
if AMD_COMPUTE and not TORCH_DEVICE:
os.environ["TORCH_DEVICE"] = "cuda"
os.environ["TORCH_DEVICE_MODEL"] = "cuda"
@@ -60,17 +67,68 @@ def _configure_env():
def get_model_dict() -> Dict[str, Any]:
global _marker_dict
if _marker_dict is None:
- _marker_dict = create_model_dict()
+ with _lock:
+ if _marker_dict is None:
+ _marker_dict = create_model_dict()
return _marker_dict
+# ── Auth decorator ───────────────────────────────────────────────────
+def require_api_key(f):
+ @functools.wraps(f)
+ def wrapper(*args, **kwargs):
+ if API_KEY:
+ header_key = request.headers.get("X-API-Key", "")
+ auth_header = request.headers.get("Authorization", "")
+ if auth_header.startswith("Bearer "):
+ header_key = header_key or auth_header[7:]
+ if header_key != API_KEY:
+ return jsonify({"success": False, "error": "Invalid or missing API key"}), 403
+ return f(*args, **kwargs)
+ return wrapper
+
+
+# ── Option parsing ───────────────────────────────────────────────────
+def parse_options(src: dict) -> dict:
+ bool_fields = [
+ "paginate_output", "force_ocr", "disable_image_extraction",
+ "use_llm", "redo_inline_math", "strip_existing_ocr", "debug",
+ "paginate", "skip_cache", "save_checkpoint",
+ "disable_image_captions", "fence_synthetic_captions",
+ "token_efficient_markdown", "add_block_ids",
+ "include_markdown_in_chunks", "word_bboxes",
+ ]
+ str_fields = [
+ "page_range", "output_format", "processors", "config_json",
+ "converter_cls", "llm_service", "block_correction_prompt",
+ "langs", "mode", "file_url", "additional_config",
+ "extras", "pipeline_id", "webhook_url", "processing_location",
+ "page_schema", "segmentation_schema", "model_override_settings",
+ ]
+ int_fields = ["max_pages", "workflowstepdata_id"]
+ opts = {}
+ for k in bool_fields:
+ v = src.get(k)
+ if isinstance(v, str):
+ opts[k] = v.lower() in ("true", "1", "yes")
+ elif v is not None:
+ opts[k] = bool(v)
+ for k in str_fields:
+ v = src.get(k)
+ if v is not None and v != "":
+ opts[k] = str(v)
+ for k in int_fields:
+ v = src.get(k)
+ if v is not None:
+ opts[k] = int(v)
+ return opts
+
+
def build_options(**extra: Any) -> Dict[str, Any]:
opts: Dict[str, Any] = {}
for k, v in extra.items():
if v is not None:
opts[k] = v
-
- # Ensure defaults are set
opts.setdefault("output_format", "markdown")
opts.setdefault("force_ocr", False)
opts.setdefault("paginate_output", False)
@@ -79,8 +137,6 @@ def build_options(**extra: Any) -> Dict[str, Any]:
opts.setdefault("disable_image_extraction", False)
opts.setdefault("output_dir", marker_settings.OUTPUT_DIR)
opts.setdefault("llm_service", DEFAULT_LLM_SERVICE)
-
- # Pass default LLM settings from environment if not explicitly provided
if DEFAULT_OPENAI_BASE_URL:
opts.setdefault("openai_base_url", DEFAULT_OPENAI_BASE_URL)
if DEFAULT_OPENAI_API_KEY:
@@ -91,32 +147,25 @@ def build_options(**extra: Any) -> Dict[str, Any]:
opts.setdefault("ollama_base_url", OLLAMA_HOST)
if DEESEEK_OCR_MODEL:
opts.setdefault("ollama_model", DEESEEK_OCR_MODEL)
-
config_parser = ConfigParser(opts)
return config_parser.generate_config_dict()
+# ── Conversion core ──────────────────────────────────────────────────
def convert_file_bytes(raw_bytes: bytes, filename: str, **opts: Any) -> dict:
- """Convert in-memory file bytes to markdown (or other format)."""
-
tmp = tempfile.NamedTemporaryFile(delete=False, suffix="." + filename.rsplit(".", 1)[-1] if "." in filename else "")
try:
tmp.write(raw_bytes)
tmp.close()
filepath = tmp.name
-
config_dict = build_options(**opts)
config_dict["disable_tqdm"] = True
-
model_dict = get_model_dict()
-
- # Build parsed options, preserving server default LLM config when client omits it
parsed_opts = {k: v for k, v in opts.items() if v is not None}
- for key in ("llm_service", "openai_base_url", "openai_api_key", "openai_model"):
+ for key in ("output_format", "llm_service", "openai_base_url", "openai_api_key", "openai_model"):
if key not in parsed_opts and key in config_dict:
parsed_opts[key] = config_dict[key]
parsed = ConfigParser(parsed_opts)
-
converter = PdfConverter(
config=config_dict,
artifact_dict=model_dict,
@@ -126,7 +175,6 @@ def convert_file_bytes(raw_bytes: bytes, filename: str, **opts: Any) -> dict:
)
rendered = converter(filepath)
text, _, images = text_from_rendered(rendered)
-
except Exception as exc:
traceback.print_exc()
return {"success": False, "error": str(exc)}
@@ -134,116 +182,256 @@ def convert_file_bytes(raw_bytes: bytes, filename: str, **opts: Any) -> dict:
tmp.close()
if os.path.exists(tmp.name):
os.remove(tmp.name)
-
encoded_images: Dict[str, str] = {}
img_fmt = marker_settings.OUTPUT_IMAGE_FORMAT
for k, img in images.items():
buf = io.BytesIO()
img.save(buf, format=img_fmt)
encoded_images[k] = base64.b64encode(buf.getvalue()).decode("utf-8")
-
return {
"format": opts.get("output_format", "markdown"),
"output": text,
"images_b64": encoded_images,
- "metadata": rendered.metadata if 'rendered' in dir() else {},
+ "metadata": rendered.metadata if hasattr(rendered, 'metadata') else {},
"success": True,
}
+def convert_with_ocr_backend(raw_bytes: bytes, filename: str, **opts: Any) -> dict:
+ if OCR_BACKEND == "deepseek":
+ page_range = opts.get("page_range")
+ max_pages = opts.get("max_pages")
+ ocr_result = dsocr.ocr_pdf(raw_bytes, max_pages=max_pages, page_range=page_range)
+ return {
+ "format": opts.get("output_format", "markdown"),
+ "output": ocr_result.get("text", ""),
+ "images_b64": {},
+ "metadata": {"page_count": ocr_result.get("page_count", 0), "ocr_backend": "deepseek"},
+ "success": ocr_result.get("success", False),
+ "page_count": ocr_result.get("page_count", 0),
+ }
+ return convert_file_bytes(raw_bytes, filename, **opts)
+
+
+# ── Background worker ────────────────────────────────────────────────
+# The job queue (submit_job, _job_queue, _job_cond) lives in request_store.py
+# so both the Flask app and gunicorn's post_fork hook can access it.
+
+
+# ── Extraction / Segmentation helpers ────────────────────────────────
+def _apply_extraction_schema(markdown_text: str, schema: dict) -> dict:
+ result = {}
+ for field_name, field_def in schema.items():
+ if isinstance(field_def, dict):
+ desc = field_def.get("description", "")
+ ftype = field_def.get("type", "string")
+ result[field_name] = _extract_field(markdown_text, field_name, desc, ftype)
+ else:
+ result[field_name] = _extract_field(markdown_text, field_name, str(field_def), "string")
+ return result
+
+
+def _extract_field(text: str, name: str, desc: str, ftype: str) -> Any:
+ import re
+ patterns = [
+ re.compile(rf"(?:{re.escape(name)}\s*:?\s*(.*?))(?:\n\n|\Z)", re.IGNORECASE | re.DOTALL),
+ re.compile(rf"(?:{re.escape(desc)}\s*:?\s*(.*?))(?:\n\n|\Z)", re.IGNORECASE | re.DOTALL),
+ ]
+ for pat in patterns:
+ m = pat.search(text)
+ if m:
+ val = m.group(1).strip()
+ if ftype == "number":
+ try:
+ return float(val)
+ except ValueError:
+ return val
+ return val
+ return None
+
+
+def _apply_segmentation_schema(markdown_text: str, schema: dict) -> list[dict]:
+ segments = []
+ for seg_name, seg_desc in schema.items():
+ if isinstance(seg_desc, str):
+ desc_text = seg_desc
+ elif isinstance(seg_desc, dict):
+ desc_text = seg_desc.get("description", seg_name)
+ else:
+ desc_text = str(seg_desc)
+ import re
+ pattern = re.compile(rf"(?:{re.escape(desc_text)})(.*?)(?=(?:{'|'.join(re.escape(str(v)) if isinstance(v, str) else re.escape(str(v.get('description', ''))) for v in schema.values())})|\Z)", re.IGNORECASE | re.DOTALL)
+ m = pattern.search(markdown_text)
+ if m:
+ segments.append({"name": seg_name, "description": desc_text, "content": m.group(1).strip()})
+ return segments
+
+
+def _extract_tables(conv_result: dict) -> list[dict]:
+ output = conv_result.get("output", "")
+ if conv_result.get("format") == "json":
+ try:
+ data = json.loads(output) if isinstance(output, str) else output
+ if isinstance(data, dict):
+ return data.get("tables", [])
+ if isinstance(data, list):
+ return [item for item in data if "table" in str(item).lower() or "rows" in str(item)]
+ except (json.JSONDecodeError, TypeError):
+ pass
+ import re
+ tables = []
+ table_pattern = re.compile(r"\|(.+)\|[\s\S]*?(?=\n\n|\Z)", re.MULTILINE)
+ for i, m in enumerate(table_pattern.finditer(output)):
+ tables.append({"index": i, "table": m.group(0).strip()})
+ return tables
+
+
+# ── File management ──────────────────────────────────────────────────
+def _file_storage_path() -> Path:
+ p = Path(os.environ.get("UPLOAD_DIR", "/app/uploads"))
+ p.mkdir(parents=True, exist_ok=True)
+ return p
+
+
+def _get_file_metadata(file_id: str) -> dict | None:
+ meta_path = _file_storage_path() / file_id / "metadata.json"
+ if not meta_path.exists():
+ return None
+ return json.loads(meta_path.read_text())
+
+
+def _submit_and_poll(endpoint: str, raw_bytes: bytes, filename: str, form: dict | None = None, opts: dict | None = None) -> Response:
+ if opts is None:
+ opts = parse_options(form or {})
+ request_id = request_store.submit_job(endpoint, raw_bytes, filename, opts)
+ check_url = f"{BASE_URL}/api/v1/{endpoint}/{request_id}"
+ return jsonify({
+ "success": True,
+ "request_id": request_id,
+ "request_check_url": check_url,
+ })
+
+
+def _poll_result(endpoint: str, request_id: str) -> Response:
+ entry = request_store.get_request(request_id)
+ if entry is None:
+ return jsonify({"success": False, "error": "Request not found"}), 404
+ if entry["status"] == "processing":
+ return jsonify({"status": "processing"})
+ result = request_store.get_result(request_id)
+ if result is None:
+ return jsonify({"status": "complete", "success": False, "error": "Result not found"}), 500
+ return jsonify({
+ "status": "complete",
+ "success": result.get("success", False),
+ "error": result.get("error"),
+ **{k: v for k, v in result.items() if k not in ("success", "error")},
+ })
+
+
+# ── Flask app factory ────────────────────────────────────────────────
def create_app() -> Flask:
app = Flask(__name__)
- # ---- docs page ----
+ # Start cleanup thread for expired results
+ request_store.start_cleanup_thread()
+
HTML_DOCS = r"""
marker-api
-
+th{background:#f0f0f0}
+.alert{padding:12px 16px;border-radius:4px;margin:16px 0}
+.alert-deprecated{background:#fff3cd;border:1px solid #ffc107}
+.alert-info{background:#d1ecf1;border:1px solid #bee5eb}
+code{background:#f4f4f4;padding:2px 6px;border-radius:3px}
+
marker-api
-Convert PDFs, EPUBs, DOCX, XLSX, PPTX, HTML, and images to Markdown.
-Endpoints
+Datalab-compatible document conversion API. Convert PDFs, images, documents to Markdown/HTML/JSON.
+
+OCR Backend: {ocr_backend}
+
+Datalab-compatible Endpoints
-| Endpoint | Method | Description |
-| / | GET | This documentation page |
-| /health | GET | Health check with configuration |
-| /marker | POST | Convert a file (sync, returns result) |
-| /v1/conversions | POST | Convert a file (async-style, returns directly) |
-| /v1/files/convert | POST | Convert a file via JSON body with base64 |
+| Method | Path | Description |
+| POST | /api/v1/convert | Convert document (async, returns request_id + check_url) |
+| GET | /api/v1/convert/<id> | Poll conversion result |
+| POST | /api/v1/extract | Structured extraction with JSON schema |
+| GET | /api/v1/extract/<id> | Poll extraction result |
+| POST | /api/v1/segment | Document segmentation with JSON schema |
+| GET | /api/v1/segment/<id> | Poll segmentation result |
+| POST | /api/v1/ocr | [DEPRECATED] OCR-only extraction |
+| GET | /api/v1/ocr/<id> | Poll OCR result |
+| POST | /api/v1/table_rec | [DEPRECATED] Table recognition |
+| GET | /api/v1/table_rec/<id> | Poll table recognition result |
+| POST | /api/v1/marker | [DEPRECATED] Sync conversion (returns directly) |
+| POST | /api/v1/create-document | Create DOCX from markdown |
-POST /marker (multipart/form-data)
-curl -X POST http://localhost:8000/marker \
- -F "file=@document.pdf" \
- -F "force_ocr=false" \
- -F "page_range=0,5-10" \
- -F "output_format=markdown"
-Returns the converted content directly as a file download.
+File Management
+
+| Method | Path | Description |
+| POST | /api/v1/files/upload | Request file upload URL |
+| GET | /api/v1/files | List uploaded files |
+| GET | /api/v1/files/<id> | Get file metadata |
+| GET | /api/v1/files/<id>/download | Get file download URL |
+| DELETE | /api/v1/files/<id> | Delete file |
+
-POST /marker (application/json)
-curl -X POST http://localhost:8000/marker \
- -H "Content-Type: application/json" \
- -d '{
- "file_b64": "base64-encoded-file-content",
- "filename": "document.pdf",
- "output_format": "markdown",
- "force_ocr": false
- }'
+Legacy Endpoints
+
+| Method | Path | Description |
+| POST | /marker | Sync conversion (original) |
+| POST | /v1/conversions | Sync conversion (original) |
+| POST | /v1/files/convert | Sync conversion via base64 JSON |
+
-POST /v1/files/convert (application/json)
-curl -X POST http://localhost:8000/v1/files/convert \
- -H "Content-Type: application/json" \
- -d '{
- "file_b64": "base64-encoded-file-content",
- "filename": "document.pdf",
- "output_format": "json",
- "page_range": "0,5-10"
- }'
+Authentication
+All /api/v1/* endpoints accept X-API-Key header (or Authorization: Bearer).
-Options
+POST /api/v1/convert
+curl -X POST http://localhost:8001/api/v1/convert \\
+ -H "X-API-Key: YOUR_KEY" \\
+ -F "file=@document.pdf" \\
+ -F "output_format=markdown" \\
+ -F "mode=balanced"
+
+Poll for results
+curl http://localhost:8001/api/v1/convert/REQUEST_ID \\
+ -H "X-API-Key: YOUR_KEY"
+
+Parameters
| Parameter | Type | Default | Description |
-| file / file_b64 | file / string | required | The file to convert |
-| force_ocr | bool | false | Force OCR on all pages |
-| paginate_output | bool | false | Separate pages with horizontal rules |
-| output_format | string | markdown | markdown, json, html, chunks |
-| page_range | string | all | Comma-separated page numbers/ranges: "0,5-10" |
-| disable_image_extraction | bool | false | Disable extraction of embedded images |
-| processors | string | auto | Comma-separated full module paths |
-| config_json | string | none | Path to JSON file with additional config |
-| converter_cls | string | auto-detected | Full module path of converter class |
-| use_llm | bool | false | Use an LLM to improve accuracy (requires LLM service) |
-| llm_service | string | marker.services. ollama.OllamaService | LLM service class path: gemini, vertex, claude, openai, azure_openai, ollama |
-| block_correction_prompt | string | none | Custom prompt for LLM block correction |
-| redo_inline_math | bool | false | Re-process inline math with LLM |
-| strip_existing_ocr | bool | false | Remove all existing OCR text and re-OCR |
-| debug | bool | false | Enable debug mode with additional logging |
+| file | file | required | Document to convert |
+| output_format | string | markdown | markdown, html, json, chunks |
+| mode | string | fast | fast, balanced, accurate |
+| page_range | string | all | e.g. "0,5-10" |
+| max_pages | int | all | Max pages to process |
+| paginate | bool | false | Add page delimiters |
-Supported Formats
-{formats}
-
-Environment Variables
+Environment
| Variable | Default | Description |
-| OLLAMA_HOST | http://10.0.1.127:11434 | Ollama instance for OCR fallback |
-| DEESEEK_OCR_MODEL | deepseek-ocr | OCR model name in Ollama |
-| AMD_COMPUTE | false | Enable AMD ROCm GPU compute (set to "true") |
-| TORCH_DEVICE | auto | PyTorch device: rocm, cuda, cpu |
-| MODEL_DTYPE | float32 | Model dtype: float32, bfloat16 |
-| PORT | 8000 | Listening port |
-| HOST | 0.0.0.0 | Listening host |
-| LLM_SERVICE | marker.services.ollama.OllamaService | Default LLM service class for use_llm |
-| USE_LLM | false | Default use_llm flag (true/false) |
-| OPENAI_BASE_URL | | Base URL for OpenAI-compatible LLM service |
-| OPENAI_API_KEY | | API key for OpenAI-compatible LLM service |
-| OPENAI_MODEL | | Model name for OpenAI-compatible LLM service |
+| OCR_BACKEND | marker | "marker" (marker-pdf) or "deepseek" (deepseek-ocr via ollama) |
+| API_KEY | (none) | API key for X-API-Key auth (empty = disabled) |
+| DEEPSEEK_OLLAMA_HOST | http://localhost:11434 | Ollama host for deepseek-ocr |
+| DEEPSEEK_OCR_MODEL | deepseek-ocr:latest | Model name for deepseek-ocr |
+| OLLAMA_HOST | http://localhost:11435 | Ollama host for LLM correction |
+| DEESEEK_OCR_MODEL | deepseek-ocr | LLM correction model name |
+| TORCH_DEVICE | auto | cpu, cuda, rocm |
+| PORT | 8001 | Listening port |
+| GUNICORN_WORKERS | 3 | Number of workers |
+Supported Formats
+{formats}
"""
+ # ── Legacy /info endpoints ───────────────────────────────────────
@app.route("/")
def docs():
- body = HTML_DOCS.replace("{formats}", ", ".join(SUPPORTED_DISPLAY))
+ body = HTML_DOCS.replace("{ocr_backend}", OCR_BACKEND).replace("{formats}", ", ".join(SUPPORTED_DISPLAY))
return body
@app.route("/health")
@@ -251,11 +439,11 @@ th{background:#f0f0f0}
try:
device = TORCH_DEVICE or (marker_settings.TORCH_DEVICE_MODEL if hasattr(marker_settings, 'TORCH_DEVICE_MODEL') else 'auto')
except Exception:
- device = 'unknown'
+ device = "unknown"
return jsonify({
"status": "ok",
"ollama": OLLAMA_HOST,
- "ocr_model": DEESEEK_OCR_MODEL,
+ "ocr_backend": OCR_BACKEND,
"amd_compute": AMD_COMPUTE,
"torch_device": device,
"supported_formats": SUPPORTED_DISPLAY,
@@ -266,35 +454,19 @@ th{background:#f0f0f0}
"openai_model": DEFAULT_OPENAI_MODEL or None,
})
+ # ── Existing original endpoints (unchanged behavior) ────────────
@app.route("/marker", methods=["POST"])
def convert_sync():
if "file" in request.files:
file = request.files["file"]
filename = file.filename or "file"
raw = file.read()
-
fmt = request.form.get("output_format", "markdown")
- opts = {
- "page_range": request.form.get("page_range"),
- "paginate_output": request.form.get("paginate_output", "false").lower() == "true",
- "force_ocr": request.form.get("force_ocr", "false").lower() == "true",
- "output_format": fmt,
- "disable_image_extraction": request.form.get("disable_image_extraction", "false").lower() == "true",
- "processors": request.form.get("processors"),
- "config_json": request.form.get("config_json"),
- "converter_cls": request.form.get("converter_cls"),
- "use_llm": request.form.get("use_llm", "false").lower() == "true",
- "llm_service": request.form.get("llm_service"),
- "block_correction_prompt": request.form.get("block_correction_prompt"),
- "redo_inline_math": request.form.get("redo_inline_math", "false").lower() == "true",
- "strip_existing_ocr": request.form.get("strip_existing_ocr", "false").lower() == "true",
- "debug": request.form.get("debug", "false").lower() == "true",
- }
- result = convert_file_bytes(raw, filename, **opts)
-
+ opts = parse_options(request.form)
+ opts["output_format"] = fmt
+ result = convert_with_ocr_backend(raw, filename, **opts)
if not result["success"]:
return jsonify(result), 500
-
if fmt == "markdown":
return Response(
result["output"],
@@ -302,65 +474,31 @@ th{background:#f0f0f0}
headers={"Content-Disposition": f'attachment; filename="{filename.rsplit(".", 1)[0]}.md"'},
)
return jsonify(result)
-
if request.is_json:
data = request.get_json()
if "file_b64" not in data or not data.get("filename"):
return jsonify({"error": "JSON body must include 'file_b64' and 'filename'"}), 400
raw = base64.b64decode(data["file_b64"])
fmt = data.get("output_format", "markdown")
- opts = {
- "page_range": data.get("page_range"),
- "paginate_output": data.get("paginate_output", False),
- "force_ocr": data.get("force_ocr", False),
- "output_format": fmt,
- "disable_image_extraction": data.get("disable_image_extraction", False),
- "processors": data.get("processors"),
- "config_json": data.get("config_json"),
- "converter_cls": data.get("converter_cls"),
- "use_llm": data.get("use_llm", False),
- "llm_service": data.get("llm_service"),
- "block_correction_prompt": data.get("block_correction_prompt"),
- "redo_inline_math": data.get("redo_inline_math", False),
- "strip_existing_ocr": data.get("strip_existing_ocr", False),
- "debug": data.get("debug", False),
- }
- result = convert_file_bytes(raw, data["filename"], **opts)
+ opts = parse_options(data)
+ opts["output_format"] = fmt
+ result = convert_with_ocr_backend(raw, data["filename"], **opts)
return jsonify(result)
-
return jsonify({"error": "No file provided. Use multipart/form-data or JSON with 'file_b64'."}), 400
@app.route("/v1/conversions", methods=["POST"])
def convert_async_style():
- """Convert a file, returns the result directly (async-style naming for API compatibility)."""
if request.is_json:
data = request.get_json()
if "file_b64" not in data or not data.get("filename"):
return jsonify({"error": "JSON body must include 'file_b64' and 'filename'"}), 400
-
raw = base64.b64decode(data["file_b64"])
fmt = data.get("output_format", "markdown")
- opts = {
- "page_range": data.get("page_range"),
- "paginate_output": data.get("paginate_output", False),
- "force_ocr": data.get("force_ocr", False),
- "output_format": fmt,
- "disable_image_extraction": data.get("disable_image_extraction", False),
- "processors": data.get("processors"),
- "config_json": data.get("config_json"),
- "converter_cls": data.get("converter_cls"),
- "use_llm": data.get("use_llm", False),
- "llm_service": data.get("llm_service"),
- "block_correction_prompt": data.get("block_correction_prompt"),
- "redo_inline_math": data.get("redo_inline_math", False),
- "strip_existing_ocr": data.get("strip_existing_ocr", False),
- "debug": data.get("debug", False),
- }
- result = convert_file_bytes(raw, data["filename"], **opts)
-
+ opts = parse_options(data)
+ opts["output_format"] = fmt
+ result = convert_with_ocr_backend(raw, data["filename"], **opts)
if not result["success"]:
return jsonify(result), 500
-
return jsonify({
"id": str(uuid.uuid4()),
"filename": data["filename"],
@@ -370,33 +508,16 @@ th{background:#f0f0f0}
"images_b64": result.get("images_b64", {}),
"metadata": result.get("metadata", {}),
})
-
if "file" in request.files:
file = request.files["file"]
filename = file.filename or "file"
raw = file.read()
fmt = request.form.get("output_format", "markdown")
- opts = {
- "page_range": request.form.get("page_range"),
- "paginate_output": request.form.get("paginate_output", "false").lower() == "true",
- "force_ocr": request.form.get("force_ocr", "false").lower() == "true",
- "output_format": fmt,
- "disable_image_extraction": request.form.get("disable_image_extraction", "false").lower() == "true",
- "processors": request.form.get("processors"),
- "config_json": request.form.get("config_json"),
- "converter_cls": request.form.get("converter_cls"),
- "use_llm": request.form.get("use_llm", "false").lower() == "true",
- "llm_service": request.form.get("llm_service"),
- "block_correction_prompt": request.form.get("block_correction_prompt"),
- "redo_inline_math": request.form.get("redo_inline_math", "false").lower() == "true",
- "strip_existing_ocr": request.form.get("strip_existing_ocr", "false").lower() == "true",
- "debug": request.form.get("debug", "false").lower() == "true",
- }
- result = convert_file_bytes(raw, filename, **opts)
-
+ opts = parse_options(request.form)
+ opts["output_format"] = fmt
+ result = convert_with_ocr_backend(raw, filename, **opts)
if not result["success"]:
return jsonify(result), 500
-
return jsonify({
"id": str(uuid.uuid4()),
"filename": filename,
@@ -406,74 +527,377 @@ th{background:#f0f0f0}
"images_b64": result.get("images_b64", {}),
"metadata": result.get("metadata", {}),
})
-
return jsonify({"error": "No file provided."}), 400
@app.route("/v1/files/convert", methods=["POST"])
def convert_files():
- """Convert a file via JSON body with base64."""
if "file" in request.files:
file = request.files["file"]
filename = file.filename or "file"
raw = file.read()
fmt = request.form.get("output_format", "markdown")
- opts = {
- "page_range": request.form.get("page_range"),
- "paginate_output": request.form.get("paginate_output", False),
- "force_ocr": request.form.get("force_ocr", False),
- "output_format": fmt,
- "disable_image_extraction": request.form.get("disable_image_extraction", False),
- "processors": request.form.get("processors"),
- "config_json": request.form.get("config_json"),
- "converter_cls": request.form.get("converter_cls"),
- "use_llm": request.form.get("use_llm", "false").lower() == "true",
- "llm_service": request.form.get("llm_service"),
- "block_correction_prompt": request.form.get("block_correction_prompt"),
- "redo_inline_math": request.form.get("redo_inline_math", "false").lower() == "true",
- "strip_existing_ocr": request.form.get("strip_existing_ocr", "false").lower() == "true",
- "debug": request.form.get("debug", "false").lower() == "true",
- }
- result = convert_file_bytes(raw, filename, **opts)
+ opts = parse_options(request.form)
+ opts["output_format"] = fmt
+ result = convert_with_ocr_backend(raw, filename, **opts)
+ filename_var = filename
elif request.is_json:
data = request.get_json()
if "file_b64" not in data or not data.get("filename"):
return jsonify({"error": "JSON body must include 'file_b64' and 'filename'"}), 400
-
raw = base64.b64decode(data["file_b64"])
fmt = data.get("output_format", "markdown")
- opts = {
- "page_range": data.get("page_range"),
- "paginate_output": data.get("paginate_output", False),
- "force_ocr": data.get("force_ocr", False),
- "output_format": fmt,
- "disable_image_extraction": data.get("disable_image_extraction", False),
- "processors": data.get("processors"),
- "config_json": data.get("config_json"),
- "converter_cls": data.get("converter_cls"),
- "use_llm": data.get("use_llm", False),
- "llm_service": data.get("llm_service"),
- "block_correction_prompt": data.get("block_correction_prompt"),
- "redo_inline_math": data.get("redo_inline_math", False),
- "strip_existing_ocr": data.get("strip_existing_ocr", False),
- "debug": data.get("debug", False),
- }
- result = convert_file_bytes(raw, data["filename"], **opts)
- fmt = fmt
+ opts = parse_options(data)
+ opts["output_format"] = fmt
+ result = convert_with_ocr_backend(raw, data["filename"], **opts)
+ filename_var = data["filename"]
else:
return jsonify({"error": "No file provided."}), 400
-
if not result["success"]:
return jsonify(result), 500
-
- _filename = data.get("filename") if request.is_json else filename
return jsonify({
- "filename": _filename,
+ "filename": filename_var,
"format": fmt,
"output": result["output"],
"images_b64": result.get("images_b64", {}),
"metadata": result.get("metadata", {}),
})
+ # ── New Datalab-compatible endpoints ─────────────────────────────
+
+ # POST /api/v1/convert — async submit
+ @app.route("/api/v1/convert", methods=["POST"])
+ @require_api_key
+ def api_convert_submit():
+ if "file" in request.files:
+ file = request.files["file"]
+ raw = file.read()
+ filename = file.filename or "file"
+ elif request.is_json:
+ data = request.get_json()
+ if "file_b64" in data:
+ raw = base64.b64decode(data["file_b64"])
+ filename = data.get("filename", "file")
+ elif "file_url" in data:
+ import requests as req
+ resp = req.get(data["file_url"], timeout=120)
+ resp.raise_for_status()
+ raw = resp.content
+ filename = data.get("filename", data["file_url"].rsplit("/", 1)[-1] or "file")
+ else:
+ return jsonify({"error": "Provide file, file_b64, or file_url"}), 400
+ else:
+ return jsonify({"error": "No file provided"}), 400
+ source = request.form if request.form else (request.get_json() if request.is_json else {})
+ opts = parse_options(source)
+ opts["output_format"] = source.get("output_format", "markdown")
+ return _submit_and_poll("convert", raw, filename, opts=opts)
+
+ # GET /api/v1/convert/ — poll
+ @app.route("/api/v1/convert/", methods=["GET"])
+ @require_api_key
+ def api_convert_poll(request_id: str):
+ return _poll_result("convert", request_id)
+
+ # POST /api/v1/marker — deprecated (Datalab compat: async submit-and-poll)
+ @app.route("/api/v1/marker", methods=["POST"])
+ @require_api_key
+ def api_marker_deprecated():
+ if "file" in request.files:
+ file = request.files["file"]
+ raw = file.read()
+ filename = file.filename or "file"
+ elif request.is_json:
+ data = request.get_json()
+ if "file_b64" in data:
+ raw = base64.b64decode(data["file_b64"])
+ filename = data.get("filename", "file")
+ else:
+ return jsonify({"error": "Provide file or file_b64"}), 400
+ else:
+ return jsonify({"error": "No file provided"}), 400
+ source = request.form if request.form else (request.get_json() if request.is_json else {})
+ opts = parse_options(source)
+ opts.setdefault("output_format", "markdown")
+ resp = _submit_and_poll("convert", raw, filename, opts=opts)
+ if isinstance(resp, Response):
+ resp.headers["Warning"] = "299 marker-api \"POST /api/v1/marker is deprecated, use /api/v1/convert\""
+ return resp
+
+ # POST /api/v1/extract — structured extraction
+ @app.route("/api/v1/extract", methods=["POST"])
+ @require_api_key
+ def api_extract_submit():
+ if "file" in request.files:
+ file = request.files["file"]
+ raw = file.read()
+ filename = file.filename or "file"
+ elif request.is_json:
+ data = request.get_json()
+ if "file_b64" in data:
+ raw = base64.b64decode(data["file_b64"])
+ filename = data.get("filename", "file")
+ else:
+ return jsonify({"error": "Provide file or file_b64"}), 400
+ else:
+ return jsonify({"error": "No file provided"}), 400
+ source = request.form if request.form else (request.get_json() if request.is_json else {})
+ opts = parse_options(source)
+ opts.setdefault("output_format", "markdown")
+ return _submit_and_poll("extract", raw, filename, opts=opts)
+
+ # GET /api/v1/extract/
+ @app.route("/api/v1/extract/", methods=["GET"])
+ @require_api_key
+ def api_extract_poll(request_id: str):
+ return _poll_result("extract", request_id)
+
+ # POST /api/v1/segment — document segmentation
+ @app.route("/api/v1/segment", methods=["POST"])
+ @require_api_key
+ def api_segment_submit():
+ if "file" in request.files:
+ file = request.files["file"]
+ raw = file.read()
+ filename = file.filename or "file"
+ elif request.is_json:
+ data = request.get_json()
+ if "file_b64" in data:
+ raw = base64.b64decode(data["file_b64"])
+ filename = data.get("filename", "file")
+ else:
+ return jsonify({"error": "Provide file or file_b64"}), 400
+ else:
+ return jsonify({"error": "No file provided"}), 400
+ source = request.form if request.form else (request.get_json() if request.is_json else {})
+ opts = parse_options(source)
+ opts.setdefault("output_format", "markdown")
+ return _submit_and_poll("segment", raw, filename, opts=opts)
+
+ # GET /api/v1/segment/
+ @app.route("/api/v1/segment/", methods=["GET"])
+ @require_api_key
+ def api_segment_poll(request_id: str):
+ return _poll_result("segment", request_id)
+
+ # POST /api/v1/ocr — deprecated OCR-only endpoint
+ @app.route("/api/v1/ocr", methods=["POST"])
+ @require_api_key
+ def api_ocr_submit():
+ if "file" not in request.files:
+ return jsonify({"error": "No file provided"}), 400
+ file = request.files["file"]
+ raw = file.read()
+ filename = file.filename or "file"
+ opts = parse_options(request.form)
+ return _submit_and_poll("ocr", raw, filename, opts=opts)
+
+ # GET /api/v1/ocr/
+ @app.route("/api/v1/ocr/", methods=["GET"])
+ @require_api_key
+ def api_ocr_poll(request_id: str):
+ return _poll_result("ocr", request_id)
+
+ # POST /api/v1/table_rec — deprecated table recognition
+ @app.route("/api/v1/table_rec", methods=["POST"])
+ @require_api_key
+ def api_table_rec_submit():
+ if "file" not in request.files:
+ return jsonify({"error": "No file provided"}), 400
+ file = request.files["file"]
+ raw = file.read()
+ filename = file.filename or "file"
+ opts = parse_options(request.form)
+ return _submit_and_poll("table_rec", raw, filename, opts=opts)
+
+ # GET /api/v1/table_rec/
+ @app.route("/api/v1/table_rec/", methods=["GET"])
+ @require_api_key
+ def api_table_rec_poll(request_id: str):
+ return _poll_result("table_rec", request_id)
+
+ # POST /api/v1/create-document — create DOCX from markdown
+ @app.route("/api/v1/create-document", methods=["POST"])
+ @require_api_key
+ def api_create_document():
+ data = request.get_json() if request.is_json else {}
+ if not data or "markdown" not in data:
+ return jsonify({"error": "JSON body must include 'markdown'"}), 400
+ md = data["markdown"]
+ output_format = data.get("output_format", "docx")
+ try:
+ import subprocess
+ import base64
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f:
+ f.write(md)
+ md_path = f.name
+ out_path = md_path.replace(".md", f".{output_format}")
+ if output_format == "docx":
+ subprocess.run(["pandoc", md_path, "-o", out_path], capture_output=True, timeout=60)
+ with open(out_path, "rb") as f:
+ b64 = base64.b64encode(f.read()).decode()
+ os.unlink(out_path)
+ os.unlink(md_path)
+ return jsonify({
+ "success": True,
+ "output_format": "docx",
+ "output_base64": b64,
+ })
+ else:
+ return jsonify({"error": f"Unsupported output format: {output_format}"}), 400
+ except FileNotFoundError:
+ return jsonify({"error": "pandoc not installed, cannot create document"}), 500
+ except Exception as exc:
+ return jsonify({"success": False, "error": str(exc)}), 500
+
+ # ── File management endpoints ────────────────────────────────────
+ @app.route("/api/v1/files/upload", methods=["POST"])
+ @require_api_key
+ def api_file_upload():
+ data = request.get_json() if request.is_json else {}
+ filename = data.get("filename", "file")
+ content_type = data.get("content_type", "application/octet-stream")
+ file_id = str(uuid.uuid4())
+ file_dir = _file_storage_path() / file_id
+ file_dir.mkdir(parents=True, exist_ok=True)
+ metadata = {
+ "file_id": file_id,
+ "filename": filename,
+ "content_type": content_type,
+ "created_at": time.time(),
+ }
+ (file_dir / "metadata.json").write_text(json.dumps(metadata))
+ import secrets
+ token = secrets.token_urlsafe(32)
+ upload_url = f"{BASE_URL}/api/v1/files/{file_id}/upload/{token}"
+ (file_dir / "upload_token.txt").write_text(token)
+ return jsonify({
+ "file_id": file_id,
+ "upload_url": upload_url,
+ "reference": f"datalab://file-{file_id}",
+ })
+
+ @app.route("/api/v1/files//upload/", methods=["PUT"])
+ def api_file_upload_put(file_id: str, token: str):
+ file_dir = _file_storage_path() / file_id
+ token_path = file_dir / "upload_token.txt"
+ if not token_path.exists() or token_path.read_text().strip() != token:
+ return jsonify({"error": "Invalid upload token"}), 403
+ (file_dir / "content").write_bytes(request.data)
+ metadata_path = file_dir / "metadata.json"
+ if metadata_path.exists():
+ meta = json.loads(metadata_path.read_text())
+ meta["uploaded_at"] = time.time()
+ meta["size"] = len(request.data)
+ metadata_path.write_text(json.dumps(meta))
+ return jsonify({"success": True})
+
+ @app.route("/api/v1/files", methods=["GET"])
+ @require_api_key
+ def api_file_list():
+ storage = _file_storage_path()
+ limit = int(request.args.get("limit", 50))
+ offset = int(request.args.get("offset", 0))
+ files = []
+ for child in sorted(storage.iterdir(), reverse=True):
+ if child.is_dir():
+ meta = _get_file_metadata(child.name)
+ if meta:
+ files.append(meta)
+ return jsonify({"files": files[offset:offset + limit], "total": len(files)})
+
+ @app.route("/api/v1/files/", methods=["GET"])
+ @require_api_key
+ def api_file_get(file_id: str):
+ meta = _get_file_metadata(file_id)
+ if meta is None:
+ return jsonify({"error": "File not found"}), 404
+ return jsonify(meta)
+
+ @app.route("/api/v1/files//confirm", methods=["GET"])
+ @require_api_key
+ def api_file_confirm(file_id: str):
+ meta = _get_file_metadata(file_id)
+ if meta is None:
+ return jsonify({"error": "File not found"}), 404
+ file_dir = _file_storage_path() / file_id
+ content_path = file_dir / "content"
+ if not content_path.exists():
+ return jsonify({"error": "File content not uploaded yet"}), 400
+ return jsonify({"success": True, "file_id": file_id})
+
+ @app.route("/api/v1/files//download", methods=["GET"])
+ @require_api_key
+ def api_file_download(file_id: str):
+ meta = _get_file_metadata(file_id)
+ if meta is None:
+ return jsonify({"error": "File not found"}), 404
+ expires_in = int(request.args.get("expires_in", 3600))
+ import secrets
+ token = secrets.token_urlsafe(32)
+ file_dir = _file_storage_path() / file_id
+ (file_dir / "download_token.txt").write_text(token)
+ download_url = f"{BASE_URL}/api/v1/files/{file_id}/download/{token}?expires_in={expires_in}"
+ return jsonify({"download_url": download_url})
+
+ @app.route("/api/v1/files//download/", methods=["GET"])
+ def api_file_download_token(file_id: str, token: str):
+ file_dir = _file_storage_path() / file_id
+ token_path = file_dir / "download_token.txt"
+ if not token_path.exists() or token_path.read_text().strip() != token:
+ return jsonify({"error": "Invalid download token"}), 403
+ content_path = file_dir / "content"
+ if not content_path.exists():
+ return jsonify({"error": "File content not found"}), 404
+ meta = _get_file_metadata(file_id) or {}
+ return Response(
+ content_path.read_bytes(),
+ mimetype=meta.get("content_type", "application/octet-stream"),
+ headers={"Content-Disposition": f'attachment; filename="{meta.get("filename", "file")}"'},
+ )
+
+ @app.route("/api/v1/files/", methods=["DELETE"])
+ @require_api_key
+ def api_file_delete(file_id: str):
+ file_dir = _file_storage_path() / file_id
+ if file_dir.exists():
+ import shutil
+ shutil.rmtree(str(file_dir))
+ return jsonify({"success": True})
+
+ # ── Thumbnails ───────────────────────────────────────────────────
+ @app.route("/api/v1/thumbnails/", methods=["GET"])
+ @require_api_key
+ def api_thumbnails(lookup_key: str):
+ thumb_width = int(request.args.get("thumb_width", 300))
+ page_range = request.args.get("page_range")
+ entry = request_store.get_request(lookup_key)
+ if entry is None:
+ return jsonify({"success": False, "error": "Request not found"}), 404
+ result = request_store.get_result(lookup_key)
+ if result is None:
+ return jsonify({"success": False, "error": "No result found"}), 404
+ metadata = result.get("metadata", {})
+ page_count = metadata.get("page_count", 0) if isinstance(metadata, dict) else 0
+ if page_range:
+ pages = []
+ for part in page_range.split(","):
+ part = part.strip()
+ if "-" in part:
+ a, b = part.split("-", 1)
+ pages.extend(range(int(a.strip()), int(b.strip()) + 1))
+ else:
+ pages.append(int(part))
+ else:
+ pages = list(range(page_count))
+ thumbnails = []
+ for p in pages:
+ if p < page_count:
+ thumbnails.append("") # placeholder — no rendered page images stored
+ return jsonify({
+ "success": True,
+ "thumbnails": thumbnails,
+ })
+
return app
@@ -481,17 +905,15 @@ app_instance = create_app()
if __name__ == "__main__":
_configure_env()
- _marker_dict = create_model_dict() # pre-warm models
-
- port = int(os.environ.get("PORT", "8000"))
+ _marker_dict = create_model_dict()
+ port = int(os.environ.get("PORT", "8001"))
host = os.environ.get("HOST", "0.0.0.0")
print("=" * 60)
print("marker-api starting")
+ print(f" OCR_BACKEND = {OCR_BACKEND}")
+ print(f" API_KEY = {'set' if API_KEY else '(not set)'}")
print(f" OLLAMA_HOST = {OLLAMA_HOST}")
- print(f" OCR_MODEL = {DEESEEK_OCR_MODEL}")
- print(f" AMD_COMPUTE = {AMD_COMPUTE}")
print(f" TORCH_DEVICE = {TORCH_DEVICE or '(auto)'}")
- print(f" MODEL_DTYPE = {MODEL_DTYPE}")
print(f" LISTENING ON = {host}:{port}")
print(f" FORMATS = {', '.join(SUPPORTED_DISPLAY)}")
print("=" * 60)
diff --git a/deepseek_ocr.py b/deepseek_ocr.py
new file mode 100644
index 0000000..3ad13ae
--- /dev/null
+++ b/deepseek_ocr.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+import base64
+import io
+import os
+import tempfile
+import subprocess
+import requests
+from PIL import Image
+
+OLLAMA_HOST = os.environ.get("DEEPSEEK_OLLAMA_HOST", "http://localhost:11434")
+DEEPSEEK_MODEL = os.environ.get("DEEPSEEK_OCR_MODEL", "deepseek-ocr:latest")
+DEEPSEEK_PROMPT = os.environ.get("DEEPSEEK_OCR_PROMPT", "<|grounding|>Free OCR.")
+
+
+def _page_to_image(raw_bytes: bytes, page_num: int = 0) -> bytes | None:
+ with tempfile.TemporaryDirectory() as tmpdir:
+ result = subprocess.run(
+ [
+ "pdftoppm", "-png",
+ "-f", str(page_num + 1),
+ "-l", str(page_num + 1),
+ "-",
+ os.path.join(tmpdir, "page"),
+ ],
+ input=raw_bytes,
+ capture_output=True,
+ timeout=120,
+ )
+ if result.returncode != 0:
+ return None
+ out_files = os.listdir(tmpdir)
+ if not out_files:
+ return None
+ img_path = os.path.join(tmpdir, out_files[0])
+ img = Image.open(img_path)
+ buf = io.BytesIO()
+ img.save(buf, format="PNG")
+ return buf.getvalue()
+
+
+def _image_to_text(image_bytes: bytes) -> str:
+ b64 = base64.b64encode(image_bytes).decode()
+ resp = requests.post(
+ f"{OLLAMA_HOST}/api/generate",
+ json={
+ "model": DEEPSEEK_MODEL,
+ "prompt": DEEPSEEK_PROMPT,
+ "images": [b64],
+ "stream": False,
+ "options": {"temperature": 0},
+ },
+ timeout=120,
+ )
+ resp.raise_for_status()
+ return resp.json().get("response", "")
+
+
+def ocr_pdf(raw_bytes: bytes, max_pages: int | None = None, page_range: str | None = None) -> dict:
+ result = {
+ "success": True,
+ "text": "",
+ "pages": [],
+ "page_count": 0,
+ }
+ pages_to_process = _parse_page_range(page_range, max_pages, raw_bytes)
+ for page_num in pages_to_process:
+ img_bytes = _page_to_image(raw_bytes, page_num)
+ if img_bytes is None:
+ continue
+ text = _image_to_text(img_bytes)
+ result["pages"].append({"page": page_num, "text": text})
+ result["text"] += f"\n\n--- PAGE {page_num + 1} ---\n\n{text}"
+ result["page_count"] = len(result["pages"])
+ return result
+
+
+def _parse_page_range(page_range: str | None, max_pages: int | None, raw_bytes: bytes) -> list[int]:
+ import tempfile, os, subprocess
+ total = 0
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as f:
+ f.write(raw_bytes)
+ f.flush()
+ r = subprocess.run(
+ ["pdfinfo", f.name],
+ capture_output=True, text=True, timeout=30,
+ )
+ os.unlink(f.name)
+ for line in r.stdout.splitlines():
+ if line.startswith("Pages:"):
+ total = int(line.split(":")[1].strip())
+ break
+ if page_range:
+ pages = set()
+ for part in page_range.split(","):
+ part = part.strip()
+ if "-" in part:
+ a, b = part.split("-", 1)
+ for p in range(int(a.strip()), int(b.strip()) + 1):
+ pages.add(p)
+ else:
+ pages.add(int(part))
+ return sorted(pages)
+ if max_pages is not None:
+ return list(range(min(max_pages, total)))
+ return list(range(total))
diff --git a/docker-compose.yml b/docker-compose.yml
index ca0e33c..5af359c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -14,6 +14,12 @@ services:
- MODEL_DTYPE=${MODEL_DTYPE:-float32}
- OLLAMA_HOST=${OLLAMA_HOST:-http://localhost:11435}
- DEESEEK_OCR_MODEL=${DEESEEK_OCR_MODEL:-}
+ - OCR_BACKEND=${OCR_BACKEND:-marker}
+ - DEEPSEEK_OLLAMA_HOST=${DEEPSEEK_OLLAMA_HOST:-http://host.containers.internal:11434}
+ - DEEPSEEK_OCR_MODEL=${DEEPSEEK_OCR_MODEL:-deepseek-ocr:latest}
+ - DEEPSEEK_OCR_PROMPT=<|grounding|>Free OCR.
+ - API_KEY=${API_KEY:-}
+ - SELF_URL=http://localhost:8001
- LLM_SERVICE=marker.services.openai.OpenAIService
- USE_LLM=${USE_LLM:-false}
- OPENAI_BASE_URL=http://localhost:11435/v1
@@ -21,7 +27,7 @@ services:
- OPENAI_MODEL=
- PORT=${PORT:-8001}
- HOST=0.0.0.0
- - GUNICORN_WORKERS=${GUNICORN_WORKERS:-1}
+ - GUNICORN_WORKERS=${GUNICORN_WORKERS:-3}
- GUNICORN_THREADS=${GUNICORN_THREADS:-2}
- GUNICORN_TIMEOUT=${GUNICORN_TIMEOUT:-600}
ports:
diff --git a/entrypoint.sh b/entrypoint.sh
index 5028c03..2df7cdd 100644
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -16,5 +16,5 @@ fi
# Check torch/ROCm availability
python3 -c "import torch; dev=torch.device('cuda' if torch.cuda.is_available() else 'cpu'); print(f'[entrypoint] PyTorch device: {dev}, ROCm: {torch.version.rocm if hasattr(torch.version, \"rocm\") else \"n/a\"}')" 2>&1
-echo "[entrypoint] Starting marker-api on ${HOST:-0.0.0.0}:${PORT:-8000} ..."
+echo "[entrypoint] Starting marker-api on ${HOST:-0.0.0.0}:${PORT:-8001} ..."
exec "$@"
diff --git a/gunicorn.conf.py b/gunicorn.conf.py
index 36a2b3b..0f67b66 100644
--- a/gunicorn.conf.py
+++ b/gunicorn.conf.py
@@ -1,29 +1,91 @@
-# gunicorn.conf.py -- production WSGI config for marker-api
import os
import multiprocessing
+import threading
-port = int(os.environ.get("PORT", "8000"))
-workers = min(multiprocessing.cpu_count() * 2 + 1, 8)
+port = int(os.environ.get("PORT", "8001"))
+workers = int(os.environ.get("GUNICORN_WORKERS", "3"))
+threads = int(os.environ.get("GUNICORN_THREADS", "4"))
+timeout = int(os.environ.get("GUNICORN_TIMEOUT", "600"))
bind = f"0.0.0.0:{port}"
-workers = int(os.environ.get("GUNICORN_WORKERS", "1"))
workers = min(workers, 8)
worker_class = "gthread"
-threads = int(os.environ.get("GUNICORN_THREADS", "4"))
-timeout = int(os.environ.get("GUNICORN_TIMEOUT", "300"))
graceful_timeout = 60
accesslog = "-"
errorlog = "-"
loglevel = os.environ.get("GUNICORN_LOGLEVEL", "info")
-# Preload so models are loaded once in master process (not duplicated per-worker)
-preload_app = True
+# Do NOT preload — each worker starts its own background thread and loads models
+preload_app = False
-# Enable worker recycling to prevent memory leaks
max_requests = 1000
max_requests_jitter = 100
-# Tempdir for marker uploads
tempdir = "/app/uploads"
+
+
+def post_fork(server, worker):
+ """Start background worker thread after fork (filesystem-based store works across workers)."""
+ t = threading.Thread(target=_bg_worker, daemon=True)
+ t.start()
+
+
+def _bg_worker():
+ import app as marker_api
+ from request_store import _job_queue, _job_cond, get_request, complete_request, fail_request, get_result
+
+ while True:
+ import time
+ import traceback
+ with _job_cond:
+ while not _job_queue:
+ _job_cond.wait()
+ job = _job_queue.pop(0)
+ request_id, endpoint, raw_bytes, filename, opts = job
+ try:
+ if endpoint == "convert":
+ result = marker_api.convert_with_ocr_backend(raw_bytes, filename, **opts)
+ elif endpoint == "ocr":
+ from deepseek_ocr import ocr_pdf
+ result = ocr_pdf(raw_bytes, max_pages=opts.get("max_pages"), page_range=opts.get("page_range"))
+ elif endpoint == "extract":
+ conv_result = marker_api.convert_with_ocr_backend(raw_bytes, filename, **opts)
+ if conv_result["success"]:
+ import json
+ schema_json = opts.get("page_schema") or "{}"
+ try:
+ schema = json.loads(schema_json) if isinstance(schema_json, str) else schema_json
+ except json.JSONDecodeError:
+ schema = {}
+ extraction = marker_api._apply_extraction_schema(conv_result["output"], schema)
+ result = {**conv_result, "extraction": extraction}
+ else:
+ result = conv_result
+ elif endpoint == "segment":
+ conv_result = marker_api.convert_with_ocr_backend(raw_bytes, filename, **opts)
+ if conv_result["success"]:
+ import json
+ schema_json = opts.get("segmentation_schema") or "{}"
+ try:
+ schema = json.loads(schema_json) if isinstance(schema_json, str) else schema_json
+ except json.JSONDecodeError:
+ schema = {}
+ segments = marker_api._apply_segmentation_schema(conv_result["output"], schema)
+ result = {**conv_result, "segments": segments}
+ else:
+ result = conv_result
+ elif endpoint == "table_rec":
+ conv_result = marker_api.convert_with_ocr_backend(raw_bytes, filename, **opts, output_format="json")
+ tables = marker_api._extract_tables(conv_result)
+ result = {**conv_result, "tables": tables}
+ else:
+ result = {"success": False, "error": f"Unknown endpoint: {endpoint}"}
+ if result["success"]:
+ complete_request(request_id, result)
+ else:
+ fail_request(request_id, result.get("error", "Unknown error"))
+ except Exception as exc:
+ traceback.print_exc()
+ fail_request(request_id, str(exc))
diff --git a/marker_endpoint_reference.md b/marker_endpoint_reference.md
new file mode 100644
index 0000000..2a87139
--- /dev/null
+++ b/marker_endpoint_reference.md
@@ -0,0 +1,2248 @@
+2. Datalab Marker (PDF to Markdown) [[1](https://modal.com/docs/examples/doc_ocr_jobs)]
+
+The [Datalab Marker API](https://www.google.com/url?sa=i&source=web&rct=j&url=https://documentation.datalab.to/api-reference/%5Bdeprecated%5D-marker&ved=2ahUKEwiTmpHLlPeUAxUc5AIHHVeCDaMQy_kOegoIAggACAAIDxAC&opi=89978449&cd&psig=AOvVaw3JMHBkAiwdgyypdQssD6Zb&ust=1780991525629000) is used for converting documents like PDFs into structured Markdown. Key endpoints include: [[1](https://github.com/adithya-s-k/marker-api), [2](https://documentation.datalab.to/api-reference/[deprecated]-marker)]
+
+- **POST /convert-document**: High-level endpoint to convert files to Markdown.
+- **POST /extract-structured-data**: Extracts specific fields from documents using JSON schemas.
+- **GET /convert-result-check**: Polls for the status of a conversion task.
+- **POST /marker**: (Now deprecated in favor of specific document conversion endpoints). [[1](https://documentation.datalab.to/docs/recipes/structured-extraction/api-overview), [2](https://developer.adobe.com/firefly-services/docs/indesign-apis/api/), [3](https://blog.postman.com/what-is-an-api-endpoint/)]
+
+A datalab marker-api client
+https://github.com/datalab-to/sdk.git
+
+
+---
+
+# Documentation
+
+
+
+> ## Documentation Index
+> Fetch the complete documentation index at: https://documentation.datalab.to/llms.txt
+> Use this file to discover all available pages before exploring further.
+
+# API Overview
+
+> REST API reference for document conversion, form filling, and file management.
+
+Datalab provides REST APIs for document conversion, structured extraction, form filling, and file management. All APIs use the same authentication and follow similar patterns.
+
+
+ For the simplest integration, use the [Python SDK](/docs/welcome/sdk). The SDK handles authentication, polling, and provides typed responses.
+
+
+## Authentication
+
+All requests require an API key in the `X-API-Key` header:
+
+```bash theme={null}
+curl -X POST https://www.datalab.to/api/v1/convert \
+ -H "X-API-Key: YOUR_API_KEY" \
+ -F "file=@document.pdf"
+```
+
+Get your API key from the [API Keys dashboard](https://www.datalab.to/app/keys).
+
+## Request Pattern
+
+All processing endpoints follow this pattern:
+
+1. **Submit** a document for processing (returns immediately with a `request_id`)
+2. **Poll** the status endpoint until processing completes
+3. **Retrieve** results from the completed response
+
+### Submit Request
+
+```bash theme={null}
+POST /api/v1/{endpoint}
+```
+
+Response:
+
+```json theme={null}
+{
+ "success": true,
+ "request_id": "abc123",
+ "request_check_url": "https://www.datalab.to/api/v1/{endpoint}/abc123"
+}
+```
+
+### Poll for Results
+
+```bash theme={null}
+GET /api/v1/{endpoint}/{request_id}
+```
+
+Response while processing:
+
+```json theme={null}
+{
+ "status": "processing"
+}
+```
+
+Response when complete:
+
+```json theme={null}
+{
+ "status": "complete",
+ "success": true,
+ ...results...
+}
+```
+
+
+ Results are deleted from Datalab servers one hour after processing completes. Retrieve your results promptly.
+
+
+## Document Conversion
+
+Convert documents to Markdown, HTML, JSON, or chunks.
+
+**Endpoint:** `POST /api/v1/convert`
+
+### Request
+
+```python theme={null}
+import requests
+
+url = "https://www.datalab.to/api/v1/convert"
+headers = {"X-API-Key": "YOUR_API_KEY"}
+
+with open("document.pdf", "rb") as f:
+ response = requests.post(
+ url,
+ files={"file": ("document.pdf", f, "application/pdf")},
+ data={
+ "output_format": "markdown",
+ "mode": "balanced",
+ },
+ headers=headers
+ )
+
+data = response.json()
+check_url = data["request_check_url"]
+```
+
+### Parameters
+
+| Parameter | Type | Default | Description |
+| ---------------------------- | ------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------ |
+| `file` | file | - | Document file (multipart upload) |
+| `file_url` | string | - | URL to document (alternative to file upload) |
+| `output_format` | string | `markdown` | Output format: `markdown`, `html`, `json`, `chunks` |
+| `mode` | string | `fast` | Processing mode: `fast`, `balanced`, `accurate` |
+| `max_pages` | int | - | Maximum pages to process |
+| `page_range` | string | - | Specific pages (e.g., `"0-5,10"`, 0-indexed). For spreadsheets, filters by sheet index. |
+| `paginate` | bool | `false` | Add page delimiters to output |
+| `skip_cache` | bool | `false` | Skip cached results |
+| `disable_image_extraction` | bool | `false` | Don't extract images |
+| `disable_image_captions` | bool | `false` | Don't generate image captions |
+| `save_checkpoint` | bool | `false` | Save checkpoint for reuse |
+| `extras` | string | - | Comma-separated: `track_changes`, `chart_understanding`, `extract_links`, `table_row_bboxes`, `infographic`, `new_block_types` |
+| `add_block_ids` | bool | `false` | Add block IDs to HTML for citations |
+| `include_markdown_in_chunks` | bool | `false` | Include markdown content in chunks output |
+| `token_efficient_markdown` | bool | `false` | Optimize markdown for LLM token efficiency |
+| `fence_synthetic_captions` | bool | `false` | Wrap synthetic image captions in HTML comments |
+| `additional_config` | string | - | JSON with extra config options |
+| `webhook_url` | string | - | Override webhook URL for this request |
+
+### Processing Modes
+
+| Mode | Description |
+| ---------- | --------------------------------------------------- |
+| `fast` | Lowest latency, good for simple documents (default) |
+| `balanced` | Balance of speed and accuracy |
+| `accurate` | Highest accuracy, best for complex layouts |
+
+### Response
+
+Poll `request_check_url` until `status` is `complete`:
+
+```python theme={null}
+import time
+
+while True:
+ response = requests.get(check_url, headers=headers)
+ result = response.json()
+
+ if result["status"] == "complete":
+ break
+ time.sleep(2)
+
+print(result["markdown"])
+```
+
+Response fields:
+
+| Field | Type | Description |
+| --------------------- | ------ | ---------------------------------------- |
+| `status` | string | `processing`, `complete`, or `failed` |
+| `success` | bool | Whether conversion succeeded |
+| `markdown` | string | Markdown output (if format is markdown) |
+| `html` | string | HTML output (if format is html) |
+| `json` | object | JSON output (if format is json) |
+| `chunks` | object | Chunked output (if format is chunks) |
+| `images` | object | Extracted images as `{filename: base64}` |
+| `metadata` | object | Document metadata |
+| `page_count` | int | Number of pages processed |
+| `parse_quality_score` | float | Quality score (0-5) |
+| `cost_breakdown` | object | Cost in cents |
+| `error` | string | Error message if failed |
+
+
+ For structured data extraction, see the [Extract endpoint](#structured-extraction). For document segmentation, see the [Segment endpoint](#document-segmentation).
+
+
+## Structured Extraction
+
+Extract structured data from documents using a JSON schema.
+
+**Endpoint:** `POST /api/v1/extract`
+
+### Request
+
+```python theme={null}
+import requests
+import json
+
+headers = {"X-API-Key": "YOUR_API_KEY"}
+
+schema = {
+ "invoice_number": {"type": "string", "description": "Invoice ID"},
+ "total": {"type": "number", "description": "Total amount"},
+ "line_items": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "description": {"type": "string"},
+ "amount": {"type": "number"}
+ }
+ }
+ }
+}
+
+response = requests.post(
+ "https://www.datalab.to/api/v1/extract",
+ files={"file": ("invoice.pdf", open("invoice.pdf", "rb"), "application/pdf")},
+ data={
+ "page_schema": json.dumps(schema),
+ "mode": "balanced"
+ },
+ headers=headers
+)
+
+data = response.json()
+check_url = data["request_check_url"]
+```
+
+### Parameters
+
+| Parameter | Type | Default | Description |
+| ----------------- | ------ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `file` | file | - | Document file (multipart upload) |
+| `file_url` | string | - | URL to document (alternative to file upload) |
+| `page_schema` | string | - | JSON schema defining the data to extract. Required unless `schema_id` is provided. |
+| `schema_id` | string | - | ID of a [saved extraction schema](/docs/recipes/structured-extraction/saved-schemas) (e.g. `sch_k8Hx9mP2nQ4v`). Mutually exclusive with `page_schema`. |
+| `schema_version` | int | - | Version of the saved schema to use. Only valid with `schema_id`; defaults to the latest version. |
+| `checkpoint_id` | string | - | Checkpoint ID from a previous `/convert` call (with `save_checkpoint=true`). Skips re-parsing. |
+| `mode` | string | `fast` | Processing mode: `fast`, `balanced`, `accurate` |
+| `output_format` | string | `markdown` | Output format: `markdown`, `html`, `json`, `chunks` |
+| `max_pages` | int | - | Maximum pages to process |
+| `page_range` | string | - | Specific pages (e.g., `"0-5,10"`, 0-indexed). For spreadsheets, filters by sheet index. |
+| `save_checkpoint` | bool | `false` | Save a checkpoint after processing for reuse with subsequent calls |
+| `webhook_url` | string | - | Override webhook URL for this request |
+
+The extracted data is returned in `extraction_schema_json` in the poll response.
+
+See [Structured Extraction](/docs/recipes/structured-extraction/api-overview) for detailed examples.
+
+## Document Segmentation
+
+Segment documents into structured sections using a JSON schema.
+
+**Endpoint:** `POST /api/v1/segment`
+
+### Parameters
+
+| Parameter | Type | Default | Description |
+| --------------------- | ------ | ------------ | ---------------------------------------------------------------------------------------------- |
+| `file` | file | - | Document file (multipart upload) |
+| `file_url` | string | - | URL to document (alternative to file upload) |
+| `segmentation_schema` | string | **required** | JSON schema defining the segments to extract |
+| `checkpoint_id` | string | - | Checkpoint ID from a previous `/convert` call (with `save_checkpoint=true`). Skips re-parsing. |
+| `mode` | string | `fast` | Processing mode: `fast`, `balanced`, `accurate` |
+
+See [Document Segmentation](/docs/recipes/document-segmentation/auto-segmentation) for detailed examples.
+
+## Track Changes
+
+Extract tracked changes (insertions and deletions) from DOCX files.
+
+**Endpoint:** `POST /api/v1/track-changes`
+
+```python theme={null}
+response = requests.post(
+ "https://www.datalab.to/api/v1/track-changes",
+ files={"file": ("document.docx", open("document.docx", "rb"), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")},
+ headers=headers
+)
+```
+
+See [Track Changes](/docs/recipes/extract-redlines-and-comments/track-changes-from-word-documents) for detailed examples.
+
+## Custom Processor
+
+This feature is currently in beta. The API may change.
+
+Execute custom AI-powered processors on documents.
+
+**Endpoint:** `POST /api/v1/custom-processor`
+
+
+ `POST /api/v1/custom-pipeline` is deprecated (sunset: September 30, 2026). Migrate to `POST /api/v1/custom-processor`.
+
+
+### Parameters
+
+| Parameter | Type | Default | Description |
+| --------------- | ------ | ------------ | --------------------------------------------------- |
+| `file` | file | - | Document file (multipart upload) |
+| `file_url` | string | - | URL to document |
+| `pipeline_id` | string | **required** | Custom processor ID (`cp_XXXXX`) |
+| `version` | int | - | Processor version to run (default: active version) |
+| `run_eval` | bool | `false` | Run evaluation rules defined for the processor |
+| `mode` | string | `fast` | Processing mode: `fast`, `balanced`, `accurate` |
+| `output_format` | string | `markdown` | Output format: `markdown`, `html`, `json`, `chunks` |
+| `webhook_url` | string | - | URL to POST when complete |
+
+## Form Filling
+
+Fill forms in PDFs and images.
+
+**Endpoint:** `POST /api/v1/fill`
+
+### Request
+
+```python theme={null}
+import json
+
+field_data = {
+ "full_name": {"value": "John Doe", "description": "Full legal name"},
+ "date": {"value": "2024-01-15", "description": "Today's date"},
+ "signature": {"value": "John Doe", "description": "Signature field"}
+}
+
+response = requests.post(
+ "https://www.datalab.to/api/v1/fill",
+ files={"file": ("form.pdf", open("form.pdf", "rb"), "application/pdf")},
+ data={
+ "field_data": json.dumps(field_data),
+ "confidence_threshold": "0.5"
+ },
+ headers=headers
+)
+```
+
+### Parameters
+
+| Parameter | Type | Default | Description |
+| ---------------------- | ------ | ------- | ------------------------------------- |
+| `file` | file | - | Form file (PDF or image) |
+| `file_url` | string | - | URL to form |
+| `field_data` | string | - | JSON mapping field names to values |
+| `context` | string | - | Additional context for field matching |
+| `confidence_threshold` | float | `0.5` | Minimum confidence for matching (0-1) |
+| `page_range` | string | - | Specific pages to process |
+| `skip_cache` | bool | `false` | Skip cached results |
+
+### Field Data Format
+
+```json theme={null}
+{
+ "field_key": {
+ "value": "The value to fill",
+ "description": "Description to help match the field"
+ }
+}
+```
+
+### Response
+
+| Field | Type | Description |
+| ------------------ | ------ | ------------------------------- |
+| `status` | string | Processing status |
+| `success` | bool | Whether filling succeeded |
+| `output_format` | string | `pdf` or `png` |
+| `output_base64` | string | Base64-encoded filled form |
+| `fields_filled` | array | Successfully filled field names |
+| `fields_not_found` | array | Unmatched field names |
+| `page_count` | int | Pages processed |
+| `cost_breakdown` | object | Cost details |
+
+See [Form Filling](/docs/recipes/form-filling/form-filling-api-overview) for more examples.
+
+## File Management
+
+Upload and manage files for use in pipelines.
+
+### Upload File
+
+**Step 1:** Request an upload URL
+
+```bash theme={null}
+POST /api/v1/files/upload
+Content-Type: application/json
+
+{
+ "filename": "document.pdf",
+ "content_type": "application/pdf"
+}
+```
+
+Response:
+
+```json theme={null}
+{
+ "file_id": 123,
+ "upload_url": "https://...",
+ "reference": "datalab://file-abc123"
+}
+```
+
+**Step 2:** Upload directly to the presigned URL
+
+```bash theme={null}
+PUT {upload_url}
+Content-Type: application/pdf
+
+
+```
+
+**Step 3:** Confirm upload
+
+```bash theme={null}
+GET /api/v1/files/{file_id}/confirm
+```
+
+### List Files
+
+```bash theme={null}
+GET /api/v1/files?limit=50&offset=0
+```
+
+### Get File Metadata
+
+```bash theme={null}
+GET /api/v1/files/{file_id}
+```
+
+### Get Download URL
+
+```bash theme={null}
+GET /api/v1/files/{file_id}/download?expires_in=3600
+```
+
+### Delete File
+
+```bash theme={null}
+DELETE /api/v1/files/{file_id}
+```
+
+See [File Management](/docs/recipes/file-management/file-upload-api) for detailed examples.
+
+## Thumbnails
+
+Generate page thumbnails from a previously processed document:
+
+```bash theme={null}
+GET /api/v1/thumbnails/{lookup_key}?thumb_width=300&page_range=0-2
+```
+
+| Parameter | Type | Default | Description |
+| ------------- | ------ | --------- | ----------------------------------------- |
+| `lookup_key` | string | Required | The request ID from a previous conversion |
+| `thumb_width` | int | 300 | Thumbnail width in pixels |
+| `page_range` | string | All pages | Pages to generate (e.g., `"0,2-4"`) |
+
+Response:
+
+```json theme={null}
+{
+ "success": true,
+ "thumbnails": ["base64_encoded_jpg_1", "base64_encoded_jpg_2"]
+}
+```
+
+Thumbnails are returned as base64-encoded JPG images.
+
+## Create Document
+
+Generate DOCX files from markdown with track changes support:
+
+```bash theme={null}
+POST /api/v1/create-document
+Content-Type: application/json
+
+{
+ "markdown": "# Title\n\nThis is newly added text.",
+ "output_format": "docx"
+}
+```
+
+See [Create Document](/docs/recipes/create-document/create-document-api-overview) for detailed examples.
+
+## Webhooks
+
+Configure webhooks to receive notifications when processing completes instead of polling.
+
+Set a default webhook URL in your [account settings](https://www.datalab.to/settings), or override per-request with the `webhook_url` parameter.
+
+See [Webhooks](/platform/webhooks) for configuration details.
+
+## Rate Limits
+
+Default rate limits apply per API key. If you exceed limits, you'll receive a `429` response.
+
+See [Rate Limits](/docs/common/limits) for details and how to request higher limits.
+
+## Next Steps
+
+
+
+ Use the Python SDK for a simpler integration with typed responses.
+
+
+
+ Receive notifications when processing completes instead of polling.
+
+
+
+ Understand file size limits, page limits, and rate limiting.
+
+
+
+ Detailed guide to converting documents to Markdown, HTML, or JSON.
+
+
+
+
+> ## Documentation Index
+> Fetch the complete documentation index at: https://documentation.datalab.to/llms.txt
+> Use this file to discover all available pages before exploring further.
+
+# [DEPRECATED] Marker
+
+> **DEPRECATED**: Use the new endpoints instead:
+- `/convert` for document conversion
+- `/extract` for structured data extraction
+- `/segment` for document segmentation
+- `/custom-pipeline` for custom pipeline execution
+
+This endpoint will be removed in a future version.
+
+
+
+## OpenAPI
+
+````yaml https://www.datalab.to/openapi.json post /api/v1/marker
+openapi: 3.1.0
+info:
+ title: Datalab API
+ version: 0.0.1
+servers:
+ - url: https://www.datalab.to
+ description: Datalab API
+security: []
+paths:
+ /api/v1/marker:
+ post:
+ summary: '[DEPRECATED] Marker'
+ description: |-
+ **DEPRECATED**: Use the new endpoints instead:
+ - `/convert` for document conversion
+ - `/extract` for structured data extraction
+ - `/segment` for document segmentation
+ - `/custom-pipeline` for custom pipeline execution
+
+ This endpoint will be removed in a future version.
+ operationId: marker_api_v1_marker_post
+ parameters:
+ - name: wos-session
+ in: cookie
+ required: false
+ schema:
+ type: string
+ title: Wos-Session
+ - name: datalab_active_team
+ in: cookie
+ required: false
+ schema:
+ type: string
+ title: Datalab Active Team
+ requestBody:
+ content:
+ multipart/form-data:
+ schema:
+ $ref: '#/components/schemas/Body_marker_api_v1_marker_post'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/InitialResponse'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ deprecated: true
+ security:
+ - APIKeyHeader: []
+components:
+ schemas:
+ Body_marker_api_v1_marker_post:
+ properties:
+ file_url:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: File Url
+ description: >-
+ Optional file URL (http/https). If provided, the server will
+ download and process it.
+ mode:
+ type: string
+ title: Mode
+ description: >-
+ Which output mode to use. Valid values: 'fast' (lowest latency,
+ great for real-time use cases), 'balanced' (balanced accuracy and
+ latency, works well with most documents), 'accurate' (highest
+ accuracy and latency, good on the most complex documents).
+ default: fast
+ choices:
+ - fast
+ - balanced
+ - accurate
+ max_pages:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Max Pages
+ description: The maximum number of pages in the PDF to convert.
+ page_range:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Page Range
+ description: >-
+ The page range to parse, comma separated like 0,5-10,20. This will
+ override max_pages if provided. Example: '0,2-4' will process pages
+ 0, 2, 3, and 4.
+ langs:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Langs
+ description: >-
+ Note: This parameter has been deprecated, and will be ignored in the
+ current version. The languages to use if OCR is needed, comma
+ separated. Must be either the names or codes from
+ https://github.com/datalab-to/surya/blob/master/surya/languages.py.
+ Any other inputs will be ignored.
+ force_ocr:
+ type: boolean
+ title: Force Ocr
+ description: >-
+ [DEPRECATED] This parameter is deprecated and has no effect. OCR is
+ handled automatically by the parsing pipeline.
+ default: false
+ deprecated: true
+ format_lines:
+ type: boolean
+ title: Format Lines
+ description: >-
+ [DEPRECATED] This parameter is deprecated and has no effect. Line
+ formatting is handled automatically by the parsing pipeline.
+ default: false
+ deprecated: true
+ paginate:
+ type: boolean
+ title: Paginate
+ description: >-
+ Whether to paginate the output. Defaults to False. If set to True,
+ each page of the output will be separated by a horizontal rule that
+ contains the page number (2 newlines, {PAGE_NUMBER}, 48 -
+ characters, 2 newlines).
+ default: false
+ add_block_ids:
+ type: boolean
+ title: Add Block Ids
+ description: >-
+ Add data-block-id attributes to HTML elements for citation tracking.
+ Only applies when output_format includes 'html'.
+ default: false
+ include_markdown_in_chunks:
+ type: boolean
+ title: Include Markdown In Chunks
+ description: >-
+ Include markdown field in chunks and JSON output. When enabled, each
+ chunk will have a 'markdown' field with the markdown representation
+ of that block. Only applies when output_format includes 'json' or
+ 'chunks'.
+ default: false
+ strip_existing_ocr:
+ type: boolean
+ title: Strip Existing Ocr
+ description: >-
+ [DEPRECATED] This parameter is deprecated and has no effect. OCR
+ handling is managed automatically by the parsing pipeline.
+ default: false
+ deprecated: true
+ disable_image_extraction:
+ type: boolean
+ title: Disable Image Extraction
+ description: >-
+ Disable image extraction from the PDF. If use_llm is also set, then
+ images will be automatically captioned. Defaults to False.
+ default: false
+ disable_image_captions:
+ type: boolean
+ title: Disable Image Captions
+ description: >-
+ Disable synthetic image captions/descriptions in output. Images will
+ be rendered as plain img tags without alt text or the
+ img-description wrapper div. Defaults to False.
+ default: false
+ fence_synthetic_captions:
+ type: boolean
+ title: Fence Synthetic Captions
+ description: >-
+ Wrap synthetic image captions in markdown with HTML comment markers
+ ( ... ) for
+ easy identification/removal. Only applies to markdown output.
+ default: false
+ disable_ocr_math:
+ type: boolean
+ title: Disable Ocr Math
+ description: >-
+ [DEPRECATED] This parameter is deprecated and has no effect. Math
+ recognition is handled automatically by the parsing pipeline.
+ default: false
+ deprecated: true
+ use_llm:
+ type: boolean
+ title: Use Llm
+ description: >-
+ [DEPRECATED] This parameter is deprecated. Use the 'mode' parameter
+ instead: 'balanced' or 'accurate' modes.
+ default: false
+ deprecated: true
+ output_format:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Output Format
+ description: >-
+ The output format for the text. Can be 'json', 'html', 'markdown',
+ or 'chunks'. Defaults to 'markdown'. You can comma separate
+ multiple formats, like `markdown,html`.
+ token_efficient_markdown:
+ type: boolean
+ title: Token Efficient Markdown
+ description: >-
+ When enabled, the markdown output uses token-efficient formatting
+ optimized for LLMs (compact tables with single-dash headers,
+ single-space list indents).
+ default: false
+ skip_cache:
+ type: boolean
+ title: Skip Cache
+ description: >-
+ Skip the cache and re-run the inference. Defaults to False. If set
+ to True, the cache will be skipped and the inference will be re-run.
+ default: false
+ save_checkpoint:
+ type: boolean
+ title: Save Checkpoint
+ description: >-
+ Save the checkpoint after processing. Defaults to False. This is
+ only useful if you're applying custom rules iteratively.
+ default: false
+ block_correction_prompt:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Block Correction Prompt
+ description: >-
+ [DEPRECATED] This parameter is deprecated and has no effect. Block
+ correction is not currently supported.
+ deprecated: true
+ page_schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Page Schema
+ description: >-
+ The schema to use for structured extraction (only used with
+ structured extraction endpoint). The ideal way to generate this is
+ to create a Pydantic schema, then convert to JSON with
+ .model_dump_json().
+ segmentation_schema:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Segmentation Schema
+ description: >-
+ The schema to use for document segmentation. Should be a JSON string
+ containing segment names and descriptions for identifying page
+ ranges of different document sections.
+ additional_config:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Additional Config
+ description: >-
+ Additional configuration options as a JSON string. Only these keys
+ have effect: 'keep_pageheader_in_output' (bool),
+ 'keep_pagefooter_in_output' (bool), 'keep_spreadsheet_formatting'
+ (bool).
+ workflowstepdata_id:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Workflowstepdata Id
+ description: >-
+ Optional workflow step data ID. If provided, this request will be
+ associated with the specified workflow step execution.
+ extras:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Extras
+ description: >-
+ Comma-separated list of extra features to enable. Currently
+ supports: 'track_changes', 'chart_understanding',
+ 'table_row_bboxes', 'extract_links', 'infographic',
+ 'new_block_types'.
+ webhook_url:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Webhook Url
+ description: >-
+ Optional webhook URL to call when the request is complete. If
+ provided, this will override the webhook URL stored in your account
+ settings for this specific request.
+ processing_location:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Processing Location
+ description: >-
+ Optional residency region override (e.g. us, eu). When provided, use
+ file_url or direct-upload; multipart uploads are rejected. When
+ omitted, the request uses the team's configured residency and
+ profile.
+ pipeline_id:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Pipeline Id
+ description: >-
+ Optional custom pipeline ID. If provided, will execute the custom
+ pipeline configuration associated with this ID.
+ run_eval:
+ type: boolean
+ title: Run Eval
+ description: 'Internal: run evals over custom pipeline.'
+ default: false
+ model_override_settings:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Model Override Settings
+ word_bboxes:
+ type: boolean
+ title: Word Bboxes
+ description: >-
+ When enabled, predict per-word bounding boxes for each page and
+ include them under page_info[id].metadata.words. Only supported by
+ the Chandra parse pipeline.
+ default: false
+ file:
+ anyOf:
+ - type: string
+ format: binary
+ - type: 'null'
+ title: File
+ description: >-
+ Input PDF, word document, powerpoint, or image file, uploaded as
+ multipart form data. Images must be png, jpg, or webp format.
+ type: object
+ title: Body_marker_api_v1_marker_post
+ InitialResponse:
+ properties:
+ success:
+ type: boolean
+ title: Success
+ description: Whether the request was successful.
+ default: true
+ error:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Error
+ description: >-
+ If the request was not successful, this will contain an error
+ message.
+ request_id:
+ type: string
+ title: Request Id
+ description: >-
+ The ID of the request. This ID can be used to check the status of
+ the request.
+ request_check_url:
+ type: string
+ title: Request Check Url
+ description: The URL to check the status of the request and get results.
+ versions:
+ anyOf:
+ - additionalProperties: true
+ type: object
+ - type: string
+ - type: 'null'
+ title: Versions
+ description: A dictionary of the versions of the libraries used in the request.
+ type: object
+ required:
+ - request_id
+ - request_check_url
+ title: InitialResponse
+ HTTPValidationError:
+ properties:
+ detail:
+ items:
+ $ref: '#/components/schemas/ValidationError'
+ type: array
+ title: Detail
+ type: object
+ title: HTTPValidationError
+ ValidationError:
+ properties:
+ loc:
+ items:
+ anyOf:
+ - type: string
+ - type: integer
+ type: array
+ title: Location
+ msg:
+ type: string
+ title: Message
+ type:
+ type: string
+ title: Error Type
+ type: object
+ required:
+ - loc
+ - msg
+ - type
+ title: ValidationError
+ securitySchemes:
+ APIKeyHeader:
+ type: apiKey
+ in: header
+ name: X-API-Key
+
+````
+
+---
+
+> ## Documentation Index
+> Fetch the complete documentation index at: https://documentation.datalab.to/llms.txt
+> Use this file to discover all available pages before exploring further.
+
+# [DEPRECATED] Table Recognition
+
+> [DEPRECATED] This endpoint is deprecated and will be removed in the future.
+This endpoint is used to submit a request for table recognition. The detected tables will be returned, as well as their parsed structure.
+
+
+
+## OpenAPI
+
+````yaml https://www.datalab.to/openapi.json post /api/v1/table_rec
+openapi: 3.1.0
+info:
+ title: Datalab API
+ version: 0.0.1
+servers:
+ - url: https://www.datalab.to
+ description: Datalab API
+security: []
+paths:
+ /api/v1/table_rec:
+ post:
+ summary: '[DEPRECATED] Table Recognition'
+ description: >-
+ [DEPRECATED] This endpoint is deprecated and will be removed in the
+ future.
+
+ This endpoint is used to submit a request for table recognition. The
+ detected tables will be returned, as well as their parsed structure.
+ operationId: table_rec_api_v1_table_rec_post
+ parameters:
+ - name: wos-session
+ in: cookie
+ required: false
+ schema:
+ type: string
+ title: Wos-Session
+ - name: datalab_active_team
+ in: cookie
+ required: false
+ schema:
+ type: string
+ title: Datalab Active Team
+ requestBody:
+ content:
+ multipart/form-data:
+ schema:
+ $ref: '#/components/schemas/Body_table_rec_api_v1_table_rec_post'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/InitialResponse'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ deprecated: true
+ security:
+ - APIKeyHeader: []
+components:
+ schemas:
+ Body_table_rec_api_v1_table_rec_post:
+ properties:
+ max_pages:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Max Pages
+ description: The maximum number of pages in the PDF to convert.
+ page_range:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Page Range
+ description: >-
+ The page range to parse, comma separated like 0,5-10,20. This will
+ override max_pages if provided. Example: '0,2-4' will process pages
+ 0, 2, 3, and 4.
+ output_format:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Output Format
+ description: >-
+ The output format for the table. Can be 'json', 'html', or
+ 'markdown'. Defaults to 'markdown'.
+ skip_cache:
+ type: boolean
+ title: Skip Cache
+ description: >-
+ Skip the cache and re-run the inference. Defaults to False. If set
+ to True, the cache will be skipped and the inference will be re-run.
+ default: false
+ processing_location:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Processing Location
+ description: >-
+ Optional residency region override (e.g. us, eu). When provided, use
+ file_url or direct-upload; multipart uploads are rejected. When
+ omitted, the request uses the team's configured residency and
+ profile.
+ paginate:
+ type: boolean
+ title: Paginate
+ description: >-
+ Whether to paginate the output. Defaults to False. If set to True,
+ each page of the output will be separated by a horizontal rule that
+ contains the page number (2 newlines, {PAGE_NUMBER}, 48 -
+ characters, 2 newlines).
+ default: false
+ file:
+ anyOf:
+ - type: string
+ format: binary
+ - type: 'null'
+ title: File
+ description: >-
+ Input PDF, word document, powerpoint, or image file, uploaded as
+ multipart form data. Images must be png, jpg, or webp format.
+ type: object
+ title: Body_table_rec_api_v1_table_rec_post
+ InitialResponse:
+ properties:
+ success:
+ type: boolean
+ title: Success
+ description: Whether the request was successful.
+ default: true
+ error:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Error
+ description: >-
+ If the request was not successful, this will contain an error
+ message.
+ request_id:
+ type: string
+ title: Request Id
+ description: >-
+ The ID of the request. This ID can be used to check the status of
+ the request.
+ request_check_url:
+ type: string
+ title: Request Check Url
+ description: The URL to check the status of the request and get results.
+ versions:
+ anyOf:
+ - additionalProperties: true
+ type: object
+ - type: string
+ - type: 'null'
+ title: Versions
+ description: A dictionary of the versions of the libraries used in the request.
+ type: object
+ required:
+ - request_id
+ - request_check_url
+ title: InitialResponse
+ HTTPValidationError:
+ properties:
+ detail:
+ items:
+ $ref: '#/components/schemas/ValidationError'
+ type: array
+ title: Detail
+ type: object
+ title: HTTPValidationError
+ ValidationError:
+ properties:
+ loc:
+ items:
+ anyOf:
+ - type: string
+ - type: integer
+ type: array
+ title: Location
+ msg:
+ type: string
+ title: Message
+ type:
+ type: string
+ title: Error Type
+ type: object
+ required:
+ - loc
+ - msg
+ - type
+ title: ValidationError
+ securitySchemes:
+ APIKeyHeader:
+ type: apiKey
+ in: header
+ name: X-API-Key
+
+````
+
+---
+
+> ## Documentation Index
+> Fetch the complete documentation index at: https://documentation.datalab.to/llms.txt
+> Use this file to discover all available pages before exploring further.
+
+# [DEPRECATED] OCR
+
+> [DEPRECATED] This endpoint is deprecated and will be removed in the future.
+This endpoint is used to submit a PDF or image for OCR. The OCR text lines will be returned, along with their bbox and polygon coordinates.
+
+
+
+## OpenAPI
+
+````yaml https://www.datalab.to/openapi.json post /api/v1/ocr
+openapi: 3.1.0
+info:
+ title: Datalab API
+ version: 0.0.1
+servers:
+ - url: https://www.datalab.to
+ description: Datalab API
+security: []
+paths:
+ /api/v1/ocr:
+ post:
+ summary: '[DEPRECATED] OCR'
+ description: >-
+ [DEPRECATED] This endpoint is deprecated and will be removed in the
+ future.
+
+ This endpoint is used to submit a PDF or image for OCR. The OCR text
+ lines will be returned, along with their bbox and polygon coordinates.
+ operationId: ocr_api_v1_ocr_post
+ parameters:
+ - name: wos-session
+ in: cookie
+ required: false
+ schema:
+ type: string
+ title: Wos-Session
+ - name: datalab_active_team
+ in: cookie
+ required: false
+ schema:
+ type: string
+ title: Datalab Active Team
+ requestBody:
+ content:
+ multipart/form-data:
+ schema:
+ $ref: '#/components/schemas/Body_ocr_api_v1_ocr_post'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/InitialResponse'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ deprecated: true
+ security:
+ - APIKeyHeader: []
+components:
+ schemas:
+ Body_ocr_api_v1_ocr_post:
+ properties:
+ max_pages:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Max Pages
+ description: The maximum number of pages in the PDF to convert.
+ page_range:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Page Range
+ description: >-
+ The page range to parse, comma separated like 0,5-10,20. This will
+ override max_pages if provided. Example: '0,2-4' will process pages
+ 0, 2, 3, and 4.
+ langs:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Langs
+ description: >-
+ Note: This parameter has been deprecated, and is no longer used. The
+ languages to use for OCR, comma separated. Can be up to 4
+ languages. Must be either the names or codes from
+ https://github.com/datalab-to/surya/blob/master/surya/languages.py.
+ Any other inputs will be ignored. Defaults to 'en' if not provided.
+ skip_cache:
+ type: boolean
+ title: Skip Cache
+ description: >-
+ Skip the cache and re-run the inference. Defaults to False. If set
+ to True, the cache will be skipped and the inference will be re-run.
+ default: false
+ processing_location:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Processing Location
+ description: >-
+ Optional residency region override (e.g. us, eu). When provided, use
+ file_url or direct-upload; multipart uploads are rejected. When
+ omitted, the request uses the team's configured residency and
+ profile.
+ file:
+ anyOf:
+ - type: string
+ format: binary
+ - type: 'null'
+ title: File
+ description: >-
+ Input PDF, word document, powerpoint, or image file, uploaded as
+ multipart form data. Images must be png, jpg, or webp format.
+ type: object
+ title: Body_ocr_api_v1_ocr_post
+ InitialResponse:
+ properties:
+ success:
+ type: boolean
+ title: Success
+ description: Whether the request was successful.
+ default: true
+ error:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Error
+ description: >-
+ If the request was not successful, this will contain an error
+ message.
+ request_id:
+ type: string
+ title: Request Id
+ description: >-
+ The ID of the request. This ID can be used to check the status of
+ the request.
+ request_check_url:
+ type: string
+ title: Request Check Url
+ description: The URL to check the status of the request and get results.
+ versions:
+ anyOf:
+ - additionalProperties: true
+ type: object
+ - type: string
+ - type: 'null'
+ title: Versions
+ description: A dictionary of the versions of the libraries used in the request.
+ type: object
+ required:
+ - request_id
+ - request_check_url
+ title: InitialResponse
+ HTTPValidationError:
+ properties:
+ detail:
+ items:
+ $ref: '#/components/schemas/ValidationError'
+ type: array
+ title: Detail
+ type: object
+ title: HTTPValidationError
+ ValidationError:
+ properties:
+ loc:
+ items:
+ anyOf:
+ - type: string
+ - type: integer
+ type: array
+ title: Location
+ msg:
+ type: string
+ title: Message
+ type:
+ type: string
+ title: Error Type
+ type: object
+ required:
+ - loc
+ - msg
+ - type
+ title: ValidationError
+ securitySchemes:
+ APIKeyHeader:
+ type: apiKey
+ in: header
+ name: X-API-Key
+
+````
+
+> ## Documentation Index
+> Fetch the complete documentation index at: https://documentation.datalab.to/llms.txt
+> Use this file to discover all available pages before exploring further.
+
+# Convert Document
+
+> Convert a PDF, image, or document to markdown, HTML, JSON, or chunks. Use save_checkpoint=true to save parsed state for later /extract or /segment calls.
+
+
+
+## OpenAPI
+
+````yaml https://www.datalab.to/openapi.json post /api/v1/convert
+openapi: 3.1.0
+info:
+ title: Datalab API
+ version: 0.0.1
+servers:
+ - url: https://www.datalab.to
+ description: Datalab API
+security: []
+paths:
+ /api/v1/convert:
+ post:
+ summary: Convert Document
+ description: >-
+ Convert a PDF, image, or document to markdown, HTML, JSON, or chunks.
+ Use save_checkpoint=true to save parsed state for later /extract or
+ /segment calls.
+ operationId: convert_api_v1_convert_post
+ parameters:
+ - name: wos-session
+ in: cookie
+ required: false
+ schema:
+ type: string
+ title: Wos-Session
+ - name: datalab_active_team
+ in: cookie
+ required: false
+ schema:
+ type: string
+ title: Datalab Active Team
+ requestBody:
+ content:
+ multipart/form-data:
+ schema:
+ $ref: '#/components/schemas/Body_convert_api_v1_convert_post'
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/InitialResponse'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ security:
+ - APIKeyHeader: []
+components:
+ schemas:
+ Body_convert_api_v1_convert_post:
+ properties:
+ file_url:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: File Url
+ description: >-
+ Optional file URL (http/https). If provided, the server will
+ download and process it.
+ mode:
+ type: string
+ title: Mode
+ description: >-
+ Which output mode to use. Valid values: 'fast' (lowest latency),
+ 'balanced' (balanced accuracy and latency), 'accurate' (highest
+ accuracy).
+ default: fast
+ choices:
+ - fast
+ - balanced
+ - accurate
+ dashboard:
+ description: Processing mode balancing speed and accuracy.
+ max_pages:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Max Pages
+ description: The maximum number of pages in the document to convert.
+ page_range:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Page Range
+ description: >-
+ The page range to convert, comma separated like 0,5-10,20. Overrides
+ max_pages if provided.
+ dashboard:
+ description: >-
+ Comma-separated page ranges to process, e.g. '0-2,4'. Leave empty
+ for all pages.
+ paginate:
+ type: boolean
+ title: Paginate
+ description: >-
+ Whether to paginate the output. Each page will be separated by a
+ horizontal rule with the page number.
+ default: false
+ dashboard:
+ description: Separate output by page with horizontal rules.
+ add_block_ids:
+ type: boolean
+ title: Add Block Ids
+ description: >-
+ Add data-block-id attributes to HTML elements for citation tracking.
+ Only applies when output_format includes 'html'.
+ default: false
+ include_markdown_in_chunks:
+ type: boolean
+ title: Include Markdown In Chunks
+ description: Include markdown field in chunks and JSON output.
+ default: false
+ disable_image_extraction:
+ type: boolean
+ title: Disable Image Extraction
+ description: Disable image extraction from the document.
+ default: false
+ dashboard: {}
+ disable_image_captions:
+ type: boolean
+ title: Disable Image Captions
+ description: Disable synthetic image captions/descriptions in output.
+ default: false
+ dashboard: {}
+ word_bboxes:
+ type: boolean
+ title: Word Bboxes
+ description: >-
+ When enabled, predict per-word bounding boxes for each page and
+ include them under page_info[id].metadata.words. Only supported by
+ the Chandra parse pipeline.
+ default: false
+ fence_synthetic_captions:
+ type: boolean
+ title: Fence Synthetic Captions
+ description: >-
+ Wrap synthetic image captions with HTML comment markers for easy
+ identification/removal.
+ default: false
+ output_format:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Output Format
+ description: >-
+ The output format. Can be 'json', 'html', 'markdown', or 'chunks'.
+ Defaults to 'markdown'. Comma separate multiple formats.
+ dashboard:
+ choices:
+ - markdown
+ - html
+ - json
+ - chunks
+ description: Output format for the converted document.
+ type: select
+ token_efficient_markdown:
+ type: boolean
+ title: Token Efficient Markdown
+ description: >-
+ Optimize markdown for LLM token usage (compact tables, single-space
+ indents).
+ default: false
+ skip_cache:
+ type: boolean
+ title: Skip Cache
+ description: Skip the cache and re-run the conversion.
+ default: false
+ dashboard:
+ description: Skip cache and re-run processing.
+ save_checkpoint:
+ type: boolean
+ title: Save Checkpoint
+ description: >-
+ Save a checkpoint after conversion. The checkpoint_id in the
+ response can be used with /extract or /segment to skip re-parsing.
+ default: false
+ dashboard:
+ description: Save a checkpoint for later /extract or /segment calls.
+ additional_config:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Additional Config
+ description: >-
+ Additional configuration as a JSON string. Supported keys:
+ 'keep_pageheader_in_output', 'keep_pagefooter_in_output',
+ 'keep_spreadsheet_formatting'.
+ workflowstepdata_id:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Workflowstepdata Id
+ description: Optional workflow step data ID to associate with this request.
+ extras:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Extras
+ description: >-
+ Comma-separated list of extra features: 'track_changes',
+ 'chart_understanding', 'table_row_bboxes', 'extract_links',
+ 'infographic', 'new_block_types'.
+ dashboard:
+ description: >-
+ Comma-separated feature flags: chart_understanding, infographic,
+ extract_links, table_row_bboxes, new_block_types.
+ webhook_url:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Webhook Url
+ description: Optional webhook URL to call when the request is complete.
+ processing_location:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Processing Location
+ description: >-
+ Optional residency region override (e.g. us, eu). When provided, use
+ file_url or direct-upload; multipart uploads are rejected. When
+ omitted, the request uses the team's configured residency and
+ profile.
+ eval_rubric_id:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Eval Rubric Id
+ description: Optional eval rubric ID to run evaluation after conversion.
+ force_new:
+ type: boolean
+ title: Force New
+ description: 'Internal: force Modal backend.'
+ default: false
+ model_override_settings:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Model Override Settings
+ file:
+ anyOf:
+ - type: string
+ format: binary
+ - type: 'null'
+ title: File
+ description: >-
+ Input PDF, word document, powerpoint, or image file, uploaded as
+ multipart form data. Images must be png, jpg, or webp format.
+ type: object
+ title: Body_convert_api_v1_convert_post
+ InitialResponse:
+ properties:
+ success:
+ type: boolean
+ title: Success
+ description: Whether the request was successful.
+ default: true
+ error:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Error
+ description: >-
+ If the request was not successful, this will contain an error
+ message.
+ request_id:
+ type: string
+ title: Request Id
+ description: >-
+ The ID of the request. This ID can be used to check the status of
+ the request.
+ request_check_url:
+ type: string
+ title: Request Check Url
+ description: The URL to check the status of the request and get results.
+ versions:
+ anyOf:
+ - additionalProperties: true
+ type: object
+ - type: string
+ - type: 'null'
+ title: Versions
+ description: A dictionary of the versions of the libraries used in the request.
+ type: object
+ required:
+ - request_id
+ - request_check_url
+ title: InitialResponse
+ HTTPValidationError:
+ properties:
+ detail:
+ items:
+ $ref: '#/components/schemas/ValidationError'
+ type: array
+ title: Detail
+ type: object
+ title: HTTPValidationError
+ ValidationError:
+ properties:
+ loc:
+ items:
+ anyOf:
+ - type: string
+ - type: integer
+ type: array
+ title: Location
+ msg:
+ type: string
+ title: Message
+ type:
+ type: string
+ title: Error Type
+ type: object
+ required:
+ - loc
+ - msg
+ - type
+ title: ValidationError
+ securitySchemes:
+ APIKeyHeader:
+ type: apiKey
+ in: header
+ name: X-API-Key
+
+````
+
+---
+
+
+> ## Documentation Index
+> Fetch the complete documentation index at: https://documentation.datalab.to/llms.txt
+> Use this file to discover all available pages before exploring further.
+
+# Marker Result Check
+
+> Poll this endpoint to check status of Marker request and retrieve final results
+
+
+
+## OpenAPI
+
+````yaml https://www.datalab.to/openapi.json get /api/v1/marker/{request_id}
+openapi: 3.1.0
+info:
+ title: Datalab API
+ version: 0.0.1
+servers:
+ - url: https://www.datalab.to
+ description: Datalab API
+security: []
+paths:
+ /api/v1/marker/{request_id}:
+ get:
+ summary: Marker Result Check
+ description: >-
+ Poll this endpoint to check status of Marker request and retrieve final
+ results
+ operationId: result_response_api_v1_marker__request_id__get
+ parameters:
+ - name: request_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Request Id
+ - name: wos-session
+ in: cookie
+ required: false
+ schema:
+ type: string
+ title: Wos-Session
+ - name: datalab_active_team
+ in: cookie
+ required: false
+ schema:
+ type: string
+ title: Datalab Active Team
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MarkerFinalResponse'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ security:
+ - APIKeyHeader: []
+components:
+ schemas:
+ MarkerFinalResponse:
+ properties:
+ status:
+ type: string
+ title: Status
+ description: >-
+ The status of the request. Should be 'complete' when the request is
+ done.
+ result_url:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Result Url
+ description: >-
+ Signed URL for downloading the completed result JSON when direct
+ result download is required.
+ expires_in:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Expires In
+ description: Number of seconds until result_url expires.
+ output_format:
+ type: string
+ title: Output Format
+ description: The format of the output. 'markdown' or 'json'.
+ chunks:
+ anyOf:
+ - additionalProperties: true
+ type: object
+ - type: string
+ - type: 'null'
+ title: Chunks
+ description: >-
+ The output in chunks format. The top-level key 'blocks' contains a
+ list of blocks from the document with metadata.
+ json:
+ anyOf:
+ - additionalProperties: true
+ type: object
+ - type: string
+ - type: 'null'
+ title: Json
+ description: The JSON representation of the PDF if the output format is 'json'.
+ markdown:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Markdown
+ description: >-
+ The markdown representation of the PDF if the output format is
+ 'markdown'.
+ html:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Html
+ description: The HTML representation of the PDF if the output format is 'html'.
+ extraction_schema_json:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Extraction Schema Json
+ description: >-
+ The output of a marker extraction request containing the filled in
+ extraction schema.
+ extraction_score_average:
+ anyOf:
+ - type: number
+ - type: 'null'
+ title: Extraction Score Average
+ description: >-
+ Average confidence score (1-5) across all extracted fields, when
+ scoring is applied.
+ extraction_mode:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Extraction Mode
+ description: 'The extraction mode used for this request: ''fast'' or ''balanced''.'
+ segmentation_results:
+ anyOf:
+ - additionalProperties: true
+ type: object
+ - type: 'null'
+ title: Segmentation Results
+ description: >-
+ Results of document segmentation showing page ranges for each
+ identified segment. Contains segment names, page ranges, and
+ confidence levels (high/medium/low).
+ images:
+ anyOf:
+ - additionalProperties:
+ type: string
+ type: object
+ - type: 'null'
+ title: Images
+ description: >-
+ A dictionary of the images in the PDF, where the key is the filename
+ for the image, and the value is the base64 encoded image. Images
+ should be stored in the same directory as the PDF.
+ metadata:
+ anyOf:
+ - additionalProperties: true
+ type: object
+ - type: 'null'
+ title: Metadata
+ description: A dictionary of metadata about the PDF and the conversion process.
+ success:
+ anyOf:
+ - type: boolean
+ - type: 'null'
+ title: Success
+ description: Whether the conversion was successful.
+ error:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Error
+ description: >-
+ If the conversion was not successful, this will contain an error
+ message.
+ parse_quality_score:
+ anyOf:
+ - type: number
+ - type: 'null'
+ title: Parse Quality Score
+ description: The parse quality score of the output, if available.
+ page_count:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Page Count
+ description: The number of pages that were converted.
+ total_cost:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Total Cost
+ description: The total cost of the conversion.
+ deprecated: true
+ cost_breakdown:
+ anyOf:
+ - additionalProperties: true
+ type: object
+ - type: 'null'
+ title: Cost Breakdown
+ description: >-
+ A dictionary of the cost breakdown of this request. Includes the
+ list cost without discounts and final cost to clients after any
+ discounts (e.g. for opting into model training).
+ runtime:
+ anyOf:
+ - type: number
+ - type: 'null'
+ title: Runtime
+ description: The runtime of the conversion.
+ checkpoint_id:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Checkpoint Id
+ description: >-
+ The ID of the checkpoint that was created for this conversion. This
+ can be used to retrieve the checkpoint later.
+ versions:
+ anyOf:
+ - additionalProperties: true
+ type: object
+ - type: string
+ - type: 'null'
+ title: Versions
+ description: A dictionary of the versions of the libraries used in the request.
+ evaluation:
+ anyOf:
+ - $ref: '#/components/schemas/EvaluationResults'
+ - type: 'null'
+ description: >-
+ Evaluation results, when available, for requests that run
+ evaluation. Contains per-rule scores for validating custom pipeline
+ behavior.
+ type: object
+ required:
+ - status
+ title: MarkerFinalResponse
+ HTTPValidationError:
+ properties:
+ detail:
+ items:
+ $ref: '#/components/schemas/ValidationError'
+ type: array
+ title: Detail
+ type: object
+ title: HTTPValidationError
+ EvaluationResults:
+ properties:
+ eval_definition_name:
+ type: string
+ title: Eval Definition Name
+ description: Name of the evaluation definition
+ evaluations:
+ items:
+ $ref: '#/components/schemas/EvaluationRuleSummary'
+ type: array
+ title: Evaluations
+ description: Per-rule evaluation summaries
+ total_items_evaluated:
+ type: integer
+ title: Total Items Evaluated
+ description: Total number of items evaluated across all rules
+ type: object
+ required:
+ - eval_definition_name
+ - evaluations
+ - total_items_evaluated
+ title: EvaluationResults
+ description: Container for evaluation results.
+ ValidationError:
+ properties:
+ loc:
+ items:
+ anyOf:
+ - type: string
+ - type: integer
+ type: array
+ title: Location
+ msg:
+ type: string
+ title: Message
+ type:
+ type: string
+ title: Error Type
+ type: object
+ required:
+ - loc
+ - msg
+ - type
+ title: ValidationError
+ EvaluationRuleSummary:
+ properties:
+ name:
+ type: string
+ title: Name
+ description: Name of the evaluation rule
+ type:
+ type: string
+ title: Type
+ description: 'Type of evaluation: block, page, or document'
+ rule_score:
+ type: number
+ title: Rule Score
+ description: Aggregated score for this rule (0-5)
+ items_evaluated:
+ type: integer
+ title: Items Evaluated
+ description: Number of items evaluated
+ individual_results:
+ items:
+ additionalProperties: true
+ type: object
+ type: array
+ title: Individual Results
+ description: >-
+ Bottom-k lowest scoring individual results with score, feedback,
+ block_id, page_id, block_type
+ type: object
+ required:
+ - name
+ - type
+ - rule_score
+ - items_evaluated
+ title: EvaluationRuleSummary
+ description: Summary of a single evaluation rule result.
+ securitySchemes:
+ APIKeyHeader:
+ type: apiKey
+ in: header
+ name: X-API-Key
+
+````
+
+> ## Documentation Index
+> Fetch the complete documentation index at: https://documentation.datalab.to/llms.txt
+> Use this file to discover all available pages before exploring further.
+
+# OCR Result Check
+
+> Poll this endpoint to check status of an OCR request and retrieve final results
+
+
+
+## OpenAPI
+
+````yaml https://www.datalab.to/openapi.json get /api/v1/ocr/{request_id}
+openapi: 3.1.0
+info:
+ title: Datalab API
+ version: 0.0.1
+servers:
+ - url: https://www.datalab.to
+ description: Datalab API
+security: []
+paths:
+ /api/v1/ocr/{request_id}:
+ get:
+ summary: OCR Result Check
+ description: >-
+ Poll this endpoint to check status of an OCR request and retrieve final
+ results
+ operationId: result_response_api_v1_ocr__request_id__get
+ parameters:
+ - name: request_id
+ in: path
+ required: true
+ schema:
+ type: string
+ title: Request Id
+ - name: wos-session
+ in: cookie
+ required: false
+ schema:
+ type: string
+ title: Wos-Session
+ - name: datalab_active_team
+ in: cookie
+ required: false
+ schema:
+ type: string
+ title: Datalab Active Team
+ responses:
+ '200':
+ description: Successful Response
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/OCRFinalResponse'
+ '422':
+ description: Validation Error
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HTTPValidationError'
+ security:
+ - APIKeyHeader: []
+components:
+ schemas:
+ OCRFinalResponse:
+ properties:
+ status:
+ type: string
+ title: Status
+ description: >-
+ The status of the request. Should be 'complete' when the request is
+ done.
+ result_url:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Result Url
+ description: >-
+ Signed URL for downloading the completed result JSON when direct
+ result download is required.
+ expires_in:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Expires In
+ description: Number of seconds until result_url expires.
+ pages:
+ anyOf:
+ - items:
+ additionalProperties: true
+ type: object
+ type: array
+ - type: 'null'
+ title: Pages
+ description: >-
+ The detected OCR text on each page. Each page will have the bboxes
+ and detected text within each line.
+ success:
+ anyOf:
+ - type: boolean
+ - type: 'null'
+ title: Success
+ description: Whether the conversion was successful.
+ error:
+ anyOf:
+ - type: string
+ - type: 'null'
+ title: Error
+ description: >-
+ If the conversion was not successful, this will contain an error
+ message.
+ page_count:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Page Count
+ description: The number of pages that had ocr run on them.
+ total_cost:
+ anyOf:
+ - type: integer
+ - type: 'null'
+ title: Total Cost
+ description: The total cost of the conversion.
+ deprecated: true
+ cost_breakdown:
+ anyOf:
+ - additionalProperties: true
+ type: object
+ - type: 'null'
+ title: Cost Breakdown
+ description: >-
+ A dictionary of the cost breakdown of this request. Includes the
+ list cost without discounts and final cost to clients after any
+ discounts (e.g. for opting into model training).
+ versions:
+ anyOf:
+ - additionalProperties: true
+ type: object
+ - type: string
+ - type: 'null'
+ title: Versions
+ description: A dictionary of the versions of the libraries used in the request.
+ type: object
+ required:
+ - status
+ title: OCRFinalResponse
+ HTTPValidationError:
+ properties:
+ detail:
+ items:
+ $ref: '#/components/schemas/ValidationError'
+ type: array
+ title: Detail
+ type: object
+ title: HTTPValidationError
+ ValidationError:
+ properties:
+ loc:
+ items:
+ anyOf:
+ - type: string
+ - type: integer
+ type: array
+ title: Location
+ msg:
+ type: string
+ title: Message
+ type:
+ type: string
+ title: Error Type
+ type: object
+ required:
+ - loc
+ - msg
+ - type
+ title: ValidationError
+ securitySchemes:
+ APIKeyHeader:
+ type: apiKey
+ in: header
+ name: X-API-Key
+
+````
diff --git a/request_store.py b/request_store.py
new file mode 100644
index 0000000..a7584b0
--- /dev/null
+++ b/request_store.py
@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+import json
+import os
+import time
+import threading
+from pathlib import Path
+
+DEFAULT_RESULTS_DIR = os.environ.get("RESULTS_DIR", "/app/conversion_results")
+DEFAULT_TTL = 3600 # 1 hour (matches Datalab)
+
+_lock = threading.Lock()
+
+
+def _req_path(request_id: str) -> Path:
+ return Path(DEFAULT_RESULTS_DIR) / request_id
+
+
+def create_request(request_id: str, metadata: dict | None = None) -> dict:
+ d = _req_path(request_id)
+ d.mkdir(parents=True, exist_ok=True)
+ entry = {
+ "request_id": request_id,
+ "status": "processing",
+ "success": None,
+ "created_at": time.time(),
+ "metadata": metadata or {},
+ }
+ with _lock:
+ (d / "status.json").write_text(json.dumps(entry))
+ return entry
+
+
+def get_request(request_id: str) -> dict | None:
+ p = _req_path(request_id) / "status.json"
+ if not p.exists():
+ return None
+ with _lock:
+ return json.loads(p.read_text())
+
+
+def update_request(request_id: str, **kwargs) -> dict | None:
+ d = _req_path(request_id)
+ p = d / "status.json"
+ if not p.exists():
+ return None
+ with _lock:
+ entry = json.loads(p.read_text())
+ entry.update(kwargs)
+ entry["updated_at"] = time.time()
+ p.write_text(json.dumps(entry))
+ return entry
+
+
+def complete_request(request_id: str, result: dict):
+ entry = update_request(request_id, status="complete", success=True)
+ if entry:
+ (Path(DEFAULT_RESULTS_DIR) / request_id / "result.json").write_text(
+ json.dumps(result)
+ )
+
+
+def fail_request(request_id: str, error: str):
+ update_request(request_id, status="complete", success=False, error=error)
+
+
+def get_result(request_id: str) -> dict | None:
+ p = _req_path(request_id) / "result.json"
+ if not p.exists():
+ return None
+ return json.loads(p.read_text())
+
+
+def _cleanup_loop(interval: int = 300, ttl: int = DEFAULT_TTL):
+ while True:
+ time.sleep(interval)
+ now = time.time()
+ try:
+ results_dir = Path(DEFAULT_RESULTS_DIR)
+ if not results_dir.exists():
+ continue
+ for child in results_dir.iterdir():
+ if child.is_dir():
+ status_p = child / "status.json"
+ if status_p.exists():
+ entry = json.loads(status_p.read_text())
+ if now - entry.get("created_at", 0) > ttl:
+ import shutil
+ shutil.rmtree(str(child))
+ except Exception:
+ pass
+
+
+def start_cleanup_thread(interval: int = 300, ttl: int = DEFAULT_TTL):
+ t = threading.Thread(target=_cleanup_loop, args=(interval, ttl), daemon=True)
+ t.start()
+
+
+# ── Job queue for background processing (shared across workers via disk, but
+# each worker has its own in-memory queue for picking up jobs) ──────
+_job_queue: list[tuple[str, str, bytes, str, dict]] = []
+_job_cond = threading.Condition()
+
+
+def submit_job(endpoint: str, raw_bytes: bytes, filename: str, opts: dict) -> str:
+ import uuid
+ request_id = str(uuid.uuid4())
+ create_request(request_id, {"endpoint": endpoint, "filename": filename})
+ with _job_cond:
+ _job_queue.append((request_id, endpoint, raw_bytes, filename, opts))
+ _job_cond.notify()
+ return request_id
+
+
+def wait_for_job(timeout: float | None = None) -> tuple | None:
+ with _job_cond:
+ if not _job_queue:
+ _job_cond.wait(timeout=timeout)
+ if not _job_queue:
+ return None
+ return _job_queue.pop(0)