Suya OCR API — vLLM-backed, OpenAI-compatible OCR service

FastAPI service wrapping the Surya-OCR-2 model (datalab-to) served through vLLM:
legacy /v1/api/ai/* endpoints, an OpenAI-compatible /v1/chat/completions endpoint,
a coalescing request batcher, a local OCR CLI, Docker packaging, multilingual
example outputs, and quantization/concurrency benchmarks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Fu Dai
2026-06-17 10:20:02 +04:00
co-authored by Claude Opus 4.8
commit 1a585693be
147 changed files with 13827 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
"""Surya inference manager.
One process owns one SuryaInferenceManager. The manager wraps a single backend
(vllm | llamacpp) which speaks OpenAI-compatible chat completions.
Predictors take the manager via explicit injection at construction time.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from typing import List, Optional
from surya.inference.backends.base import Backend
from surya.inference.schema import BatchInputItem, BatchOutputItem
from surya.logging import get_logger
from surya.settings import settings
from surya.timing import timing_span
logger = get_logger()
def _has_nvidia_gpu() -> bool:
"""True if an NVIDIA GPU is present on this host.
We deliberately do *not* rely solely on ``torch.cuda.is_available()``:
the installed torch wheel's CUDA build can be newer than the host driver
(PyPI's default wheel tracks the latest CUDA), in which case torch reports
no CUDA even on a perfectly good GPU box. That would silently route us to
the CPU llama.cpp backend on a machine that should be running vllm. So we
take torch's word when it *does* see CUDA, and otherwise fall back to
probing for the GPU directly via ``nvidia-smi``.
"""
try:
import torch
if torch.cuda.is_available():
return True
except Exception:
pass
# Instant, load-independent check: the NVIDIA device node only exists when
# a GPU + driver are present. Preferred over nvidia-smi because nvidia-smi
# can block for several seconds on a GPU under heavy load, which would race
# a timeout and falsely report "no GPU".
if os.path.exists("/dev/nvidia0"):
return True
nvidia_smi = shutil.which("nvidia-smi")
if not nvidia_smi:
return False
try:
result = subprocess.run(
[nvidia_smi, "-L"], capture_output=True, text=True, timeout=15
)
return result.returncode == 0 and "GPU" in result.stdout
except Exception:
return False
def _autodetect_backend() -> str:
if settings.SURYA_INFERENCE_BACKEND:
return settings.SURYA_INFERENCE_BACKEND
# NVIDIA GPU → vllm, mps/cpu → llamacpp
if _has_nvidia_gpu():
return "vllm"
return "llamacpp"
def _build_backend(method: str) -> Backend:
method = method.lower()
if method == "vllm":
from surya.inference.backends.vllm import VllmBackend
return VllmBackend()
if method == "llamacpp":
from surya.inference.backends.llamacpp import LlamaCppBackend
return LlamaCppBackend()
raise ValueError(
f"Unknown inference backend {method!r}. Supported: 'vllm', 'llamacpp'."
)
class SuryaInferenceManager:
"""Single entry point for VLM inference. Construct once per process."""
def __init__(self, method: Optional[str] = None, lazy: bool = True):
self.method = method or _autodetect_backend()
self.backend: Backend = _build_backend(self.method)
if not lazy:
self.backend.start()
def start(self) -> None:
self.backend.start()
def stop(self) -> None:
self.backend.stop()
def generate(self, batch: List[BatchInputItem]) -> List[BatchOutputItem]:
with timing_span("surya_manager_generate", backend=self.method, item_count=len(batch)):
return self.backend.generate(batch)
# Module-level lazy singleton for callers that don't want explicit construction
# (notebooks, ad-hoc scripts). Surya's own models.py and marker should use
# explicit construction.
_default_manager: Optional[SuryaInferenceManager] = None
def get_default_manager() -> SuryaInferenceManager:
global _default_manager
if _default_manager is None:
_default_manager = SuryaInferenceManager()
return _default_manager
+2
View File
@@ -0,0 +1,2 @@
from surya.inference.backends.base import Backend as Backend
from surya.inference.backends.base import ServerHandle as ServerHandle
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import List
from surya.inference.schema import BatchInputItem, BatchOutputItem
@dataclass
class ServerHandle:
base_url: str # e.g. "http://127.0.0.1:8765/v1"
model_name: str # what gets passed in OpenAI `model` field
spawned_by_us: bool # if True, we manage atexit cleanup
class Backend:
"""Abstract backend. Concrete backends own server lifecycle + generation."""
name: str # "vllm" | "llamacpp"
def start(self) -> ServerHandle:
"""Idempotent: probe → attach if alive, else spawn. Returns handle."""
raise NotImplementedError
def stop(self) -> None:
"""Stop the server if we spawned it."""
raise NotImplementedError
def generate(self, batch: List[BatchInputItem]) -> List[BatchOutputItem]:
raise NotImplementedError
+207
View File
@@ -0,0 +1,207 @@
"""llama.cpp backend: spawns the upstream `llama-server` binary natively.
Install:
- macOS: brew install llama.cpp (Metal build, MPS)
- Linux: brew install llama.cpp OR github.com/ggml-org/llama.cpp/releases
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
from typing import List, Optional
from huggingface_hub import hf_hub_download
from openai import OpenAI
from surya.inference.backends.base import Backend, ServerHandle
from surya.inference.backends.openai_client import chat_completions_batch
from surya.inference.backends.spawn import (
SpawnHandle,
SpawnError,
attach_or_spawn,
)
from surya.inference.schema import BatchInputItem, BatchOutputItem
from surya.logging import get_logger
from surya.settings import settings
logger = get_logger()
def _resolve_llama_server_binary() -> str:
binary = settings.LLAMA_CPP_BINARY
if binary and os.path.isfile(binary):
return binary
found = shutil.which(binary or "llama-server")
if found:
return found
raise SpawnError(
"llama-server binary not found. Install with:\n"
" macOS: brew install llama.cpp\n"
" Linux: brew install llama.cpp OR download from\n"
" https://github.com/ggml-org/llama.cpp/releases\n"
"Or set LLAMA_CPP_BINARY in your env to the binary path."
)
def _download_gguf_files() -> tuple[str, str]:
"""Download model + mmproj GGUFs from HF Hub. Returns local paths."""
repo = settings.SURYA_GGUF_REPO
model_file = settings.SURYA_GGUF_MODEL_FILE
mmproj_file = settings.SURYA_GGUF_MMPROJ_FILE
logger.info(f"Downloading {model_file} and {mmproj_file} from {repo}")
model_path = hf_hub_download(repo_id=repo, filename=model_file)
mmproj_path = hf_hub_download(repo_id=repo, filename=mmproj_file)
return model_path, mmproj_path
def _health_url(port: int) -> str:
return f"http://{settings.SURYA_INFERENCE_HOST}:{port}"
def _openai_url(port: int) -> str:
return f"http://{settings.SURYA_INFERENCE_HOST}:{port}/v1"
class LlamaCppBackend(Backend):
name = "llamacpp"
def __init__(self):
self.handle: Optional[ServerHandle] = None
self._client: Optional[OpenAI] = None
def start(self) -> ServerHandle:
if self.handle is not None:
return self.handle
# If user pinned an external server, attach without spawning.
# No binary or GGUF download needed in that case.
if settings.SURYA_INFERENCE_URL:
spawned = attach_or_spawn(
backend=self.name,
expected_model_name=settings.SURYA_MODEL_CHECKPOINT,
spawn_fn=lambda port: SpawnHandle(
pid=None, cleanup_id="", cleanup_kind="process"
), # never called
health_url_for=_health_url,
openai_url_for=_openai_url,
startup_timeout=settings.SURYA_INFERENCE_STARTUP_TIMEOUT,
)
self.handle = ServerHandle(
base_url=spawned.base_url,
model_name=spawned.model_name,
spawned_by_us=spawned.spawned_by_us,
)
self._client = OpenAI(api_key="EMPTY", base_url=self.handle.base_url)
return self.handle
binary = _resolve_llama_server_binary()
# Pre-download GGUFs so the spawn doesn't race the download
if (
settings.SURYA_GGUF_LOCAL_MODEL_PATH
and settings.SURYA_GGUF_LOCAL_MMPROJ_PATH
):
model_path = settings.SURYA_GGUF_LOCAL_MODEL_PATH
mmproj_path = settings.SURYA_GGUF_LOCAL_MMPROJ_PATH
else:
model_path, mmproj_path = _download_gguf_files()
# Total KV-cache budget. llama-server divides --ctx-size across
# --parallel slots, so a too-small total silently truncates outputs
# once each slot's share fills. Scale with parallel by default;
# SURYA_INFERENCE_CTX_SIZE overrides to a fixed value if set.
parallel = settings.SURYA_INFERENCE_PARALLEL
per_slot = settings.SURYA_INFERENCE_CTX_PER_SLOT
ctx_size = settings.SURYA_INFERENCE_CTX_SIZE
if ctx_size is None:
ctx_size = max(16384, parallel * per_slot)
effective_per_slot = ctx_size // max(parallel, 1)
logger.info(
f"llama-server ctx-size={ctx_size} "
f"(~{effective_per_slot}/slot × {parallel} parallel slots)"
)
if effective_per_slot < per_slot:
logger.warning(
f"per-slot ctx ({effective_per_slot}) is below recommended "
f"{per_slot}; outputs may truncate. Raise "
f"SURYA_INFERENCE_CTX_SIZE or SURYA_INFERENCE_CTX_PER_SLOT, "
f"or lower SURYA_INFERENCE_PARALLEL."
)
def spawn_fn(port: int) -> SpawnHandle:
cmd = [
binary,
"-m",
model_path,
"--mmproj",
mmproj_path,
"-ngl",
str(settings.LLAMA_CPP_NGL),
"--host",
settings.SURYA_INFERENCE_HOST,
"--port",
str(port),
"--parallel",
str(parallel),
"--ctx-size",
str(ctx_size),
"--no-mmproj-offload" if settings.LLAMA_CPP_NO_MMPROJ_OFFLOAD else "",
"--alias",
settings.SURYA_MODEL_CHECKPOINT,
"--jinja",
]
cmd = [c for c in cmd if c]
for extra in (settings.LLAMA_CPP_EXTRA_ARGS or "").split():
cmd.append(extra)
logger.info(f"Spawning: {' '.join(cmd)}")
log_path = Path("~/.cache/datalab/surya/llamacpp_server.log").expanduser()
log_path.parent.mkdir(parents=True, exist_ok=True)
log_fp = open(log_path, "ab")
proc = subprocess.Popen(
cmd,
stdout=log_fp,
stderr=subprocess.STDOUT,
start_new_session=True,
)
return SpawnHandle(
pid=proc.pid, cleanup_id=str(proc.pid), cleanup_kind="process"
)
spawned = attach_or_spawn(
backend=self.name,
expected_model_name=settings.SURYA_MODEL_CHECKPOINT,
spawn_fn=spawn_fn,
health_url_for=_health_url,
openai_url_for=_openai_url,
startup_timeout=settings.SURYA_INFERENCE_STARTUP_TIMEOUT,
)
self.handle = ServerHandle(
base_url=spawned.base_url,
model_name=spawned.model_name,
spawned_by_us=spawned.spawned_by_us,
)
self._client = OpenAI(
api_key="EMPTY",
base_url=self.handle.base_url,
)
return self.handle
def stop(self) -> None:
# atexit handler in spawn.py owns cleanup; nothing to do here.
self.handle = None
self._client = None
def generate(self, batch: List[BatchInputItem]) -> List[BatchOutputItem]:
if self.handle is None or self._client is None:
self.start()
return chat_completions_batch(
batch,
client=self._client,
model_name=self.handle.model_name,
timeout=settings.SURYA_INFERENCE_TIMEOUT_SECONDS,
max_workers=settings.SURYA_INFERENCE_PARALLEL,
request_logprobs_default=settings.SURYA_INFERENCE_LOGPROBS,
)
+257
View File
@@ -0,0 +1,257 @@
"""Shared OpenAI-compatible chat completions client. Used by vllm + llama.cpp.
Both servers expose `/v1/chat/completions` with the same request/response shape,
so this module is the single point of HTTP contact for both backends.
"""
from __future__ import annotations
import base64
import io
import math
import os
import time
from concurrent.futures import ThreadPoolExecutor
from typing import List, Optional
from PIL import Image
from surya.inference.prompts import PROMPT_MAPPING
from surya.inference.schema import (
BatchInputItem,
BatchOutputItem,
GenerationResult,
)
from surya.inference.util import detect_repeat_token, scale_to_fit
from surya.logging import get_logger
from surya.timing import get_current_timing, set_current_timing, timing_span
logger = get_logger()
def resolve_max_workers(batch_len: int, max_inflight: int) -> int:
"""Concurrent HTTP workers for a batch: as many as the batch needs, capped
by max_inflight so we keep vLLM's sequence slots full without over-queueing."""
return max(1, min(batch_len, max_inflight))
def encode_image_b64(image: Image.Image) -> tuple[str, str]:
image_format = os.getenv("SUYA_VLLM_IMAGE_FORMAT", "JPEG").upper()
if image_format not in {"JPEG", "PNG"}:
raise ValueError("SUYA_VLLM_IMAGE_FORMAT must be JPEG or PNG")
buf = io.BytesIO()
if image_format == "JPEG":
quality = int(os.getenv("SUYA_VLLM_JPEG_QUALITY", "92"))
image.save(buf, format="JPEG", quality=quality, subsampling=0)
mime_type = "image/jpeg"
else:
image.save(buf, format="PNG")
mime_type = "image/png"
view = buf.getbuffer()
try:
return base64.b64encode(view).decode("ascii"), mime_type
finally:
view.release()
def _build_messages(image: Image.Image, prompt: str):
with timing_span("openai_encode_image_b64", image_size=image.size):
image_b64, mime_type = encode_image_b64(image)
return [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": f"data:{mime_type};base64,{image_b64}"},
},
{"type": "text", "text": prompt},
],
}
]
def _mean_token_prob(logprobs_content) -> Optional[float]:
if not logprobs_content:
return None
probs = []
for tok in logprobs_content:
lp = (
tok.get("logprob")
if isinstance(tok, dict)
else getattr(tok, "logprob", None)
)
if lp is None:
continue
probs.append(math.exp(lp))
if not probs:
return None
return sum(probs) / len(probs)
def _generate_one(
item: BatchInputItem,
client,
model_name: str,
max_tokens_default: int,
temperature: float,
top_p: float,
timeout: float,
request_logprobs_default: bool,
) -> GenerationResult:
prompt = item.prompt or PROMPT_MAPPING[item.prompt_type]
with timing_span("openai_scale_image", prompt_type=item.prompt_type, image_size=item.image.size):
image = scale_to_fit(item.image)
with timing_span("openai_build_messages", prompt_type=item.prompt_type):
messages = _build_messages(image, prompt)
max_tokens = item.max_tokens or max_tokens_default
request_logprobs = item.request_logprobs or request_logprobs_default
kwargs = dict(
model=model_name,
messages=messages,
max_tokens=max_tokens,
temperature=temperature,
top_p=top_p,
timeout=timeout,
)
if request_logprobs:
kwargs["logprobs"] = True
# Structured output: prefer OpenAI-standard response_format (works on both
# vllm and llama.cpp). Fall back to vllm's extra_body for guided_regex.
if item.guided_json is not None:
kwargs["response_format"] = {
"type": "json_schema",
"json_schema": {
"name": "structured_output",
"schema": item.guided_json,
"strict": True,
},
}
if item.guided_regex is not None:
kwargs.setdefault("extra_body", {})["guided_regex"] = item.guided_regex
try:
with timing_span(
"openai_chat_completion",
prompt_type=item.prompt_type,
max_tokens=max_tokens,
):
completion = client.chat.completions.create(**kwargs)
raw = completion.choices[0].message.content or ""
token_count = completion.usage.completion_tokens if completion.usage else 0
with timing_span(
"openai_parse_completion",
prompt_type=item.prompt_type,
token_count=token_count,
):
mean_p = None
logprobs_content = None
if request_logprobs:
choice = completion.choices[0]
lp = getattr(choice, "logprobs", None)
if lp is not None:
content = getattr(lp, "content", None)
if content is not None:
logprobs_content = [
c.model_dump() if hasattr(c, "model_dump") else c
for c in content
]
mean_p = _mean_token_prob(content)
return GenerationResult(
raw=raw,
token_count=token_count,
error=False,
mean_token_prob=mean_p,
logprobs=logprobs_content,
)
except Exception as e:
logger.warning(f"Inference error: {e}")
return GenerationResult(raw="", token_count=0, error=True)
def _should_retry(
result: GenerationResult,
retries: int,
max_retries: int,
) -> bool:
if retries >= max_retries:
return False
if result.error:
return True
has_repeat = detect_repeat_token(result.raw) or (
len(result.raw) > 50 and detect_repeat_token(result.raw, cut_from_end=50)
)
return has_repeat
def chat_completions_batch(
batch: List[BatchInputItem],
client,
model_name: str,
max_tokens_default: int = 2048,
temperature: float = 0.0,
top_p: float = 0.1,
timeout: float = 600.0,
max_workers: Optional[int] = None,
max_retries: int = 3,
request_logprobs_default: bool = True,
) -> List[BatchOutputItem]:
"""Run a batch of items through the chat completions endpoint with concurrent workers."""
if not batch:
return []
if max_workers is None:
max_workers = min(64, len(batch))
collector = get_current_timing()
def _process(item: BatchInputItem) -> BatchOutputItem:
if collector is not None:
set_current_timing(collector)
result = _generate_one(
item,
client=client,
model_name=model_name,
max_tokens_default=max_tokens_default,
temperature=temperature,
top_p=top_p,
timeout=timeout,
request_logprobs_default=request_logprobs_default,
)
retries = 0
while _should_retry(result, retries, max_retries):
backoff = 1.5 * (retries + 1) if result.error else 0
if backoff:
time.sleep(backoff)
retry_temp = min(temperature + 0.2 * (retries + 1), 0.8)
retry_top_p = 0.95 if not result.error else top_p
result = _generate_one(
item,
client=client,
model_name=model_name,
max_tokens_default=max_tokens_default,
temperature=retry_temp,
top_p=retry_top_p,
timeout=timeout,
request_logprobs_default=request_logprobs_default,
)
retries += 1
return BatchOutputItem(
raw=result.raw,
token_count=result.token_count,
error=result.error,
mean_token_prob=result.mean_token_prob,
logprobs=result.logprobs,
metadata=item.metadata,
)
with timing_span(
"openai_batch_threadpool",
item_count=len(batch),
max_workers=max_workers,
max_retries=max_retries,
):
with ThreadPoolExecutor(max_workers=max_workers) as executor:
return list(executor.map(_process, batch))
+351
View File
@@ -0,0 +1,351 @@
"""Server lifecycle: probe, filelock, sentinel, atexit cleanup.
Pattern: probe `/health` → if alive return handle → else acquire lock, re-probe,
spawn detached, write sentinel, register atexit kill (only the spawner cleans up).
"""
from __future__ import annotations
import atexit
import json
import os
import socket
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
import httpx
from surya.logging import get_logger
from surya.settings import settings
logger = get_logger()
def _cache_dir() -> Path:
base = Path(os.path.expanduser("~/.cache/datalab/surya"))
base.mkdir(parents=True, exist_ok=True)
return base
def _sentinel_path(backend: str) -> Path:
return _cache_dir() / f"{backend}_server.json"
def _lock_path(backend: str) -> Path:
return _cache_dir() / f"{backend}_server.lock"
def find_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def probe_health(base_url: str, timeout: float = 1.0) -> bool:
"""Returns True if the server reports healthy at /health."""
try:
# llama.cpp returns 200 on /health when ready; vllm returns 200 on /health too.
with httpx.Client(timeout=timeout) as client:
r = client.get(f"{base_url}/health")
return r.status_code == 200
except Exception:
return False
def wait_for_health(
base_url: str, total_timeout: float = 300.0, interval: float = 1.0
) -> bool:
deadline = time.time() + total_timeout
while time.time() < deadline:
if probe_health(base_url):
return True
time.sleep(interval)
return False
def probe_model_id(openai_base: str, timeout: float = 5.0) -> Optional[str]:
"""Returns the model id reported by the running server, or None on failure."""
try:
with httpx.Client(timeout=timeout) as client:
r = client.get(f"{openai_base}/models")
r.raise_for_status()
data = r.json()
models = data.get("data") or []
if models:
return models[0].get("id")
except Exception:
return None
return None
@dataclass
class SpawnedServer:
base_url: str # full openai base, e.g. "http://127.0.0.1:8765/v1"
health_url: str # base for /health, e.g. "http://127.0.0.1:8765"
model_name: str # what to pass as `model`
pid: Optional[int]
backend: str
spawned_by_us: bool
class SpawnError(RuntimeError):
pass
def _read_sentinel(backend: str) -> Optional[dict]:
p = _sentinel_path(backend)
if not p.exists():
return None
try:
return json.loads(p.read_text())
except Exception:
return None
def _write_sentinel(backend: str, data: dict) -> None:
_sentinel_path(backend).write_text(json.dumps(data))
def _delete_sentinel(backend: str) -> None:
p = _sentinel_path(backend)
if p.exists():
try:
p.unlink()
except Exception:
pass
def _stop_process(pid: int, name: str) -> None:
try:
# Graceful first
os.kill(pid, 15) # SIGTERM
for _ in range(20):
try:
os.kill(pid, 0) # still alive?
except ProcessLookupError:
logger.info(f"Stopped {name} (pid {pid})")
return
time.sleep(0.5)
# Hard
os.kill(pid, 9)
logger.warning(f"Force-killed {name} (pid {pid})")
except ProcessLookupError:
pass
except Exception as e:
logger.warning(f"Failed to stop {name} (pid {pid}): {e}")
def _capture_server_logs(handle: "SpawnHandle", tail: int = 100) -> str:
"""Best-effort tail of a server's logs, for surfacing startup failures."""
try:
if handle.cleanup_kind == "docker":
r = subprocess.run(
["docker", "logs", "--tail", str(tail), handle.cleanup_id],
capture_output=True,
text=True,
timeout=15,
)
return (r.stdout or "") + (r.stderr or "") or "(no docker logs)"
# llama.cpp process backend logs to this file (see llamacpp.py)
log_path = Path("~/.cache/datalab/surya/llamacpp_server.log").expanduser()
if log_path.exists():
lines = log_path.read_text(errors="replace").splitlines()
return "\n".join(lines[-tail:]) or "(empty log)"
except Exception as e:
return f"(could not capture logs: {e})"
return "(no logs available)"
def _stop_docker_container(name: str) -> None:
try:
subprocess.run(
["docker", "stop", name], check=False, capture_output=True, timeout=30
)
logger.info(f"Stopped docker container {name}")
except Exception as e:
logger.warning(f"Failed to stop docker container {name}: {e}")
def attach_or_spawn(
backend: str,
expected_model_name: str,
spawn_fn: Callable[[int], "SpawnHandle"],
health_url_for: Callable[[int], str],
openai_url_for: Callable[[int], str],
startup_timeout: float = 600.0,
) -> SpawnedServer:
"""Generic attach-or-spawn with file lock and sentinel.
`spawn_fn(port)` must launch the server detached and return a SpawnHandle
with `pid` (int or None for docker) and a `cleanup_id` (e.g. container name).
"""
# 0. If user pinned an external URL, attach without lock
if settings.SURYA_INFERENCE_URL:
base_url = settings.SURYA_INFERENCE_URL.rstrip("/")
health_url = base_url[: -len("/v1")] if base_url.endswith("/v1") else base_url
if not probe_health(health_url):
raise SpawnError(
f"SURYA_INFERENCE_URL={base_url} is not reachable at /health. "
"Start the server or unset the variable."
)
model_name = probe_model_id(base_url) or expected_model_name
if model_name != expected_model_name:
raise SpawnError(
f"Model mismatch at {base_url}: expected {expected_model_name!r}, got {model_name!r}. "
"Stop the running server or unset SURYA_INFERENCE_URL."
)
return SpawnedServer(
base_url=base_url,
health_url=health_url,
model_name=model_name,
pid=None,
backend=backend,
spawned_by_us=False,
)
# 1. Probe sentinel without lock
existing = _read_sentinel(backend)
if existing:
port = existing.get("port")
pid = existing.get("pid")
if port and probe_health(health_url_for(port)):
running_model = probe_model_id(openai_url_for(port)) or expected_model_name
if running_model != expected_model_name:
raise SpawnError(
f"Existing {backend} server on port {port} serves {running_model!r}, "
f"expected {expected_model_name!r}. Stop it before continuing."
)
logger.info(f"Attaching to existing {backend} server on port {port}")
return SpawnedServer(
base_url=openai_url_for(port),
health_url=health_url_for(port),
model_name=running_model,
pid=pid,
backend=backend,
spawned_by_us=False,
)
else:
_delete_sentinel(backend)
if not settings.SURYA_INFERENCE_AUTOSTART:
raise SpawnError(
f"No running {backend} server and SURYA_INFERENCE_AUTOSTART is False. "
"Set the variable to True or start the server manually."
)
# 2. Acquire filelock to prevent races
try:
from filelock import FileLock
except ImportError as e:
raise SpawnError(
"filelock is required for server spawn. pip install filelock"
) from e
lock = FileLock(str(_lock_path(backend)), timeout=120)
with lock:
# Re-check sentinel inside the lock
existing = _read_sentinel(backend)
if existing:
port = existing.get("port")
if port and probe_health(health_url_for(port)):
running_model = (
probe_model_id(openai_url_for(port)) or expected_model_name
)
if running_model != expected_model_name:
raise SpawnError(
f"Existing {backend} server on port {port} serves {running_model!r}, "
f"expected {expected_model_name!r}."
)
return SpawnedServer(
base_url=openai_url_for(port),
health_url=health_url_for(port),
model_name=running_model,
pid=existing.get("pid"),
backend=backend,
spawned_by_us=False,
)
# 3. Spawn fresh
port = settings.SURYA_INFERENCE_PORT or find_free_port()
logger.info(f"Spawning {backend} server on port {port}")
spawn_handle = spawn_fn(port)
# 4. Write sentinel
_write_sentinel(
backend,
{
"port": port,
"pid": spawn_handle.pid,
"model": expected_model_name,
"backend": backend,
"cleanup_id": spawn_handle.cleanup_id,
"cleanup_kind": spawn_handle.cleanup_kind,
},
)
# 5. Register atexit cleanup (only spawner). Skipped when keep-alive is
# set so the server outlives this process and later commands attach to
# it via the sentinel. (_cleanup is still callable below on startup
# failure, where we always tear a half-started server down.)
def _cleanup():
try:
if spawn_handle.cleanup_kind == "docker":
_stop_docker_container(spawn_handle.cleanup_id)
elif spawn_handle.cleanup_kind == "process":
if spawn_handle.pid:
_stop_process(spawn_handle.pid, backend)
finally:
_delete_sentinel(backend)
if settings.SURYA_INFERENCE_KEEP_ALIVE:
logger.info(
f"keep-alive: {backend} server on port {port} will stay up "
f"after exit (cleanup_id={spawn_handle.cleanup_id!r})"
)
else:
atexit.register(_cleanup)
# 6. Wait for health
health_url = health_url_for(port)
if not wait_for_health(health_url, total_timeout=startup_timeout):
# Grab the server's own logs *before* cleanup tears the (--rm)
# container down, otherwise the actual failure reason is lost and
# all the caller sees is this timeout.
logs = _capture_server_logs(spawn_handle)
_cleanup()
raise SpawnError(
f"{backend} server failed to become healthy at {health_url} "
f"within {startup_timeout}s.\n"
f"--- last {backend} server logs ---\n{logs}"
)
# 7. Verify model name
running_model = probe_model_id(openai_url_for(port))
if running_model and running_model != expected_model_name:
logger.warning(
f"{backend} server reports model={running_model!r} "
f"but expected {expected_model_name!r}; using reported name."
)
expected_model_name = running_model
logger.info(
f"{backend} server ready on port {port} (model={expected_model_name})"
)
return SpawnedServer(
base_url=openai_url_for(port),
health_url=health_url,
model_name=expected_model_name,
pid=spawn_handle.pid,
backend=backend,
spawned_by_us=True,
)
@dataclass
class SpawnHandle:
pid: Optional[int]
cleanup_id: str # container name for docker, str(pid) for process
cleanup_kind: str # "docker" | "process"
+224
View File
@@ -0,0 +1,224 @@
"""vllm backend: spawns the vllm/vllm-openai docker image with MTP=2."""
from __future__ import annotations
import json
import math
import os
import shutil
import subprocess
from typing import List, Optional
from openai import OpenAI
from surya.inference.backends.base import Backend, ServerHandle
from surya.inference.backends.openai_client import chat_completions_batch, resolve_max_workers
from surya.inference.backends.spawn import (
SpawnHandle,
SpawnError,
attach_or_spawn,
)
from surya.inference.schema import BatchInputItem, BatchOutputItem
from surya.logging import get_logger
from surya.settings import settings
from surya.timing import timing_span
logger = get_logger()
# 24GB baseline (re-tune for surya-2 once benchmarks land)
BASELINE_VRAM_GB = 24
BASELINE_MAX_BATCHED_TOKENS = 8192
BASELINE_MAX_NUM_SEQS = 32
GPU_VRAM_GB = {
"b300": 270,
"b200": 180,
"h200": 141,
"h100": 80,
"a100-80": 80,
"a100": 40,
"a100-40": 40,
"l40s": 48,
"a10": 24,
"l4": 24,
"5090": 32,
"4090": 24,
"3090": 24,
"t4": 16,
}
def _gpu_settings(gpu: str) -> tuple[int, int]:
vram = GPU_VRAM_GB.get(gpu)
if vram is None:
available = ", ".join(sorted(GPU_VRAM_GB.keys()))
raise SpawnError(f"Unknown VLLM_GPU_TYPE {gpu!r}. Available: {available}")
ratio = vram / BASELINE_VRAM_GB
raw_tokens = BASELINE_MAX_BATCHED_TOKENS * ratio
max_batched_tokens = max(1024, 2 ** math.floor(math.log2(raw_tokens)))
max_num_seqs = max(8, (int(BASELINE_MAX_NUM_SEQS * ratio) // 8) * 8)
return max_batched_tokens, max_num_seqs
def _resolve_docker_binary() -> str:
found = shutil.which("docker")
if found:
return found
raise SpawnError(
"docker binary not found. Install Docker (https://docs.docker.com/get-docker/) "
"and ensure the daemon is running."
)
def _health_url(port: int) -> str:
return f"http://{settings.SURYA_INFERENCE_HOST}:{port}"
def _openai_url(port: int) -> str:
return f"http://{settings.SURYA_INFERENCE_HOST}:{port}/v1"
class VllmBackend(Backend):
name = "vllm"
def __init__(self):
self.handle: Optional[ServerHandle] = None
self._client: Optional[OpenAI] = None
def start(self) -> ServerHandle:
if self.handle is not None:
return self.handle
# If user pinned an external server, attach without spawning docker.
if settings.SURYA_INFERENCE_URL:
spawned = attach_or_spawn(
backend=self.name,
expected_model_name=settings.SURYA_MODEL_CHECKPOINT,
spawn_fn=lambda port: SpawnHandle(
pid=None, cleanup_id="", cleanup_kind="docker"
),
health_url_for=_health_url,
openai_url_for=_openai_url,
startup_timeout=settings.SURYA_INFERENCE_STARTUP_TIMEOUT,
)
self.handle = ServerHandle(
base_url=spawned.base_url,
model_name=spawned.model_name,
spawned_by_us=spawned.spawned_by_us,
)
self._client = OpenAI(
api_key=settings.VLLM_API_KEY, base_url=self.handle.base_url
)
return self.handle
if os.getenv("SUYA_ALLOW_NESTED_DOCKER", "false").lower() not in {"1", "true", "yes"}:
raise SpawnError(
"Nested Docker vLLM startup is disabled. Start vLLM in this "
"container and set SURYA_INFERENCE_URL, for example "
"http://127.0.0.1:8000/v1."
)
docker = _resolve_docker_binary()
max_batched_tokens, max_num_seqs = _gpu_settings(settings.VLLM_GPU_TYPE)
def spawn_fn(port: int) -> SpawnHandle:
container_name = f"surya-vllm-{port}"
hf_cache = os.path.expanduser(settings.DOCKER_HF_CACHE_PATH)
cmd = [
docker,
"run",
"--rm",
"-d",
"--name",
container_name,
"--runtime",
"nvidia",
"--gpus",
f"device={settings.VLLM_GPUS}",
"-v",
f"{hf_cache}:/root/.cache/huggingface",
"-p",
f"{port}:8000",
"--ipc=host",
settings.VLLM_DOCKER_IMAGE,
"--model",
settings.SURYA_MODEL_CHECKPOINT,
"--no-enforce-eager",
"--max-num-seqs",
str(max_num_seqs),
"--dtype",
settings.VLLM_DTYPE,
"--max-model-len",
str(settings.VLLM_MAX_MODEL_LEN),
"--max-num-batched-tokens",
str(max_batched_tokens),
"--gpu-memory-utilization",
str(settings.VLLM_GPU_MEMORY_UTILIZATION),
"--enable-prefix-caching",
"--mm-processor-kwargs",
json.dumps({"min_pixels": 3136, "max_pixels": 6291456}),
"--served-model-name",
settings.SURYA_MODEL_CHECKPOINT,
]
if settings.VLLM_ENABLE_MTP:
spec_config = json.dumps(
{
"method": "mtp",
"num_speculative_tokens": settings.VLLM_MTP_TOKENS,
}
)
cmd.extend(["--speculative-config", spec_config])
for extra in (settings.VLLM_EXTRA_ARGS or "").split():
cmd.append(extra)
logger.info(f"Spawning: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
raise SpawnError(f"docker run failed: {result.stderr or result.stdout}")
return SpawnHandle(
pid=None, cleanup_id=container_name, cleanup_kind="docker"
)
spawned = attach_or_spawn(
backend=self.name,
expected_model_name=settings.SURYA_MODEL_CHECKPOINT,
spawn_fn=spawn_fn,
health_url_for=_health_url,
openai_url_for=_openai_url,
startup_timeout=settings.SURYA_INFERENCE_STARTUP_TIMEOUT,
)
self.handle = ServerHandle(
base_url=spawned.base_url,
model_name=spawned.model_name,
spawned_by_us=spawned.spawned_by_us,
)
self._client = OpenAI(
api_key=settings.VLLM_API_KEY,
base_url=self.handle.base_url,
)
return self.handle
def stop(self) -> None:
self.handle = None
self._client = None
def generate(self, batch: List[BatchInputItem]) -> List[BatchOutputItem]:
if self.handle is None or self._client is None:
with timing_span("vllm_backend_start"):
self.start()
with timing_span(
"vllm_backend_generate",
item_count=len(batch),
parallel=settings.SURYA_INFERENCE_PARALLEL,
):
return chat_completions_batch(
batch,
client=self._client,
model_name=self.handle.model_name,
timeout=settings.SURYA_INFERENCE_TIMEOUT_SECONDS,
max_workers=resolve_max_workers(
len(batch), settings.SURYA_INFERENCE_MAX_INFLIGHT
),
max_retries=settings.SURYA_INFERENCE_MAX_RETRIES,
request_logprobs_default=settings.SURYA_INFERENCE_LOGPROBS,
)
+182
View File
@@ -0,0 +1,182 @@
"""Parsers for the three task outputs."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from typing import List, Tuple
from surya.logging import get_logger
logger = get_logger()
# ---- Layout (LAYOUT_PROMPT) -------------------------------------------------
@dataclass
class ParsedLayoutBlock:
label: str
bbox: Tuple[float, float, float, float] # 0-1000 normalized
count: int # multiple of 50, model's token estimate
_JSON_ARRAY_RE = re.compile(r"\[.*\]", re.DOTALL)
def _strip_fences(text: str) -> str:
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = re.sub(r"^```[a-zA-Z]*\n", "", cleaned)
cleaned = re.sub(r"\n```\s*$", "", cleaned)
return cleaned
def _coerce_bbox(bbox) -> Tuple[float, float, float, float]:
if isinstance(bbox, str):
parts = [float(x) for x in bbox.replace(",", " ").split()]
else:
parts = [float(x) for x in bbox]
if len(parts) != 4:
raise ValueError(f"Bad bbox: {bbox!r}")
return (parts[0], parts[1], parts[2], parts[3])
def _coerce_count(value) -> int:
if value is None:
return 0
try:
return max(0, int(value))
except (TypeError, ValueError):
return 0
def parse_layout(text: str) -> List[ParsedLayoutBlock]:
"""Pull the JSON array out of LAYOUT_PROMPT output and convert to typed blocks.
Tolerates code fences, missing fields, and stringified bboxes.
"""
cleaned = _strip_fences(text)
m = _JSON_ARRAY_RE.search(cleaned)
if not m:
raise ValueError(f"No JSON array found in layout output: {text[:500]!r}")
raw = json.loads(m.group(0))
out: List[ParsedLayoutBlock] = []
for item in raw:
try:
bbox = _coerce_bbox(item["bbox"])
except (KeyError, ValueError) as e:
logger.warning(f"Skipping layout block with bad bbox: {e}")
continue
label = str(item.get("label", "block"))
count = _coerce_count(item.get("count"))
out.append(ParsedLayoutBlock(label=label, bbox=bbox, count=count))
return out
# ---- Table rec (TABLE_REC_PROMPT) ------------------------------------------
@dataclass
class ParsedTableElement:
label: str # "Row" or "Col"
bbox: Tuple[float, float, float, float]
def parse_table_rec(text: str) -> List[ParsedTableElement]:
"""Parse JSON array of {label: "Row"|"Col", bbox: "x0 y0 x1 y1"} from
TABLE_REC_PROMPT output. Returns a flat list of Row + Col elements;
cell derivation is the caller's job."""
cleaned = _strip_fences(text)
m = _JSON_ARRAY_RE.search(cleaned)
if not m:
raise ValueError(f"No JSON array found in table_rec output: {text[:500]!r}")
raw = json.loads(m.group(0))
out: List[ParsedTableElement] = []
for item in raw:
label = str(item.get("label", "")).strip()
if label not in ("Row", "Col"):
continue
try:
bbox = _coerce_bbox(item["bbox"])
except (KeyError, ValueError):
continue
out.append(ParsedTableElement(label=label, bbox=bbox))
return out
# ---- Block HTML (BLOCK_PROMPT for full table path / general block path) ---
def clean_block_html(html: str) -> str:
"""Light cleanup of model-emitted HTML for a single block.
Strips code fences, leading/trailing whitespace. Does NOT validate against
ALLOWED_TAGS — the model is expected to comply, and downstream consumers
can sanitize further if needed.
"""
cleaned = _strip_fences(html).strip()
return cleaned
# ---- Full-page fallback (HIGH_ACCURACY_BBOX_PROMPT) -----------------------
@dataclass
class ParsedFullPageBlock:
label: str
bbox: Tuple[float, float, float, float] # 0-1000 normalized
html: str # inner HTML of the wrapping div
def parse_full_page_html(text: str) -> List[ParsedFullPageBlock]:
"""Parse output of HIGH_ACCURACY_BBOX_PROMPT — top-level <div data-bbox=...
data-label=...>inner HTML</div> blocks. Returns one entry per top-level div."""
from bs4 import BeautifulSoup
cleaned = _strip_fences(text).strip()
if not cleaned:
return []
# The model outputs a sequence of top-level divs (no surrounding root).
# BeautifulSoup parses fine without one.
soup = BeautifulSoup(cleaned, "html.parser")
divs = soup.find_all("div", recursive=False)
out: List[ParsedFullPageBlock] = []
for div in divs:
label = div.get("data-label")
bbox_str = div.get("data-bbox")
if not label or not bbox_str:
continue
try:
parts = [float(x) for x in bbox_str.split()]
except ValueError:
continue
if len(parts) != 4:
continue
# Strip nested data-bbox attrs from the inner HTML so downstream
# consumers don't see model debug info on every child element.
for tag in div.find_all(attrs={"data-bbox": True}):
del tag["data-bbox"]
for tag in div.find_all(attrs={"data-label": True}):
del tag["data-label"]
inner = "".join(str(c) for c in div.contents).strip()
out.append(
ParsedFullPageBlock(
label=str(label),
bbox=(parts[0], parts[1], parts[2], parts[3]),
html=inner,
)
)
return out
def denorm_bbox(bbox, img_w: int, img_h: int, scale: int = 1000):
x0, y0, x1, y1 = bbox
return (
x0 / scale * img_w,
y0 / scale * img_h,
x1 / scale * img_w,
y1 / scale * img_h,
)
+158
View File
@@ -0,0 +1,158 @@
"""Prompt strings for surya2. The exact wording is the model's training-time
contract — do not paraphrase without retraining."""
from surya.inference.schema import PROMPT_TYPE_BLOCK as PROMPT_TYPE_BLOCK
from surya.inference.schema import (
PROMPT_TYPE_HIGH_ACCURACY_BBOX as PROMPT_TYPE_HIGH_ACCURACY_BBOX,
)
from surya.inference.schema import PROMPT_TYPE_LAYOUT as PROMPT_TYPE_LAYOUT
from surya.inference.schema import PROMPT_TYPE_TABLE_REC as PROMPT_TYPE_TABLE_REC
ALLOWED_TAGS = [
"math",
"br",
"i",
"b",
"u",
"del",
"sup",
"sub",
"table",
"tr",
"td",
"p",
"th",
"div",
"pre",
"h1",
"h2",
"h3",
"h4",
"h5",
"ul",
"ol",
"li",
"input",
"a",
"span",
"img",
"hr",
"tbody",
"small",
"caption",
"strong",
"thead",
"big",
"code",
"chem",
]
ALLOWED_ATTRIBUTES = [
"class",
"colspan",
"rowspan",
"display",
"checked",
"type",
"border",
"value",
"style",
"href",
"alt",
"align",
"data-bbox",
"data-label",
]
# Block labels we don't run OCR on.
SKIP_OCR_LABELS = {"Figure", "Image", "Diagram", "Blank-Page"}
LAYOUT_PROMPT = (
"Output the layout of this image as JSON. Each entry is a dict with "
'"label", "bbox", and "count" fields. Bbox is x0 y0 x1 y1, normalized 0-1000.'
)
BLOCK_PROMPT = "OCR this block image to HTML."
TABLE_REC_PROMPT = (
"Output the table rows then columns as JSON. Each entry is a dict with "
'"label" ("Row" or "Col") and "bbox" (x0 y0 x1 y1, normalized 0-1000).'
)
HIGH_ACCURACY_BBOX_PROMPT = (
"OCR this image to HTML. Each block is a div with data-label and data-bbox "
"(x0 y0 x1 y1, normalized 0-1000)."
)
PROMPT_MAPPING = {
"layout": LAYOUT_PROMPT,
"block": BLOCK_PROMPT,
"table_rec": TABLE_REC_PROMPT,
"high_accuracy_bbox": HIGH_ACCURACY_BBOX_PROMPT,
}
# JSON schema for LAYOUT_PROMPT — enforced via vllm guided decoding so the
# model can't emit malformed JSON. bbox is a "x0 y0 x1 y1" string (model's
# training-time format); count is a non-negative integer.
LAYOUT_LABEL_SET = [
"Caption",
"Footnote",
"Equation-Block",
"List-Group",
"Page-Header",
"Page-Footer",
"Image",
"Section-Header",
"Table",
"Text",
"Complex-Block",
"Code-Block",
"Form",
"Table-Of-Contents",
"Figure",
"Chemical-Block",
"Diagram",
"Bibliography",
"Blank-Page",
]
LAYOUT_JSON_SCHEMA = {
"type": "array",
"maxItems": 200,
"items": {
"type": "object",
"properties": {
"label": {"type": "string", "enum": LAYOUT_LABEL_SET},
"bbox": {
"type": "string",
"pattern": r"^\d{1,4} \d{1,4} \d{1,4} \d{1,4}$",
},
"count": {"type": "integer", "minimum": 0, "maximum": 10000},
},
"required": ["label", "bbox", "count"],
"additionalProperties": False,
},
}
# JSON schema for TABLE_REC_PROMPT — array of {label: Row|Col, bbox: "x0 y0 x1 y1"}.
TABLE_REC_LABEL_SET = ["Row", "Col"]
TABLE_REC_JSON_SCHEMA = {
"type": "array",
"maxItems": 200,
"items": {
"type": "object",
"properties": {
"label": {"type": "string", "enum": TABLE_REC_LABEL_SET},
"bbox": {
"type": "string",
"pattern": r"^\d{1,4} \d{1,4} \d{1,4} \d{1,4}$",
},
},
"required": ["label", "bbox"],
"additionalProperties": False,
},
}
+45
View File
@@ -0,0 +1,45 @@
from dataclasses import dataclass, field
from typing import Any, List, Optional
from PIL import Image
PROMPT_TYPE_LAYOUT = "layout"
PROMPT_TYPE_BLOCK = "block"
PROMPT_TYPE_TABLE_REC = "table_rec"
PROMPT_TYPE_HIGH_ACCURACY_BBOX = "high_accuracy_bbox"
@dataclass
class BatchInputItem:
image: Image.Image
prompt_type: str
prompt: Optional[str] = None # If set, overrides the default prompt for prompt_type
max_tokens: Optional[int] = None
request_logprobs: bool = False
# vllm-native guided decoding — JSON schema, regex, or grammar string.
# When set, the server constrains the decode tokens to match the schema.
guided_json: Optional[dict] = None
guided_regex: Optional[str] = None
metadata: dict = field(default_factory=dict) # Free-form, passes through to output
@dataclass
class GenerationResult:
raw: str
token_count: int
error: bool = False
# Mean of exp(logprob) across response tokens, if logprobs requested
mean_token_prob: Optional[float] = None
# Per-token logprobs (raw OpenAI-style content list), if requested - phase 2 use
logprobs: Optional[List[Any]] = None
@dataclass
class BatchOutputItem:
raw: str
token_count: int
error: bool
mean_token_prob: Optional[float] = None
logprobs: Optional[List[Any]] = None
metadata: dict = field(default_factory=dict)
+96
View File
@@ -0,0 +1,96 @@
from typing import Tuple
from PIL import Image
def scale_to_fit(
img: Image.Image,
max_size: Tuple[int, int] = (3072, 2048),
min_size: Tuple[int, int] = (1792, 28),
grid_size: int = 28,
) -> Image.Image:
resample_method = Image.Resampling.LANCZOS
width, height = img.size
if width <= 0 or height <= 0:
return img
original_ar = width / height
current_pixels = width * height
max_pixels = max_size[0] * max_size[1]
min_pixels = min_size[0] * min_size[1]
scale = 1.0
if current_pixels > max_pixels:
scale = (max_pixels / current_pixels) ** 0.5
elif current_pixels < min_pixels:
scale = (min_pixels / current_pixels) ** 0.5
w_blocks = max(1, round((width * scale) / grid_size))
h_blocks = max(1, round((height * scale) / grid_size))
while (w_blocks * h_blocks * grid_size * grid_size) > max_pixels:
if w_blocks == 1 and h_blocks == 1:
break
if w_blocks == 1:
h_blocks -= 1
continue
if h_blocks == 1:
w_blocks -= 1
continue
ar_w_loss = abs(((w_blocks - 1) / h_blocks) - original_ar)
ar_h_loss = abs((w_blocks / (h_blocks - 1)) - original_ar)
if ar_w_loss < ar_h_loss:
w_blocks -= 1
else:
h_blocks -= 1
new_width = w_blocks * grid_size
new_height = h_blocks * grid_size
if (new_width, new_height) == (width, height):
return img
return img.resize((new_width, new_height), resample=resample_method)
def detect_repeat_token(
predicted_tokens: str,
base_max_repeats: int = 4,
window_size: int = 500,
cut_from_end: int = 0,
scaling_factor: float = 3.0,
) -> bool:
if cut_from_end > 0:
predicted_tokens = predicted_tokens[:-cut_from_end]
for seq_len in range(1, window_size // 2 + 1):
candidate_seq = predicted_tokens[-seq_len:]
max_repeats = int(base_max_repeats * (1 + scaling_factor / seq_len))
repeat_count = 0
pos = len(predicted_tokens) - seq_len
if pos < 0:
continue
while pos >= 0:
if predicted_tokens[pos : pos + seq_len] == candidate_seq:
repeat_count += 1
pos -= seq_len
else:
break
if repeat_count > max_repeats:
return True
return False
def image_token_budget(block_count: int, ceiling: int = 4096, floor: int = 64) -> int:
"""Per-block max_tokens: count + 100, clamped to [floor, ceiling]."""
return min(max(block_count + 100, floor), ceiling)