This commit is contained in:
Tyler Coatsworth 2026-10-04 11:59:05 -04:00 • committed by GitHub
commit 5a1670425a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 61 additions and 0 deletions

View file

@ -26,6 +26,18 @@ _FORM_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"application/x-www-form-
# Binary bodies (e.g. OTLP trace exports on POST /v1/traces) are not JSON: arbitrary bytes used to
# hit the JSON surrogate-repair path and fail auth with a 400. JSON under these types still parses.
_BINARY_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"application/x-protobuf", "application/protobuf"})
_OBJECT_ONLY_JSON_ROUTES: Final[frozenset[str]] = frozenset(
{
"/v1/chat/completions",
"/chat/completions",
"/engines/{model:path}/chat/completions",
"/openai/deployments/{model:path}/chat/completions",
"/v1/responses",
"/responses",
"/openai/v1/responses",
"/v1/messages",
}
)
_ANNOTATION_QUALIFIERS: Final[frozenset[object]] = frozenset({Annotated, NotRequired, ReadOnly, Required})
@ -284,6 +296,16 @@ async def _read_request_body(request: Request | None) -> dict:
code=status.HTTP_400_BAD_REQUEST,
)
matched_route: Final = request.scope.get("route")
route_path: Final = getattr(matched_route, "path", None) or request.scope.get("path", "")
if route_path in _OBJECT_ONLY_JSON_ROUTES and not isinstance(parsed_body, dict):
raise ProxyException(
message="Invalid JSON payload: request body must be a JSON object",
type="invalid_request_error",
param="request_body",
code=status.HTTP_400_BAD_REQUEST,
)
# Cache the parsed result
_safe_set_request_parsed_body(request=request, parsed_body=parsed_body)
return parsed_body

View file

@ -2,6 +2,7 @@ import gzip
import io
import json
from collections.abc import Mapping
from types import SimpleNamespace
from typing import Final, Literal, get_type_hints
from unittest.mock import AsyncMock, MagicMock, patch
@ -108,6 +109,44 @@ async def test_read_raw_json_body_returns_the_bytes_the_parsed_body_came_from():
assert await read_raw_json_body(request) == body
@pytest.mark.asyncio
@pytest.mark.parametrize(
("path", "route_template"),
[
("/v1/chat/completions", None),
("/chat/completions", None),
("/engines/model/chat/completions", "/engines/{model:path}/chat/completions"),
("/openai/deployments/model/chat/completions", "/openai/deployments/{model:path}/chat/completions"),
("/v1/responses", None),
("/responses", None),
("/openai/v1/responses", None),
("/v1/messages", None),
],
)
@pytest.mark.parametrize("body", [b"[]", b"null", b'"text"', b"1", b"true"])
async def test_non_object_json_body_is_rejected_before_caching(body: bytes, path: str, route_template: str | None):
request: Final = _starlette_request(body, "application/json", path=path)
if route_template is not None:
request.scope["route"] = SimpleNamespace(path=route_template)
with pytest.raises(ProxyException) as exc_info:
await _read_request_body(request)
assert exc_info.value.code == "400"
assert exc_info.value.type == "invalid_request_error"
assert "must be a JSON object" in exc_info.value.message
assert _safe_get_request_parsed_body(request) is None
@pytest.mark.asyncio
async def test_passthrough_json_array_body_remains_available():
body: Final = b'[{"role":"user","content":"hello"}]'
request: Final = _starlette_request(body, "application/json", path="/vertex-ai/v1/rawPredict")
assert await _read_request_body(request) == json.loads(body)
assert await request.body() == body
@pytest.mark.asyncio
async def test_read_raw_json_body_is_none_until_the_body_has_been_parsed():
request = _starlette_request(b'{"model": "claude-sonnet-4-5"}', "application/json")