let a model carry its prompt, and say how big a page may be (v0.4.91)

Two asks that turned out to be about the same thing: a request taken as meant.

A model fine-tuned on a quality ladder needs that ladder on every request, and
a client speaking the plain OpenAI image API sends a sentence with nowhere to
put it. CoderAI adds no tags of its own and still does not: prompt_prefix and
negative_prompt_prefix on the MODEL's config entry are comma-joined in front of
whatever the caller sent, and both default to empty, so an install that
configures nothing sends exactly what was written. A request overrides either,
and an empty string switches one off for that call -- what a caller writing its
own complete prompt needs. Applied at most once: a prompt that already opens
with the ladder is left alone rather than given it twice, which would only
dilute the rest.

The prefix resolves once, before the encode, and the same text feeds the
embedding cache key. Keyed on the prompt as SENT, two models with different
prefixes would share one entry and the second would be served the first's
text. The archive keeps the prompt that ran plus the one requested when they
differ.

On OCR: there is no per-page byte limit, and there never was. A page over a
megabyte is ordinary -- an A4 scan at 300 DPI is several -- and both a 3 MB and
a 50 MB body upload completely and are rejected only at authentication, with
the bundled nginx at 4096m and the generated /v1/ocr routing not lowering it.

What did exist was a hardcoded 100 MB ceiling on one document, which a 300 DPI
colour filing passes without being unusual and which nothing could raise. It is
ocr.max_upload_mb now (0 = no ceiling here), in Settings, and its 413 names the
setting and says the document was NOT read -- the loud refusal ocr.max_pages
already makes, instead of "file too large (max 100 MB)".

The 1 MB figure that makes a large page fail in the field is nginx's own
client_max_body_size default, in a reverse proxy added in front. That is
exactly the shape of "anything over a megabyte fails", so it is recorded in the
code, the settings hint and docs/ocr.md rather than left to be rediscovered.
Also documented there: what IS capped is pixels, not bytes -- Surya-2 and
Chandra top out near 6.29 MP, an A4 page at about 255 DPI, and above that the
page is silently downscaled to fit.

37 tests, including a real 1.25 MB single-page PDF built from noise and read
back through pdfium as one page. Full suite 4512 passed with the same 15
pre-existing failures as 0.4.90 (diff empty).
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S8LAP3ueNzQJtwzhgVbq66
parent 03169236
......@@ -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.4.90"
__version__ = "0.4.91"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
......@@ -2932,6 +2932,7 @@ async def api_model_configure(request: Request, username: str = Depends(require_
# booru-tag fine-tunes need clip_skip 2 whatever the caller
# sends, and an OpenAI-API client has no field to send it in.
"clip_skip", "prompt_weighting", "textual_inversion",
"prompt_prefix", "negative_prompt_prefix",
"component_quantization", "output_crf", "force_vram_update",
"balanced_gpu_percent", "acceleration",
"cache_type_k", "cache_type_v", "kv_offload", "n_batch", "n_ubatch", "n_seq_max",
......@@ -4195,6 +4196,7 @@ def build_settings_dict(c, gpu_cards):
"surya_dpi": c.ocr.surya_dpi,
"max_concurrency": c.ocr.max_concurrency,
"max_pages": c.ocr.max_pages,
"max_upload_mb": c.ocr.max_upload_mb,
"pod_pipeline": c.ocr.pod_pipeline,
"pod_pipeline_wait_s": c.ocr.pod_pipeline_wait_s,
"pod_http_async": c.ocr.pod_http_async,
......
......@@ -1553,6 +1553,15 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
</div>
<span class="form-hint">On, the A1111 grammar is parsed: <code>(tag:1.3)</code> emphasises, <code>[tag]</code> attenuates, bare <code>(tag)</code> is 1.1, nesting multiplies, <code>\(</code> is a literal bracket, and <code>BREAK</code> starts a new 75-token window. Off, brackets reach the model as text. Either way the prompt is encoded in as many windows as it needs, so nothing is cut at 77 tokens.</span>
<div class="form-row" style="margin-top:.75rem">
<label class="form-label">Prompt prefix <span class="muted">(prepended to every prompt)</span></label>
<input type="text" id="cfg-prompt-prefix" class="form-input" placeholder="score_9, score_8_up, score_7_up, score_6_up">
<span class="form-hint">Comma-joined in front of whatever the caller sent. This is how a model trained on a quality ladder gets it on every request, including from a client that speaks the plain OpenAI image API and only sends a sentence. Applied at most once — a prompt that already starts with it is left alone — and a request can override it, or send an empty string to switch it off.</span>
</div>
<div class="form-row" style="margin-top:.75rem">
<label class="form-label">Negative prompt prefix</label>
<input type="text" id="cfg-negative-prompt-prefix" class="form-input" placeholder="score_4, score_5, score_6, worst quality">
</div>
<div class="form-row" style="margin-top:.75rem">
<label class="form-label">Textual inversions <span class="muted">(comma-separated; always loaded for this model)</span></label>
<input type="text" id="cfg-textual-inversion" class="form-input" placeholder="easynegative, bad-hands-5, my-emb.safetensors:myneg">
......@@ -4311,6 +4320,8 @@ function openCfgModal(idx, cfgIdx){
document.getElementById('cfg-vae-tiling').checked = !!s.vae_tiling;
document.getElementById('cfg-clip-skip').value = (s.clip_skip != null ? s.clip_skip : '');
document.getElementById('cfg-prompt-weighting').checked = (s.prompt_weighting !== false);
document.getElementById('cfg-prompt-prefix').value = (s.prompt_prefix || '');
document.getElementById('cfg-negative-prompt-prefix').value = (s.negative_prompt_prefix || '');
document.getElementById('cfg-textual-inversion').value =
(Array.isArray(s.textual_inversion) ? s.textual_inversion.join(', ') : (s.textual_inversion || ''));
document.getElementById('cfg-output-crf').value = (s.output_crf != null ? s.output_crf : '');
......@@ -5418,6 +5429,8 @@ async function saveModelConfig(){
clip_skip: (document.getElementById('cfg-clip-skip').value.trim() === ''
? null : parseInt(document.getElementById('cfg-clip-skip').value, 10)),
prompt_weighting: document.getElementById('cfg-prompt-weighting').checked,
prompt_prefix: document.getElementById('cfg-prompt-prefix').value.trim(),
negative_prompt_prefix: document.getElementById('cfg-negative-prompt-prefix').value.trim(),
textual_inversion: document.getElementById('cfg-textual-inversion').value
.split(',').map(v => v.trim()).filter(v => v !== ''),
};
......
......@@ -1136,6 +1136,16 @@
over-long document is refused in milliseconds with a 413 naming its page
count &mdash; never a silent or empty read.</span>
</div>
<div class="form-group" style="flex:1;min-width:150px">
<label class="form-label">Max upload size (MB)</label>
<input type="number" id="s-ocr-max-upload-mb" class="form-input" min="0" placeholder="100">
<span class="form-hint">Ceiling on ONE document, not on a page: a 300 DPI
colour scan is several MB per page. <b>0 = no ceiling here.</b> If a large
page is refused with 413 well under this, the body was stopped by a reverse
proxy in front &mdash; nginx defaults <code>client_max_body_size</code> to
<b>1 MB</b>, which is exactly the shape of &ldquo;anything over a megabyte
fails&rdquo;.</span>
</div>
<div class="form-group" style="flex:1;min-width:170px">
<label class="form-label">When no pod can take a page</label>
<select id="s-ocr-pod-pipeline" class="form-input">
......@@ -2363,6 +2373,7 @@ async function loadSettings(){
document.getElementById('s-ocr-surya-dpi').value = ocr.surya_dpi ?? 250;
document.getElementById('s-ocr-max-concurrency').value = ocr.max_concurrency ?? 64;
document.getElementById('s-ocr-max-pages').value = ocr.max_pages ?? 0;
document.getElementById('s-ocr-max-upload-mb').value = ocr.max_upload_mb ?? 100;
document.getElementById('s-ocr-pod-pipeline').value = ocr.pod_pipeline ?? 'prefer';
document.getElementById('s-ocr-pod-pipeline-wait').value = ocr.pod_pipeline_wait_s ?? 60;
document.getElementById('s-ocr-pod-http-async').value = (ocr.pod_http_async ?? true) ? '1' : '0';
......@@ -2679,6 +2690,7 @@ async function saveSettings(silent){
surya_dpi: parseInt(document.getElementById('s-ocr-surya-dpi').value) || 0,
max_concurrency: parseInt(document.getElementById('s-ocr-max-concurrency').value) || 64,
max_pages: parseInt(document.getElementById('s-ocr-max-pages').value) || 0,
max_upload_mb: parseInt(document.getElementById('s-ocr-max-upload-mb').value) || 0,
pod_pipeline: document.getElementById('s-ocr-pod-pipeline').value || 'prefer',
pod_pipeline_wait_s: parseFloat(document.getElementById('s-ocr-pod-pipeline-wait').value) || 0,
pod_http_async: document.getElementById('s-ocr-pod-http-async').value === '1',
......
This diff is collapsed.
......@@ -38,8 +38,30 @@ from codai.ocr.schemas import schema_store
router = APIRouter()
# Maximum upload size: 100 MB (multi-page scanned PDFs can be large).
_MAX_OCR_BYTES = 100 * 1024 * 1024
# Default ceiling on one upload, when the config says nothing: 100 MB. A scan
# is as big as its DPI and its page count make it, so `ocr.max_upload_mb`
# overrides this and 0 removes the ceiling altogether.
_DEFAULT_MAX_OCR_MB = 100
def _max_upload_bytes(cfg) -> int:
"""Bytes one upload may be, or 0 for no ceiling.
Note what this is NOT: a per-page limit. Nothing in the OCR path caps a
single page, and a page bigger than a megabyte is ordinary -- a 300 DPI A4
scan is several. When a large page is refused with 413 and this ceiling is
nowhere near reached, the body was stopped by a reverse proxy in front:
nginx defaults `client_max_body_size` to 1 MB, which is exactly the shape
of "anything over a megabyte fails".
"""
mb = getattr(cfg, "max_upload_mb", None)
if mb is None:
mb = _DEFAULT_MAX_OCR_MB
try:
mb = int(mb)
except (TypeError, ValueError):
mb = _DEFAULT_MAX_OCR_MB
return max(0, mb) * 1024 * 1024
global_args = None
......@@ -204,8 +226,13 @@ async def create_ocr(
data = await file.read()
if not data:
raise HTTPException(status_code=400, detail="empty upload")
if len(data) > _MAX_OCR_BYTES:
raise HTTPException(status_code=413, detail="file too large (max 100 MB)")
_cap = _max_upload_bytes(cfg)
if _cap and len(data) > _cap:
raise HTTPException(status_code=413, detail=(
f"upload is {len(data) / 1048576:.1f} MB, over the "
f"{_cap // 1048576} MB ceiling this install sets for one document "
f"(ocr.max_upload_mb) — it was NOT read. Raise the ceiling, or "
f"split the document and send the parts."))
try:
result = await _bounded(cfg, ocr_manager.ocr_document(
......@@ -258,11 +285,16 @@ async def create_ocr_batch(
data = await f.read()
payloads.append((f.filename or "", f.content_type or "", data))
_cap = _max_upload_bytes(cfg)
async def _one(name, ctype, data):
if not data:
return {"filename": name, "error": "empty upload"}
if len(data) > _MAX_OCR_BYTES:
return {"filename": name, "error": "file too large (max 100 MB)"}
if _cap and len(data) > _cap:
return {"filename": name, "error": (
f"{len(data) / 1048576:.1f} MB, over the {_cap // 1048576} MB "
f"ceiling this install sets for one document "
f"(ocr.max_upload_mb) — it was NOT read")}
try:
res = await ocr_manager.ocr_document(
data, filename=name, content_type=ctype, engine=engine, dpi=dpi,
......
......@@ -1176,6 +1176,13 @@ class OcrConfig:
# It must never look like an empty or failed read -- see the "a degraded
# page still answers 200" trap this subsystem already carries.
max_pages: int = 0
# Ceiling on ONE upload, in MB. A scan is as big as its DPI and its page
# count make it -- a 300 DPI colour filing passes 100 MB without being
# unusual -- so this is a setting rather than the constant it used to be.
# 0 = no ceiling here (the reverse proxy's body limit still applies, and
# nginx's own default of 1 MB is the usual reason a large page is refused
# before coderai ever sees it: see docs/ocr.md).
max_upload_mb: int = 100
# What to do when the model's pods are the ones that run the OCR pipeline
# (their image carries the rasteriser) but no pod can take a page.
#
......@@ -2329,6 +2336,7 @@ class ConfigManager:
"max_concurrency": self.config.ocr.max_concurrency,
"max_resident_mb": self.config.ocr.max_resident_mb,
"max_pages": self.config.ocr.max_pages,
"max_upload_mb": self.config.ocr.max_upload_mb,
"pod_pipeline": self.config.ocr.pod_pipeline,
"pod_pipeline_wait_s": self.config.ocr.pod_pipeline_wait_s,
"pod_http_async": self.config.ocr.pod_http_async,
......
......@@ -57,6 +57,13 @@ class ImageGenerationRequest(BaseModel):
"Take the text embedding this many CLIP layers from the end instead of the last. "
"The booru-tag SDXL fine-tune families (Pony, Illustrious, NoobAI) are trained at 2. "
"Omitted uses the model's configured default, then the pipeline's own."))
prompt_prefix: Optional[str] = Field(None, description=(
"Text prepended to the prompt, comma-joined. Omitted uses the model's own "
"prompt_prefix (how a model fine-tuned on a quality ladder gets it on every "
"request); an empty string switches it off for this call. Applied at most "
"once -- a prompt that already starts with it is left alone."))
negative_prompt_prefix: Optional[str] = Field(None, description=(
"The same, for the negative prompt (e.g. 'score_4, score_5, score_6')."))
embeddings: Optional[List[str]] = Field(None, description=(
"Textual-inversion embeddings to load before generating, so their trigger "
"words work in the prompt (e.g. [\"easynegative\"]). A bare name is looked "
......
......@@ -50,6 +50,7 @@ take:
| `clip_skip` | Read the embedding this many CLIP layers from the end. Omitted uses the model's configured value, then the pipeline's own default. |
| `prompt_weighting` | `false` keeps brackets literal, for a caller sending prose. The windowing stays on either way, so turning it off never costs tokens. |
| `embeddings` | Textual inversions to load, so their trigger words work in the prompt — `["easynegative"]`. |
| `prompt_prefix` / `negative_prompt_prefix` | Override the model's standing prefix; `""` switches it off for this call. |
```json
{
......@@ -102,6 +103,33 @@ source photo's own aspect at about 1 MP on a 64-pixel grid. Both used to
default to a square, which squashed every non-square photo — and on inpaint it
pulled the mask out of alignment with the image as well.
## Standing prefixes: the ladder a model always wants
CoderAI never adds tags to a prompt by itself. The `score_*` ladder is yours to
send — in both directions — and what the sections above do is make sure it is
read correctly, not supply it.
When a model should always get it, that belongs to the model:
`prompt_prefix` and `negative_prompt_prefix` on its config entry (admin →
Models → Prompt Handling). They are comma-joined in front of whatever the
caller sent, which is what makes a Pony-family model usable from a client that
speaks the plain OpenAI image API and only sends a sentence:
```json
{ "prompt_prefix": "score_9, score_8_up, score_7_up, score_6_up",
"negative_prompt_prefix": "score_4, score_5, score_6" }
```
A request can send its own `prompt_prefix` / `negative_prompt_prefix` to
override either, and an **empty string switches the prefix off for that call** —
what a caller writing its own complete prompt needs. The prefix is applied at
most once: a prompt that already opens with it is left alone, so pasting a full
prompt does not get the ladder twice. Both default to empty, so an install that
configures nothing sends exactly what the caller wrote.
The prefix is prompt text like any other, so the weight grammar works in it:
`(masterpiece:1.2), score_9` is a valid prefix.
## Learned prompt tokens (textual inversion)
A textual inversion is a few vectors trained to sit in CLIP's embedding table
......
......@@ -548,6 +548,39 @@ GET /v1/ocr/engines what this install offers, and what each engine returns
GET/POST/DELETE /v1/ocr/schemas[/{name}] manage extraction schemas
```
### How big a document may be
| Limit | Default | Where |
|---|---|---|
| One upload | 100 MB | `ocr.max_upload_mb` (0 = no ceiling here) |
| Pages per document | no ceiling | `ocr.max_pages` |
| Pixels per page | ~6.29 MP | the engines' own cap (see below) |
**There is no per-page byte limit.** A page over a megabyte is ordinary — an A4
scan at 300 DPI is several — and nothing in the OCR path stops at one.
So if a large page comes back `413` while `ocr.max_upload_mb` is nowhere near
reached, the body was stopped **in front of coderai**. nginx defaults
`client_max_body_size` to **1 MB**, and that default is exactly the shape of
"anything over a megabyte fails". coderai's own bundled nginx raises it to
4096m and the generated `/v1/ocr` routing does not lower it, so the culprit is
a reverse proxy added in front — raise it there
(see [reverse-proxy-nginx.md](reverse-proxy-nginx.md)):
```nginx
client_max_body_size 1024m;
proxy_request_buffering off; # stream it rather than spool the whole scan
```
coderai's own 413 names the setting and says the document was **not read**, so
it can be told apart from a proxy's terse refusal.
What *is* capped is **pixels**, not bytes: Surya-2 and Chandra both top out
around 6,291,456 pixels (3072×2048), which an A4 page reaches at about 255 DPI.
Above that the page is scaled down to fit before the model sees it — a
deliberate downscale, not a refusal, and the reason `ocr.surya_dpi` defaults to
250 rather than something higher.
`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
......
This diff is collapsed.
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