fix(guardrails): drop vLLM token ids from non-streamed choices a post-call hook rewrote

A masking post_call guardrail rewrote the answer text but left the hosted_vllm
token_ids that decode back to the unmasked words, in the response and in the
logged payload. Compare each choice that carries token ids before and after the
post-call hooks, and set its token_ids to null when its output changed. The
logging overlay that swaps in the served text drops the logged ids the same way.
This commit is contained in:
mateo-berri 2026-10-30 17:09:43 -07:00
parent 298911d61b
commit 2c522b3544
5 changed files with 191 additions and 3 deletions

View file

@ -227,6 +227,19 @@ def _redact_provider_specific_fields(message: object) -> None:
setattr(message, "provider_specific_fields", _redacted_provider_specific_fields(fields)) # noqa: B010 # typed as object
def _drop_provider_specific_token_ids(holder: object) -> None:
fields: Final = _as_mapping(getattr(holder, "provider_specific_fields", None))
if fields is not None and fields.get("token_ids") is not None:
setattr(holder, "provider_specific_fields", {**fields, "token_ids": None}) # noqa: B010 # typed as object
def drop_token_ids(choice: object) -> None:
if getattr(choice, "token_ids", None) is not None:
setattr(choice, "token_ids", None) # noqa: B010 # pydantic extra, not a declared field
for holder in (choice, getattr(choice, "delta", None), getattr(choice, "message", None)):
_drop_provider_specific_token_ids(holder)
def _redact_provider_specific_fields_dict(choice: Mapping[str, object]) -> None:
for message in (choice.get("message"), choice.get("delta")):
if (

View file

@ -3,7 +3,7 @@ post-call guardrail rewrote rather than the provider response the proxy assemble
from __future__ import annotations
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from itertools import chain
from typing import Final, Literal
@ -191,13 +191,23 @@ def overlay_served_output_texts(
}
def _without_token_ids(holder: Mapping[str, object]) -> Mapping[str, object]:
fields: Final = _as_json_object(holder.get("provider_specific_fields"))
dropped: Final = {**holder, "token_ids": None} if holder.get("token_ids") is not None else holder
if fields is None or fields.get("token_ids") is None:
return dropped
return {**dropped, "provider_specific_fields": {**fields, "token_ids": None}}
def _choice_with_text(choice: object, text: str | None) -> object:
choice_obj: Final = _as_json_object(choice)
if choice_obj is None or text is None:
return choice
message: Final = _as_json_object(choice_obj.get("message"))
if message is not None:
return choice if message.get("content") == text else {**choice_obj, "message": {**message, "content": text}}
if message.get("content") == text:
return choice
return _without_token_ids({**choice_obj, "message": _without_token_ids({**message, "content": text})})
if isinstance(choice_obj.get("text"), str):
return {**choice_obj, "text": text}
return choice if choice_obj.get("text") == text else _without_token_ids({**choice_obj, "text": text})
return choice

View file

@ -164,6 +164,7 @@ from litellm.litellm_core_utils.duration_parser import get_budget_window_start
from litellm.litellm_core_utils.env_utils import get_env_float, get_env_int
from litellm.litellm_core_utils.litellm_logging import BilledRoundsDispatch, Logging, deployment_pricing_model_info
from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token
from litellm.litellm_core_utils.redact_messages import drop_token_ids
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.litellm_core_utils.served_output_texts import (
@ -172,6 +173,7 @@ from litellm.litellm_core_utils.served_output_texts import (
)
from litellm.litellm_core_utils.token_counter import offload_token_count, usage_tokenizer_model
from litellm.llms import load_guardrail_translation_mappings
from litellm.llms.base_llm.guardrail_translation.utils import stream_item_field, stream_item_items
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy._types import (
AlertType,
@ -702,6 +704,34 @@ def _pipeline_step_streams_live(step: "PipelineStep") -> bool:
return not unified_guardrail.resolve_streaming_flag(callback, "streaming_buffer_until_moderated", True)
def _choice_output(choice: object) -> tuple[object, object]:
message: Final = getattr(choice, "message", None)
return (message.model_dump() if isinstance(message, BaseModel) else message, getattr(choice, "text", None))
def _carries_token_ids(choice: object) -> bool:
return getattr(choice, "token_ids", None) is not None or any(
stream_item_field(stream_item_field(holder, "provider_specific_fields"), "token_ids") is not None
for holder in (choice, getattr(choice, "message", None))
)
def _token_id_choice_outputs(response: object) -> Mapping[int, tuple[object, object]]:
return MappingProxyType(
{
index: _choice_output(choice)
for index, choice in enumerate(stream_item_items(response, "choices"))
if _carries_token_ids(choice)
}
)
def _drop_token_ids_of_rewritten_choices(response: object, outputs_before: Mapping[int, tuple[object, object]]) -> None:
for index, choice in enumerate(stream_item_items(response, "choices")):
if index in outputs_before and _choice_output(choice) != outputs_before[index]:
drop_token_ids(choice)
def _pipelines_stream_live(pipelines: Sequence[tuple[str, "GuardrailPipeline"]]) -> bool:
return all(_pipeline_step_streams_live(step) for step in _pipeline_steps(pipelines))
@ -4142,6 +4172,7 @@ class ProxyLogging:
from litellm.proxy.proxy_server import llm_router
from litellm.types.guardrails import GuardrailEventHooks
token_id_outputs: Final = _token_id_choice_outputs(response)
pipeline_response: Final = await self._run_post_call_pipelines(
data=data,
user_api_key_dict=user_api_key_dict,
@ -4230,6 +4261,7 @@ class ProxyLogging:
response = callback_response
except Exception as e:
raise e
_drop_token_ids_of_rewritten_choices(response, token_id_outputs)
return response
async def _run_parallel_post_call_guardrails(

View file

@ -157,6 +157,40 @@ def test_overlay_replaces_logged_choice_text_with_served_text():
assert _choice_texts(logged) == (RAW, RAW)
RAW_TOKEN_IDS: Final = [7, 8, 9]
def _choice_token_ids(choice: dict[str, object]) -> tuple[object, object, object]:
message: Final = choice.get("message")
message_fields: Final = message.get("provider_specific_fields") if isinstance(message, dict) else None
choice_fields: Final = choice.get("provider_specific_fields")
return (
choice.get("token_ids"),
choice_fields.get("token_ids") if isinstance(choice_fields, dict) else None,
message_fields.get("token_ids") if isinstance(message_fields, dict) else None,
)
@pytest.mark.parametrize(
"logged_choice",
(
{"index": 0, "provider_specific_fields": {"token_ids": RAW_TOKEN_IDS}, "message": {"content": RAW}},
{"index": 0, "message": {"content": RAW, "provider_specific_fields": {"token_ids": RAW_TOKEN_IDS}}},
{"index": 0, "text": RAW, "token_ids": RAW_TOKEN_IDS},
),
ids=("chat-choice-fields", "chat-message-fields", "text-completion"),
)
@pytest.mark.parametrize(("served", "keeps_token_ids"), ((MASKED, False), (RAW, True)), ids=("rewritten", "unchanged"))
def test_overlay_drops_logged_token_ids_only_when_it_rewrites_the_choice_text(
logged_choice: dict[str, object], served: str, keeps_token_ids: bool
) -> None:
overlaid: Final = overlay_served_output_texts({"id": "x", "choices": [logged_choice]}, (served,))
assert isinstance(overlaid, dict)
choices: Final = overlaid["choices"]
assert isinstance(choices, list)
assert (RAW_TOKEN_IDS in _choice_token_ids(choices[0])) is keeps_token_ids
def test_overlay_leaves_unreadable_inputs_untouched():
logged = _chat_dict(RAW)
assert overlay_served_output_texts(logged, None) is logged

View file

@ -120,3 +120,102 @@ async def test_post_call_block_names_the_blocking_guardrail_in_applied_guardrail
data=data, response=MagicMock(), user_api_key_dict=make_user_api_key_auth()
)
assert data["metadata"]["applied_guardrails"] == ["passer", "blocker"]
def _vllm_chat_response(content: str) -> litellm.ModelResponse:
from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import (
convert_to_model_response_object,
)
return convert_to_model_response_object(
response_object={
"id": "chatcmpl-vllm",
"object": "chat.completion",
"created": 1,
"model": "Qwen/Qwen3-0.6B",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"token_ids": [785, 6722, 315, 9625, 374, 12095, 13],
"message": {"role": "assistant", "content": content},
}
],
"usage": {"prompt_tokens": 5, "completion_tokens": 7, "total_tokens": 12},
},
model_response_object=litellm.ModelResponse(),
)
def _vllm_text_completion_response(text: str) -> litellm.TextCompletionResponse:
return litellm.TextCompletionResponse(
id="cmpl-vllm",
object="text_completion",
created=1,
model="Qwen/Qwen3-0.6B",
choices=[{"index": 0, "text": text, "finish_reason": "length", "logprobs": None, "token_ids": [12095, 13]}],
usage={"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7},
)
def _chat_token_ids(response: litellm.ModelResponse) -> object:
return response.choices[0].provider_specific_fields["token_ids"]
def _text_token_ids(response: litellm.TextCompletionResponse) -> object:
return response.choices[0].token_ids
@pytest.mark.asyncio
@pytest.mark.parametrize(
("response", "data", "token_ids_of", "kept"),
[
(
_vllm_chat_response("The capital of France is Paris."),
{"model": "m", "messages": [{"role": "user", "content": "capital of France?"}]},
_chat_token_ids,
None,
),
(
_vllm_chat_response("The capital of France is Lyon."),
{"model": "m", "messages": [{"role": "user", "content": "capital of France?"}]},
_chat_token_ids,
[785, 6722, 315, 9625, 374, 12095, 13],
),
(
_vllm_text_completion_response(" Paris."),
{"model": "m", "prompt": "The capital of France is"},
_text_token_ids,
None,
),
(
_vllm_text_completion_response(" Lyon."),
{"model": "m", "prompt": "The capital of France is"},
_text_token_ids,
[12095, 13],
),
],
ids=["chat-masked", "chat-untouched", "completion-masked", "completion-untouched"],
)
async def test_post_call_success_hook_drops_token_ids_only_from_choices_a_guardrail_rewrote(
proxy_logging, make_user_api_key_auth, monkeypatch, response, data, token_ids_of, kept
):
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
ContentFilterGuardrail,
)
from litellm.types.guardrails import BlockedWord, ContentFilterAction
mask_paris = ContentFilterGuardrail(
guardrail_name="mask-paris",
blocked_words=[BlockedWord(keyword="Paris", action=ContentFilterAction.MASK)],
event_hook=GuardrailEventHooks.post_call,
default_on=True,
)
monkeypatch.setattr(litellm, "callbacks", [mask_paris])
served = await proxy_logging.post_call_success_hook(
data=data, response=response, user_api_key_dict=make_user_api_key_auth()
)
assert served is response
assert token_ids_of(served) == kept