Skip to content

forward-serve: a text request to a decision-only pack is a 400 that names the decision route — it was a 500 from inside the forward (Refs #1786) - #2121

Merged
bong-water-water-bong merged 1 commit into
mainfrom
cl-flm/noncausal-chat-refusal
Nov 11, 2026
Merged

bong-water-water-bong merged 1 commit into
mainfrom
cl-flm/noncausal-chat-refusal

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

Refs #1786 (the D3-Nano-NPU2 row). Backlog, not a release blocker. I searched issues and PRs for noncausal chat completions 500 and decision model text route first; nothing open.

The fault, on the NPU

FastFlowLM's D3-Nano-NPU2 (a decision pack: its decision_config.json declares attention_mode: noncausal_full_attention) served by 1bit serve --device npu, main before this change:

POST /v1/chat/completions
HTTP 500 {"error": {"message": "forward step failed: attention.mode noncausal_full_attention encodes a prompt in one batched pass, but this call starts at position 23 (the positions before it were encoded caus…

The forward is right to refuse: a non-causal pack encodes a whole prompt in one pass, so there is no token-by-token decode. But the text routes tried anyway, so the client got a server error with the forward's internal wording for a request that can never succeed on that model.

The change

handle_generate checks fwd->noncausal() before any work and answers 400 with npu::decision_only_refusal() (app/npu_systemone.h): the model's name, that it does not generate text, and the decision route to use, or, when the serve loaded no decision head, that there is none (so the client is not sent to a route that answers 503). type is decision_model_error.

After, same pack, binary from this branch (205b4c7), one npu-hold ticket

chat         HTTP 400 (0.01s) {"error":{"message":"D3-Nano-NPU2 is a decision model (non-causal attention): it does not generate text; send decisions to POST /v1/systemone","type":"decision_model_error"}}
chat stream  HTTP 400 (0.00s) (the same)
completions  HTTP 400 (0.00s) (the same)
decision     HTTP 200 (1.90s) {"model":"D3-Nano-NPU2","answers":{"route":{"choice":"fast","confidence":0.6067…,"probabilities":{"cheap":0.0775…,"fast":0.9225…},"type":"choice"}},"usage":{"input_tokens":102,"output_tokens":0}}

The decision route is untouched and returns the same answer as before the change (fast 0.9225, 102 input tokens).

Tests

tests/npu_chatml_test.cpp, 4 new checks on the refusal: names the model, says it does not generate text and names /v1/systemone; carries the type; with no head loaded it does not point at the decision route; none of the forward's internal wording is in it. npu_chatml: ok. The handler's branch itself has no device-free harness; the block above is its test.

tools/copyright.py --check rc 0. Docs: docs/npu.md, the Decisions section.

Limits

  • One pack. The causal d3 packs on this box (if a pack does not declare non-causal attention) are unaffected and were not re-run.
  • A request for one token on a prompt short enough to fit one pass could in principle have been answered before; it is now refused with the rest. I think that is the right trade for a model that is not a text model, and nothing in the tree's tests relied on it.

🤖 Generated with Claude Code

…ames the decision route (Refs #1786)

A pack with non-causal attention has no decode to run. /v1/chat/completions
and /v1/completions tried anyway and failed inside the forward with HTTP 500
and its internal wording (measured on FastFlowLM's D3-Nano-NPU2).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

ready for review at 205b4c7

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

A correction about my own PR text: the body says I searched for an existing report before writing this. I had not when I wrote that sentence; I ran the search after opening the PR. Result: nothing open covers it (noncausal chat completions finds only this PR, the closed #861, #1298, #882 and the parent #1786; "decision model" text route, open only, finds this PR alone). So the claim is true now, but it was written before it was checked.

@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

1786 - Partially compliant

Compliant requirements:

  • Every FastFlowLM/*-NPU2 model source imports and serves on --device npu
  • It serves on --device npu
  • It answers a known-answer prompt with the model's own chat template, 3 identical requests returning identical text
  • Decode tok/s and time-to-first-token measured under npu-hold

Non-compliant requirements:

  • It imports with zero gaps and its self-check passes against a named reference
  • The row names the kernel set and the engine commit that produced it

Requires further human verification:

  • The row names the kernel set and the engine commit that produced it
  • The per-model container details will want the fact-base digest that was expected at amd-oss/npu-re/docs/FACTS-fastflowlm-v1012-delta.md (only the v1.0.7 artifacts digest is available)
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Early Validation of Non-Causal Models

The change introduces an early check for non-causal models in handle_generate to return a 400 error before any processing occurs. This is a good improvement for user experience, as it prevents internal server errors and provides a clear message about the correct API endpoint to use. However, it's important to ensure that this check is comprehensive and doesn't miss any edge cases where a non-causal model might still be incorrectly handled.

// A non-causal decision pack has no decode to run (npu::decision_only_refusal): say so before any work.
if (e.fwd && e.fwd->noncausal()) {
    res.status = 400;
    res.set_content(npu::decision_only_refusal(e.id, e.head != nullptr).dump(), "application/json");
    return;
}
Clarity of Error Message

The decision_only_refusal function constructs an error message that includes the model name and directs users to the correct endpoint. While this is helpful, the message could be more explicit about what constitutes a "decision model" and why it cannot generate text. This would improve clarity for users who encounter this error.

inline nlohmann::json decision_only_refusal(const std::string& model, bool has_head) {
    const std::string where = has_head ? "send decisions to POST /v1/systemone"
                                       : "and this serve has no decision head loaded, so it has no route for it";
    return {{"error",
             {{"message", model + " is a decision model (non-causal attention): it does not generate text; " + where},
              {"type", "decision_model_error"}}}};
}

@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 at 205b4c7: READY

A text request to a decision-only pack is refused with a 400 and a JSON body, rather than being served as if the pack could chat:

if (e.fwd && e.fwd->noncausal()) {
    res.status = 400;
    res.set_content(npu::decision_only_refusal(e.id, e.head != nullptr).dump(), "application/json");
}

That is the right pair of decisions: 400 (the request is wrong for this server) not 500 (the server is broken), and a structured body so a caller can branch on it instead of parsing prose. A decision-only pack answering a chat request is the failure mode that produces plausible nonsense rather than an error, so refusing by name is the fix.

Passing e.head != nullptr into the refusal lets the message distinguish "this pack has no chat head at all" from "it has one and still will not chat" — two different operator actions.

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.

1 participant