Filter Pydantic-internal markers from validation error param (#46457)

Signed-off-by: muhammadfawaz1 <135441198+muhammadfawaz1@users.noreply.github.com> Co-authored-by: Mahad Rehmann <114791389+mahadrehmann@users.noreply.github.com>

Muhammad Fawaz committed Jun 23, 2026 at 18:20 UTC 1bf149f3348e4ac0283386c914c1aa15a8ce6e09
2 files changed +115 -2
tests/entrypoints/serve/utils/test_server_utils.py
+38 -1
@@ -16,7 +16,10 @@ from types import SimpleNamespace
16 import pytest
17 from fastapi.exceptions import RequestValidationError
18
19 -from vllm.entrypoints.serve.utils.server_utils import validation_exception_handler
19 +from vllm.entrypoints.serve.utils.server_utils import (
20 + clean_loc_for_param,
21 + validation_exception_handler,
22 +)
23
24
25 def _fake_request(log_error_stack: bool = False) -> SimpleNamespace:
@@ -63,3 +66,37 @@ class TestValidationErrorParamFallback:
66 body = json.loads(response.body)
67
68 assert body["error"]["param"] is None
69 +
70 +
71 +class TestCleanLocForParam:
72 + """Guards against PR #1's naive dot-joined `loc` fallback leaking
73 + Pydantic-internal wrapper/union-branch markers into `param`, e.g.
74 + 'body.function-wrap[__log_extra_fields__()].prompt' instead of the
75 + clean 'body.prompt' an API consumer would recognize.
76 + """
77 +
78 + @pytest.mark.parametrize(
79 + "loc,expected",
80 + [
81 + (("body", "prompt"), "body.prompt"),
82 + (("body", "messages", 2, "content"), "body.messages.2.content"),
83 + (
84 + ("body", "function-wrap[__log_extra_fields__()]", "prompt"),
85 + "body.prompt",
86 + ),
87 + (("body", "stop", "str"), "body.stop"),
88 + (("body", "stop", "list[str]"), "body.stop"),
89 + (
90 + ("body", "prompt", "list[constrained-int]"),
91 + "body.prompt",
92 + ),
93 + ],
94 + )
95 + def test_strips_internal_markers(self, loc, expected):
96 + assert clean_loc_for_param(loc) == expected
97 +
98 + def test_all_internal_falls_back_to_raw_join(self):
99 + """If every loc segment looks internal, fall back to the raw
100 + dot-join rather than returning an empty string."""
101 + loc = ("function-wrap[__log_extra_fields__()]",)
102 + assert clean_loc_for_param(loc) == "function-wrap[__log_extra_fields__()]"
vllm/entrypoints/serve/utils/server_utils.py
+77 -1
@@ -11,6 +11,7 @@ from contextlib import asynccontextmanager
11 from http import HTTPStatus
12
13 import pydantic
14 +import regex as re
15 from fastapi import FastAPI, HTTPException, Request
16 from fastapi.exceptions import RequestValidationError
17 from fastapi.responses import JSONResponse
@@ -409,6 +410,81 @@ async def http_exception_handler(req: Request, exc: HTTPException):
410 return JSONResponse(err.model_dump(), status_code=exc.status_code)
411
412
413 +_BRACKETED_INTERNAL_RE = re.compile(r"[\[\]{}()]")
414 +
415 +# NOTE: this list is pydantic-core's internal schema-kind vocabulary,
416 +# not a stable public API -- it can grow when pydantic-core adds new
417 +# wrapper/validator kinds. To refresh it after a pydantic upgrade:
418 +# 1. Fuzz the validation-error-prone endpoints (e.g. /tokenize,
419 +# /v1/completions, /v1/chat/completions) with deliberately
420 +# malformed values for union-typed and wrapped fields (e.g. `stop`,
421 +# `prompt`), and inspect the raw `loc` tuples in the response.
422 +# 2. Any *unbracketed* segment that isn't a real field name or list
423 +# index is a new internal marker -- add it here. Bracketed/
424 +# parenthesized markers (e.g. "list[...]", "function-wrap[...]")
425 +# are already caught structurally by _BRACKETED_INTERNAL_RE and
426 +# don't need a list entry.
427 +# 3. pydantic-core's source (the `error.rs`/schema-kind definitions
428 +# in the pydantic-core Rust crate) is the canonical reference if
429 +# you want to check before it shows up in a live fuzz run.
430 +_INTERNAL_LOC_MARKERS = frozenset(
431 + {
432 + "function-wrap",
433 + "function-after",
434 + "function-before",
435 + "function-plain",
436 + "json-or-python",
437 + "lax-or-strict",
438 + "chain",
439 + "default",
440 + "nullable",
441 + "tagged-union",
442 + "union",
443 + "call",
444 + "arguments",
445 + "is-instance",
446 + "is-subclass",
447 + "callable",
448 + "str",
449 + "int",
450 + "float",
451 + "bool",
452 + "bytes",
453 + "bytearray",
454 + "list",
455 + "tuple",
456 + "dict",
457 + "set",
458 + "frozenset",
459 + "complex",
460 + "none",
461 + "nonetype",
462 + }
463 +)
464 +
465 +
466 +def _is_internal_loc_segment(segment: str) -> bool:
467 + """True if `segment` is a Pydantic-internal wrapper/union-branch
468 + marker rather than a user-meaningful field name or list index."""
469 + if _BRACKETED_INTERNAL_RE.search(segment):
470 + return True
471 + return segment.lower() in _INTERNAL_LOC_MARKERS
472 +
473 +
474 +def clean_loc_for_param(loc: tuple) -> str:
475 + """Join a Pydantic error `loc` tuple into a clean dotted `param`
476 + path, dropping internal wrapper/union-branch markers that don't
477 + correspond to a real field name an API consumer would recognize.
478 +
479 + E.g. ('body', 'function-wrap[__log_extra_fields__()]', 'prompt')
480 + -> "body.prompt", not "body.function-wrap[__log_extra_fields__()].prompt".
481 + """
482 + parts = [str(p) for p in loc if not _is_internal_loc_segment(str(p))]
483 + if not parts:
484 + return ".".join(str(p) for p in loc)
485 + return ".".join(parts)
486 +
487 +
488 async def validation_exception_handler(req: Request, exc: RequestValidationError):
489 if req.app.state.args.log_error_stack:
490 logger.exception(
@@ -431,7 +507,7 @@ async def validation_exception_handler(req: Request, exc: RequestValidationError
507 first_error = errors[0]
508 loc = first_error.get("loc") if isinstance(first_error, dict) else None
509 if loc:
434 - param = ".".join(str(part) for part in loc)
510 + param = clean_loc_for_param(loc)
511
512 exc_str = str(exc)
513 errors_str = str(errors)