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)