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',
......
......@@ -987,6 +987,50 @@ def _resolve_prompt_weighting(request, model_config, args=None) -> bool:
return True
def _apply_prefix(prefix, text: str) -> str:
"""``prefix`` in front of ``text``, comma-joined, applied at most once.
Booru-tag prompts are a comma-separated list, so the two parts join with
", " and the prefix's own trailing comma is not doubled. If ``text``
already starts with the prefix -- a caller who pasted a full prompt
including the ladder -- it is left alone rather than repeated, which would
only dilute the rest of the prompt.
"""
head = (prefix or "").strip().rstrip(",").strip()
body = (text or "").strip()
if not head:
return text or ""
if body.lower().startswith(head.lower()):
return body
return f"{head}, {body}" if body else head
def _effective_prompts(request, model_config=None):
"""``(prompt, negative_prompt)`` with the model's standing prefixes applied.
A model fine-tuned on a quality ladder wants it on every request, and a
client speaking the plain OpenAI image API sends a sentence and has no
field to put the ladder in. So the prefix belongs to the model:
``prompt_prefix`` and ``negative_prompt_prefix`` on its config entry.
A request can override either one -- its own value wins, and an empty
string switches the prefix off for that call, which is what a caller
writing its own complete prompt needs. Nothing configured means the
prompts are returned exactly as sent.
"""
cfg = model_config or {}
pos = getattr(request, 'prompt_prefix', None)
if pos is None:
pos = cfg.get('prompt_prefix')
neg = getattr(request, 'negative_prompt_prefix', None)
if neg is None:
neg = cfg.get('negative_prompt_prefix')
prompt = _apply_prefix(pos, getattr(request, 'prompt', '') or "")
negative = _apply_prefix(neg, getattr(request, 'negative_prompt', None) or "")
return prompt, negative
def _apply_embeddings(pipeline, request, model_config=None, args=None):
"""Load this request's textual inversions onto the pipeline, if any.
......@@ -1120,7 +1164,9 @@ async def _generate_with_diffusers(pipeline, request, global_args, http_request=
# cache miss instead of feeding stale GPU tensors into a fresh pipeline.
_pipe_cls = str(type(pipeline).__name__)
_model_name = getattr(pipeline, 'model_name_or_path', None) or _pipe_cls
neg_prompt = getattr(request, 'negative_prompt', None) or ""
# The model's standing prefixes, applied once here so the encode, the
# cache key and the pipeline call all see the same text.
prompt, neg_prompt = _effective_prompts(request, model_config)
do_cfg = cfg_scale > 1.0
# Learned prompt tokens first: they change what the prompt tokenizes TO.
......@@ -1142,7 +1188,7 @@ async def _generate_with_diffusers(pipeline, request, global_args, http_request=
# unpatchify). These are not safe to cache, so encode fresh every time.
_embed_cacheable = "ZImage" not in _pipe_cls
cached_embeds = _embed_cache.get(request.prompt, neg_prompt, model_id) if _embed_cacheable else None
cached_embeds = _embed_cache.get(prompt, neg_prompt, model_id) if _embed_cacheable else None
embed_kwargs = {}
cache_hit = False
......@@ -1160,9 +1206,9 @@ async def _generate_with_diffusers(pipeline, request, global_args, http_request=
# prompt needs and with per-token weights. The pipeline's own encoder
# cannot do either — it truncates at 77 and reads the brackets as text.
embed_kwargs = _weighted_embed_kwargs(
pipeline, request.prompt, neg_prompt, _clip_skip, _weighting) or {}
pipeline, prompt, neg_prompt, _clip_skip, _weighting) or {}
if embed_kwargs and _embed_cacheable:
_embed_cache.put(request.prompt, neg_prompt, model_id, embed_kwargs)
_embed_cache.put(prompt, neg_prompt, model_id, embed_kwargs)
if not embed_kwargs and not cache_hit and _embed_cacheable:
# Not a CLIP-pair pipeline (Flux, SD3), or the encode failed: the
......@@ -1171,7 +1217,7 @@ async def _generate_with_diffusers(pipeline, request, global_args, http_request=
if hasattr(pipeline, 'encode_prompt'):
import inspect as _inspect
_ep_params = set(_inspect.signature(pipeline.encode_prompt).parameters)
_ep_kwargs = {"prompt": request.prompt}
_ep_kwargs = {"prompt": prompt}
if "device" in _ep_params:
_ep_kwargs["device"] = pipeline.device
if "num_images_per_prompt" in _ep_params:
......@@ -1198,7 +1244,7 @@ async def _generate_with_diffusers(pipeline, request, global_args, http_request=
'negative_pooled_prompt_embeds': enc[3],
}
if embed_kwargs and _embed_cacheable:
_embed_cache.put(request.prompt, neg_prompt, model_id, embed_kwargs)
_embed_cache.put(prompt, neg_prompt, model_id, embed_kwargs)
print(f"Prompt embed cache STORE for model '{model_id}'")
except Exception as e:
print(f"Warning: prompt encode/cache failed ({e}), using plain text prompt")
......@@ -1255,7 +1301,7 @@ async def _generate_with_diffusers(pipeline, request, global_args, http_request=
)
else:
call_kwargs = dict(
prompt=request.prompt,
prompt=prompt,
negative_prompt=neg_prompt or None,
num_images_per_prompt=request.n,
height=height,
......@@ -1402,8 +1448,10 @@ async def _generate_with_diffusers(pipeline, request, global_args, http_request=
archive_manager.save_generation,
"image", "/v1/images/generations",
getattr(request, 'model', None) or model_id,
request.prompt,
prompt,
{
"requested_prompt": request.prompt if prompt != request.prompt else None,
"negative_prompt": neg_prompt or None,
"size": f"{width}x{height}",
"requested_size": request.size,
"n": request.n,
......@@ -1447,7 +1495,7 @@ async def _generate_with_sdcpp(sd_model, request, global_args, http_request=None
steps = request.steps if request.steps else (_accel_steps or 4)
cfg_scale = request.guidance_scale or _accel_cfg or get_cfg_scale()
prompt = request.prompt
prompt, neg_prompt = _effective_prompts(request, model_config)
if _accel and _accel.get('lora') and (model_config or {}).get('lora_model_dir'):
from codai.models.acceleration import _split_lora_ref
_repo, _wn = _split_lora_ref(_accel['lora'])
......@@ -1482,7 +1530,7 @@ async def _generate_with_sdcpp(sd_model, request, global_args, http_request=None
# holds quality down (score_4, score_5, score_6) stopped at the API.
_sd_kwargs = dict(
prompt=prompt,
negative_prompt=getattr(request, 'negative_prompt', None) or '',
negative_prompt=neg_prompt or '',
width=width,
height=height,
cfg_scale=cfg_scale,
......@@ -1545,8 +1593,10 @@ async def _generate_with_sdcpp(sd_model, request, global_args, http_request=None
archive_manager.save_generation,
"image", "/v1/images/generations",
getattr(request, 'model', None),
request.prompt,
prompt,
{
"requested_prompt": request.prompt if prompt != request.prompt else None,
"negative_prompt": neg_prompt or None,
"size": f"{width}x{height}",
"requested_size": request.size,
"n": request.n,
......@@ -1922,6 +1972,10 @@ class ImageEditRequest(BaseModel):
negative_prompt: Optional[str] = None # what to avoid; CFG needs it as much as a t2i call does
clip_skip: Optional[int] = None # None = the model's configured default
prompt_weighting: Optional[bool] = None # None = on; see codai/models/prompt_weighting.py
#: Prepended to the prompts, comma-joined. None = the model's own prefix,
#: "" = none for this call. See _effective_prompts.
prompt_prefix: Optional[str] = None
negative_prompt_prefix: Optional[str] = None
n: int = 1
#: Omitted means "keep the source's shape" (see _default_edit_size), NOT a
#: square: defaulting to 1024x1024 silently distorted every non-square photo.
......@@ -1987,14 +2041,14 @@ def _prompt_call_kwargs(pipeline, request, model_config=None):
nowhere to put one, so an edit could only ever be steered positively.
"""
_apply_embeddings(pipeline, request, model_config, global_args)
neg = getattr(request, 'negative_prompt', None) or ""
prompt, neg = _effective_prompts(request, model_config)
kw = _weighted_embed_kwargs(
pipeline, request.prompt, neg,
pipeline, prompt, neg,
_resolve_clip_skip(request, model_config, global_args),
_resolve_prompt_weighting(request, model_config, global_args))
if kw:
return kw
return {"prompt": request.prompt, "negative_prompt": neg or None}
return {"prompt": prompt, "negative_prompt": neg or None}
def _load_img2img_pipeline(model_name: str, global_args):
......@@ -2143,6 +2197,10 @@ class ImageInpaintRequest(BaseModel):
negative_prompt: Optional[str] = None
clip_skip: Optional[int] = None
prompt_weighting: Optional[bool] = None
#: Prepended to the prompts, comma-joined. None = the model's own prefix,
#: "" = none for this call. See _effective_prompts.
prompt_prefix: Optional[str] = None
negative_prompt_prefix: Optional[str] = None
n: int = 1
response_format: Optional[str] = "url"
steps: Optional[int] = None
......@@ -2869,6 +2927,10 @@ class ImageOutfitRequest(BaseModel):
negative_prompt: Optional[str] = None
clip_skip: Optional[int] = None # None = the model's configured default
prompt_weighting: Optional[bool] = None # None = on
#: Prepended to the prompts, comma-joined. None = the model's own prefix,
#: "" = none for this call. See _effective_prompts.
prompt_prefix: Optional[str] = None
negative_prompt_prefix: Optional[str] = None
mask: Optional[str] = None # optional manual mask (base64); auto-generated if absent
steps: Optional[int] = 30
guidance_scale: Optional[float] = 7.5
......
......@@ -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
......
"""A model's standing prompt prefix, and how big one OCR upload may be.
Two unrelated asks, both about a request being taken as meant.
**Prompt prefix.** 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 and has nowhere to put it. So the prefix belongs to the model, with
the request able to override or switch it off -- and applied at most once, or a
caller who pastes a full prompt gets the ladder twice.
**OCR upload size.** Nothing in the OCR path caps a single page, and a page
over a megabyte is ordinary: a 300 DPI A4 scan is several. What existed was a
hardcoded 100 MB ceiling on a whole document, which a 300 DPI colour filing
passes without being unusual, and no way to raise it. It is now
``ocr.max_upload_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 in
front -- so the error says so, which is the only way a caller can tell the two
apart.
"""
import io
import types
import pytest
from fastapi import HTTPException
from codai.api import images as images_mod
# --------------------------------------------------------------------------- #
# the prompt prefix
# --------------------------------------------------------------------------- #
def _req(**kw):
base = dict(prompt="1girl, red hair", negative_prompt=None,
prompt_prefix=None, negative_prompt_prefix=None)
base.update(kw)
return types.SimpleNamespace(**base)
def test_nothing_configured_leaves_the_prompts_alone():
p, n = images_mod._effective_prompts(_req(negative_prompt="blurry"), {})
assert p == "1girl, red hair"
assert n == "blurry"
def test_the_model_supplies_the_ladder():
"""The case this exists for: a client that only sends a sentence."""
p, n = images_mod._effective_prompts(
_req(prompt="a knight in a field"),
{"prompt_prefix": "score_9, score_8_up, score_7_up",
"negative_prompt_prefix": "score_4, score_5, score_6"})
assert p == "score_9, score_8_up, score_7_up, a knight in a field"
assert n == "score_4, score_5, score_6"
def test_the_negative_prefix_joins_an_existing_negative():
p, n = images_mod._effective_prompts(
_req(negative_prompt="blurry, jpeg artifacts"),
{"negative_prompt_prefix": "score_4, score_5"})
assert n == "score_4, score_5, blurry, jpeg artifacts"
def test_the_request_overrides_the_model():
p, _ = images_mod._effective_prompts(
_req(prompt_prefix="masterpiece"), {"prompt_prefix": "score_9"})
assert p == "masterpiece, 1girl, red hair"
def test_an_empty_string_switches_it_off_for_this_call():
"""What a caller writing its own complete prompt needs."""
p, n = images_mod._effective_prompts(
_req(prompt_prefix="", negative_prompt_prefix="",
negative_prompt="blurry"),
{"prompt_prefix": "score_9", "negative_prompt_prefix": "score_4"})
assert p == "1girl, red hair"
assert n == "blurry"
def test_it_is_applied_at_most_once():
"""A pasted prompt that already opens with the ladder must not get it
twice -- repeating it only dilutes the rest of the prompt."""
prefix = "score_9, score_8_up"
p, _ = images_mod._effective_prompts(
_req(prompt=f"{prefix}, 1girl"), {"prompt_prefix": prefix})
assert p == f"{prefix}, 1girl"
assert p.count("score_9") == 1
def test_the_already_applied_check_ignores_case():
p, _ = images_mod._effective_prompts(
_req(prompt="SCORE_9, score_8_up, 1girl"),
{"prompt_prefix": "score_9, score_8_up"})
assert p.lower().count("score_9") == 1
@pytest.mark.parametrize("prefix,expected", [
("score_9,", "score_9, 1girl"), # no doubled comma
("score_9 , ", "score_9, 1girl"),
(" score_9 ", "score_9, 1girl"),
])
def test_the_join_is_tidy(prefix, expected):
p, _ = images_mod._effective_prompts(
_req(prompt="1girl"), {"prompt_prefix": prefix})
assert p == expected
def test_a_prefix_with_an_empty_prompt_is_the_whole_prompt():
p, _ = images_mod._effective_prompts(
_req(prompt=""), {"prompt_prefix": "score_9, score_8_up"})
assert p == "score_9, score_8_up"
def test_a_prefix_carries_the_weight_grammar_through():
"""The prefix is prompt text like any other, so (tag:1.2) in it works."""
p, _ = images_mod._effective_prompts(
_req(prompt="1girl"), {"prompt_prefix": "(masterpiece:1.2), score_9"})
assert p == "(masterpiece:1.2), score_9, 1girl"
from codai.models.prompt_weighting import parse_prompt_attention
weights = {t.strip(" ,"): w for t, w in parse_prompt_attention(p)}
assert weights["masterpiece"] == pytest.approx(1.2)
@pytest.mark.parametrize("cls_name", ["ImageEditRequest", "ImageInpaintRequest",
"ImageOutfitRequest"])
def test_every_endpoint_that_takes_a_prompt_takes_the_prefixes(cls_name):
cls = getattr(images_mod, cls_name)
for field in ("prompt_prefix", "negative_prompt_prefix"):
assert field in cls.model_fields, f"{cls_name} is missing {field}"
assert cls.model_fields[field].default is None
def test_the_generation_request_takes_them_too():
from codai.pydantic.imagerequest import ImageGenerationRequest
r = ImageGenerationRequest(model="m", prompt="p", prompt_prefix="score_9")
assert r.prompt_prefix == "score_9"
assert r.negative_prompt_prefix is None
def test_the_prefix_reaches_the_encode_and_the_cache_key_together():
"""If the cache were keyed on the prompt as SENT, two models with different
prefixes would share one entry and the second would get the first's text."""
src = open("codai/api/images.py").read()
head = src.index("async def _generate_with_diffusers")
body = src[head:head + 9000]
assert "prompt, neg_prompt = _effective_prompts(request, model_config)" in body
assert "_embed_cache.get(prompt, neg_prompt, model_id)" in body
assert "_embed_cache.get(request.prompt" not in body
def test_it_is_configurable_per_model():
assert '"prompt_prefix", "negative_prompt_prefix",' in \
open("codai/admin/routes.py").read()
ui = open("codai/admin/templates/models.html").read()
assert "cfg-prompt-prefix" in ui and "cfg-negative-prompt-prefix" in ui
def test_nothing_injects_a_quality_ladder_on_its_own():
"""The prefix is the only way tags are added, and it is empty by default:
an install that configures nothing sends exactly what the caller wrote."""
for cfg in (None, {}, {"prompt_prefix": "", "negative_prompt_prefix": ""}):
p, n = images_mod._effective_prompts(_req(), cfg)
assert p == "1girl, red hair", cfg
assert n == "", cfg
# --------------------------------------------------------------------------- #
# OCR upload size
# --------------------------------------------------------------------------- #
def test_the_default_ceiling_is_a_hundred_megabytes():
from codai.api.ocr import _max_upload_bytes, _DEFAULT_MAX_OCR_MB
assert _DEFAULT_MAX_OCR_MB == 100
assert _max_upload_bytes(types.SimpleNamespace()) == 100 * 1024 * 1024
def test_the_ceiling_is_configurable():
from codai.api.ocr import _max_upload_bytes
assert _max_upload_bytes(
types.SimpleNamespace(max_upload_mb=512)) == 512 * 1024 * 1024
def test_zero_means_no_ceiling_here():
"""For an install whose reverse proxy already decides the limit."""
from codai.api.ocr import _max_upload_bytes
assert _max_upload_bytes(types.SimpleNamespace(max_upload_mb=0)) == 0
@pytest.mark.parametrize("value", [None, "", "lots", -5])
def test_a_meaningless_ceiling_falls_back_to_the_default(value):
from codai.api.ocr import _max_upload_bytes
got = _max_upload_bytes(types.SimpleNamespace(max_upload_mb=value))
assert got in (0, 100 * 1024 * 1024)
@pytest.mark.parametrize("mb", [1, 2, 5, 20, 99])
def test_a_page_over_a_megabyte_is_under_the_ceiling(mb):
"""The ask, as arithmetic: nothing in this path stops at 1 MB."""
from codai.api.ocr import _max_upload_bytes
cap = _max_upload_bytes(types.SimpleNamespace())
assert mb * 1024 * 1024 < cap
def test_the_413_names_the_setting_and_says_it_was_not_read():
"""A 413 that just says "too large" leaves a caller unable to tell this
ceiling from a reverse proxy's 1 MB default."""
src = open("codai/api/ocr.py").read()
assert "ocr.max_upload_mb" in src
assert "it was NOT read" in src
assert "file too large (max 100 MB)" not in src # the old fixed message
def test_the_code_records_that_this_is_not_a_per_page_limit():
"""The 1 MB figure in the field is nginx's client_max_body_size default,
and whoever next reads this code should not have to rediscover that."""
src = open("codai/api/ocr.py").read()
assert "client_max_body_size" in src
def test_the_bundled_nginx_lets_a_big_body_through():
"""coderai's own nginx must not be the thing stopping a page: its config
raises the 1 MB default, and the OCR location inherits it."""
conf = open("packaging/linux/nginx.conf").read()
assert "client_max_body_size" in conf
size = conf.split("client_max_body_size", 1)[1].split(";", 1)[0].strip()
assert size.endswith("m") and int(size[:-1]) >= 1024, size
def test_the_generated_ocr_routing_does_not_lower_it():
"""The fleet's nginx include is written per request path; a
client_max_body_size there would override the inherited one."""
from codai import ocr_fleet_workers as fleet
upstream, locations = fleet.render([9100, 9101])
assert "location = /v1/ocr {" in locations
assert "client_max_body_size" not in locations
assert "client_max_body_size" not in upstream
def test_a_multi_megabyte_single_page_pdf_is_read_as_one_page():
"""End to end on the real reader: a page far over a megabyte is ordinary.
Built as one page carrying an incompressible image, so the file is big for
the reason a scan is big rather than by padding.
"""
pytest.importorskip("pypdfium2")
import os
import random
from PIL import Image
random.seed(0)
# ~2480x3508 is A4 at 300 DPI; noise so nothing compresses it away.
img = Image.frombytes("RGB", (1240, 1754),
bytes(random.getrandbits(8)
for _ in range(1240 * 1754 * 3)))
buf = io.BytesIO()
img.save(buf, "PDF", resolution=150.0)
data = buf.getvalue()
assert len(data) > 1024 * 1024, f"only {len(data)} bytes; test is not testing"
import pypdfium2 as pdfium
doc = pdfium.PdfDocument(data)
try:
assert len(doc) == 1
w, h = doc.get_page_size(0)
assert w > 0 and h > 0
finally:
doc.close()
from codai.api.ocr import _max_upload_bytes
assert len(data) < _max_upload_bytes(types.SimpleNamespace())
assert len(data) > 1024 * 1024
del os
def test_the_setting_is_exposed_where_an_operator_looks():
from codai.config import OcrConfig
assert OcrConfig().max_upload_mb == 100
assert "max_upload_mb" in open("codai/admin/routes.py").read()
assert "s-ocr-max-upload-mb" in open(
"codai/admin/templates/settings.html").read()
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