mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
* 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
302 lines
9.7 KiB
Python
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)
|