ocr: let a client ask what this install can read pages with (v0.3.10)

Naming an engine an install had not enabled was a 400, and there was no way to
find out beforehand — the client had to know, or guess and handle the refusal.

GET /v1/ocr/engines answers it from this install's own config: every engine
with `enabled` and, when it is not, a `reason` that names the setting to
change (an unaccepted licence is reported as such rather than as merely
disabled, because that is a different checkbox). Each row also carries `kind`
— an engine owns its weights, a pipeline drives a model — the `aliases` that
also resolve to it, and `returns`, so a caller that needs coordinates can see
that olmOCR has none rather than discovering it in an empty field. The top
level reports both what the operator wrote as the default and what it resolves
to.

Where the model runs is deliberately not in the answer. It is configuration,
it can differ between two requests, and a client that could see it would start
depending on it. A test asserts the payload mentions no pod, node or address.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxYHWbCAjjjD7A1rhFKtaW
parent 52dfaca3
......@@ -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.9"
__version__ = "0.3.10"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
......@@ -153,6 +153,30 @@ class SchemaPayload(BaseModel):
model_config = ConfigDict(extra="allow", populate_by_name=True)
@router.get("/v1/ocr/engines", summary="List the OCR engines this install offers")
async def list_ocr_engines():
"""Which engine names `/v1/ocr` accepts here, and what each one returns.
A client used to have to know: naming an engine this install had not enabled
was a 400 with nothing to ask beforehand. Every field is read from this
install's config, including the ids that also resolve to an engine (so a
caller can name the model it knows rather than the pipeline driving it) and
which engines can return boxes or layout regions at all.
Where an engine's model runs — here, a pinned cluster node, a rented pod — is
deliberately NOT in the answer: that is configuration, it can change between
two requests, and a client never has to care.
"""
cfg = _ocr_config()
if cfg is None:
raise HTTPException(status_code=503, detail="OCR subsystem is not available")
ocr_manager.configure(cfg)
try:
return ocr_manager.engine_catalogue()
except OcrError as e:
raise HTTPException(status_code=e.status, detail=str(e))
@router.get("/v1/ocr/schemas", summary="List available extraction schemas")
async def list_schemas():
return {"schemas": schema_store.list()}
......
......@@ -410,6 +410,70 @@ class OcrManager:
getattr(cfg, "vlm_gpu_memory_utilization", 0.0),
)
def engine_catalogue(self) -> dict:
"""What a client may ask for, and what it would get.
Until now a caller had to KNOW which engines an install had enabled; a
wrong guess was a 400 and there was nothing to ask. Everything here is
read from this install's own config, so it answers the two questions a
client actually has: which names work, and which of them returns the
boxes or the layout I need.
"""
cfg = self._require_cfg()
# What each engine can put in a page, so a client can choose on capability
# rather than on folklore. olmOCR is a document VLM: it transcribes a whole
# page in reading order and has no notion of where a word was.
shapes = {
"paddle": {"boxes": True, "regions": True, "tables": True, "meta": False},
"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},
}
# 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"}
models = {"surya": ("surya_model_id", "surya_model"),
"olmocr": ("olmocr_model_id", "olmocr_model")}
data = []
for name in sorted(_ENGINE_FACTORIES):
enabled = _engine_enabled(cfg, name)
row = {
"id": name,
"kind": kinds.get(name, "engine"),
"enabled": bool(enabled),
"instances": _engine_instances(cfg, name),
"returns": shapes.get(name, {}),
}
if not enabled:
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)")
else:
row["reason"] = f"not enabled (ocr.{name}_enabled)"
# The ids that ALSO resolve to this engine, so a client can name the
# model it knows about instead of the pipeline that drives it.
aliases = []
for field in models.get(name, ()):
got = str(getattr(cfg, field, "") or "").strip()
if got and got not in aliases:
aliases.append(got)
if aliases:
row["aliases"] = aliases
row["model"] = aliases[0]
row["serve"] = str(getattr(cfg, f"{name}_serve", "") or "").strip()
data.append(row)
default = str(getattr(cfg, "default_engine", "") or "").strip()
try:
resolved = self.resolve_engine(None)
except OcrError:
resolved = ""
return {"object": "list", "default": default, "default_engine": resolved,
"data": data}
def _engine_serving_model(self, wanted: str) -> str:
"""The engine configured to read pages with this MODEL, or "".
......
......@@ -271,9 +271,24 @@ aggregate throughput without vLLM (a continuous-batching path is a future option
```
POST /v1/ocr multipart: file=@doc.pdf [engine=] [dpi=] [detect=] [structured=] [schema=]
POST /v1/ocr/batch multipart: files=@a.pdf files=@b.png [...same fields...]
GET /v1/ocr/engines what this install offers, and what each engine returns
GET/POST/DELETE /v1/ocr/schemas[/{name}] manage extraction schemas
```
`engine=` takes an engine name (`paddle`, `doctr`, `surya`, `olmocr`) **or** the id
of the model a pipeline drives, so `engine=surya` and
`engine=datalab-to/surya-ocr-2` reach the same place. Omit it and
`ocr.default_engine` decides — which may itself be written either way.
`GET /v1/ocr/engines` is how a client finds that out instead of guessing: each row
carries `enabled` (with a `reason` when it is not), `kind` (`engine` owns its
weights, `pipeline` drives a model), `aliases` (the model ids that also resolve to
it), `instances`, and `returns` — `{boxes, regions, tables, meta}`, so a caller
needing coordinates can see that olmOCR has none. The top level reports `default`
(what the operator wrote) and `default_engine` (what it resolves to). Where an
engine's model runs is deliberately absent: it is configuration, it can change
between two requests, and a client that learned it would come to depend on it.
Response: `{engine, num_pages, text, pages[], stamps[], signatures[], structured}`. Each
page carries `lines[]` (text + bbox + conf), `regions[]` (layout), `tables[]`, and
`meta` when the engine reports page metadata (olmOCR does; the others do not). The
......
......@@ -809,3 +809,89 @@ def test_the_default_may_itself_be_a_model_id():
def test_an_explicit_engine_still_overrides_the_default():
mgr = _mgr(default_engine="surya", surya_model_id="datalab-to/surya-ocr-2")
assert mgr.resolve_engine("doctr") == "doctr"
# ------------------------------------------------ what can I ask this install #
# Naming an engine the install had not enabled was a 400, and there was nothing
# to ask beforehand — the client had to know, or guess.
def test_the_catalogue_lists_every_engine_with_whether_it_is_enabled():
mgr = _mgr(default_engine="surya", surya_model_id="datalab-to/surya-ocr-2",
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"}
assert rows["surya"]["enabled"] is True
assert rows["paddle"]["enabled"] is False
def test_a_disabled_engine_says_why():
"""So the answer is actionable rather than just negative."""
mgr = _mgr(paddle_enabled=False)
rows = {r["id"]: r for r in mgr.engine_catalogue()["data"]}
assert "ocr.paddle_enabled" in rows["paddle"]["reason"]
def test_an_unaccepted_licence_is_not_reported_as_merely_disabled():
"""It is one checkbox away, and saying "not enabled" would send the operator
to the wrong setting."""
mgr = _mgr(surya_enabled=True, surya_accept_license=False)
rows = {r["id"]: r for r in mgr.engine_catalogue()["data"]}
assert "licence" in rows["surya"]["reason"]
def test_the_catalogue_publishes_the_model_names_that_also_work():
"""The aliases are the whole point of accepting a model id in the request."""
mgr = _mgr(surya_model_id="datalab-to/surya-ocr-2",
olmocr_model_id="allenai/olmOCR-2-7B-1025-FP8")
rows = {r["id"]: r for r in mgr.engine_catalogue()["data"]}
assert "datalab-to/surya-ocr-2" in rows["surya"]["aliases"]
assert rows["olmocr"]["model"] == "allenai/olmOCR-2-7B-1025-FP8"
# And every advertised alias really does resolve.
for row in rows.values():
for alias in row.get("aliases", []):
assert mgr.resolve_engine(alias) == row["id"]
def test_the_catalogue_says_which_engines_can_return_boxes():
"""olmOCR is a document VLM: it transcribes a page and has no notion of where
a word was, so a client needing coordinates must be able to see that."""
mgr = _mgr()
rows = {r["id"]: r for r in mgr.engine_catalogue()["data"]}
assert rows["olmocr"]["returns"]["boxes"] is False
assert rows["surya"]["returns"]["boxes"] is True
assert rows["paddle"]["returns"]["regions"] is True
def test_the_catalogue_distinguishes_a_pipeline_from_an_engine():
"""Whether "where does it run" is even a question depends on this."""
mgr = _mgr()
rows = {r["id"]: r for r in mgr.engine_catalogue()["data"]}
assert rows["surya"]["kind"] == "pipeline"
assert rows["paddle"]["kind"] == "engine"
def test_the_catalogue_reports_what_a_request_with_no_engine_will_use():
mgr = _mgr(default_engine="surya", surya_model_id="datalab-to/surya-ocr-2")
cat = mgr.engine_catalogue()
assert cat["default"] == "surya"
assert cat["default_engine"] == mgr.resolve_engine(None)
def test_a_default_written_as_a_model_id_is_reported_resolved():
"""`default` is what the operator wrote; `default_engine` is what it means."""
mgr = _mgr(default_engine="allenai/olmOCR-2-7B-1025-FP8",
olmocr_model_id="allenai/olmOCR-2-7B-1025-FP8")
cat = mgr.engine_catalogue()
assert cat["default"] == "allenai/olmOCR-2-7B-1025-FP8"
assert cat["default_engine"] == "olmocr"
def test_the_catalogue_does_not_leak_where_the_model_runs():
"""Placement is configuration and can change between two requests; a client
that learned it would start depending on it."""
mgr = _mgr(surya_model_id="datalab-to/surya-ocr-2")
blob = repr(mgr.engine_catalogue())
for leak in ("runpod", "pod", "node", "127.0.0.1", "http"):
assert leak not in blob.lower()
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