From c66bdf7b47f4ba6e172805a94fb2b2b85218d289 Mon Sep 17 00:00:00 2001 From: gongwei-130 Date: Mon, 30 Mar 2026 15:11:08 -0700 Subject: [PATCH 1/6] refactor sglang tokenized validation and add grpc servicer test Signed-off-by: gongwei-130 --- .github/workflows/pr-test-rust.yml | 18 +++++++ .../smg_grpc_servicer/sglang/servicer.py | 17 ++---- .../smg_grpc_servicer/sglang/validation.py | 16 ++++++ grpc_servicer/tests/test_sglang_validation.py | 52 +++++++++++++++++++ 4 files changed, 91 insertions(+), 12 deletions(-) create mode 100644 grpc_servicer/smg_grpc_servicer/sglang/validation.py create mode 100644 grpc_servicer/tests/test_sglang_validation.py diff --git a/.github/workflows/pr-test-rust.yml b/.github/workflows/pr-test-rust.yml index 32049b3d45..5c78095eb9 100644 --- a/.github/workflows/pr-test-rust.yml +++ b/.github/workflows/pr-test-rust.yml @@ -232,6 +232,24 @@ jobs: python3 -m pip install pytest pytest-cov pytest-xdist pytest -q tests --cov=smg --cov-config=.coveragerc --cov-report=term-missing --cov-fail-under=80 + grpc-servicer-unit-tests: + runs-on: k8s-runner-cpu + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Run grpc_servicer unit tests + run: | + python3 -m pip install pytest + cd grpc_servicer + pytest -q tests/test_sglang_validation.py + unit-tests: runs-on: k8s-runner-cpu permissions: diff --git a/grpc_servicer/smg_grpc_servicer/sglang/servicer.py b/grpc_servicer/smg_grpc_servicer/sglang/servicer.py index 628014ce39..485cb1f358 100644 --- a/grpc_servicer/smg_grpc_servicer/sglang/servicer.py +++ b/grpc_servicer/smg_grpc_servicer/sglang/servicer.py @@ -47,6 +47,7 @@ from smg_grpc_servicer.sglang.health_servicer import SGLangHealthServicer from smg_grpc_servicer.sglang.request_manager import GrpcRequestManager from smg_grpc_servicer.sglang.utils import abort_code_from_output +from smg_grpc_servicer.sglang.validation import validate_tokenized_input logger = logging.getLogger(__name__) HEALTH_CHECK_TIMEOUT = int(os.getenv("SGLANG_HEALTH_CHECK_TIMEOUT", 20)) @@ -699,12 +700,8 @@ def _convert_generate_request( ) -> TokenizedGenerateReqInput: """Convert gRPC GenerateRequest to internal format.""" - # Extract tokenized input - if not grpc_req.HasField("tokenized"): - raise ValueError("Tokenized input must be provided") - - input_text = grpc_req.tokenized.original_text - input_ids = list(grpc_req.tokenized.input_ids) + # Extract and validate tokenized input + input_text, input_ids = validate_tokenized_input(grpc_req, "generate") # Convert sampling params sampling_params = self._convert_sampling_params(grpc_req.sampling_params) @@ -805,12 +802,8 @@ def _convert_embed_request( ) -> TokenizedEmbeddingReqInput: """Convert gRPC EmbedRequest to internal format.""" - # Extract tokenized input - if not grpc_req.HasField("tokenized"): - raise ValueError("Tokenized input must be provided") - - input_text = grpc_req.tokenized.original_text - input_ids = list(grpc_req.tokenized.input_ids) + # Extract and validate tokenized input + input_text, input_ids = validate_tokenized_input(grpc_req, "embed") # Convert sampling params sampling_params = self._convert_sampling_params(grpc_req.sampling_params) diff --git a/grpc_servicer/smg_grpc_servicer/sglang/validation.py b/grpc_servicer/smg_grpc_servicer/sglang/validation.py new file mode 100644 index 0000000000..7b1ba8bfd0 --- /dev/null +++ b/grpc_servicer/smg_grpc_servicer/sglang/validation.py @@ -0,0 +1,16 @@ +"""Validation helpers for SGLang gRPC request payloads.""" + +from typing import Any + + +def validate_tokenized_input(grpc_req: Any, request_kind: str) -> tuple[str, list[int]]: + """Validate tokenized field and return (original_text, input_ids).""" + if not grpc_req.HasField("tokenized"): + raise ValueError("Tokenized input must be provided") + + input_text = grpc_req.tokenized.original_text + input_ids = list(grpc_req.tokenized.input_ids) + if not input_ids: + raise ValueError(f"input_ids cannot be empty for {request_kind} requests") + + return input_text, input_ids diff --git a/grpc_servicer/tests/test_sglang_validation.py b/grpc_servicer/tests/test_sglang_validation.py new file mode 100644 index 0000000000..73ab7980d0 --- /dev/null +++ b/grpc_servicer/tests/test_sglang_validation.py @@ -0,0 +1,52 @@ +""" +Unit tests for SGLang request validation helpers. + +These tests focus on validating tokenized request input in isolation. +""" + +import pytest + +from smg_grpc_servicer.sglang.validation import validate_tokenized_input + + +class _Tokenized: + def __init__(self, original_text: str, input_ids: list[int]): + self.original_text = original_text + self.input_ids = input_ids + + +class _GrpcReqStub: + def __init__(self, tokenized: _Tokenized | None): + self.tokenized = tokenized + + def HasField(self, field_name: str) -> bool: # noqa: N802 + return field_name == "tokenized" and self.tokenized is not None + + +class TestSGLangValidation: + """Test tokenized input validation behavior.""" + + def test_validate_tokenized_input_rejects_missing_tokenized(self): + """Reject requests without tokenized payload.""" + req = _GrpcReqStub(tokenized=None) + with pytest.raises(ValueError, match="Tokenized input must be provided"): + validate_tokenized_input(req, "generate") + + def test_validate_tokenized_input_rejects_empty_generate(self): + """Reject generate requests with empty input_ids.""" + req = _GrpcReqStub(tokenized=_Tokenized("hello", [])) + with pytest.raises(ValueError, match="input_ids cannot be empty for generate requests"): + validate_tokenized_input(req, "generate") + + def test_validate_tokenized_input_rejects_empty_embed(self): + """Reject embed requests with empty input_ids.""" + req = _GrpcReqStub(tokenized=_Tokenized("hello", [])) + with pytest.raises(ValueError, match="input_ids cannot be empty for embed requests"): + validate_tokenized_input(req, "embed") + + def test_validate_tokenized_input_returns_text_and_ids(self): + """Return original text and ids for valid tokenized payload.""" + req = _GrpcReqStub(tokenized=_Tokenized("hello", [1, 2])) + input_text, input_ids = validate_tokenized_input(req, "embed") + assert input_text == "hello" + assert input_ids == [1, 2] From 5a5eb77fd4f44e567082ca6197f9f471159525ab Mon Sep 17 00:00:00 2001 From: gongwei-130 Date: Mon, 30 Mar 2026 15:18:34 -0700 Subject: [PATCH 2/6] fix(ci): unblock grpc servicer validation test imports Signed-off-by: gongwei-130 --- .github/workflows/pr-test-rust.yml | 3 +-- grpc_servicer/smg_grpc_servicer/sglang/__init__.py | 12 +++++++++--- grpc_servicer/tests/test_sglang_validation.py | 1 - 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-test-rust.yml b/.github/workflows/pr-test-rust.yml index 5c78095eb9..e7c35f6dd2 100644 --- a/.github/workflows/pr-test-rust.yml +++ b/.github/workflows/pr-test-rust.yml @@ -247,8 +247,7 @@ jobs: - name: Run grpc_servicer unit tests run: | python3 -m pip install pytest - cd grpc_servicer - pytest -q tests/test_sglang_validation.py + PYTHONPATH=grpc_servicer pytest -q grpc_servicer/tests/test_sglang_validation.py unit-tests: runs-on: k8s-runner-cpu diff --git a/grpc_servicer/smg_grpc_servicer/sglang/__init__.py b/grpc_servicer/smg_grpc_servicer/sglang/__init__.py index c5652e1698..7c41af2952 100644 --- a/grpc_servicer/smg_grpc_servicer/sglang/__init__.py +++ b/grpc_servicer/smg_grpc_servicer/sglang/__init__.py @@ -1,5 +1,11 @@ -"""SGLang gRPC servicer — implements SglangScheduler proto service.""" - -from smg_grpc_servicer.sglang.servicer import SGLangSchedulerServicer +"""SGLang gRPC servicer package.""" __all__ = ["SGLangSchedulerServicer"] + + +def __getattr__(name: str): + if name == "SGLangSchedulerServicer": + from smg_grpc_servicer.sglang.servicer import SGLangSchedulerServicer + + return SGLangSchedulerServicer + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/grpc_servicer/tests/test_sglang_validation.py b/grpc_servicer/tests/test_sglang_validation.py index 73ab7980d0..c2e2e67b5a 100644 --- a/grpc_servicer/tests/test_sglang_validation.py +++ b/grpc_servicer/tests/test_sglang_validation.py @@ -5,7 +5,6 @@ """ import pytest - from smg_grpc_servicer.sglang.validation import validate_tokenized_input From c6eae0ee9fac5f78eadfd72947880aa78bc2a38a Mon Sep 17 00:00:00 2001 From: gongwei-130 Date: Mon, 30 Mar 2026 19:29:48 -0700 Subject: [PATCH 3/6] ci: include grpc-servicer-unit-tests in finish check Signed-off-by: gongwei-130 --- .github/workflows/pr-test-rust.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-test-rust.yml b/.github/workflows/pr-test-rust.yml index b5658b678a..097b0fb3e6 100644 --- a/.github/workflows/pr-test-rust.yml +++ b/.github/workflows/pr-test-rust.yml @@ -888,7 +888,7 @@ jobs: path: benchmark_go_bindings/ finish: - needs: [pre-commit, python-lint, grpc-proto-build-check, build-wheel, python-unit-tests, unit-tests, benchmarks, e2e-1gpu-chat, e2e-1gpu-embeddings, e2e-1gpu-gateway, e2e-2gpu-chat, e2e-2gpu-responses, e2e-2gpu-pd, e2e-4gpu-chat, e2e-4gpu-gateway, e2e-vendor, go-unit-tests, go-bindings-e2e] + needs: [pre-commit, python-lint, grpc-proto-build-check, build-wheel, python-unit-tests, grpc-servicer-unit-tests, unit-tests, benchmarks, e2e-1gpu-chat, e2e-1gpu-embeddings, e2e-1gpu-gateway, e2e-2gpu-chat, e2e-2gpu-responses, e2e-2gpu-pd, e2e-4gpu-chat, e2e-4gpu-gateway, e2e-vendor, go-unit-tests, go-bindings-e2e] if: always() runs-on: k8s-runner-cpu permissions: {} @@ -900,6 +900,7 @@ jobs: "${{ needs.grpc-proto-build-check.result }}" == "failure" || \ "${{ needs.build-wheel.result }}" == "failure" || \ "${{ needs.python-unit-tests.result }}" == "failure" || \ + "${{ needs.grpc-servicer-unit-tests.result }}" == "failure" || \ "${{ needs.unit-tests.result }}" == "failure" || \ "${{ needs.benchmarks.result }}" == "failure" || \ "${{ needs.e2e-1gpu-chat.result }}" == "failure" || \ From a5486597cc76d129863fb4cf18b09713193542fd Mon Sep 17 00:00:00 2001 From: gongwei-130 Date: Tue, 31 Mar 2026 07:26:02 -0700 Subject: [PATCH 4/6] test(grpc_servicer): add generate success-path validation test Signed-off-by: gongwei-130 --- grpc_servicer/tests/test_sglang_validation.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/grpc_servicer/tests/test_sglang_validation.py b/grpc_servicer/tests/test_sglang_validation.py index c2e2e67b5a..f8e480a3b6 100644 --- a/grpc_servicer/tests/test_sglang_validation.py +++ b/grpc_servicer/tests/test_sglang_validation.py @@ -49,3 +49,10 @@ def test_validate_tokenized_input_returns_text_and_ids(self): input_text, input_ids = validate_tokenized_input(req, "embed") assert input_text == "hello" assert input_ids == [1, 2] + + def test_validate_tokenized_input_returns_text_and_ids_generate(self): + """Return original text and ids for valid generate tokenized payload.""" + req = _GrpcReqStub(tokenized=_Tokenized("world", [3, 4, 5])) + input_text, input_ids = validate_tokenized_input(req, "generate") + assert input_text == "world" + assert input_ids == [3, 4, 5] From 9df0bf76fe67037fc5f2d9ceda02667a01577870 Mon Sep 17 00:00:00 2001 From: gongwei-130 Date: Tue, 31 Mar 2026 07:34:11 -0700 Subject: [PATCH 5/6] docs(grpc_servicer): improve docstring coverage for sglang validation Signed-off-by: gongwei-130 --- grpc_servicer/smg_grpc_servicer/sglang/__init__.py | 1 + grpc_servicer/tests/test_sglang_validation.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/grpc_servicer/smg_grpc_servicer/sglang/__init__.py b/grpc_servicer/smg_grpc_servicer/sglang/__init__.py index 7c41af2952..f5e7f0722a 100644 --- a/grpc_servicer/smg_grpc_servicer/sglang/__init__.py +++ b/grpc_servicer/smg_grpc_servicer/sglang/__init__.py @@ -4,6 +4,7 @@ def __getattr__(name: str): + """Lazily expose `SGLangSchedulerServicer` to avoid eager heavy imports.""" if name == "SGLangSchedulerServicer": from smg_grpc_servicer.sglang.servicer import SGLangSchedulerServicer diff --git a/grpc_servicer/tests/test_sglang_validation.py b/grpc_servicer/tests/test_sglang_validation.py index f8e480a3b6..e88de4a39f 100644 --- a/grpc_servicer/tests/test_sglang_validation.py +++ b/grpc_servicer/tests/test_sglang_validation.py @@ -10,15 +10,18 @@ class _Tokenized: def __init__(self, original_text: str, input_ids: list[int]): + """Store tokenized payload fields used by validation tests.""" self.original_text = original_text self.input_ids = input_ids class _GrpcReqStub: def __init__(self, tokenized: _Tokenized | None): + """Mimic a protobuf request object with an optional `tokenized` field.""" self.tokenized = tokenized def HasField(self, field_name: str) -> bool: # noqa: N802 + """Match protobuf HasField behavior for the `tokenized` presence check.""" return field_name == "tokenized" and self.tokenized is not None From 732998b45511d4f2a681f4cd5974104a043280c9 Mon Sep 17 00:00:00 2001 From: gongwei-130 Date: Tue, 31 Mar 2026 16:34:54 -0700 Subject: [PATCH 6/6] address comment Signed-off-by: gongwei-130 --- .github/workflows/pr-test-rust.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-test-rust.yml b/.github/workflows/pr-test-rust.yml index e08b01cafb..b2e2a35d27 100644 --- a/.github/workflows/pr-test-rust.yml +++ b/.github/workflows/pr-test-rust.yml @@ -247,7 +247,7 @@ jobs: - name: Run grpc_servicer unit tests run: | python3 -m pip install pytest - PYTHONPATH=grpc_servicer pytest -q grpc_servicer/tests/test_sglang_validation.py + PYTHONPATH=grpc_servicer pytest -q grpc_servicer/tests/ unit-tests: runs-on: k8s-runner-cpu