Skip to content

npu: a wrong-typed max_tokens, stream or tools is a 400 naming the field, not a 500 with no body - #1108

Merged
bong-water-water-bong merged 2 commits into
mainfrom
1bit/npu-bad-request-400
Nov 5, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
1bit/npu-bad-request-400

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Three top-level fields are read before the route's refusal guard, so a wrong-typed value escapes as
nlohmann::json::type_error and httplib answers 500 with an empty body — which a client (Lemonade's backend
watchdog included) cannot tell from a server fault. And tools of the wrong type is accepted in the default
tool_choice mode and silently installs nothing.

Measured on ad47253, Qwen3-0.6B, NPU_INFER_HOST_GEMM=1 (no device, no ticket), one serve, max_tokens 2 unless the
field under test is max_tokens itself:

request before after
{"max_tokens": "8"} 500, empty body 400 max_tokens: must be an integer
{"max_completion_tokens": "8"} 500, empty body 400 max_completion_tokens: must be an integer
{"stream": "yes"} 500, empty body 400 stream: must be true or false
{"tools": "a string"} (default tool_choice) 200, no grammar, tools_sent still set 400 tools: must be an array
{"tools": []} 200, no tools unchanged: 200, still counts as "tools sent"
{"max_tokens": 8} / {"max_tokens": 8.5} / no max_tokens 200 unchanged (truncates, as body.value did)
{"logprobs": "yes"} / {"top_logprobs": "3"} / {"stop": 5} / {"tool_choice": 7} 400 unchanged — they were already inside the guard

Root cause, three sites

  1. app/forward_serve.cpp:186 — body.value("max_completion_tokens", body.value("max_tokens", 256)) ran before the
    try at :191.
  2. app/forward_serve.cpp:213 — body.value("stream", false), same.
  3. app/npu_tool_grammar.h:104 — if (!tools.is_array() || tools.empty()) return ""; folded "wrong type" and "no
    tools" into one early return, so the tools: must be an array refusal (read_tools) was only reached for a
    non-default tool_choice. Worse than cosmetic: Options::tools_sent is set before the call, so the answer parser
    still treated <tool_call> markup as a call in a request whose tools was never valid.

The change

  • app/npu_sampling.h: npu::int_field() / npu::bool_field() — absent or null is the default, a number/boolean is
    taken, anything else throws std::runtime_error("<field>: …"), which is the type the handler already turns into a 400.
    Fractional max_tokens still truncates, so no existing caller changes.
  • app/forward_serve.cpp: both reads go through those helpers, max_tokens inside the existing options guard and
    stream in its own small guard with the same 400 shape.
  • app/npu_tool_grammar.h: !tools.is_array() throws; only an empty array means "no tools".
  • docs/npu.md: max_tokens/max_completion_tokens/stream rows in the request-field table, and the refusal
    paragraph names the two new messages.

Tests

tests/npu_sampling_test.cpp: test_typed_fields (integer/bool readers: absent, null, integer, fractional, string,
boolean — each refusal asserted by its exact message) and test_tools_type (string and numeric tools refused in the
default mode, empty array still accepted as "sent, no grammar"). 253 assertion sites, npu_sampling: ok);
tools/copyright.py --check rc 0; 1bit builds with -DONEBIT_NPU=ON and the private runtime.

Closes #1106.

…eld, not a 500 with no body

Three fields were read before the route's refusal guard, so a wrong-typed value threw
nlohmann::json::type_error past it and httplib answered **500 with an empty body** -- indistinguishable
from a server fault to a client, and the shape that makes an operator restart a healthy backend:

  {"max_tokens": "8"}             -> 500, no body   (forward_serve.cpp:186, before the try at :191)
  {"max_completion_tokens": "8"}  -> 500, no body   (same site)
  {"stream": "yes"}               -> 500, no body   (forward_serve.cpp:213)

And `tools` of the wrong type was accepted in the default tool_choice mode: npu_tool_grammar.h's
`if (!tools.is_array() || tools.empty()) return "";` folded "wrong type" into "no tools", so the
`tools: must be an array` refusal was only reached for a non-default tool_choice. That one is worse than
cosmetic -- Options::tools_sent is set before the call, so the answer parser still treated <tool_call>
markup as a call in a request whose tools was never valid.

Fixed with two typed readers in app/npu_sampling.h (npu::int_field / npu::bool_field: absent or null is
the default, a number/boolean is taken, anything else throws "<field>: ..." -- the type the handler already
turns into a 400; a fractional max_tokens still truncates, so no caller changes), the two forward_serve
reads routed through them inside the guard, and the tools short-circuit split so only an EMPTY array means
"no tools".

Device-free rows (Qwen3-0.6B, NPU_INFER_HOST_GEMM=1, no ticket): the three fields and both wrong-typed
tools forms now answer 400 with the field named; max_tokens 8 / 8.5 / absent, tools:[] and the pre-existing
refusals (logprobs, stop) are unchanged at 200/400. tests: test_typed_fields + test_tools_type, 253
assertion sites, npu_sampling: ok. docs/npu.md gains the two field rows and names the new messages.

Closes #1106.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VERDICT: READY at 49f436bb — I compiled your test standalone and ran it, then probed the two new helpers myself, then checked the throw actually lands on a 400.

$ g++ -std=c++17 -I app -I <nlohmann> tests/npu_sampling_test.cpp -o /tmp/samp_test && /tmp/samp_test
npu_sampling: ok

My own probe of int_field / bool_field (independent of your 12 assertions, same six shapes):

int_field  {"a":"8"}  string for an int   -> a: must be an integer        <- the field is named
int_field  {"b":4.7}  float               -> 4                            <- truncation preserved
int_field  {"d":null}                     -> 256 (default)
int_field  {}         absent              -> 256 (default)
bool_field {"c":true}                     -> true
bool_field {"e":[1]}  array for a bool    -> e: must be true or false

Two things I care about in that table: the message names the field, which is the whole point of a 400 over a 500, and the float truncation matches what body.value did, so nothing that works today changes behaviour.

And the 500-with-empty-body cannot recur through this path: max_tokens = npu::int_field(…) sits inside the route's try, whose catch is res.status = 400 with "bad request: " + ex.what(). That is the structural fix — the old code threw nlohmann::json::type_error from outside that guard, which is why it escaped as a server fault.

The tools split is right too: if (!tools.is_array() || tools.empty()) return ""; became if (!tools.is_array()) throw …; if (tools.empty()) return ""; — so a wrong-typed tools is now a 400 naming the field, while an empty array is still accepted silently as before. That is the distinction the bug was made of: tools: "x" was being read as "no tools", i.e. a client mistake silently reinterpreted as a different valid request.

Not verified by me: the 253-site before/after HTTP rows. I did not stand up the server and replay them, so those are yours; what I verified is that the failure path they exercise ends in a 400 with the field named, and that the helpers behave identically on every input a good client sends.

Together with #1107 this is a good pair for the NPU route: one fixes a container the converter padded (a silent wrong answer that surfaced as a 500), the other fixes client errors that surfaced as 500s with no body. Both are the same disease — a fault reported as the wrong kind of fault.

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

1106 - Partially compliant

Compliant requirements:

  • Three malformed request shapes now return 400 instead of 500
  • Wrong-typed tools field now returns 400 in default mode
  • Fields are now parsed inside the refusal guard
  • Unit tests added for typed field parsing and tools validation
  • Documentation updated to reflect new error handling behavior

Non-compliant requirements:

  • None

Requires further human verification:

  • None
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Incorrect field parsing order

The parsing of max_tokens and max_completion_tokens was moved outside the try-catch block, which could lead to unhandled exceptions if the fields are of wrong type. The fix ensures these fields are parsed inside the guard to properly handle type errors as 400 responses.

int max_tokens = 256;
try {
    // Both read here, not before: a wrong type must be the 400 below, not a json::type_error that escapes
    // the guard and answers 500 with an empty body (engine#1106).
    max_tokens = npu::int_field(body, "max_completion_tokens", npu::int_field(body, "max_tokens", 256));
Stream field parsing

The stream field is now parsed inside a try-catch block to ensure that type errors are properly caught and converted to 400 responses instead of escaping to cause 500 errors.

bool stream = false;
try {
    stream = npu::bool_field(body, "stream");  // engine#1106: a wrong type is a 400, not a 500 with no body
} catch (const std::exception& ex) {
    res.status = 400;
    res.set_content(json{{"error", {{"message", ex.what()}}}}.dump(), "application/json");
    return;
}
Tools validation logic

The logic for validating tools has been split to ensure that a non-array tools field throws a 400 error instead of silently being treated as no tools. This prevents the default tool_choice mode from accepting wrong-typed tools fields.

if (!tools.is_array()) throw std::runtime_error("tools: must be an array");
if (tools.empty()) return "";

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

npu: three fields answer 500 with an empty body instead of a 400, and a wrong-typed tools is accepted in the default mode

1 participant