Skip to content

Commit 196476a

Browse files
committed
feat: add Dynamo chat nvext transport
1 parent 562db77 commit 196476a

3 files changed

Lines changed: 152 additions & 32 deletions

File tree

renderers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
reject_assistant_in_extension,
3737
trim_to_turn_close,
3838
)
39+
from renderers.client import RendererTransport
3940
from renderers.deepseek_v3 import DeepSeekV3Renderer
4041
from renderers.default import DefaultRenderer
4142
from renderers.glm5 import GLM5Renderer
@@ -80,6 +81,7 @@
8081
"RenderedTokens",
8182
"Renderer",
8283
"RendererPool",
84+
"RendererTransport",
8385
"TextPart",
8486
"ThinkingPart",
8587
"ToolCall",

renderers/client.py

Lines changed: 83 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import asyncio
1515
import base64
1616
import logging
17-
from typing import Any, cast
17+
from typing import Any, Literal, cast
1818

1919
import numpy as np
2020
from openai import AsyncOpenAI, BadRequestError
@@ -28,6 +28,8 @@
2828
ToolSpec,
2929
)
3030

31+
RendererTransport = Literal["vllm", "dynamo"]
32+
3133
_request_logger = logging.getLogger("renderers.client")
3234

3335

@@ -58,12 +60,13 @@ async def generate(
5860
cache_salt: str | None = None,
5961
priority: int | None = None,
6062
extra_headers: dict[str, str] | None = None,
63+
transport: RendererTransport = "vllm",
6164
) -> dict[str, Any]:
62-
"""Tokenize messages, call vLLM /inference/v1/generate, parse the response.
65+
"""Tokenize messages, call the selected token-in backend, parse response.
6366
64-
``sampling_params`` is forwarded to vLLM verbatim. Two fields are always
65-
set by us and override caller values: ``stop_token_ids`` (from the
66-
renderer) and ``logprobs=1`` (we always emit completion_logprobs). Pass
67+
``sampling_params`` is forwarded to the selected token-in backend. Two
68+
fields are always set by us and override caller values: stop token IDs
69+
from the renderer and ``logprobs=1`` (we always emit completion_logprobs). Pass
6770
``prompt_ids`` to skip rendering and use a prebuilt token sequence —
6871
pair it with ``multi_modal_data`` when the prebuilt prompt has image /
6972
video placeholders that need engine-side mm payload.
@@ -101,31 +104,65 @@ def _prepare():
101104
sp["logprobs"] = 1
102105
sp.setdefault("skip_special_tokens", False)
103106

104-
body: dict[str, Any] = {
105-
"model": model,
106-
"token_ids": prompt_ids,
107-
"sampling_params": sp,
108-
}
109-
features = (
110-
_build_mm_features(renderer, mm_data)
111-
if mm_data and not mm_data.is_empty()
112-
else None
113-
)
114-
if features is not None:
115-
body["features"] = features
116-
if cache_salt is not None:
117-
body["cache_salt"] = cache_salt
118-
if priority is not None:
119-
body["priority"] = priority
120-
121-
# /inference/v1/generate is mounted at the server root, not under /v1
122-
# like the OpenAI-compatible endpoints. Build an absolute URL so the
123-
# AsyncOpenAI client doesn't prepend its automatic /v1.
124-
base = str(client.base_url).rstrip("/").removesuffix("/v1")
125-
endpoint = f"{base}/inference/v1/generate"
107+
if transport == "vllm":
108+
body: dict[str, Any] = {
109+
"model": model,
110+
"token_ids": prompt_ids,
111+
"sampling_params": sp,
112+
}
113+
features = (
114+
_build_mm_features(renderer, mm_data)
115+
if mm_data and not mm_data.is_empty()
116+
else None
117+
)
118+
if features is not None:
119+
body["features"] = features
120+
if cache_salt is not None:
121+
body["cache_salt"] = cache_salt
122+
if priority is not None:
123+
body["priority"] = priority
124+
125+
# /inference/v1/generate is mounted at the server root, not under /v1
126+
# like the OpenAI-compatible endpoints. Build an absolute URL so the
127+
# AsyncOpenAI client doesn't prepend its automatic /v1.
128+
base = str(client.base_url).rstrip("/").removesuffix("/v1")
129+
endpoint = f"{base}/inference/v1/generate"
130+
elif transport == "dynamo":
131+
nvext: dict[str, Any] = {
132+
"token_data": prompt_ids,
133+
"extra_fields": ["completion_token_ids"],
134+
}
135+
if priority is not None:
136+
nvext["agent_hints"] = {"priority": priority}
137+
138+
body = {
139+
"model": model,
140+
"messages": [{"role": "user", "content": "(token-in mode)"}],
141+
"stream": False,
142+
"logprobs": True,
143+
"stop": stop_token_ids,
144+
"nvext": nvext,
145+
}
146+
if cache_salt is not None:
147+
body["cache_salt"] = cache_salt
148+
149+
passthrough = dict(sp)
150+
passthrough.pop("stop_token_ids", None)
151+
passthrough.pop("stop", None)
152+
passthrough.pop("logprobs", None)
153+
passthrough.pop("skip_special_tokens", None)
154+
max_tokens = passthrough.pop("max_tokens", None)
155+
if max_tokens is not None:
156+
body["max_completion_tokens"] = max_tokens
157+
body.update({k: v for k, v in passthrough.items() if v is not None})
158+
endpoint = "/chat/completions"
159+
else:
160+
raise ValueError(f"Unsupported renderer transport: {transport}")
161+
126162
_request_logger.debug(
127-
"POST %s prompt_len=%d max_tokens=%s",
163+
"POST %s transport=%s prompt_len=%d max_tokens=%s",
128164
endpoint,
165+
transport,
129166
len(prompt_ids),
130167
sp.get("max_tokens"),
131168
)
@@ -147,7 +184,23 @@ def _prepare():
147184
raise
148185

149186
choice = (data.get("choices") or [{}])[0]
150-
completion_ids = choice.get("token_ids") or []
187+
if transport == "dynamo":
188+
completion_ids = (
189+
choice.get("token_ids")
190+
or choice.get("nvext", {}).get("completion_token_ids")
191+
or data.get("nvext", {}).get("completion_token_ids")
192+
or []
193+
)
194+
raw_re = (
195+
choice.get("routed_experts")
196+
or choice.get("nvext", {}).get("routed_experts")
197+
or data.get("nvext", {}).get("routed_experts")
198+
)
199+
request_id = data.get("id") or data.get("request_id") or ""
200+
else:
201+
completion_ids = choice.get("token_ids") or []
202+
raw_re = choice.get("routed_experts")
203+
request_id = data.get("request_id") or ""
151204

152205
parsed = await _maybe_offload(
153206
renderer, lambda: renderer.parse_response(completion_ids, tools=tools)
@@ -159,7 +212,6 @@ def _prepare():
159212
completion_logprobs = [float(c.get("logprob") or 0.0) for c in content_lp or []]
160213

161214
routed_experts = None
162-
raw_re = choice.get("routed_experts")
163215
if isinstance(raw_re, dict) and "data" in raw_re and "shape" in raw_re:
164216
routed_experts = (
165217
np.frombuffer(base64.b85decode(raw_re["data"]), dtype=np.int32)
@@ -183,7 +235,7 @@ def _prepare():
183235
finish_reason = "tool_calls"
184236

185237
return {
186-
"request_id": data.get("request_id") or "",
238+
"request_id": request_id,
187239
"prompt_ids": list(prompt_ids),
188240
"completion_ids": list(completion_ids),
189241
"completion_logprobs": completion_logprobs,

tests/test_client.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,17 @@ class _FakeClient:
5454
URL off ``client.base_url``, so we expose one that includes the /v1 suffix
5555
the OpenAI SDK normally appends."""
5656

57-
def __init__(self):
57+
def __init__(self, response=None):
5858
self.calls = []
5959
self.base_url = "http://fake-host:8000/v1"
60+
self.response = response
6061

6162
async def post(self, path, *, cast_to=dict, body=None, options=None):
6263
self.calls.append(
6364
{"path": path, "cast_to": cast_to, "body": body, "options": options}
6465
)
66+
if self.response is not None:
67+
return self.response
6568
routed_experts = np.array([[[1]], [[2]]], dtype=np.int32)
6669
return {
6770
"request_id": "gen-test",
@@ -291,3 +294,66 @@ def test_generate_serializes_multimodal_features_for_qwen3_vl():
291294
# Items are base64 strings (encode_mm_kwargs_item output).
292295
for item in features["kwargs_data"]["image"]:
293296
assert isinstance(item, str) and len(item) > 0
297+
298+
299+
def test_generate_can_use_dynamo_transport():
300+
client = _FakeClient(
301+
response={
302+
"id": "chatcmpl-test",
303+
"model": "test-model",
304+
"nvext": {"completion_token_ids": [7, 8]},
305+
"choices": [
306+
{
307+
"logprobs": {
308+
"content": [
309+
{"token": "token_id:7", "logprob": -0.1},
310+
{"token": "token_id:8", "logprob": -0.2},
311+
]
312+
},
313+
"finish_reason": "stop",
314+
}
315+
],
316+
}
317+
)
318+
319+
result = asyncio.run(
320+
generate(
321+
client=client,
322+
renderer=_FakeRenderer(),
323+
messages=[{"role": "user", "content": "hi"}],
324+
model="test-model",
325+
tools=[{"type": "function", "function": {"name": "echo"}}],
326+
sampling_params={
327+
"temperature": 0.3,
328+
"max_tokens": 7,
329+
"min_tokens": 2,
330+
"stop": "caller-stop",
331+
},
332+
priority=4,
333+
cache_salt="ckpt-42",
334+
transport="dynamo",
335+
)
336+
)
337+
338+
assert client.calls[0]["path"] == "/chat/completions"
339+
assert client.calls[0]["body"] == {
340+
"model": "test-model",
341+
"messages": [{"role": "user", "content": "(token-in mode)"}],
342+
"stream": False,
343+
"logprobs": True,
344+
"stop": [99],
345+
"nvext": {
346+
"token_data": [1, 2, 3],
347+
"extra_fields": ["completion_token_ids"],
348+
"agent_hints": {"priority": 4},
349+
},
350+
"cache_salt": "ckpt-42",
351+
"max_completion_tokens": 7,
352+
"temperature": 0.3,
353+
"min_tokens": 2,
354+
}
355+
assert result["request_id"] == "chatcmpl-test"
356+
assert result["prompt_ids"] == [1, 2, 3]
357+
assert result["completion_ids"] == [7, 8]
358+
assert result["completion_logprobs"] == [-0.1, -0.2]
359+
assert result["finish_reason"] == "tool_calls"

0 commit comments

Comments
 (0)