litellm/tests/_fake_openai_endpoint_server.py
Mateo Wang 556e8f89c8
ci: run a local fake OpenAI endpoint instead of the shared Railway mock (#30695)
Several CI jobs run the proxy against a model whose api_base is a shared
"fake OpenAI endpoint" hosted on Railway
(exampleopenaiendpoint-production.up.railway.app) so the E2E runs return
canned responses without paying for or depending on a live provider. When
that single deployment is down, every one of those jobs fails with
"404 Application not found" even though nothing in the PR is broken; the
whole repo is coupled to the uptime of one free external service.

This adds tests/_fake_openai_endpoint_server.py, a small canned-response
OpenAI-shaped server (chat, text, embeddings, streaming with usage, and the
"429" rate-limit special case), and a reusable start_fake_openai_endpoint
CircleCI command that runs it on host port 8190 and waits until healthy. The
affected jobs now inject FAKE_OPENAI_API_BASE pointing at the local server,
and the example configs they mount resolve api_base from that env var. The
intentionally bad fallback URL in proxy_server_config.yaml is left untouched
so the fallback test still exercises a failing upstream.

Wired into build_and_test, litellm_router_testing,
db_migration_disable_update_check, proxy_logging_guardrails_model_info_tests,
proxy_spend_accuracy_tests, proxy_multi_instance_tests,
proxy_store_model_in_db_tests, and proxy_build_from_pip_tests.
2026-06-17 17:01:13 -07:00

239 lines
7.8 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 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 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"
_PROMPT_TOKENS: Final = 20
_COMPLETION_TOKENS: Final = 20
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 _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 _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 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("/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)