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
+96
View File
@@ -0,0 +1,96 @@
import base64
import io
from unittest.mock import patch
from fastapi.testclient import TestClient
from PIL import Image
def _png_data_url() -> str:
buf = io.BytesIO()
Image.new("RGB", (8, 8), "white").save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return f"data:image/png;base64,{b64}"
def _client() -> TestClient:
from surya.endpoint.app import app
return TestClient(app)
def _image_message():
return [
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": _png_data_url()}},
{"type": "text", "text": "extract the text"},
],
}
]
def test_returns_openai_chat_completion_shape():
body = {"model": "surya-ocr", "messages": _image_message()}
fake = {"text_lines": "hello\nworld", "ocr_text_json": {"blocks": []}, "elapsed_seconds": 1.23}
with patch("surya.endpoint.service.ocr_via_batcher", return_value=fake):
r = _client().post("/v1/chat/completions", json=body)
assert r.status_code == 200
d = r.json()
assert d["object"] == "chat.completion"
assert d["model"] == "surya-ocr"
assert d["choices"][0]["message"]["content"] == "hello\nworld"
assert d["choices"][0]["finish_reason"] == "stop"
assert d["surya"]["ocr_text_json"] == {"blocks": []}
assert d["surya"]["elapsed_seconds"] == 1.23
assert "usage" in d
def test_mode_full_page_skips_text_detection():
body = {"model": "m", "mode": "full_page", "messages": _image_message()}
fake = {"text_lines": "x", "ocr_text_json": {}, "elapsed_seconds": None}
with patch("surya.endpoint.service.ocr_via_batcher", return_value=fake) as m:
r = _client().post("/v1/chat/completions", json=body)
assert r.status_code == 200
assert m.call_args.kwargs["skip_text_detection"] is True
def test_mode_table_routes_to_table_image():
body = {"model": "m", "mode": "table", "messages": _image_message()}
fake = {"text_lines": "tbl", "ocr_text_json": {"x": 1}, "elapsed_seconds": None}
with patch("surya.endpoint.service.table_image", return_value=fake) as m:
r = _client().post("/v1/chat/completions", json=body)
assert r.status_code == 200
assert m.called
assert r.json()["choices"][0]["message"]["content"] == "tbl"
def test_ocr_with_boxes_false_omits_structured_json():
body = {"model": "m", "ocr_with_boxes": False, "messages": _image_message()}
fake = {"text_lines": "x", "ocr_text_json": {"a": 1}, "elapsed_seconds": None}
with patch("surya.endpoint.service.ocr_via_batcher", return_value=fake):
r = _client().post("/v1/chat/completions", json=body)
assert r.json()["surya"]["ocr_text_json"] is None
def test_stream_true_returns_400():
body = {"model": "m", "stream": True, "messages": _image_message()}
r = _client().post("/v1/chat/completions", json=body)
assert r.status_code == 400
assert r.json()["error"]["type"] == "invalid_request_error"
def test_missing_image_returns_400():
body = {"model": "m", "messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]}
r = _client().post("/v1/chat/completions", json=body)
assert r.status_code == 400
assert r.json()["error"]["type"] == "invalid_request_error"
def test_pipeline_error_returns_500():
body = {"model": "m", "messages": _image_message()}
with patch("surya.endpoint.service.ocr_via_batcher", side_effect=RuntimeError("boom")):
r = _client().post("/v1/chat/completions", json=body)
assert r.status_code == 500
assert r.json()["error"]["type"] == "internal_error"