John Ho Claude Fable 5 commited on
Commit
dda27f4
Β·
1 Parent(s): a1f0c4d

Fit hosted-backend payloads under the HF router's ~5MiB body cap

Browse files

All HF-Inference extract models 413'd ("request entity too large") on a
15MP photo: router.huggingface.co rejects request bodies over ~5MiB
(undocumented; measured 2026-08-28 β€” 5.23MB passes, 5.31MB fails), and
the image rides inline as a base64 data URL. The app path made it worse:
_prepare_image saves its 3MP downscale as lossless PNG, which for photos
can exceed the original JPEG (3.5MB JPEG -> 5.1MB PNG -> 6.85MB base64).

extract_arena.image_to_data_url now budgets MAX_DATA_URL_BYTES (4.5MB):
images under it pass through byte-identical; oversized ones re-encode as
JPEG q85 and downscale 15% per step until they fit (logged). Fixes the
CLI and the Space tab at the single shared choke point; moondream3
(file upload) and local council members were never affected.

Also: note the recompression in the extract_fields MCP docstring
(schema re-verified) and record the router cap + PNG-inflation trap in
MODEL_RESEARCH.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files changed (3) hide show
  1. MODEL_RESEARCH.md +7 -0
  2. app.py +3 -1
  3. extract_arena.py +37 -2
MODEL_RESEARCH.md CHANGED
@@ -18,6 +18,13 @@ Verification endpoints (all checked live during the original research):
18
  - **Reliability marker**: a model whose ONLY provider is `featherless-ai` is on-demand: it
19
  cold-starts (503 `capacity_exhausted`, sometimes for minutes) and its router entries carry no
20
  pricing/latency fields. Treat featherless-only as "works, but flaky".
 
 
 
 
 
 
 
21
  - **Leaderboards**: [Roboflow vision evals β€” Data Extraction](https://playground.roboflow.com/evals/data-extraction)
22
  (single-field, exact-match β€” the closest public proxy for this project's task),
23
  [OCRBench v2](https://99franklin.github.io/ocrbench_v2/) (has an Extraction subscore),
 
18
  - **Reliability marker**: a model whose ONLY provider is `featherless-ai` is on-demand: it
19
  cold-starts (503 `capacity_exhausted`, sometimes for minutes) and its router entries carry no
20
  pricing/latency fields. Treat featherless-only as "works, but flaky".
21
+ - **Router body cap ~5 MiB** (undocumented; measured 2026-08-28 by binary search: 5.23MB body
22
+ passes, 5.31MB gets 413 `request entity too large`, identical across models/providers). Images
23
+ ride inline as base64 data URLs (Γ—4/3), so the effective image budget is ~3.7MB encoded β€”
24
+ `extract_arena.image_to_data_url` recompresses to fit `MAX_DATA_URL_BYTES`. Trap that
25
+ triggered this: `app.py`'s `_prepare_image` saves its 3MP downscale as **lossless PNG**, which
26
+ for photos can be *larger* than the original JPEG (a 3.5MB/15MP JPEG became a 5.1MB PNG β†’
27
+ 6.85MB base64 β†’ 413 on every hosted model).
28
  - **Leaderboards**: [Roboflow vision evals β€” Data Extraction](https://playground.roboflow.com/evals/data-extraction)
29
  (single-field, exact-match β€” the closest public proxy for this project's task),
30
  [OCRBench v2](https://99franklin.github.io/ocrbench_v2/) (has an Extraction subscore),
app.py CHANGED
@@ -502,7 +502,9 @@ configured on the server; without it they fail per-model while the rest still ru
502
  which models fit your task, read the maintained model research notes (benchmarks, strengths,
503
  quirks per member) at {MODEL_RESEARCH_URLS}.
504
  Inputs larger than {MAX_IMAGE_PIXELS / 1e6:.1f} megapixels are downscaled (aspect preserved)
505
- before inference, so very small text in very large images may need pre-cropping by the caller.
 
 
506
  The output is a JSON list with one object per requested model, in request order. Each object
507
  has: "model" (the model name, or null when the request itself was invalid); "value" (single-field
508
  requests: the normalized answer, "" meaning the model abstained/field not found, null on
 
502
  which models fit your task, read the maintained model research notes (benchmarks, strengths,
503
  quirks per member) at {MODEL_RESEARCH_URLS}.
504
  Inputs larger than {MAX_IMAGE_PIXELS / 1e6:.1f} megapixels are downscaled (aspect preserved)
505
+ before inference, so very small text in very large images may need pre-cropping by the caller;
506
+ images sent to hosted backends are additionally recompressed as JPEG when needed to fit the HF
507
+ router's ~5MB request-body cap, so hosted models may see a slightly lossier image than local ones.
508
  The output is a JSON list with one object per requested model, in request order. Each object
509
  has: "model" (the model name, or null when the request itself was invalid); "value" (single-field
510
  requests: the normalized answer, "" meaning the model abstained/field not found, null on
extract_arena.py CHANGED
@@ -65,6 +65,12 @@ GEMMA_26B_MODEL_ID = "google/gemma-4-26B-A4B-it"
65
  # (@spaces.GPU(duration=30)) caps how far this can be raised.
66
  MOONDREAM_SETTINGS = '{"temperature": 0.0, "top_p": 0.95, "max_tokens": 1024}'
67
 
 
 
 
 
 
 
68
  # Abstention sentinel: models emit a fixed token far more reliably than an
69
  # empty reply; _normalize_value maps it (and close variants) back to "".
70
  NOT_FOUND = "N/A"
@@ -110,8 +116,37 @@ class ExtractResult:
110
 
111
 
112
  def image_to_data_url(image_path: Path) -> str:
113
- mime = mimetypes.guess_type(image_path.name)[0] or "image/png"
114
- return f"data:{mime};base64,{base64.b64encode(image_path.read_bytes()).decode()}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
 
117
  def _strip_think(text: str) -> str:
 
65
  # (@spaces.GPU(duration=30)) caps how far this can be raised.
66
  MOONDREAM_SETTINGS = '{"temperature": 0.0, "top_p": 0.95, "max_tokens": 1024}'
67
 
68
+ # router.huggingface.co rejects request bodies over ~5 MiB with a 413
69
+ # (measured empirically 2026-08-28: 5.23MB passes, 5.31MB fails). The image
70
+ # rides inline as a base64 data URL, so budget it below the cap with headroom
71
+ # for the prompt and JSON scaffolding.
72
+ MAX_DATA_URL_BYTES = 4_500_000
73
+
74
  # Abstention sentinel: models emit a fixed token far more reliably than an
75
  # empty reply; _normalize_value maps it (and close variants) back to "".
76
  NOT_FOUND = "N/A"
 
116
 
117
 
118
  def image_to_data_url(image_path: Path) -> str:
119
+ """Encode the image as a data URL that fits MAX_DATA_URL_BYTES.
120
+
121
+ Images already under budget pass through byte-identical (no quality loss);
122
+ oversized ones are re-encoded as JPEG and downscaled until they fit.
123
+ """
124
+ encoded = base64.b64encode(image_path.read_bytes()).decode()
125
+ if len(encoded) <= MAX_DATA_URL_BYTES:
126
+ mime = mimetypes.guess_type(image_path.name)[0] or "image/png"
127
+ return f"data:{mime};base64,{encoded}"
128
+
129
+ import io
130
+
131
+ from PIL import Image
132
+
133
+ with Image.open(image_path) as im:
134
+ im = im.convert("RGB")
135
+ while True:
136
+ buf = io.BytesIO()
137
+ im.save(buf, format="JPEG", quality=85)
138
+ recoded = base64.b64encode(buf.getvalue()).decode()
139
+ if len(recoded) <= MAX_DATA_URL_BYTES or min(im.size) <= 64:
140
+ break
141
+ im = im.resize(
142
+ (max(1, round(im.width * 0.85)), max(1, round(im.height * 0.85))),
143
+ Image.LANCZOS,
144
+ )
145
+ logger.info(
146
+ "Recompressed {} for the ~5MiB router body cap: {} -> {} data-URL bytes ({}x{} sent)",
147
+ image_path.name, len(encoded), len(recoded), im.width, im.height,
148
+ )
149
+ return f"data:image/jpeg;base64,{recoded}"
150
 
151
 
152
  def _strip_think(text: str) -> str: