diff --git a/python/sglang/multimodal_gen/registry.py b/python/sglang/multimodal_gen/registry.py index 9ae6492ef..5874408da 100644 --- a/python/sglang/multimodal_gen/registry.py +++ b/python/sglang/multimodal_gen/registry.py @@ -245,10 +245,29 @@ def get_model_short_name(model_id: str) -> str: return model_id -def _get_config_info(model_path: str) -> Optional[ConfigInfo]: +@lru_cache(maxsize=1) +def _get_config_info( + model_path: str, model_id: Optional[str] = None +) -> Optional[ConfigInfo]: """ Gets the ConfigInfo for a given model path using mappings and detectors. """ + all_model_hf_paths = sorted(_MODEL_HF_PATH_TO_NAME.keys(), key=len, reverse=True) + + # 0. Explicit model_id override: match by short name + if model_id is not None: + model_id_lower = model_id.lower() + for registered_hf_id in all_model_hf_paths: + if get_model_short_name(registered_hf_id).lower() == model_id_lower: + logger.debug( + f"Resolved model via explicit --model-id '{model_id}' → '{registered_hf_id}'." + ) + return _CONFIG_REGISTRY.get(_MODEL_HF_PATH_TO_NAME[registered_hf_id]) + logger.warning( + f"--model-id '{model_id}' did not match any registered model; " + "falling back to automatic detection." + ) + # 1. Exact match if model_path in _MODEL_HF_PATH_TO_NAME: model_id = _MODEL_HF_PATH_TO_NAME[model_path] @@ -256,12 +275,11 @@ def _get_config_info(model_path: str) -> Optional[ConfigInfo]: return _CONFIG_REGISTRY.get(model_id) # 2. Partial match: find the best (longest) match against all registered model hf paths. - model_name = get_model_short_name(model_path.lower()) - all_model_hf_paths = sorted(_MODEL_HF_PATH_TO_NAME.keys(), key=len, reverse=True) + model_short_name = get_model_short_name(model_path.lower()) for registered_model_hf_id in all_model_hf_paths: registered_model_name = get_model_short_name(registered_model_hf_id.lower()) - if registered_model_name == model_name: + if registered_model_name == model_short_name: logger.debug( f"Resolved model name '{registered_model_hf_id}' from partial path match." ) @@ -310,7 +328,7 @@ class ModelInfo: pipeline_config_cls: Type[PipelineConfig] -def _get_diffusers_model_info(model_path: str) -> ModelInfo: +def _get_diffusers_model_info() -> ModelInfo: """ Get model info for diffusers backend. @@ -337,6 +355,7 @@ def _get_diffusers_model_info(model_path: str) -> ModelInfo: def get_model_info( model_path: str, backend: Optional[Union[str, "Backend"]] = None, + model_id: Optional[str] = None, ) -> Optional[ModelInfo]: """ Resolves all necessary classes (pipeline, sampling, config) for a given model path. @@ -365,7 +384,7 @@ def get_model_info( logger.info( "Using diffusers backend for model '%s' (explicitly requested)", model_path ) - return _get_diffusers_model_info(model_path) + return _get_diffusers_model_info() # For AUTO or SGLANG backend, try native implementation first # 1. Discover all available pipeline classes and cache them @@ -389,7 +408,7 @@ def get_model_info( logger.error(f"Could not read model config for '{model_path}': {e}") if backend == Backend.AUTO: logger.info("Falling back to diffusers backend") - return _get_diffusers_model_info(model_path) + return _get_diffusers_model_info() return None pipeline_class_name = config.get("_class_name") @@ -399,7 +418,7 @@ def get_model_info( ) if backend == Backend.AUTO: logger.info("Falling back to diffusers backend") - return _get_diffusers_model_info(model_path) + return _get_diffusers_model_info() return None pipeline_cls = _PIPELINE_REGISTRY.get(pipeline_class_name) @@ -409,7 +428,7 @@ def get_model_info( f"Pipeline class '{pipeline_class_name}' specified in '{model_path}' has no native sglang support. " f"Falling back to diffusers backend." ) - return _get_diffusers_model_info(model_path) + return _get_diffusers_model_info() else: logger.error( f"Pipeline class '{pipeline_class_name}' specified in '{model_path}' is not a registered EntryClass in the framework. " @@ -419,14 +438,14 @@ def get_model_info( return None # 3. Get configuration classes (sampling, pipeline config) - config_info = _get_config_info(model_path) + config_info = _get_config_info(model_path, model_id=model_id) if not config_info: if backend == Backend.AUTO: logger.warning( f"Could not resolve native configuration for model '{model_path}'. " f"Falling back to diffusers backend." ) - return _get_diffusers_model_info(model_path) + return _get_diffusers_model_info() else: logger.error( f"Could not resolve configuration for model '{model_path}'. " diff --git a/python/sglang/multimodal_gen/runtime/entrypoints/http_server.py b/python/sglang/multimodal_gen/runtime/entrypoints/http_server.py index 46a29a819..29aeaa0f0 100644 --- a/python/sglang/multimodal_gen/runtime/entrypoints/http_server.py +++ b/python/sglang/multimodal_gen/runtime/entrypoints/http_server.py @@ -78,7 +78,7 @@ async def get_models(request: Request): from sglang.multimodal_gen.registry import get_model_info server_args: ServerArgs = request.app.state.server_args - model_info = get_model_info(server_args.model_path) + model_info = get_model_info(server_args.model_path, model_id=server_args.model_id) response = { "model_path": server_args.model_path, diff --git a/python/sglang/multimodal_gen/runtime/entrypoints/openai/common_api.py b/python/sglang/multimodal_gen/runtime/entrypoints/openai/common_api.py index a7898dae8..921f64410 100644 --- a/python/sglang/multimodal_gen/runtime/entrypoints/openai/common_api.py +++ b/python/sglang/multimodal_gen/runtime/entrypoints/openai/common_api.py @@ -180,7 +180,11 @@ async def available_models(): if not server_args: raise HTTPException(status_code=500, detail="Server args not initialized") - model_info = get_model_info(server_args.model_path, backend=server_args.backend) + model_info = get_model_info( + server_args.model_path, + backend=server_args.backend, + model_id=server_args.model_id, + ) card_kwargs = { "id": server_args.model_path, @@ -222,7 +226,11 @@ async def retrieve_model(model: str): }, ) - model_info = get_model_info(server_args.model_path, backend=server_args.backend) + model_info = get_model_info( + server_args.model_path, + backend=server_args.backend, + model_id=server_args.model_id, + ) card_kwargs = { "id": model, diff --git a/python/sglang/multimodal_gen/runtime/pipelines_core/__init__.py b/python/sglang/multimodal_gen/runtime/pipelines_core/__init__.py index 74d27c00b..fbc371b35 100644 --- a/python/sglang/multimodal_gen/runtime/pipelines_core/__init__.py +++ b/python/sglang/multimodal_gen/runtime/pipelines_core/__init__.py @@ -66,7 +66,11 @@ def build_pipeline( ) else: logger.info("No pipeline_class_name specified, using model_index.json") - model_info = get_model_info(model_path, backend=server_args.backend) + model_info = get_model_info( + model_path, + backend=server_args.backend, + model_id=server_args.model_id, + ) pipeline_cls = model_info.pipeline_cls logger.info(f"Using pipeline from model_index.json: {pipeline_cls.__name__}") diff --git a/python/sglang/multimodal_gen/runtime/server_args.py b/python/sglang/multimodal_gen/runtime/server_args.py index d5fb89a09..ad90cace7 100644 --- a/python/sglang/multimodal_gen/runtime/server_args.py +++ b/python/sglang/multimodal_gen/runtime/server_args.py @@ -190,6 +190,9 @@ class ServerArgs: # Model and path configuration (for convenience) model_path: str + # explicit model ID override (e.g. "Qwen-Image") + model_id: str | None = None + # Model backend (sglang native or diffusers) backend: Backend = Backend.AUTO @@ -328,9 +331,13 @@ class ServerArgs: """ return self.host is None or self.port is None + def _adjust_path(self): + self.model_path = os.path.expanduser(self.model_path) + def _adjust_parameters(self): """set defaults and normalize values.""" self._adjust_offload() + self._adjust_path() self._adjust_quant_config() self._adjust_warmup() self._adjust_network_ports() @@ -582,6 +589,17 @@ class ServerArgs: type=str, help="The path of the model weights. This can be a local folder or a Hugging Face repo ID.", ) + parser.add_argument( + "--model-id", + type=str, + default=ServerArgs.model_id, + help=( + "Override the model ID used for config resolution. " + "Useful when --model-path is a local directory whose name does not match " + "any registered HF repo name. Should be the repo name portion of the HF ID " + "(e.g. 'Qwen-Image' for 'Qwen/Qwen-Image')." + ), + ) # attention parser.add_argument( "--attention-backend", diff --git a/python/sglang/multimodal_gen/test/run_suite.py b/python/sglang/multimodal_gen/test/run_suite.py index 046eb6005..3b9497d12 100644 --- a/python/sglang/multimodal_gen/test/run_suite.py +++ b/python/sglang/multimodal_gen/test/run_suite.py @@ -34,6 +34,7 @@ SUITES = { "../unit/test_sampling_params_validate.py", "../unit/test_storage.py", "../unit/test_lora_format_adapter.py", + "../unit/test_server_args_unit.py", # add new unit tests here ], "1-gpu": [ diff --git a/python/sglang/multimodal_gen/test/unit/test_server_args_unit.py b/python/sglang/multimodal_gen/test/unit/test_server_args_unit.py new file mode 100644 index 000000000..59d7584d6 --- /dev/null +++ b/python/sglang/multimodal_gen/test/unit/test_server_args_unit.py @@ -0,0 +1,50 @@ +import os +import unittest + +from sglang.multimodal_gen.registry import _get_config_info +from sglang.multimodal_gen.runtime.server_args import ServerArgs + + +class TestServerArgsPathExpansion(unittest.TestCase): + def test_tilde_model_path_is_expanded(self): + args = ServerArgs.from_dict({"model_path": "~/fake/local/model"}) + expected = os.path.expanduser("~/fake/local/model") + self.assertEqual(args.model_path, expected) + self.assertFalse(args.model_path.startswith("~")) + + def test_absolute_path_is_unchanged(self): + args = ServerArgs.from_dict({"model_path": "/data/my-model"}) + self.assertEqual(args.model_path, "/data/my-model") + + +class TestModelIdResolution(unittest.TestCase): + def setUp(self): + _get_config_info.cache_clear() + + def test_model_id_overrides_arbitrary_local_path(self): + # a local path whose directory name does not match any HF repo name; + # --model-id tells the engine which config to use + info = _get_config_info("/data/my-custom-qwen", model_id="Qwen-Image") + self.assertIsNotNone(info) + from sglang.multimodal_gen.configs.pipeline_configs.qwen_image import ( + QwenImagePipelineConfig, + ) + + self.assertIs(info.pipeline_config_cls, QwenImagePipelineConfig) + + def test_model_id_works_after_tilde_expansion(self): + # simulate the full flow: user passes ~/..., engine expands and resolves + expanded = os.path.expanduser("~/.cache/huggingface/hub/bbb/snapshots/ccc") + _get_config_info.cache_clear() + info = _get_config_info(expanded, model_id="Qwen-Image") + self.assertIsNotNone(info) + + def test_model_id_unknown_falls_back_without_crash(self): + # unrecognized model_id: should warn and fall back to path-based detection + # with an unresolvable path, expect RuntimeError from the detector step + with self.assertRaises((RuntimeError, Exception)): + _get_config_info("/data/no-such-model", model_id="NonExistentModelXYZ") + + +if __name__ == "__main__": + unittest.main()