ocr: the budgets never reached a batch, and /v1/ocr was routed to a helper (v0.3.52)

Found while writing the Digesta dev the answer to "which field names does
0.3.51 actually honour" -- a question that could not be answered honestly
because of these two.

/v1/ocr/batch never accepted max_tokens or max_continuations. A batch is how a
caller sends real work, so the budgets were accepted only on the path nobody
benchmarks: every claim since 0.3.49 that "/v1/ocr and /v1/ocr/batch take these
per request" was half wrong, docs included. FastAPI drops an unknown form field
in silence, which is the same trap this feature existed to close -- the caller
sets max_tokens on every batch and is never told it did nothing. The dev's
measurements at "max_tokens=8000/4000/2000" were therefore all taken at the
CONFIGURED value, whatever that was at the time.

And /v1/ocr's route decorator was attached to the _page_opts HELPER rather than
to create_ocr, because 0.3.49 inserted the helper between the decorator and its
function. FastAPI registered the helper as the handler, so the endpoint existed,
answered, and was not the OCR endpoint. Nothing failed at import; no test looked
at the route table.

16 tests, 12 of which fail against the pre-fix code: the route resolves to
create_ocr, _page_opts is not registered as a handler, both paths take both
budgets, each defaults to None (0 is meaningful for both -- no cap override,
resumption off), negatives clamp, and the batch path is asserted to FORWARD
them, since a signature that accepts a field proves nothing about using it.
Checked against the route table and signatures, so they need no engine or GPU.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxYHWbCAjjjD7A1rhFKtaW
parent 2a45785d
......@@ -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.51"
__version__ = "0.3.52"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
......@@ -60,7 +60,6 @@ def _ocr_config():
return None
@router.post("/v1/ocr", summary="OCR an image or PDF with a dedicated OCR engine")
def _page_opts(max_tokens, max_continuations) -> dict:
"""Per-request engine overrides, or {} when the caller said nothing.
......@@ -77,6 +76,7 @@ def _page_opts(max_tokens, max_continuations) -> dict:
return out
@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|chandra (default from config)
......@@ -127,9 +127,17 @@ async def create_ocr_batch(
structured: Optional[bool] = Form(False),
schema_: Optional[str] = Form(None, alias="schema"),
detect: Optional[str] = Form(None),
max_tokens: Optional[int] = Form(None),
max_continuations: Optional[int] = Form(None),
):
"""Fan a batch of documents across the engine's instance pool. Per-file failures are
reported inline (not fatal) so one bad scan doesn't sink the batch."""
reported inline (not fatal) so one bad scan doesn't sink the batch.
Takes the same per-request budgets as ``/v1/ocr``, and must: a batch is how
a caller sends real work, so a budget this endpoint did not accept was a
budget that never applied to anything that mattered. FastAPI drops an
unknown form field silently, so a caller could set ``max_tokens`` on every
batch for a week and never be told it did nothing."""
cfg = _ocr_config()
if cfg is None:
raise HTTPException(status_code=503, detail="OCR subsystem is not available")
......@@ -149,6 +157,7 @@ async def create_ocr_batch(
res = await ocr_manager.ocr_document(
data, filename=name, content_type=ctype, engine=engine, dpi=dpi,
detect=detect, structured=bool(structured), schema=schema_, lang=lang,
page_opts=_page_opts(max_tokens, max_continuations),
)
res["filename"] = name
return res
......
"""What `/v1/ocr` and `/v1/ocr/batch` actually accept, and that they are wired.
Two defects this covers, both of the same family -- a thing that silently does
nothing looks exactly like a thing that works:
1. `/v1/ocr`'s route decorator ended up attached to the `_page_opts` HELPER
rather than to `create_ocr`, because the helper was inserted between the
decorator and its function. FastAPI registered the helper as the handler, so
the endpoint existed, answered, and was not the OCR endpoint. Nothing failed
at import and no test looked at the route table.
2. `/v1/ocr/batch` never took `max_tokens`/`max_continuations` at all. A batch
is how a caller sends real work, so the budgets were accepted only on the
path nobody benchmarks -- and FastAPI drops an unknown form field in silence,
so a caller setting `max_tokens` on every batch was told nothing.
Asserted against the route table and the signatures rather than by calling the
endpoints, so the tests hold without an OCR engine, a model or a GPU.
"""
import inspect
import pytest
def _routes():
from codai.api import ocr as ocr_api
return {(r.path, tuple(sorted(r.methods))): r
for r in ocr_api.router.routes if hasattr(r, "methods")}
def _endpoint(path, method="POST"):
for (p, methods), r in _routes().items():
if p == path and method in methods:
return r.endpoint
raise AssertionError(f"no {method} {path} route is registered")
def test_the_ocr_route_points_at_the_ocr_handler():
"""The bug was not a missing route — it was a route to the wrong function."""
fn = _endpoint("/v1/ocr")
assert fn.__name__ == "create_ocr", (
f"POST /v1/ocr is handled by {fn.__name__!r}")
def test_the_page_options_helper_is_not_an_endpoint():
from codai.api import ocr as ocr_api
handlers = {r.endpoint for r in ocr_api.router.routes if hasattr(r, "endpoint")}
assert ocr_api._page_opts not in handlers, (
"_page_opts is registered as an HTTP handler")
def test_the_ocr_handler_takes_an_upload():
"""A helper promoted to a handler by accident would take neither."""
params = inspect.signature(_endpoint("/v1/ocr")).parameters
assert "file" in params
@pytest.mark.parametrize("path,upload", [("/v1/ocr", "file"),
("/v1/ocr/batch", "files")])
@pytest.mark.parametrize("field", ["max_tokens", "max_continuations"])
def test_both_paths_accept_the_per_request_budgets(path, upload, field):
params = inspect.signature(_endpoint(path)).parameters
assert upload in params, f"{path} does not take an upload"
assert field in params, (
f"{path} does not accept {field} — FastAPI will drop it in silence")
@pytest.mark.parametrize("path", ["/v1/ocr", "/v1/ocr/batch"])
@pytest.mark.parametrize("field", ["max_tokens", "max_continuations"])
def test_a_budget_is_optional_and_defaults_to_the_configuration(path, field):
"""Omitted must mean "use the configured value", never 0: 0 is meaningful
for both (no cap override; resumption off)."""
p = inspect.signature(_endpoint(path)).parameters[field]
assert p.default is not inspect.Parameter.empty
assert getattr(p.default, "default", "missing") is None, (
f"{path}:{field} does not default to None")
# --- what the budgets become ------------------------------------------------
def test_nothing_sent_means_nothing_overridden():
from codai.api.ocr import _page_opts
assert _page_opts(None, None) == {}
def test_zero_is_carried_rather_than_treated_as_absent():
from codai.api.ocr import _page_opts
assert _page_opts(0, 0) == {"max_tokens": 0, "max_continuations": 0}
def test_each_budget_travels_alone():
from codai.api.ocr import _page_opts
assert _page_opts(3072, None) == {"max_tokens": 3072}
assert _page_opts(None, 4) == {"max_continuations": 4}
def test_a_negative_budget_is_clamped_not_passed_on():
from codai.api.ocr import _page_opts
assert _page_opts(-5, -1) == {"max_tokens": 0, "max_continuations": 0}
def test_the_batch_path_forwards_them_to_the_document_call():
"""The signature accepting a field proves nothing about it being used."""
from codai.api import ocr as ocr_api
src = inspect.getsource(_endpoint("/v1/ocr/batch"))
body = src.split("ocr_manager.ocr_document")[1]
assert "page_opts=_page_opts(max_tokens, max_continuations)" in body, (
"the batch path accepts the budgets and then drops them")
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