qwen3_coder: max_tokens truncation leaks raw tool-call markup into content and arguments
Los mantenedores suelen responder en 1 día
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 4/5
- Tiempo estimado
- 3-5 días
- Aptitud para principiantes
- 42/100
Línea de trabajo
Start with python/freetoken/server/function_call_parser.py: Qwen3CoderDetector.detect_and_parse and its function_regex, parameter_regex, and _parse_function_call, plus recover_truncated_call. Then read generation._parse_tool_response and the InvokeParamStreamMixin streaming path. Reproduce with the attached script at full and truncated max_tokens. Done when no cut point leaks <tool_call> or <function markup into content, a fabricated tool name, or a </parameter> fragment into arguments, in both modes.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
Before you start
- I have read the FAQ and my problem is not answered there.
- I have read the Roadmap and this is not already planned there.
- I have searched existing issues and found no duplicate.
- I am on the latest release, or on a freshly rebuilt
mainwhen building from source.
What happened
On Qwen3.5-4B (the qwen3_coder tool parser and the qwen3 reasoning parser, both auto-selected),
/v1/chat/completions returns tool-call markup to the client when max_tokens cuts a tool call inside one of its tags. For a cut before the > that closes the <function=NAME> header, the two modes fail differently on the same request:
- Non-streaming:
tool_callsis absent andmessage.contentis the raw markup generated so far, for example<tool_call>\n<function=get_weather. - Streaming: once the cut falls after
<function=plus at least one character of the name, the stream emits a tool call whosenameis the partial name with</tool_callappended, for exampleget_weather</tool_callorget</tool_call, with arguments{}. No tool by that name was offered. For earlier cuts the stream returns nothing.
A cut inside the parameter's closing tag </parameter> (after the value, before the tag's >) fails in the
non-streaming mode only:
- Non-streaming: the call is returned, but the cut tag is part of the argument value:
{"city": "Lima\n</"}or{"city": "Lima\n</parameter"}. - Streaming: the arguments stop at the value,
{"city":"Lima, with no fragment.
With thinking off, the full call is 27 tokens. The cuts at max_tokens 1–6 out of 27 fall in the header and the cuts
at 18–19 fall in </parameter>. With thinking on (the same request with enable_thinking: true), the answer is a short reasoning and then the same call, 68 tokens, and all three faces appear at the same distance from the end: the header cuts at 42–47 of 68 (streamed names get</tool_call / get_weather</tool_call at 46–47), and the cuts at 59–60 of 68 return {"city": "Lima\n</"} / {"city": "Lima\n</parameter"} non-streaming and {"city":"Lima streamed. Cuts inside the reasoning return clean, partial reasoning_content. The same request at the full budget is fine in both modes, and the other cut points (inside <parameter=city>, inside the value, after </parameter>) return a clean call in both modes.
A stop string that ends the answer inside the header has the same non-streaming result, because stop strings are matched on the raw decode: stop: ["function"] gives content='<tool_call>\n<' and stop: ["\n"] gives content='<tool_call>', both with finish_reason: "stop".
Reproduced 3/3 runs on main (377a3bc): 36 affected answers and 0 control failures. These are 18 non-streaming header cuts, 6 streaming header cuts, 6 non-streaming </parameter> cuts and 6 non-streaming stops. With thinking on, 3/3 runs gave 30 affected answers (18 + 6 + 6, the same faces at the same cut points; both stop strings end inside the reasoning, so they do not apply) and 0 control failures. python/freetoken/server/function_call_parser.py is unchanged on current main, and so is generation._parse_tool_response.
Reproduction:
# the full call (thinking off) is 27 tokens; cut it at 6
curl -s localhost:8000/v1/chat/completions -H 'Content-Type: application/json' -d '{
"model": "qwen35-4b", "temperature": 0, "max_tokens": 6, "chat_template_kwargs": {"enable_thinking": false},
"messages": [{"role": "user", "content": "What is the weather in Lima right now? Use the get_weather tool."}],
"tools": [{"type": "function", "function": {"name": "get_weather", "description": "Get the current weather for a city.",
"parameters": {"type": "object", "properties": {"city": {"type": "string", "description": "The city name"}},
"required": ["city"]}}}]}'
# -> "content": "<tool_call>\n<function=get_weather", no "tool_calls", "finish_reason": "length"
# the same body with "stream": true -> one tool call: name "get_weather</tool_call", arguments "{}", finish_reason "length"
# the same body at "max_tokens": 19 (the cut falls inside </parameter>)
# -> "tool_calls": [{"function": {"name": "get_weather", "arguments": "{\"city\": \"Lima\\n</parameter\"}"}}], "finish_reason": "length"
# streamed: arguments '{"city":"Lima' (no fragment)
repro_truncated_function_tag.py (stdlib only) sends the request at the full budget
in both modes, sweeps every cut point 1..N-1 in both modes, then tries the two stop strings; --thinking on runs the same sweep with thinking on. Every answer is checked
for markup in content, in tool-call names and in argument values. [A] is raw markup in content, [B] a
fabricated name, and [C] markup inside an argument value:
run 0 ok control full budget json finish=tool_calls tokens=27 calls=[('get_weather', '{"city": "Lima"}')] content=''
run 0 ok control full budget stream finish=tool_calls tokens=None calls=[('get_weather', '{"city":"Lima"}')] content=''
run 0 FAIL [A] cut max_tokens=1 of 27: json finish=length calls=[] content='<tool_call>' | stream finish=length calls=[] content=''
run 0 FAIL [A] cut max_tokens=2 of 27: json finish=length calls=[] content='<tool_call>\n' | stream finish=length calls=[] content=''
run 0 FAIL [A] cut max_tokens=3 of 27: json finish=length calls=[] content='<tool_call>\n<' | stream finish=length calls=[] content=''
run 0 FAIL [A] cut max_tokens=4 of 27: json finish=length calls=[] content='<tool_call>\n<function' | stream finish=length calls=[] content=''
run 0 FAIL [A,B] cut max_tokens=5 of 27: json finish=length calls=[] content='<tool_call>\n<function=get' | stream finish=length calls=[('get</tool_call', '{}')] content=''
run 0 FAIL [A,B] cut max_tokens=6 of 27: json finish=length calls=[] content='<tool_call>\n<function=get_weather' | stream finish=length calls=[('get_weather</tool_call', '{}')] content=''
run 0 FAIL [C] cut max_tokens=18 of 27: json finish=length calls=[('get_weather', '{"city": "Lima\\n</"}')] content='' | stream finish=length calls=[('get_weather', '{"city":"Lima')] content=''
run 0 FAIL [C] cut max_tokens=19 of 27: json finish=length calls=[('get_weather', '{"city": "Lima\\n</parameter"}')] content='' | stream finish=length calls=[('get_weather', '{"city":"Lima')] content=''
run 0: 8 of 26 cut points carry tool-call markup in a visible field
run 0 FAIL stop=['function']: json finish=stop calls=[] content='<tool_call>\n<' | stream finish=stop calls=[] content=''
run 0 FAIL stop=['\n']: json finish=stop calls=[] content='<tool_call>' | stream finish=stop calls=[] content=''
faces (face, mode): A/json=18, B/stream=6, C/json=6
verdict: BUG PRESENT (36 answer(s) with qwen3_coder markup over 3 run(s); 0 control failure(s))
(Runs 1 and 2 are identical.)
Some possible cause, assited with Claude
- Non-streaming:
generation._parse_tool_responsehands the text toFunctionCallParser.parse_non_stream, because<tool_call>and<function=are inTOOLS_TAG_LIST.Qwen3CoderDetector.detect_and_parsefinds the block with the unterminated alternative offunction_regex(<function=(.*?)</function>|<function=(.*)$), but_parse_function_callreturnsNonewhen the function string has no>(function_str.index(">")raises). With no calls,_parse_tool_responsereturnsNone, so_generate_full_implkeeps the whole text ascontent. The same happens for a cut before<function=(detect_and_parsereturns no call). Thetext[:idx]normal text the detector computed is never used. - Streaming:
InvokeParamStreamMixinholds<function=get_weatherin its buffer while waiting for>. At the end of the stream,FunctionCallParser.recover_truncated_callappendseot_token(</tool_call>) and re-parses. The unterminated<function=(.*)$alternative now capturesget_weather</tool_call>, and_parse_function_calltakes everything up to the first>as the name:get_weather</tool_call. - That name is not one of the request's tools.
_parse_function_callstill keeps it because_should_forward_unknown_toolis true by default (FREETOKEN_FORWARD_UNKNOWN_TOOLSdefaults to"True"), so recovered item is emitted as a call. - Argument value, non-streaming:
Qwen3CoderDetector.parameter_regexis<parameter=(.*?)(?:</parameter>|(?=<parameter=)|(?=</function>)|$). When the text ends with</parameteror</(the closing tag cut before its>), the lazy group cannot stop at a complete</parameter>, so it runs to$._parse_function_callthen strips only one leading and one trailing\nfrom the value, which ends inror/, not in\n. The value keeps\n</parameter. The streaming path (InvokeParamStreamMixin,pstrmode) holds back a suffix that could still grow into</parameter>(_ends_with_partial_token) together with the trailing newline, so the streamed fragments stop atLima.
How did you install FreeToken
Built from source
FreeToken version
freetoken 0.1.3, git rev-parse --short HEAD: 377a3bc (main, 2026-10-07, "ci: auto-label amd issues and prs by title (#620)")
OS
Arch Linux
OS details
Red Hat Enterprise Linux 9.8, kernel 5.14.0-687.56.1.el9_8.x86_64, Python 3.12.3.
GPU and driver
NVIDIA RTX A4000 16 GB (sm_86), CUDA 13.0.
CPU and system RAM
AMD EPYC 7313 (8 cores allotted), 48 GB allotted.
Checkpoint
Qwen/Qwen3.5-4B (Hugging Face, revision 851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a)
Command
python -m freetoken --model <local snapshot of Qwen/Qwen3.5-4B @ 851bf6e> --served-model-name qwen35-4b --memory-ratio 0.85 \
--max-seq-len-override 16384 --moe-strategy fused --cache-type radix --num-tokens 16384 --max-running-requests 4 \
--max-prefill-length 2048 --enable-cache-report
Full log
Resolved config: attention_backend='fi', cache_type='hybrid_radix', page_size=1
Allocating 16384 tokens for KV cache, K + V = 0.50 GiB
API server is ready to serve on 127.0.0.1:35211
# the script's requests: only batches and 200s, no warning or traceback (0 Traceback lines in the whole log)
INFO Prefill batch, #new-seq: 1, #new-token: 294, #cached-token: 0, token usage: 0.02, #mamba-slot: 4/24, mamba usage: 0.17, #running-req: 1, #queue-req: 0, input throughput (token/s): 296.45
INFO Decode batch, #running-req: 1, #token: 304, token usage: 0.02, #mamba-slot: 4/24, mamba usage: 0.17, gen throughput (token/s): 15.00, #queue-req: 0
INFO: 127.0.0.1:46422 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO: 127.0.0.1:46428 - "POST /v1/chat/completions HTTP/1.1" 200 OK
INFO Prefill batch, #new-seq: 1, #new-token: 38, #cached-token: 256, token usage: 0.02, #mamba-slot: 4/24, mamba usage: 0.17, #running-req: 1, #queue-req: 0, input throughput (token/s): 33.21
Anything else
No response
- Lenguaje dominante
- Python
- Estrellas
- 14.2k
- Forks
- 1.4k
- Merge medio
- 21 h 39 min
- PR fusionados (30 d)
- 100
Preparar el entorno
Este proyecto no incluye contenedor de desarrollo, Dockerfile ni guía de contribución, así que la configuración corre por tu cuenta: empieza por su README y consulta nuestra guía para la primera contribución para los pasos generales.
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de FlashML-org/FreeToken
-
Desktop feature
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
FlashML-org/FreeToken#800 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 84/100
FlashML-org/FreeToken#767 ·
Los mantenedores suelen responder en 1 día
-
test_gemv_path_matches_the_transcription fails on sm_120 (bf16 rounding tie exceeds the tolerance)Posiblemente ocupada @void-mckenzie la tomó hace 17 días. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 77/100
FlashML-org/FreeToken#649 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
FlashML-org/FreeToken#648 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
FlashML-org/FreeToken#646 · 1 comentario ·
Los mantenedores suelen responder en 1 día
Todos los issues de FlashML-org/FreeToken
Issues similares
-
bug
Dificultad 1/5 Menos de una hora Aptitud para principiantes 86/100
awslabs/visual-asset-management-system#937 ·
Los mantenedores suelen responder en 1 día
-
Admin member of a research group cannot see the HX2 allocation until they've been added to itAbierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
-
follow-up
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
Los mantenedores suelen responder en 1 día
-
Dificultad 1/5 Menos de una hora Aptitud para principiantes 83/100
Los mantenedores suelen responder en 3 días
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 70/100
Los mantenedores suelen responder en 2 días