litellm/tests/_fake_openai_endpoint_server.py
yuneng-jiang 0c2e404be3
test(ci): serve /moderations from the canned OpenAI mock (#37739)
* test(ci): serve /moderations from the canned OpenAI mock

The otel proxy E2E job points its `openai/*` wildcard deployment at the
canned mock, and #37492 made `get_model_list` agree with
`get_available_deployment` on bare model names. /moderations now resolves
`omni-moderation-latest` to that wildcard deployment the way
/chat/completions already did, so the request lands on the mock, which
never implemented the route and answers a bare 404.

Add /moderations and /v1/moderations to the mock, returning an
OpenAI-shaped response with one result per input item.

* style(ci): annotate the new moderations locals as Final
2026-08-20 16:58:38 -07:00

302 lines
9.7 KiB
Python

"""Canned OpenAI-shaped mock server for the CI proxy E2Es.
Several CI jobs run the litellm proxy (often in its own Docker container) against
a model whose ``api_base`` is a fake OpenAI endpoint that returns canned
responses, so the run costs nothing and does not depend on a real provider. That
endpoint used to be a single shared deployment; when it went down every one of
those jobs failed with ``404 Application not found`` even though nothing in the
PR was broken.
This process is the local stand-in. A model points its ``api_base`` here and
gets back a well-formed chat/text/embedding/moderation response with realistic
``usage`` so cost tracking and spend accounting still exercise their real code
paths. The one behavioral special case mirrors the old hosted mock: a request
whose ``model`` is ``429`` returns HTTP 429 so rate-limit and cooldown tests
still have something to trip on.
"""
from __future__ import annotations
import asyncio
import json
import time
import uuid
from typing import AsyncIterator, Final
import uvicorn
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse, PlainTextResponse, Response, StreamingResponse
from starlette.routing import Route
_CANNED_CONTENT: Final = "Hello! This is a mock response from the fake OpenAI endpoint."
_RATE_LIMIT_MODEL: Final = "429"
_SLOW_MODEL: Final = "slow-endpoint"
_SLOW_RESPONSE_SECONDS: Final = 3.0
_PROMPT_TOKENS: Final = 20
_COMPLETION_TOKENS: Final = 20
_MODERATION_CATEGORIES: Final = (
"harassment",
"harassment/threatening",
"hate",
"hate/threatening",
"illicit",
"illicit/violent",
"self-harm",
"self-harm/instructions",
"self-harm/intent",
"sexual",
"sexual/minors",
"violence",
"violence/graphic",
)
def _usage() -> dict[str, int]:
return {
"prompt_tokens": _PROMPT_TOKENS,
"completion_tokens": _COMPLETION_TOKENS,
"total_tokens": _PROMPT_TOKENS + _COMPLETION_TOKENS,
}
def _requested_model(body: dict[str, object]) -> str:
model = body.get("model")
return model if isinstance(model, str) else "mock-model"
def _wants_stream(body: dict[str, object]) -> bool:
return body.get("stream") is True
def _wants_stream_usage(body: dict[str, object]) -> bool:
options = body.get("stream_options")
return isinstance(options, dict) and options.get("include_usage") is True
async def _parse_body(request: Request) -> dict[str, object]:
raw = await request.body()
if not raw:
return {}
try:
parsed = json.loads(raw)
except ValueError:
return {}
return parsed if isinstance(parsed, dict) else {}
def _rate_limit_response(model: str) -> JSONResponse:
return JSONResponse(
status_code=429,
content={
"error": {
"message": f"Rate limit reached for model `{model}` (mock).",
"type": "rate_limit_error",
"code": "429",
}
},
)
def _chat_completion_body(model: str) -> dict[str, object]:
return {
"id": f"chatcmpl-{uuid.uuid4().hex[:24]}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": _CANNED_CONTENT},
"finish_reason": "stop",
}
],
"usage": _usage(),
}
async def _chat_completion_stream(model: str, with_usage: bool) -> AsyncIterator[str]:
response_id = f"chatcmpl-{uuid.uuid4().hex[:24]}"
created = int(time.time())
def chunk(delta: dict[str, object], finish_reason: str | None) -> dict[str, object]:
return {
"id": response_id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
}
yield f"data: {json.dumps(chunk({'role': 'assistant', 'content': _CANNED_CONTENT}, None))}\n\n"
yield f"data: {json.dumps(chunk({}, 'stop'))}\n\n"
if with_usage:
final = chunk({}, None) | {"choices": [], "usage": _usage()}
yield f"data: {json.dumps(final)}\n\n"
yield "data: [DONE]\n\n"
async def chat_completions(request: Request) -> Response:
body = await _parse_body(request)
model = _requested_model(body)
if model == _RATE_LIMIT_MODEL:
return _rate_limit_response(model)
if model == _SLOW_MODEL:
await asyncio.sleep(_SLOW_RESPONSE_SECONDS)
if _wants_stream(body):
return StreamingResponse(
_chat_completion_stream(model, _wants_stream_usage(body)),
media_type="text/event-stream",
)
return JSONResponse(_chat_completion_body(model))
def _text_completion_body(model: str) -> dict[str, object]:
return {
"id": f"cmpl-{uuid.uuid4().hex[:24]}",
"object": "text_completion",
"created": int(time.time()),
"model": model,
"choices": [
{
"text": _CANNED_CONTENT,
"index": 0,
"logprobs": None,
"finish_reason": "stop",
}
],
"usage": _usage(),
}
async def _text_completion_stream(model: str, with_usage: bool) -> AsyncIterator[str]:
response_id = f"cmpl-{uuid.uuid4().hex[:24]}"
created = int(time.time())
def chunk(text: str, finish_reason: str | None) -> dict[str, object]:
return {
"id": response_id,
"object": "text_completion",
"created": created,
"model": model,
"choices": [{"text": text, "index": 0, "logprobs": None, "finish_reason": finish_reason}],
}
yield f"data: {json.dumps(chunk(_CANNED_CONTENT, None))}\n\n"
yield f"data: {json.dumps(chunk('', 'stop'))}\n\n"
if with_usage:
final = chunk("", None) | {"choices": [], "usage": _usage()}
yield f"data: {json.dumps(final)}\n\n"
yield "data: [DONE]\n\n"
async def completions(request: Request) -> Response:
body = await _parse_body(request)
model = _requested_model(body)
if model == _RATE_LIMIT_MODEL:
return _rate_limit_response(model)
if model == _SLOW_MODEL:
await asyncio.sleep(_SLOW_RESPONSE_SECONDS)
if _wants_stream(body):
return StreamingResponse(
_text_completion_stream(model, _wants_stream_usage(body)),
media_type="text/event-stream",
)
return JSONResponse(_text_completion_body(model))
async def embeddings(request: Request) -> Response:
body = await _parse_body(request)
raw_input = body.get("input", "")
count = len(raw_input) if isinstance(raw_input, list) else 1
return JSONResponse(
{
"object": "list",
"data": [{"object": "embedding", "index": i, "embedding": [0.0] * 1536} for i in range(max(count, 1))],
"model": _requested_model(body),
"usage": {"prompt_tokens": 5, "total_tokens": 5},
}
)
async def triton_embeddings(_request: Request) -> Response:
return JSONResponse(
{
"model_name": "my-triton-model",
"outputs": [
{
"name": "output",
"datatype": "FP32",
"shape": [1, 2],
"data": [0.1, 0.2],
}
],
}
)
def _moderation_result() -> dict[str, object]:
return {
"flagged": False,
"categories": {category: False for category in _MODERATION_CATEGORIES},
"category_scores": {category: 0.0 for category in _MODERATION_CATEGORIES},
"category_applied_input_types": {category: ["text"] for category in _MODERATION_CATEGORIES},
}
async def moderations(request: Request) -> Response:
body: Final = await _parse_body(request)
raw_input: Final = body.get("input", "")
count: Final = len(raw_input) if isinstance(raw_input, list) else 1
return JSONResponse(
{
"id": f"modr-{uuid.uuid4().hex[:24]}",
"model": _requested_model(body),
"results": [_moderation_result() for _ in range(max(count, 1))],
}
)
async def list_models(_request: Request) -> Response:
return JSONResponse(
{
"object": "list",
"data": [
{"id": "fake", "object": "model", "owned_by": "mock"},
{"id": "my-fake-model", "object": "model", "owned_by": "mock"},
],
}
)
async def health(_request: Request) -> Response:
return PlainTextResponse("ok")
app = Starlette(
routes=[
Route("/health", health, methods=["GET"]),
Route("/", health, methods=["GET"]),
Route("/chat/completions", chat_completions, methods=["POST"]),
Route("/v1/chat/completions", chat_completions, methods=["POST"]),
Route("/completions", completions, methods=["POST"]),
Route("/v1/completions", completions, methods=["POST"]),
Route("/embeddings", embeddings, methods=["POST"]),
Route("/v1/embeddings", embeddings, methods=["POST"]),
Route("/triton/embeddings", triton_embeddings, methods=["POST"]),
Route("/moderations", moderations, methods=["POST"]),
Route("/v1/moderations", moderations, methods=["POST"]),
Route("/models", list_models, methods=["GET"]),
Route("/v1/models", list_models, methods=["GET"]),
]
)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8190)
args = parser.parse_args()
uvicorn.run(app, host=args.host, port=args.port)