fix(proxy): strip every TypedDict qualifier before numeric form-field detection

_numeric_form_type only peeled a single ReadOnly layer, so a field still
wrapped in Required/NotRequired was read as non-numeric and dropped from the
mapping. Which qualifiers survive get_type_hints varies by interpreter version
and by include_extras, so on Python 3.10 NotRequired[ReadOnly[int]] reached the
check intact and the field was silently skipped, which is what turns the mapped
test red on the 3.10 leg only.

Peel Required/NotRequired/ReadOnly/Annotated in any order and nesting instead.
The one production caller feeds a schema with no qualifiers, so the resulting
mapping is unchanged on every interpreter in the matrix, but a field written the
house-convention way stops being dropped.
This commit is contained in:
Yuneng Jiang 2026-09-04 11:50:28 -07:00
parent f74bc9427b
commit 2042364fc2
No known key found for this signature in database
2 changed files with 31 additions and 3 deletions

View file

@ -2,11 +2,11 @@ import json
import re
from collections.abc import Collection, Mapping
from types import MappingProxyType, UnionType
from typing import Any, Final, Union, get_args, get_origin
from typing import Annotated, Any, Final, Union, get_args, get_origin
import orjson
from fastapi import Request, UploadFile, status
from typing_extensions import ReadOnly
from typing_extensions import NotRequired, ReadOnly, Required
from litellm._logging import verbose_proxy_logger
from litellm.constants import MAX_REQUEST_BODY_SIZE_TO_REPAIR_MB
@ -18,6 +18,8 @@ from litellm.types.router import Deployment
_FORM_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"application/x-www-form-urlencoded", "multipart/form-data"})
_ANNOTATION_QUALIFIERS: Final[frozenset[object]] = frozenset({Annotated, NotRequired, ReadOnly, Required})
def _normalize_media_type(content_type: str) -> str:
"""Return the bare media type per RFC 7231: strip params, trim, lowercase."""
@ -42,9 +44,17 @@ def _is_json_content_type(content_type: str) -> bool:
return _normalize_media_type(content_type) == "application/json"
def _unqualified(annotation: object) -> object:
"""Which qualifiers ``get_type_hints`` already stripped varies by interpreter version, so peel them all."""
if get_origin(annotation) not in _ANNOTATION_QUALIFIERS:
return annotation
qualified: Final[tuple[object, ...]] = get_args(annotation)
return _unqualified(qualified[0])
def _numeric_form_type(annotation: object) -> type[int] | type[float] | None:
"""The scalar to parse an ``int``/``float``-typed field as, else ``None``."""
unwrapped: Final = get_args(annotation)[0] if get_origin(annotation) is ReadOnly else annotation
unwrapped: Final = _unqualified(annotation)
candidates: Final = (
tuple(arg for arg in get_args(unwrapped) if arg is not type(None))
if get_origin(unwrapped) in (Union, UnionType)

View file

@ -1053,6 +1053,8 @@ class TestNumericFormFields:
read_only: ReadOnly[int | None]
not_required: NotRequired[ReadOnly[int]]
required: Required[ReadOnly[Annotated[float, "meta"]]]
read_only_not_required: ReadOnly[NotRequired[int]]
read_only_required: ReadOnly[Required[float]]
assert dict(numeric_form_fields(get_type_hints(Schema))) == {
"plain": int,
@ -1061,6 +1063,22 @@ class TestNumericFormFields:
"read_only": int,
"not_required": int,
"required": float,
"read_only_not_required": int,
"read_only_required": float,
}
def test_qualifiers_are_unwrapped_when_get_type_hints_keeps_extras(self):
from typing_extensions import Annotated, NotRequired, ReadOnly, Required, TypedDict
class Schema(TypedDict, total=False):
annotated: ReadOnly[Annotated[int, "meta"]]
not_required: NotRequired[ReadOnly[int]]
required: Required[ReadOnly[Annotated[float, "meta"]]]
assert dict(numeric_form_fields(get_type_hints(Schema, include_extras=True))) == {
"annotated": int,
"not_required": int,
"required": float,
}
def test_non_scalar_and_bool_fields_are_skipped(self):