DeepSeek's OCR-2, and the newer Dots.mocr β each handles different document types better than the others, and wrapping them in an OpenAI-compatible
/v1/chat/completions
interface means zero refactoring for existing callers.## Why bother with a unified wrapper
Most OCR APIs return plain text or markdown. These three return structured JSON with bounding boxes, confidence scores, and reading order β critical when you're feeding output into an RAG chunker or a layout-aware summarizer. But each vendor ships its own SDK, auth scheme, and response schema. A thin FastAPI layer normalizes all of that.
Architecture overview #
client β /v1/chat/completions (OpenAI schema)
β
βββ router picks model by `model` field
β βββ glm-ocr β Zhipu HTTP endpoint
β βββ deepseek-ocr-2 β DeepSeek HTTP endpoint
β βββ dots-mocr β local vLLM / TGI instance
β
βββ response normalizer β OpenAI `choices[0].message.content` (JSON string)
1. Spin up the normalizer service #
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Literal
import httpx, os, json
app = FastAPI()
class ChatRequest(BaseModel):
model: Literal["glm-ocr", "deepseek-ocr-2", "dots-mocr"]
messages: list[dict]
max_tokens: int = 4096
temperature: float = 0.0
ENDPOINTS = {
"glm-ocr": os.getenv("GLM_OCR_URL", "https://open.bigmodel.cn/api/paas/v4/chat/completions"),
"deepseek-ocr-2": os.getenv("DS_OCR_URL", "https://api.deepseek.com/v1/chat/completions"),
"dots-mocr": os.getenv("DOTS_URL", "http://localhost:8001/v1/chat/completions"),
}
HEADERS = {
"glm-ocr": {"Authorization": f"Bearer {os.getenv('GLM_API_KEY')}"},
"deepseek-ocr-2": {"Authorization": f"Bearer {os.getenv('DEEPSEEK_API_KEY')}"},
"dots-mocr": {"Authorization": f"Bearer {os.getenv('DOTS_API_KEY', 'local')}"},
}
async def call_upstream(model: str, payload: dict) -> dict:
async with httpx.AsyncClient(timeout=120) as client:
r = await client.post(ENDPOINTS[model], json=payload, headers=HEADERS[model])
r.raise_for_status()
return r.json()
def normalize(model: str, upstream: dict) -> dict:
"""Map each vendor's response to OpenAI shape with JSON content."""
if model == "glm-ocr":
raw = upstream["choices"][0]["message"]["content"]
elif model == "deepseek-ocr-2":
raw = upstream["choices"][0]["message"]["content"]
else: # dots-mocr already returns JSON string in content
raw = upstream["choices"][0]["message"]["content"]
json.loads(raw) # raises if malformed
return {
"id": upstream.get("id", "ocr-" + model),
"object": "chat.completion",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": raw},
"finish_reason": "stop"
}],
"usage": upstream.get("usage", {})
}
@app.post("/v1/chat/completions")
async def chat(req: ChatRequest):
if req.model not in ENDPOINTS:
raise HTTPException(400, f"Unknown model {req.model}")
user_msg = next((m for m in reversed(req.messages) if m["role"] == "user"), None)
if not user_msg or "image_url" not in user_msg.get("content", [{}])[0]:
raise HTTPException(400, "Expected image_url in last user message")
payload = {
"model": req.model,
"messages": req.messages,
"max_tokens": req.max_tokens,
"temperature": req.temperature,
}
upstream = await call_upstream(req.model, payload)
return normalize(req.model, upstream)
2. Deploy Dots.mocr locally (optional but recommended) #
Dots.mocr runs well on a single 24 GB VRAM GPU via vLLM:
docker run --gpus all -p 8001:8000 \
-v $PWD/models:/models \
vllm/vllm-openai:latest \
--model /models/dots-mocr \
--served-model-name dots-mocr \
--max-model-len 8192 \
--limit-mm-per-prompt image=4
Pull the model first:
huggingface-cli download DOTS-OCR/DOTS-OCR-2.0 --local-dir ./models/dots-mocr
3. Client usage stays identical #
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="dummy")
resp = client.chat.completions.create(
model="glm-ocr",
messages=[{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": "https://example.com/invoice.jpg"}}
]
}],
max_tokens=4096
)
print(resp.choices[0].message.content) # JSON string with cells, bbox, confidence
resp = client.chat.completions.create(
model="deepseek-ocr-2",
messages=[{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": "https://example.com/handwritten.png"}}
]
}]
)
resp = client.chat.completions.create(
model="dots-mocr",
messages=[{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": "file:///data/contract.pdf"}}
]
}]
)
4. Response schema you can count on #
All three normalize to this JSON structure inside content
:
{
"pages": [
{
"page_index": 0,
"width": 2480,
"height": 3508,
"blocks": [
{
"type": "table",
"bbox": [120, 340, 2360, 1200],
"confidence": 0.96,
"cells": [
{"row": 0, "col": 0, "text": "Item", "bbox": [130, 350, 400, 410]},
{"row": 0, "col": 1, "text": "Qty",
[Next The hype cycle promised mass adoption by 2024 β reality check β](/en/news/6968/)