feat(router): add tokenizer asset registry - #5376
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces an independent, token-aware router service for DeepSeek-V4 on Xinference, adding a persistent SQLite configuration store, an in-memory runtime registry, a dedicated router process, and REST endpoints to proxy chat completions based on token budgets or typed routing rules. The review feedback highlights several important improvements: ensuring compatibility with Python 3.9 and below by catching asyncio.TimeoutError instead of the built-in TimeoutError in control_plane.py and admission.py, reusing a single httpx.AsyncClient instance in restful_api.py to avoid socket exhaustion, and optimizing performance in tokenizer.py by replacing expensive copy.deepcopy calls with shallow copies.
052331d to
a41b00a
Compare
81febd2 to
9f04632
Compare
dc305b8 to
239bcbb
Compare
qinxuye
left a comment
There was a problem hiding this comment.
Three findings from the current-head review:
| path: str, capabilities: Dict[str, bool], connection: Any | ||
| ) -> None: | ||
| try: | ||
| connection.send(("ok", _execute_smoke_test(Path(path), capabilities))) |
There was a problem hiding this comment.
[P1] Do not execute asset Python with Supervisor privileges
This calls DeepSeekV4TokenEstimator, which imports and executes the asset's encoding/encoding_dsv4.py. A spawned daemon process is not a security sandbox: it still inherits the Supervisor's environment, filesystem identity, and network access. Unlike the Router tokenization worker, this child does not even remove the API/internal tokens, and the checksum is declared by the same asset manifest rather than an independent trust root. A compromised asset can therefore read control-plane secrets or modify Supervisor-owned state during create/validate. Please run this in a genuinely least-privilege sandbox (or remove executable asset code from Supervisor validation), with secrets, filesystem/network access, and the trust anchor isolated from the asset.
There was a problem hiding this comment.
Fixed in 462cd8e. The Supervisor no longer executes any asset Python.
- Removed
_execute_smoke_test/_smoke_test_worker/_smoke_testand_SMOKE_TEST_TIMEOUT_SECONDSfromxinference/core/tokenizer_asset_registry.py.DeepSeekV4TokenEstimatoris never instantiated in the Supervisor or any child process it spawns. - Validation is now purely static: manifest structure/field checks, SHA-256 checksum verification, path-safety checks (absolute paths,
..traversal, escape from the asset directory), and file hashing for the fingerprint. validate_path()and_inspect_entry()keep thesmoke_testparameter only for API compatibility; it is unused.- Tests updated to assert the static results (
required_files,checksums,manifest,capabilities) instead of smoke-test checks, includingtest_registered_asset_list_resolve_and_validateandtest_declared_capabilities_are_reported.
| "tokenizer_asset": { | ||
| "asset_id": snapshot.config.tokenizer_asset_id, | ||
| "revision": snapshot.config.tokenizer_asset_revision, | ||
| "fingerprint": snapshot.config.tokenizer_asset_fingerprint, |
There was a problem hiding this comment.
[P1] Report the asset that was actually loaded
These values are copied from the control-plane config, and validate_token_router() compares them with that same stored config. The Router never hashes the files loaded by TokenizationService, so a different asset at the same path (for example on another host/mount, or after a TOCTOU replacement) still reports the expected fingerprint and passes validation. Please compute the fingerprint/revision from the files actually loaded by the Router/tokenization worker and publish that measured value in the heartbeat; add a test where the configured fingerprint differs from the local asset contents.
There was a problem hiding this comment.
Fixed in 462cd8e. The Router now measures the asset it actually loaded and publishes the measured values.
- New
xinference/router/tokenizer_asset.py:compute_tokenizer_asset_fingerprint()hashes the files the worker uses (tokenizer.json,encoding/encoding_dsv4.py) with a path-safety check, andread_tokenizer_asset_revision()readsasset.jsonrevision statically (JSON only, no code execution). The same aggregation function is shared with the registry so both sides compute identical fingerprints. initialize_tokenization_worker()now computes the fingerprint and revision before and after constructingDeepSeekV4TokenEstimator; if they differ (TOCTOU replacement during load) initialization raises. It also scrubs all three Router credential env vars.ping_worker()returns the measured fingerprint/revision;TokenizationService.start()verifies every worker loaded the same asset (single non-empty fingerprint and single revision) and exposesasset_fingerprint/asset_revision.- The heartbeat
process.tokenizer_assetandRouterRuntime.summary()now report the measured values from the snapshot instead of copying the control-plane config, and Supervisor validation compares against the measured values. - Regression coverage:
test_start_measures_tokenizer_asset_fingerprintandtest_validation_detects_loaded_fingerprint_mismatch(configured fingerprint differs from local asset contents).
| "tokenizer_asset_id": asset_id, | ||
| "tokenizer_path": resolved_path, | ||
| "tokenizer_asset_revision": str(asset.get("revision", "")), | ||
| "tokenizer_asset_fingerprint": str(asset.get("fingerprint", "")), |
There was a problem hiding this comment.
[P2] Enforce the declared asset capabilities at runtime
tools and thinking currently only decide which smoke tests run. resolve() drops the capability map, RouterConfig has no corresponding field, and the request path still accepts tool/thinking payloads unconditionally. An asset declaring tools: false or thinking: false can therefore be deployed and used for an unsupported request, causing a tokenization failure or incorrect budget/routing. Please propagate the validated capabilities into the Router config and reject unsupported requests/configured rules, with regression coverage for both false flags.
There was a problem hiding this comment.
Fixed in 462cd8e. Validated capabilities now flow into the Router config and are enforced on both the request path and rule validation.
resolve()returnstokenizer_asset_capabilitiesandtokenizer_asset_files;RouterConfiggains both fields andconfig_from_control_plane()reads them (manifest may declare capabilities as a dict or a list).- Request path in
xinference/router/app.py: tool requests are pre-checked before tokenization (400tools_not_allowed), and thinking-mode requests are rejected after budget computation (400thinking_not_allowed). - Rule validation rejects rules that require tools/thinking when the asset does not support them, in both
_validate_config()and Supervisor v2 validation invalidate_token_router(). - Custom paths keep the phase-1 behavior (full capabilities + default required files) for backward compatibility.
- Regression coverage:
test_tools_request_rejected_when_asset_lacks_tools_capability,test_thinking_request_rejected_when_asset_lacks_thinking_capability, v2 rule-rejection cases, andtest_v2_validation_rejects_rules_asset_cannot_support.
- P1-1: stop executing asset Python in the Supervisor; validation is static (manifest, checksum, path safety, file hashes) and smoke tests are removed. - P1-2: Router workers measure the asset fingerprint and asset.json revision after real loading and report them through the heartbeat; the Supervisor compares against measured values, with TOCTOU checks. - P2: propagate asset capabilities into RouterConfig and enforce them on the request path (tools/thinking) and during rule validation.
| ) | ||
| worker_pids = {pid for pid, _, _, _, _ in results} | ||
| if len(worker_pids) >= self._max_workers: | ||
| self._asset_fingerprint = fingerprints.pop() |
There was a problem hiding this comment.
[P1] Fail closed when the measured asset differs from the configuration
This only proves that the tokenization workers agree with one another. TokenizationService is never given the expected fingerprint/revision, so if every worker on this host loads the same wrong asset, startup succeeds, the runtime ACKs the revision and reports ready. resolve_token_router_runtime() also selects ready/ACKed instances without checking the measured heartbeat metadata; the manual validation endpoint therefore does not prevent traffic from reaching the mismatched tokenizer. I reproduced a configured sha256:expected plus heartbeat sha256:swapped resolving as available=True. Please compare the measured values with RouterConfig before the snapshot becomes ready (or exclude mismatched heartbeats in Supervisor runtime selection), and add a regression proving such an instance cannot be resolved/routed.
| tools_present=bool(payload.get("tools")), | ||
| stream=bool(payload.get("stream", False)), | ||
| ) | ||
| if budget.enable_thinking and "thinking" not in capabilities: |
There was a problem hiding this comment.
[P2] Reject unsupported thinking before invoking the asset tokenizer
This capability check runs only after estimate(). An asset declaring thinking: false may fail while rendering the thinking payload, so control never reaches this branch and the client receives a generic invalid_request_error instead of thinking_not_allowed. The new _payload_thinking() helper is currently unused. Please perform equivalent normalized detection before tokenization (including the existing top-level / extra_body / JSON-string chat_template_kwargs precedence), and cover the case where the estimator would raise if it were invoked.
The CI optional deps (diffusers -> huggingface-hub>=1.23) resolve click to 8.4.x, which removed CliRunner(mix_stderr=...) and captures stdout/stderr separately by default. The two failing cmdline tests assumed click <8.2 semantics: - test_cmdline_model_path_error: drop mix_stderr=False (unused by the test). - test_cmdline_of_custom_model: assert on result.output (combined streams on both click <8.2 mix_stderr default and click >=8.4 capture='sys'), since list_model_registrations writes its table to stderr.
CI failure: click 8.4 compatibility for cmdline tests (fixed in eb3a4d6)The failing Root cause: dependency drift. Two failing tests:
Fix (eb3a4d6): make the assertions version-agnostic without changing CLI behavior:
Verified with minimal repros on both click 8.1.8 and 8.4.2; pre-commit passes and the fast unit test in the file passes. New CI run (32428725269) is in progress on eb3a4d6. |
qinxuye
left a comment
There was a problem hiding this comment.
One additional finding in the shutdown follow-up:
| # while applying a new runtime snapshot. Setting the stop | ||
| # event alone cannot interrupt either operation, so cancel | ||
| # the task explicitly before waiting for shutdown. | ||
| control_task.cancel() |
There was a problem hiding this comment.
[P1] Clean up a replacement snapshot when shutdown cancels apply()
This cancellation can land while control_plane.run() is awaiting runtime.apply(). RouterRuntime.apply() only catches Exception around replacement.tokenization.start(), but asyncio.CancelledError is a BaseException, so the replacement snapshot is abandoned without closing its HTTP client, tokenization service, or process pool; the subsequent runtime.aclose() only closes the current snapshot. I reproduced cancellation during replacement startup with all three replacement resources still open, which can make the shutdown hang on orphaned worker processes. Please make the pre-swap portion of apply() cancellation-safe (clean up on CancelledError/BaseException, then re-raise; use bounded/cancellation-safe executor cleanup as needed), and add a regression that blocks replacement startup, cancels apply(), and asserts the replacement snapshot is closed.
Summary
Depends on #5375
Validation
pytest -q xinference/core/tests/test_tokenizer_asset_registry.pypre-commit run --files xinference/constants.py xinference/core/tokenizer_asset_registry.py xinference/core/tests/test_tokenizer_asset_registry.pyPlease review now if convenient, but merge only after the dependency PRs have landed.