litellm/tests/integration/compatibility/test_status_code_followups.py
yuneng-jiang fc27dfdc7d
test(integration): give each owned proxy a private database and Redis by default (#47213)
* test(integration): give each owned proxy a private database and Redis by default

owned_proxy and owned_proxy_process now boot on a scratch database and a
per-proxy redis-server unless the caller opts into the worker's state with
state=OwnedState.SHARED_WITH_WORKER, passes its own DATABASE_URL, or strips
DATABASE_URL. Under the replica profile the private database gets the
writer and reader roles so the reader path stays covered.

Gateway carries the database_url its proxy writes to, and the Scenario
cleanup checks and delete_key_if_present read from it, so they keep
checking something on a private database.

The 189 test files with owned proxy sites that depend on the worker's
database or Redis opt in with a shares_worker_state module marker, which a
module-scoped conftest fixture turns into the shared state. The 7 helper
modules with such sites opt in per call, since conftest fixtures and other
test files reach them.

* test(integration): reach session fixtures with the shared marker and keep the worker retry default

The module-scoped fixture that applied shares_worker_state ran after
session-scoped fixtures, so proxies those booted went private in marked
files. The marker is now applied around each marked item's setup, call
and teardown, which covers fixtures of every scope.

Owned proxies on the worker database inherited the worker gateway's
router_settings.num_retries of 0 from it. A private proxy now gets the same
default in its config copy unless the config sets num_retries itself.

Five providers files read the owned proxy's spend rows or response cache
through the worker's database or Redis and opt into sharing.
2026-10-18 03:37:41 +00:00

374 lines
16 KiB
Python

from __future__ import annotations
import json
import uuid
from collections.abc import Iterator
from pathlib import Path
from typing import Final
import httpx
import pytest
from integration._support.client import (
JSON_OBJECT,
Gateway,
Scenario,
eventually,
gateway_from_environment,
object_value,
)
from integration._support.process import owned_proxy_process
from integration._support.upstream import ScenarioHandle, delete_scenario, register_scenario
from pydantic import JsonValue
from tests.integration.cost_calculation.cost_tracking_case import BinaryResponse, JsonResponse
_ROUTER_DEFAULTS: Final[dict[str, JsonValue]] = {
"prompt": "router default prompt",
"input": "router default input",
"max_tokens": 32,
}
_MESSAGE: Final[dict[str, JsonValue]] = {
"id": "msg-$UNIQUE_ID",
"type": "message",
"role": "assistant",
"model": "claude-haiku-4-5",
"content": [{"type": "text", "text": "scripted"}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 1, "output_tokens": 1},
}
_CHAT: Final[dict[str, JsonValue]] = {
"id": "chatcmpl-$UNIQUE_ID",
"object": "chat.completion",
"created": 1,
"model": "gpt-4o-mini",
"choices": [
{"index": 0, "message": {"role": "assistant", "content": "scripted"}, "finish_reason": "stop"},
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
_RESPONSE: Final[dict[str, JsonValue]] = {
"id": "resp_$UNIQUE_ID",
"object": "response",
"created_at": 1,
"status": "completed",
"model": "gpt-4o-mini",
"output": [],
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
}
_VECTOR_STORE_FILE: Final[dict[str, JsonValue]] = {
"id": "file-audit",
"object": "vector_store.file",
"created_at": 1,
"usage_bytes": 0,
"vector_store_id": "vs-audit",
"status": "completed",
"last_error": None,
"attributes": {},
}
_SEVENTEEN_ATTRIBUTES: Final[dict[str, JsonValue]] = {f"key_{index:02d}": "v" for index in range(17)}
def _openai_error(message: str, param: str) -> JsonResponse:
return JsonResponse(
content_type="application/json",
status=400,
body={"error": {"message": message, "type": "invalid_request_error", "param": param, "code": None}},
)
class _Observations:
def __init__(self, url: str) -> None:
self.url = url.rstrip("/")
self.items: tuple[dict[str, JsonValue], ...] = ()
def read(self) -> tuple[dict[str, JsonValue], ...]:
with httpx.Client(timeout=10, trust_env=False) as client:
payload: Final = JSON_OBJECT.validate_python(
client.get(f"{self.url}/__observations?include_method=true").json()
)
requests: Final = payload.get("requests")
assert isinstance(requests, list)
self.items = (*self.items, *(object_value(item) for item in requests if isinstance(item, dict)))
return self.items
def provider_calls(self, identity: str) -> tuple[dict[str, JsonValue], ...]:
return tuple(
item
for item in self.items
if f"/{identity}/" in str(item.get("path"))
and not (item.get("method") == "GET" and str(item.get("path", "")).endswith(("/v1/models", "/models")))
)
def _scripted(scenario: Scenario, prefix: str, response: JsonResponse | BinaryResponse) -> tuple[str, ScenarioHandle]:
identity: Final = f"{prefix}-{uuid.uuid4().hex}"
handle: Final = register_scenario(identity, response)
scenario.cleanups.callback(delete_scenario, handle)
return identity, handle
def _outbound(gateway: Gateway, identity: str) -> dict[str, JsonValue]:
observations: Final = _Observations(gateway.upstream_url)
eventually(observations.read, lambda _items: len(observations.provider_calls(identity)) == 1, seconds=20)
return object_value(observations.provider_calls(identity)[0]["body"])
def _assert_no_provider_call(gateway: Gateway, identity: str) -> None:
observations: Final = _Observations(gateway.upstream_url)
observations.read()
assert observations.provider_calls(identity) == ()
def _assert_no_call_to_deployment(gateway: Gateway, model_name: str) -> None:
observations: Final = _Observations(gateway.upstream_url)
observations.read()
calls: Final = tuple(
f"{item.get('method')} {item.get('path')}"
for item in observations.items
if f"/{model_name}-" in str(item.get("path")) and not str(item.get("path")).endswith("/models")
)
assert calls == (), calls
def _post(gateway: Gateway, path: str, body: dict[str, JsonValue]) -> httpx.Response:
return gateway.client.post(path, json=body, headers={"Authorization": f"Bearer {gateway.key}"})
def _invalid_request(response: httpx.Response) -> dict[str, JsonValue]:
assert response.status_code == 400, response.text
error: Final = object_value(JSON_OBJECT.validate_python(response.json())["error"])
assert error.get("type") == "invalid_request_error", response.text
return error
def _write_config(path: Path, config: dict[str, JsonValue]) -> Path:
path.write_text(json.dumps(config), encoding="utf-8")
return path
@pytest.fixture(scope="module")
def router_default_proxy(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Gateway]:
"""A proxy whose router-wide defaults name positional and route-required params,
with client-side `user_config` allowed so a null value can be sent."""
directory: Final = tmp_path_factory.mktemp("router-defaults")
with gateway_from_environment() as gateway, gateway.scenario() as scenario:
image_identity, image_handle = _scripted(
scenario, "followup-image", JsonResponse(content_type="application/json", body={"created": 1, "data": []})
)
text_identity, text_handle = _scripted(
scenario, "followup-text", JsonResponse(content_type="application/json", body={"choices": []})
)
speech_identity, speech_handle = _scripted(
scenario, "followup-speech", BinaryResponse(content_type="audio/mpeg", length=16)
)
message_identity, message_handle = _scripted(
scenario, "followup-message", JsonResponse(content_type="application/json", body=_MESSAGE)
)
config: Final = _write_config(
directory / "router-defaults.yaml",
{
"model_list": [
{
"model_name": "followup-image",
"litellm_params": {
"model": "openai/gpt-image-1",
"api_base": image_handle.api_base(),
"api_key": image_identity,
},
},
{
"model_name": "followup-text",
"litellm_params": {
"model": "openai/gpt-3.5-turbo-instruct",
"api_base": text_handle.api_base(),
"api_key": text_identity,
},
},
{
"model_name": "followup-speech",
"litellm_params": {
"model": "openai/gpt-4o-mini-tts",
"api_base": speech_handle.api_base(),
"api_key": speech_identity,
},
},
{
"model_name": "followup-message",
"litellm_params": {
"model": "anthropic/claude-haiku-4-5",
"api_base": message_handle.api_base(),
"api_key": message_identity,
},
},
],
"router_settings": {"default_litellm_params": _ROUTER_DEFAULTS},
"general_settings": {"allow_client_side_credentials": True},
},
)
with owned_proxy_process(gateway, directory, {}, config=config) as owned:
yield owned.gateway
def test_ocr_multipart_without_file_returns_400(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
identity, handle = _scripted(
scenario, "followup-ocr", JsonResponse(content_type="application/json", body={"pages": []})
)
model: Final = scenario.model(model="mistral/mistral-ocr-latest", api_base=handle.api_base(), api_key=identity)
response: Final = gateway.client.post(
"/v1/ocr", files={"model": (None, model)}, headers={"Authorization": f"Bearer {gateway.key}"}
)
error: Final = _invalid_request(response)
assert error.get("param") == "file", response.text
_assert_no_provider_call(gateway, identity)
def test_ocr_empty_file_returns_400(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
identity, handle = _scripted(
scenario, "followup-ocr-empty", JsonResponse(content_type="application/json", body={"pages": []})
)
model: Final = scenario.model(model="mistral/mistral-ocr-latest", api_base=handle.api_base(), api_key=identity)
response: Final = gateway.request_multipart(
"/v1/ocr", {"model": model}, {"file": ("empty.pdf", b"", "application/pdf")}
)
error: Final = _invalid_request(response)
assert error.get("message") == "Uploaded file is empty", response.text
_assert_no_provider_call(gateway, identity)
@pytest.mark.parametrize("max_output_tokens", (-1, 0), ids=("negative", "zero"))
def test_responses_non_positive_max_output_tokens_returns_provider_400(
gateway: Gateway, max_output_tokens: int
) -> None:
message: Final = f"Invalid 'max_output_tokens': integer below minimum value. Expected a value >= 16, but got {max_output_tokens} instead."
with gateway.scenario() as scenario:
identity, handle = _scripted(scenario, "followup-max-output", _openai_error(message, "max_output_tokens"))
model: Final = scenario.model(model="openai/gpt-4o-mini", api_base=handle.api_base(), api_key=identity)
response: Final = _post(
gateway,
"/v1/responses",
{"model": model, "input": "Say pong", "store": False, "max_output_tokens": max_output_tokens},
)
assert response.status_code == 400, response.text
assert message in response.text, response.text
assert _outbound(gateway, identity).get("max_output_tokens") == max_output_tokens
def test_responses_small_positive_max_output_tokens_is_still_raised_to_minimum(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
identity, handle = _scripted(
scenario, "followup-max-output-min", JsonResponse(content_type="application/json", body=_RESPONSE)
)
model: Final = scenario.model(model="openai/gpt-4o-mini", api_base=handle.api_base(), api_key=identity)
response: Final = _post(
gateway, "/v1/responses", {"model": model, "input": "Say pong", "store": False, "max_output_tokens": 1}
)
assert response.status_code == 200, response.text
assert _outbound(gateway, identity).get("max_output_tokens") == 16
def test_team_daily_activity_end_before_start_returns_400(gateway: Gateway) -> None:
response: Final = gateway.request(
"GET",
"/team/daily/activity",
params={"start_date": "2026-10-01", "end_date": "2026-09-24", "page": "1"},
)
assert response.status_code == 400, response.text
assert response.json() == {"detail": {"error": "end_date must be on or after start_date"}}, response.text
def _registered_store(gateway: Gateway, scenario: Scenario, identity: str, handle: ScenarioHandle) -> str:
store: Final = f"vs-{uuid.uuid4().hex}"
model: Final = scenario.model(model="openai/text-embedding-3-small", api_base=handle.api_base(), api_key=identity)
gateway.post(
"/vector_store/new",
{
"vector_store_id": store,
"custom_llm_provider": "openai",
"litellm_params": {"model": model, "api_base": handle.api_base(), "api_key": identity},
},
)
scenario.cleanups.callback(gateway.post, "/vector_store/delete", {"vector_store_id": store})
return store
@pytest.mark.parametrize(
("path", "body"),
(
("/v1/vector_stores/{store}/files/file-audit", {"attributes": _SEVENTEEN_ATTRIBUTES}),
("/v1/vector_stores/{store}/files", {"file_id": "file-audit", "attributes": _SEVENTEEN_ATTRIBUTES}),
),
ids=("update", "create"),
)
def test_vector_store_file_attributes_over_provider_limit_reach_provider(
gateway: Gateway, path: str, body: dict[str, JsonValue]
) -> None:
message: Final = (
"Invalid 'attributes': too many properties. "
"Expected an object with at most 16 properties, but got an object with 17 properties instead."
)
with gateway.scenario() as scenario:
identity, handle = _scripted(scenario, "followup-attributes", _openai_error(message, "attributes"))
store: Final = _registered_store(gateway, scenario, identity, handle)
response: Final = _post(gateway, path.format(store=store), body)
assert response.status_code == 400, response.text
assert message in response.text, response.text
assert _outbound(gateway, identity).get("attributes") == _SEVENTEEN_ATTRIBUTES
@pytest.mark.parametrize(
("path", "body", "missing"),
(
("/v1/images/generations", {"model": "followup-image"}, "prompt"),
("/v1/completions", {"model": "followup-text"}, "prompt"),
("/v1/audio/speech", {"model": "followup-speech", "voice": "alloy"}, "input"),
),
ids=("image-prompt", "text-completion-prompt", "speech-input"),
)
def test_router_default_for_positional_param_returns_400(
router_default_proxy: Gateway, path: str, body: dict[str, JsonValue], missing: str
) -> None:
response: Final = _post(router_default_proxy, path, body)
error: Final = _invalid_request(response)
assert error.get("param") == missing, response.text
_assert_no_call_to_deployment(router_default_proxy, str(body["model"]))
def test_router_default_for_model_outside_router_returns_400(router_default_proxy: Gateway) -> None:
response: Final = _post(router_default_proxy, "/v1/moderations", {"model": "omni-moderation-2024-09-26"})
error: Final = _invalid_request(response)
assert error.get("param") == "input", response.text
@pytest.mark.parametrize(
("body", "expected_max_tokens"),
(
({"messages": [{"role": "user", "content": "router default"}]}, 32),
({"messages": [{"role": "user", "content": "explicit"}], "max_tokens": 8}, 8),
),
ids=("router-default", "explicit"),
)
def test_null_user_config_is_treated_as_absent(
router_default_proxy: Gateway, body: dict[str, JsonValue], expected_max_tokens: int
) -> None:
observations: Final = _Observations(router_default_proxy.upstream_url)
observations.read()
before: Final = len(observations.items)
response: Final = _post(
router_default_proxy, "/v1/messages", {"model": "followup-message", "user_config": None, **body}
)
assert response.status_code == 200, response.text
assert JSON_OBJECT.validate_python(response.json()).get("type") == "message", response.text
eventually(
observations.read,
lambda items: any("followup-message-" in str(item.get("path")) for item in items[before:]),
seconds=20,
)
outbound: Final = next(
object_value(item["body"])
for item in observations.items[before:]
if "followup-message-" in str(item.get("path"))
)
assert outbound.get("max_tokens") == expected_max_tokens