Add v1/models endpoint to diffusion model APIs so that they can be discovered by model gateway (#16425)
This commit is contained in:
@@ -55,9 +55,15 @@ async def health():
|
|||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@health_router.get("/models")
|
@health_router.get("/models", deprecated=True)
|
||||||
async def get_models(request: Request):
|
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
|
from sglang.multimodal_gen.registry import get_model_info
|
||||||
|
|
||||||
server_args: ServerArgs = request.app.state.server_args
|
server_args: ServerArgs = request.app.state.server_args
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, HTTPException
|
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 (
|
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import (
|
||||||
MergeLoraWeightsReq,
|
MergeLoraWeightsReq,
|
||||||
SetLoraReq,
|
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.scheduler_client import async_scheduler_client
|
||||||
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
|
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.multimodal_gen.runtime.utils.logging_utils import init_logger
|
||||||
|
from sglang.srt.entrypoints.openai.protocol import ModelCard
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1")
|
router = APIRouter(prefix="/v1")
|
||||||
logger = init_logger(__name__)
|
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):
|
async def _handle_lora_request(req: Any, success_msg: str, failure_msg: str):
|
||||||
try:
|
try:
|
||||||
output: OutputBatch = await async_scheduler_client.forward(req)
|
output: OutputBatch = await async_scheduler_client.forward(req)
|
||||||
@@ -117,3 +131,71 @@ async def model_info():
|
|||||||
"model_path": server_args.model_path,
|
"model_path": server_args.model_path,
|
||||||
}
|
}
|
||||||
return result
|
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()
|
||||||
|
|||||||
@@ -153,7 +153,9 @@ def run_pytest(files, filter_expr=None):
|
|||||||
cmd = list(base_cmd)
|
cmd = list(base_cmd)
|
||||||
if i > 0:
|
if i > 0:
|
||||||
cmd.append("--last-failed")
|
cmd.append("--last-failed")
|
||||||
else:
|
# 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)
|
cmd.extend(files)
|
||||||
|
|
||||||
if i > 0:
|
if i > 0:
|
||||||
|
|||||||
@@ -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
|
"[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(
|
def test_diffusion_perf(
|
||||||
self,
|
self,
|
||||||
case: DiffusionTestCase,
|
case: DiffusionTestCase,
|
||||||
@@ -539,6 +627,9 @@ Consider updating perf_baselines.json with the snippets below:
|
|||||||
|
|
||||||
self._validate_and_record(case, perf_record)
|
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)
|
# LoRA API functionality test with E2E validation (only for LoRA-enabled cases)
|
||||||
if case.server_args.lora_path:
|
if case.server_args.lora_path:
|
||||||
self._test_lora_api_functionality(diffusion_server, case, generate_fn)
|
self._test_lora_api_functionality(diffusion_server, case, generate_fn)
|
||||||
|
|||||||
Reference in New Issue
Block a user