Skip to content

Error during DeepSeek Harness chat: This turn failed the prompt has more images than the embeddings file PI_AI_ERROR #150

Closed

Description

@ecatvids-glitch

Error as per the title. I have no idea what is going on, but the strata downloaded qwen coder says (and apologies if this is to verbose):
Strata version: 0.1.24 (/props → build_info)
Model: Qwen3.8-Flash-Next-GSQ-RCO-IQ1_M-00001-of-00002.gguf + mmproj-Qwen3.8-Flash-Next-BF16.gguf (--vision gpu)
Platform: Windows, serve.server --engine strata --port 8080

Summary

A /v1/chat/completions request that carries at least one image and whose message text happens to contain a literal special token — in practice <|im_start|> — is rejected with:

HTTP 400
{"error":{"type":"invalid_request_error","message":"the prompt has more images than the embeddings file"}}

The token is not in an image part. It is plain text that came from a web page, a source file, or a chat-template document. The tokenizer turns it into a real image-pad id, so the prompt ends up with more image markers than the embeddings file has images, and the engine refuses the request.

This is not a cap and not a size limit: the same server accepts 24 images at ~1 MiB each (a ~110 MB request body) and 16 images at 2048×2048 without complaint.

Reproduction

Self-contained (Node 18+, no dependencies, no image files needed — it builds a 64×64 PNG in memory):

// node repro.js   (expects the server on 127.0.0.1:8080)
const zlib = require("zlib");
const T = (() => { const t = []; for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); t[n] = c >>> 0; } return t; })();
const crc32 = (b) => { let c = 0xffffffff; for (const x of b) c = T[(c ^ x) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
const chunk = (type, data) => { const l = Buffer.alloc(4); l.writeUInt32BE(data.length); const body = Buffer.concat([Buffer.from(type, "ascii"), data]); const c = Buffer.alloc(4); c.writeUInt32BE(crc32(body)); return Buffer.concat([l, body, c]); };
const png = (w, h) => { const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 6; const stride = 1 + w * 4; const raw = Buffer.alloc(h * stride); for (let y = 0; y < h; y++) { raw[y * stride] = 0; for (let x = 0; x < w; x++) { const o = y * stride + 1 + x * 4; raw[o] = 200; raw[o+1] = 120; raw[o+2] = 60; raw[o+3] = 255; } } return Buffer.concat([Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]), chunk("IHDR", ihdr), chunk("IDAT", zlib.deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]); };
const b64 = png(64, 64).toString("base64");
const PAD = "<" + "|image_pad|" + ">";   // split so this file stays pasteable

async function post(messages) {
  const r = await fetch("http://127.0.0.1:8080/v1/chat/completions", { method: "POST", headers: { "content-type": "application/json" },
    body: JSON.stringify({ model: "qwen3.8-flash-next-coder-iq1_m", messages, max_tokens: 4, temperature: 0 }) });
  console.log(await r.text().then((t) => ({ status: r.status, body: t.slice(0, 200) })));
}
async function main() {
  const img = { type: "image_url", image_url: { url: "data:image/png;base64," + b64 } };
  await post([{ role: "user", content: [{ type: "text", text: "describe" }, img] }]);                                   // A
  await post([{ role: "user", content: [{ type: "text", text: "the token " + PAD + " is used by llama.cpp" }, img] }]);  // B
  await post([{ role: "user", content: "the token " + PAD + " appears in llama.cpp docs" }]);                          // C
}
main();

Observed on the live server:

# request result
A one image, clean text 200 — answers normally
B one image + the literal token in text 400 the prompt has more images than the embeddings file
C the literal token in text, no images 200 — answers normally

A and B differ by one string in the text. C shows the trigger needs both halves: a literal token and at least one image in the same request.

Root cause

prepare() renders the template and tokenizes the whole prompt with parse_special=True:

# serve/server.py:710-711
prompt = self.template.render(messages, tools=tools, **kwargs)
ids = self.tok.encode(prompt, parse_special=True)

Message text reaches that render call verbatim — serve/frontend.py:_parts_of (line 122) appends part.get("text", "") unchanged — so a special token sitting inside a text item becomes a real <|im_start|> id in ids.

The expansion loop then treats that phantom id as an image slot, but the image list is already exhausted, so it falls through to the else branch and stays a bare pad token:

# serve/server.py:725-733
out, k = [], 0
for t in ids:                               # one  per image -> one per image token
    if t == pad and k < len(encoded):
        out += [pad] * encoded[k][1]
        k += 1
    else:
        out.append(t)                       # <-- the phantom marker lands here, unexpanded
if k != len(encoded):
    raise ValueError("the prompt and its images do not match")   # k == len(encoded): passes

So the Python guard only catches the fewer markers than images direction. The opposite direction — more markers than images — survives prepare() and is only caught in the engine, which walks the markers against the combined .sve file:

// src/program/generate.cpp:3614-3616
while (ve.empty() && i < n) {
    if (ids[(size_t) i] != kImagePad) { put(i, p, p, p); ++p; ++i; continue; }
    if (k >= imgs.size()) { ve = "the prompt has more images than the embeddings file"; break; }

The neighbouring checks at generate.cpp:3636 (the embeddings file has more images than the prompt) and :3637 (the prompt cannot end in an image) are reachable the same way, so they are worth covering together.

Text-only requests are unaffected because prepare() skips the whole vision branch when images is empty (serve/server.py:714), and the engine takes GEN rather than GENI.

Why this matters for agent clients

Any client that puts fetched web pages, source files, or documentation into tool results will hit this the moment the conversation also carries an image — llama.cpp, Qwen-VL, and mmproj docs are full of the literal token. The failure is a hard 400 on a turn whose history is already durable, so the conversation cannot be retried without losing the turn.

Suggested fix

Neutralize special-token syntax in message text before it is rendered, leaving the markers the template itself emits untouched. The narrow place to do it is serve/frontend.py, where text items are collected (_text_of at line 51 and _parts_of at lines 110-123):

SPECIAL_RE = re.compile(r"<\|([a-z_]+)\|>")   # adjust to the model's real special set

def _safe_text(s: str) -> str:
    return SPECIAL_RE.sub(lambda m: "<" + "\u2400|" + m.group(1) + "|\u2400>", s)   # visible, not tokenizable

Escaping rather than dropping keeps the text readable to the model (these strings are exactly what vision-related questions are about). Alternatives that would also work: encode text spans with parse_special=False and splice the template's markers in as ids, or count phantom markers in prepare() and fail with a clear message that names the offending text.

Notes for the maintainer

  • Not a limit issue: 24 images / ~110 MB body and 16 × 2048×2048 images are accepted.
  • /props reports n_ctx: 131072, modalities.vision: true; /health reports max_context: 131072.
  • Happy to run the repro against a patched build if you want a verification pass.

Activity

  1. Niko1221 commented on Sep 29, 2026

    @Niko1221
    Owner

    Thanks for the clear repro, confirmed on main. A literal <|image_pad|> in message text is tokenized as a real image marker, so the counts no longer match. (It's only that marker; other control tokens in the text don't cause this 400.) We'll make the server expand only the markers the template emits for images and add your repro as a test.

  2. Niko1221 commented on Sep 29, 2026

    @Niko1221
    Owner

    Fixed in 0.1.27: only the image markers the chat template writes (right after <|vision_start|>) become images. The same text inside a message stays plain text, so an image plus <|image_pad|> in the message no longer gives the 400. Your repro is now a server test. Thanks @ecatvids-glitch!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions