Repository navigation
feat(mlx): add Python gRPC servicer for MLX backend #1099
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 15 commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
bf9da2c
feat(mlx): add Python servicer for MLX gRPC backend
key4ng ebd94ea
test(mlx): remove unit tests to match vllm/sglang convention
key4ng 6c60b06
test(mlx): add E2E test for MLX gRPC backend
key4ng a827734
ci(mlx): add GitHub workflow for MLX E2E tests on Apple Silicon
key4ng 6ee5cce
ci(mlx): use debug maturin build to speed up workflow
key4ng 17389ce
ci(mlx): use maturin build + pip install (no virtualenv) and fix ruff…
key4ng f202dc2
ci(mlx): use ci profile for maturin build
key4ng 13c1520
fix(mlx): address PR #1099 review feedback
key4ng 541a85d
fix(mlx): aggregate non-streaming logprobs + guard gRPC bind failure
key4ng 024d542
test(mlx): hoist json import to module level
key4ng 0e2ff44
refactor(mlx): hoist mlx.core and generation_stream imports to module…
key4ng b6fd962
fix(mlx): add threading.Lock around BatchGenerator state
key4ng eb35371
style(mlx): apply ruff format from pre-commit
key4ng d9c7d26
fix(mlx): address 3 new review comments on #1099
key4ng 9910275
fix(mlx): fall back to 256 when no context-length key found
key4ng c2628d5
refactor(mlx): simplify servicer after review round
key4ng 95866f6
fix(mlx): address two new codex review comments
key4ng 0159b2b
fix(mlx): include chat template sidecars in tokenizer export
key4ng 000f74c
chore(mlx): split E2E tests and CI workflow into separate branch
key4ng a05ac9c
chore(mlx): remove unused __main__.py
key4ng 2d3638e
refactor(mlx): hoist remaining inline imports to module top
key4ng 3b47bf5
fix(mlx): address 4 new codex review comments
key4ng 5c24ce2
fix(mlx): report real backend status in HealthCheck
key4ng File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,135 @@ | ||
| name: PR Test (MLX) | ||
|
|
||
| on: | ||
| push: | ||
| branches: [main] | ||
| paths: | ||
| - "crates/grpc_client/proto/mlx_engine.proto" | ||
| - "crates/grpc_client/src/mlx_engine.rs" | ||
| - "crates/grpc_client/python/**" | ||
| - "grpc_servicer/smg_grpc_servicer/mlx/**" | ||
| - "grpc_servicer/pyproject.toml" | ||
| - "e2e_test/chat_completions/test_mlx_backend.py" | ||
| - "e2e_test/infra/model_specs.py" | ||
| - "e2e_test/infra/worker.py" | ||
| - "e2e_test/infra/constants.py" | ||
| - ".github/workflows/pr-test-mlx.yml" | ||
| pull_request: | ||
| branches: [main] | ||
| types: [opened, synchronize, reopened] | ||
| paths: | ||
| - "crates/grpc_client/proto/mlx_engine.proto" | ||
| - "crates/grpc_client/src/mlx_engine.rs" | ||
| - "crates/grpc_client/python/**" | ||
| - "grpc_servicer/smg_grpc_servicer/mlx/**" | ||
| - "grpc_servicer/pyproject.toml" | ||
| - "e2e_test/chat_completions/test_mlx_backend.py" | ||
| - "e2e_test/infra/model_specs.py" | ||
| - "e2e_test/infra/worker.py" | ||
| - "e2e_test/infra/constants.py" | ||
| - ".github/workflows/pr-test-mlx.yml" | ||
| workflow_dispatch: | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| concurrency: | ||
| group: mlx-tests-${{ github.ref }} | ||
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | ||
|
|
||
| jobs: | ||
| e2e-mlx: | ||
| name: E2E (MLX on Apple Silicon) | ||
| runs-on: macos-latest | ||
| timeout-minutes: 30 | ||
| permissions: | ||
| contents: read | ||
| env: | ||
| E2E_RUNTIME: mlx | ||
| E2E_ENGINE: mlx | ||
| PYTHONUNBUFFERED: "1" | ||
| steps: | ||
| - name: Checkout code | ||
| uses: actions/checkout@v6 | ||
|
|
||
| - name: Set up Python | ||
| uses: actions/setup-python@v6 | ||
| with: | ||
| python-version: "3.12" | ||
|
|
||
| - name: Install Rust toolchain | ||
| uses: dtolnay/rust-toolchain@stable | ||
|
|
||
| - name: Install protoc | ||
| run: brew install protobuf | ||
|
|
||
| - name: Cache Cargo registry and target | ||
| uses: Swatinem/rust-cache@v2 | ||
| with: | ||
| shared-key: mlx-pr-test | ||
| cache-on-failure: true | ||
|
|
||
| - name: Cache HuggingFace models | ||
| uses: actions/cache@v4 | ||
| with: | ||
| path: ~/.cache/huggingface/hub | ||
| key: hf-mlx-${{ runner.os }}-Qwen3-0.6B-4bit | ||
| restore-keys: | | ||
| hf-mlx-${{ runner.os }}- | ||
|
|
||
| - name: Install uv (for openapi codegen) | ||
| run: pip install uv | ||
|
|
||
| - name: Build smg-grpc-proto Python package (proto codegen) | ||
| run: pip install -e ./crates/grpc_client/python | ||
|
|
||
| - name: Install grpc_servicer with MLX extra (mlx + mlx-lm) | ||
| run: pip install -e "./grpc_servicer[mlx]" | ||
|
|
||
| - name: Build and install SMG Python bindings (ci profile) | ||
| working-directory: bindings/python | ||
| run: | | ||
| pip install maturin | ||
| # `ci` profile (opt-level=2, thin LTO, 16 codegen-units) — faster | ||
| # to compile than release, runtime still plenty fast for a | ||
| # correctness E2E test. | ||
| # Use `maturin build` + `pip install` (not `maturin develop`) | ||
| # because the GitHub-hosted runner's Python is not in a virtualenv. | ||
| maturin build --profile ci --out dist | ||
| pip install dist/*.whl | ||
|
|
||
| - name: Generate Python client types (required by e2e_test/conftest.py) | ||
| run: make generate-python-types | ||
|
|
||
| - name: Install smg-client | ||
| run: pip install ./clients/python | ||
|
|
||
| - name: Install E2E test dependencies | ||
| run: pip install ./e2e_test | ||
|
|
||
| - name: Verify imports | ||
| run: | | ||
| python -c "from smg_grpc_proto import mlx_engine_pb2, mlx_engine_pb2_grpc; print('proto OK')" | ||
| python -c "from smg_grpc_servicer.mlx.servicer import MlxEngineServicer; print('servicer OK')" | ||
| python -c "import smg; print('smg OK')" | ||
| python -c "from smg_client import SmgClient; print('smg_client OK')" | ||
| python -c "import mlx_lm; print('mlx-lm OK')" | ||
|
|
||
| - name: Run MLX E2E tests | ||
| env: | ||
| SHOW_WORKER_LOGS: "1" | ||
| SHOW_ROUTER_LOGS: "1" | ||
| E2E_LOG_DIR: e2e-logs | ||
| run: | | ||
| pytest e2e_test/chat_completions/test_mlx_backend.py \ | ||
| -s -vv \ | ||
| --reruns 1 --reruns-delay 5 | ||
|
|
||
| - name: Upload logs on failure | ||
| if: failure() || cancelled() | ||
| uses: actions/upload-artifact@v7 | ||
| with: | ||
| name: e2e-mlx-logs | ||
| path: e2e-logs/ | ||
| retention-days: 7 | ||
| if-no-files-found: ignore | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| """MLX backend E2E tests (Apple Silicon only). | ||
|
|
||
| Tests the SMG router → gRPC → MLX worker pipeline using mlx-lm's | ||
| BatchGenerator. The MLX backend only supports gRPC mode. | ||
|
|
||
| Run locally with: | ||
| E2E_RUNTIME=mlx pytest e2e_test/chat_completions/test_mlx_backend.py -v | ||
|
|
||
| CI runs on macos-14 (Apple Silicon) with the smallest Qwen3 model (~400 MB). | ||
| See .github/workflows/pr-test-mlx.yml. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import platform | ||
| import sys | ||
|
|
||
| import pytest | ||
|
|
||
| # MLX is Apple Silicon only — skip the entire module elsewhere. | ||
| pytestmark = pytest.mark.skipif( | ||
| sys.platform != "darwin" or platform.machine() != "arm64", | ||
| reason="MLX backend requires Apple Silicon (macOS arm64)", | ||
| ) | ||
|
|
||
|
|
||
| # ── Tools used for function calling tests ──────────────────────────────────── | ||
|
|
||
| WEATHER_TOOL = { | ||
| "type": "function", | ||
| "function": { | ||
| "name": "get_weather", | ||
| "description": "Get the current weather in a given location.", | ||
| "parameters": { | ||
| "type": "object", | ||
| "properties": { | ||
| "location": { | ||
| "type": "string", | ||
| "description": "The city name, e.g. 'Tokyo' or 'Paris'.", | ||
| }, | ||
| "unit": { | ||
| "type": "string", | ||
| "enum": ["celsius", "fahrenheit"], | ||
| }, | ||
| }, | ||
| "required": ["location"], | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
|
|
||
| @pytest.mark.engine("mlx") | ||
| @pytest.mark.gpu(0) # MLX uses unified memory, no discrete GPU | ||
| @pytest.mark.model("mlx-community/Qwen3-0.6B-4bit") | ||
| @pytest.mark.gateway(extra_args=["--tool-call-parser", "qwen"]) | ||
| @pytest.mark.parametrize("setup_backend", ["grpc"], indirect=True) | ||
| class TestMlxBackend: | ||
| """End-to-end tests for the MLX gRPC backend. | ||
|
|
||
| Uses mlx-community/Qwen3-0.6B-4bit (~400 MB) — small enough to download | ||
| and run on a macos-latest GitHub Actions runner. Qwen3 supports both | ||
| native tool calling and thinking mode in a single model. | ||
| """ | ||
|
|
||
| # Qwen3 enters thinking mode by default; disable for the basic chat tests | ||
| # so the small 0.6B model produces actual content within max_tokens budget. | ||
| NO_THINKING = {"chat_template_kwargs": {"enable_thinking": False}} | ||
|
|
||
| def test_basic_chat(self, model, api_client): | ||
| """Non-streaming chat completion goes through the full pipeline.""" | ||
| response = api_client.chat.completions.create( | ||
| model=model, | ||
| messages=[{"role": "user", "content": "Reply with the single word 'OK'."}], | ||
| max_tokens=20, | ||
| temperature=0, | ||
| extra_body=self.NO_THINKING, | ||
| ) | ||
| assert response.id | ||
| assert response.choices[0].message.role == "assistant" | ||
| assert isinstance(response.choices[0].message.content, str) | ||
| assert len(response.choices[0].message.content) > 0 | ||
| assert response.usage.prompt_tokens > 0 | ||
| assert response.usage.completion_tokens > 0 | ||
|
|
||
| def test_streaming_chat(self, model, api_client): | ||
| """Streaming chat yields incremental delta chunks.""" | ||
| stream = api_client.chat.completions.create( | ||
| model=model, | ||
| messages=[{"role": "user", "content": "Count: 1, 2, 3."}], | ||
| max_tokens=30, | ||
| temperature=0, | ||
| stream=True, | ||
| extra_body=self.NO_THINKING, | ||
| ) | ||
| chunks = list(stream) | ||
| # First chunk has the role, subsequent chunks carry content deltas. | ||
| assert len(chunks) >= 2 | ||
| text_pieces = [ | ||
| c.choices[0].delta.content for c in chunks if c.choices and c.choices[0].delta.content | ||
| ] | ||
| assert len(text_pieces) > 0 | ||
| assert "".join(text_pieces).strip() | ||
|
|
||
| def test_tool_calling(self, model, api_client): | ||
| """Qwen3 emits <tool_call> tags; SMG's qwen ToolParser extracts them.""" | ||
| response = api_client.chat.completions.create( | ||
| model=model, | ||
| messages=[ | ||
| {"role": "user", "content": "What's the weather in Tokyo right now?"}, | ||
| ], | ||
| tools=[WEATHER_TOOL], | ||
| tool_choice="auto", | ||
| max_tokens=300, | ||
| temperature=0, | ||
| ) | ||
| msg = response.choices[0].message | ||
| assert response.choices[0].finish_reason == "tool_calls", ( | ||
| f"Expected finish_reason=tool_calls, got {response.choices[0].finish_reason!r} " | ||
| f"with content={msg.content!r}" | ||
| ) | ||
| assert msg.tool_calls, "Expected tool_calls to be populated" | ||
| call = msg.tool_calls[0] | ||
| assert call.function.name == "get_weather" | ||
| # arguments is a JSON string per OpenAI spec | ||
| args = json.loads(call.function.arguments) | ||
| assert "tokyo" in args.get("location", "").lower() | ||
|
|
||
| def test_reasoning_thinking_mode(self, model, api_client): | ||
| """Qwen3 thinking mode populates reasoning_content separately from content.""" | ||
| response = api_client.chat.completions.create( | ||
| model=model, | ||
| messages=[ | ||
| { | ||
| "role": "user", | ||
| "content": ( | ||
| "Alice has 5 apples. She gives 2 to Bob. " | ||
| "How many apples does Alice have? Think step by step." | ||
| ), | ||
| } | ||
| ], | ||
| max_tokens=600, | ||
| temperature=0, | ||
| ) | ||
| msg = response.choices[0].message | ||
| # Qwen3-0.6B may or may not always use thinking mode for trivial questions, | ||
| # but at minimum the final answer must mention 3. | ||
| content = msg.content or "" | ||
| reasoning = getattr(msg, "reasoning_content", None) or "" | ||
| full = content + reasoning | ||
| assert "3" in full, ( | ||
| f"Expected '3' in response, got content={content!r} reasoning={reasoning!r}" | ||
| ) | ||
|
|
||
| def test_max_tokens_finish_reason(self, model, api_client): | ||
| """When max_tokens is reached, finish_reason is 'length'.""" | ||
| response = api_client.chat.completions.create( | ||
| model=model, | ||
| messages=[{"role": "user", "content": "Tell me a story."}], | ||
| max_tokens=10, | ||
| temperature=0, | ||
| extra_body=self.NO_THINKING, | ||
| ) | ||
| assert response.choices[0].finish_reason == "length" | ||
| assert response.usage.completion_tokens == 10 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.