mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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.
This commit is contained in:
parent
dcf1b445e6
commit
d78702bc78
3 changed files with 221 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
Loading…
Add table
Reference in a new issue