Repository navigation
semantic build fails against VLM-backed embedders: data:image/...;base64 in chunk text is parsed as an image input #817
Description
Summary
With semantic.backend = "openai_compatible" pointed at an NVIDIA NIM embeddings deployment, any
repository whose source text contains the literal prefix data:image/<mime>;base64 fails its entire
semantic build:
failed to build semantic index: openai compatible request failed (HTTP 400 Bad Request):
{"object": "error", "message": "image inputs require VLM serving to be enabled on this server",
"type": "invalid_request_error"}
One offending row rejects the whole batch, so the build dies at the same chunk every session and
restarts from chunk 0 the next time. In the affected installation the warning had been firing every
session for 48 hours: ~8,500 chunks (134 of 326 batches, ~220 s) re-embedded per attempt, then the
same 400.
Environment
- AFT 0.62.0 (
@cortexkit/aft-opencode@0.62.0), OpenCode on Linux x86_64. - Config:
semantic = { backend: "openai_compatible", model: "nvidia/nemotron-3-embed-1b", base_url: "https://integrate.api.nvidia.com/v1", api_key_env: "NVIDIA_API_KEY" }. - Failing roots: a ComfyUI checkout and a TypeScript/Node web app.
Reproduction without AFT
curl -s https://integrate.api.nvidia.com/v1/embeddings \
-H "Authorization: Bearer $NVIDIA_API_KEY" -H "Content-Type: application/json" \
-d '{"model":"nvidia/nemotron-3-embed-1b","input":["data:image/png;base64,"]}'
# HTTP 400 image inputs require VLM serving to be enabled on this server
The same string without the prefix, or with a non-image mime type, returns HTTP 200.
Reproduction with AFT
A one-file repository containing ordinary image-encoding code:
def to_data_uri(payload: bytes) -> str:
return f"data:image/png;base64,{payload.decode('ascii')}"plus a user config with a standing root for it and the NVIDIA semantic backend, then:
XDG_CONFIG_HOME=<tmp>/config AFT_STORAGE_DIR=<tmp>/storage aft index
snapshot entry="/tmp/.../repo" kind=search status=success result_count=1
snapshot entry="/tmp/.../repo" kind=semantic status=gap usable=false
reason="openai compatible request failed (HTTP 400 Bad Request): {... \"message\": \"image inputs
require VLM serving to be enabled on this server\" ...}"
exit=2
Root cause
SemanticEmbeddingModel::embed_texts sends chunk text verbatim as the embeddings input:
crates/aft/src/semantic_index.rs (pre-patch, line 3558):
let body = serde_json::json!({
"input": texts,
"model": model,
});NVIDIA's current embedding deployments are VLM-backed. The deployment scans each input string for
the data-URI image grammar and, on a hit, treats that row as an image part; with VLM serving disabled
the request is rejected and every row in the batch is lost. The other live NVIDIA embedding models
behave the same (nemotron-3.5-embed-1b/8b additionally require input_type), and the older
text-only ones are EOL:
| model | result |
|---|---|
nvidia/nemotron-3-embed-1b |
400 image inputs require VLM serving ... |
nvidia/nemotron-3.5-embed-1b |
400 failed to parse VLM input ... Invalid padding |
nvidia/nv-embedcode-7b-v1 |
410 EOL 2026-08-25 |
nvidia/nv-embedqa-e5-v5 |
410 EOL 2026-08-25 |
nvidia/llama-3.2-nv-embedqa-1b-v2 |
410 EOL 2026-05-18 |
So there is no text-only NVIDIA embedding model to point at instead.
Measured trigger boundaries on nemotron-3-embed-1b (each row sent as input: [row]):
| row | status |
|---|---|
data:image/png;base64, |
400 |
data:image/png;base64 abc (no comma, invalid payload) |
400 |
xdata:image/png;base64,abc |
400 |
Data URI string (e.g., 'data:image/png;base64,...'). |
400 |
data:text/plain;base64,abc |
200 |
data:application/json;base64,e30= |
200 |
data:audio/wav;base64,AAAA |
200 |
data:IMAGE/PNG;base64, |
200 |
data-uri:image/png;base64, |
200 |
The check is a case-sensitive data:image/ prefix match with no payload validation, so a string
literal in code is enough. Real triggers that were hit in practice:
- ComfyUI
comfy_api_nodes/nodes_openai.py:945,nodes_grok.py:444,nodes_qwen.py,
nodes_recraft.py,nodes_wan.py,comfy_api_nodes/apis/bytedance_llm.py,
comfy_api_nodes/util/conversions.py:224(docstring). - career-ops
web/src/lib/apply/session.ts:198,web/src/lib/apply/drive.ts:147,
hired-wall-build.mjs:132,tests/cv-visual/fixtures.mjs:3,tests/*.test.mjs.
This is not exotic content: any codebase that builds screenshot/avatar/thumbnail data URIs contains
it, and it is exactly the code a semantic index wants to cover.
Proposed fix
Neutralize the image data-URI prefix in the OpenAI-compatible request body only, at the wire
boundary:
const IMAGE_DATA_URI_PREFIX: &str = "data:image/";
const NEUTRALIZED_IMAGE_DATA_URI_PREFIX: &str = "data-uri:image/";
fn neutralize_image_data_uris(text: &str) -> Cow<'_, str> {
if !text.contains(IMAGE_DATA_URI_PREFIX) {
return Cow::Borrowed(text);
}
Cow::Owned(text.replace(IMAGE_DATA_URI_PREFIX, NEUTRALIZED_IMAGE_DATA_URI_PREFIX))
}let sanitized_texts: Vec<Cow<'_, str>> = texts
.iter()
.map(|text| neutralize_image_data_uris(text))
.collect();
let body = serde_json::json!({ "input": sanitized_texts, "model": model });Notes on the design:
- Literal prefix rewrite, not a regex. The backend's trigger is the literal, case-sensitive
data:image/prefix and it does not require a valid payload, so nothing needs parsing. One
containsscan per row keeps the common case allocation-free (Cow::Borrowed); a pattern
rewrite would allocate for every row and add a regex to the hot path for no gain. - Wire boundary, not chunk creation. Chunk text is user-visible in search snippets and
provenance, and localfastembed/ollamabackends do not interpret data URIs. Rewriting only the
remote OpenAI-compatible payload leaves the persisted index byte-identical and keeps the behavior
scoped to the backend that needs it.embed_textsis the single request builder, so build batches
and interactive query embeds are both covered. - Only
data:image/. The table above shows the other mime types and the uppercase form already
pass, so rewriting them would only perturb embeddings without fixing anything.
Test plan
- Unit tests for the helper: borrowed pass-through, template literal, multiple occurrences, docstring
mention without payload, non-image mime types untouched, uppercase untouched, and a serialized
body assertion that nodata:image/survives. - End-to-end with the one-file fixture above and the NVIDIA backend: the stock 0.62.0 binary
reproduces the failure (verified:kind=semantic status=gap usable=false, exit 2); the patched
binary reportskind=semantic status=success, exit 0.
Workarounds available today
None that keep the backend: per-file .aftignore also removes the path from trigram search and the
call graph, the local fastembed backend rebuilds every index on CPU, and a request-rewriting proxy
adds a supervised process to the embedding path.
Activity
citizenadam commentedon Oct 23, 2026
Verified with a locally built binary from
main(7834278) plus the patch in #819, againsthttps://integrate.api.nvidia.com/v1/nvidia/nemotron-3-embed-1b:f"data:image/png;base64,{payload.decode('ascii')}":stock 0.62.0 ->
kind=semantic status=gap usable=false, exit 2;patched ->
kind=semantic status=success result_count=4, exit 0.aft indexinto the plugin's storage:/mnt/s/ComfyUI->kind=semantic status=success result_count=20651(stock failed atbatch 134/326 with the 400),
/go/career-ops->kind=semantic status=success result_count=3850. Exit 0, no build-breaker suspensions recorded afterwards.cargo test -p agent-file-tools --lib image_data_uri_tests,debug profile;
--releasecannot build this crate's lib tests becausestorage_retention::test_volume_uuid_guardis#[cfg(debug_assertions)]).Draft PR with the patch and the full write-up: #819 (awaiting design review per CONTRIBUTING).
AI assistance disclosure: this verification was produced with an AI coding agent under
human direction.