From 86ae02faff40a82ec1d9200e9f6900d203cf8be9 Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Tue, 16 Jun 2026 21:15:48 -0500 Subject: [PATCH 01/22] Studio: detect transformers 5.3.0 tier from config.json for local checkpoints A local safetensors folder whose config.json did not match the Gemma4 (510/550) architecture signals short-circuited get_transformers_tier() to "default" (transformers 4.57.x), never reaching the name-substring check that routes Qwen3.5 to the 5.3.0 sidecar. So a local Qwen3.5 checkpoint (model_type "qwen3_5", needs transformers >= 5.2.0) loaded with 4.57.x and failed with "does not support Qwen3.5". The same model as a remote HF id worked, because it has no local config.json to trigger the short-circuit. Detect the 5.3.0 tier from config.json (model_type "qwen3_5" / architecture Qwen3_5ForCausalLM) in the local-config branch, mirroring the existing Gemma4 510/550 handling. This is a positive config signal, so it fixes local Qwen3.5 without weakening the directory-name false-positive guard (a llama checkpoint under a "gemma-4-12b-*" parent still resolves to default). Adds tests for the config-based 530 detection and local-folder tier resolution. --- .../tests/test_transformers_version.py | 48 +++++++++++++++++++ studio/backend/utils/transformers_version.py | 24 ++++++++++ 2 files changed, 72 insertions(+) diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index 60bbcde9ec0..8e8c54dd631 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -34,6 +34,7 @@ _check_tokenizer_config_needs_v5, _check_config_needs_510, _check_config_needs_550, + _config_needs_530, _config_json_cache, _tokenizer_class_cache, _config_needs_510_cache, @@ -789,3 +790,50 @@ def test_no_install_logging_when_venv_already_valid(self, tmp_path: Path, caplog assert ok is True mock_install.assert_not_called() assert "Installing" not in " ".join(r.getMessage() for r in caplog.records) + + +# --------------------------------------------------------------------------- +# Local-folder 5.3.0 tier detection via config.json (Qwen3.5 et al.) +# +# A local checkpoint with a config.json whose architecture/tokenizer didn't match +# the Gemma4 (510/550) signals short-circuited to "default", mis-routing a local +# Qwen3.5 folder (model_type "qwen3_5", needs transformers >= 5.2.0) to +# transformers 4.57.x, which fails to load it. Adding a config-based 530 check +# fixes it without weakening the directory-name false-positive guard. +# --------------------------------------------------------------------------- + + +class TestLocalConfig530Tier: + def setup_method(self): + _config_json_cache.clear() + _tokenizer_class_cache.clear() + + def test_config_needs_530_model_type(self): + """config.json with model_type=qwen3_5 is the 5.3.0 tier.""" + assert _config_needs_530({"model_type": "qwen3_5"}) is True + + def test_config_needs_530_plain_qwen3_is_false(self): + """A regular Qwen3 checkpoint must not be promoted to 5.3.0.""" + assert _config_needs_530({"model_type": "qwen3"}) is False + + def test_tier_local_qwen35_config_selects_530(self, tmp_path: Path): + """The reported case: a local Qwen3.5 folder with a qwen3_5 config + resolves to 530 instead of short-circuiting to default.""" + named = tmp_path / "Qwen3.5-2B" + named.mkdir() + (named / "config.json").write_text(json.dumps({"model_type": "qwen3_5"})) + assert get_transformers_tier(str(named)) == "530" + + def test_tier_local_plain_model_still_default(self, tmp_path: Path): + """A local non-5.x checkpoint still resolves default, so the + directory-name false-positive guard is preserved.""" + plain = tmp_path / "checkpoint-1000" + plain.mkdir() + (plain / "config.json").write_text( + json.dumps({"architectures": ["LlamaForCausalLM"], "model_type": "llama"}) + ) + with patch( + "utils.transformers_version._check_tokenizer_config_needs_v5", + return_value = False, + ): + assert get_transformers_tier(str(plain)) == "default" diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 87a6726d33d..4dee7432e9a 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -103,6 +103,16 @@ def _env_offline() -> bool: "gemma4", } +# Architecture classes / model_type values that require transformers 5.3.0. +# Checked via config.json so a local checkpoint whose directory name does not +# advertise the family (e.g. a renamed Qwen3.5 folder) is still routed correctly. +_TRANSFORMERS_530_ARCHITECTURES: set[str] = { + "Qwen3_5ForCausalLM", +} +_TRANSFORMERS_530_MODEL_TYPES: set[str] = { + "qwen3_5", +} + # Tokenizer classes that only exist in transformers>=5.x. _TRANSFORMERS_5_TOKENIZER_CLASSES: set[str] = { "TokenizersBackend", @@ -389,6 +399,14 @@ def _config_needs_510(cfg: dict) -> bool: ) +def _config_needs_530(cfg: dict) -> bool: + return _config_matches_tier( + cfg, + _TRANSFORMERS_530_ARCHITECTURES, + _TRANSFORMERS_530_MODEL_TYPES, + ) + + def _check_config_needs_550(model_name: str) -> bool: """True if ``config.json`` has architectures/model_type needing transformers 5.5.0 (e.g. Gemma 4). @@ -470,6 +488,12 @@ def get_transformers_tier(model_name: str) -> str: model_name, ) return "550" + if cfg is not None and _config_needs_530(cfg): + logger.info( + "Transformers tier 530 selected for %s (local config.json check)", + model_name, + ) + return "530" if cfg is not None: local_tc = Path(model_name) / "tokenizer_config.json" if local_tc.is_file() and _check_tokenizer_config_needs_v5(model_name): From a443b3c1141abc2066cea21a845403621ded29f6 Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Tue, 16 Jun 2026 23:05:36 -0500 Subject: [PATCH 02/22] Studio: suppress false warning when config.json parse fails for sidecar-tier models --- studio/backend/utils/hardware/hardware.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/studio/backend/utils/hardware/hardware.py b/studio/backend/utils/hardware/hardware.py index 86dfa8a93f7..eff3bd20181 100644 --- a/studio/backend/utils/hardware/hardware.py +++ b/studio/backend/utils/hardware/hardware.py @@ -1091,7 +1091,26 @@ def _load_config_for_gpu_estimate(model_name: str, hf_token: Optional[str] = Non trust_remote_code = trust_remote_code, ) except Exception as e: - logger.warning("Could not load config for '%s': %s", model_name, e) + # A 5.x-only architecture (e.g. Qwen3.5 / model_type "qwen3_5") cannot be + # parsed by the default in-process transformers -- that is expected, the + # worker reloads the model under the matching transformers sidecar. Only + # warn loudly when no tier switch will rescue the load. + tier = "default" + try: + from utils.transformers_version import get_transformers_tier + tier = get_transformers_tier(model_name) + except Exception: + pass + if tier != "default": + _tier_version = {"510": "5.10.x", "530": "5.3.0", "550": "5.5.0"}.get(tier, "5.x") + logger.info( + "Config for '%s' not parseable by the default transformers; " + "needs transformers %s and will be loaded with that sidecar in the worker", + model_name, + _tier_version, + ) + else: + logger.warning("Could not load config for '%s': %s", model_name, e) return None From daa28a8727f1fc2711e16be7706863f5c782eb68 Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Tue, 16 Jun 2026 23:24:43 -0500 Subject: [PATCH 03/22] Studio: generalize local-checkpoint tier detection for all 5.3.0 families Expands the config.json-based tier detection to cover all known 5.3.0-tier model families (Qwen3 MoE, GLM-4.7-Flash, LFM2.5-VL) and adds a _name_or_path fallback so renamed local checkpoints with unrecognised model_type values still route correctly via the HF ID embedded in their config.json. - Expand _TRANSFORMERS_530_ARCHITECTURES / _MODEL_TYPES with verified entries from Qwen3MoeForCausalLM, Glm4MoeLiteForCausalLM, Lfm2VlForConditionalGeneration, and Qwen3_5ForConditionalGeneration (confirmed from local Qwen3.5-2B config.json) - Extract _tier_from_name() helper, deduplicating the fast-substring logic used by both the remote-path branch and the new config _name_or_path fallback - In the local-config branch: after architecture checks, resolve the tier from cfg._name_or_path / cfg.model_name before returning "default", preserving the existing directory-name false-positive guard - 79 tests passing --- .../tests/test_transformers_version.py | 179 ++++++++++++++++-- studio/backend/utils/transformers_version.py | 148 +++++++++------ 2 files changed, 252 insertions(+), 75 deletions(-) diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index 8e8c54dd631..5e9cbb7a8cb 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -35,6 +35,7 @@ _check_config_needs_510, _check_config_needs_550, _config_needs_530, + _tier_from_name, _config_json_cache, _tokenizer_class_cache, _config_needs_510_cache, @@ -793,13 +794,63 @@ def test_no_install_logging_when_venv_already_valid(self, tmp_path: Path, caplog # --------------------------------------------------------------------------- -# Local-folder 5.3.0 tier detection via config.json (Qwen3.5 et al.) +# _tier_from_name — shared name-based detection helper +# --------------------------------------------------------------------------- + + +class TestTierFromName: + """Unit tests for _tier_from_name(), which backs both the fast substring + path and the config _name_or_path fallback.""" + + def test_returns_none_for_unknown(self): + assert _tier_from_name("meta-llama/Llama-3-8B") is None + + def test_gemma4_returns_550(self): + tier, _ = _tier_from_name("google/gemma-4-E2B-it") + assert tier == "550" + + def test_gemma4_assistant_returns_510(self): + tier, match = _tier_from_name("google/gemma-4-E2B-it-assistant") + assert tier == "510" + assert "assistant" in match + + def test_gemma4_12b_returns_510(self): + tier, _ = _tier_from_name("unsloth/gemma-4-12b-it") + assert tier == "510" + + def test_qwen35_returns_530(self): + tier, match = _tier_from_name("Qwen/Qwen3.5-7B") + assert tier == "530" + assert "qwen3.5" in match + + def test_ministral3_returns_530(self): + # The existing substring "ministral-3-" matches the 2512 naming style. + tier, _ = _tier_from_name("mistralai/Ministral-3-8B-Instruct-2512") + assert tier == "530" + + def test_qwen3_moe_substring_returns_530(self): + tier, _ = _tier_from_name("Qwen/Qwen3-30B-A3B-Instruct-2507") + assert tier == "530" + + def test_510_beats_550(self): + """gemma-4-12b matches 510 (checked first), not 550.""" + tier, _ = _tier_from_name("google/gemma-4-12b-it") + assert tier == "510" + + def test_550_beats_530(self): + """gemma-4 matches 550, not 530.""" + tier, _ = _tier_from_name("gemma-4-model") + assert tier == "550" + + +# --------------------------------------------------------------------------- +# Local-folder tier detection via config.json # -# A local checkpoint with a config.json whose architecture/tokenizer didn't match -# the Gemma4 (510/550) signals short-circuited to "default", mis-routing a local -# Qwen3.5 folder (model_type "qwen3_5", needs transformers >= 5.2.0) to -# transformers 4.57.x, which fails to load it. Adding a config-based 530 check -# fixes it without weakening the directory-name false-positive guard. +# When a local checkpoint's config.json architecture/model_type matches a known +# sidecar set, that's the authoritative answer. When it doesn't match (unknown +# or future family), the HF model ID from _name_or_path / model_name in the +# config is run through the same name-based rules so renamed folders are still +# routed correctly without introducing path false-positives. # --------------------------------------------------------------------------- @@ -808,32 +859,118 @@ def setup_method(self): _config_json_cache.clear() _tokenizer_class_cache.clear() - def test_config_needs_530_model_type(self): - """config.json with model_type=qwen3_5 is the 5.3.0 tier.""" + # --- config-set matches ------------------------------------------------- + + def test_config_needs_530_qwen3_5_model_type(self): assert _config_needs_530({"model_type": "qwen3_5"}) is True + def test_config_needs_530_qwen3_5_conditional_generation(self): + assert _config_needs_530({"architectures": ["Qwen3_5ForConditionalGeneration"]}) is True + + def test_config_needs_530_qwen3_moe(self): + assert _config_needs_530({"model_type": "qwen3_moe"}) is True + + def test_config_needs_530_glm4_moe_lite(self): + assert _config_needs_530({"model_type": "glm4_moe_lite"}) is True + + def test_config_needs_530_lfm2_vl(self): + assert _config_needs_530({"model_type": "lfm2_vl"}) is True + def test_config_needs_530_plain_qwen3_is_false(self): - """A regular Qwen3 checkpoint must not be promoted to 5.3.0.""" + """Regular Qwen3 (non-MoE, non-3.5) must not be promoted to 5.3.0.""" assert _config_needs_530({"model_type": "qwen3"}) is False def test_tier_local_qwen35_config_selects_530(self, tmp_path: Path): - """The reported case: a local Qwen3.5 folder with a qwen3_5 config - resolves to 530 instead of short-circuiting to default.""" - named = tmp_path / "Qwen3.5-2B" - named.mkdir() - (named / "config.json").write_text(json.dumps({"model_type": "qwen3_5"})) - assert get_transformers_tier(str(named)) == "530" + """Reported case: a local Qwen3.5 folder routes to 530 via config.json.""" + d = tmp_path / "Qwen3.5-2B" + d.mkdir() + (d / "config.json").write_text(json.dumps({"model_type": "qwen3_5"})) + assert get_transformers_tier(str(d)) == "530" + + def test_tier_local_qwen3_moe_config_selects_530(self, tmp_path: Path): + """Local Qwen3 MoE checkpoint routes to 530 via config.json.""" + d = tmp_path / "my-qwen3-moe" + d.mkdir() + (d / "config.json").write_text( + json.dumps({"model_type": "qwen3_moe", "architectures": ["Qwen3MoeForCausalLM"]}) + ) + assert get_transformers_tier(str(d)) == "530" + + def test_tier_local_glm4_moe_lite_config_selects_530(self, tmp_path: Path): + """Local GLM-4.7-Flash checkpoint routes to 530 via config.json.""" + d = tmp_path / "my-glm-model" + d.mkdir() + (d / "config.json").write_text( + json.dumps({"model_type": "glm4_moe_lite", "architectures": ["Glm4MoeLiteForCausalLM"]}) + ) + assert get_transformers_tier(str(d)) == "530" + + def test_tier_local_lfm2_vl_config_selects_530(self, tmp_path: Path): + """Local LFM2.5-VL checkpoint routes to 530 via config.json.""" + d = tmp_path / "my-liquid-model" + d.mkdir() + (d / "config.json").write_text( + json.dumps({"model_type": "lfm2_vl", "architectures": ["Lfm2VlForConditionalGeneration"]}) + ) + assert get_transformers_tier(str(d)) == "530" + + # --- _name_or_path fallback --------------------------------------------- + + def test_renamed_folder_falls_back_to_hf_id_in_config(self, tmp_path: Path): + """A renamed local folder with an unrecognised model_type but a known + HF ID in _name_or_path still routes to the correct tier.""" + d = tmp_path / "my-custom-name" + d.mkdir() + # Simulate a future/unknown model_type; the HF ID carries the tier signal. + (d / "config.json").write_text( + json.dumps({ + "model_type": "future_unknown_type", + "_name_or_path": "Qwen/Qwen3.5-7B", + }) + ) + assert get_transformers_tier(str(d)) == "530" + + def test_hf_id_fallback_respects_550_tier(self, tmp_path: Path): + """_name_or_path pointing to a Gemma-4 HF ID routes to 550.""" + d = tmp_path / "renamed-gemma" + d.mkdir() + (d / "config.json").write_text( + json.dumps({ + "model_type": "future_unknown_type", + "_name_or_path": "google/gemma-4-E2B-it", + }) + ) + assert get_transformers_tier(str(d)) == "550" + + def test_hf_id_fallback_skipped_when_same_as_path(self, tmp_path: Path): + """If _name_or_path equals the model path, skip the name fallback to + avoid false positives from self-referencing configs.""" + d = tmp_path / "qwen3.5-experiment" + d.mkdir() + # _name_or_path is the local path itself (e.g. saved via save_pretrained) + (d / "config.json").write_text( + json.dumps({ + "model_type": "llama", + "_name_or_path": str(d), + }) + ) + with patch("utils.transformers_version._check_tokenizer_config_needs_v5", return_value=False): + # "qwen3.5" is in the path but config says llama and _name_or_path + # is self-referencing — must not be promoted to 530. + assert get_transformers_tier(str(d)) == "default" + + # --- false-positive guard ----------------------------------------------- def test_tier_local_plain_model_still_default(self, tmp_path: Path): - """A local non-5.x checkpoint still resolves default, so the - directory-name false-positive guard is preserved.""" - plain = tmp_path / "checkpoint-1000" - plain.mkdir() - (plain / "config.json").write_text( + """A local non-5.x checkpoint returns default; the directory-name + false-positive guard is preserved.""" + d = tmp_path / "checkpoint-1000" + d.mkdir() + (d / "config.json").write_text( json.dumps({"architectures": ["LlamaForCausalLM"], "model_type": "llama"}) ) with patch( "utils.transformers_version._check_tokenizer_config_needs_v5", return_value = False, ): - assert get_transformers_tier(str(plain)) == "default" + assert get_transformers_tier(str(d)) == "default" diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 4dee7432e9a..9bdcc97f298 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -104,13 +104,20 @@ def _env_offline() -> bool: } # Architecture classes / model_type values that require transformers 5.3.0. -# Checked via config.json so a local checkpoint whose directory name does not -# advertise the family (e.g. a renamed Qwen3.5 folder) is still routed correctly. +# Checked via config.json so local checkpoints with non-standard directory names +# are still routed to the correct sidecar. _TRANSFORMERS_530_ARCHITECTURES: set[str] = { - "Qwen3_5ForCausalLM", + "Qwen3_5ForCausalLM", # Qwen3.5 text-only variant + "Qwen3_5ForConditionalGeneration", # Qwen3.5 vision/conditional variant + "Qwen3MoeForCausalLM", # Qwen3-30B-A3B, Qwen3 MoE variants + "Glm4MoeLiteForCausalLM", # GLM-4.7-Flash + "Lfm2VlForConditionalGeneration", # LiquidAI LFM2.5-VL } _TRANSFORMERS_530_MODEL_TYPES: set[str] = { - "qwen3_5", + "qwen3_5", # Qwen3.5 family + "qwen3_moe", # Qwen3-30B-A3B and all Qwen3 MoE variants + "glm4_moe_lite", # GLM-4.7-Flash + "lfm2_vl", # LiquidAI LFM2.5-VL-450M } # Tokenizer classes that only exist in transformers>=5.x. @@ -458,6 +465,31 @@ def _check_config_needs_510(model_name: str) -> bool: return result +def _tier_from_name(name: str) -> tuple[str, str] | None: + """Return ``(tier, matched_reason)`` from name-based substring rules, or + ``None`` if nothing matches. + + Applies the same detection order used by :func:`get_transformers_tier`: + 510 before 550 before 530, with the Gemma-4 assistant special-case first. + Used both for direct model-name checks and as a fallback when a local + checkpoint's ``config.json`` architectures aren't yet enumerated in the + config sets. + """ + lowered = name.lower() + if "assistant" in lowered and ("gemma-4" in lowered or "gemma4" in lowered): + return "510", "gemma-4 assistant variant" + for s in TRANSFORMERS_510_MODEL_SUBSTRINGS: + if s in lowered: + return "510", s + for s in TRANSFORMERS_550_MODEL_SUBSTRINGS: + if s in lowered: + return "550", s + for s in TRANSFORMERS_5_MODEL_SUBSTRINGS: + if s in lowered: + return "530", s + return None + + def get_transformers_tier(model_name: str) -> str: """Return the transformers tier required for *model_name*. @@ -466,35 +498,63 @@ def get_transformers_tier(model_name: str) -> str: ``"530"`` for models needing transformers 5.3.0 (e.g. Ministral-3, Qwen3 MoE), or ``"default"`` for everything else (4.57.x). - Higher 5.x tiers run first. + Detection hierarchy (higher tiers checked first within each stage): + + 1. Local ``config.json`` architecture/model_type sets — highest confidence, + no I/O beyond reading the local file. + 2. HF model ID from ``config.json`` (``_name_or_path`` / ``model_name``) run + through the same substring rules as stage 3. Handles renamed local + checkpoints whose ``model_type`` isn't yet in the config sets, without + introducing false positives from parent directory name fragments. + 3. Local ``tokenizer_config.json`` tokenizer-class check. + 4. Fast name substring checks — no I/O, for remote HF IDs and local paths + without a ``config.json``. + 5. Slow remote ``config.json`` fetches (network) — last resort for HF IDs + that don't match any substring. """ - lowered = model_name.lower() - - # Local checkpoint names can contain architecture substrings in their - # directory names (for example a pytest temp dir). If config.json exists, - # trust it before using name heuristics. + # --- Local checkpoint path --- + # config.json acts as a positive-signal oracle: if it matches a known + # sidecar architecture, return immediately. If it doesn't, we fall back + # to the HF ID embedded in the config rather than the filesystem path, so + # renamed folders are handled correctly and parent-dir false-positives are + # avoided. local_cfg = Path(model_name) / "config.json" if local_cfg.is_file(): cfg = _load_config_json(model_name) - if cfg is not None and _config_needs_510(cfg): - logger.info( - "Transformers tier 510 selected for %s (local config.json check)", - model_name, - ) - return "510" - if cfg is not None and _config_needs_550(cfg): - logger.info( - "Transformers tier 550 selected for %s (local config.json check)", - model_name, - ) - return "550" - if cfg is not None and _config_needs_530(cfg): - logger.info( - "Transformers tier 530 selected for %s (local config.json check)", - model_name, - ) - return "530" if cfg is not None: + if _config_needs_510(cfg): + logger.info( + "Transformers tier 510 selected for %s (local config.json check)", + model_name, + ) + return "510" + if _config_needs_550(cfg): + logger.info( + "Transformers tier 550 selected for %s (local config.json check)", + model_name, + ) + return "550" + if _config_needs_530(cfg): + logger.info( + "Transformers tier 530 selected for %s (local config.json check)", + model_name, + ) + return "530" + # Architecture not in any config set — fall back to the HF model ID + # recorded in config.json rather than the filesystem path. + hf_id = cfg.get("model_name") or cfg.get("_name_or_path") or "" + if hf_id and hf_id != model_name: + result = _tier_from_name(hf_id) + if result is not None: + tier, match = result + logger.info( + "Transformers tier %s selected for %s " + "(config _name_or_path match: %s)", + tier, + model_name, + match, + ) + return tier local_tc = Path(model_name) / "tokenizer_config.json" if local_tc.is_file() and _check_tokenizer_config_needs_v5(model_name): logger.info( @@ -509,36 +569,16 @@ def get_transformers_tier(model_name: str) -> str: return "default" # --- Fast substring checks (no I/O) ------------------------------------ - if "assistant" in lowered and ("gemma-4" in lowered or "gemma4" in lowered): + result = _tier_from_name(model_name) + if result is not None: + tier, match = result logger.info( - "Transformers tier 510 selected for %s (gemma-4 assistant variant)", - model_name, - ) - return "510" - match = next((sub for sub in TRANSFORMERS_510_MODEL_SUBSTRINGS if sub in lowered), None) - if match is not None: - logger.info( - "Transformers tier 510 selected for %s (substring match: %s)", + "Transformers tier %s selected for %s (substring match: %s)", + tier, model_name, match, ) - return "510" - match = next((sub for sub in TRANSFORMERS_550_MODEL_SUBSTRINGS if sub in lowered), None) - if match is not None: - logger.info( - "Transformers tier 550 selected for %s (substring match: %s)", - model_name, - match, - ) - return "550" - match = next((sub for sub in TRANSFORMERS_5_MODEL_SUBSTRINGS if sub in lowered), None) - if match is not None: - logger.info( - "Transformers tier 530 selected for %s (substring match: %s)", - model_name, - match, - ) - return "530" + return tier # --- Slow config fallbacks (network for HF IDs) ------------------------ if _check_config_needs_510(model_name): From 9537dabfd0e310e6aed7cf0e816fc42440883d58 Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Tue, 16 Jun 2026 23:33:34 -0500 Subject: [PATCH 04/22] Studio: match 510/550 style for 530 config sets (no inline comments) --- studio/backend/utils/transformers_version.py | 21 ++++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 9bdcc97f298..8c1f2210428 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -104,20 +104,19 @@ def _env_offline() -> bool: } # Architecture classes / model_type values that require transformers 5.3.0. -# Checked via config.json so local checkpoints with non-standard directory names -# are still routed to the correct sidecar. +# Checked via config.json (local or HuggingFace). _TRANSFORMERS_530_ARCHITECTURES: set[str] = { - "Qwen3_5ForCausalLM", # Qwen3.5 text-only variant - "Qwen3_5ForConditionalGeneration", # Qwen3.5 vision/conditional variant - "Qwen3MoeForCausalLM", # Qwen3-30B-A3B, Qwen3 MoE variants - "Glm4MoeLiteForCausalLM", # GLM-4.7-Flash - "Lfm2VlForConditionalGeneration", # LiquidAI LFM2.5-VL + "Qwen3_5ForCausalLM", + "Qwen3_5ForConditionalGeneration", + "Qwen3MoeForCausalLM", + "Glm4MoeLiteForCausalLM", + "Lfm2VlForConditionalGeneration", } _TRANSFORMERS_530_MODEL_TYPES: set[str] = { - "qwen3_5", # Qwen3.5 family - "qwen3_moe", # Qwen3-30B-A3B and all Qwen3 MoE variants - "glm4_moe_lite", # GLM-4.7-Flash - "lfm2_vl", # LiquidAI LFM2.5-VL-450M + "qwen3_5", + "qwen3_moe", + "glm4_moe_lite", + "lfm2_vl", } # Tokenizer classes that only exist in transformers>=5.x. From fe6f7f7e69f1b0fa8b4945ebeb324b76a21024fd Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Tue, 16 Jun 2026 23:36:04 -0500 Subject: [PATCH 05/22] Studio: use _resolve_base_model instead of reinlining _name_or_path lookup --- studio/backend/utils/transformers_version.py | 29 +++++++------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 8c1f2210428..1ff232dc584 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -497,19 +497,8 @@ def get_transformers_tier(model_name: str) -> str: ``"530"`` for models needing transformers 5.3.0 (e.g. Ministral-3, Qwen3 MoE), or ``"default"`` for everything else (4.57.x). - Detection hierarchy (higher tiers checked first within each stage): - - 1. Local ``config.json`` architecture/model_type sets — highest confidence, - no I/O beyond reading the local file. - 2. HF model ID from ``config.json`` (``_name_or_path`` / ``model_name``) run - through the same substring rules as stage 3. Handles renamed local - checkpoints whose ``model_type`` isn't yet in the config sets, without - introducing false positives from parent directory name fragments. - 3. Local ``tokenizer_config.json`` tokenizer-class check. - 4. Fast name substring checks — no I/O, for remote HF IDs and local paths - without a ``config.json``. - 5. Slow remote ``config.json`` fetches (network) — last resort for HF IDs - that don't match any substring. + Higher 5.x tiers run first. For local paths, ``config.json`` is checked + before name heuristics to avoid false-positives from directory name fragments. """ # --- Local checkpoint path --- # config.json acts as a positive-signal oracle: if it matches a known @@ -539,16 +528,18 @@ def get_transformers_tier(model_name: str) -> str: model_name, ) return "530" - # Architecture not in any config set — fall back to the HF model ID - # recorded in config.json rather than the filesystem path. - hf_id = cfg.get("model_name") or cfg.get("_name_or_path") or "" - if hf_id and hf_id != model_name: - result = _tier_from_name(hf_id) + # Architecture not in any config set — resolve the base model name + # (reuses _resolve_base_model which reads _name_or_path/model_name) + # and run the same substring rules on that HF ID. Handles renamed + # local folders without false-positives from filesystem path fragments. + resolved = _resolve_base_model(model_name) + if resolved != model_name: + result = _tier_from_name(resolved) if result is not None: tier, match = result logger.info( "Transformers tier %s selected for %s " - "(config _name_or_path match: %s)", + "(resolved base model match: %s)", tier, model_name, match, From a5ee64baa18fa6de733530782c8f25540203df55 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:23:04 +0000 Subject: [PATCH 06/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../tests/test_transformers_version.py | 38 ++++++++++++------- studio/backend/utils/transformers_version.py | 3 +- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index 5e9cbb7a8cb..762a76f7522 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -910,7 +910,9 @@ def test_tier_local_lfm2_vl_config_selects_530(self, tmp_path: Path): d = tmp_path / "my-liquid-model" d.mkdir() (d / "config.json").write_text( - json.dumps({"model_type": "lfm2_vl", "architectures": ["Lfm2VlForConditionalGeneration"]}) + json.dumps( + {"model_type": "lfm2_vl", "architectures": ["Lfm2VlForConditionalGeneration"]} + ) ) assert get_transformers_tier(str(d)) == "530" @@ -923,10 +925,12 @@ def test_renamed_folder_falls_back_to_hf_id_in_config(self, tmp_path: Path): d.mkdir() # Simulate a future/unknown model_type; the HF ID carries the tier signal. (d / "config.json").write_text( - json.dumps({ - "model_type": "future_unknown_type", - "_name_or_path": "Qwen/Qwen3.5-7B", - }) + json.dumps( + { + "model_type": "future_unknown_type", + "_name_or_path": "Qwen/Qwen3.5-7B", + } + ) ) assert get_transformers_tier(str(d)) == "530" @@ -935,10 +939,12 @@ def test_hf_id_fallback_respects_550_tier(self, tmp_path: Path): d = tmp_path / "renamed-gemma" d.mkdir() (d / "config.json").write_text( - json.dumps({ - "model_type": "future_unknown_type", - "_name_or_path": "google/gemma-4-E2B-it", - }) + json.dumps( + { + "model_type": "future_unknown_type", + "_name_or_path": "google/gemma-4-E2B-it", + } + ) ) assert get_transformers_tier(str(d)) == "550" @@ -949,12 +955,16 @@ def test_hf_id_fallback_skipped_when_same_as_path(self, tmp_path: Path): d.mkdir() # _name_or_path is the local path itself (e.g. saved via save_pretrained) (d / "config.json").write_text( - json.dumps({ - "model_type": "llama", - "_name_or_path": str(d), - }) + json.dumps( + { + "model_type": "llama", + "_name_or_path": str(d), + } + ) ) - with patch("utils.transformers_version._check_tokenizer_config_needs_v5", return_value=False): + with patch( + "utils.transformers_version._check_tokenizer_config_needs_v5", return_value = False + ): # "qwen3.5" is in the path but config says llama and _name_or_path # is self-referencing — must not be promoted to 530. assert get_transformers_tier(str(d)) == "default" diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 1ff232dc584..a6e122f1d0f 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -538,8 +538,7 @@ def get_transformers_tier(model_name: str) -> str: if result is not None: tier, match = result logger.info( - "Transformers tier %s selected for %s " - "(resolved base model match: %s)", + "Transformers tier %s selected for %s (resolved base model match: %s)", tier, model_name, match, From 043709b69adb267c9184688df1d9f0fc7a4a0459 Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Wed, 17 Jun 2026 14:30:18 -0500 Subject: [PATCH 07/22] Studio: recurse into get_transformers_tier for resolved base model (Gemini suggestion) --- studio/backend/utils/transformers_version.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index a6e122f1d0f..96a4f6b8672 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -530,18 +530,16 @@ def get_transformers_tier(model_name: str) -> str: return "530" # Architecture not in any config set — resolve the base model name # (reuses _resolve_base_model which reads _name_or_path/model_name) - # and run the same substring rules on that HF ID. Handles renamed - # local folders without false-positives from filesystem path fragments. + # and run full tier detection on it so all detection paths apply. resolved = _resolve_base_model(model_name) if resolved != model_name: - result = _tier_from_name(resolved) - if result is not None: - tier, match = result + tier = get_transformers_tier(resolved) + if tier != "default": logger.info( - "Transformers tier %s selected for %s (resolved base model match: %s)", + "Transformers tier %s selected for %s (resolved base model: %s)", tier, model_name, - match, + resolved, ) return tier local_tc = Path(model_name) / "tokenizer_config.json" From ddaad842aa06d2df80bb544146faa3b5b6662c0e Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Thu, 18 Jun 2026 09:35:34 -0500 Subject: [PATCH 08/22] Studio: use _tier_from_name in local-config fallback to avoid network probes Using get_transformers_tier(resolved) on the _name_or_path fallback would trigger up to 3 network fetches (config.json + tokenizer_config.json, 10s each) for every ordinary checkpoint whose _name_or_path is a plain HF ID like meta-llama/Llama-3-8B. The fallback's purpose is name-based detection on the resolved HF ID, _tier_from_name covers all known cases without I/O. --- studio/backend/utils/transformers_version.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index f94fff9d6a7..129639aaa15 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -541,17 +541,21 @@ def get_transformers_tier(model_name: str) -> str: ) return "530" # Architecture not in any config set — resolve the base model name - # (reuses _resolve_base_model which reads _name_or_path/model_name) - # and run full tier detection on it so all detection paths apply. + # (_name_or_path / model_name in config) and run name-based detection. + # _tier_from_name is intentional here: using the full get_transformers_tier + # would trigger network probes (config.json + tokenizer_config.json fetches) + # for every ordinary checkpoint whose _name_or_path is a plain HF ID. resolved = _resolve_base_model(model_name) if resolved != model_name: - tier = get_transformers_tier(resolved) - if tier != "default": + result = _tier_from_name(resolved) + if result is not None: + tier, match = result logger.info( - "Transformers tier %s selected for %s (resolved base model: %s)", + "Transformers tier %s selected for %s (resolved base model: %s, match: %s)", tier, model_name, resolved, + match, ) return tier local_tc = Path(model_name) / "tokenizer_config.json" From fdc074fc7edd0c851af0038b0f7099f15be3cca2 Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Thu, 18 Jun 2026 09:37:41 -0500 Subject: [PATCH 09/22] Studio: add _check_config_needs_530 to slow HF-ID fallback path Private or renamed HF repos whose model IDs lack a 5.3 substring were silently routed to the default tier. _check_config_needs_530 mirrors the existing 510/550 pattern: fetches config.json once, caches the result, and is called after the 550 check in the slow path. Includes 5 unit tests. --- .../tests/test_transformers_version.py | 51 +++++++++++++++++++ studio/backend/utils/transformers_version.py | 31 +++++++++++ 2 files changed, 82 insertions(+) diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index 762a76f7522..866c2480d16 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -33,12 +33,14 @@ _resolve_base_model, _check_tokenizer_config_needs_v5, _check_config_needs_510, + _check_config_needs_530, _check_config_needs_550, _config_needs_530, _tier_from_name, _config_json_cache, _tokenizer_class_cache, _config_needs_510_cache, + _config_needs_530_cache, _config_needs_550_cache, needs_transformers_5, get_transformers_tier, @@ -858,6 +860,7 @@ class TestLocalConfig530Tier: def setup_method(self): _config_json_cache.clear() _tokenizer_class_cache.clear() + _config_needs_530_cache.clear() # --- config-set matches ------------------------------------------------- @@ -984,3 +987,51 @@ def test_tier_local_plain_model_still_default(self, tmp_path: Path): return_value = False, ): assert get_transformers_tier(str(d)) == "default" + + +# --------------------------------------------------------------------------- +# _check_config_needs_530 — slow HF-ID path (network stub) +# --------------------------------------------------------------------------- + + +class TestCheckConfigNeeds530: + """_check_config_needs_530 is used in the slow HF-ID fallback path for + private or renamed repos whose names don't contain a 5.3 substring.""" + + def setup_method(self): + _config_json_cache.clear() + _config_needs_530_cache.clear() + + def test_returns_true_for_qwen3_5_model_type(self): + with patch( + "utils.transformers_version._load_config_json", + return_value = {"model_type": "qwen3_5"}, + ): + assert _check_config_needs_530("some-private/qwen3.5-variant") is True + + def test_returns_true_for_qwen3_moe_architecture(self): + with patch( + "utils.transformers_version._load_config_json", + return_value = {"architectures": ["Qwen3MoeForCausalLM"]}, + ): + assert _check_config_needs_530("org/private-moe-model") is True + + def test_returns_false_for_llama(self): + with patch( + "utils.transformers_version._load_config_json", + return_value = {"model_type": "llama", "architectures": ["LlamaForCausalLM"]}, + ): + assert _check_config_needs_530("meta-llama/Llama-3-8B") is False + + def test_returns_false_when_config_unavailable(self): + with patch("utils.transformers_version._load_config_json", return_value = None): + assert _check_config_needs_530("org/unreachable-model") is False + + def test_result_is_cached(self): + with patch( + "utils.transformers_version._load_config_json", + return_value = {"model_type": "qwen3_5"}, + ) as mock_load: + _check_config_needs_530("cached-model") + _check_config_needs_530("cached-model") + assert mock_load.call_count == 1 diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 129639aaa15..060e6031cc7 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -131,6 +131,7 @@ def _env_offline() -> bool: _config_json_cache: dict[tuple[str, str | None], dict | None] = {} _config_needs_510_cache: dict[str, bool] = {} _config_needs_550_cache: dict[str, bool] = {} +_config_needs_530_cache: dict[str, bool] = {} # Versions TRANSFORMERS_510_VERSION = "5.10.2" @@ -453,6 +454,33 @@ def _check_config_needs_550(model_name: str) -> bool: return result +def _check_config_needs_530(model_name: str) -> bool: + """Check ``config.json`` for 5.3.0-only architectures (Qwen3.5, Qwen3 MoE, GLM-4.7, LFM2.5-VL). + + Used in the slow HF-ID path for private/renamed repos where name substrings + aren't reliable. + """ + if model_name in _config_needs_530_cache: + return _config_needs_530_cache[model_name] + + cfg = _load_config_json(model_name) + if cfg is None: + _config_needs_530_cache[model_name] = False + return False + + result = _config_needs_530(cfg) + if result: + logger.info( + "config.json check: %s needs transformers %s (architectures=%s, model_type=%s)", + model_name, + TRANSFORMERS_530_VERSION, + cfg.get("architectures", []), + cfg.get("model_type"), + ) + _config_needs_530_cache[model_name] = result + return result + + def _check_config_needs_510(model_name: str) -> bool: """Check ``config.json`` for Gemma 4 Unified / 12B architectures.""" if model_name in _config_needs_510_cache: @@ -590,6 +618,9 @@ def get_transformers_tier(model_name: str) -> str: if _check_config_needs_550(model_name): logger.info("Transformers tier 550 selected for %s (config.json check)", model_name) return "550" + if _check_config_needs_530(model_name): + logger.info("Transformers tier 530 selected for %s (config.json check)", model_name) + return "530" if _check_tokenizer_config_needs_v5(model_name): logger.info( "Transformers tier 530 selected for %s (tokenizer_config.json check)", From 9ff182c68e4574f05b0089aac87315de6bd92ab7 Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Thu, 18 Jun 2026 09:46:35 -0500 Subject: [PATCH 10/22] Studio: guard _tier_from_name fallback against local-path false positives When _name_or_path in config.json is an absolute path to the same checkpoint passed as a relative path, the textual resolved != model_name check passes and _tier_from_name would scan the directory path for substrings. Split the fallback: local directories recurse into get_transformers_tier (config check, no network I/O); HF Hub IDs use _tier_from_name (name-based, no network). --- .../tests/test_transformers_version.py | 24 +++++++++++ studio/backend/utils/transformers_version.py | 43 ++++++++++++------- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index 866c2480d16..541d6d5f68a 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -972,6 +972,30 @@ def test_hf_id_fallback_skipped_when_same_as_path(self, tmp_path: Path): # is self-referencing — must not be promoted to 530. assert get_transformers_tier(str(d)) == "default" + def test_hf_id_fallback_not_triggered_when_name_or_path_is_absolute_self( + self, tmp_path: Path + ): + """_name_or_path == absolute path of the same checkpoint while model_name + is a relative path: the two strings differ, but both point to the same + directory. The absolute path must not be scanned for tier substrings.""" + d = tmp_path / "qwen3.5-experiment" + d.mkdir() + (d / "config.json").write_text( + json.dumps( + { + "model_type": "llama", + # absolute path — textually different from a relative model_name + "_name_or_path": str(d), + } + ) + ) + with patch( + "utils.transformers_version._check_tokenizer_config_needs_v5", return_value = False + ): + # Even though str(d) contains "qwen3.5", the local-dir branch recurses + # into config checks on the resolved path, which returns default. + assert get_transformers_tier(str(d)) == "default" + # --- false-positive guard ----------------------------------------------- def test_tier_local_plain_model_still_default(self, tmp_path: Path): diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 060e6031cc7..d2d0d48ad82 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -569,23 +569,36 @@ def get_transformers_tier(model_name: str) -> str: ) return "530" # Architecture not in any config set — resolve the base model name - # (_name_or_path / model_name in config) and run name-based detection. - # _tier_from_name is intentional here: using the full get_transformers_tier - # would trigger network probes (config.json + tokenizer_config.json fetches) - # for every ordinary checkpoint whose _name_or_path is a plain HF ID. + # (_name_or_path / model_name in config) and detect tier from it. + # Split on whether the resolved value is a local path or a HF Hub ID: + # local dir → recurse (config.json check, no network I/O) so a + # self-referencing absolute path doesn't false-positive + # on directory-name substrings. + # HF Hub ID → _tier_from_name only (no network probes). resolved = _resolve_base_model(model_name) if resolved != model_name: - result = _tier_from_name(resolved) - if result is not None: - tier, match = result - logger.info( - "Transformers tier %s selected for %s (resolved base model: %s, match: %s)", - tier, - model_name, - resolved, - match, - ) - return tier + if Path(resolved).is_dir(): + tier = get_transformers_tier(resolved) + if tier != "default": + logger.info( + "Transformers tier %s selected for %s (resolved local path: %s)", + tier, + model_name, + resolved, + ) + return tier + else: + result = _tier_from_name(resolved) + if result is not None: + tier, match = result + logger.info( + "Transformers tier %s selected for %s (resolved HF ID: %s, match: %s)", + tier, + model_name, + resolved, + match, + ) + return tier local_tc = Path(model_name) / "tokenizer_config.json" if local_tc.is_file() and _check_tokenizer_config_needs_v5(model_name): logger.info( From 29952331f4a2079fcc0796bc54319bfe3f1c369c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:47:23 +0000 Subject: [PATCH 11/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/tests/test_transformers_version.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index 541d6d5f68a..6904f997717 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -972,9 +972,7 @@ def test_hf_id_fallback_skipped_when_same_as_path(self, tmp_path: Path): # is self-referencing — must not be promoted to 530. assert get_transformers_tier(str(d)) == "default" - def test_hf_id_fallback_not_triggered_when_name_or_path_is_absolute_self( - self, tmp_path: Path - ): + def test_hf_id_fallback_not_triggered_when_name_or_path_is_absolute_self(self, tmp_path: Path): """_name_or_path == absolute path of the same checkpoint while model_name is a relative path: the two strings differ, but both point to the same directory. The absolute path must not be scanned for tier substrings.""" From e7ffb2a5e7ce2168700a0afaae8b897503faf0ad Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Fri, 19 Jun 2026 07:07:55 -0500 Subject: [PATCH 12/22] Studio: separator-norm aliases, model_name/_name_or_path fallback, tests - _norm_separators(): collapse _ . whitespace to - so underscore/dot model ID variants (Qwen3_5, Qwen3_Next) match the canonical substring list - _tier_from_name(): apply norm to both name and each substring so aliases resolve without duplicating the substring lists - _resolve_base_model(): try model_name then _name_or_path separately so a self-referential Unsloth model_name doesn't hide the useful HF ID in _name_or_path - Gate get_base_model_from_lora on adapter_cfg_path.is_file() to avoid eagerly importing transformers before the sidecar venv is on sys.path - 17 new tests covering _norm_separators, separator-insensitive _tier_from_name, and the model_name/_name_or_path fallback --- .../tests/test_transformers_version.py | 106 ++++++++++++++++++ studio/backend/utils/transformers_version.py | 62 ++++++---- 2 files changed, 147 insertions(+), 21 deletions(-) diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index 6904f997717..2c08f106a02 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -36,6 +36,7 @@ _check_config_needs_530, _check_config_needs_550, _config_needs_530, + _norm_separators, _tier_from_name, _config_json_cache, _tokenizer_class_cache, @@ -1057,3 +1058,108 @@ def test_result_is_cached(self): _check_config_needs_530("cached-model") _check_config_needs_530("cached-model") assert mock_load.call_count == 1 + + +# --------------------------------------------------------------------------- +# _norm_separators +# --------------------------------------------------------------------------- + + +class TestNormSeparators: + def test_underscore_to_hyphen(self): + assert _norm_separators("qwen3_5") == "qwen3-5" + + def test_dot_to_hyphen(self): + assert _norm_separators("qwen3.5") == "qwen3-5" + + def test_hyphen_unchanged(self): + assert _norm_separators("gemma-4") == "gemma-4" + + def test_mixed(self): + assert _norm_separators("Qwen3_5.MoE") == "Qwen3-5-MoE" + + def test_whitespace_to_hyphen(self): + assert _norm_separators("some model") == "some-model" + + def test_empty(self): + assert _norm_separators("") == "" + + +# --------------------------------------------------------------------------- +# _tier_from_name — separator-insensitive matching +# --------------------------------------------------------------------------- + + +class TestTierFromNameSeparatorNorm: + """Verify that underscore/dot aliases in model IDs resolve to the same + tier as their canonical hyphen/dot counterparts.""" + + def test_qwen3_underscore_5_returns_530(self): + tier, _ = _tier_from_name("Qwen/Qwen3_5-7B") + assert tier == "530" + + def test_qwen3_next_underscore_returns_530(self): + tier, _ = _tier_from_name("org/Qwen3_Next-14B") + assert tier == "530" + + def test_gemma_4_underscore_returns_550(self): + tier, _ = _tier_from_name("google/gemma_4_E2B_it") + assert tier == "550" + + def test_gemma_4_12b_underscore_returns_510(self): + tier, _ = _tier_from_name("unsloth/gemma_4_12b_it") + assert tier == "510" + + def test_canonical_dot_still_works(self): + tier, _ = _tier_from_name("Qwen/Qwen3.5-7B") + assert tier == "530" + + def test_unrelated_underscores_not_promoted(self): + assert _tier_from_name("meta_llama/Llama_3_8B") is None + + +# --------------------------------------------------------------------------- +# _resolve_base_model — model_name-then-_name_or_path fallback +# --------------------------------------------------------------------------- + + +class TestResolveBaseModelNameOrPathFallback: + """When config.json has both 'model_name' (self-referential local path) and + '_name_or_path' (original HF ID), _resolve_base_model must use _name_or_path.""" + + def test_name_or_path_used_when_model_name_is_self_ref(self, tmp_path: Path): + d = tmp_path / "my-qwen35-finetune" + d.mkdir() + (d / "config.json").write_text( + json.dumps({ + "model_name": str(d), + "_name_or_path": "Qwen/Qwen3.5-7B", + }) + ) + assert _resolve_base_model(str(d)) == "Qwen/Qwen3.5-7B" + + def test_model_name_used_when_not_self_ref(self, tmp_path: Path): + d = tmp_path / "adapter" + d.mkdir() + (d / "config.json").write_text( + json.dumps({ + "model_name": "unsloth/Qwen3.5-7B-bnb-4bit", + "_name_or_path": "Qwen/Qwen3.5-7B", + }) + ) + # model_name is not the local path, so it wins + assert _resolve_base_model(str(d)) == "unsloth/Qwen3.5-7B-bnb-4bit" + + def test_tier_resolved_via_name_or_path_when_model_name_self_refs(self, tmp_path: Path): + """End-to-end: get_transformers_tier picks up the sidecar tier from + _name_or_path even when model_name is set to the checkpoint's own path.""" + d = tmp_path / "my-custom-finetune" + d.mkdir() + (d / "config.json").write_text( + json.dumps({ + "model_type": "future_unknown_type", + "model_name": str(d), + "_name_or_path": "Qwen/Qwen3.5-7B", + }) + ) + assert get_transformers_tier(str(d)) == "530" diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index d2d0d48ad82..0b32da24394 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -251,20 +251,30 @@ def _resolve_base_model(model_name: str) -> str: try: with open(config_json_path) as f: cfg = json.load(f) - # Unsloth writes "model_name"; HF writes "_name_or_path" - base = cfg.get("model_name") or cfg.get("_name_or_path") - if base and base != str(local_path): - logger.info( - "Resolved checkpoint '%s' → base model '%s' (via config.json)", - model_name, - base, - ) - return base + # Unsloth writes "model_name"; HF writes "_name_or_path". Try both: + # if "model_name" is self-referential (equals the local path), the + # useful base id may still live in "_name_or_path". + for _key in ("model_name", "_name_or_path"): + base = cfg.get(_key) + if base and base != str(local_path): + logger.info( + "Resolved checkpoint '%s' → base model '%s' (via config.json)", + model_name, + base, + ) + return base except Exception as exc: logger.debug("Could not read %s: %s", config_json_path, exc) - # --- Only try the heavier fallback for local directories ---------------- - if local_path.is_dir(): + # --- Only try the heavier fallback for genuine LoRA adapters ------------ + # ``get_base_model_from_lora`` returns None for anything that isn't a LoRA + # adapter, so the only effect of taking this branch for a full checkpoint is + # the side effect of importing ``utils.models``, which eagerly imports + # ``transformers``. During subprocess activation that pins the *default* + # transformers into ``sys.modules`` BEFORE the correct sidecar venv is + # prepended to ``sys.path``, so the worker then loads the wrong version. + # Gate on a real adapter_config.json to keep activation import-clean. + if adapter_cfg_path.is_file(): try: from utils.models import get_base_model_from_lora base = get_base_model_from_lora(model_name) @@ -504,6 +514,12 @@ def _check_config_needs_510(model_name: str) -> bool: return result +def _norm_separators(s: str) -> str: + """Collapse ``_``, ``.`` and whitespace to ``-`` so underscore/dot aliases + (e.g. ``Qwen3_5``, ``Qwen3_Next``) match the canonical hyphen/dot substrings.""" + return "".join("-" if ch in "_. \t" else ch for ch in s) + + def _tier_from_name(name: str) -> tuple[str, str] | None: """Return ``(tier, matched_reason)`` from name-based substring rules, or ``None`` if nothing matches. @@ -513,19 +529,23 @@ def _tier_from_name(name: str) -> tuple[str, str] | None: Used both for direct model-name checks and as a fallback when a local checkpoint's ``config.json`` architectures aren't yet enumerated in the config sets. + + Matching is separator-insensitive: a name is matched both verbatim and with + ``_ . whitespace`` collapsed to ``-``, so ``Qwen3_5-MoE`` resolves the same + as ``Qwen3.5-MoE``. """ lowered = name.lower() - if "assistant" in lowered and ("gemma-4" in lowered or "gemma4" in lowered): + norm = _norm_separators(lowered) + if "assistant" in lowered and ("gemma-4" in norm or "gemma4" in norm): return "510", "gemma-4 assistant variant" - for s in TRANSFORMERS_510_MODEL_SUBSTRINGS: - if s in lowered: - return "510", s - for s in TRANSFORMERS_550_MODEL_SUBSTRINGS: - if s in lowered: - return "550", s - for s in TRANSFORMERS_5_MODEL_SUBSTRINGS: - if s in lowered: - return "530", s + for substrings, tier in ( + (TRANSFORMERS_510_MODEL_SUBSTRINGS, "510"), + (TRANSFORMERS_550_MODEL_SUBSTRINGS, "550"), + (TRANSFORMERS_5_MODEL_SUBSTRINGS, "530"), + ): + for s in substrings: + if s in lowered or _norm_separators(s) in norm: + return tier, s return None From da225a669a90dcc16565a76c2b055beb580129b0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:08:32 +0000 Subject: [PATCH 13/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../tests/test_transformers_version.py | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index 2c08f106a02..65e132839a2 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -1131,10 +1131,12 @@ def test_name_or_path_used_when_model_name_is_self_ref(self, tmp_path: Path): d = tmp_path / "my-qwen35-finetune" d.mkdir() (d / "config.json").write_text( - json.dumps({ - "model_name": str(d), - "_name_or_path": "Qwen/Qwen3.5-7B", - }) + json.dumps( + { + "model_name": str(d), + "_name_or_path": "Qwen/Qwen3.5-7B", + } + ) ) assert _resolve_base_model(str(d)) == "Qwen/Qwen3.5-7B" @@ -1142,10 +1144,12 @@ def test_model_name_used_when_not_self_ref(self, tmp_path: Path): d = tmp_path / "adapter" d.mkdir() (d / "config.json").write_text( - json.dumps({ - "model_name": "unsloth/Qwen3.5-7B-bnb-4bit", - "_name_or_path": "Qwen/Qwen3.5-7B", - }) + json.dumps( + { + "model_name": "unsloth/Qwen3.5-7B-bnb-4bit", + "_name_or_path": "Qwen/Qwen3.5-7B", + } + ) ) # model_name is not the local path, so it wins assert _resolve_base_model(str(d)) == "unsloth/Qwen3.5-7B-bnb-4bit" @@ -1156,10 +1160,12 @@ def test_tier_resolved_via_name_or_path_when_model_name_self_refs(self, tmp_path d = tmp_path / "my-custom-finetune" d.mkdir() (d / "config.json").write_text( - json.dumps({ - "model_type": "future_unknown_type", - "model_name": str(d), - "_name_or_path": "Qwen/Qwen3.5-7B", - }) + json.dumps( + { + "model_type": "future_unknown_type", + "model_name": str(d), + "_name_or_path": "Qwen/Qwen3.5-7B", + } + ) ) assert get_transformers_tier(str(d)) == "530" From cba9d1944a5fcbf37c32fa705a1e0bd9617e7384 Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Fri, 19 Jun 2026 07:23:17 -0500 Subject: [PATCH 14/22] Studio: only pre-resolve LoRA adapters in activation callers activate_transformers_for_subprocess and ensure_transformers_version were pre-resolving all local checkpoints via _resolve_base_model before calling get_transformers_tier. After the model_name/_name_or_path fix, a full checkpoint with a private/offline _name_or_path and no tier substring would resolve to that HF ID, which can't be probed, bypassing the local config.json model_type check entirely. Gate pre-resolution on adapter_config.json so full checkpoints go straight to get_transformers_tier, which reads config.json directly. LoRA adapters still pre-resolve as before. --- .../tests/test_transformers_version.py | 21 +++++++++++++++++++ studio/backend/utils/transformers_version.py | 16 +++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index 65e132839a2..024771f734f 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -1169,3 +1169,24 @@ def test_tier_resolved_via_name_or_path_when_model_name_self_refs(self, tmp_path ) ) assert get_transformers_tier(str(d)) == "530" + + def test_local_config_tier_not_bypassed_by_private_name_or_path(self, tmp_path: Path): + """Full checkpoint with model_type: qwen3_5 must still route to 530 even + when _name_or_path is a private HF ID with no recognisable tier substring. + + Regression guard: before the adapter-only pre-resolve fix, + activate_transformers_for_subprocess would resolve to the private HF ID + and then fail to probe it offline, returning default instead of 530. + """ + d = tmp_path / "my-finetuned-model" + d.mkdir() + (d / "config.json").write_text( + json.dumps({ + "model_type": "qwen3_5", + "model_name": str(d), + "_name_or_path": "my-org/private-custom-id", + }) + ) + # get_transformers_tier reads config.json directly and returns 530 + # without needing to probe the private HF ID. + assert get_transformers_tier(str(d)) == "530" diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 0b32da24394..38be897c263 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -161,7 +161,14 @@ def activate_transformers_for_subprocess(model_name: str) -> None: ``sys.path``, and propagates it via ``PYTHONPATH`` for child processes (e.g. GGUF converter). Used by training, inference, and export workers. """ - resolved = _resolve_base_model(model_name) + # Only pre-resolve for LoRA adapters (adapter_config.json present). + # Full checkpoints go directly to get_transformers_tier, which reads + # their local config.json for model_type — more reliable than resolving + # to a private/offline HF ID that can't be probed and may lack tier substrings. + if (Path(model_name) / "adapter_config.json").is_file(): + resolved = _resolve_base_model(model_name) + else: + resolved = model_name tier = get_transformers_tier(resolved) if tier == "510": @@ -940,8 +947,11 @@ def ensure_transformers_version(model_name: str) -> None: NOTE: Training and inference use subprocess isolation instead. Used only by the export path (routes/export.py). """ - # Resolve LoRA adapters to their base model for accurate detection. - resolved = _resolve_base_model(model_name) + # Only pre-resolve for LoRA adapters; see activate_transformers_for_subprocess. + if (Path(model_name) / "adapter_config.json").is_file(): + resolved = _resolve_base_model(model_name) + else: + resolved = model_name tier = get_transformers_tier(resolved) if tier == "510": From d5a841055021a525c91407e3938fe42441f3f9d8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:24:03 +0000 Subject: [PATCH 15/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/tests/test_transformers_version.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index 024771f734f..bef1fce9bfa 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -1181,11 +1181,13 @@ def test_local_config_tier_not_bypassed_by_private_name_or_path(self, tmp_path: d = tmp_path / "my-finetuned-model" d.mkdir() (d / "config.json").write_text( - json.dumps({ - "model_type": "qwen3_5", - "model_name": str(d), - "_name_or_path": "my-org/private-custom-id", - }) + json.dumps( + { + "model_type": "qwen3_5", + "model_name": str(d), + "_name_or_path": "my-org/private-custom-id", + } + ) ) # get_transformers_tier reads config.json directly and returns 530 # without needing to probe the private HF ID. From cf72db57b7993a9a87fe8919410a01be75330e41 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sat, 20 Jun 2026 10:55:41 +0000 Subject: [PATCH 16/22] Studio: fix Qwen3.5 MoE/Qwen3.6 tier detection and dot-version false positives - Add Qwen3.5 MoE (qwen3_5_moe / Qwen3_5MoeForConditionalGeneration) and Qwen3-Next to the 5.3.0 config sets, so renamed local checkpoints route to the sidecar instead of default transformers - Let a 510/550 name match override a 530 config match, so Qwen3.6 (which reuses qwen3_5 / qwen3_5_moe config ids) still routes to the 5.5.0 sidecar - Stop normalizing version dots to hyphens so size names like Qwen3-5B and Qwen3-6B are not promoted to a 5.x sidecar; underscore aliases still match - Skip name matching for resolved values that look like stale local paths --- .../tests/test_transformers_version.py | 74 ++++++++++++++++++- studio/backend/utils/transformers_version.py | 46 ++++++++++-- 2 files changed, 109 insertions(+), 11 deletions(-) diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index bef1fce9bfa..7b5f63bcc87 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -880,6 +880,17 @@ def test_config_needs_530_glm4_moe_lite(self): def test_config_needs_530_lfm2_vl(self): assert _config_needs_530({"model_type": "lfm2_vl"}) is True + def test_config_needs_530_qwen3_5_moe(self): + """Qwen3.5 MoE (Qwen3.5-35B-A3B / 122B-A10B) uses qwen3_5_moe ids.""" + assert _config_needs_530( + {"model_type": "qwen3_5_moe", "architectures": ["Qwen3_5MoeForConditionalGeneration"]} + ) is True + + def test_config_needs_530_qwen3_next(self): + assert _config_needs_530( + {"model_type": "qwen3_next", "architectures": ["Qwen3NextForCausalLM"]} + ) is True + def test_config_needs_530_plain_qwen3_is_false(self): """Regular Qwen3 (non-MoE, non-3.5) must not be promoted to 5.3.0.""" assert _config_needs_530({"model_type": "qwen3"}) is False @@ -920,6 +931,56 @@ def test_tier_local_lfm2_vl_config_selects_530(self, tmp_path: Path): ) assert get_transformers_tier(str(d)) == "530" + def test_tier_local_qwen35_moe_config_selects_530(self, tmp_path: Path): + """A renamed Qwen3.5 MoE folder (no name hint) routes to 530 via config.""" + d = tmp_path / "my-custom-moe" + d.mkdir() + (d / "config.json").write_text( + json.dumps( + {"model_type": "qwen3_5_moe", "architectures": ["Qwen3_5MoeForConditionalGeneration"]} + ) + ) + assert get_transformers_tier(str(d)) == "530" + + # --- Qwen3.6 reuses Qwen3.5 config ids but routes to 550 by name --------- + + def test_local_qwen36_config_keeps_550_name_tier(self, tmp_path: Path): + """Qwen3.6 config carries qwen3_5 ids; a higher-tier name match wins.""" + d = tmp_path / "Qwen3.6-27B" + d.mkdir() + (d / "config.json").write_text( + json.dumps({"model_type": "qwen3_5", "architectures": ["Qwen3_5ForConditionalGeneration"]}) + ) + assert get_transformers_tier(str(d)) == "550" + + def test_local_qwen36_moe_via_name_or_path_keeps_550(self, tmp_path: Path): + """Renamed Qwen3.6 MoE folder: _name_or_path carries the 5.5 name signal.""" + d = tmp_path / "renamed-q36-moe" + d.mkdir() + (d / "config.json").write_text( + json.dumps( + { + "model_type": "qwen3_5_moe", + "architectures": ["Qwen3_5MoeForConditionalGeneration"], + "_name_or_path": "Qwen/Qwen3.6-35B-A3B", + } + ) + ) + assert get_transformers_tier(str(d)) == "550" + + def test_stale_absolute_name_or_path_not_promoted(self, tmp_path: Path): + """A non-5.x checkpoint whose _name_or_path is a stale absolute path + containing a 5.x substring must not be name-matched into a sidecar.""" + d = tmp_path / "my-llama-ckpt" + d.mkdir() + (d / "config.json").write_text( + json.dumps({"model_type": "llama", "_name_or_path": "/old/run/qwen3.5-source"}) + ) + with patch( + "utils.transformers_version._check_tokenizer_config_needs_v5", return_value = False + ): + assert get_transformers_tier(str(d)) == "default" + # --- _name_or_path fallback --------------------------------------------- def test_renamed_folder_falls_back_to_hf_id_in_config(self, tmp_path: Path): @@ -1069,14 +1130,14 @@ class TestNormSeparators: def test_underscore_to_hyphen(self): assert _norm_separators("qwen3_5") == "qwen3-5" - def test_dot_to_hyphen(self): - assert _norm_separators("qwen3.5") == "qwen3-5" + def test_dot_preserved(self): + assert _norm_separators("qwen3.5") == "qwen3.5" def test_hyphen_unchanged(self): assert _norm_separators("gemma-4") == "gemma-4" def test_mixed(self): - assert _norm_separators("Qwen3_5.MoE") == "Qwen3-5-MoE" + assert _norm_separators("Qwen3_5.MoE") == "Qwen3-5.MoE" def test_whitespace_to_hyphen(self): assert _norm_separators("some model") == "some-model" @@ -1117,6 +1178,13 @@ def test_canonical_dot_still_works(self): def test_unrelated_underscores_not_promoted(self): assert _tier_from_name("meta_llama/Llama_3_8B") is None + def test_qwen3_hyphen_6_size_not_promoted(self): + """Qwen3-6B is a size name, not the qwen3.6 release line.""" + assert _tier_from_name("Qwen/Qwen3-6B-Instruct") is None + + def test_qwen3_hyphen_5_size_not_promoted(self): + assert _tier_from_name("Qwen/Qwen3-5B") is None + # --------------------------------------------------------------------------- # _resolve_base_model — model_name-then-_name_or_path fallback diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 38be897c263..429e490e185 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -108,13 +108,18 @@ def _env_offline() -> bool: _TRANSFORMERS_530_ARCHITECTURES: set[str] = { "Qwen3_5ForCausalLM", "Qwen3_5ForConditionalGeneration", + "Qwen3_5MoeForCausalLM", # Qwen3.5 MoE (e.g. Qwen3.5-35B-A3B / 122B-A10B) + "Qwen3_5MoeForConditionalGeneration", "Qwen3MoeForCausalLM", + "Qwen3NextForCausalLM", # Qwen3-Next "Glm4MoeLiteForCausalLM", "Lfm2VlForConditionalGeneration", } _TRANSFORMERS_530_MODEL_TYPES: set[str] = { "qwen3_5", + "qwen3_5_moe", "qwen3_moe", + "qwen3_next", "glm4_moe_lite", "lfm2_vl", } @@ -522,9 +527,19 @@ def _check_config_needs_510(model_name: str) -> bool: def _norm_separators(s: str) -> str: - """Collapse ``_``, ``.`` and whitespace to ``-`` so underscore/dot aliases - (e.g. ``Qwen3_5``, ``Qwen3_Next``) match the canonical hyphen/dot substrings.""" - return "".join("-" if ch in "_. \t" else ch for ch in s) + """Collapse ``_`` and whitespace to ``-`` so underscore aliases (e.g. + ``Qwen3_Next``) match the canonical hyphen substrings. ``.`` is left intact: + version dots (``qwen3.5``) must not be conflated with size separators + (``Qwen3-5B`` / ``Qwen3-6B``).""" + return "".join("-" if ch in "_ \t" else ch for ch in s) + + +def _looks_like_hf_id(value: str) -> bool: + """True if *value* looks like a Hub id (``org/name``) rather than a local + filesystem path, so a stale/renamed checkpoint path isn't name-matched.""" + if os.path.isabs(value) or value.startswith((".", "~")) or "\\" in value: + return False + return value.count("/") <= 1 def _tier_from_name(name: str) -> tuple[str, str] | None: @@ -537,12 +552,13 @@ def _tier_from_name(name: str) -> tuple[str, str] | None: checkpoint's ``config.json`` architectures aren't yet enumerated in the config sets. - Matching is separator-insensitive: a name is matched both verbatim and with - ``_ . whitespace`` collapsed to ``-``, so ``Qwen3_5-MoE`` resolves the same - as ``Qwen3.5-MoE``. + Underscore aliases match (``Qwen3_5`` == ``Qwen3.5``), but a dot-version + substring (``qwen3.5``/``qwen3.6``) matches only the dot or underscore form, + never a hyphen, so ``Qwen3-5B``/``Qwen3-6B`` size names aren't promoted. """ lowered = name.lower() norm = _norm_separators(lowered) + dotted = lowered.replace("_", ".") if "assistant" in lowered and ("gemma-4" in norm or "gemma4" in norm): return "510", "gemma-4 assistant variant" for substrings, tier in ( @@ -551,7 +567,10 @@ def _tier_from_name(name: str) -> tuple[str, str] | None: (TRANSFORMERS_5_MODEL_SUBSTRINGS, "530"), ): for s in substrings: - if s in lowered or _norm_separators(s) in norm: + if "." in s: + if s in lowered or s in dotted: + return tier, s + elif s in lowered or _norm_separators(s) in norm: return tier, s return None @@ -590,6 +609,17 @@ def get_transformers_tier(model_name: str) -> str: ) return "550" if _config_needs_530(cfg): + # Qwen3.6 reuses Qwen3.5 config ids (qwen3_5 / qwen3_5_moe) but is + # a 5.5 model by name; let a higher-tier name match override 530. + base = _resolve_base_model(model_name) + hint = _tier_from_name(base if base != model_name else Path(model_name).name) + if hint is not None and hint[0] in ("510", "550"): + logger.info( + "Transformers tier %s selected for %s (name overrides 530 config)", + hint[0], + model_name, + ) + return hint[0] logger.info( "Transformers tier 530 selected for %s (local config.json check)", model_name, @@ -614,7 +644,7 @@ def get_transformers_tier(model_name: str) -> str: resolved, ) return tier - else: + elif _looks_like_hf_id(resolved): result = _tier_from_name(resolved) if result is not None: tier, match = result From 99b8d7f0bfc9e80ee331705553fa88318c401e92 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:56:44 +0000 Subject: [PATCH 17/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../tests/test_transformers_version.py | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index 7b5f63bcc87..21c05a2d212 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -882,14 +882,23 @@ def test_config_needs_530_lfm2_vl(self): def test_config_needs_530_qwen3_5_moe(self): """Qwen3.5 MoE (Qwen3.5-35B-A3B / 122B-A10B) uses qwen3_5_moe ids.""" - assert _config_needs_530( - {"model_type": "qwen3_5_moe", "architectures": ["Qwen3_5MoeForConditionalGeneration"]} - ) is True + assert ( + _config_needs_530( + { + "model_type": "qwen3_5_moe", + "architectures": ["Qwen3_5MoeForConditionalGeneration"], + } + ) + is True + ) def test_config_needs_530_qwen3_next(self): - assert _config_needs_530( - {"model_type": "qwen3_next", "architectures": ["Qwen3NextForCausalLM"]} - ) is True + assert ( + _config_needs_530( + {"model_type": "qwen3_next", "architectures": ["Qwen3NextForCausalLM"]} + ) + is True + ) def test_config_needs_530_plain_qwen3_is_false(self): """Regular Qwen3 (non-MoE, non-3.5) must not be promoted to 5.3.0.""" @@ -937,7 +946,10 @@ def test_tier_local_qwen35_moe_config_selects_530(self, tmp_path: Path): d.mkdir() (d / "config.json").write_text( json.dumps( - {"model_type": "qwen3_5_moe", "architectures": ["Qwen3_5MoeForConditionalGeneration"]} + { + "model_type": "qwen3_5_moe", + "architectures": ["Qwen3_5MoeForConditionalGeneration"], + } ) ) assert get_transformers_tier(str(d)) == "530" @@ -949,7 +961,9 @@ def test_local_qwen36_config_keeps_550_name_tier(self, tmp_path: Path): d = tmp_path / "Qwen3.6-27B" d.mkdir() (d / "config.json").write_text( - json.dumps({"model_type": "qwen3_5", "architectures": ["Qwen3_5ForConditionalGeneration"]}) + json.dumps( + {"model_type": "qwen3_5", "architectures": ["Qwen3_5ForConditionalGeneration"]} + ) ) assert get_transformers_tier(str(d)) == "550" From 3703faaa7dc1b3497ce479f3d715e4b7d2cfe7d2 Mon Sep 17 00:00:00 2001 From: LeoBorcherding Date: Sat, 20 Jun 2026 09:09:42 -0500 Subject: [PATCH 18/22] Studio: close remaining codex P2s: adapter-only LoRA + 530-override path-hint guard - adapter_model-only LoRA: add import-light _is_lora_adapter_dir/_has_adapter_weights and gate activation/export pre-resolve on them, so LoRA dirs with adapter_model*.safetensors but no adapter_config.json still resolve to their base model (via _resolve_base_model's new unsloth__ directory-name parse) instead of tiering off the adapter folder. - 530 override: only treat a resolved value as a name hint when it is a real Hub id; a stale/renamed local path in model_name/_name_or_path can no longer flip a correct 530 config to 550. Current folder basename still allowed. Added 7 regression tests; suite at 116 passing. --- .../tests/test_transformers_version.py | 124 ++++++++++++++++++ studio/backend/utils/transformers_version.py | 75 +++++++++-- 2 files changed, 189 insertions(+), 10 deletions(-) diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index 21c05a2d212..4352df8e1e0 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -31,6 +31,8 @@ from utils.transformers_version import ( _resolve_base_model, + _is_lora_adapter_dir, + _has_adapter_weights, _check_tokenizer_config_needs_v5, _check_config_needs_510, _check_config_needs_530, @@ -1274,3 +1276,125 @@ def test_local_config_tier_not_bypassed_by_private_name_or_path(self, tmp_path: # get_transformers_tier reads config.json directly and returns 530 # without needing to probe the private HF ID. assert get_transformers_tier(str(d)) == "530" + + +# --------------------------------------------------------------------------- +# adapter_model-only LoRA resolution (no adapter_config.json) +# --------------------------------------------------------------------------- + + +class TestAdapterModelOnlyLoRA: + """A LoRA dir with adapter_model*.safetensors but no adapter_config.json must + still be detected as an adapter and resolved to its base model so the worker + activates the base model's sidecar instead of tiering off the adapter folder.""" + + def test_has_adapter_weights_detects_safetensors_and_bin(self, tmp_path: Path): + d = tmp_path / "adapter" + d.mkdir() + assert _has_adapter_weights(d) is False + (d / "adapter_model.safetensors").write_text("") + assert _has_adapter_weights(d) is True + d2 = tmp_path / "adapter_bin" + d2.mkdir() + (d2 / "adapter_model.bin").write_text("") + assert _has_adapter_weights(d2) is True + + def test_is_lora_adapter_dir_for_config_and_weights_only(self, tmp_path: Path): + # adapter_config.json present + a = tmp_path / "cfg" + a.mkdir() + (a / "adapter_config.json").write_text("{}") + assert _is_lora_adapter_dir(a) is True + # adapter_model weights only, no config + b = tmp_path / "weights_only" + b.mkdir() + (b / "adapter_model.safetensors").write_text("") + assert _is_lora_adapter_dir(b) is True + # plain checkpoint dir (neither) + c = tmp_path / "plain" + c.mkdir() + (c / "config.json").write_text("{}") + assert _is_lora_adapter_dir(c) is False + # not a directory + assert _is_lora_adapter_dir(tmp_path / "missing") is False + + def test_resolve_adapter_only_lora_via_unsloth_dir_name(self, tmp_path: Path): + """adapter_model-only LoRA with the unsloth__ naming resolves to + unsloth/ through the import-light directory-name parse.""" + d = tmp_path / "unsloth_Qwen3.5-7B_20260620" + d.mkdir() + (d / "adapter_model.safetensors").write_text("") + assert _resolve_base_model(str(d)) == "unsloth/Qwen3.5-7B" + + def test_activation_pre_resolves_adapter_only_lora(self, tmp_path: Path): + """Regression: activate_transformers_for_subprocess must pre-resolve an + adapter_model-only LoRA dir (weights present, adapter_config.json absent). + Before the gate used _is_lora_adapter_dir, the adapter_config-only check + skipped resolution and the worker tiered off the adapter folder itself.""" + d = tmp_path / "my-custom-lora" + d.mkdir() + (d / "adapter_model.safetensors").write_text("") + snap = (list(sys.path), os.environ.get("PYTHONPATH")) + try: + with ( + patch( + "utils.transformers_version._resolve_base_model", + side_effect = lambda m: m, + ) as mock_resolve, + patch( + "utils.transformers_version.get_transformers_tier", + return_value = "default", + ), + ): + activate_transformers_for_subprocess(str(d)) + finally: + sys.path[:] = snap[0] + if snap[1] is None: + os.environ.pop("PYTHONPATH", None) + else: + os.environ["PYTHONPATH"] = snap[1] + mock_resolve.assert_called_once_with(str(d)) + + +# --------------------------------------------------------------------------- +# 530-config override must not be flipped by stale local path hints +# --------------------------------------------------------------------------- + + +class TestConfig530OverrideGuard: + """A checkpoint whose config.json correctly matches the 530 set must not be + promoted to 550 by an arbitrary 5.5-looking substring in a stale/renamed + local path saved in model_name/_name_or_path. Only a real Hub id (or the + current folder basename) may override the config tier.""" + + def test_stale_local_path_does_not_flip_530_to_550(self, tmp_path: Path): + d = tmp_path / "my-qwen35-run" + d.mkdir() + (d / "config.json").write_text( + json.dumps( + { + "model_type": "qwen3_5", + "_name_or_path": "/old/run/qwen3.6-source", + } + ) + ) + # qwen3.6 in the stale path would name-match 550, but it is not a Hub id, + # so the correct 530 config wins. + assert get_transformers_tier(str(d)) == "530" + + def test_current_basename_can_still_override_to_550(self, tmp_path: Path): + d = tmp_path / "Qwen3.6-27B" + d.mkdir() + # Qwen3.6 reuses the qwen3_5 config id but is a 5.5 model by name. + (d / "config.json").write_text( + json.dumps({"model_type": "qwen3_5", "_name_or_path": str(d)}) + ) + assert get_transformers_tier(str(d)) == "550" + + def test_real_hub_id_can_still_override_to_550(self, tmp_path: Path): + d = tmp_path / "my-finetune" + d.mkdir() + (d / "config.json").write_text( + json.dumps({"model_type": "qwen3_5", "_name_or_path": "Qwen/Qwen3.6-27B"}) + ) + assert get_transformers_tier(str(d)) == "550" diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 429e490e185..554618c26a5 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -166,11 +166,12 @@ def activate_transformers_for_subprocess(model_name: str) -> None: ``sys.path``, and propagates it via ``PYTHONPATH`` for child processes (e.g. GGUF converter). Used by training, inference, and export workers. """ - # Only pre-resolve for LoRA adapters (adapter_config.json present). - # Full checkpoints go directly to get_transformers_tier, which reads - # their local config.json for model_type — more reliable than resolving - # to a private/offline HF ID that can't be probed and may lack tier substrings. - if (Path(model_name) / "adapter_config.json").is_file(): + # Only pre-resolve for LoRA adapter directories (adapter_config.json present + # OR adapter_model-only weights). Full checkpoints go directly to + # get_transformers_tier, which reads their local config.json for model_type — + # more reliable than resolving to a private/offline HF ID that can't be probed + # and may lack tier substrings. + if _is_lora_adapter_dir(Path(model_name)): resolved = _resolve_base_model(model_name) else: resolved = model_name @@ -231,6 +232,32 @@ def activate_transformers_for_subprocess(model_name: str) -> None: logger.info("Using default transformers (4.57.x) for %s", model_name) +def _has_adapter_weights(path: Path) -> bool: + """True if *path* holds LoRA adapter weight files (``adapter_model.*``).""" + try: + return any(path.glob("adapter_model*.safetensors")) or any( + path.glob("adapter_model*.bin") + ) + except OSError: + return False + + +def _is_lora_adapter_dir(path: Path) -> bool: + """True if *path* is a local LoRA adapter directory. + + Mirrors ``utils.models._looks_like_lora_adapter`` but stays import-light so it + can run during subprocess activation without dragging in transformers. Detects + both ``adapter_config.json`` adapters and adapter_model-only LoRAs (weights + present, config absent) that a config-only check would miss. + """ + try: + if not path.is_dir(): + return False + except OSError: + return False + return (path / "adapter_config.json").is_file() or _has_adapter_weights(path) + + def _resolve_base_model(model_name: str) -> str: """If *model_name* points to a LoRA adapter, return its base model. @@ -305,6 +332,23 @@ def _resolve_base_model(model_name: str) -> str: exc, ) + # --- adapter_model-only LoRA (weights but no adapter_config.json) -------- + # These can't be resolved from a config, so fall back to the + # ``unsloth__`` directory-name convention (matching + # get_base_model_from_lora's last-resort branch). This is a pure string + # parse — no transformers import — so subprocess activation ordering is + # preserved even though the heavier resolver above is skipped. + if local_path.name.startswith("unsloth_") and _has_adapter_weights(local_path): + parts = local_path.name.split("_") + if len(parts) >= 2: # unsloth__ + base = "unsloth/" + "_".join(parts[1:-1]) + logger.info( + "Resolved adapter-only LoRA '%s' → base model '%s' (via directory name)", + model_name, + base, + ) + return base + return model_name @@ -611,8 +655,18 @@ def get_transformers_tier(model_name: str) -> str: if _config_needs_530(cfg): # Qwen3.6 reuses Qwen3.5 config ids (qwen3_5 / qwen3_5_moe) but is # a 5.5 model by name; let a higher-tier name match override 530. + # Only trust the resolved value as a name hint when it's a real + # Hub id — a stale/renamed local path saved in model_name/ + # _name_or_path (e.g. /old/run/qwen3.6-source) must not flip a + # correct 530 config to 550 via arbitrary path substrings. Fall + # back to the current folder's basename otherwise. base = _resolve_base_model(model_name) - hint = _tier_from_name(base if base != model_name else Path(model_name).name) + hint_src = ( + base + if (base != model_name and _looks_like_hf_id(base)) + else Path(model_name).name + ) + hint = _tier_from_name(hint_src) if hint is not None and hint[0] in ("510", "550"): logger.info( "Transformers tier %s selected for %s (name overrides 530 config)", @@ -971,14 +1025,15 @@ def ensure_transformers_version(model_name: str) -> None: • Need 5.3.0 → prepend .venv_t5_530/ to sys.path, purge modules. • Need 4.x → remove all .venv_t5_*/ from sys.path, purge modules. - For custom-named LoRA adapters, the base model is resolved from - ``adapter_config.json`` before checking. + For custom-named LoRA adapters, the base model is resolved before checking + (from ``adapter_config.json`` or, for adapter_model-only LoRAs, the directory + name). NOTE: Training and inference use subprocess isolation instead. Used only by the export path (routes/export.py). """ - # Only pre-resolve for LoRA adapters; see activate_transformers_for_subprocess. - if (Path(model_name) / "adapter_config.json").is_file(): + # Only pre-resolve for LoRA adapter dirs; see activate_transformers_for_subprocess. + if _is_lora_adapter_dir(Path(model_name)): resolved = _resolve_base_model(model_name) else: resolved = model_name From 1bae385d44ea2ea2db3b04656a2833ac0bf24dce Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:10:12 +0000 Subject: [PATCH 19/22] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/utils/transformers_version.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index 554618c26a5..be5c6f5d2fb 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -235,9 +235,7 @@ def activate_transformers_for_subprocess(model_name: str) -> None: def _has_adapter_weights(path: Path) -> bool: """True if *path* holds LoRA adapter weight files (``adapter_model.*``).""" try: - return any(path.glob("adapter_model*.safetensors")) or any( - path.glob("adapter_model*.bin") - ) + return any(path.glob("adapter_model*.safetensors")) or any(path.glob("adapter_model*.bin")) except OSError: return False From bde4f3f4fd3a8134f11a8add7c3335f791501b55 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 04:52:30 +0000 Subject: [PATCH 20/22] Studio: address review feedback on tier detection - Add Qwen3.5 text-tower model types (qwen3_5_text / qwen3_5_moe_text) to the 5.3.0 config set so text-only configs with stripped architectures still route to the sidecar - Apply the Qwen3.6 name override on the remote slow path too, so a renamed or private repo whose config reuses qwen3_5 ids but names Qwen3.6 in _name_or_path selects 5.5.0 instead of 5.3.0 - Treat an existing local path (or empty value) as a path, not a Hub id, in _looks_like_hf_id so a real local checkpoint folder is not name matched - Guard _resolve_base_model against non-string config values and compare paths by realpath so relative or absolute self references resolve correctly - Keep the LoRA adapter is_file check inside the OSError guard --- .../tests/test_transformers_version.py | 51 +++++++++++++++++ studio/backend/utils/transformers_version.py | 57 ++++++++++++++++--- 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index 4352df8e1e0..cc8a8c053b1 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -40,6 +40,7 @@ _config_needs_530, _norm_separators, _tier_from_name, + _looks_like_hf_id, _config_json_cache, _tokenizer_class_cache, _config_needs_510_cache, @@ -107,6 +108,14 @@ def test_config_json_fallback_name_or_path(self, tmp_path: Path): result = _resolve_base_model(str(tmp_path)) assert result == "Qwen/Qwen3.5-9B" + def test_non_string_base_does_not_crash(self, tmp_path: Path): + """A malformed config (list/dict for model_name) must not raise.""" + config_cfg = {"model_name": ["x"], "_name_or_path": "Qwen/Qwen3.5-9B"} + (tmp_path / "config.json").write_text(json.dumps(config_cfg)) + + # Skips the non-string model_name and falls through to _name_or_path. + assert _resolve_base_model(str(tmp_path)) == "Qwen/Qwen3.5-9B" + def test_model_name_takes_priority_over_name_or_path(self, tmp_path: Path): """model_name should be preferred over _name_or_path.""" config_cfg = { @@ -902,6 +911,11 @@ def test_config_needs_530_qwen3_next(self): is True ) + def test_config_needs_530_qwen3_5_text_towers(self): + """Text-tower configs (architectures may be stripped) still need 5.3.0.""" + assert _config_needs_530({"model_type": "qwen3_5_text"}) is True + assert _config_needs_530({"model_type": "qwen3_5_moe_text"}) is True + def test_config_needs_530_plain_qwen3_is_false(self): """Regular Qwen3 (non-MoE, non-3.5) must not be promoted to 5.3.0.""" assert _config_needs_530({"model_type": "qwen3"}) is False @@ -1398,3 +1412,40 @@ def test_real_hub_id_can_still_override_to_550(self, tmp_path: Path): json.dumps({"model_type": "qwen3_5", "_name_or_path": "Qwen/Qwen3.6-27B"}) ) assert get_transformers_tier(str(d)) == "550" + + def test_remote_qwen36_name_or_path_overrides_530(self): + """Slow path: a private/renamed Hub repo whose fetched config reuses the + qwen3_5 id but names Qwen3.6 in _name_or_path must select 550, not 530.""" + _config_needs_530_cache.clear() + _config_json_cache.clear() + with patch( + "utils.transformers_version._load_config_json", + return_value = {"model_type": "qwen3_5", "_name_or_path": "Qwen/Qwen3.6-27B"}, + ): + assert get_transformers_tier("private/renamed-q36") == "550" + + +class TestLooksLikeHfId: + def test_empty_and_whitespace_are_not_ids(self): + assert _looks_like_hf_id("") is False + assert _looks_like_hf_id(" ") is False + + def test_plain_hub_id(self): + assert _looks_like_hf_id("Qwen/Qwen3.5-7B") is True + + def test_absolute_and_dot_paths_are_not_ids(self): + assert _looks_like_hf_id("/old/run/qwen3.5-source") is False + assert _looks_like_hf_id("./qwen3.5-source") is False + + def test_existing_local_path_is_not_an_id(self, tmp_path: Path): + d = tmp_path / "Qwen3.5-7B" + d.mkdir() + import os as _os + + cwd = _os.getcwd() + try: + _os.chdir(tmp_path) + # "Qwen3.5-7B" exists relative to cwd, so it is a path, not a Hub id. + assert _looks_like_hf_id("Qwen3.5-7B") is False + finally: + _os.chdir(cwd) diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index be5c6f5d2fb..fa5e71a097d 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -117,7 +117,9 @@ def _env_offline() -> bool: } _TRANSFORMERS_530_MODEL_TYPES: set[str] = { "qwen3_5", + "qwen3_5_text", # Qwen3.5 text tower (dense) "qwen3_5_moe", + "qwen3_5_moe_text", # Qwen3.5 MoE text tower "qwen3_moe", "qwen3_next", "glm4_moe_lite", @@ -251,9 +253,20 @@ def _is_lora_adapter_dir(path: Path) -> bool: try: if not path.is_dir(): return False + return (path / "adapter_config.json").is_file() or _has_adapter_weights(path) + except OSError: + return False + + +def _is_same_path(value: str, local_path: Path) -> bool: + """True if *value* points to *local_path* (handles relative/absolute and + symlink differences), so a self-referential config id isn't taken as a base.""" + if value == str(local_path): + return True + try: + return os.path.realpath(value) == os.path.realpath(str(local_path)) except OSError: return False - return (path / "adapter_config.json").is_file() or _has_adapter_weights(path) def _resolve_base_model(model_name: str) -> str: @@ -293,7 +306,7 @@ def _resolve_base_model(model_name: str) -> str: # useful base id may still live in "_name_or_path". for _key in ("model_name", "_name_or_path"): base = cfg.get(_key) - if base and base != str(local_path): + if isinstance(base, str) and base and not _is_same_path(base, local_path): logger.info( "Resolved checkpoint '%s' → base model '%s' (via config.json)", model_name, @@ -578,9 +591,14 @@ def _norm_separators(s: str) -> str: def _looks_like_hf_id(value: str) -> bool: """True if *value* looks like a Hub id (``org/name``) rather than a local - filesystem path, so a stale/renamed checkpoint path isn't name-matched.""" + filesystem path, so a stale/renamed checkpoint path isn't name-matched. + Mirrors transformers' own rule: an existing local path is a path, not an id.""" + if not value or not value.strip(): + return False if os.path.isabs(value) or value.startswith((".", "~")) or "\\" in value: return False + if os.path.exists(value): + return False return value.count("/") <= 1 @@ -617,6 +635,16 @@ def _tier_from_name(name: str) -> tuple[str, str] | None: return None +def _higher_tier_name_override(name_hint: str | None) -> str | None: + """Return ``"510"``/``"550"`` if *name_hint* names a higher-tier model, else + ``None``. Qwen3.6 configs reuse Qwen3.5 ids (qwen3_5 / qwen3_5_moe) but need + the 5.5 sidecar, so a 5.5/5.10 name hint must override a 530 config match.""" + if not name_hint: + return None + hint = _tier_from_name(name_hint) + return hint[0] if hint is not None and hint[0] in ("510", "550") else None + + def get_transformers_tier(model_name: str) -> str: """Return the transformers tier required for *model_name*. @@ -664,14 +692,14 @@ def get_transformers_tier(model_name: str) -> str: if (base != model_name and _looks_like_hf_id(base)) else Path(model_name).name ) - hint = _tier_from_name(hint_src) - if hint is not None and hint[0] in ("510", "550"): + override = _higher_tier_name_override(hint_src) + if override is not None: logger.info( "Transformers tier %s selected for %s (name overrides 530 config)", - hint[0], + override, model_name, ) - return hint[0] + return override logger.info( "Transformers tier 530 selected for %s (local config.json check)", model_name, @@ -741,6 +769,21 @@ def get_transformers_tier(model_name: str) -> str: logger.info("Transformers tier 550 selected for %s (config.json check)", model_name) return "550" if _check_config_needs_530(model_name): + # Same Qwen3.6 caveat as the local path: a renamed/private repo whose + # fetched config reuses Qwen3.5 ids but whose _name_or_path names Qwen3.6 + # needs the 5.5 sidecar. Apply the name override before selecting 530. + remote_cfg = _load_config_json(model_name) or {} + base = remote_cfg.get("_name_or_path") or remote_cfg.get("model_name") + override = _higher_tier_name_override( + base if isinstance(base, str) and base != model_name else None + ) + if override is not None: + logger.info( + "Transformers tier %s selected for %s (name overrides 530 config)", + override, + model_name, + ) + return override logger.info("Transformers tier 530 selected for %s (config.json check)", model_name) return "530" if _check_tokenizer_config_needs_v5(model_name): From 810d3aa97e500a5502fe613348e06cdd651ab020 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 05:42:30 +0000 Subject: [PATCH 21/22] Studio: harden tier detection against malformed configs and bad paths - _config_matches_tier no longer raises TypeError when a malformed config.json carries a non-string model_type (e.g. a list) or non-list architectures; it fails open to no-match - guard the model_name-derived is_file/is_dir probes with _safe_is_file / _safe_is_dir so a pathological or over-long path (e.g. a Windows long path) fails open to the default tier instead of raising OSError No routing changes for any valid model; purely defensive. Verified by a cross-platform simulation (POSIX + NT path semantics) and a before/after tier matrix that is unchanged for all previously supported models. --- .../tests/test_transformers_version.py | 35 +++++++++++++++ studio/backend/utils/transformers_version.py | 45 +++++++++++++------ 2 files changed, 67 insertions(+), 13 deletions(-) diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index cc8a8c053b1..a2779e604d7 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -1449,3 +1449,38 @@ def test_existing_local_path_is_not_an_id(self, tmp_path: Path): assert _looks_like_hf_id("Qwen3.5-7B") is False finally: _os.chdir(cwd) + + +class TestMalformedInputRobustness: + """Tier detection must never crash on malformed configs or pathological + model names; it fails open to the default tier.""" + + def setup_method(self): + _config_json_cache.clear() + _config_needs_530_cache.clear() + + def test_non_string_model_type_does_not_crash(self): + # A list model_type is unhashable; the matcher must not raise. + assert _config_needs_530({"model_type": ["qwen3_5"]}) is False + + def test_non_list_architectures_does_not_crash(self): + assert _config_needs_530({"architectures": "Qwen3_5ForCausalLM"}) is False + + def test_local_config_non_string_fields_returns_default(self, tmp_path: Path): + d = tmp_path / "weird" + d.mkdir() + (d / "config.json").write_text( + json.dumps({"model_type": ["qwen3_5"], "_name_or_path": {"x": 1}}) + ) + with patch( + "utils.transformers_version._check_tokenizer_config_needs_v5", return_value = False + ): + assert get_transformers_tier(str(d)) == "default" + + def test_pathological_long_name_does_not_crash(self): + # An over-long name component raises OSError from is_file(); tier + # detection must fail open instead of propagating it. + assert get_transformers_tier("x" * 5000) == "default" + + def test_empty_name_returns_default(self): + assert get_transformers_tier("") == "default" diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index fa5e71a097d..bdab4fa00fe 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -53,6 +53,24 @@ def _env_offline() -> bool: ) or os.environ.get("TRANSFORMERS_OFFLINE", "").lower() in ("1", "true", "yes") +def _safe_is_file(p: Path) -> bool: + """``p.is_file()`` that fails closed on a bad path (over-long name, null byte, + Windows long path) instead of raising, so tier detection never crashes on a + pathological model_name.""" + try: + return p.is_file() + except (OSError, ValueError): + return False + + +def _safe_is_dir(p: Path) -> bool: + """``p.is_dir()`` counterpart of :func:`_safe_is_file`.""" + try: + return p.is_dir() + except (OSError, ValueError): + return False + + # --------------------------------------------------------------------------- # Detection # --------------------------------------------------------------------------- @@ -280,7 +298,7 @@ def _resolve_base_model(model_name: str) -> str: # --- Fast local check --------------------------------------------------- local_path = Path(model_name) adapter_cfg_path = local_path / "adapter_config.json" - if adapter_cfg_path.is_file(): + if _safe_is_file(adapter_cfg_path): try: with open(adapter_cfg_path) as f: cfg = json.load(f) @@ -297,7 +315,7 @@ def _resolve_base_model(model_name: str) -> str: # --- config.json fallback (works for both LoRA and full fine-tune) ------ config_json_path = local_path / "config.json" - if config_json_path.is_file(): + if _safe_is_file(config_json_path): try: with open(config_json_path) as f: cfg = json.load(f) @@ -324,7 +342,7 @@ def _resolve_base_model(model_name: str) -> str: # transformers into ``sys.modules`` BEFORE the correct sidecar venv is # prepended to ``sys.path``, so the worker then loads the wrong version. # Gate on a real adapter_config.json to keep activation import-clean. - if adapter_cfg_path.is_file(): + if _safe_is_file(adapter_cfg_path): try: from utils.models import get_base_model_from_lora base = get_base_model_from_lora(model_name) @@ -376,7 +394,7 @@ def _check_tokenizer_config_needs_v5(model_name: str) -> bool: # --- Check local tokenizer_config.json first --------------------------- local_path = Path(model_name) local_tc = local_path / "tokenizer_config.json" - if local_tc.is_file(): + if _safe_is_file(local_tc): try: with open(local_tc) as f: data = json.load(f) @@ -437,7 +455,7 @@ def _load_config_json(model_name: str, hf_token: str | None = None) -> dict | No return _config_json_cache[cache_key] local_cfg = Path(model_name) / "config.json" - if local_cfg.is_file(): + if _safe_is_file(local_cfg): try: with open(local_cfg) as f: cfg = json.load(f) @@ -471,12 +489,13 @@ def _load_config_json(model_name: str, hf_token: str | None = None) -> dict | No def _config_matches_tier(cfg: dict, architectures: set[str], model_types: set[str]) -> bool: - archs = cfg.get("architectures", []) - if any(a in architectures for a in archs): - return True - if cfg.get("model_type") in model_types: + # Be defensive: a malformed/custom config.json may carry non-string values + # (e.g. a list model_type), which must not raise during tier detection. + archs = cfg.get("architectures") + if isinstance(archs, (list, tuple)) and any(a in architectures for a in archs): return True - return False + mt = cfg.get("model_type") + return isinstance(mt, str) and mt in model_types def _config_needs_550(cfg: dict) -> bool: @@ -663,7 +682,7 @@ def get_transformers_tier(model_name: str) -> str: # renamed folders are handled correctly and parent-dir false-positives are # avoided. local_cfg = Path(model_name) / "config.json" - if local_cfg.is_file(): + if _safe_is_file(local_cfg): cfg = _load_config_json(model_name) if cfg is not None: if _config_needs_510(cfg): @@ -714,7 +733,7 @@ def get_transformers_tier(model_name: str) -> str: # HF Hub ID → _tier_from_name only (no network probes). resolved = _resolve_base_model(model_name) if resolved != model_name: - if Path(resolved).is_dir(): + if _safe_is_dir(Path(resolved)): tier = get_transformers_tier(resolved) if tier != "default": logger.info( @@ -737,7 +756,7 @@ def get_transformers_tier(model_name: str) -> str: ) return tier local_tc = Path(model_name) / "tokenizer_config.json" - if local_tc.is_file() and _check_tokenizer_config_needs_v5(model_name): + if _safe_is_file(local_tc) and _check_tokenizer_config_needs_v5(model_name): logger.info( "Transformers tier 530 selected for %s (local tokenizer_config.json check)", model_name, From 7b89643ff3e4a25063cc29fcad2ff61a87c5b7b2 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 22 Jun 2026 08:09:04 +0000 Subject: [PATCH 22/22] Studio: trim verbose comments in tier detection Shorten/remove over-long comments and docstrings, mainly on internal helpers, without changing behavior. Verified code-only via comment_tools.py check; suite unchanged at 128 passing. --- .../tests/test_transformers_version.py | 22 +--- studio/backend/utils/hardware/hardware.py | 6 +- studio/backend/utils/transformers_version.py | 124 ++++++------------ 3 files changed, 47 insertions(+), 105 deletions(-) diff --git a/studio/backend/tests/test_transformers_version.py b/studio/backend/tests/test_transformers_version.py index a2779e604d7..c22b4d29e7e 100644 --- a/studio/backend/tests/test_transformers_version.py +++ b/studio/backend/tests/test_transformers_version.py @@ -999,8 +999,7 @@ def test_local_qwen36_moe_via_name_or_path_keeps_550(self, tmp_path: Path): assert get_transformers_tier(str(d)) == "550" def test_stale_absolute_name_or_path_not_promoted(self, tmp_path: Path): - """A non-5.x checkpoint whose _name_or_path is a stale absolute path - containing a 5.x substring must not be name-matched into a sidecar.""" + """A non-5.x checkpoint with a stale absolute _name_or_path isn't name-matched.""" d = tmp_path / "my-llama-ckpt" d.mkdir() (d / "config.json").write_text( @@ -1376,10 +1375,8 @@ def test_activation_pre_resolves_adapter_only_lora(self, tmp_path: Path): class TestConfig530OverrideGuard: - """A checkpoint whose config.json correctly matches the 530 set must not be - promoted to 550 by an arbitrary 5.5-looking substring in a stale/renamed - local path saved in model_name/_name_or_path. Only a real Hub id (or the - current folder basename) may override the config tier.""" + """A correct 530 config must not be flipped to 550 by a 5.5-looking substring in + a stale/renamed local path; only a real Hub id or the folder basename may override.""" def test_stale_local_path_does_not_flip_530_to_550(self, tmp_path: Path): d = tmp_path / "my-qwen35-run" @@ -1392,8 +1389,7 @@ def test_stale_local_path_does_not_flip_530_to_550(self, tmp_path: Path): } ) ) - # qwen3.6 in the stale path would name-match 550, but it is not a Hub id, - # so the correct 530 config wins. + # Stale path is not a Hub id, so the 530 config wins over its qwen3.6 substring. assert get_transformers_tier(str(d)) == "530" def test_current_basename_can_still_override_to_550(self, tmp_path: Path): @@ -1414,8 +1410,7 @@ def test_real_hub_id_can_still_override_to_550(self, tmp_path: Path): assert get_transformers_tier(str(d)) == "550" def test_remote_qwen36_name_or_path_overrides_530(self): - """Slow path: a private/renamed Hub repo whose fetched config reuses the - qwen3_5 id but names Qwen3.6 in _name_or_path must select 550, not 530.""" + """Slow path: a fetched qwen3_5 config naming Qwen3.6 in _name_or_path -> 550.""" _config_needs_530_cache.clear() _config_json_cache.clear() with patch( @@ -1452,15 +1447,13 @@ def test_existing_local_path_is_not_an_id(self, tmp_path: Path): class TestMalformedInputRobustness: - """Tier detection must never crash on malformed configs or pathological - model names; it fails open to the default tier.""" + """Tier detection fails open to default instead of crashing on bad input.""" def setup_method(self): _config_json_cache.clear() _config_needs_530_cache.clear() def test_non_string_model_type_does_not_crash(self): - # A list model_type is unhashable; the matcher must not raise. assert _config_needs_530({"model_type": ["qwen3_5"]}) is False def test_non_list_architectures_does_not_crash(self): @@ -1478,8 +1471,7 @@ def test_local_config_non_string_fields_returns_default(self, tmp_path: Path): assert get_transformers_tier(str(d)) == "default" def test_pathological_long_name_does_not_crash(self): - # An over-long name component raises OSError from is_file(); tier - # detection must fail open instead of propagating it. + # An over-long name makes is_file() raise OSError; must fail open. assert get_transformers_tier("x" * 5000) == "default" def test_empty_name_returns_default(self): diff --git a/studio/backend/utils/hardware/hardware.py b/studio/backend/utils/hardware/hardware.py index e5472d49fa6..dc977180540 100644 --- a/studio/backend/utils/hardware/hardware.py +++ b/studio/backend/utils/hardware/hardware.py @@ -1161,10 +1161,8 @@ def _to_ns(d): return _to_ns(cfg) except Exception as e: - # A 5.x-only architecture (e.g. Qwen3.5 / model_type "qwen3_5") cannot be - # parsed by the default in-process transformers -- that is expected, the - # worker reloads the model under the matching transformers sidecar. Only - # warn loudly when no tier switch will rescue the load. + # A 5.x-only config can't be parsed by the default transformers; that is + # expected (the worker reloads under the sidecar), so only warn for default tier. tier = "default" try: from utils.transformers_version import get_transformers_tier diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py index bdab4fa00fe..0d1e10456cf 100644 --- a/studio/backend/utils/transformers_version.py +++ b/studio/backend/utils/transformers_version.py @@ -54,9 +54,7 @@ def _env_offline() -> bool: def _safe_is_file(p: Path) -> bool: - """``p.is_file()`` that fails closed on a bad path (over-long name, null byte, - Windows long path) instead of raising, so tier detection never crashes on a - pathological model_name.""" + """``p.is_file()`` returning False instead of raising on a bad path.""" try: return p.is_file() except (OSError, ValueError): @@ -64,7 +62,7 @@ def _safe_is_file(p: Path) -> bool: def _safe_is_dir(p: Path) -> bool: - """``p.is_dir()`` counterpart of :func:`_safe_is_file`.""" + """``p.is_dir()`` returning False instead of raising on a bad path.""" try: return p.is_dir() except (OSError, ValueError): @@ -126,18 +124,18 @@ def _safe_is_dir(p: Path) -> bool: _TRANSFORMERS_530_ARCHITECTURES: set[str] = { "Qwen3_5ForCausalLM", "Qwen3_5ForConditionalGeneration", - "Qwen3_5MoeForCausalLM", # Qwen3.5 MoE (e.g. Qwen3.5-35B-A3B / 122B-A10B) + "Qwen3_5MoeForCausalLM", "Qwen3_5MoeForConditionalGeneration", "Qwen3MoeForCausalLM", - "Qwen3NextForCausalLM", # Qwen3-Next + "Qwen3NextForCausalLM", "Glm4MoeLiteForCausalLM", "Lfm2VlForConditionalGeneration", } _TRANSFORMERS_530_MODEL_TYPES: set[str] = { "qwen3_5", - "qwen3_5_text", # Qwen3.5 text tower (dense) + "qwen3_5_text", "qwen3_5_moe", - "qwen3_5_moe_text", # Qwen3.5 MoE text tower + "qwen3_5_moe_text", "qwen3_moe", "qwen3_next", "glm4_moe_lite", @@ -186,11 +184,8 @@ def activate_transformers_for_subprocess(model_name: str) -> None: ``sys.path``, and propagates it via ``PYTHONPATH`` for child processes (e.g. GGUF converter). Used by training, inference, and export workers. """ - # Only pre-resolve for LoRA adapter directories (adapter_config.json present - # OR adapter_model-only weights). Full checkpoints go directly to - # get_transformers_tier, which reads their local config.json for model_type — - # more reliable than resolving to a private/offline HF ID that can't be probed - # and may lack tier substrings. + # Pre-resolve only LoRA adapters; full checkpoints go to get_transformers_tier + # so their local config.json drives the tier (avoids a fragile HF-id probe). if _is_lora_adapter_dir(Path(model_name)): resolved = _resolve_base_model(model_name) else: @@ -261,13 +256,8 @@ def _has_adapter_weights(path: Path) -> bool: def _is_lora_adapter_dir(path: Path) -> bool: - """True if *path* is a local LoRA adapter directory. - - Mirrors ``utils.models._looks_like_lora_adapter`` but stays import-light so it - can run during subprocess activation without dragging in transformers. Detects - both ``adapter_config.json`` adapters and adapter_model-only LoRAs (weights - present, config absent) that a config-only check would miss. - """ + """True if *path* is a local LoRA dir (adapter_config.json or adapter_model-only + weights). Import-light so it can run during subprocess activation.""" try: if not path.is_dir(): return False @@ -277,8 +267,7 @@ def _is_lora_adapter_dir(path: Path) -> bool: def _is_same_path(value: str, local_path: Path) -> bool: - """True if *value* points to *local_path* (handles relative/absolute and - symlink differences), so a self-referential config id isn't taken as a base.""" + """True if *value* resolves to *local_path* (relative/absolute/symlink).""" if value == str(local_path): return True try: @@ -319,9 +308,7 @@ def _resolve_base_model(model_name: str) -> str: try: with open(config_json_path) as f: cfg = json.load(f) - # Unsloth writes "model_name"; HF writes "_name_or_path". Try both: - # if "model_name" is self-referential (equals the local path), the - # useful base id may still live in "_name_or_path". + # Unsloth writes model_name, HF writes _name_or_path; skip a self-reference. for _key in ("model_name", "_name_or_path"): base = cfg.get(_key) if isinstance(base, str) and base and not _is_same_path(base, local_path): @@ -334,14 +321,9 @@ def _resolve_base_model(model_name: str) -> str: except Exception as exc: logger.debug("Could not read %s: %s", config_json_path, exc) - # --- Only try the heavier fallback for genuine LoRA adapters ------------ - # ``get_base_model_from_lora`` returns None for anything that isn't a LoRA - # adapter, so the only effect of taking this branch for a full checkpoint is - # the side effect of importing ``utils.models``, which eagerly imports - # ``transformers``. During subprocess activation that pins the *default* - # transformers into ``sys.modules`` BEFORE the correct sidecar venv is - # prepended to ``sys.path``, so the worker then loads the wrong version. - # Gate on a real adapter_config.json to keep activation import-clean. + # Gate the heavy resolver on adapter_config.json: importing utils.models pulls + # in transformers, which would pin the default into sys.modules before the + # sidecar venv is prepended during activation. if _safe_is_file(adapter_cfg_path): try: from utils.models import get_base_model_from_lora @@ -361,12 +343,8 @@ def _resolve_base_model(model_name: str) -> str: exc, ) - # --- adapter_model-only LoRA (weights but no adapter_config.json) -------- - # These can't be resolved from a config, so fall back to the - # ``unsloth__`` directory-name convention (matching - # get_base_model_from_lora's last-resort branch). This is a pure string - # parse — no transformers import — so subprocess activation ordering is - # preserved even though the heavier resolver above is skipped. + # adapter_model-only LoRA: no config to resolve from, so use the + # unsloth__ dir-name convention (pure string parse). if local_path.name.startswith("unsloth_") and _has_adapter_weights(local_path): parts = local_path.name.split("_") if len(parts) >= 2: # unsloth__ @@ -489,8 +467,7 @@ def _load_config_json(model_name: str, hf_token: str | None = None) -> dict | No def _config_matches_tier(cfg: dict, architectures: set[str], model_types: set[str]) -> bool: - # Be defensive: a malformed/custom config.json may carry non-string values - # (e.g. a list model_type), which must not raise during tier detection. + # Defensive: a malformed config may carry non-string values (e.g. list model_type). archs = cfg.get("architectures") if isinstance(archs, (list, tuple)) and any(a in architectures for a in archs): return True @@ -601,17 +578,14 @@ def _check_config_needs_510(model_name: str) -> bool: def _norm_separators(s: str) -> str: - """Collapse ``_`` and whitespace to ``-`` so underscore aliases (e.g. - ``Qwen3_Next``) match the canonical hyphen substrings. ``.`` is left intact: - version dots (``qwen3.5``) must not be conflated with size separators - (``Qwen3-5B`` / ``Qwen3-6B``).""" + """Collapse ``_``/whitespace to ``-`` (underscore aliases) but keep ``.`` so a + version dot (``qwen3.5``) isn't conflated with a size separator (``Qwen3-5B``).""" return "".join("-" if ch in "_ \t" else ch for ch in s) def _looks_like_hf_id(value: str) -> bool: - """True if *value* looks like a Hub id (``org/name``) rather than a local - filesystem path, so a stale/renamed checkpoint path isn't name-matched. - Mirrors transformers' own rule: an existing local path is a path, not an id.""" + """True if *value* looks like a Hub id (``org/name``), not a local path. An + existing path is treated as a path, mirroring transformers' own resolution.""" if not value or not value.strip(): return False if os.path.isabs(value) or value.startswith((".", "~")) or "\\" in value: @@ -622,18 +596,11 @@ def _looks_like_hf_id(value: str) -> bool: def _tier_from_name(name: str) -> tuple[str, str] | None: - """Return ``(tier, matched_reason)`` from name-based substring rules, or - ``None`` if nothing matches. - - Applies the same detection order used by :func:`get_transformers_tier`: - 510 before 550 before 530, with the Gemma-4 assistant special-case first. - Used both for direct model-name checks and as a fallback when a local - checkpoint's ``config.json`` architectures aren't yet enumerated in the - config sets. - - Underscore aliases match (``Qwen3_5`` == ``Qwen3.5``), but a dot-version - substring (``qwen3.5``/``qwen3.6``) matches only the dot or underscore form, - never a hyphen, so ``Qwen3-5B``/``Qwen3-6B`` size names aren't promoted. + """``(tier, reason)`` from name substrings (order 510 > 550 > 530), or ``None``. + + Underscore aliases match (``Qwen3_5`` == ``Qwen3.5``); a dot-version substring + matches only the dot/underscore form, never a hyphen, so ``Qwen3-6B`` size names + aren't promoted. """ lowered = name.lower() norm = _norm_separators(lowered) @@ -655,9 +622,8 @@ def _tier_from_name(name: str) -> tuple[str, str] | None: def _higher_tier_name_override(name_hint: str | None) -> str | None: - """Return ``"510"``/``"550"`` if *name_hint* names a higher-tier model, else - ``None``. Qwen3.6 configs reuse Qwen3.5 ids (qwen3_5 / qwen3_5_moe) but need - the 5.5 sidecar, so a 5.5/5.10 name hint must override a 530 config match.""" + """510/550 tier if *name_hint* names a higher-tier model, else ``None``. Qwen3.6 + reuses Qwen3.5 config ids but needs the 5.5 sidecar, so a name hint overrides 530.""" if not name_hint: return None hint = _tier_from_name(name_hint) @@ -675,12 +641,8 @@ def get_transformers_tier(model_name: str) -> str: Higher 5.x tiers run first. For local paths, ``config.json`` is checked before name heuristics to avoid false-positives from directory name fragments. """ - # --- Local checkpoint path --- - # config.json acts as a positive-signal oracle: if it matches a known - # sidecar architecture, return immediately. If it doesn't, we fall back - # to the HF ID embedded in the config rather than the filesystem path, so - # renamed folders are handled correctly and parent-dir false-positives are - # avoided. + # Local path: trust config.json. If its arch matches a known sidecar, return; + # else fall back to the HF id in the config (not the folder name) for renamed dirs. local_cfg = Path(model_name) / "config.json" if _safe_is_file(local_cfg): cfg = _load_config_json(model_name) @@ -698,13 +660,9 @@ def get_transformers_tier(model_name: str) -> str: ) return "550" if _config_needs_530(cfg): - # Qwen3.6 reuses Qwen3.5 config ids (qwen3_5 / qwen3_5_moe) but is - # a 5.5 model by name; let a higher-tier name match override 530. - # Only trust the resolved value as a name hint when it's a real - # Hub id — a stale/renamed local path saved in model_name/ - # _name_or_path (e.g. /old/run/qwen3.6-source) must not flip a - # correct 530 config to 550 via arbitrary path substrings. Fall - # back to the current folder's basename otherwise. + # Qwen3.6 reuses Qwen3.5 config ids but needs 5.5 by name. Only a real + # Hub id (or the folder basename) may override 530, so a stale local + # path in _name_or_path can't flip a correct 530 config to 550. base = _resolve_base_model(model_name) hint_src = ( base @@ -724,13 +682,8 @@ def get_transformers_tier(model_name: str) -> str: model_name, ) return "530" - # Architecture not in any config set — resolve the base model name - # (_name_or_path / model_name in config) and detect tier from it. - # Split on whether the resolved value is a local path or a HF Hub ID: - # local dir → recurse (config.json check, no network I/O) so a - # self-referencing absolute path doesn't false-positive - # on directory-name substrings. - # HF Hub ID → _tier_from_name only (no network probes). + # Unknown arch: resolve the base id from config. A resolved local dir + # recurses (config check); a Hub id uses name rules only (no network). resolved = _resolve_base_model(model_name) if resolved != model_name: if _safe_is_dir(Path(resolved)): @@ -788,9 +741,8 @@ def get_transformers_tier(model_name: str) -> str: logger.info("Transformers tier 550 selected for %s (config.json check)", model_name) return "550" if _check_config_needs_530(model_name): - # Same Qwen3.6 caveat as the local path: a renamed/private repo whose - # fetched config reuses Qwen3.5 ids but whose _name_or_path names Qwen3.6 - # needs the 5.5 sidecar. Apply the name override before selecting 530. + # Same Qwen3.6 caveat as the local path: honor a _name_or_path name hint + # before selecting 530. remote_cfg = _load_config_json(model_name) or {} base = remote_cfg.get("_name_or_path") or remote_cfg.get("model_name") override = _higher_tier_name_override(