Repository navigation
forward-serve: GPT-OSS replies are split by harmony markup, not qwen3 (refs #1786) - #2298
Conversation
… (refs #1786) GPT-OSS-20B-NPU2 serves on the NPU, but its reply was parsed as qwen3, so content began with the raw `<|channel|>analysis<|message|>` header and reasoning_content was empty. A third answer family, harmony, is picked when the template writes the analysis channel: analysis is reasoning, final is content, the message headers are dropped, and `to=functions.NAME ... <|message|>{args}<|call|>` is a call in both header orders. Two scanner fixes the new tags needed: a tag may open with a byte other than '<' (the recipient `to=functions.`), and a whole tag ending the buffer is no longer split by holding back a tail that also starts another tag (`<|start|>assistant` ends in the `t` of `to=`), which broke the byte-at-a-time stream. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
ready for review at ea878e2 |
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at ea878e2: READY
Reviewed at ea878e276aa04f17150c291990d8f357cc0caf78. It is on main (0 behind) and merges clean. The diff is 4 files, +152/−9.
Checked
- Detection.
harmonyis picked only when the template writes<|channel|>analysis<|message|>. That string is in no other family's template, so qwen3, gemma4 and the rest detect as before. - The other families are unaffected.
- The widened scanner test is
safe[i] == '<' || safe[i] == tags_.call_open[0], andcall_open[0]is<for every family except harmony. drops_is empty for non-harmony families.- The new holdback rule returns 0 only when a whole tag ends the buffer, and the scanner then matches that tag whole.
- The every-tag, every-split-point arm, which covers all families, still passes.
- The widened scanner test is
- The call parser.
close_harmony_call()takes the name up to the first space or<, checks its characters, and parses the JSON after the last<|message|>. A non-object or malformed body goes back into content unchanged. - Tests.
npu_sampling_testreports ok, andtools/copyright.py --checkreturns rc 0.
My mutants (each restored, and the test passes again)
| mutant | result |
|---|---|
| `< | channel |
| the harmony template detection removed | FAIL "a template that writes the analysis channel is harmony" |
Together with the body's two scanner mutants, the parser, the stream path and the detection are each pinned.
The measured device reply (content='<|channel|>analysis<|message|>…', before) is the author's run on GPT-OSS-20B-NPU2. I did not re-run it on the NPU; the test reproduces that reply byte for byte.
CI build at this head: completed success, "100% tests passed, 0 tests failed out of 276".
|
VERDICT: READY at The symptom is the class, and the measurement is the raw reply
That is the same family as #2064 ( The fix is picked from the artefact, not from a table
Selecting the parser by what the template actually emits is the right discriminator — the same conclusion #2242/#2254/#2261 reached for config keys: the container tells you which rules apply, and a hardcoded per-model table rots the moment a family gains a variant. And the boundary case is handled explicitly
That is the streaming hazard in its precise form: to avoid emitting half a tag you hold back a tail, and the bug is holding back too much — a complete tag at the buffer's end whose tail also begins another. Fixing it in Two asks
|
Refs #1786 (the GPT-OSS-20B-NPU2 row).
What was wrong. On
--device npu, GPT-OSS-20B-NPU2 loads and answers, butforward-serveparsed its reply asqwen3(answer markup=qwen3at load). Measured reply to "What is the capital of France? Answer with one word.":Change.
app/npu_answer_parser.h: a third family,harmony. It is picked when the chat template writes<|channel|>analysis<|message|>; the pack's template does this five times, and no other family's template carries<|channel|>. Analysis is reasoning and final is content. The message headers (<|start|>assistant,<|channel|>final<|message|>, a commentary preamble's header, a rendered<|return|>) are dropped outside a call.to=functions.NAME … <|message|>{args}<|call|>is a tool call in both header orders, the template's and the model card's. Arguments that are not a JSON object go back intocontentunchanged.<(the recipientto=functions.).<|start|>assistantends in thetofto=, so a byte-at-a-time stream sent the header out as text.family_name(), so it saysharmony.docs/npu-chat-template.md: a table row and a paragraph.Tests (
tests/npu_sampling_test.cpp):test_harmony_answer_parser: the device reply byte for byte, whole and fed one byte at a time; a final header with no<|end|>; no analysis channel; both call orders; no tools; bad JSON.harmonyadded to the every-tag, every-split-point arm.Each scanner fix was checked by removing it: without the
<widening the four call checks fail, and without the holdback rule the byte-at-a-time check fails.ctest -j 6without the device tests: 273/273 passed (skips as on main).🤖 Generated with Claude Code