fix(#1687): substitute, don't send, an embedding input with no text - #155
Open
DevNexsler wants to merge 1 commit into
Open
fix(#1687): substitute, don't send, an embedding input with no text#155DevNexsler wants to merge 1 commit into
DevNexsler wants to merge 1 commit into
Conversation
An embeddings request is all-or-nothing at both ends. `bound_inputs` already
truncated an input past the model's context window (#0569) because the route
rejects the WHOLE batch and the input never gets shorter. The mirror case was
unguarded: an input with no text is rejected just as hard and just as
permanently, and takes every other document in the batch with it.
Measured against openrouter.ai/api/v1/embeddings (qwen/qwen3-embedding-8b) on
2026-08-27:
["first", "", "third"] -> 400 zod `too_small`, "path": ["input", 1]
— the gateway's own validation, before any
upstream sees the batch
[" "] / [" "] -> 400 {"detail":"Prompt must not be empty"}
— the upstream, and intermittently: three runs of
[" "] answered 400 / 400 / 200 depending on which
provider the gateway routed to
So the guard keys on content, not on length: any input whose `.strip()` is
empty is replaced with EMPTY_INPUT_PLACEHOLDER ("." — one printable character,
the smallest input no layer treats as absent). Substituting rather than
dropping is forced by EmbedProvider's one-vector-per-input contract:
`_call_embeddings` indexes the response back onto the request, so dropping a
slot would silently misalign every later vector in the batch. The check runs
after truncation, because a text whose only content sits past the token limit
is bounded down to whitespace and is then the same problem.
The bound lives in providers/embed/limits.py, so all four embed providers get
it from the seam that already owns "what will this route accept" — no call site
re-derives it. `max_input_tokens: 0` still disables the context-window bound;
the route's refusal of a text-free input is not a tunable.
Reachable producers today: `attachment_context_refresh.build_contextualized_text`
returns "" when the anchor chunk is context-only and the refreshed context is
empty, and `TaxonomyStore._build_row` / `.search` embed a raw label. The
indexing chunk path cannot produce one — every chunk is prefixed with a
non-empty `_build_chunk_context` header, whose title falls back to the doc id.
staging/provider_sim learns both rejections first, so the hermetic tier can
reproduce this at all (#1658): the empty-string case with the gateway's exact
zod body, the whitespace-only case with the upstream's. The sim answers the
second one deterministically even though the real route is intermittent — a
simulator stricter than the real route cannot produce a false green, a more
permissive one can.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ticket #1687 — doc-organizer: an embedding input with no text is rejected by the
route and takes the whole batch with it.
What this fixes
providers/embed/limits.pyalready truncated an input past the model's context window(#0569), because an embeddings request is all-or-nothing and the input never gets
shorter. The mirror case was unguarded. Measured against
openrouter.ai/api/v1/embeddings(qwen/qwen3-embedding-8b) on 2026-08-27:["first", "", "third"]too_small,"path": ["input", 1]— the gateway's own validation, before any upstream sees the batch[" "]{"detail":"Prompt must not be empty"}— the upstream, and intermittently: three runs answered 400 / 400 / 200[" "]["first", " ", "third"]["\n"],["\t \n"],["."]Two layers reject, and the second one is a coin flip depending on which upstream the
gateway routed to — so the guard keys on content, not length. Any input whose
.strip()is empty is replaced withEMPTY_INPUT_PLACEHOLDER(".", one printablecharacter, the smallest input no layer treats as absent).
Substituting rather than dropping is forced by
EmbedProvider's one-vector-per-inputcontract:
_call_embeddingsindexes the response back onto the request, so dropping aslot would silently misalign every later vector in the batch. The check runs after
truncation, because a text whose only content sits past the token limit is bounded down
to whitespace and is then the same problem.
The bound lives at the provider boundary, so all four embed providers inherit it from
the seam that already owns "what will this route accept" — no call site re-derives it.
max_input_tokens: 0still disables the context-window bound; the route's refusal of atext-free input is not a tunable.
staging/provider_simlearns both rejections first, so the hermetic tier can reproducethis at all (#1658) — the empty-string case with the gateway's exact zod body, the
whitespace-only case with the upstream's. The sim answers the second deterministically
even though the real route is intermittent: a simulator stricter than the real route
cannot produce a false green, a more permissive one can.
Where the empty input comes from.
attachment_context_refresh.build_contextualized_textreturns
""when the anchor chunk is context-only and the refreshed context is empty;TaxonomyStore._build_row/.searchembed a raw label. The indexing chunk pathcannot produce one — every chunk is prefixed with a non-empty
_build_chunk_contextheader whose title falls back to the doc id.
Correction to the ticket: the production 400s are not this defect
The ticket was filed on the theory that the 103
Client error '400 ... /v1/embeddings'skips in
indexer.logwere empty inputs masking #1655's character-cap 422s. They arenot.
qwen/qwen3-embedding-8bis served through more than one upstream, and they reportan oversized input differently — the same 195000-character body returned both:
That
code: 20015body is logged verbatim inindexer.logfordocuments::000dUon2026-07-24 (the #0569 attachment). So a document that reports 422 four times and 400 on
the fifth has not changed and has not hit a second defect — it hit a different upstream.
Consequence for reviewers: the ticket's acceptance box "
Client error '400 .*v1/embeddingsstops appearing" closes with #1655 / PR #145 (the character bound), not with this PR.
Do not read #1655's acceptance as failed because 400s remain, and do not tick that box as
evidence for this guard. If 400s on this route survive a landed character bound, that is
the point at which a separate text-free input actually exists — that is the discriminating
check. The bodies are indistinguishable in the log only because
raise_for_status()discards them (#1657).
Tests
Red → green, proven both ways. With the guard reverted (constant kept so collection
still works):
6 failed, 34 passed, the provider-through-sim test failing with the exactproduction string
Client error '400 Bad Request' for url '.../api/v1/embeddings'. Withthe guard:
45 passed.Full
make gate(artifacts.evals/gate-runs/20260827-062919/):pytest -m live -q --ignore=tests/e2eafterlive preflight: PASS)The gate's own
livetier aborts at session start on any branch at this base — theknown #1654 defect:
tests/conftest.pyderives the tier marker with"_live" in fname,which also matches
tests/e2e/test_health_liveness.py, whosetests/e2e/conftest.pypytest.exit()s becausegate.pyalready tore the staging stack down.tests/conftest.pyis not in this diff, and
pytest -m live --collect-onlyshows those two e2e files beingpulled in. The runbook's prescribed workaround (
--ignore=tests/e2e) is what the 167above ran.
Candidate-container qualification (AGENTS.md 3c)
Source SHA
7a8254969326cc2a53a2e2fb1c61945e81c3550a, isolated manager-owned Composeproject, images
sha256:db96b8232b8719a1009e12f6767618e77b0d50a732ef002f40bb19913359669b(app) andsha256:18194eda58ac4613810d20d6d4ff20273e7fd4618670d97a1964b257b6c98c9c(provider-sim).["first", "", "third"]→3 vectors, dim=768, order preserved across the empty slot,with the guard's warning in the container's own log.
POST /api/index/document→
documents::00001→ retrievable via MCPfile_search(presence, not rank).health=healthy restarts=0 oom=false exit=0;/healthstatus: ok.docker compose down -vremoved containers, network and all three volumes.Maint ticket: #1687 — Maint-Manager
tickets/review/1687-doc-organizer-an-empty-embed-input-400s-the-whole-b.mdOn merge: verify this ticket's acceptance boxes against production (not tests),
tick them, then move the ticket to
tickets/done/and setstatus: done.A merged PR is not a closed ticket — an unmerged PR is not a shipped fix.
Note for the closer: the box "
Client error '400 .*v1/embeddingsstops appearing indoc-organizer:/data/index/indexer.log`" is not closeable by this PR — see the
correction above. Close it with #1655. The check that this PR is what it claims:
A hit means the guard fired on a real production input and the batch survived; no hit
after a full sweep means no text-free input reached the provider in that window, which
is also a pass.
🤖 Generated with Claude Code