diff --git a/python/sglang/multimodal_gen/runtime/entrypoints/http_server.py b/python/sglang/multimodal_gen/runtime/entrypoints/http_server.py index cfa80f9f1..49205c86e 100644 --- a/python/sglang/multimodal_gen/runtime/entrypoints/http_server.py +++ b/python/sglang/multimodal_gen/runtime/entrypoints/http_server.py @@ -55,9 +55,15 @@ async def health(): return {"status": "ok"} -@health_router.get("/models") +@health_router.get("/models", deprecated=True) async def get_models(request: Request): - """Get information about the model served by this server.""" + """ + Get information about the model served by this server. + + .. deprecated:: + Use /v1/models instead for OpenAI-compatible model discovery. + This endpoint will be removed in a future version. + """ from sglang.multimodal_gen.registry import get_model_info server_args: ServerArgs = request.app.state.server_args 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 8f7372ad5..3e57153ea 100644 --- a/python/sglang/multimodal_gen/runtime/entrypoints/openai/common_api.py +++ b/python/sglang/multimodal_gen/runtime/entrypoints/openai/common_api.py @@ -1,7 +1,9 @@ from typing import Any, Optional from fastapi import APIRouter, Body, HTTPException +from fastapi.responses import ORJSONResponse +from sglang.multimodal_gen.registry import get_model_info from sglang.multimodal_gen.runtime.entrypoints.openai.utils import ( MergeLoraWeightsReq, SetLoraReq, @@ -11,11 +13,23 @@ from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBa from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client from sglang.multimodal_gen.runtime.server_args import get_global_server_args from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger +from sglang.srt.entrypoints.openai.protocol import ModelCard router = APIRouter(prefix="/v1") logger = init_logger(__name__) +class DiffusionModelCard(ModelCard): + """Extended ModelCard with diffusion-specific fields.""" + + num_gpus: Optional[int] = None + task_type: Optional[str] = None + dit_precision: Optional[str] = None + vae_precision: Optional[str] = None + pipeline_name: Optional[str] = None + pipeline_class: Optional[str] = None + + async def _handle_lora_request(req: Any, success_msg: str, failure_msg: str): try: output: OutputBatch = await async_scheduler_client.forward(req) @@ -117,3 +131,71 @@ async def model_info(): "model_path": server_args.model_path, } return result + + +@router.get("/models", response_class=ORJSONResponse) +async def available_models(): + """Show available models. OpenAI-compatible endpoint with extended diffusion info.""" + server_args = get_global_server_args() + if not server_args: + raise HTTPException(status_code=500, detail="Server args not initialized") + + model_info = get_model_info(server_args.model_path) + + card_kwargs = { + "id": server_args.model_path, + "root": server_args.model_path, + # Extended diffusion-specific fields + "num_gpus": server_args.num_gpus, + "task_type": server_args.pipeline_config.task_type.name, + "dit_precision": server_args.pipeline_config.dit_precision, + "vae_precision": server_args.pipeline_config.vae_precision, + } + + if model_info: + card_kwargs["pipeline_name"] = model_info.pipeline_cls.pipeline_name + card_kwargs["pipeline_class"] = model_info.pipeline_cls.__name__ + + model_card = DiffusionModelCard(**card_kwargs) + + # Return dict directly to preserve extended fields (ModelList strips them) + return {"object": "list", "data": [model_card.model_dump()]} + + +@router.get("/models/{model:path}", response_class=ORJSONResponse) +async def retrieve_model(model: str): + """Retrieve a model instance. OpenAI-compatible endpoint with extended diffusion info.""" + server_args = get_global_server_args() + if not server_args: + raise HTTPException(status_code=500, detail="Server args not initialized") + + if model != server_args.model_path: + return ORJSONResponse( + status_code=404, + content={ + "error": { + "message": f"The model '{model}' does not exist", + "type": "invalid_request_error", + "param": "model", + "code": "model_not_found", + } + }, + ) + + model_info = get_model_info(server_args.model_path) + + card_kwargs = { + "id": model, + "root": model, + "num_gpus": server_args.num_gpus, + "task_type": server_args.pipeline_config.task_type.name, + "dit_precision": server_args.pipeline_config.dit_precision, + "vae_precision": server_args.pipeline_config.vae_precision, + } + + if model_info: + card_kwargs["pipeline_name"] = model_info.pipeline_cls.pipeline_name + card_kwargs["pipeline_class"] = model_info.pipeline_cls.__name__ + + # Return dict to preserve extended fields + return DiffusionModelCard(**card_kwargs).model_dump() diff --git a/python/sglang/multimodal_gen/test/run_suite.py b/python/sglang/multimodal_gen/test/run_suite.py index e02496521..8fe81996e 100644 --- a/python/sglang/multimodal_gen/test/run_suite.py +++ b/python/sglang/multimodal_gen/test/run_suite.py @@ -153,8 +153,10 @@ def run_pytest(files, filter_expr=None): cmd = list(base_cmd) if i > 0: cmd.append("--last-failed") - else: - cmd.extend(files) + # Always include files to constrain test discovery scope + # This prevents pytest from scanning the entire rootdir and + # discovering unrelated tests that may have missing dependencies + cmd.extend(files) if i > 0: print( diff --git a/python/sglang/multimodal_gen/test/server/test_server_common.py b/python/sglang/multimodal_gen/test/server/test_server_common.py index c8620bb17..99f3c37ff 100644 --- a/python/sglang/multimodal_gen/test/server/test_server_common.py +++ b/python/sglang/multimodal_gen/test/server/test_server_common.py @@ -513,6 +513,94 @@ Consider updating perf_baselines.json with the snippets below: "[LoRA Switch E2E] All dynamic switch E2E tests passed for %s", case.id ) + def _test_v1_models_endpoint( + self, ctx: ServerContext, case: DiffusionTestCase + ) -> None: + """ + Test /v1/models endpoint returns OpenAI-compatible response. + This endpoint is required for sgl-model-gateway router compatibility. + """ + base_url = f"http://localhost:{ctx.port}" + + # Test GET /v1/models + logger.info("[Models API] Testing GET /v1/models for %s", case.id) + resp = requests.get(f"{base_url}/v1/models") + assert resp.status_code == 200, f"/v1/models failed: {resp.text}" + + data = resp.json() + assert ( + data["object"] == "list" + ), f"Expected object='list', got {data.get('object')}" + assert len(data["data"]) >= 1, "Expected at least one model in response" + + model = data["data"][0] + assert "id" in model, "Model missing 'id' field" + assert ( + model["object"] == "model" + ), f"Expected object='model', got {model.get('object')}" + assert ( + model["id"] == case.server_args.model_path + ), f"Model ID mismatch: expected {case.server_args.model_path}, got {model['id']}" + + # Verify extended diffusion-specific fields + assert "num_gpus" in model, "Model missing 'num_gpus' field" + assert "task_type" in model, "Model missing 'task_type' field" + assert "dit_precision" in model, "Model missing 'dit_precision' field" + assert "vae_precision" in model, "Model missing 'vae_precision' field" + assert ( + model["num_gpus"] == case.server_args.num_gpus + ), f"num_gpus mismatch: expected {case.server_args.num_gpus}, got {model['num_gpus']}" + # Verify task_type is consistent with the modality specified in the test config. + # We can't access pipeline_config from test config, but we can validate against modality. + modality_to_valid_task_types = { + "image": {"T2I", "I2I", "TI2I"}, + "video": {"T2V", "I2V", "TI2V"}, + } + valid_task_types = modality_to_valid_task_types.get( + case.server_args.modality, set() + ) + assert model["task_type"] in valid_task_types, ( + f"task_type '{model['task_type']}' not valid for modality " + f"'{case.server_args.modality}'. Expected one of: {valid_task_types}" + ) + logger.info( + "[Models API] GET /v1/models returned valid response with extended fields" + ) + + # Test GET /v1/models/{model_path} + model_path = model["id"] + logger.info("[Models API] Testing GET /v1/models/%s", model_path) + resp = requests.get(f"{base_url}/v1/models/{model_path}") + assert resp.status_code == 200, f"/v1/models/{model_path} failed: {resp.text}" + + single_model = resp.json() + assert single_model["id"] == model_path, "Single model ID mismatch" + assert single_model["object"] == "model", "Single model object type mismatch" + + # Verify extended fields on single model endpoint too + assert "num_gpus" in single_model, "Single model missing 'num_gpus' field" + assert "task_type" in single_model, "Single model missing 'task_type' field" + assert single_model["task_type"] in valid_task_types, ( + f"Single model task_type '{single_model['task_type']}' not valid for modality " + f"'{case.server_args.modality}'. Expected one of: {valid_task_types}" + ) + logger.info( + "[Models API] GET /v1/models/{model_path} returned valid response with extended fields" + ) + + # Test GET /v1/models/{non_existent_model} returns 404 + logger.info("[Models API] Testing GET /v1/models/non_existent_model") + resp = requests.get(f"{base_url}/v1/models/non_existent_model") + assert resp.status_code == 404, f"Expected 404, got {resp.status_code}" + error_data = resp.json() + assert "error" in error_data, "404 response missing 'error' field" + assert ( + error_data["error"]["code"] == "model_not_found" + ), f"Incorrect error code: {error_data['error'].get('code')}" + logger.info("[Models API] GET /v1/models/non_existent returns 404 as expected") + + logger.info("[Models API] All /v1/models tests passed for %s", case.id) + def test_diffusion_perf( self, case: DiffusionTestCase, @@ -539,6 +627,9 @@ Consider updating perf_baselines.json with the snippets below: self._validate_and_record(case, perf_record) + # Test /v1/models endpoint for router compatibility + self._test_v1_models_endpoint(diffusion_server, case) + # LoRA API functionality test with E2E validation (only for LoRA-enabled cases) if case.server_args.lora_path: self._test_lora_api_functionality(diffusion_server, case, generate_fn)