Skip to content
Merged
2 changes: 1 addition & 1 deletion src/models/model_type.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace Generators {
struct ModelType {
inline static bool IsLLM(const std::string& model_type) {
// Large-language model (LLM)
static constexpr std::array<std::string_view, 23> LLM = {"chatglm", "decoder", "ernie4_5", "gemma", "gemma2", "gemma3_text", "gemma4_text", "gpt2", "gptoss", "granite", "internlm2", "lfm2", "llama", "mistral", "nemotron", "olmo", "phi", "phimoe", "phi3", "phi3small", "qwen2", "qwen3", "smollm3"};
static constexpr std::array<std::string_view, 24> LLM = {"chatglm", "decoder", "ernie4_5", "gemma", "gemma2", "gemma3_text", "gemma4_text", "gpt2", "gptoss", "granite", "internlm2", "lfm2", "llama", "mistral", "nemotron", "olmo", "phi", "phimoe", "phi3", "phi3small", "qwen2", "qwen3", "qwen3_5_text", "smollm3"};
return std::find(LLM.begin(), LLM.end(), model_type) != LLM.end();
}

Expand Down
9 changes: 7 additions & 2 deletions src/models/multi_modal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -650,8 +650,13 @@ DecoderState::DecoderState(const MultiModalLanguageModel& model, DeviceSpan<int3
recurrent_state_{CreateRecurrentState(*this)} {
inputs_embeds_.Add();

// Some multimodal decoders (e.g., Gemma4) require input_ids alongside inputs_embeds
if (model_.session_info_.HasInput(model_.config_->model.decoder.inputs.input_ids)) {
// Some multimodal decoders (e.g., Gemma4) require input_ids alongside inputs_embeds.
// Use a decoder-only SessionInfo to avoid false positives from the embedding session
// (which always has input_ids), preventing incorrect injection into decoders that only
// accept inputs_embeds (e.g., Qwen3.5, Mistral3/Pixtral).
SessionInfo decoder_only_info;
decoder_only_info.Add(*model_.decoder_session_);
if (decoder_only_info.HasInput(model_.config_->model.decoder.inputs.input_ids)) {
decoder_input_ids_ = std::make_unique<DefaultInputIDs>(*this);
decoder_input_ids_->Add();
Comment thread
apsonawane marked this conversation as resolved.
Outdated
}
Expand Down
79 changes: 73 additions & 6 deletions src/python/py/models/builders/qwen.py
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,8 @@ class Qwen35TextModel(Model):

def __init__(self, config, io_dtype, onnx_dtype, ep, cache_dir, extra_options):
# Qwen3.5 is a VL model. The decoder takes inputs_embeds.
# When exclude_embeds is explicitly set to False, build as a standalone LLM.
self.is_text_only = extra_options.get("exclude_embeds") is False
Comment thread
apsonawane marked this conversation as resolved.
Outdated
if "exclude_embeds" not in extra_options:
extra_options["exclude_embeds"] = True
print("Setting exclude_embeds=True for Qwen3.5 VL decoder.")
Expand Down Expand Up @@ -969,8 +971,14 @@ def __init__(self, config, io_dtype, onnx_dtype, ep, cache_dir, extra_options):
# SkipSimplifiedLayerNormalization can be used directly.
self.layernorm_attrs["add_offset"] = 1

# 3D position_ids for mRoPE: [3, batch_size, sequence_length]
self.input_shapes["position_ids"] = [3, "batch_size", "sequence_length"]
# Position IDs input.
# In text-only mode the runtime provides standard 2D [B, S] position_ids.
# We expand them to 3D [3, B, S] inside the graph so mRoPE works unchanged.
# In VL mode the pipeline provides 3D position_ids directly.
Comment thread
kunal-vaishnavi marked this conversation as resolved.
if self.is_text_only:
self.input_shapes["position_ids"] = ["batch_size", "sequence_length"]
else:
self.input_shapes["position_ids"] = [3, "batch_size", "sequence_length"]
self.input_names["position_ids"] = "position_ids"

# mRoPE config
Expand Down Expand Up @@ -1077,6 +1085,35 @@ def _setup_hybrid_cache_io(self):
self.output_names["present.key"] = filtered_key_outputs
self.output_names["present.value"] = filtered_value_outputs

def make_inputs_and_outputs(self):
super().make_inputs_and_outputs()

if self.is_text_only:
# The graph input is 2D position_ids [B, S].
# Expand to 3D [3, B, S] for mRoPE by stacking 3 copies.
Comment thread
kunal-vaishnavi marked this conversation as resolved.
pos_2d = "position_ids"
unsq_name = "/model/position_ids_expand/Unsqueeze"
unsq_output = f"{unsq_name}/output_0"
self.make_unsqueeze(
unsq_name,
[pos_2d, "/model/constants/INT64/[0]"],
ir.DataType.INT64,
[1, "batch_size", "sequence_length"],
)
expand_name = "/model/position_ids_expand/Expand"
expand_output = f"{expand_name}/output_0"
self.make_expand(
expand_name,
[unsq_output, "/model/constants/INT64/[3, 1, 1]"],
Comment thread
apsonawane marked this conversation as resolved.
Outdated
ir.DataType.INT64,
[3, "batch_size", "sequence_length"],
)
# Store the 3D position_ids name for mRoPE usage
# Keep self.input_names["position_ids"] as "position_ids" for genai_config
self._pos_ids_3d = expand_output
Comment thread
apsonawane marked this conversation as resolved.
Outdated
else:
self._pos_ids_3d = self.input_names["position_ids"]

def make_attention(self, layer_id, attention, root_input, **kwargs):
"""Dispatch to full attention or GatedDeltaNet based on layer type."""
if self.layer_types[layer_id] == "linear_attention":
Expand Down Expand Up @@ -1320,10 +1357,10 @@ def _get_shared_l2_eps(self):
def _make_mrope_cos_sin(self, basename):
"""Build interleaved mRoPE cos/sin from pre-computed cache + position_ids.

Input: position_ids [3, B, S]
Input: position_ids [3, B, S] (from self._pos_ids_3d)
Output: cos [B, S, rdim_half], sin [B, S, rdim_half]
"""
pos_ids = self.input_names["position_ids"]
pos_ids = self._pos_ids_3d
cos_cache = "model.rotary_emb.cos_cache"
Comment thread
apsonawane marked this conversation as resolved.
Outdated
sin_cache = "model.rotary_emb.sin_cache"
h_mask = "model.rotary_emb.h_mask"
Expand Down Expand Up @@ -1388,7 +1425,7 @@ def _make_synthetic_position_ids(self):
created once and reused across all layers and Q/K calls.
"""
basename = "/model/attn/synthetic_pos_ids"
pos_ids_input = self.input_names["position_ids"]
pos_ids_input = self._pos_ids_3d

# Shape(position_ids) → [3, B, S]
shape_name = f"{basename}/Shape"
Expand Down Expand Up @@ -1997,7 +2034,7 @@ def make_genai_config(self, model_name_or_path, extra_kwargs, out_dir):
"model_type": self.model_type,
}
self.num_layers = len(self.layer_types)
self.model_type = "Qwen3_5ForConditionalGeneration"
self.model_type = "Qwen3_5_textForCausalLM" if self.is_text_only else "Qwen3_5ForConditionalGeneration"
self.input_names["past_key_values.key"] = "past_key_values.%d.key"
self.input_names["past_key_values.value"] = "past_key_values.%d.value"
self.output_names["present.key"] = "present.%d.key"
Expand All @@ -2012,3 +2049,33 @@ def make_genai_config(self, model_name_or_path, extra_kwargs, out_dir):
del self.input_names["past_key_values.value"]
del self.output_names["present.key"]
del self.output_names["present.value"]

def save_processing(self, model_name_or_path, extra_kwargs, out_dir):
super().save_processing(model_name_or_path, extra_kwargs, out_dir)
# Patch tokenizer regex: remove \p{M} (Unicode Mark category) which is
# unsupported by the C++ std::regex engine in onnxruntime-extensions.
import json
import os

def _patch_pM(obj):
if isinstance(obj, dict):
for k, v in obj.items():
if isinstance(v, str) and "\\p{M}" in v:
obj[k] = v.replace("[\\p{L}\\p{M}]", "\\p{L}").replace(
"[^\\s\\p{L}\\p{M}\\p{N}]", "[^\\s\\p{L}\\p{N}]"
)
else:
_patch_pM(v)
elif isinstance(obj, list):
for item in obj:
_patch_pM(item)

for fname in ("tokenizer_config.json", "tokenizer.json"):
fpath = os.path.join(out_dir, fname)
if os.path.exists(fpath):
with open(fpath, "r") as f:
data = json.load(f)
_patch_pM(data)
with open(fpath, "w") as f:
Comment thread
apsonawane marked this conversation as resolved.
Outdated
json.dump(data, f, indent=2, ensure_ascii=False)
Comment thread
apsonawane marked this conversation as resolved.
Outdated
print(f"Patched unsupported \\p{{M}} regex in {fname}")
80 changes: 80 additions & 0 deletions test/python/test_qwen35_text_only.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License

"""
Unit tests for Qwen3.5 text-only (LLM) model.

Tests cover model loading, tokenizer creation, generator creation, and
basic text generation for the text-only variant of Qwen3.5 (model type
"qwen3_5_text"), which uses 2D position_ids and hybrid KV/recurrent state.

Usage:
pytest test_qwen35_text_only.py --test_models=test/test_models
"""

import os
from pathlib import Path

import onnxruntime_genai as og
import pytest

MODEL_DIR = "qwen35-text-only"


def _model_path(test_data_path):
return os.fspath(Path(test_data_path) / MODEL_DIR)


def _skip_if_missing(test_data_path):
path = _model_path(test_data_path)
if not os.path.exists(path):
pytest.skip(f"{MODEL_DIR} test model not found at {path}")
return path


def test_qwen35_text_only_model_loads(test_data_path):
"""Test that a Qwen3.5 text-only model loads successfully."""
model_path = _skip_if_missing(test_data_path)
model = og.Model(model_path)
assert model is not None


def test_qwen35_text_only_generator_creates(test_data_path):
"""Test that a Generator can be created for the text-only model.
Validates that hybrid state auto-discovery works with qwen3_5_text type."""
model_path = _skip_if_missing(test_data_path)
model = og.Model(model_path)
params = og.GeneratorParams(model)
params.set_search_options(max_length=10)
generator = og.Generator(model, params)
assert generator is not None


def test_qwen35_text_only_accepts_input_ids(test_data_path):
"""Test that the text-only model accepts input_ids (not inputs_embeds).
The dummy model uses Identity pass-through which doesn't support KV cache
shape changes, so we only validate that the generator constructs and
is ready to accept tokens."""
model_path = _skip_if_missing(test_data_path)
model = og.Model(model_path)
params = og.GeneratorParams(model)
params.set_search_options(max_length=5)

# The model should accept raw token IDs (input_ids, not inputs_embeds)
generator = og.Generator(model, params)
assert generator is not None


def test_qwen35_text_only_has_no_multimodal_processor(test_data_path):
"""Text-only model should not have a multimodal processor."""
model_path = _skip_if_missing(test_data_path)
model = og.Model(model_path)

# Text-only model has no vision/embedding sub-models,
# so creating a multimodal processor should fail or return None
try:
processor = model.create_multimodal_processor()
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
# If it doesn't raise, the test still passes - some runtimes may return
# a processor that just doesn't process images
except Exception:
pass # Expected: no multimodal support
Comment thread
apsonawane marked this conversation as resolved.
Outdated
Loading