From 67567571cffc4eb37a6cf9f2d01ac474c11aa88a Mon Sep 17 00:00:00 2001 From: Brad Hilton Date: Sat, 26 Sep 2026 13:25:09 +0000 Subject: [PATCH 1/2] Fence lossy serial-tool history certificates by producer policy --- src/art/tinker/server.py | 17 +++- src/art_inference/append_only.py | 29 +++++- src/art_inference/sglang.py | 2 + src/art_inference/vllm.py | 12 ++- tests/unit/test_append_only.py | 100 ++++++++++++++++++ tests/unit/test_inference_history.py | 145 ++++++++++++++++++++++++++- tests/unit/test_sglang_history.py | 39 ++++++- tests/unit/test_tinker_renderers.py | 88 ++++++++++++++++ 8 files changed, 420 insertions(+), 12 deletions(-) diff --git a/src/art/tinker/server.py b/src/art/tinker/server.py index 0909d58b9..0da3e4637 100644 --- a/src/art/tinker/server.py +++ b/src/art/tinker/server.py @@ -38,7 +38,9 @@ from art.tokenizer import get_tokenizer from art.types import Message, Tools from art.utils.append_only import ( + chat_prefix_eligible, chat_prefix_observations, + chat_prefix_scope, has_renderable_tool_arguments, output_prefix_observations, preserves_history, @@ -378,9 +380,11 @@ async def chat_completions( samplable_model = await tenant.get_samplable_model(body["model"]) template_kwargs = cast(dict[str, Any], body).get("chat_template_kwargs") preserve = preserves_history(template_kwargs) - scope = json.dumps( - [id(tenant), samplable_model.base_model, template_kwargs], - sort_keys=True, + scope = chat_prefix_scope( + json.dumps( + [id(tenant), samplable_model.base_model, template_kwargs], + sort_keys=True, + ) ) rendered_prompt_tokens = await worker.prompt_tokens( base_model=samplable_model.base_model, @@ -432,6 +436,7 @@ async def chat_completions( ) = await worker.chat_completion_and_prefixes( base_model=samplable_model.base_model, sample_response=sample_response, + parallel_tool_calls=body.get("parallel_tool_calls"), model_name=body["model"], prompt_tokens=prompt_tokens, rendered_prompt=rendered_prompt_tokens, @@ -633,6 +638,7 @@ async def chat_completion_and_prefixes( messages: list[ChatCompletionMessageParam], tools: list[ChatCompletionToolUnionParam] | None, chat_template_kwargs: dict[str, Any] | None = None, + parallel_tool_calls: bool | None = None, ) -> tuple[ChatCompletion, list[tuple[list[int], list[int], tuple[Any, ...]]]]: renderer = self._get_renderer(base_model) choices: list[Choice] = [] @@ -683,7 +689,10 @@ async def render(assistant: dict[str, Any]) -> list[int]: ) if reasoning else None, - complete=sequence.stop_reason == "stop", + complete=sequence.stop_reason == "stop" + and chat_prefix_eligible( + parallel_tool_calls, tools, openai_message + ), ) ) tool_calls = ( diff --git a/src/art_inference/append_only.py b/src/art_inference/append_only.py index fb0184cbe..3ce0c5f74 100644 --- a/src/art_inference/append_only.py +++ b/src/art_inference/append_only.py @@ -8,6 +8,7 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence from functools import wraps +import hashlib import json import sys from typing import Any @@ -15,6 +16,25 @@ from .token_prefix import PrefixEdit, prefix_edits PrefixObservation = tuple[list[int], list[int], tuple[PrefixEdit, ...]] +CHAT_PREFIX_POLICY = "serial-tool-lossless-v1" + + +def chat_prefix_scope(base_scope: str) -> str: + """Fence certificates produced before serial-tool projection was checked. + + Producers must derive this namespace themselves, not accept a caller's + policy label. Reads, including local and fallback reads, use only this scope. + """ + return hashlib.sha256( + json.dumps([CHAT_PREFIX_POLICY, base_scope], separators=(",", ":")).encode() + ).hexdigest() + + +def chat_prefix_eligible( + parallel_tool_calls: bool | None, tools: Any, message: Mapping[str, Any] +) -> bool: + """Serial projection has no trusted witness for omitted sampled calls.""" + return parallel_tool_calls is not False or not (tools or message.get("tool_calls")) def _rendering_edits(tokenizer, rendered, raw): @@ -500,7 +520,14 @@ async def complete(message: Mapping[str, Any]) -> list[int] | None: raw_prompt, output, reasoning_prompt=reasoning_prompt, - complete=finished, + # Serial tool projection can hide sampled calls even when the + # parsed result contains only one (or no) call. Its terminal + # token does not prove full-turn equivalence; independently + # aligned reasoning remains eligible above. + complete=finished + and chat_prefix_eligible( + payload.get("parallel_tool_calls"), payload.get("tools"), message + ), ) ) return entries diff --git a/src/art_inference/sglang.py b/src/art_inference/sglang.py index 680a9bd39..b047bf890 100644 --- a/src/art_inference/sglang.py +++ b/src/art_inference/sglang.py @@ -325,6 +325,8 @@ async def record_response(payload): protocol = importer("sglang.srt.entrypoints.openai.protocol") view = protocol.ChatCompletionRequest( model=request.model, + parallel_tool_calls=getattr(request, "parallel_tool_calls", None) + is not False, messages=self._construct_input_messages(request, previous), tools=self._response_tools_to_chat_tools(request) or None, chat_template=getattr(request, "chat_template", None), diff --git a/src/art_inference/vllm.py b/src/art_inference/vllm.py index f796f0546..cd6c8c912 100644 --- a/src/art_inference/vllm.py +++ b/src/art_inference/vllm.py @@ -16,6 +16,7 @@ from .append_only import ( aligned_values, + chat_prefix_scope, chat_response_prefixes, merge_chat_delta, openai_tool_arguments, @@ -291,9 +292,11 @@ async def create(self, request, raw_request=None): getattr(request, "chat_template_kwargs", None), headers.get("authorization", ""), ] - scope = hashlib.sha256( - json.dumps(material, sort_keys=True).encode() - ).hexdigest() + scope = chat_prefix_scope( + hashlib.sha256( + json.dumps(material, sort_keys=True).encode() + ).hexdigest() + ) turn = _Turn( scope, tokenizer, @@ -353,6 +356,9 @@ async def observe_response(response): ) view = protocol.ChatCompletionRequest( model=request.model, + parallel_tool_calls=getattr(request, "parallel_tool_calls", None) + is not False, + tools=tools or [], messages=[ openai_tool_arguments(message) for message in conversation ], diff --git a/tests/unit/test_append_only.py b/tests/unit/test_append_only.py index 4c761020d..e0a313839 100644 --- a/tests/unit/test_append_only.py +++ b/tests/unit/test_append_only.py @@ -9,6 +9,7 @@ from art.token_prefix import TokenPrefixCache, apply_prefix_edits from art.utils.append_only import chat_prefix_observations, chat_response_prefixes from art_inference.append_only import ( + chat_prefix_scope, output_prefix_observations, patch_deepseek_renderer, ) @@ -57,6 +58,105 @@ def decode(self, tokens, *, skip_special_tokens=False): return bytes(tokens).decode() +@pytest.mark.parametrize("parallel", [False, True, None]) +@pytest.mark.parametrize("visible_calls", [0, 1, 2]) +@pytest.mark.parametrize("reasoning", [False, True]) +def test_serial_projection_cannot_certify_hidden_sampled_actions( + parallel, visible_calls, reasoning +): + class Request(BaseModel): + messages: list[dict] + parallel_tool_calls: bool | None = None + tools: list[dict] = [{"type": "function"}] + + tokenizer = Tokenizer() + encode = tokenizer.encode + request = Request(messages=[{"role": "user"}], parallel_tool_calls=parallel) + calls = [ + {"type": "function", "function": {"name": name, "arguments": "{}"}} + for name in ("first", "second") + ] + message = {"role": "assistant", "tool_calls": calls[:visible_calls]} + if reasoning: + message["reasoning_content"] = "thought" + + async def render(value): + if len(value.messages) == 1: + return encode("prompt:") + assistant = value.messages[-1] + text = "thought#" if assistant.get("reasoning_content") else "" + text += "".join( + call["function"]["name"] for call in assistant.get("tool_calls", []) + ) + return encode("prompt:" + text + "END") + + sampled = encode(("\nthought#" if reasoning else "") + "firstsecondEND") + choices = [(message, sampled, True), (message, sampled, False)] + before = deepcopy((request.model_dump(), choices)) + entries = asyncio.run( + chat_response_prefixes(tokenizer, request, encode("prompt:"), choices, render) + ) + full = [entry for entry in entries if entry[1] == encode("prompt:") + sampled] + assert len(full) == (0 if parallel is False else 1) + if parallel is False: + assert all(b"first" not in bytes(entry[1]) for entry in entries) + # A distinct, aligned reasoning boundary survives only if the parsed + # action makes it distinguishable from the reasoning-only rendering. + assert bool(entries) == bool(reasoning and visible_calls) + assert (request.model_dump(), choices) == before + for rendered, raw, edits in entries: + assert apply_prefix_edits(rendered, edits) == raw + + +def test_serial_non_tool_turn_retains_full_certificate(): + class Request(BaseModel): + messages: list[dict] = [] + parallel_tool_calls: bool = False + + async def render(value): + return [1, 2] if value.messages else [1] + + entries = asyncio.run( + chat_response_prefixes( + Tokenizer(), + Request(), + [1], + [ + ( + { + "role": "assistant", + "tool_calls": [ + {"function": {"name": "first", "arguments": "{}"}} + ], + }, + [3, 2], + True, + ), + ({"role": "assistant"}, [2], True), + ], + render, + ) + ) + assert len(entries) == 1 and entries[0][1] == [1, 2] + + +def test_policy_scope_keeps_old_certificates_in_a_separate_namespace(): + from art_inference.token_prefix import TokenPrefixStore + + cache = TokenPrefixStore() + base = "a" * 64 + current = chat_prefix_scope(base) + assert current != base + assert len(current) == 64 + assert current == chat_prefix_scope(base) + for canonical in ([1, 2], [1, 2, 3]): + cache.insert(base, canonical, [9], "lineage") + assert cache.lookup(current, [1, 2, 3, 4], "lineage") is None + cache.insert(current, [1, 2], [8], "lineage") + match = cache.lookup(current, [1, 2, 3, 4], "lineage") + assert match is not None and match.raw_prefix == (8,) + + def test_many_protocol_markers_do_not_multiply_long_prompt_storage(): tokenizer = SimpleNamespace( all_special_ids=[1, 2, 3], diff --git a/tests/unit/test_inference_history.py b/tests/unit/test_inference_history.py index 646e8d9b9..3f763289e 100644 --- a/tests/unit/test_inference_history.py +++ b/tests/unit/test_inference_history.py @@ -1,5 +1,6 @@ import asyncio import copy +import hashlib import json from types import SimpleNamespace @@ -8,6 +9,7 @@ import pytest from art_inference import vllm +from art_inference.append_only import chat_prefix_scope from art_inference.token_prefix import TokenPrefixStore @@ -25,6 +27,7 @@ class Request(BaseModel): previous_response_id: str | None = None tools: list = [] tool_choice: str = "auto" + parallel_tool_calls: bool | None = None @model_validator(mode="after") def validate_stream(self): @@ -37,6 +40,7 @@ class Message(BaseModel): role: str = "assistant" reasoning_content: str content: str + tool_calls: list[dict] = [] @pytest.fixture @@ -63,6 +67,7 @@ def get_apply_chat_template_kwargs(self): class Engine: def __init__(self): self.prompts = [] + self.sampled = None async def generate(self, prompt, sampling_params=None): self.prompts.append(prompt["prompt_token_ids"]) @@ -71,6 +76,8 @@ async def generate(self, prompt, sampling_params=None): if sampling_params else [b"\nthought\n#action~"] ) + if self.sampled is not None: + chunks = [self.sampled] for index, tokens in enumerate(chunks): yield SimpleNamespace( outputs=[ @@ -90,6 +97,7 @@ def __init__(self): self.chat_template = None self.chat_template_content_format = "string" self.parser = None + self.message = Message(reasoning_content="\nthought\n", content="action") async def preprocess_chat(self, request, messages, **kwargs): return await self.render_chat_request(request) @@ -109,6 +117,10 @@ async def render_chat_request(self, request): + message.get("reasoning_content", "").strip() + "#" + (message.get("content") or "") + + "".join( + call["function"]["name"] + for call in message.get("tool_calls") or [] + ) + "~\n" ) else: @@ -119,7 +131,13 @@ async def render_chat_request(self, request): async def create_chat_completion(self, request, raw_request=None): _, inputs = await self.render_chat_request(request) - message = Message(reasoning_content="\nthought\n", content="action") + message = self.message + samples = { + "token_ids": list(self.engine.sampled or b"\nthought\n#action~"), + "logprobs": [-0.25] + * len(self.engine.sampled or b"\nthought\n#action~"), + } + usage = {"completion_tokens": len(samples["token_ids"])} async def stream(): params = SimpleNamespace(output_kind=SimpleNamespace(name="DELTA")) @@ -128,7 +146,12 @@ async def stream(): yield ( "data: " + json.dumps( - {"choices": [{"index": 0, "delta": message.model_dump()}]} + { + "choices": [ + {"index": 0, "delta": message.model_dump(), **samples} + ], + "usage": usage, + } ) + "\n\n" ) @@ -138,7 +161,10 @@ async def stream(): return stream() async for _ in self.engine.generate(inputs[0]): pass - return SimpleNamespace(choices=[SimpleNamespace(index=0, message=message)]) + return SimpleNamespace( + choices=[SimpleNamespace(index=0, message=message, **samples)], + usage=usage, + ) async def create_completion(self, request, raw_request=None): return await self.create_chat_completion(request, raw_request) @@ -290,6 +316,119 @@ async def run(): assert not vllm._PREFIXES._scopes +@pytest.mark.parametrize("stream", [False, True]) +def test_serial_filtered_response_retains_all_samples_but_not_full_certificate( + serving, monkeypatch, stream +): + server, modules = serving + sampled = b"\nthought\n#firstsecond~" + server.engine.sampled = sampled + server.message = Message( + reasoning_content="\nthought\n", + content="", + tool_calls=[ + { + "index": 0, + "type": "function", + "function": {"name": "first", "arguments": "{}"}, + } + ], + ) + entries, raw_choices = [], [] + observer = vllm.chat_response_prefixes + + async def observe_choices(tokenizer, request, prompt, choices, render): + raw_choices.extend(copy.deepcopy(choices)) + return await observer(tokenizer, request, prompt, choices, render) + + async def observe(raw_request, values): + assert raw_request.headers == {"x-caladan-prefix-scope": "base"} + entries.extend(values) + + monkeypatch.setattr(vllm, "chat_response_prefixes", observe_choices) + vllm.set_external_history_observer(observe, modules.__getitem__) + + async def run(): + request = Request( + messages=[{"role": "user", "content": "question"}], + parallel_tool_calls=False, + tools=[{"type": "function"}], + stream=stream, + ) + result = await server.create_chat_completion( + request, SimpleNamespace(headers={"x-caladan-prefix-scope": "base"}) + ) + if stream: + chunks = [chunk async for chunk in result] + payload = json.loads(chunks[0][len("data: ") :]) + choice = payload["choices"][0] + message = choice["delta"] + usage = payload["usage"] + assert chunks[-1] == "data: [DONE]\n\n" + else: + message = result.choices[0].message.model_dump() + choice = vars(result.choices[0]) + usage = result.usage + assert message == server.message.model_dump() + assert choice["token_ids"] == list(sampled) + assert choice["logprobs"] == [-0.25] * len(sampled) + assert usage == {"completion_tokens": len(sampled)} + + asyncio.run(run()) + assert len(raw_choices) == 1 + assert raw_choices[0][1:] == (list(sampled), True) + assert entries + assert all(b"first" not in bytes(raw) for _, raw, _ in entries) + + +def test_native_scope_does_not_reuse_old_local_certificate(serving): + server, _ = serving + base = hashlib.sha256( + json.dumps(["model", None, {}, ""], sort_keys=True).encode() + ).hexdigest() + prompt = list(b"Uquestion;A") + vllm._PREFIXES.insert(base, prompt, list(b"unsafe"), "content") + asyncio.run( + server.create_chat_completion( + Request(messages=[{"role": "user", "content": "question"}]) + ) + ) + assert server.engine.prompts == [prompt] + assert chat_prefix_scope(base) in vllm._PREFIXES._scopes + + +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("parallel", [False, True, None, "absent"]) +def test_responses_serial_policy_reaches_chat_observer( + serving, monkeypatch, stream, parallel +): + server, _ = serving + observed = [] + original = vllm.chat_response_prefixes + + async def observe(tokenizer, request, prompt, choices, render): + observed.append(request.parallel_tool_calls) + return await original(tokenizer, request, prompt, choices, render) + + monkeypatch.setattr(vllm, "chat_response_prefixes", observe) + + async def run(): + request = Request( + messages=[{"role": "user", "content": "question"}], + parallel_tool_calls=parallel if isinstance(parallel, bool) else None, + stream=stream, + ) + if parallel == "absent": + del request.parallel_tool_calls + result = await server.create_responses(request) + if stream: + async for _ in result: + pass + + asyncio.run(run()) + assert observed == [parallel is not False] + + def test_previous_responses_keep_reasoning_and_tool_calls(serving): _, modules = serving previous = [{"type": "reasoning"}, {"type": "function_call"}] diff --git a/tests/unit/test_sglang_history.py b/tests/unit/test_sglang_history.py index 391a2d80d..5ff36cab6 100644 --- a/tests/unit/test_sglang_history.py +++ b/tests/unit/test_sglang_history.py @@ -19,6 +19,7 @@ class Request(BaseModel): previous_response_id: str | None = None stream: bool = False continue_final_message: bool = False + parallel_tool_calls: bool | None = None class Response(BaseModel): @@ -26,6 +27,10 @@ class Response(BaseModel): output: list = [] +class ChatRequest(Request): + parallel_tool_calls: bool = True + + @pytest.fixture def serving(): class Tokenizer: @@ -188,7 +193,7 @@ async def observe(request, observations): ), "sglang.srt.entrypoints.openai.encoding_dsv4": encoding, "sglang.srt.entrypoints.openai.protocol": SimpleNamespace( - ChatCompletionRequest=Request + ChatCompletionRequest=ChatRequest ), "sglang.srt.entrypoints.harmony_utils": SimpleNamespace( render_for_completion=lambda messages: messages @@ -202,6 +207,38 @@ async def observe(request, observations): return Serving(), entries +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("parallel", [False, True, None, "absent"]) +def test_responses_serial_policy_reaches_chat_observer( + serving, monkeypatch, stream, parallel +): + server, _ = serving + observed = [] + original = sglang.chat_response_prefixes + + async def observe(tokenizer, request, prompt, choices, render): + observed.append(request.parallel_tool_calls) + return await original(tokenizer, request, prompt, choices, render) + + monkeypatch.setattr(sglang, "chat_response_prefixes", observe) + + async def run(): + request = Request( + input=[{"role": "user", "content": "question"}], + parallel_tool_calls=parallel if isinstance(parallel, bool) else None, + stream=stream, + ) + if parallel == "absent": + del request.parallel_tool_calls + result = await server.create_responses(request) + if stream: + async for _ in result: + pass + + asyncio.run(run()) + assert observed == [parallel is not False] + + @pytest.mark.parametrize("stream", [False, True]) def test_sglang_observes_normalized_history_and_edited_actions(serving, stream): server, entries = serving diff --git a/tests/unit/test_tinker_renderers.py b/tests/unit/test_tinker_renderers.py index 81e2bed8f..6f96771a0 100644 --- a/tests/unit/test_tinker_renderers.py +++ b/tests/unit/test_tinker_renderers.py @@ -4,6 +4,7 @@ from types import SimpleNamespace from typing import Any, cast +import pytest from tinker import EncodedTextChunk, ModelInput from tinker_cookbook import renderers from tinker_cookbook.tokenizer_utils import Tokenizer @@ -103,6 +104,93 @@ def apply_chat_template(self, messages, **kwargs): assert message["tool_calls"][0]["function"]["arguments"] == '{"x": 1}' +@pytest.mark.parametrize("parallel", [False, True, None]) +def test_tinker_serial_projection_does_not_certify_hidden_actions( + parallel, monkeypatch +): + from art.tinker.server import OpenAICompatibleTinkerServerWorker + + class Tokenizer: + def encode(self, text, **kwargs): + return list(text.encode()) + + def decode(self, tokens, **kwargs): + return bytes([tokens] if isinstance(tokens, int) else tokens).decode() + + tokenizer = Tokenizer() + sampled = tokenizer.encode("\nthought#firstsecondEND") + logprobs = [-0.25] * len(sampled) + message = { + "role": "assistant", + "content": "", + "reasoning_content": "thought", + "tool_calls": [ + { + "id": "first", + "type": "function", + "function": {"name": "first", "arguments": "{}"}, + } + ], + } + renderer = SimpleNamespace( + tokenizer=tokenizer, + parse_response=lambda tokens: (message, True), + to_openai_message=lambda value: value, + ) + worker = OpenAICompatibleTinkerServerWorker( + _renderers={"model": cast(Any, renderer)} + ) + + async def render(base_model, messages, tools, **kwargs): + assistant = messages[-1] + return tokenizer.encode( + "prompt:thought#" + + "".join( + call["function"]["name"] for call in assistant.get("tool_calls", []) + ) + + "END" + ) + + monkeypatch.setattr(worker, "prompt_tokens", render) + response = SimpleNamespace( + sequences=[ + SimpleNamespace(tokens=sampled, logprobs=logprobs, stop_reason="stop") + ] + ) + result, entries = asyncio.run( + worker.chat_completion_and_prefixes( + "model", + cast(Any, response), + "model", + tokenizer.encode("prompt:"), + tokenizer.encode("prompt:"), + [], + None, + parallel_tool_calls=parallel, + ) + ) + canonical = tokenizer.encode("prompt:thought#firstEND") + assert any(rendered == canonical for rendered, _, _ in entries) == ( + parallel is not False + ) + if parallel is False: + normalized = [raw for rendered, raw, _ in entries if rendered != raw] + assert normalized + assert all(b"first" not in bytes(raw) for raw in normalized) + # Exact raw-to-raw observations remain safe, including every sampled call. + assert any( + rendered == raw == tokenizer.encode("prompt:") + sampled + for rendered, raw, _ in entries + ) + assert result.usage is not None and result.usage.completion_tokens == len(sampled) + result_logprobs = result.choices[0].logprobs + assert result_logprobs is not None and result_logprobs.content is not None + assert [item.logprob for item in result_logprobs.content] == logprobs + assert [item.token for item in result_logprobs.content] == [ + f"token_id:{token}" for token in sampled + ] + + def test_qwen3_5_generation_prompt_matches_hf_suffixes() -> None: tokenizer = FakeTokenizer() From 63c1728b63d56997953571fd06e35ab9956ae813 Mon Sep 17 00:00:00 2001 From: Brad Hilton Date: Sat, 26 Sep 2026 16:20:46 +0000 Subject: [PATCH 2/2] Preserve absent tools in Responses history observer --- src/art_inference/vllm.py | 2 +- tests/unit/test_inference_history.py | 63 +++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/art_inference/vllm.py b/src/art_inference/vllm.py index cd6c8c912..6598e8ec6 100644 --- a/src/art_inference/vllm.py +++ b/src/art_inference/vllm.py @@ -358,7 +358,7 @@ async def observe_response(response): model=request.model, parallel_tool_calls=getattr(request, "parallel_tool_calls", None) is not False, - tools=tools or [], + tools=tools or None, messages=[ openai_tool_arguments(message) for message in conversation ], diff --git a/tests/unit/test_inference_history.py b/tests/unit/test_inference_history.py index 3f763289e..afd894ca5 100644 --- a/tests/unit/test_inference_history.py +++ b/tests/unit/test_inference_history.py @@ -25,7 +25,7 @@ class Request(BaseModel): continue_final_message: bool = False chat_template_kwargs: dict = {} previous_response_id: str | None = None - tools: list = [] + tools: list | None = [] tool_choice: str = "auto" parallel_tool_calls: bool | None = None @@ -429,6 +429,67 @@ async def run(): assert observed == [parallel is not False] +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize( + "tools", [None, [], [{"type": "function", "function": {"name": "echo"}}]] +) +def test_responses_optional_tools_use_valid_native_chat_view( + serving, monkeypatch, stream, tools +): + server, modules = serving + observed = [] + original = vllm.chat_response_prefixes + + class NativeChatRequest(Request): + tools: list | None = None + tool_choice: str = "none" + + @model_validator(mode="before") + @classmethod + def reject_empty_tools(cls, data): + # vLLM's ChatCompletionRequest rejects [], while Responses accepts + # omitted tools and its conversion helper returns None. + if data.get("tools") == []: + raise ValueError("`tools` must not be an empty array") + return data + + modules[ + "vllm.entrypoints.openai.chat_completion.protocol" + ].ChatCompletionRequest = NativeChatRequest + modules["vllm.entrypoints.openai.responses.utils"].construct_tool_dicts = ( + lambda values, choice: values or None + ) + + async def observe(tokenizer, request, prompt, choices, render): + observed.append((request.tools, request.parallel_tool_calls, choices)) + return await original(tokenizer, request, prompt, choices, render) + + monkeypatch.setattr(vllm, "chat_response_prefixes", observe) + + async def run(): + options = {} if tools is None else {"tools": tools} + response = await server.create_responses( + Request( + messages=[{"role": "user", "content": "question"}], + parallel_tool_calls=False, + stream=stream, + **options, + ) + ) + if stream: + events = [event async for event in response] + assert len(events) == 1 + assert events[0].type == "response.completed" + response = events[0].response + assert response.output[0]["content"] == "action" + + asyncio.run(run()) + assert len(server.engine.prompts) == 1 + assert len(observed) == 1 + assert observed[0][:2] == (tools or None, False) + assert observed[0][2][0][1:] == (list(b"\nthought\n#action~"), True) + + def test_previous_responses_keep_reasoning_and_tool_calls(serving): _, modules = serving previous = [{"type": "reasoning"}, {"type": "function_call"}]