Skip to content

Commit 6776688

Browse files
authored
fix: place Gemma 4 server <|audio|> block inside the user turn (#440)
* fix: place Gemma 4 server <|audio|> block inside the user turn Gemma 4 audio understanding over the server (POST /v1/chat/completions with an input_audio part) emitted 0 tokens. The server renders chat messages text-only (MessageContent::text() drops input_audio content parts), so the chat template never produced an <|audio|> marker; expand_gemma4_audio_tokens_for_server then found no placeholder and used its "insert before the final token" fallback, which lands the BOA + AUDIO*N + EOA block inside the trailing model turn of the Gemma generation prompt and forces an immediate EOS. This is the server half of the placement defect that #436 fixed for the CLI. expand_gemma4_audio_tokens_for_server now places the audio block at the token-stream level, the same layer the image path uses via expand_gemma4_image_tokens / apply_image_token_blocks, so the prefix-cache-sensitive render path in chat_request.rs is untouched. When a rendered <|audio|> placeholder is present it is wrapped in place; otherwise the block is spliced in immediately before the last <end_of_turn> token, which closes the latest user turn in a Gemma generation prompt, so the audio stays inside the user turn. The model worker resolves the <end_of_turn> id from the tokenizer and threads it through both Gemma 4 audio paths: prepare_gemma4_audio_embeddings (Conformer VLM) and prepare_gemma4_unified_audio_embeddings (encoder-free Unified). The legacy before-the-final-token insertion stays only as a last resort for prompts with no <end_of_turn> marker. Non-audio requests are byte-identical: only the two audio embedding paths change and chat_request.rs is not touched, so text-only, image-only, and non-Gemma server requests keep their exact prompt and prompt-cache prefix behavior. New unit tests cover in-place wrap, user-turn insertion, multi-turn last-user-turn targeting, and both last-resort fallbacks. * fix(server): resolve Gemma 4 end-of-turn token so audio lands in the user turn The server places the Gemma 4 audio block before the last end-of-turn token (issue #437), but `resolve_end_of_turn_token_id` only looked up "<end_of_turn>". Gemma 4 renamed the marker to "<turn|>" (id 106; start-of-turn is "<|turn>"), so the lookup returned None and the placement silently degraded to the model-turn fallback, still yielding 0 tokens. Try both "<end_of_turn>" (Gemma 2/3) and "<turn|>" (Gemma 4) so the audio block lands inside the last user turn. Verified end to end: a chat-completions request with an input_audio part to gemma-4-e2b now transcribes (0 -> 120 tokens) instead of emitting EOS at prefill. * test: add regression tests for resolve_end_of_turn_token_id Add three unit tests to src/server/model_worker_tests.rs covering the function that had the bug (resolve_end_of_turn_token_id had no tests before this PR): - resolve_end_of_turn_id_handles_gemma4_turn_marker: a tokenizer with "<turn|>" = id 106 but no "<end_of_turn>" must return Some(106). This is the exact regression guard for the #437 bug, where the old implementation only looked up "<end_of_turn>" and returned None for Gemma 4 tokenizers. - resolve_end_of_turn_id_handles_gemma23_end_of_turn_marker: a tokenizer with "<end_of_turn>" = id 107 must return Some(107). - resolve_end_of_turn_id_returns_none_when_no_marker_present: a tokenizer with neither marker must return None so the caller keeps its own fallback. Each test builds a minimal HuggingFace BPE stub (via two helper functions, stub_eot_tokenizer and stub_tokenizer_no_eot) that mirrors the pattern of MlxcelTokenizer::stub_with_byte_fallback. Also remove the stale "not yet wired through the chat prompt" note from docs/supported-models.md and replace it with a brief description of what the fix delivers.
1 parent 263a759 commit 6776688

5 files changed

Lines changed: 326 additions & 31 deletions

File tree

docs/supported-models.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ Implemented VLM variants include:
7171

7272
- Gemma 3 VL, Gemma 3n VL, Gemma 4 VL
7373
- Gemma 4 Unified (`gemma4_unified`): encoder-free text + image + audio + video. Patch-projection vision embedder and waveform-chunk audio path feed the shared Gemma 4 backbone, with blockwise bidirectional attention over image/video token spans during prefill. Video is handled as images-per-frame: frames are extracted with `ffmpeg` (uniform sampling, default 2.0 fps), patchified through the same vision embedder with a per-frame `vision_soft_tokens_per_video_frame` budget (70), and scattered into `video_token_id` placeholder spans. Video is available on both the CLI (`--video`) and the server (`video_url` content blocks). The prompt grows by ~70 soft tokens per sampled frame, so a multi-second clip at the default fps expands past the model's 1024-token sliding window. That over-window single-pass prefill is handled correctly: the windowed prefill mask spans the full prompt (the rotating cache keeps every prefill key and only trims to the window for the decode step), so long clips decode coherently without lowering `--fps`.
74-
- Gemma 4 VL audio: the Gemma 4 VL checkpoints that ship a Conformer audio tower (for example the `e2b` / `e4b` instruct models) take spoken audio from the CLI with `--audio <path>` and transcribe or answer questions about it. Input audio is resampled to 16 kHz before the Conformer encoder, so a clip at any source rate produces the encoder frame count the duration-based audio-token budget expects. Server-side audio input for this path is not yet wired through the chat prompt.
74+
- Gemma 4 VL audio: the Gemma 4 VL checkpoints that ship a Conformer audio tower (for example the `e2b` / `e4b` instruct models) take spoken audio from the CLI with `--audio <path>` and transcribe or answer questions about it. Input audio is resampled to 16 kHz before the Conformer encoder, so a clip at any source rate produces the encoder frame count the duration-based audio-token budget expects. Server-side `input_audio` in `POST /v1/chat/completions` is also supported: the audio block is spliced inside the last user turn, before the `<turn|>` end-of-turn marker that Gemma 4 uses (id 106).
7575
- Llama 4 VLM
7676
- LLaVA and LLaVA-Bunny
7777
- Aya Vision and PaliGemma

src/multimodal/vlm_runtime.rs

Lines changed: 60 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,40 +1089,77 @@ pub fn expand_gemma4_unified_video_tokens(
10891089
Ok(())
10901090
}
10911091

1092-
/// Expand audio token placeholder in prompt tokens for server requests.
1092+
/// Expand the Gemma 4 audio placeholder in server prompt tokens into a full
1093+
/// `boa + audio_token*N + eoa` block.
10931094
///
1094-
/// Replaces the first `audio_token_id` with `boa + audio_token*N + eoa`.
1095-
/// If no audio placeholder is found, inserts before the last token.
1095+
/// Placement order, most specific first:
1096+
///
1097+
/// 1. **In place.** If a rendered `<|audio|>` (`audio_token_id`) placeholder is
1098+
/// already present (e.g. a future template path emits one into the user
1099+
/// turn), wrap the first occurrence as `boa + audio_token*N + eoa`. This is
1100+
/// the same in-place expansion the CLI relies on after its chat template
1101+
/// renders the marker.
1102+
/// 2. **End of the last user turn.** The server renders chat messages
1103+
/// text-only (`MessageContent::text()` drops `input_audio` content parts),
1104+
/// so no `<|audio|>` marker reaches the prompt. When `end_of_turn_token_id`
1105+
/// is supplied, insert the audio block immediately before the *last*
1106+
/// `<end_of_turn>` token. In a Gemma generation prompt
1107+
/// (`<start_of_turn>user\n…<end_of_turn>\n<start_of_turn>model\n`) the final
1108+
/// `<end_of_turn>` closes the latest user turn (the pending model turn has
1109+
/// no closing marker yet), so the block lands inside the user turn. This is
1110+
/// the fix for issue #437: the previous "before the last token" fallback put
1111+
/// the audio inside the model turn, which forced an immediate EOS (0-token
1112+
/// output).
1113+
/// 3. **Before the final token.** Last-resort fallback for prompts with no
1114+
/// `<end_of_turn>` marker (e.g. `--no-chat-template`). Preserves the
1115+
/// historical behavior so a non-Gemma-shaped prompt still gets an audio
1116+
/// block rather than none.
10961117
pub fn expand_gemma4_audio_tokens_for_server(
10971118
prompt_tokens: &mut Vec<i32>,
10981119
audio_token_id: i32,
10991120
boa_token_id: i32,
11001121
eoa_token_id: i32,
11011122
num_audio_tokens: usize,
1123+
end_of_turn_token_id: Option<i32>,
11021124
) {
1103-
let mut expanded = Vec::with_capacity(prompt_tokens.len() + num_audio_tokens + 2);
1104-
let mut found = false;
1105-
for &token in prompt_tokens.iter() {
1106-
if token == audio_token_id && !found {
1107-
found = true;
1108-
expanded.push(boa_token_id);
1109-
expanded.extend(std::iter::repeat_n(audio_token_id, num_audio_tokens));
1110-
expanded.push(eoa_token_id);
1111-
} else {
1112-
expanded.push(token);
1125+
let build_block = || {
1126+
let mut block = Vec::with_capacity(num_audio_tokens + 2);
1127+
block.push(boa_token_id);
1128+
block.extend(std::iter::repeat_n(audio_token_id, num_audio_tokens));
1129+
block.push(eoa_token_id);
1130+
block
1131+
};
1132+
1133+
// 1. Wrap a rendered `<|audio|>` placeholder in place.
1134+
if prompt_tokens.contains(&audio_token_id) {
1135+
let mut expanded = Vec::with_capacity(prompt_tokens.len() + num_audio_tokens + 2);
1136+
let mut found = false;
1137+
for &token in prompt_tokens.iter() {
1138+
if token == audio_token_id && !found {
1139+
found = true;
1140+
expanded.extend(build_block());
1141+
} else {
1142+
expanded.push(token);
1143+
}
11131144
}
1145+
*prompt_tokens = expanded;
1146+
return;
11141147
}
1115-
// If no placeholder found, insert before last token
1116-
if !found {
1117-
let last = expanded.pop();
1118-
expanded.push(boa_token_id);
1119-
expanded.extend(std::iter::repeat_n(audio_token_id, num_audio_tokens));
1120-
expanded.push(eoa_token_id);
1121-
if let Some(tok) = last {
1122-
expanded.push(tok);
1123-
}
1148+
1149+
// 2. Insert inside the last user turn (before its closing `<end_of_turn>`).
1150+
if let Some(eot) = end_of_turn_token_id
1151+
&& let Some(pos) = prompt_tokens.iter().rposition(|&token| token == eot)
1152+
{
1153+
prompt_tokens.splice(pos..pos, build_block());
1154+
return;
1155+
}
1156+
1157+
// 3. Last resort: insert before the final token.
1158+
let last = prompt_tokens.pop();
1159+
prompt_tokens.extend(build_block());
1160+
if let Some(tok) = last {
1161+
prompt_tokens.push(tok);
11241162
}
1125-
*prompt_tokens = expanded;
11261163
}
11271164

11281165
#[cfg(test)]

src/multimodal/vlm_runtime_tests.rs

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
// limitations under the License.
1414

1515
use super::{
16-
VlmPreparationSummary, expand_gemma4_unified_video_tokens, expand_gemma4_video_tokens,
16+
VlmPreparationSummary, expand_gemma4_audio_tokens_for_server,
17+
expand_gemma4_unified_video_tokens, expand_gemma4_video_tokens,
1718
format_molmo_v1_prompt_for_processor, prepared_embedding_refs,
1819
shift_molmo_v1_image_input_idx_for_bos, should_prepare_vlm_embeddings,
1920
};
@@ -221,3 +222,83 @@ fn expand_gemma4_unified_video_tokens_no_op_when_videos_empty() {
221222
expand_gemma4_unified_video_tokens(&mut prompt, 100, 201, 202, &[]).unwrap();
222223
assert_eq!(prompt, original);
223224
}
225+
226+
// Audio token ids used across the audio-placement tests below.
227+
const AUDIO: i32 = 50; // audio_token_id
228+
const BOA: i32 = 51; // boa_token_id
229+
const EOA: i32 = 52; // eoa_token_id
230+
const EOT: i32 = 106; // <end_of_turn>
231+
const SOT: i32 = 105; // <start_of_turn>
232+
233+
#[test]
234+
fn server_audio_wraps_rendered_placeholder_in_place() {
235+
// A rendered `<|audio|>` (AUDIO) already sits in the user turn: wrap the
236+
// first occurrence as BOA + AUDIO*N + EOA in place, leaving the turn
237+
// structure intact.
238+
// [BOS, <sot>, user_text, AUDIO, <eot>, <sot>, model]
239+
let mut prompt = vec![2, SOT, 7, AUDIO, EOT, SOT, 8];
240+
expand_gemma4_audio_tokens_for_server(&mut prompt, AUDIO, BOA, EOA, 3, Some(EOT));
241+
assert_eq!(
242+
prompt,
243+
vec![2, SOT, 7, BOA, AUDIO, AUDIO, AUDIO, EOA, EOT, SOT, 8]
244+
);
245+
}
246+
247+
#[test]
248+
fn server_audio_inserts_inside_last_user_turn() {
249+
// Text-only server render: no AUDIO placeholder. The block must land
250+
// before the user turn's closing `<end_of_turn>` so it stays in the user
251+
// turn, not the model turn (issue #437).
252+
// [BOS, <sot>, user, text, <eot>, <sot>, model]
253+
let mut prompt = vec![2, SOT, 11, 7, EOT, SOT, 8];
254+
expand_gemma4_audio_tokens_for_server(&mut prompt, AUDIO, BOA, EOA, 3, Some(EOT));
255+
// Block spliced before the EOT at index 4, i.e. after the user text.
256+
assert_eq!(
257+
prompt,
258+
vec![2, SOT, 11, 7, BOA, AUDIO, AUDIO, AUDIO, EOA, EOT, SOT, 8]
259+
);
260+
// The audio block precedes the (only) `<end_of_turn>`, never the trailing
261+
// `<start_of_turn>model` generation prompt.
262+
let eot_pos = prompt.iter().position(|&t| t == EOT).unwrap();
263+
let eoa_pos = prompt.iter().position(|&t| t == EOA).unwrap();
264+
assert!(eoa_pos < eot_pos);
265+
}
266+
267+
#[test]
268+
fn server_audio_targets_the_last_user_turn_in_multi_turn() {
269+
// Multi-turn prompt: user / model / user, then the generation prompt. The
270+
// block must go before the LAST `<end_of_turn>` (closing the latest user
271+
// turn), not an earlier one.
272+
// idx: 0 1 2 3 4 5 6 7 8 9 10 11
273+
// [BOS,<sot>,u1, EOT,<sot>,m1, EOT,<sot>,u2, EOT,<sot>,model]
274+
let mut prompt = vec![2, SOT, 21, EOT, SOT, 31, EOT, SOT, 22, EOT, SOT, 8];
275+
expand_gemma4_audio_tokens_for_server(&mut prompt, AUDIO, BOA, EOA, 2, Some(EOT));
276+
assert_eq!(
277+
prompt,
278+
vec![
279+
2, SOT, 21, EOT, SOT, 31, EOT, SOT, 22, BOA, AUDIO, AUDIO, EOA, EOT, SOT, 8
280+
]
281+
);
282+
// The first two `<end_of_turn>` markers stay adjacent to their turn text;
283+
// only the final user turn gains the audio block.
284+
assert_eq!(prompt[3], EOT);
285+
assert_eq!(prompt[6], EOT);
286+
}
287+
288+
#[test]
289+
fn server_audio_falls_back_before_last_token_without_end_of_turn() {
290+
// No placeholder and no `<end_of_turn>` id (e.g. --no-chat-template): keep
291+
// the historical "before the final token" insertion as a last resort.
292+
let mut prompt = vec![2, 11, 8];
293+
expand_gemma4_audio_tokens_for_server(&mut prompt, AUDIO, BOA, EOA, 2, None);
294+
assert_eq!(prompt, vec![2, 11, BOA, AUDIO, AUDIO, EOA, 8]);
295+
}
296+
297+
#[test]
298+
fn server_audio_falls_back_when_end_of_turn_absent_from_prompt() {
299+
// `<end_of_turn>` id is known but the prompt does not contain it: the
300+
// last-resort fallback still fires rather than dropping the audio block.
301+
let mut prompt = vec![2, 11, 8];
302+
expand_gemma4_audio_tokens_for_server(&mut prompt, AUDIO, BOA, EOA, 2, Some(EOT));
303+
assert_eq!(prompt, vec![2, 11, BOA, AUDIO, AUDIO, EOA, 8]);
304+
}

src/server/model_worker.rs

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -957,12 +957,28 @@ pub(crate) fn prepare_request_vlm_embeddings(
957957

958958
// Audio-only or audio+images for Gemma4 / Gemma4 Unified
959959
if !audio.is_empty() {
960-
if let Some(embeddings) =
961-
prepare_gemma4_unified_audio_embeddings(model, prompt_tokens, images, audio)?
962-
{
960+
// The server renders chat messages text-only, so the prompt carries no
961+
// `<|audio|>` marker (issue #437). Resolve the Gemma `<end_of_turn>`
962+
// id so the per-family audio expansion can place the audio block inside
963+
// the last user turn instead of the model turn (which forces an
964+
// immediate EOS / 0-token output).
965+
let end_of_turn_token_id = resolve_end_of_turn_token_id(tokenizer);
966+
if let Some(embeddings) = prepare_gemma4_unified_audio_embeddings(
967+
model,
968+
prompt_tokens,
969+
images,
970+
audio,
971+
end_of_turn_token_id,
972+
)? {
963973
return Ok(Some(embeddings));
964974
}
965-
match prepare_gemma4_audio_embeddings(model, prompt_tokens, images, audio)? {
975+
match prepare_gemma4_audio_embeddings(
976+
model,
977+
prompt_tokens,
978+
images,
979+
audio,
980+
end_of_turn_token_id,
981+
)? {
966982
Some(embeddings) => return Ok(Some(embeddings)),
967983
None => {
968984
// Model does not support audio (not Gemma4 or no audio tower).
@@ -1045,6 +1061,42 @@ pub(crate) fn prepare_request_vlm_embeddings(
10451061
Ok(None)
10461062
}
10471063

1064+
/// Resolve the Gemma `<end_of_turn>` token id from the tokenizer.
1065+
///
1066+
/// The server flattens chat messages to text-only, so an `input_audio` request
1067+
/// produces a prompt with no `<|audio|>` marker. Knowing the `<end_of_turn>`
1068+
/// id lets [`crate::vlm_runtime::expand_gemma4_audio_tokens_for_server`] place
1069+
/// the audio block inside the last user turn instead of the model turn (issue
1070+
/// #437). Returns `None` when the marker is not a single token in this
1071+
/// tokenizer (non-Gemma tokenizers), in which case the caller keeps the legacy
1072+
/// "before the final token" insertion.
1073+
fn resolve_end_of_turn_token_id(tokenizer: &MlxcelTokenizer) -> Option<i32> {
1074+
// The end-of-turn marker differs across Gemma generations: Gemma 2/3 use
1075+
// "<end_of_turn>", while Gemma 4 renamed it to "<turn|>" (id 106, and
1076+
// "<|turn>" for start-of-turn). Try both so the audio block lands inside
1077+
// the last user turn on every Gemma checkpoint; "<end_of_turn>" is tried
1078+
// first because that is the value carried by the non-Gemma-4 templates.
1079+
const EOT_CANDIDATES: &[&str] = &["<end_of_turn>", "<turn|>"];
1080+
if let Some(hf) = tokenizer.hf_tokenizer() {
1081+
for candidate in EOT_CANDIDATES {
1082+
if let Some(id) = hf.token_to_id(candidate) {
1083+
return Some(id as i32);
1084+
}
1085+
}
1086+
}
1087+
// SentencePiece / Tiktoken fallback: accept only when the literal marker
1088+
// encodes to exactly one token, so a tokenizer that splits it into pieces
1089+
// does not yield a bogus mid-vocabulary id.
1090+
for candidate in EOT_CANDIDATES {
1091+
if let Ok(ids) = tokenizer.encode(candidate, false)
1092+
&& ids.len() == 1
1093+
{
1094+
return Some(ids[0] as i32);
1095+
}
1096+
}
1097+
None
1098+
}
1099+
10481100
/// Process audio (and optionally images) for Gemma4 VLM models.
10491101
///
10501102
/// Returns `Ok(None)` if the model is not a Gemma4 VLM with audio support.
@@ -1053,6 +1105,7 @@ fn prepare_gemma4_audio_embeddings(
10531105
prompt_tokens: &mut Vec<i32>,
10541106
images: &[Vec<u8>],
10551107
audio_data: &[Vec<u8>],
1108+
end_of_turn_token_id: Option<i32>,
10561109
) -> Result<Option<InputEmbeddings>> {
10571110
use crate::audio;
10581111

@@ -1087,13 +1140,15 @@ fn prepare_gemma4_audio_embeddings(
10871140

10881141
let num_audio_tokens = audio::compute_audio_num_tokens(samples.len(), sample_rate, 40, 750);
10891142

1090-
// Expand audio tokens: BOA + AUDIO*N + EOA
1143+
// Expand audio tokens: BOA + AUDIO*N + EOA, placed inside the last user
1144+
// turn (issue #437).
10911145
crate::vlm_runtime::expand_gemma4_audio_tokens_for_server(
10921146
prompt_tokens,
10931147
gemma4_vl.audio_token_id,
10941148
gemma4_vl.boa_token_id,
10951149
gemma4_vl.eoa_token_id,
10961150
num_audio_tokens,
1151+
end_of_turn_token_id,
10971152
);
10981153

10991154
// `AudioFeatureExtractor::extract` assumes a 16 kHz waveform (160-sample
@@ -1163,6 +1218,7 @@ fn prepare_gemma4_unified_audio_embeddings(
11631218
prompt_tokens: &mut Vec<i32>,
11641219
images: &[Vec<u8>],
11651220
audio_data: &[Vec<u8>],
1221+
end_of_turn_token_id: Option<i32>,
11661222
) -> Result<Option<InputEmbeddings>> {
11671223
use crate::audio;
11681224

@@ -1201,6 +1257,7 @@ fn prepare_gemma4_unified_audio_embeddings(
12011257
unified.boa_token_id,
12021258
unified.eoa_token_id,
12031259
num_audio_tokens,
1260+
end_of_turn_token_id,
12041261
);
12051262

12061263
// Process images alongside audio (encoder-free patch projector).

0 commit comments

Comments
 (0)