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:
Poodle Glamour 2026-06-21 17:51:56 +10:00
parent dcf1b445e6
commit d78702bc78
3 changed files with 221 additions and 0 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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}"