From d78702bc78e765005c9d316a9447d6081a2184f8 Mon Sep 17 00:00:00 2001 From: Poodle Glamour <10000040+poodle64@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:51:56 +1000 Subject: [PATCH] feat(proxy): document request bodies for raw-body passthrough endpoints The moderations, rerank, and audio/speech proxy endpoints read the request body raw (await request.body() / orjson.loads) so they can forward arbitrary provider params unchanged, rather than declaring a typed Pydantic body. Because FastAPI only emits a requestBody in the OpenAPI schema when a handler has a typed body parameter, app.openapi() currently exposes no request body for these routes, so SDKs generated from the schema produce client methods that cannot send a payload. Add FastAPI's openapi_extra={"requestBody": ...} to the moderations (/moderations, /v1/moderations), rerank (/rerank, /v1/rerank, /v2/rerank), and audio/speech (/audio/speech, /v1/audio/speech) route decorators to document the body in the generated schema without changing handler behaviour (handlers still read the raw body). Schemas mark only genuinely-required fields required and set additionalProperties: true so forwarded params are not implied invalid; moderations input is typed string-or-array to match the OpenAI wire shape. Adds a test asserting app.openapi() exposes the requestBody and the moderations string-or-array input for each route. --- litellm/proxy/proxy_server.py | 74 ++++++++++++++ litellm/proxy/rerank_endpoints/endpoints.py | 50 ++++++++++ .../test_openapi_request_bodies.py | 97 +++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 tests/test_litellm/proxy/proxy_server/test_openapi_request_bodies.py diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index c138626a272..8a6d37815d8 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -9389,17 +9389,89 @@ async def embeddings( ) +_MODERATIONS_REQUEST_BODY = { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "ModerationsRequest", + "required": ["input"], + "additionalProperties": True, + "properties": { + "input": { + "oneOf": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}}, + ], + "title": "Input", + "description": "Text (or list of texts) to classify for policy violations.", + }, + "model": { + "type": "string", + "title": "Model", + "description": "Moderation model ID. Optional; provider default is used when omitted.", + }, + }, + } + } + }, +} + +_AUDIO_SPEECH_REQUEST_BODY = { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "AudioSpeechRequest", + "required": ["model", "input", "voice"], + "additionalProperties": True, + "properties": { + "model": { + "type": "string", + "title": "Model", + "description": "TTS model ID (e.g. 'tts-1', 'tts-1-hd'). Forwarded to the upstream provider.", + }, + "input": { + "type": "string", + "title": "Input", + "description": "The text to synthesise into speech.", + }, + "voice": { + "type": "string", + "title": "Voice", + "description": "Voice identifier (e.g. 'alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer').", + }, + "response_format": { + "type": "string", + "title": "Response Format", + "description": "Audio output format (e.g. 'mp3', 'opus', 'aac', 'flac'). Optional.", + }, + "speed": { + "type": "number", + "title": "Speed", + "description": "Speech speed in the range 0.25-4.0. Optional; defaults to 1.0.", + }, + }, + } + } + }, +} + @router.post( "/v1/moderations", dependencies=[Depends(user_api_key_auth)], response_class=ORJSONResponse, tags=["moderations"], + openapi_extra={"requestBody": _MODERATIONS_REQUEST_BODY}, ) @router.post( "/moderations", dependencies=[Depends(user_api_key_auth)], response_class=ORJSONResponse, tags=["moderations"], + openapi_extra={"requestBody": _MODERATIONS_REQUEST_BODY}, ) async def moderations( request: Request, @@ -9526,11 +9598,13 @@ async def _audio_speech_chunk_generator( "/v1/audio/speech", dependencies=[Depends(user_api_key_auth)], tags=["audio"], + openapi_extra={"requestBody": _AUDIO_SPEECH_REQUEST_BODY}, ) @router.post( "/audio/speech", dependencies=[Depends(user_api_key_auth)], tags=["audio"], + openapi_extra={"requestBody": _AUDIO_SPEECH_REQUEST_BODY}, ) async def audio_speech( request: Request, diff --git a/litellm/proxy/rerank_endpoints/endpoints.py b/litellm/proxy/rerank_endpoints/endpoints.py index ba9046b3c20..c16dba205ed 100644 --- a/litellm/proxy/rerank_endpoints/endpoints.py +++ b/litellm/proxy/rerank_endpoints/endpoints.py @@ -13,23 +13,73 @@ router = APIRouter() import asyncio + +_RERANK_REQUEST_BODY = { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "RerankRequest", + "required": ["model", "query", "documents"], + "additionalProperties": True, + "properties": { + "model": { + "type": "string", + "title": "Model", + "description": "Rerank model ID (e.g. 'rerank-english-v3.0'). Forwarded to the upstream provider.", + }, + "query": { + "type": "string", + "title": "Query", + "description": "The search query whose relevance the documents are ranked against.", + }, + "documents": { + "type": "array", + "title": "Documents", + "description": "List of documents (strings or dicts) to rerank.", + "items": {}, + }, + "top_n": { + "type": "integer", + "title": "Top N", + "description": "Return only the top N ranked documents. Optional; defaults to returning all.", + }, + "return_documents": { + "type": "boolean", + "title": "Return Documents", + "description": "When true, include the original document text in the response. Optional.", + }, + "max_chunks_per_doc": { + "type": "integer", + "title": "Max Chunks Per Doc", + "description": "Maximum number of chunks to produce per document when chunking is applied. Optional.", + }, + }, + } + } + }, +} @router.post( "/v2/rerank", dependencies=[Depends(user_api_key_auth)], response_class=ORJSONResponse, tags=["rerank"], + openapi_extra={"requestBody": _RERANK_REQUEST_BODY}, ) @router.post( "/v1/rerank", dependencies=[Depends(user_api_key_auth)], response_class=ORJSONResponse, tags=["rerank"], + openapi_extra={"requestBody": _RERANK_REQUEST_BODY}, ) @router.post( "/rerank", dependencies=[Depends(user_api_key_auth)], response_class=ORJSONResponse, tags=["rerank"], + openapi_extra={"requestBody": _RERANK_REQUEST_BODY}, ) async def rerank( request: Request, diff --git a/tests/test_litellm/proxy/proxy_server/test_openapi_request_bodies.py b/tests/test_litellm/proxy/proxy_server/test_openapi_request_bodies.py new file mode 100644 index 00000000000..5d59c763463 --- /dev/null +++ b/tests/test_litellm/proxy/proxy_server/test_openapi_request_bodies.py @@ -0,0 +1,97 @@ +"""Behavior pins: requestBody appears in app.openapi() for raw-body passthrough routes. + +These routes read the request body via ``await request.body()`` / ``orjson.loads`` +rather than a typed Pydantic parameter, so FastAPI emits no requestBody by default. +The ``openapi_extra={"requestBody": ...}`` decorator argument injects the schema +without changing handler behavior. + +Covered routes: + - POST /moderations and /v1/moderations + - POST /rerank, /v1/rerank, /v2/rerank + - POST /audio/speech and /v1/audio/speech +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(scope="module") +def openapi_schema(app): + """Cached app.openapi() result for the module — expensive call, run once.""" + return app.openapi() + + +# --------------------------------------------------------------------------- +# moderations +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("path", ["/moderations", "/v1/moderations"]) +def test_moderations_has_request_body(openapi_schema, path): + """POST /moderations and /v1/moderations must expose a requestBody schema.""" + post_op = openapi_schema["paths"][path]["post"] + assert "requestBody" in post_op, f"No requestBody on POST {path}" + + +@pytest.mark.parametrize("path", ["/moderations", "/v1/moderations"]) +def test_moderations_input_is_required(openapi_schema, path): + """``input`` must be listed in ``required``.""" + schema = openapi_schema["paths"][path]["post"]["requestBody"]["content"]["application/json"]["schema"] + assert "input" in schema.get("required", []), f"'input' not in required on POST {path}" + + +@pytest.mark.parametrize("path", ["/moderations", "/v1/moderations"]) +def test_moderations_input_accepts_string_or_array(openapi_schema, path): + """``input`` must accept both a plain string and an array of strings (oneOf).""" + schema = openapi_schema["paths"][path]["post"]["requestBody"]["content"]["application/json"]["schema"] + input_schema = schema["properties"]["input"] + # Must use oneOf to cover both string and array-of-strings shapes + assert "oneOf" in input_schema, ( + f"'input' on POST {path} must use oneOf to accept string-or-array; got: {input_schema}" + ) + type_values = {branch.get("type") for branch in input_schema["oneOf"]} + assert "string" in type_values, "oneOf must include a string branch" + assert "array" in type_values, "oneOf must include an array branch" + + +# --------------------------------------------------------------------------- +# rerank +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("path", ["/rerank", "/v1/rerank", "/v2/rerank"]) +def test_rerank_has_request_body(openapi_schema, path): + """POST /rerank, /v1/rerank, /v2/rerank must expose a requestBody schema.""" + post_op = openapi_schema["paths"][path]["post"] + assert "requestBody" in post_op, f"No requestBody on POST {path}" + + +@pytest.mark.parametrize("path", ["/rerank", "/v1/rerank", "/v2/rerank"]) +def test_rerank_required_fields(openapi_schema, path): + """``model``, ``query``, and ``documents`` must all be required.""" + schema = openapi_schema["paths"][path]["post"]["requestBody"]["content"]["application/json"]["schema"] + required = schema.get("required", []) + for field in ("model", "query", "documents"): + assert field in required, f"'{field}' not in required on POST {path}" + + +# --------------------------------------------------------------------------- +# audio/speech +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("path", ["/audio/speech", "/v1/audio/speech"]) +def test_audio_speech_has_request_body(openapi_schema, path): + """POST /audio/speech and /v1/audio/speech must expose a requestBody schema.""" + post_op = openapi_schema["paths"][path]["post"] + assert "requestBody" in post_op, f"No requestBody on POST {path}" + + +@pytest.mark.parametrize("path", ["/audio/speech", "/v1/audio/speech"]) +def test_audio_speech_required_fields(openapi_schema, path): + """``model``, ``input``, and ``voice`` must all be required.""" + schema = openapi_schema["paths"][path]["post"]["requestBody"]["content"]["application/json"]["schema"] + required = schema.get("required", []) + for field in ("model", "input", "voice"): + assert field in required, f"'{field}' not in required on POST {path}"