Fix qwen3coder tool parser keeping quotes in function and parameter names - #4502
Open
lusoris wants to merge 1 commit into
Open
Fix qwen3coder tool parser keeping quotes in function and parameter names#4502lusoris wants to merge 1 commit into
lusoris wants to merge 1 commit into
Conversation
Contributor
There was a problem hiding this comment.
🟢 Approval recommended
The functional change is small and well-covered by targeted tests; the only remaining feedback is a minor performance optimization opportunity in the new helper.
Pull request overview
This PR hardens the Qwen3Coder tool-call XML parser so that function and parameter names extracted from quoted attributes (e.g. <function="bash">, <parameter="command">) are normalized before being used as tool names / JSON argument keys and before schema-driven type handling lookups.
Changes:
- Added
trimSurroundingQuotes()helper to normalize quoted XML-style attribute values. - Applied quote trimming to both extracted function names and parameter names in the qwen3coder tool parser.
- Added focused unit and regression tests (including streaming) to prevent quoted-name regressions and to ensure schema-driven coercions still apply.
File summaries
| File | Description |
|---|---|
| src/llm/io_processing/qwen3coder/qwen3coder_tool_parser.cpp | Trim surrounding quotes for extracted function/parameter names before downstream lookup/serialization. |
| src/llm/io_processing/utils.hpp | Declare trimSurroundingQuotes() helper with documented trimming rules. |
| src/llm/io_processing/utils.cpp | Implement trimSurroundingQuotes() normalization logic. |
| src/test/llm/io_processing_utils_test.cpp | Add unit tests covering the helper’s boundary conditions and no-over-trim behavior. |
| src/test/llm/output_parsers/qwen3coder_output_parser_test.cpp | Add regression tests ensuring quoted attributes don’t leak into tool names/argument keys and that schema coercion still works. |
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+138
to
+151
| void trimSurroundingQuotes(std::string& str) { | ||
| if (str.size() < 2) { | ||
| return; | ||
| } | ||
| const char quote = str.front(); | ||
| if ((quote != '"' && quote != '\'') || str.back() != quote) { | ||
| return; | ||
| } | ||
| // A quote in the middle means the character belongs to the value, not to a delimiter pair. | ||
| if (str.find(quote, 1) != str.size() - 1) { | ||
| return; | ||
| } | ||
| str = str.substr(1, str.size() - 2); | ||
| } |
…ames JIRA/Issue: openvinotoolkit#4487 The qwen3coder state machine takes the XML tag attribute verbatim between "<function="/"<parameter=" and the next ">". When the model emits the attribute quoted (<parameter="command">), the quotes end up in the emitted JSON argument key ("\"command\"") and in the tool name, so OpenAI-schema validation on the client rejects the tool call. The same names are the lookup keys into toolsParametersTypeMap, so the quotes also silently disable boolean normalization and string enforcement for every parameter of that call. Add trimSurroundingQuotes() next to the existing tag-parser helpers in io_processing/utils and apply it to both extracted names. Only a clean wrapping pair is treated as a delimiter: the name is left byte-identical unless the same quote character is both its first and its last character and does not occur anywhere in between. Unpaired quotes are deliberately left alone.
lusoris
force-pushed
the
fix/qwen3coder-quoted-parameter-names
branch
from
September 3, 2026 08:21
0f50a0d to
639baca
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🛠 Summary
JIRA/Issue: #4487
Symptom. When the model emits the XML tag attribute quoted —
<parameter="command">insteadof the spec's
<parameter=command>— the quotes end up inside the emitted JSON argument key. Theclient receives
{"\"command\"": "..."}and OpenAI-schema validation rejects the tool call. Thesame defect exists on the function name (
<function="bash">yields the tool name"bash").Measured impact. Restating the figure from #4487 precisely, because the headline number is
easy to misread: a sample of 600 opencode runs on 2026-08-29 produced 171 tool calls that
failed client-side schema validation, spread across 88 runs. 54 of those 171 carried a quoted
argument key — the largest single malformed-key shape, roughly a third of the total. The other
117 are different shapes and are not evidence for this PR. Caveat, as already stated on the
issue on 2026-09-03: that 54/171 count aggregates more than one Qwen lane, and because request
bodies and generated fragments were not retained, individual rows cannot be attributed to one
model or one card. Please treat it as symptom evidence for the shape, not as a per-model
reproducer. We currently carry a LiteLLM post-call hook that rewrites these keys; that is defence
in depth, and the fix belongs in the parser.
Lanes that can carry this shape. From retained configuration, not from attribution: three
models in our fleet are configured with
--tool_parser qwen3coder --reasoning_parser qwen3onIntel Arc (
xe) —qwen3-8-27b,qwen3-6-35b-a3bandqwythos-9b-v2. The one I can nameconcretely for the reported symptom is
qwen3-8-27b: artifactOpenVINO/Qwen3.8-27B-int4-ov,OVMS 2026.4, Intel Arc Pro B60,
--target_device=GPU. I cannot claim the 54 rows all came fromthat lane.
Root cause.
src/llm/io_processing/qwen3coder/qwen3coder_tool_parser.cpp, inQwen3CoderToolParserImpl::parseUntilStateChange(line numbers againstmain3607c3572c58de28ba161d7325b6723d0ff85756)::157—case State::InsideFunctionName:this->currentFunction.name = streamContent.substr(this->lastProcessedPosition, pos - this->lastProcessedPosition);:177—case State::InsideParameterName:this->currentParameterName = streamContent.substr(this->lastProcessedPosition, pos - this->lastProcessedPosition);poscomes fromDEFINE_TAG_POSITION_AND_BREAK_IF_NOT_FOUND(:68-73), a barefind(">"), sothe attribute is taken verbatim.
Qwen3CoderToolParser::PARAMETER_NAME_TAG/FUNCTION_NAME_TAG(
:34,:32) carry no quote characters, so nothing consumes them.Second-order effect, which is why this is more than cosmetic. Both names are the lookup keys
into
toolsParametersTypeMap:currentFunction.nameat:79, andcurrentParameterNameat:86(gatingnormalizeBooleanString) and:109(gatingenforceStringValue). A quoted namemakes every one of those lookups miss, so schema-driven type handling is silently skipped for the
whole call: a parameter declared
type: stringis no longer coerced, andTrue/Falseis nolonger normalized. With this patch a quoted-attribute call gets the same typing as a clean one.
Scope within this repo — one parser, not five.
qwen3coderis the only tool parser here whosename terminator is a bare
>(Qwen3CoderToolParser::XML_TAG_END = ">",:33). The two otherparsers with the same
substr-until-terminator shape embed the quote in the terminator itself —OnyxToolParser::NAME_ATTR_END_TAG = "\">"andMinicpm5ToolParser::XML_TAG_END = "\">"— so aquoted attribute is consumed there by construction and neither is affected. (
minicpm5additionally has an explicit quote-aware
extractNameAttribute().) No other parser insrc/llm/io_processing/uses XML attribute-style name tags at all. This change therefore needs toland in exactly one place.
On whether the model is at fault. It is — and the PR does not claim otherwise. Qwen's official
templates render the attribute unquoted on both the prompt-spec side and the assistant-rendering
side (
Qwen/Qwen3-Coder-30B-A3B-Instructchat_template.jinja:88,Qwen/Qwen3.8-27Bchat_template.jinja:137:{{- '<parameter=' + args_name + '>\n' }}), so<parameter="name">is a model deviation, not a dialect variant.The argument for handling it in the parser is that this file has already absorbed exactly this
class of deviation once, in the value position: #3727 (merged 2025-11-05, @atobiszei, same file)
split the old
escapeString()intoescapeQuotes()+escapeNewline()so that model-emittedquotes inside string parameter values stopped corrupting the JSON. To be accurate about the
current tree:
escapeQuotes()no longer exists —grep -rn escapeQuotes src/returns nothing onmain— because the value path was later refactored to go throughrapidjsonParse()plusenforceStringValue()(src/llm/io_processing/utils.cpp:164onmain;:179on this branch,which adds the new helper above it). The precedent is what I am
citing, not the helper. Still live today:
addParameterToCurrentFunctionDoccallstrimNewline()on values to strip template artifacts, guarded on
removeNewlineAroundParameters, which is= truefor this parser (qwen3coder_tool_parser.hpp:109). The name is the one position thatnever got equivalent treatment.
To be explicit about what this is not: no other qwen3-coder parser normalizes the name today
(vLLM's Rust
take_until(1.., ">")and Python([^>]*), SGLang'sp_match[:p_idx]all take itverbatim), so this is a proposed improvement rather than convergence with peers.
The nearest precedent in shape is vLLM's Gemma4 parser, which strips
STRING_DELIMfromstring-quoted keys on
main— theif key.startswith(STRING_DELIM) and key.endswith(STRING_DELIM)guard inside_parse_gemma4_args— after vllm#44715. I want to hedgethat comparison rather than have you point it out: Gemma4's
STRING_DELIMis<|"|>, its owndialect's legal string delimiter, so that fix was a parser learning to consume a delimiter its
own spec defines. This PR asks OVMS to tolerate a character the Qwen template does not emit at
all. Same failure surface, weaker precedent.
Scope of the change
trimSurroundingQuotes()added tosrc/llm/io_processing/utils.{hpp,cpp}, next to the existingtrimNewline/normalizeBooleanStringtag-parser helpers, and called at both extraction sites.Production diffstat, so the number matches what you see: +25 lines across three production
files, 0 deletions —
qwen3coder_tool_parser.cpp+4 (two call sites, each one line plus onecomment line),
utils.cpp+15 (the helper),utils.hpp+6 (declaration plus comment). The restof the diff (+339) is tests.
The rule is deliberately narrow: a quote pair is a delimiter only when the same quote character
(
"or') is both the first and the last character of the extracted name and does not occuranywhere in between. Consequences:
<parameter=command>→command— byte-identical, no behaviour change for clean input.<parameter="command">/<parameter='command'>→command.<parameter=arg"1>→arg"1— an internal quote is part of the name, never trimmed.<parameter="a" "b">→ unchanged — stripping would silently merge two quoted tokens.<parameter=parameter=command>(a leaked tag prefix, another shape our downstream hooknormalizes) is out of scope — we have no established cause for it, and stripping a
parameter=prefix from a name would be a real over-trim hazard.
I fixed the function name as well as the parameter name: it is the same verbatim-
substrshape, the same dialect, and it additionally un-types every parameter of the call via the
:79lookup. Happy to split it out if you would rather keep the PR to the reported symptom only.
An alternative implementation would be a quote-aware scan for the closing
>instead of apost-hoc trim. It would additionally recover
<parameter="a>b">(today the barefind(">")terminates the name early at the inner
>), but it changes when the state machine can advanceduring streaming — a name whose opening quote has arrived and whose closing quote has not would
stall. I chose the trim because it cannot change the streaming state machine's timing at all.
Say the word if you prefer the scan.
What this deliberately does NOT fix
I ran each of these through the real
Qwen3CoderToolParserImplon both pristinemain3607c357and this branch, so these are observed outputs, not predictions.<parameter= "arg1" >yields the key" \"arg1\" "on both trees — the padding defeats the first/last-character test. Being bluntabout the operational consequence: our downstream LiteLLM key normalisation (which strips
whitespace, quotes and a
parameter=prefix) therefore remains load-bearing on our side evenafter this lands. This PR removes one shape, not the need for the hook.
<parameter="command>→"commandon bothtrees. An unpaired quote is not a delimiter pair, and the parser cannot distinguish a dropped
closing quote from a name that genuinely starts with one. We do see this shape in production but
did not retain the raw model text, so we cannot evidence its cause. There is a test pinning the
current behaviour, so extending the rule later is a one-predicate, deliberate change rather than
an accident.
<parameter=“arg1”>(U+201C/U+201D) yields the key“arg1”on both trees. The helper compares rawcharvalues against'"'and'\''only; itis ASCII-only by design and does not decode UTF-8. The header comment names both accepted
characters explicitly, so I left it as written.
<parameter=arg1>first</parameter><parameter="arg1">second</parameter>gives{"arg1":"first","\"arg1\"":"second"}onmain(both keys survive) and{"arg1":"first"}onthis branch. Once both spellings normalize to the same key, the existing
if (!currentFunctionArgsDoc.HasMember(keyVal))guard drops the second and logsParameter: {} already exists in document. First occurrence wins. I think this is the desirableoutcome — a duplicate key was never valid — but it is a change, not a no-op, so I am flagging it
rather than burying it.
<function="">now yields an empty tool name. Onmainit yields the two-character name""; on this branch it yields an empty name — which is exactly what<function=>already doeson
maintoday. The patch routes one more degenerate input into an existing path; it does notcreate a new one, and it does not add error handling for it.
Tests added
src/test/llm/io_processing_utils_test.cpp(12 cases, no tokenizer and no model needed —this target depends only on
//src/llm:io_processing_utils): the helper's full truth table,including every no-over-trim boundary listed above.
src/test/llm/output_parsers/qwen3coder_output_parser_test.cpp(11 cases, existing fixture andassertion idiom, no
BUILDchange — the target globs the directory):TestJustParserImplQuotedParameterName— the reported bug, unary.TestJustParserImplSingleQuotedParameterNameTestJustParserImplQuotedFunctionNameTestJustParserImplQuotedNamesKeepStringEnforcement— proves the schema lookup is restored:string_int_toolwith quoted names yields{"arg1":"42","arg2":7}, i.e.enforceStringValueruns again (before the patch:
{"\"arg1\"":42,"\"arg2\"":7}).TestJustParserImplQuotedParameterNameKeepsBooleanNormalization—True→trueagain(before the patch:
{"\"arg1\"":"True"}).TestJustParserImplMixedQuotingOfParameterNames— quoted, unquoted and single-quoted names inone call.
TestJustParserImplTwoToolCallsWithQuotedNames— ordering and per-call allocator handling.TestJustParserImplParameterNameWithInnerQuoteKept— no over-trim.TestJustParserImplParameterNameWithUnmatchedQuoteKept— pins the out-of-scope boundary.TestJustParserImplStreamStepWithQuotedFunctionName—getCurrentFunctionName()feeds thefirst streamed delta, so it must already be clean.
StreamingQuotedFunctionAndParameterNames— end-to-end throughOutputParser: neither thefirst delta's
namenor the final delta's argument keys carry quotes.No existing expectation changes:
grep -rn 'parameter="' src/test/llm/output_parsers/and'function="'return no hits onmain, so no current test feeds a quoted attribute.Validation
What I actually ran, and where it stops:
clang-format6.0.1 (the version pinned inci/style_requirements.txt) with-style=fileon all five touched files — no diff.
cpplint1.4.3 with the exactSTYLE_CHECK_OPTSfrom theMakefileon all five touchedfiles — no warnings, exit 0.
codespell2.3.0 (also the pinned version) on the touched files — no new hits. To beprecise rather than to claim "clean": it does report
qwen3coder_output_parser_test.cpp:995: paramete ==> parameterand exits 65. That hit ispre-existing, not mine — it is the deliberately split
"<paramete"streaming chunk, which sitsat line 739 of the same file on pristine
mainand produces the identical codespell hit there.My patch only shifted its line number.
make spellfilters it via the file-level entry forsrc/test/llm/output_parsers/qwen3coder_output_parser_test.cppatspelling-whitelist.txt:28(the target pipes codespell through
grep -vFf spelling-whitelist.txt), so the gate isunaffected.
io_processing_utilsunit test, compiled and run natively. The unmodifiedsrc/llm/io_processing/utils.cppand the unmodified-plus-newsrc/test/llm/io_processing_utils_test.cppbuilt withg++ -std=c++17against system gtest andupstream rapidjson: 33/33 pass (21 pre-existing
FindInStringTest+ 12 newTrimSurroundingQuotesTest).qwen3coder_tool_parser.cpp,utils.cpp,base_output_parser.cppandstatus.cppbuilt as-iswith
g++ -std=c++17; the only substitution was a 35-line stub foropenvino/genai/{tokenizer,generation_handle}.hpp, whichQwen3CoderToolParserImpldoes notuse. Every
TestJustParserImpl*body plus the parametrized suite was lifted verbatim out ofqwen3coder_output_parser_test.cpp(TEST_F→TESTwas the only edit) and run against bothtrees:
main3607c357: 29/37 pass, 8 fail — exactly the eight new quoted-attributetests, failing with the production shapes (
{"\"arg1\"":"value1"},{"'arg1'":"value1"},tool name
"string_tool",{"\"arg1\"":"True"}).So every pre-existing parser-level test still passes, and every new test is a real regression
test rather than a vacuous one. The "does NOT fix" cases above were driven through the same
harness on both trees.
Not verified locally, please rely on CI: I could not run
bazel test --test_filter='Qwen3CoderOutputParserTest.*' //src:ovms_test. That needs theopenvino/model_server-buildcontainer and theQwen/Qwen3-8Btokenizer at/ovms/src/test/llm_testing, which I do not have here. Consequently the two tests that go throughthe real
OutputParser—StreamingQuotedFunctionAndParameterNamesand the fixture wiring of thenew
TEST_Fs — are unrun; only theirQwen3CoderToolParserImpl-level equivalents wereexecuted. I also have not run the full
//src:ovms_test,make style,make sdl-check, or anyend-to-end serving test against a real model with this build.
On the deployed artifact's template. I have now read the template shipped by the deployed
artifact repository itself, not just the stock one:
OpenVINO/Qwen3.8-27B-int4-ovatmain(repo revision
1842e0d3, last modified 2026-08-21) ships achat_template.jinjathat isbyte-identical to
Qwen/Qwen3.8-27B's — both sha256c3cf9e34abf4f9e36c2d72165aa9c132d3e2a725b6c2586aaa3a8af9d7a81041—and its line 137 renders
<parameter=unquoted. So on the evidence I have, no template on ourside instructs the quoted form. The residual uncertainty I cannot close: I read the repository's
current
main, not the copy inside the IR we staged to object storage on 2026-08-17, and therepository was modified after that date, so I cannot fully exclude revision drift between the two.
For completeness, an abliterated Qwen3.8-27B derivative exists in our model catalogue but was
not serving during the 2026-08-29 measurement — its record was only created on 2026-09-01, three
days later, and it has never been promoted past
discovered— so it is not a candidateexplanation for these rows.
🧪 Checklist
user-facing behaviour, flag or API changes — the change only affects how malformed model
output is normalized internally. Happy to add a note under
docs/if you would prefer thetolerated-input behaviour documented.
parsing of untrusted input beyond what the state machine already does; the helper only ever
shrinks a
std::stringand is bounds-checked for sizes below 2.