ocr: Chandra, the document VLM that is asked for its layout (v0.3.24)

A fifth OCR engine. Chandra (datalab-to/chandra-ocr-2) is from the same lab as
Surya and is driven the way olmOCR is — one page image over an OpenAI chat
endpoint, three serving modes (model | vllm | server) — but its `ocr_layout`
prompt asks the model to answer as one <div> per layout block carrying
data-bbox (normalised 0-1000) and data-label. So it is the only engine here
that returns text, boxes, labelled regions, tables AND page metadata in one
pass, with no separate detector.

Why it was worth adding now: it is a DIFFERENT MODEL FAMILY from surya-2
(Qwen3-VL's tokenizer, against surya-2's own 65k WordLevel vocabulary) reached
over the same transport, at the same pixel ceiling, from the same rasterised
page. That makes it a cheap controlled experiment on the U+FFFD accent loss:
accents intact under chandra and lost under surya points at the model; both
losing them points back at the document's pixels, and then pdfimages -list is
the next thing to read.

Implementation notes:

- NO new pip dependency. The two prompts are mirrored verbatim from
  chandra/prompts.py (down to the Python repr of the allowed-tag list, which is
  trained-on wording), the input scaling is chandra's own scale_to_fit
  reimplemented, and the reply is split with stdlib html.parser rather than
  pulling bs4 and markdownify into the main venv for a tag split.
- Chandra's pixel cap is 3072x2048 = 6,291,456 — the SAME numbers as surya-2 —
  and it snaps to a 28-pixel grid for the Qwen vision tower. So chandra_dpi
  defaults to 250 for exactly the reason surya_dpi does. Boxes are
  de-normalised against the image the MODEL saw, and the size the caller
  rasterised is reported as meta.source_size when they differ: de-normalising
  against the pre-scaling size would put every box on a scaled page in the
  wrong place.
- A repetition loop answers 200. A VLM that loses its place repeats the last
  thing it saw until the token budget runs out, and the result looks like a
  successful page. chandra_max_retries (2) re-asks with a raised temperature —
  a greedy decode falls into the same loop twice — and a page that still loops
  is returned flagged with meta.repeat_detected rather than handed on silently.
- The parse never loses page. No blocks at all, content outside any block, an
  unclosed final div (routine at the token limit), a bbox that will not parse:
  each keeps the text. A bbox that cannot be read yields NO region rather than
  a full-page one, because a wrong box looks like a real answer.
- Table cells get a space, not a newline, so a table does not flatten to
  "VoceImporto".

License: narrower than Surya's GPL. The code is Apache-2.0 but the WEIGHTS are
a modified OpenRAIL-M — research, personal use and companies under $2M
funding/revenue, no use competing with Datalab's API. So it is gated on
chandra_accept_license, and the pod path differs from surya's on purpose:
asking a pod for surya by name IS the GPL acceptance, while chandra's is only
ever inherited from a decision already recorded in this install's config. A pod
cannot accept a licence on an operator's behalf.

lang= is accepted and does not steer it (language_aware: false), like surya-2
and olmOCR: 90+ languages, no language input. So it keeps ONE pool whatever is
asked — a language-keyed pool is a second set of loaded instances holding VRAM.

Wired through: engine registry, licence gate, per-engine DPI, the vLLM stop
path (its VRAM is the subprocess's, not a pool's), the capability catalogue
(which now reports boxes/regions/tables from chandra_layout, because with
layout off there genuinely are none), model-name aliasing, the automatic
fallback chain, pod planning + env seeding, the frontproxy's local-OCR check,
models.json capability advertisement, the pipeline step's engine list, admin
save/load and the Settings OCR card.

tests/test_ocr_chandra.py: 61 tests. 788 passed across the OCR/config/admin/
runpod selection with the same 10 pre-existing failures (broker_config x5,
studio_composed_surfaces x3, whisper_server x2).

test_ocr_fanout's catalogue test asserted a frozen engine set; it now asserts
against the registry, which is what "lists every engine" was always supposed to
mean.

Also fixed while in there: the Settings page used `parseInt(v) || default` for
numbers whose zero is meaningful, so a deliberate 0 saved as the default. New
_num() helper only falls back for an empty or unparseable field.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxYHWbCAjjjD7A1rhFKtaW
parent 2560d56b
......@@ -276,13 +276,18 @@ registered in the embedding model category.
### Document OCR
A dedicated OCR subsystem (`/v1/ocr`) — real OCR engines, **not** a VLM prompted to read,
plus one purpose-trained document VLM for the pages they struggle with:
plus two purpose-trained document VLMs for the pages they struggle with:
- **Engines**: `doctr` (in-process), `paddle` (PaddleOCR + PP-Structure layout/tables, isolated
venv), `surya` (opt-in, GPL; local, vLLM-served Surya-2, or an external llama-server) and
venv), `surya` (opt-in, GPL; local, vLLM-served Surya-2, or an external llama-server),
`olmocr` ([olmOCR-2](https://huggingface.co/allenai/olmOCR-2-7B-1025), Apache-2.0 — best
on old scans, maths and multi-column pages; served by one of your own models, coderai's
vLLM, or any OpenAI endpoint. Returns no bounding boxes.)
vLLM, or any OpenAI endpoint. Returns no bounding boxes.) and
`chandra` ([Chandra](https://huggingface.co/datalab-to/chandra-ocr-2) — a document VLM
that is *asked* for its layout, so unlike olmOCR it returns boxes, labelled regions and
tables in one pass, and being a different model family it is the useful second opinion on
a page surya reads badly. Opt-in: code Apache-2.0, **weights modified OpenRAIL-M** —
research, personal use and under-$2M companies, no competitive use.)
- **Input**: images or PDFs (rasterised at a configurable DPI), single or batch
- **Output**: full text plus per-page `lines[]` with bounding boxes and confidence,
`regions[]` (layout), `tables[]`, and `meta` where the engine reports page metadata
......
......@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here.
__version__ = "0.3.23"
__version__ = "0.3.24"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
......@@ -4166,6 +4166,22 @@ def build_settings_dict(c, gpu_cards):
"olmocr_temperature": c.ocr.olmocr_temperature,
"olmocr_timeout": c.ocr.olmocr_timeout,
"olmocr_retry_rotation": c.ocr.olmocr_retry_rotation,
"chandra_enabled": c.ocr.chandra_enabled,
"chandra_accept_license": c.ocr.chandra_accept_license,
"chandra_instances": c.ocr.chandra_instances,
"chandra_serve": c.ocr.chandra_serve,
"chandra_model_id": c.ocr.chandra_model_id,
"chandra_model": c.ocr.chandra_model,
"chandra_server_url": c.ocr.chandra_server_url,
"chandra_api_key": c.ocr.chandra_api_key,
"chandra_dpi": c.ocr.chandra_dpi,
"chandra_layout": c.ocr.chandra_layout,
"chandra_include_furniture": c.ocr.chandra_include_furniture,
"chandra_max_tokens": c.ocr.chandra_max_tokens,
"chandra_temperature": c.ocr.chandra_temperature,
"chandra_top_p": c.ocr.chandra_top_p,
"chandra_max_retries": c.ocr.chandra_max_retries,
"chandra_timeout": c.ocr.chandra_timeout,
"detect_mode": c.ocr.detect_mode,
"detect_model_path": c.ocr.detect_model_path,
"detect_conf": c.ocr.detect_conf,
......@@ -4968,6 +4984,23 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad
if "olmocr_temperature" in d: o.olmocr_temperature = _f("olmocr_temperature", o.olmocr_temperature, 0.0)
if "olmocr_timeout" in d: o.olmocr_timeout = _f("olmocr_timeout", o.olmocr_timeout, 1.0)
if "olmocr_retry_rotation" in d: o.olmocr_retry_rotation = bool(d["olmocr_retry_rotation"])
if "chandra_enabled" in d: o.chandra_enabled = bool(d["chandra_enabled"])
if "chandra_accept_license" in d: o.chandra_accept_license = bool(d["chandra_accept_license"])
if "chandra_instances" in d: o.chandra_instances = _i("chandra_instances", o.chandra_instances, 1)
if "chandra_serve" in d: o.chandra_serve = (d.get("chandra_serve") or "model").strip().lower()
if "chandra_model_id" in d: o.chandra_model_id = (d.get("chandra_model_id") or "").strip()
if "chandra_model" in d: o.chandra_model = (d.get("chandra_model") or "").strip()
if "chandra_server_url" in d: o.chandra_server_url = (d.get("chandra_server_url") or "").strip()
if "chandra_api_key" in d: o.chandra_api_key = (d.get("chandra_api_key") or "").strip()
if "chandra_dpi" in d: o.chandra_dpi = _i("chandra_dpi", o.chandra_dpi, 0)
if "chandra_layout" in d: o.chandra_layout = bool(d["chandra_layout"])
if "chandra_include_furniture" in d: o.chandra_include_furniture = bool(d["chandra_include_furniture"])
if "chandra_max_tokens" in d: o.chandra_max_tokens = _i("chandra_max_tokens", o.chandra_max_tokens, 64)
if "chandra_temperature" in d: o.chandra_temperature = _f("chandra_temperature", o.chandra_temperature, 0.0)
if "chandra_top_p" in d: o.chandra_top_p = _f("chandra_top_p", o.chandra_top_p, 0.0)
# 0 is meaningful: accept the first reply however it looks.
if "chandra_max_retries" in d: o.chandra_max_retries = _i("chandra_max_retries", o.chandra_max_retries, 0)
if "chandra_timeout" in d: o.chandra_timeout = _f("chandra_timeout", o.chandra_timeout, 1.0)
# detection
if "detect_mode" in d: o.detect_mode = (d.get("detect_mode") or "off").strip()
if "detect_model_path" in d: o.detect_model_path = (d.get("detect_model_path") or "").strip()
......
This diff is collapsed.
......@@ -138,7 +138,7 @@ STEP_PARAMS = {
"audio_cleanup": [("audio","ref","Source audio"),("noise_reduction","checkbox","Reduce background noise"),("normalize","checkbox","Normalize levels"),("remove_hum","checkbox","Remove hum"),("repair_clicks","checkbox","Repair clicks")],
"voice_clone": [("text","textarea","Text to synthesize"),("voice_name","text","Voice profile name"),("ref_text","text","Reference transcript"),("speed","number","Speed","1.0")],
"voice_convert": [("source_audio","ref","Source audio"),("voice_name","text","Voice profile name"),("f0_condition","checkbox","Singing mode"),("pitch_shift","number","Pitch shift","0"),("diffusion_steps","number","Steps","10")],
"ocr": [("image","ref","Document image/PDF ({{stepN.url}})"),("engine","select:paddle|doctr|surya|olmocr","OCR engine","paddle"),("detect","select:off|layout|detector|both","Stamp/signature detect","off"),("structured","checkbox","Structured JSON extraction")],
"ocr": [("image","ref","Document image/PDF ({{stepN.url}})"),("engine","select:paddle|doctr|surya|olmocr|chandra","OCR engine","paddle"),("detect","select:off|layout|detector|both","Stamp/signature detect","off"),("structured","checkbox","Structured JSON extraction")],
}
......
......@@ -561,7 +561,7 @@ def _remote_catalogue(url: str, api_key: str) -> list:
#: OCR engines are selected by name, not registered in models.json like a model.
#: Without this an OCR test answered "'surya' is not in the model list, so
#: nothing can say where it should run" — true of the catalogue, and useless.
_OCR_ENGINES = ("paddle", "doctr", "surya", "olmocr")
_OCR_ENGINES = ("paddle", "doctr", "surya", "olmocr", "chandra")
def _describe(model: str):
......
......@@ -18,8 +18,10 @@
POST /v1/ocr — OCR an uploaded image or PDF with a purpose-built OCR engine
(PaddleOCR / docTR / Surya), returning faithful text plus per-line boxes and layout.
Those three are NOT vision LLMs; ``engine=olmocr`` additionally offers olmOCR-2, a
document VLM for pages the detector-based engines read badly (it returns no boxes). Structured-JSON extraction and stamp/signature detection are layered
Those three are NOT vision LLMs. Two document VLMs are available for pages the
detector-based engines read badly: ``engine=olmocr`` (olmOCR-2, no boxes at all) and
``engine=chandra`` (Chandra, which is asked for layout and so returns boxes and labelled
regions as well). Structured-JSON extraction and stamp/signature detection are layered
on in later milestones (fields already accepted, no-op until then).
"""
......@@ -61,7 +63,7 @@ def _ocr_config():
@router.post("/v1/ocr", summary="OCR an image or PDF with a dedicated OCR engine")
async def create_ocr(
file: UploadFile = File(...),
engine: Optional[str] = Form(None), # paddle|doctr|surya|olmocr (default from config)
engine: Optional[str] = Form(None), # paddle|doctr|surya|olmocr|chandra (default from config)
dpi: Optional[int] = Form(None), # PDF rasterisation DPI
lang: Optional[str] = Form(None), # document language; omit or "auto" to let the engine decide
structured: Optional[bool] = Form(False), # structured JSON extraction via text model
......
......@@ -980,13 +980,13 @@ def volume_env(mount: str) -> dict:
def pod_ocr_engine(requested: str) -> str:
"""The OCR engine a pod will actually serve for a request naming ``requested``.
Mirrors the env a pod is launched with: surya needs a VLM server the image
does not carry, so a pod serves docTR for it. The gateway uses this to
rewrite the request's own `engine` field, because the pod honours what the
request asks for over its configured default.
Mirrors the env a pod is launched with: the VLM engines (surya, olmOCR,
chandra) need a model server the image does not carry, so a pod serves docTR
for them. The gateway uses this to rewrite the request's own `engine` field,
because the pod honours what the request asks for over its configured default.
"""
r = (requested or "").strip().lower()
if r in ("surya", "olmocr"):
if r in ("surya", "olmocr", "chandra"):
return "doctr"
return r if r in ("paddle", "doctr") else "doctr"
......@@ -1001,6 +1001,16 @@ def _surya_accepted() -> bool:
return False
def _chandra_accepted() -> bool:
"""Whether this deployment has accepted Chandra's weights licence."""
try:
from codai.admin.routes import config_manager
return bool(getattr(getattr(getattr(config_manager, "config", None),
"ocr", None), "chandra_accept_license", False))
except Exception:
return False
def _ocr_cfg():
"""This install's OcrConfig, or None. The pod has no config of its own — every
OCR setting it runs with is seeded from here."""
......@@ -1014,7 +1024,8 @@ def _ocr_cfg():
def _ocr_external_server(engine: str):
"""The off-box OpenAI-compatible endpoint configured for a VLM OCR engine, or "".
This is what decides whether a pod can actually serve surya/olmOCR. Both are VLMs
This is what decides whether a pod can actually serve surya/olmOCR/chandra. All
three are VLMs
and need a model server: locally they ride coderai's own vLLM, which a pod has
none of, and baking one in would mean a SECOND torch in the image (the vLLM venv
alone is ~10.5 GB against a ~12 GB ceiling). But in the served modes the engine
......@@ -1027,6 +1038,10 @@ def _ocr_external_server(engine: str):
mode = str(getattr(o, "olmocr_serve", "") or "").strip().lower()
url = str(getattr(o, "olmocr_server_url", "") or "").strip()
return url if (mode == "server" and url) else ""
if engine == "chandra":
mode = str(getattr(o, "chandra_serve", "") or "").strip().lower()
url = str(getattr(o, "chandra_server_url", "") or "").strip()
return url if (mode == "server" and url) else ""
if engine == "surya":
mode = str(getattr(o, "surya_serve", "") or "").strip().lower()
url = str(getattr(o, "surya_server_url", "") or "").strip()
......@@ -1125,12 +1140,13 @@ def _plan(engine, image, args, mcfg, api_key, entry, served,
_cands = [str((entry or {}).get("alias") or "").strip().lower(),
str(served or "").strip().lower(),
str((entry or {}).get("path") or "").strip().lower()]
wanted = next((c for c in _cands if c in ("paddle", "doctr", "surya", "olmocr")),
wanted = next((c for c in _cands
if c in ("paddle", "doctr", "surya", "olmocr", "chandra")),
_cands[-1])
# Remember what was ASKED for: the licence decision below is made by asking
# for surya by name, and that has to survive a downgrade to docTR.
_asked = wanted
if wanted in ("surya", "olmocr"):
if wanted in ("surya", "olmocr", "chandra"):
# Surya 0.22 is a VLM ("surya-ocr-2") and olmOCR-2 is one outright, not a
# self-contained OCR library: both need a model server. Locally they ride
# coderai's own vLLM engine, which a pod has none of, and baking one in
......@@ -1154,6 +1170,14 @@ def _plan(engine, image, args, mcfg, api_key, entry, served,
_val = str(getattr(_o, _a, "") or "").strip()
if _val:
env[_v] = _val
elif wanted == "chandra":
env["CODERAI_OCR_CHANDRA_SERVE"] = "server"
env["CODERAI_OCR_CHANDRA_SERVER_URL"] = _srv
for _v, _a in (("CODERAI_OCR_CHANDRA_MODEL", "chandra_model"),
("CODERAI_OCR_CHANDRA_MODEL_ID", "chandra_model_id")):
_val = str(getattr(_o, _a, "") or "").strip()
if _val:
env[_v] = _val
else:
env["CODERAI_OCR_SURYA_SERVE"] = "llamacpp"
env["CODERAI_OCR_SURYA_SERVER_URL"] = _srv
......@@ -1165,7 +1189,7 @@ def _plan(engine, image, args, mcfg, api_key, entry, served,
f"ocr.{wanted}_server_url and the served mode) — this OCR "
f"request will be served by docTR", flush=True)
wanted = "doctr"
if wanted not in ("paddle", "doctr", "surya", "olmocr"):
if wanted not in ("paddle", "doctr", "surya", "olmocr", "chandra"):
# The pod images ship docTR, the only engine that runs in-process.
wanted = "doctr"
env["CODERAI_OCR_DEFAULT_ENGINE"] = wanted
......@@ -1189,6 +1213,14 @@ def _plan(engine, image, args, mcfg, api_key, entry, served,
# the decision made HERE — it cannot make it for itself, and asking
# for surya by name is that decision.
env["CODERAI_OCR_SURYA_ACCEPT_LICENSE"] = "1"
if _asked == "chandra" and _chandra_accepted():
# Chandra's weights are a modified OpenRAIL-M, on NARROWER terms than
# Surya's GPL (no competitive use against Datalab's API, $2M ceiling).
# So asking for it by name is NOT the acceptance — unlike surya above,
# this one is only ever inherited from a decision already recorded in
# this install's config. A pod cannot accept a licence on an
# operator's behalf.
env["CODERAI_OCR_CHANDRA_ACCEPT_LICENSE"] = "1"
eng = engine_pod_backend(entry or {}, mcfg)
if eng:
......
......@@ -886,6 +886,10 @@ class OcrConfig:
best transcription fidelity on hard pages (old scans, maths, tables, multi-column),
but NO bounding boxes. Served through coderai's own model manager, its vLLM
backend, or any OpenAI-compatible endpoint (``olmocr_serve``).
- ``chandra`` — Chandra (Datalab; LICENSE-GATED — code Apache-2.0 but the WEIGHTS are
a modified OpenRAIL-M): also a document VLM, but one that returns layout blocks WITH
boxes and labels in a single pass, and from a different model family than surya-2 —
the second opinion on a page surya reads badly (``chandra_serve``).
Heavy OCR dependencies are installed out of band (optional) so the base image
stays lean. Concurrency on a single GPU comes from loading multiple instances of
......@@ -897,7 +901,7 @@ class OcrConfig:
stamp/signature flags (``detect_*``).
"""
enabled: bool = False
default_engine: str = "paddle" # paddle|doctr|surya|olmocr
default_engine: str = "paddle" # paddle|doctr|surya|olmocr|chandra
dpi: int = 200 # PDF rasterisation DPI
# Per-engine PDF rasterisation DPI (0 = use `dpi` above). Surya-2 gets its own
# because its input is capped at 3072x2048 = 6.29 Mpixels: `scale_to_fit` in
......@@ -932,7 +936,7 @@ class OcrConfig:
# page it was mid-way through. 0 = tear down immediately.
evict_drain_timeout_s: float = 60.0
# The STARTING share of the card a VLM OCR engine's own vLLM instance asks for
# (surya in vllm mode, olmocr in vllm mode); 0 = use vllm.gpu_memory_utilization.
# (surya, olmocr or chandra in vllm mode); 0 = use vllm.gpu_memory_utilization.
# It is a fraction of the card's TOTAL memory and has to cover the engine's whole
# footprint, not just its weights: measured live, surya-ocr-2 on a 24 GB 3090 needs
# ~10.9 GB before a single KV block (weights 1.37 + CUDA graphs 0.05 + ~9.4 GB of
......@@ -955,7 +959,7 @@ class OcrConfig:
# When the chosen engine cannot serve a document, try these engines instead rather
# than failing the request: a 503 means a document nobody read, and that is strictly
# worse than a transcription from the second-best engine. "auto" = every other
# ENABLED engine, fewest-ways-to-fail first (paddle, doctr, olmocr, surya);
# ENABLED engine, fewest-ways-to-fail first (paddle, doctr, olmocr, chandra, surya);
# "off"/"none" = no fallback (fail with the original error); or an explicit
# comma/space separated order.
fallback_engines: str = "auto"
......@@ -1050,6 +1054,49 @@ class OcrConfig:
olmocr_timeout: float = 300.0 # per-page ceiling on the model call
olmocr_retry_rotation: bool = True # re-ask once on a page the model reports rotated
# --- Chandra (Datalab; code Apache-2.0, WEIGHTS modified OpenRAIL-M) --- a document
# VLM like olmOCR, but its `ocr_layout` prompt returns one <div> per layout block with
# data-bbox (normalised 0-1000) and data-label, so unlike olmOCR it gives boxes AND
# labelled regions in a single pass. Qwen3-VL family, so a different tokenizer and
# different failure modes from surya-2 — which makes it the second opinion worth
# having on a page surya cannot read. See codai/ocr/chandra.py.
chandra_enabled: bool = False
# LICENSE-GATED, and on NARROWER terms than Surya's GPL: the weights are free for
# research, personal use and companies under $2M funding/revenue, and may not be used
# competitively with Datalab's own API. Commercial self-hosting needs a licence from
# Datalab. Nothing loads until this is set deliberately.
chandra_accept_license: bool = False
chandra_instances: int = 1 # in-flight pages against the serving model
# Served exactly like olmOCR: "model" (coderai's own model manager — the only mode
# that works on a GPU-less box, including one whose job is renting a pod), "vllm"
# (coderai's vLLM backend on its own instance), "server" (an endpoint already up).
chandra_serve: str = "model" # model | vllm | server
chandra_model_id: str = "" # ("model" mode) a vision model id from models.json
chandra_model: str = "datalab-to/chandra-ocr-2" # ("vllm"/"server") HF id / served name
chandra_server_url: str = "" # ("server" mode) OpenAI base URL of that model
chandra_api_key: str = "" # ("server" mode) bearer token, if it wants one
# Chandra's input is capped at 3072x2048 = 6.29 Mpixels — the SAME ceiling as surya-2
# (chandra/model/util.py `scale_to_fit`, same numbers), reached by an A4 page at about
# 255 DPI. Above it the page is downscaled before the model sees it, so rasterising
# higher costs time and buys nothing. 250 saturates A4; see `surya_dpi`.
chandra_dpi: int = 250
# Ask for layout blocks (the `ocr_layout` prompt) rather than plain HTML. On by
# default because it is the whole reason to choose this engine over olmOCR: with it
# off the reply is one unlocated blob and every bbox comes back empty.
chandra_layout: bool = True
# Keep Page-Header/Page-Footer blocks in the text. On: a judicial document's header
# carries the court, the section and the case number.
chandra_include_furniture: bool = True
chandra_max_tokens: int = 12384 # chandra's own MAX_OUTPUT_TOKENS; HTML runs long
chandra_temperature: float = 0.0 # chandra's own default: greedy
chandra_top_p: float = 0.1
# A VLM that loses its place on a page does not fail — it repeats the last thing it
# saw until it runs out of tokens, and answers 200 with a plausible-looking page. Each
# retry raises the temperature (a greedy decode falls into the same loop twice), which
# is what chandra's own client does. 0 = accept the first reply whatever it looks like.
chandra_max_retries: int = 2
chandra_timeout: float = 300.0 # per-page ceiling on the model call
# --- stamp / signature detection --- [O3]
detect_mode: str = "off" # off|layout|detector|both
detect_model_path: str = "" # YOLO signature/stamp weights (detector/both modes)
......@@ -1682,6 +1729,22 @@ class ConfigManager:
"olmocr_temperature": self.config.ocr.olmocr_temperature,
"olmocr_timeout": self.config.ocr.olmocr_timeout,
"olmocr_retry_rotation": self.config.ocr.olmocr_retry_rotation,
"chandra_enabled": self.config.ocr.chandra_enabled,
"chandra_accept_license": self.config.ocr.chandra_accept_license,
"chandra_instances": self.config.ocr.chandra_instances,
"chandra_serve": self.config.ocr.chandra_serve,
"chandra_model_id": self.config.ocr.chandra_model_id,
"chandra_model": self.config.ocr.chandra_model,
"chandra_server_url": self.config.ocr.chandra_server_url,
"chandra_api_key": self.config.ocr.chandra_api_key,
"chandra_dpi": self.config.ocr.chandra_dpi,
"chandra_layout": self.config.ocr.chandra_layout,
"chandra_include_furniture": self.config.ocr.chandra_include_furniture,
"chandra_max_tokens": self.config.ocr.chandra_max_tokens,
"chandra_temperature": self.config.ocr.chandra_temperature,
"chandra_top_p": self.config.ocr.chandra_top_p,
"chandra_max_retries": self.config.ocr.chandra_max_retries,
"chandra_timeout": self.config.ocr.chandra_timeout,
"detect_mode": self.config.ocr.detect_mode,
"detect_model_path": self.config.ocr.detect_model_path,
"detect_conf": self.config.ocr.detect_conf,
......@@ -1762,7 +1825,7 @@ class ConfigManager:
# the three default to off. Enabling the subsystem alone got a pod as
# far as "OCR engine 'surya' is not enabled" — a second refusal from
# a second flag, one round later.
for name in ("paddle", "doctr", "surya", "olmocr"):
for name in ("paddle", "doctr", "surya", "olmocr", "chandra"):
if _flag(f"CODERAI_OCR_{name.upper()}_ENABLED"):
setattr(self.config.ocr, f"{name}_enabled", True)
# Where that engine's interpreter lives. A pod image that ships
......@@ -1785,6 +1848,10 @@ class ConfigManager:
("CODERAI_OCR_SURYA_MODEL", "surya_model"),
("CODERAI_OCR_SURYA_MODEL_ID", "surya_model_id"),
("CODERAI_OCR_SURYA_SERVER_URL", "surya_server_url"),
("CODERAI_OCR_CHANDRA_SERVE", "chandra_serve"),
("CODERAI_OCR_CHANDRA_MODEL", "chandra_model"),
("CODERAI_OCR_CHANDRA_MODEL_ID", "chandra_model_id"),
("CODERAI_OCR_CHANDRA_SERVER_URL", "chandra_server_url"),
("CODERAI_OCR_FALLBACK_ENGINES", "fallback_engines")):
_val = (_os.environ.get(_env) or "").strip()
if _val:
......@@ -1793,6 +1860,11 @@ class ConfigManager:
# Surya is GPL and gated on an explicit acceptance. The pod
# inherits the decision made here; it cannot make it itself.
self.config.ocr.surya_accept_license = True
if _flag("CODERAI_OCR_CHANDRA_ACCEPT_LICENSE"):
# Chandra's weights are a modified OpenRAIL-M, and on narrower
# terms than Surya's GPL. Same rule: the pod inherits the
# decision made here and cannot make it itself.
self.config.ocr.chandra_accept_license = True
# The native engines — ds4, colibri, k3 — ship disabled for the same
# reason OCR does, and an engines pod is rented to run exactly one of
......
......@@ -1188,6 +1188,12 @@ class FrontProxy:
external = mode == "server" and bool(
(getattr(o, "olmocr_server_url", "") or "").strip())
local.append(not external)
if (getattr(o, "chandra_enabled", False)
and getattr(o, "chandra_accept_license", False)):
mode = (getattr(o, "chandra_serve", "model") or "model").strip().lower()
external = mode == "server" and bool(
(getattr(o, "chandra_server_url", "") or "").strip())
local.append(not external)
return bool(local) and not any(local)
def _resident_on(self, engine, key: str) -> bool:
......
......@@ -76,7 +76,7 @@ class ModelCapabilities:
# Retrieval / document
reranking: bool = False # cross-encoder query↔document reranking (bge-reranker, …)
ocr: bool = False # optical character recognition (paddle/doctr/surya/olmocr)
ocr: bool = False # optical character recognition (paddle/doctr/surya/olmocr/chandra)
def to_list(self) -> List[str]:
out = []
......
......@@ -5807,6 +5807,9 @@ class MultiModelManager:
_ocr_engines.append("surya")
if getattr(ocr, "olmocr_enabled", False):
_ocr_engines.append("olmocr")
if (getattr(ocr, "chandra_enabled", False)
and getattr(ocr, "chandra_accept_license", False)):
_ocr_engines.append("chandra")
for _eng in _ocr_engines:
_add(_eng, "ocr", {"capabilities": ["ocr"], "backend": "ocr"})
except Exception:
......
This diff is collapsed.
......@@ -58,11 +58,17 @@ def _olmocr_factory(cfg):
return OlmOcrEngine(cfg)
def _chandra_factory(cfg):
from codai.ocr.chandra import ChandraEngine
return ChandraEngine(cfg)
_ENGINE_FACTORIES = {
"paddle": _paddle_factory,
"doctr": _doctr_factory,
"surya": _surya_factory,
"olmocr": _olmocr_factory,
"chandra": _chandra_factory,
}
......@@ -173,6 +179,11 @@ def _engine_enabled(cfg, name: str) -> bool:
return bool(cfg.surya_enabled and cfg.surya_accept_license)
if name == "olmocr":
return bool(getattr(cfg, "olmocr_enabled", False))
if name == "chandra":
# License-gated like surya, and on narrower terms: the weights are a
# modified OpenRAIL-M, not GPL (see OcrConfig.chandra_accept_license).
return bool(getattr(cfg, "chandra_enabled", False)
and getattr(cfg, "chandra_accept_license", False))
return False
......@@ -368,9 +379,9 @@ class _Pool:
eng: OcrEngine = await self._q.get()
self._enter_page()
try:
# Engines that call back into coderai's async API (olmOCR 'model' mode) run
# in a worker thread and need a live loop to hand the coroutine to; give them
# the app's own rather than letting them spin up a second one.
# Engines that call back into coderai's async API (olmOCR/Chandra 'model'
# mode) run in a worker thread and need a live loop to hand the coroutine to;
# give them the app's own rather than letting them spin up a second one.
try:
eng._host_loop = asyncio.get_running_loop()
except Exception:
......@@ -441,6 +452,7 @@ class OcrManager:
# reclaim it; the next OCR request re-boots it via ensure_service.
freed += self._stop_surya_vllm()
freed += self._stop_olmocr_vllm()
freed += self._stop_chandra_vllm()
if freed:
print(f"[ocr] released ~{freed:.1f} GB (pools + Surya vLLM torn down for VRAM eviction)")
return freed
......@@ -490,6 +502,28 @@ class OcrManager:
print(f"[ocr] olmOCR vLLM stop skipped: {e}")
return 0.0
def _stop_chandra_vllm(self) -> float:
"""Stop the managed Chandra vLLM instance (chandra_serve = "vllm"), for the same
reason Surya's and olmOCR's need stopping: its VRAM belongs to the subprocess,
not to a pool, so tearing the pool down does not reclaim it."""
cfg = self._cfg # unconditional, for the reason in _stop_surya_vllm
if cfg is None:
return 0.0
try:
from codai.api import vllm_worker
from codai.models.manager import get_active_vllm_config
from codai.ocr.chandra import DEFAULT_MODEL
vcfg = get_active_vllm_config()
if vcfg is None:
return 0.0
model = (getattr(cfg, "chandra_model", "") or DEFAULT_MODEL).strip()
return vllm_worker.stop_service_for(
vcfg, model_path=model, served_name=model,
gpu_memory_utilization=float(getattr(cfg, "vlm_gpu_memory_utilization", 0.0) or 0.0) or None)
except Exception as e:
print(f"[ocr] Chandra vLLM stop skipped: {e}")
return 0.0
@staticmethod
def _pool_params(cfg) -> tuple:
return (
......@@ -509,6 +543,12 @@ class OcrManager:
getattr(cfg, "olmocr_serve", "model"), getattr(cfg, "olmocr_model", ""),
getattr(cfg, "olmocr_model_id", ""), getattr(cfg, "olmocr_server_url", ""),
getattr(cfg, "olmocr_longest_side", 1288),
getattr(cfg, "chandra_enabled", False),
getattr(cfg, "chandra_accept_license", False),
getattr(cfg, "chandra_instances", 1),
getattr(cfg, "chandra_serve", "model"), getattr(cfg, "chandra_model", ""),
getattr(cfg, "chandra_model_id", ""), getattr(cfg, "chandra_server_url", ""),
getattr(cfg, "chandra_layout", True),
getattr(cfg, "vlm_gpu_memory_utilization", 0.0),
)
......@@ -530,14 +570,24 @@ class OcrManager:
"doctr": {"boxes": True, "regions": True, "tables": False, "meta": False},
"surya": {"boxes": True, "regions": True, "tables": True, "meta": False},
"olmocr": {"boxes": False, "regions": False, "tables": True, "meta": True},
# Chandra is a VLM like olmOCR but it is ASKED for boxes: its ocr_layout
# prompt returns one div per block with data-bbox and data-label, so it is
# the only engine here that gives boxes, labelled regions, tables AND page
# metadata in a single pass. (With ocr.chandra_layout off it has none of
# the three, which is why the row is read from the setting.)
"chandra": {"boxes": bool(getattr(cfg, "chandra_layout", True)),
"regions": bool(getattr(cfg, "chandra_layout", True)),
"tables": bool(getattr(cfg, "chandra_layout", True)),
"meta": True},
}
# A pipeline drives a model somebody else serves; an engine owns its own
# weights and runs in process. The distinction decides whether "where does
# it run" is even a question, so it is worth saying out loud.
kinds = {"paddle": "engine", "doctr": "engine",
"surya": "pipeline", "olmocr": "pipeline"}
"surya": "pipeline", "olmocr": "pipeline", "chandra": "pipeline"}
models = {"surya": ("surya_model_id", "surya_model"),
"olmocr": ("olmocr_model_id", "olmocr_model")}
"olmocr": ("olmocr_model_id", "olmocr_model"),
"chandra": ("chandra_model_id", "chandra_model")}
data = []
for name in sorted(_ENGINE_FACTORIES):
......@@ -558,6 +608,13 @@ class OcrManager:
if name == "surya" and cfg.surya_enabled and not cfg.surya_accept_license:
row["reason"] = ("Surya's licence has not been accepted "
"(ocr.surya_accept_license)")
elif (name == "chandra" and getattr(cfg, "chandra_enabled", False)
and not getattr(cfg, "chandra_accept_license", False)):
row["reason"] = ("Chandra's licence has not been accepted "
"(ocr.chandra_accept_license) — its weights are a "
"modified OpenRAIL-M, free for research, personal "
"use and companies under $2M, and not for use "
"competing with Datalab's API")
else:
row["reason"] = f"not enabled (ocr.{name}_enabled)"
# The ids that ALSO resolve to this engine, so a client can name the
......@@ -593,7 +650,8 @@ class OcrManager:
"""
cfg = self._require_cfg()
for engine, fields in (("surya", ("surya_model_id", "surya_model")),
("olmocr", ("olmocr_model_id", "olmocr_model"))):
("olmocr", ("olmocr_model_id", "olmocr_model")),
("chandra", ("chandra_model_id", "chandra_model"))):
for field in fields:
got = str(getattr(cfg, field, "") or "").strip().lower()
if got and got == wanted:
......@@ -705,7 +763,7 @@ class OcrManager:
if raw in ("off", "none", "no", "false"):
return []
if raw in ("auto", "", "on", "true"):
names = ["paddle", "doctr", "olmocr", "surya"]
names = ["paddle", "doctr", "olmocr", "chandra", "surya"]
else:
names = [n.strip() for n in raw.replace(",", " ").split() if n.strip()]
return [n for n in names
......
This diff is collapsed.
This diff is collapsed.
......@@ -824,7 +824,11 @@ def test_the_catalogue_lists_every_engine_with_whether_it_is_enabled():
paddle_enabled=False, doctr_enabled=False)
cat = mgr.engine_catalogue()
rows = {r["id"]: r for r in cat["data"]}
assert set(rows) == {"paddle", "doctr", "surya", "olmocr"}
# Against the REGISTRY, not a hand-written list: the point of the catalogue is that
# a client never has to know which engines exist, and a frozen list here would mean
# adding one silently left it out of the only place that answers that question.
from codai.ocr.manager import _ENGINE_FACTORIES
assert set(rows) == set(_ENGINE_FACTORIES)
assert rows["surya"]["enabled"] is True
assert rows["paddle"]["enabled"] is False
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment