685 lines
24 KiB
Python
685 lines
24 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
|
|
"""Round trip parity CI: render -> generate -> derender -> render == render.
|
|
|
|
Pins the coupled parsing path (`/v1/chat/completions` on a normal GPU
|
|
server which parses generated tokens incrementally as they stream out)
|
|
against the disaggregated path (`OnlineDerenderer.derender_chat` via
|
|
`/v1/chat/completions/derender` which parses the same tokens all at once,
|
|
out of process). A standard `vllm serve` GPU server mounts both, so one
|
|
real generation lets both parsers run on identical input.
|
|
|
|
Both paths consume the same generated token IDs (extracted from the coupled
|
|
response's `token_ids` via `return_token_ids=True`), so generation
|
|
nondeterminism is irrelevant. The only variable under test is whether the
|
|
two parsing code paths agree. Parity is asserted unconditionally as only the
|
|
stronger per case assertions (e.g. "a tool call was produced") are gated
|
|
behind the marker actually having been emitted since a 1.5B model is not
|
|
guaranteed to emit `<think>` / `<tool_call>`.
|
|
|
|
The streaming cases do the same for `stream=true`. Each coupled chunk's
|
|
`token_ids` becomes one `/inference/v1/generate` stream chunk, fed
|
|
through the chunked derender path with `stream_state` threaded across calls
|
|
the way a client would, so both parsers see the same chunk boundaries.
|
|
|
|
The text level cases pin `/inference/v1/generate` with `output_mode="text"`
|
|
against `/v1/completions/derender` on the same token IDs, batch and streaming,
|
|
plus logprob resolution and stop string handling.
|
|
"""
|
|
|
|
import json
|
|
|
|
import httpx
|
|
import pytest
|
|
import pytest_asyncio
|
|
import regex as re
|
|
|
|
from tests.entrypoints.scale_out.derender.utils import (
|
|
assemble_stream,
|
|
stream_chat_derender,
|
|
)
|
|
from tests.utils import RemoteOpenAIServer
|
|
|
|
MODEL = "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B"
|
|
ARGS = [
|
|
"--enable-auto-tool-choice",
|
|
"--tool-call-parser",
|
|
"hermes",
|
|
"--reasoning-parser",
|
|
"deepseek_r1",
|
|
"--enable-scale-out",
|
|
]
|
|
|
|
TOOLS = [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": "get_weather",
|
|
"description": "Get weather for a city",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"city": {"type": "string"}},
|
|
},
|
|
},
|
|
}
|
|
]
|
|
FORCE_WEATHER_TOOL = {"type": "function", "function": {"name": "get_weather"}}
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def server():
|
|
with RemoteOpenAIServer(MODEL, ARGS) as remote_server:
|
|
yield remote_server
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def client(server):
|
|
async with httpx.AsyncClient(
|
|
base_url=server.url_for(""), timeout=60.0
|
|
) as http_client:
|
|
yield http_client
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _coupled(client: httpx.AsyncClient, messages: list[dict], **extra) -> dict:
|
|
resp = await client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": MODEL,
|
|
"messages": messages,
|
|
"temperature": 0,
|
|
"max_tokens": 128,
|
|
"return_token_ids": True,
|
|
**extra,
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
return resp.json()
|
|
|
|
|
|
async def _disagg(
|
|
client: httpx.AsyncClient,
|
|
output_ids: list[int],
|
|
prompt_tokens: int,
|
|
finish_reason: str,
|
|
chat_request: dict,
|
|
logprobs: dict | None = None,
|
|
) -> dict:
|
|
resp = await client.post(
|
|
"/v1/chat/completions/derender",
|
|
json={
|
|
"model": MODEL,
|
|
"generate_response": {
|
|
"request_id": "parity",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"token_ids": output_ids,
|
|
"finish_reason": finish_reason,
|
|
"logprobs": logprobs,
|
|
}
|
|
],
|
|
},
|
|
"prompt_tokens": prompt_tokens,
|
|
"chat_request": chat_request,
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
return resp.json()
|
|
|
|
|
|
def _tool_sig(response_choice: dict) -> list[tuple[str, dict]]:
|
|
"""[(name, json normalized args)] so key ordering / whitespace don't
|
|
cause false negatives."""
|
|
return [
|
|
(tc["function"]["name"], json.loads(tc["function"]["arguments"]))
|
|
for tc in (response_choice["message"].get("tool_calls") or [])
|
|
]
|
|
|
|
|
|
def _assert_parity(coupled: dict, disagg: dict) -> None:
|
|
"""Both paths saw the same tokens, so they must agree unconditionally."""
|
|
c, d = coupled["choices"][0], disagg["choices"][0]
|
|
assert d["message"]["content"] == c["message"]["content"]
|
|
assert d["message"].get("reasoning") == c["message"].get("reasoning")
|
|
assert _tool_sig(d) == _tool_sig(c)
|
|
assert d["finish_reason"] == c["finish_reason"]
|
|
assert disagg["usage"]["prompt_tokens"] == coupled["usage"]["prompt_tokens"]
|
|
assert disagg["usage"]["completion_tokens"] == len(c["token_ids"])
|
|
|
|
|
|
async def _run_parity_case(
|
|
client: httpx.AsyncClient, messages: list[dict], **extra
|
|
) -> tuple[dict, dict]:
|
|
"""Run the coupled request then feed its generated tokens into the
|
|
disaggregated derender endpoint. Returns (coupled, disagg)."""
|
|
coupled = await _coupled(client, messages, **extra)
|
|
ch = coupled["choices"][0]
|
|
chat_request = {"model": MODEL, "messages": messages, **extra}
|
|
disagg = await _disagg(
|
|
client,
|
|
ch["token_ids"],
|
|
coupled["usage"]["prompt_tokens"],
|
|
ch["finish_reason"],
|
|
chat_request,
|
|
)
|
|
return coupled, disagg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Parity cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parity_plain(client):
|
|
"""Plain detokenization parity. No reasoning/tool markers involved."""
|
|
messages = [
|
|
{"role": "user", "content": "What is 2+2? Answer in one short sentence."}
|
|
]
|
|
coupled, disagg = await _run_parity_case(client, messages)
|
|
_assert_parity(coupled, disagg)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parity_reasoning(client):
|
|
"""Reasoning/content split parity for <think>...</think> outputs."""
|
|
messages = [{"role": "user", "content": "What is 17 times 23? Think it through."}]
|
|
coupled, disagg = await _run_parity_case(
|
|
client, messages, include_reasoning=True, max_tokens=256
|
|
)
|
|
_assert_parity(coupled, disagg)
|
|
|
|
if not coupled["choices"][0]["message"].get("reasoning"):
|
|
pytest.skip("Model did not emit a <think> block")
|
|
assert disagg["choices"][0]["message"]["reasoning"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parity_tool_call(client):
|
|
"""Tool call name+args parity."""
|
|
messages = [{"role": "user", "content": "What's the weather in Paris?"}]
|
|
coupled, disagg = await _run_parity_case(
|
|
client, messages, tools=TOOLS, tool_choice=FORCE_WEATHER_TOOL, max_tokens=1024
|
|
)
|
|
_assert_parity(coupled, disagg)
|
|
|
|
if not _tool_sig(coupled["choices"][0]):
|
|
pytest.skip("Model did not emit a tool call")
|
|
assert _tool_sig(disagg["choices"][0])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parity_reasoning_and_tool_call(client):
|
|
"""Combined reasoning + tool call parity means the highest drift risk
|
|
since it exercises both parser branches on the same output."""
|
|
messages = [{"role": "user", "content": "What's the weather in Paris?"}]
|
|
coupled, disagg = await _run_parity_case(
|
|
client,
|
|
messages,
|
|
tools=TOOLS,
|
|
tool_choice=FORCE_WEATHER_TOOL,
|
|
include_reasoning=True,
|
|
max_tokens=1024,
|
|
)
|
|
_assert_parity(coupled, disagg)
|
|
|
|
c_msg = coupled["choices"][0]["message"]
|
|
if not (c_msg.get("reasoning") and _tool_sig(coupled["choices"][0])):
|
|
pytest.skip("Model did not emit both a <think> block and a tool call")
|
|
d_msg = disagg["choices"][0]["message"]
|
|
assert d_msg["reasoning"]
|
|
assert _tool_sig(disagg["choices"][0])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parity_logprobs(client):
|
|
"""token_id:N resolution parity vs. the coupled server's real strings.
|
|
|
|
A real disaggregated worker only has token IDs so it emits logprobs
|
|
with `token_id:N` placeholders (`return_tokens_as_token_ids=True`
|
|
reproduces that shape here). `/derender` must resolve those
|
|
placeholders to the same token strings/bytes the coupled server
|
|
resolves them to directly.
|
|
"""
|
|
messages = [{"role": "user", "content": "What is 2+2?"}]
|
|
extra = {"logprobs": True, "top_logprobs": 3}
|
|
|
|
# What a real GPU less worker would hand to /derender is token IDs plus
|
|
# logprobs still in token_id:N placeholder form
|
|
placeholder = await _coupled(
|
|
client, messages, return_tokens_as_token_ids=True, **extra
|
|
)
|
|
ch = placeholder["choices"][0]
|
|
chat_request = {"model": MODEL, "messages": messages, **extra}
|
|
disagg = await _disagg(
|
|
client,
|
|
ch["token_ids"],
|
|
placeholder["usage"]["prompt_tokens"],
|
|
ch["finish_reason"],
|
|
chat_request,
|
|
logprobs=ch["logprobs"],
|
|
)
|
|
|
|
# The coupled server resolving the same greedy generation
|
|
# to real token strings itself.
|
|
resolved = await _coupled(client, messages, **extra)
|
|
assert resolved["choices"][0]["token_ids"] == ch["token_ids"], (
|
|
"greedy (temperature=0) generation was expected to be deterministic "
|
|
"across the two coupled calls used to build this test's fixtures"
|
|
)
|
|
_assert_parity(resolved, disagg)
|
|
|
|
r_content = resolved["choices"][0]["logprobs"]["content"]
|
|
d_content = disagg["choices"][0]["logprobs"]["content"]
|
|
assert len(d_content) == len(r_content)
|
|
for d_entry, r_entry in zip(d_content, r_content):
|
|
assert d_entry["token"] == r_entry["token"]
|
|
assert d_entry["bytes"] == r_entry["bytes"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Streaming helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _coupled_stream(
|
|
client: httpx.AsyncClient, messages: list[dict], **extra
|
|
) -> tuple[list[dict], list[int]]:
|
|
"""Stream `/v1/chat/completions` and return the choices that carry
|
|
`token_ids` along with the prompt token IDs.
|
|
|
|
The leading role only chunk has no `token_ids` and no counterpart on
|
|
`/inference/v1/generate`, so it is dropped.
|
|
"""
|
|
choices: list[dict] = []
|
|
prompt_token_ids: list[int] | None = None
|
|
async with client.stream(
|
|
"POST",
|
|
"/v1/chat/completions",
|
|
json={
|
|
"model": MODEL,
|
|
"messages": messages,
|
|
"temperature": 0,
|
|
"max_tokens": 128,
|
|
"return_token_ids": True,
|
|
"stream": True,
|
|
**extra,
|
|
},
|
|
) as resp:
|
|
assert resp.status_code == 200, await resp.aread()
|
|
async for line in resp.aiter_lines():
|
|
if not line.startswith("data: "):
|
|
continue
|
|
data = line[len("data: ") :]
|
|
if data == "[DONE]":
|
|
break
|
|
chunk = json.loads(data)
|
|
assert "error" not in chunk, chunk
|
|
if chunk.get("prompt_token_ids") is not None:
|
|
prompt_token_ids = chunk["prompt_token_ids"]
|
|
choices.extend(
|
|
ch for ch in chunk["choices"] if ch.get("token_ids") is not None
|
|
)
|
|
assert prompt_token_ids is not None
|
|
return choices, prompt_token_ids
|
|
|
|
|
|
def _tool_call_id_shape(tool_call_id: str | None) -> str | None:
|
|
"""Drop the random suffix so IDs compare by format, e.g. `chatcmpl-tool-`.
|
|
Deterministic IDs such as kimi_k2's `functions.<name>:<idx>` are kept
|
|
whole."""
|
|
if tool_call_id is None:
|
|
return None
|
|
return re.sub(r"[0-9a-f]{16}$", "", tool_call_id)
|
|
|
|
|
|
async def _run_stream_parity_case(
|
|
client: httpx.AsyncClient,
|
|
messages: list[dict],
|
|
finish_only_tail: bool = False,
|
|
**extra,
|
|
) -> tuple[dict, list[dict]]:
|
|
"""Stream the coupled request, replay its chunks through the streaming
|
|
derender endpoint and assert the assembled messages agree. Returns the
|
|
assembled coupled message and the coupled choices.
|
|
|
|
Derender gets the engine's finish reason, since `"tool_calls"` is a chat
|
|
level rewrite of `"stop"` that derender has to reapply itself. With
|
|
`finish_only_tail` it goes on a trailing chunk with no tokens.
|
|
"""
|
|
coupled_choices, prompt_token_ids = await _coupled_stream(client, messages, **extra)
|
|
finish_reason = coupled_choices[-1]["finish_reason"]
|
|
disagg = await stream_chat_derender(
|
|
client,
|
|
[tid for ch in coupled_choices for tid in ch["token_ids"]],
|
|
[len(ch["token_ids"]) for ch in coupled_choices]
|
|
+ ([0] if finish_only_tail else []),
|
|
{"model": MODEL, "messages": messages, **extra},
|
|
len(prompt_token_ids),
|
|
prompt_token_ids,
|
|
finish_reason="stop" if finish_reason == "tool_calls" else finish_reason,
|
|
)
|
|
coupled = assemble_stream(coupled_choices)
|
|
for message in (coupled, disagg):
|
|
for tc in message["tool_calls"]:
|
|
tc["id"] = _tool_call_id_shape(tc["id"])
|
|
assert disagg == coupled
|
|
return coupled, coupled_choices
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Streaming parity cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_parity_plain(client):
|
|
"""Plain streamed detokenization parity."""
|
|
messages = [
|
|
{"role": "user", "content": "What is 2+2? Answer in one short sentence."}
|
|
]
|
|
await _run_stream_parity_case(client, messages)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_parity_reasoning(client):
|
|
"""Streamed reasoning/content split parity."""
|
|
messages = [{"role": "user", "content": "What is 17 times 23? Think it through."}]
|
|
coupled, _ = await _run_stream_parity_case(
|
|
client, messages, include_reasoning=True, max_tokens=256
|
|
)
|
|
|
|
if not coupled["reasoning"]:
|
|
pytest.skip("Model did not emit a <think> block")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stream_parity_tool_call(client):
|
|
"""Streamed tool call parity under `tool_choice="auto"`, where a tool
|
|
call also rewrites `finish_reason` to `"tool_calls"`."""
|
|
messages = [{"role": "user", "content": "What's the weather in Paris?"}]
|
|
coupled, _ = await _run_stream_parity_case(
|
|
client, messages, tools=TOOLS, tool_choice="auto", max_tokens=512
|
|
)
|
|
|
|
if not coupled["tool_calls"]:
|
|
pytest.skip("Model did not emit a tool call")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"chunking", ["engine_steps", "multi_token", "finish_only_tail"]
|
|
)
|
|
async def test_stream_parity_reasoning_and_tool_call(client, chunking):
|
|
"""Streamed reasoning + forced tool call parity across chunk shapes.
|
|
|
|
`multi_token` batches tokens per chunk with `stream_interval` the way
|
|
speculative decoding does, so replay has to reuse the original chunk
|
|
boundaries. `finish_only_tail` sends the finish reason on a chunk with no
|
|
tokens, which must still flush buffered tool call arguments.
|
|
"""
|
|
messages = [{"role": "user", "content": "What's the weather in Paris?"}]
|
|
extra: dict = {
|
|
"tools": TOOLS,
|
|
"tool_choice": FORCE_WEATHER_TOOL,
|
|
"include_reasoning": True,
|
|
"max_tokens": 512,
|
|
}
|
|
if chunking == "multi_token":
|
|
extra["stream_interval"] = 4
|
|
coupled, coupled_choices = await _run_stream_parity_case(
|
|
client,
|
|
messages,
|
|
finish_only_tail=chunking == "finish_only_tail",
|
|
**extra,
|
|
)
|
|
|
|
if chunking == "multi_token":
|
|
assert any(len(ch["token_ids"]) > 1 for ch in coupled_choices)
|
|
if not (coupled["reasoning"] and coupled["tool_calls"]):
|
|
pytest.skip("Model did not emit both a <think> block and a tool call")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Inline text level: generate(output_mode="text") == derender(generate(...))
|
|
# ---------------------------------------------------------------------------
|
|
|
|
TEXT_MESSAGES = [
|
|
{"role": "user", "content": "What is 2+2? Answer in one short sentence."}
|
|
]
|
|
|
|
|
|
async def _render_token_ids(
|
|
client: httpx.AsyncClient, messages: list[dict]
|
|
) -> list[int]:
|
|
resp = await client.post(
|
|
"/v1/chat/completions/render", json={"model": MODEL, "messages": messages}
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
return resp.json()["token_ids"]
|
|
|
|
|
|
def _generate_payload(
|
|
token_ids: list[int], output_mode: str, stream: bool = False, **sampling
|
|
) -> dict:
|
|
return {
|
|
"model": MODEL,
|
|
"token_ids": token_ids,
|
|
"sampling_params": {"temperature": 0, "max_tokens": 128, **sampling},
|
|
"output_mode": output_mode,
|
|
"stream": stream,
|
|
}
|
|
|
|
|
|
async def _generate(
|
|
client: httpx.AsyncClient, token_ids: list[int], output_mode: str, **sampling
|
|
) -> dict:
|
|
resp = await client.post(
|
|
"/inference/v1/generate",
|
|
json=_generate_payload(token_ids, output_mode, **sampling),
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
data = resp.json()
|
|
assert data["output_mode"] == output_mode
|
|
return data
|
|
|
|
|
|
async def _generate_text_stream(
|
|
client: httpx.AsyncClient, token_ids: list[int], **sampling
|
|
) -> list[dict]:
|
|
"""Stream generate at the text level and return the choice of every chunk
|
|
that has one."""
|
|
choices: list[dict] = []
|
|
async with client.stream(
|
|
"POST",
|
|
"/inference/v1/generate",
|
|
json=_generate_payload(token_ids, "text", stream=True, **sampling),
|
|
) as resp:
|
|
assert resp.status_code == 200, await resp.aread()
|
|
async for line in resp.aiter_lines():
|
|
if not line.startswith("data: ") or line == "data: [DONE]":
|
|
continue
|
|
chunk = json.loads(line[len("data: ") :])
|
|
assert "error" not in chunk, chunk
|
|
assert chunk["output_mode"] == "text"
|
|
choices.extend(chunk["choices"])
|
|
return choices
|
|
|
|
|
|
async def _derender_completion_text(
|
|
client: httpx.AsyncClient, output_ids: list[int], finish_reason: str
|
|
) -> str:
|
|
resp = await client.post(
|
|
"/v1/completions/derender",
|
|
json={
|
|
"model": MODEL,
|
|
"generate_responses": [
|
|
{
|
|
"request_id": "text-parity",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"token_ids": output_ids,
|
|
"finish_reason": finish_reason,
|
|
}
|
|
],
|
|
}
|
|
],
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
return resp.json()["choices"][0]["text"]
|
|
|
|
|
|
async def _derender_completion_stream_text(
|
|
client: httpx.AsyncClient, choices: list[dict]
|
|
) -> str:
|
|
"""Replay each generate chunk's token IDs through the streaming
|
|
completions derender endpoint with the same chunk boundaries."""
|
|
state = None
|
|
text = ""
|
|
for choice in choices:
|
|
resp = await client.post(
|
|
"/v1/completions/derender",
|
|
json={
|
|
"stream": True,
|
|
"model": MODEL,
|
|
"generate_chunk": {
|
|
"request_id": "text-parity-stream",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"token_ids": choice.get("token_ids") or [],
|
|
"finish_reason": choice.get("finish_reason"),
|
|
}
|
|
],
|
|
},
|
|
"stream_state": state,
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
data = resp.json()
|
|
state = data["stream_state"]
|
|
text += "".join(ch["text"] for ch in data["chunk"]["choices"])
|
|
return text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_text_parity_batch(client):
|
|
"""Inline text matches /v1/completions/derender on the same token IDs."""
|
|
token_ids = await _render_token_ids(client, TEXT_MESSAGES)
|
|
inline = (await _generate(client, token_ids, "text"))["choices"][0]
|
|
|
|
derendered = await _derender_completion_text(
|
|
client, inline["token_ids"], inline["finish_reason"]
|
|
)
|
|
|
|
assert inline["text"]
|
|
assert inline["text"] == derendered
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_text_parity_stream(client):
|
|
"""Streamed inline text matches both the streaming and the non-streaming
|
|
derender of the same token IDs."""
|
|
token_ids = await _render_token_ids(client, TEXT_MESSAGES)
|
|
choices = await _generate_text_stream(client, token_ids)
|
|
inline = "".join(ch["text"] for ch in choices)
|
|
output_ids = [t for ch in choices for t in ch.get("token_ids") or []]
|
|
finish_reason = choices[-1]["finish_reason"]
|
|
|
|
assert finish_reason is not None
|
|
assert inline == await _derender_completion_stream_text(client, choices)
|
|
assert inline == await _derender_completion_text(client, output_ids, finish_reason)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_text_parity_logprobs(client):
|
|
"""Inline logprob tokens and bytes match /derender resolving the
|
|
token_id:N placeholders of the tokens level."""
|
|
token_ids = await _render_token_ids(client, TEXT_MESSAGES)
|
|
sampling = {"logprobs": 3}
|
|
tokens_choice = (await _generate(client, token_ids, "tokens", **sampling))[
|
|
"choices"
|
|
][0]
|
|
text_choice = (await _generate(client, token_ids, "text", **sampling))["choices"][0]
|
|
assert text_choice["token_ids"] == tokens_choice["token_ids"], (
|
|
"greedy (temperature=0) generation was expected to be deterministic "
|
|
"across the two generate calls"
|
|
)
|
|
|
|
disagg = await _disagg(
|
|
client,
|
|
tokens_choice["token_ids"],
|
|
len(token_ids),
|
|
tokens_choice["finish_reason"],
|
|
{"model": MODEL, "messages": TEXT_MESSAGES},
|
|
logprobs=tokens_choice["logprobs"],
|
|
)
|
|
|
|
inline_content = text_choice["logprobs"]["content"]
|
|
derender_content = disagg["choices"][0]["logprobs"]["content"]
|
|
assert len(inline_content) == len(derender_content)
|
|
for inline_entry, derender_entry in zip(inline_content, derender_content):
|
|
assert inline_entry["token"] == derender_entry["token"]
|
|
assert inline_entry["bytes"] == derender_entry["bytes"]
|
|
assert [
|
|
(top["token"], top["bytes"]) for top in inline_entry["top_logprobs"]
|
|
] == [(top["token"], top["bytes"]) for top in derender_entry["top_logprobs"]]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("stream", [False, True])
|
|
async def test_text_parity_stop_string(client, stream):
|
|
"""Inline text follows the engine's stop handling, like /v1/completions.
|
|
|
|
token_ids keep every generated token, so /derender brings the matched
|
|
stop string back. Its text must start with the inline text and only
|
|
differ after it.
|
|
"""
|
|
stop = "\n"
|
|
token_ids = await _render_token_ids(
|
|
client,
|
|
[{"role": "user", "content": "Write two short lines about the sea."}],
|
|
)
|
|
|
|
resp = await client.post(
|
|
"/v1/completions",
|
|
json={
|
|
"model": MODEL,
|
|
"prompt": token_ids,
|
|
"temperature": 0,
|
|
"max_tokens": 128,
|
|
"stop": [stop],
|
|
},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
coupled = resp.json()["choices"][0]
|
|
|
|
if stream:
|
|
choices = await _generate_text_stream(client, token_ids, stop=[stop])
|
|
inline_text = "".join(ch["text"] for ch in choices)
|
|
output_ids = [t for ch in choices for t in ch.get("token_ids") or []]
|
|
finish_reason = choices[-1]["finish_reason"]
|
|
else:
|
|
inline = (await _generate(client, token_ids, "text", stop=[stop]))["choices"][0]
|
|
inline_text = inline["text"]
|
|
output_ids = inline["token_ids"]
|
|
finish_reason = inline["finish_reason"]
|
|
|
|
assert inline_text == coupled["text"]
|
|
assert finish_reason == coupled["finish_reason"]
|
|
assert stop not in inline_text
|
|
|
|
derendered = await _derender_completion_text(client, output_ids, finish_reason)
|
|
assert derendered.startswith(inline_text)
|
|
if coupled["stop_reason"] != stop:
|
|
pytest.skip("Model did not emit the stop string")
|
|
assert derendered[len(inline_text) :].startswith(stop)
|