litellm/tests/integration/compatibility/test_status_code_followups_defaults.py
yuneng-jiang 44d99475a5
test(integration): stop GET /__observations from draining the upstream log (#47256)
* test(integration): stop GET /__observations from draining the upstream log

GET /__observations used to empty the queue, so a second read in a test
saw nothing and a "never reached upstream" check could pass whether or not
the request was forwarded. GET now returns every request since the last
reset and DELETE /__observations is the reset. An autouse fixture resets
the worker upstream before each test, which also covers tests that read
without the gateway fixture.

Discarded reads used as resets become DELETEs, helpers that accumulated
drained reads now read one snapshot, and tests with several phases reset
between them. The three vacuous negative checks (rag allowlist, guardrail
opt-out chaos, xAI batch output) now assert the absence on the same read
that shows a request which did reach the upstream.

* style(integration): space the observations unit test like its neighbours
2026-10-18 09:58:05 +00:00

385 lines
17 KiB
Python

"""Router-wide, deployment and `user_config` defaults against the params the Router binds positionally, the
moderation merge, unknown and A2A models, and every `user_config` shape on the three LLM endpoints."""
from __future__ import annotations
import asyncio
from collections.abc import Iterator, Mapping
from dataclasses import dataclass
from typing import Final
import anthropic
import httpx
import openai
import pytest
from integration._support.client import Gateway, gateway_from_environment
from integration.compatibility._status_code_audit import (
UNSET,
CHAT,
CHAT_FRAMES,
ENDPOINT_BODIES,
ENDPOINT_PATHS,
MESSAGE,
MESSAGE_FRAMES,
MODERATION,
RESPONSE,
RESPONSES_FRAMES,
ROUTER_DEFAULTS,
USER_MESSAGES,
assembled_text,
assert_no_provider_call,
deployment,
error_body,
invalid_request,
json_response,
one_outbound,
owned_gateway,
post,
sse_response,
stream_finished,
stream_lines,
)
from pydantic import JsonValue
from tests.integration.cost_calculation.cost_tracking_case import BinaryResponse, StoredResponse
pytestmark: Final = pytest.mark.timeout(180)
@dataclass(frozen=True, slots=True)
class _Rig:
gateway: Gateway
identities: Mapping[str, str]
deployments: Mapping[str, dict[str, JsonValue]]
_SPECS: Final[tuple[tuple[str, str, StoredResponse, dict[str, JsonValue]], ...]] = (
("audit-image", "openai/gpt-image-1", json_response({"created": 1, "data": []}), {}),
("audit-image-dep", "openai/gpt-image-1", json_response({"created": 1, "data": []}), {"prompt": "deployment"}),
("audit-text", "openai/gpt-3.5-turbo-instruct", json_response({"choices": []}), {}),
("audit-text-dep", "openai/gpt-3.5-turbo-instruct", json_response({"choices": []}), {"prompt": "deployment"}),
("audit-speech", "openai/gpt-4o-mini-tts", BinaryResponse(content_type="audio/mpeg", length=16), {}),
(
"audit-speech-dep",
"openai/gpt-4o-mini-tts",
BinaryResponse(content_type="audio/mpeg", length=16),
{"input": "deployment"},
),
("audit-transcribe", "openai/whisper-1", json_response({"text": "scripted"}), {}),
("audit-moderation", "openai/omni-moderation-latest", json_response(MODERATION), {}),
("audit-chat", "openai/gpt-4o-mini", json_response(CHAT), {}),
("audit-chat-stream", "openai/gpt-4o-mini", sse_response(CHAT_FRAMES), {}),
("audit-responses", "openai/gpt-4o-mini", json_response(RESPONSE), {}),
("audit-responses-stream", "openai/gpt-4o-mini", sse_response(RESPONSES_FRAMES), {}),
("audit-message", "anthropic/claude-haiku-4-5", json_response(MESSAGE), {}),
("audit-message-stream", "anthropic/claude-haiku-4-5", sse_response(MESSAGE_FRAMES), {}),
)
@pytest.fixture(scope="module")
def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Rig]:
"""A proxy whose router-wide defaults name every positional and route-required param, with client-side
`user_config` allowed, and one deployment per route (plus `-dep` copies that carry the param themselves)."""
with gateway_from_environment() as gateway, gateway.scenario() as scenario:
built: Final = tuple(
deployment(scenario, name, model, response, **extra) for name, model, response, extra in _SPECS
)
identities: Final = {name: identity for (name, *_), (identity, _) in zip(_SPECS, built, strict=True)}
deployments: Final = {name: entry for (name, *_), (_, entry) in zip(_SPECS, built, strict=True)}
config: Final[dict[str, JsonValue]] = {
"model_list": [entry for _, entry in built],
"router_settings": {"default_litellm_params": ROUTER_DEFAULTS, "num_retries": 0},
"general_settings": {"allow_client_side_credentials": True},
}
with owned_gateway(tmp_path_factory.mktemp("audit-defaults"), config) as owned:
yield _Rig(owned, identities, deployments)
_POSITIONAL: Final[dict[str, tuple[str, str, str, str, dict[str, JsonValue]]]] = {
"image": ("/v1/images/generations", "/image/generations", "audit-image", "prompt", {}),
"text-completion": ("/v1/completions", "/completions", "audit-text", "prompt", {}),
"speech": ("/v1/audio/speech", "/audio/speech", "audit-speech", "input", {"voice": "alloy"}),
}
def _user_config(rig: _Rig, name: str, defaults: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
return {"model_list": [rig.deployments[name]], "default_litellm_params": dict(defaults)}
@pytest.mark.parametrize("route", tuple(_POSITIONAL))
@pytest.mark.parametrize("source", ("deployment-param", "user-config-default", "user-config-null"))
def test_positional_param_from_any_default_source_returns_400(rig: _Rig, route: str, source: str) -> None:
path, error_route, name, param, extra = _POSITIONAL[route]
user_config: Final[dict[str, JsonValue]] = (
{"user_config": _user_config(rig, name, {param: "user config default"})}
if source == "user-config-default"
else {"user_config": None}
if source == "user-config-null"
else {}
)
body: Final[dict[str, JsonValue]] = {
"model": f"{name}-dep" if source == "deployment-param" else name,
**extra,
**user_config,
}
response: Final = post(rig.gateway, path, body)
error: Final = invalid_request(response)
assert error.get("param") == param, response.text
assert error.get("message") == f"{error_route}: Missing required parameter: '{param}'.", response.text
assert_no_provider_call(rig.gateway, rig.identities[name], rig.identities[f"{name}-dep"])
@pytest.mark.parametrize("route", tuple(_POSITIONAL))
def test_positional_param_in_the_body_reaches_upstream(rig: _Rig, route: str) -> None:
pytest.skip(
"BUG: a router-wide default_litellm_params value replaces the caller's own positional param "
"(prompt/input) on its way to the provider; same on the merge base"
)
path, _error_route, name, param, extra = _POSITIONAL[route]
response: Final = post(rig.gateway, path, {"model": name, param: f"explicit {route}", **extra})
assert response.status_code == 200, response.text
assert one_outbound(rig.gateway, rig.identities[name]).get(param) == f"explicit {route}"
def test_transcription_without_file_is_rejected_by_the_form_before_routing(rig: _Rig) -> None:
response: Final = rig.gateway.client.post(
"/v1/audio/transcriptions",
data={"model": "audit-transcribe"},
files={"note": ("note.txt", b"not audio", "text/plain")},
headers={"Authorization": f"Bearer {rig.gateway.key}"},
)
assert response.status_code == 422, response.text
detail: Final = response.json()["detail"]
assert [entry["loc"] for entry in detail] == [["body", "file"]], response.text
assert_no_provider_call(rig.gateway, rig.identities["audit-transcribe"])
def test_transcription_with_file_reaches_upstream(rig: _Rig) -> None:
response: Final = rig.gateway.client.post(
"/v1/audio/transcriptions",
data={"model": "audit-transcribe"},
files={"file": ("clip.wav", b"RIFF0000WAVEfmt ", "audio/wav")},
headers={"Authorization": f"Bearer {rig.gateway.key}"},
)
assert response.status_code == 200, response.text
assert response.json()["text"] == "scripted"
outbound: Final = one_outbound(rig.gateway, rig.identities["audit-transcribe"])
assert outbound.get("model") == "whisper-1"
assert outbound.get("file") == {"filename": "clip.wav", "content_type": "audio/x-wav"}
@pytest.mark.parametrize(
("body", "expected_input"),
(({}, "router default input"), ({"input": "explicit input"}, "explicit input")),
ids=("router-default", "explicit"),
)
def test_listed_moderation_model_merges_the_router_default(
rig: _Rig, body: dict[str, JsonValue], expected_input: str
) -> None:
response: Final = post(rig.gateway, "/v1/moderations", {"model": "audit-moderation", **body})
assert response.status_code == 200, response.text
assert one_outbound(rig.gateway, rig.identities["audit-moderation"]).get("input") == expected_input
@pytest.mark.parametrize(
("path", "body", "message"),
(
pytest.param(
"/v1/images/generations",
{"model": "audit-unknown-model"},
"/image/generations: Missing required parameter: 'prompt'.",
id="image-unknown-model",
),
pytest.param(
"/v1/images/generations",
{"model": "a2a/audit-unknown-agent"},
"/image/generations: Missing required parameter: 'prompt'.",
id="image-a2a-model",
),
pytest.param(
"/v1/messages",
{"model": "a2a/audit-unknown-agent", "messages": USER_MESSAGES},
"anthropic_messages: Invalid model name passed in model=a2a/audit-unknown-agent. "
"Call `/v1/models` to view available models for your key.",
id="messages-a2a-model",
),
),
)
def test_model_outside_the_router_with_a_router_default_param(
rig: _Rig, path: str, body: dict[str, JsonValue], message: str
) -> None:
response: Final = post(rig.gateway, path, body)
assert response.status_code == 400, response.text
error: Final = error_body(response)
assert error.get("type") == "invalid_request_error", response.text
assert error.get("message") == message, response.text
@pytest.mark.parametrize(
("user_config", "message"),
(
pytest.param(None, None, id="null"),
pytest.param(UNSET, None, id="missing"),
pytest.param({}, "There are no healthy deployments for this model", id="empty-object"),
pytest.param("", "Invalid request format: 'str' object has no attribute 'items'", id="empty-string"),
pytest.param(7, "Invalid request format: 'int' object has no attribute 'items'", id="int"),
pytest.param([], "Invalid request format: 'list' object has no attribute 'items'", id="list"),
pytest.param("x" * 5000, "Invalid request format: 'str' object has no attribute 'items'", id="5kb-string"),
),
)
def test_chat_user_config_shapes(rig: _Rig, user_config: object, message: str | None) -> None:
body: Final[dict[str, JsonValue]] = {
"model": "audit-chat",
"messages": USER_MESSAGES,
**({} if user_config is UNSET else {"user_config": user_config}), # pyright: ignore[reportAssignmentType] # the sad shapes are deliberately not JSON objects
}
response: Final = post(rig.gateway, "/v1/chat/completions", body)
if message is None:
assert response.status_code == 200, response.text
assert one_outbound(rig.gateway, rig.identities["audit-chat"]).get("max_tokens") == 32
return
assert response.status_code == 400, response.text
assert message in str(error_body(response).get("message")), response.text
assert_no_provider_call(rig.gateway, rig.identities["audit-chat"])
def test_messages_empty_user_config_does_not_inherit_router_defaults(rig: _Rig) -> None:
response: Final = post(
rig.gateway, "/v1/messages", {"model": "audit-message", "messages": USER_MESSAGES, "user_config": {}}
)
assert response.status_code == 400, response.text
assert error_body(response).get("message") == "anthropic_messages: Missing required parameter: 'max_tokens'."
assert_no_provider_call(rig.gateway, rig.identities["audit-message"])
@pytest.mark.parametrize(
("endpoint", "model", "stream", "expected_max_tokens"),
(
pytest.param("chat", "audit-chat", False, 32, id="chat"),
pytest.param("chat", "audit-chat-stream", True, 32, id="chat-stream"),
pytest.param("responses", "audit-responses", False, None, id="responses"),
pytest.param("responses", "audit-responses-stream", True, None, id="responses-stream"),
pytest.param("messages", "audit-message-stream", True, 32, id="messages-stream"),
),
)
def test_null_user_config_serves_every_endpoint(
rig: _Rig, endpoint: str, model: str, stream: bool, expected_max_tokens: int | None
) -> None:
body: Final[dict[str, JsonValue]] = {
**{key: value for key, value in ENDPOINT_BODIES[endpoint].items() if key != "max_tokens"},
"model": model,
"user_config": None,
**({"stream": True} if stream else {}),
}
if stream:
status, lines = stream_lines(rig.gateway, ENDPOINT_PATHS[endpoint], body)
assert status == 200, lines
assert stream_finished(endpoint, lines), lines
assert assembled_text(lines) == "streamed response", lines
else:
response: Final = post(rig.gateway, ENDPOINT_PATHS[endpoint], body)
assert response.status_code == 200, response.text
assert "scripted" in response.text, response.text
outbound: Final = one_outbound(rig.gateway, rig.identities[model])
assert "user_config" not in outbound, outbound
assert outbound.get("max_tokens") == expected_max_tokens, outbound
def test_null_user_config_without_a_valid_key_is_401(rig: _Rig) -> None:
response: Final = post(
rig.gateway,
"/v1/chat/completions",
{"model": "audit-chat", "messages": USER_MESSAGES, "user_config": None},
key="sk-audit-not-a-key",
)
assert response.status_code == 401, response.text
assert_no_provider_call(rig.gateway, rig.identities["audit-chat"])
def _openai_chat(rig: _Rig, asynchronous: bool) -> str | None:
base_url: Final = f"{rig.gateway.client.base_url}/v1"
if not asynchronous:
with openai.OpenAI(base_url=base_url, api_key=rig.gateway.key, max_retries=0) as client:
completion: Final = client.chat.completions.create(
model="audit-chat",
messages=[{"role": "user", "content": "Hello"}],
extra_body={"user_config": None},
)
return completion.choices[0].message.content
async def call() -> str | None:
async with openai.AsyncOpenAI(base_url=base_url, api_key=rig.gateway.key, max_retries=0) as client:
completion: Final = await client.chat.completions.create(
model="audit-chat",
messages=[{"role": "user", "content": "Hello"}],
extra_body={"user_config": None},
)
return completion.choices[0].message.content
return asyncio.run(call())
def _anthropic_message(rig: _Rig, asynchronous: bool) -> str:
base_url: Final = str(rig.gateway.client.base_url)
if not asynchronous:
with anthropic.Anthropic(base_url=base_url, api_key=rig.gateway.key, max_retries=0) as client:
message: Final = client.messages.create(
model="audit-message",
max_tokens=8,
messages=[{"role": "user", "content": "Hello"}],
extra_body={"user_config": None},
)
block: Final = message.content[0]
assert isinstance(block, anthropic.types.TextBlock), message
return block.text
async def call() -> str:
async with anthropic.AsyncAnthropic(base_url=base_url, api_key=rig.gateway.key, max_retries=0) as client:
message: Final = await client.messages.create(
model="audit-message",
max_tokens=8,
messages=[{"role": "user", "content": "Hello"}],
extra_body={"user_config": None},
)
block: Final = message.content[0]
assert isinstance(block, anthropic.types.TextBlock), message
return block.text
return asyncio.run(call())
@pytest.mark.parametrize("asynchronous", (False, True), ids=("sync", "async"))
def test_null_user_config_through_the_openai_sdk(rig: _Rig, asynchronous: bool) -> None:
assert _openai_chat(rig, asynchronous) == "scripted"
assert one_outbound(rig.gateway, rig.identities["audit-chat"]).get("max_tokens") == 32
@pytest.mark.parametrize("asynchronous", (False, True), ids=("sync", "async"))
def test_null_user_config_through_the_anthropic_sdk(rig: _Rig, asynchronous: bool) -> None:
assert _anthropic_message(rig, asynchronous) == "scripted"
assert one_outbound(rig.gateway, rig.identities["audit-message"]).get("max_tokens") == 8
def test_null_user_config_without_the_client_side_opt_in_is_rejected(gateway: Gateway) -> None:
response: Final = gateway.client.post(
"/v1/chat/completions",
json={"model": "audit-not-routed", "messages": USER_MESSAGES, "user_config": None},
headers={"Authorization": f"Bearer {gateway.key}"},
)
assert response.status_code == 401, response.text
assert "user_config is not allowed in request body" in response.text, response.text
def test_raw_httpx_async_null_user_config(rig: _Rig) -> None:
async def call() -> httpx.Response:
async with httpx.AsyncClient(base_url=str(rig.gateway.client.base_url), trust_env=False) as client:
return await client.post(
"/v1/responses",
json={"model": "audit-responses", "input": "Hello", "store": False, "user_config": None},
headers={"Authorization": f"Bearer {rig.gateway.key}"},
timeout=30,
)
response: Final = asyncio.run(call())
assert response.status_code == 200, response.text
assert one_outbound(rig.gateway, rig.identities["audit-responses"]).get("input") == "Hello"