test: point provider timeout tests at the fake endpoint server

The fake OpenAI endpoint now also serves the Azure deployment, Anthropic
/v1/messages and Bedrock converse URL shapes, sleeping for the
slow-endpoint model, so the remaining timeout tests that raced a real
provider with a 10 microsecond to 10 millisecond deadline get a
deterministic timeout instead. test_hanging_request_azure drops its
httpx.AsyncClient.send monkeypatch for the same slow deployment
This commit is contained in:
Yuneng Jiang 2026-09-03 11:35:30 -07:00
parent 4b1e24eae9
commit eba51ec958
No known key found for this signature in database
4 changed files with 257 additions and 209 deletions

View file

@ -12,7 +12,11 @@ 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.
still have something to trip on, and a model named ``slow-endpoint`` sleeps past
any short client deadline so timeout tests get a deterministic timeout. The Azure
deployment, Anthropic ``/v1/messages`` and Bedrock ``converse`` URL shapes are
served too, so provider timeout tests can point ``api_base`` here instead of
racing a real provider.
"""
from __future__ import annotations
@ -21,7 +25,8 @@ import asyncio
import json
import time
import uuid
from typing import AsyncIterator, Final
from collections.abc import AsyncIterator
from typing import Final
import uvicorn
from starlette.applications import Starlette
@ -65,6 +70,11 @@ def _requested_model(body: dict[str, object]) -> str:
return model if isinstance(model, str) else "mock-model"
async def _sleep_if_slow(model: str) -> None:
if model == _SLOW_MODEL:
await asyncio.sleep(_SLOW_RESPONSE_SECONDS)
def _wants_stream(body: dict[str, object]) -> bool:
return body.get("stream") is True
@ -136,13 +146,10 @@ async def _chat_completion_stream(model: str, with_usage: bool) -> AsyncIterator
yield "data: [DONE]\n\n"
async def chat_completions(request: Request) -> Response:
body = await _parse_body(request)
model = _requested_model(body)
async def _chat_completion_response(model: str, body: dict[str, object]) -> Response:
if model == _RATE_LIMIT_MODEL:
return _rate_limit_response(model)
if model == _SLOW_MODEL:
await asyncio.sleep(_SLOW_RESPONSE_SECONDS)
await _sleep_if_slow(model)
if _wants_stream(body):
return StreamingResponse(
_chat_completion_stream(model, _wants_stream_usage(body)),
@ -151,6 +158,16 @@ async def chat_completions(request: Request) -> Response:
return JSONResponse(_chat_completion_body(model))
async def chat_completions(request: Request) -> Response:
body = await _parse_body(request)
return await _chat_completion_response(_requested_model(body), body)
async def azure_chat_completions(request: Request) -> Response:
body = await _parse_body(request)
return await _chat_completion_response(request.path_params["deployment"], body)
def _text_completion_body(model: str) -> dict[str, object]:
return {
"id": f"cmpl-{uuid.uuid4().hex[:24]}",
@ -195,8 +212,7 @@ async def completions(request: Request) -> Response:
model = _requested_model(body)
if model == _RATE_LIMIT_MODEL:
return _rate_limit_response(model)
if model == _SLOW_MODEL:
await asyncio.sleep(_SLOW_RESPONSE_SECONDS)
await _sleep_if_slow(model)
if _wants_stream(body):
return StreamingResponse(
_text_completion_stream(model, _wants_stream_usage(body)),
@ -205,11 +221,8 @@ async def completions(request: Request) -> Response:
return JSONResponse(_text_completion_body(model))
async def embeddings(request: Request) -> Response:
body = await _parse_body(request)
model = _requested_model(body)
if model == _SLOW_MODEL:
await asyncio.sleep(_SLOW_RESPONSE_SECONDS)
async def _embeddings_response(model: str, body: dict[str, object]) -> Response:
await _sleep_if_slow(model)
raw_input = body.get("input", "")
count = len(raw_input) if isinstance(raw_input, list) else 1
return JSONResponse(
@ -222,6 +235,76 @@ async def embeddings(request: Request) -> Response:
)
async def embeddings(request: Request) -> Response:
body = await _parse_body(request)
return await _embeddings_response(_requested_model(body), body)
async def azure_embeddings(request: Request) -> Response:
body = await _parse_body(request)
return await _embeddings_response(request.path_params["deployment"], body)
def _anthropic_message_body(model: str) -> dict[str, object]:
return {
"id": f"msg_{uuid.uuid4().hex[:24]}",
"type": "message",
"role": "assistant",
"model": model,
"content": [{"type": "text", "text": _CANNED_CONTENT}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": _PROMPT_TOKENS, "output_tokens": _COMPLETION_TOKENS},
}
async def _anthropic_message_stream(model: str) -> AsyncIterator[str]:
def event(name: str, payload: dict[str, object]) -> str:
return f"event: {name}\ndata: {json.dumps({'type': name} | payload)}\n\n"
opening = _anthropic_message_body(model) | {
"content": [],
"stop_reason": None,
"usage": {"input_tokens": _PROMPT_TOKENS, "output_tokens": 0},
}
yield event("message_start", {"message": opening})
yield event("content_block_start", {"index": 0, "content_block": {"type": "text", "text": ""}})
yield event("content_block_delta", {"index": 0, "delta": {"type": "text_delta", "text": _CANNED_CONTENT}})
yield event("content_block_stop", {"index": 0})
yield event(
"message_delta",
{"delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": _COMPLETION_TOKENS}},
)
yield event("message_stop", {})
async def anthropic_messages(request: Request) -> Response:
body = await _parse_body(request)
model = _requested_model(body)
await _sleep_if_slow(model)
if _wants_stream(body):
return StreamingResponse(_anthropic_message_stream(model), media_type="text/event-stream")
return JSONResponse(_anthropic_message_body(model))
def _bedrock_converse_body() -> dict[str, object]:
return {
"output": {"message": {"role": "assistant", "content": [{"text": _CANNED_CONTENT}]}},
"stopReason": "end_turn",
"usage": {
"inputTokens": _PROMPT_TOKENS,
"outputTokens": _COMPLETION_TOKENS,
"totalTokens": _PROMPT_TOKENS + _COMPLETION_TOKENS,
},
"metrics": {"latencyMs": 1},
}
async def bedrock_converse(request: Request) -> Response:
await _sleep_if_slow(request.path_params["model_id"])
return JSONResponse(_bedrock_converse_body())
async def triton_embeddings(_request: Request) -> Response:
return JSONResponse(
{
@ -286,6 +369,12 @@ app = Starlette(
Route("/v1/completions", completions, methods=["POST"]),
Route("/embeddings", embeddings, methods=["POST"]),
Route("/v1/embeddings", embeddings, methods=["POST"]),
Route("/openai/deployments/{deployment}/chat/completions", azure_chat_completions, methods=["POST"]),
Route("/openai/deployments/{deployment}/embeddings", azure_embeddings, methods=["POST"]),
Route("/openai/v1/chat/completions", chat_completions, methods=["POST"]),
Route("/openai/v1/embeddings", embeddings, methods=["POST"]),
Route("/v1/messages", anthropic_messages, methods=["POST"]),
Route("/model/{model_id}/converse", bedrock_converse, methods=["POST"]),
Route("/triton/embeddings", triton_embeddings, methods=["POST"]),
Route("/moderations", moderations, methods=["POST"]),
Route("/v1/moderations", moderations, methods=["POST"]),

View file

@ -250,11 +250,15 @@ async def test_azure_ai_embedding_image(sync_mode):
def test_openai_azure_embedding_timeouts():
try:
response = embedding(
model="azure/text-embedding-ada-002",
model="azure/slow-endpoint",
input=["good morning from litellm"],
timeout=0.00001,
api_base=FAKE_OPENAI_API_BASE,
api_key="fake-key",
api_version="2024-10-21",
timeout=0.5,
)
print(response)
pytest.fail("Expected timeout error, the request returned instead")
except openai.APITimeoutError:
print("Good job got timeout error!")
pass

View file

@ -94,6 +94,77 @@ def test_slow_model_blocks_past_client_timeout():
)
_CHAT_BODY: Final = {"messages": [{"role": "user", "content": "hi"}], "max_tokens": 5}
_SLOW_PROVIDER_ROUTES: Final = (
("/openai/deployments/slow-endpoint/chat/completions", _CHAT_BODY),
("/openai/deployments/slow-endpoint/embeddings", {"input": "hi"}),
("/v1/messages", _CHAT_BODY | {"model": "slow-endpoint"}),
("/model/slow-endpoint/converse", _CHAT_BODY),
)
@pytest.mark.parametrize("path, body", _SLOW_PROVIDER_ROUTES)
def test_slow_model_blocks_past_client_timeout_on_provider_routes(path, body):
base: Final = ensure_fake_openai_endpoint()
with pytest.raises(httpx.TimeoutException):
httpx.post(f"{base}{path}", json=body, timeout=0.5)
def test_azure_deployment_route_answers_as_the_path_deployment():
base: Final = ensure_fake_openai_endpoint()
response: Final = httpx.post(
f"{base}/openai/deployments/my-deployment/chat/completions", json=_CHAT_BODY, timeout=10
)
assert response.status_code == 200
assert response.json()["model"] == "my-deployment"
assert response.json()["choices"][0]["message"]["content"]
def test_azure_deployment_embeddings_route_shape():
base: Final = ensure_fake_openai_endpoint()
response: Final = httpx.post(
f"{base}/openai/deployments/my-embedding/embeddings", json={"input": ["a", "b"]}, timeout=10
)
assert response.status_code == 200
assert response.json()["model"] == "my-embedding"
assert len(response.json()["data"]) == 2
def test_anthropic_messages_route_shape():
base: Final = ensure_fake_openai_endpoint()
response: Final = httpx.post(f"{base}/v1/messages", json=_CHAT_BODY | {"model": "claude-x"}, timeout=10)
assert response.status_code == 200
body: Final = response.json()
assert body["type"] == "message"
assert body["model"] == "claude-x"
assert body["content"][0]["text"]
assert body["usage"] == {"input_tokens": 20, "output_tokens": 20}
def test_anthropic_messages_route_streams_a_complete_message():
base: Final = ensure_fake_openai_endpoint()
response: Final = httpx.post(
f"{base}/v1/messages", json=_CHAT_BODY | {"model": "claude-x", "stream": True}, timeout=10
)
assert response.status_code == 200
events: Final = re.findall(r"^event: (\S+)$", response.text, flags=re.MULTILINE)
assert events[0] == "message_start"
assert events[-1] == "message_stop"
assert "content_block_delta" in events
def test_bedrock_converse_route_shape():
base: Final = ensure_fake_openai_endpoint()
response: Final = httpx.post(
f"{base}/model/anthropic.claude-haiku-4-5-20251001-v1:0/converse", json=_CHAT_BODY, timeout=10
)
assert response.status_code == 200
body: Final = response.json()
assert body["output"]["message"]["content"][0]["text"]
assert body["stopReason"] == "end_turn"
assert body["usage"]["totalTokens"] == 40
def test_remote_env_base_resolves_to_local(monkeypatch):
monkeypatch.setenv(
"FAKE_OPENAI_API_BASE",

View file

@ -1,11 +1,7 @@
#### What this tests ####
# This tests the timeout decorator
import os
import traceback
import time
from litellm._uuid import uuid
from typing import Final
import httpx
import openai
@ -14,6 +10,32 @@ import pytest
import litellm
from tests.fake_openai_endpoint import FAKE_OPENAI_API_BASE
_ESSAY_MESSAGES: Final = [{"role": "user", "content": "hello, write a 20 pg essay"}]
_TIMEOUT_SECONDS: Final = 0.5
def _slow_openai_deployment(model_name: str) -> dict:
return {
"model_name": model_name,
"litellm_params": {
"model": "openai/slow-endpoint",
"api_base": FAKE_OPENAI_API_BASE,
"api_key": "fake-key",
},
}
def _slow_azure_deployment(model_name: str) -> dict:
return {
"model_name": model_name,
"litellm_params": {
"model": "azure/slow-endpoint",
"api_base": FAKE_OPENAI_API_BASE,
"api_key": "fake-key",
"api_version": "2024-10-21",
},
}
@pytest.mark.parametrize(
"model, provider",
@ -45,201 +67,73 @@ async def test_httpx_timeout(model, provider, sync_mode):
def test_timeout():
# this Will Raise a timeout
litellm.set_verbose = False
try:
response = litellm.completion(
model="gpt-3.5-turbo",
timeout=0.01,
messages=[{"role": "user", "content": "hello, write a 20 pg essay"}],
with pytest.raises(openai.APITimeoutError):
litellm.completion(
model="openai/slow-endpoint",
messages=_ESSAY_MESSAGES,
api_base=FAKE_OPENAI_API_BASE,
api_key="fake-key",
timeout=_TIMEOUT_SECONDS,
)
except openai.APITimeoutError as e:
print(
"Passed: Raised correct exception. Got openai.APITimeoutError\nGood Job", e
)
print(type(e))
pass
except Exception as e:
pytest.fail(
f"Did not raise error `openai.APITimeoutError`. Instead raised error type: {type(e)}, Error: {e}"
)
# test_timeout()
def test_bedrock_timeout():
# this Will Raise a timeout
litellm.set_verbose = True
try:
response = litellm.completion(
model="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0",
timeout=0.01,
messages=[{"role": "user", "content": "hello, write a 20 pg essay"}],
)
pytest.fail("Did not raise error `openai.APITimeoutError`")
except openai.APITimeoutError as e:
print(
"Passed: Raised correct exception. Got openai.APITimeoutError\nGood Job", e
)
print(type(e))
pass
except Exception as e:
pytest.fail(
f"Did not raise error `openai.APITimeoutError`. Instead raised error type: {type(e)}, Error: {e}"
with pytest.raises(openai.APITimeoutError):
litellm.completion(
model="bedrock/converse/slow-endpoint",
messages=_ESSAY_MESSAGES,
api_base=FAKE_OPENAI_API_BASE,
aws_access_key_id="fake-access-key",
aws_secret_access_key="fake-secret-key",
aws_region_name="us-east-1",
timeout=_TIMEOUT_SECONDS,
)
def test_hanging_request_azure():
"""
Test that a slow Azure request properly raises APITimeoutError via the Router.
Uses a mock to simulate a slow HTTP response so the timeout fires reliably,
rather than racing against real network latency.
"""
@pytest.mark.asyncio
async def test_hanging_request_azure():
litellm.set_verbose = True
import asyncio
from unittest.mock import AsyncMock, patch
try:
router = litellm.Router(
model_list=[
{
"model_name": "azure-gpt",
"litellm_params": {
"model": "azure/gpt-4.1-mini",
"api_base": os.environ["AZURE_AI_API_BASE"],
"api_key": os.environ["AZURE_AI_API_KEY"],
},
},
{
"model_name": "openai-gpt",
"litellm_params": {"model": "gpt-3.5-turbo"},
},
],
num_retries=0,
router = litellm.Router(
model_list=[_slow_azure_deployment("azure-gpt"), _slow_openai_deployment("openai-gpt")],
num_retries=0,
)
with pytest.raises(openai.APITimeoutError):
await router.acompletion(
model="azure-gpt",
messages=[{"role": "user", "content": "what color is red"}],
timeout=_TIMEOUT_SECONDS,
)
encoded = litellm.utils.encode(model="gpt-3.5-turbo", text="blue")[0]
original_send = httpx.AsyncClient.send
async def _slow_send(self, request, *args, **kwargs):
await asyncio.sleep(5)
return await original_send(self, request, *args, **kwargs)
async def _test():
with patch.object(httpx.AsyncClient, "send", new=_slow_send):
response = await router.acompletion(
model="azure-gpt",
messages=[
{
"role": "user",
"content": f"what color is red {uuid.uuid4()}",
}
],
logit_bias={encoded: 100},
timeout=0.01,
)
print(response)
return response
response = asyncio.run(_test())
if response.choices[0].message.content is not None:
pytest.fail("Got a response, expected a timeout")
except openai.APITimeoutError as e:
print(
"Passed: Raised correct exception. Got openai.APITimeoutError\nGood Job", e
)
print(type(e))
pass
except Exception as e:
pytest.fail(
f"Did not raise error `openai.APITimeoutError`. Instead raised error type: {type(e)}, Error: {e}"
)
# test_hanging_request_azure()
def test_hanging_request_openai():
litellm.set_verbose = True
try:
router = litellm.Router(
model_list=[
{
"model_name": "azure-gpt",
"litellm_params": {
"model": "azure/gpt-4.1-mini",
"api_base": os.environ["AZURE_AI_API_BASE"],
"api_key": os.environ["AZURE_AI_API_KEY"],
},
},
{
"model_name": "openai-gpt",
"litellm_params": {"model": "gpt-3.5-turbo"},
},
],
num_retries=0,
)
encoded = litellm.utils.encode(model="gpt-3.5-turbo", text="blue")[0]
response = router.completion(
router = litellm.Router(
model_list=[_slow_azure_deployment("azure-gpt"), _slow_openai_deployment("openai-gpt")],
num_retries=0,
)
with pytest.raises(openai.APITimeoutError):
router.completion(
model="openai-gpt",
messages=[{"role": "user", "content": "what color is red"}],
logit_bias={encoded: 100},
timeout=0.01,
timeout=_TIMEOUT_SECONDS,
)
print(response)
if response.choices[0].message.content is not None:
pytest.fail("Got a response, expected a timeout")
except openai.APITimeoutError as e:
print(
"Passed: Raised correct exception. Got openai.APITimeoutError\nGood Job", e
)
print(type(e))
pass
except Exception as e:
pytest.fail(
f"Did not raise error `openai.APITimeoutError`. Instead raised error type: {type(e)}, Error: {e}"
)
# test_hanging_request_openai()
# test_timeout()
def test_timeout_streaming():
# this Will Raise a timeout
litellm.set_verbose = False
try:
with pytest.raises(openai.APITimeoutError):
response = litellm.completion(
model="openai/slow-endpoint",
messages=[{"role": "user", "content": "hello, write a 20 pg essay"}],
messages=_ESSAY_MESSAGES,
api_base=FAKE_OPENAI_API_BASE,
api_key="fake-key",
timeout=0.5,
timeout=_TIMEOUT_SECONDS,
stream=True,
)
for chunk in response:
print(chunk)
pytest.fail("Did not raise error `openai.APITimeoutError`. The stream completed instead")
except openai.APITimeoutError as e:
print(
"Passed: Raised correct exception. Got openai.APITimeoutError\nGood Job", e
)
print(type(e))
pass
except Exception as e:
pytest.fail(
f"Did not raise error `openai.APITimeoutError`. Instead raised error type: {type(e)}, Error: {e}"
)
# test_timeout_streaming()
@pytest.mark.skip(reason="local test")
@ -273,32 +167,22 @@ def test_timeout_ollama():
@pytest.mark.asyncio
async def test_anthropic_timeout(streaming, sync_mode):
litellm.set_verbose = False
try:
request: Final = {
"model": "anthropic/slow-endpoint",
"messages": _ESSAY_MESSAGES,
"api_base": FAKE_OPENAI_API_BASE,
"api_key": "fake-key",
"timeout": _TIMEOUT_SECONDS,
"stream": streaming,
}
with pytest.raises(openai.APITimeoutError):
if sync_mode:
response = litellm.completion(
model="claude-sonnet-4-5-20250929",
timeout=0.01,
messages=[{"role": "user", "content": "hello, write a 20 pg essay"}],
stream=streaming,
)
response = litellm.completion(**request)
if isinstance(response, litellm.CustomStreamWrapper):
for chunk in response:
for _ in response:
pass
else:
response = await litellm.acompletion(
model="claude-sonnet-4-5-20250929",
timeout=0.01,
messages=[{"role": "user", "content": "hello, write a 20 pg essay"}],
stream=streaming,
)
response = await litellm.acompletion(**request)
if isinstance(response, litellm.CustomStreamWrapper):
async for chunk in response:
async for _ in response:
pass
pytest.fail("Did not raise error `openai.APITimeoutError`")
except openai.APITimeoutError as e:
print(
"Passed: Raised correct exception. Got openai.APITimeoutError\nGood Job", e
)
print(type(e))
pass