litellm/tests/integration/compatibility/test_status_code_followups_defaults.py
yucheng-berri 784581bace
fix(proxy)!: return provider-matching 4xx for the LIT-8062 status code follow-ups (#45537)
* fix(proxy): return provider-matching 4xx for the LIT-8062 status code follow-ups

- OCR multipart errors are 400 invalid_request_error on the file param
- a missing model is a 400 on chat, responses and messages even with a wildcard deployment
- responses max_output_tokens <= 0 reaches the provider instead of being raised to 16
- daily activity rejects end_date before start_date with a 400
- vector store file attributes are sent in full so the provider enforces its key limit
- router-wide defaults no longer satisfy a param the Router takes positionally, or a model the router does not serve
- a JSON null user_config is treated as absent

* chore: keep the type gates at their merge-base counts

* fix(proxy): keep fixed-target wildcards serving a missing model and close router-default gaps

- a missing model is rejected only when the wildcard would forward it into the target
- moderation only counts router defaults for models get_model_list lists
- user_config defaults and deployment params never satisfy a positionally bound param

* test(integration): pin the fixed-target wildcard path and no provider call on positional-param 400s

* chore: validate pattern-matched deployments instead of reading untyped dicts

* fix(proxy): keep router defaults counting for a2a agent models

* test: drop the signature-walk test and type the new test helpers

* test(integration): audit matrix for the status-code follow-ups

Drives every mode the fix branches on through a real proxy, Postgres, Redis and
the scripted upstream: positional params against router, deployment and
user_config defaults; moderation listed vs unlisted; unknown and a2a models;
every user_config shape on chat, responses and messages, streaming and SDK
clients; forwarding vs fixed-target wildcards for missing, null and empty
models; Responses max_output_tokens edges; OCR parse errors; vector store file
attributes; every daily activity route; and a provider outage mid burst plus a
killed worker on a two-worker proxy. Product gaps found are skipped as BUG.

* style: ruff format the audit test files

* test: name the auth pre-check as the source of the JSON array 500

* test: build the audit helpers without mutation and share one sentinel

* test: build every audit request body as one literal

* fix(proxy): keep router defaults and model-less dispatch for requests a fallback chain serves

* fix(proxy): count request, key and team fallbacks when an unlisted model inherits router defaults

* fix(proxy): skip key and team fallbacks on the client-credential dispatch path

* fix(proxy): keep router defaults when the router's own * fallback serves an unlisted model

* fix(proxy): count client-style fallbacks, forward an empty model, report invalid usage chat dates

- The router-default pre-check now treats client-style request, key and team fallbacks
  ([{"model": ...}]) as serving an unlisted model, as the router does
- The forwarding-wildcard gate only rejects a missing or null model; an empty model
  reaches the provider as before (vLLM serves its loaded model for it)
- The /usage/ai/chat tools hand the model the date-range error instead of an empty
  dataset or a generic fetch error

* test(usage): type the invalid-date tool test

* fix(proxy): keep rejecting an empty model behind a forwarding wildcard

Key and team model checks skip an empty model, so forwarding it let a key
restricted to another model reach a server's default model (vLLM serves
its loaded model for ""). Pin that with a restricted-key integration cell.

* fix(usage): report the query layer's date 400 to the usage chat model

Checking dates before the fetch refused formats the global usage tool's query
accepts (2026/10/01 returned spend on main). Catch the daily activity query's
own 400 instead, so each tool rejects exactly what it rejected before and only
the message changes.

* fix(proxy): restore three model-less and unlisted-model paths main served

An empty model is looked up as sent, so next to a forwarding */* wildcard it
still reaches the fixed * deployment it matches. A null model with a prompt_id
is left to the prompt manager, which may pick the model. A Claude Code
subagent turn on a session-bound auto/complexity router counts as served, so
the router default max_tokens still applies.

* fix(proxy): only skip the model check for a prompt_id when a prompt manager is registered

Without a callback prompt manager nothing can pick the model, so a null model
with a prompt_id behind a forwarding wildcard gets the same 400 as any other
null model.

* fix(proxy): leave a model-less Claude Code subagent turn to its session router

A subagent turn on a session bound to an auto, complexity, adaptive or
quality router is answered by that router whatever model it names, so a
null or missing model behind a forwarding wildcard is no longer rejected.
An empty model stays rejected.

* fix(proxy): skip the wildcard model check only for a bound Claude Code session

Read the session's router binding, as the router does, instead of only
checking that the request could resume one, so a subagent turn without a
model on an unbound session is still rejected before a forwarding
wildcard sends it upstream.

* refactor(proxy): stop predicting routing in the proxy pre-checks

- drop the wildcard missing-model gate: a model-less request behind a forwarding wildcard goes to the router and gets the provider's own status, as on main
- drop the "model the router serves" check: router defaults count for any named model again, except moderation, where the Router merges them only for listed models
- keep the positional-param, moderation and null user_config fixes

* test(ocr): cover the form, size, empty body, invalid JSON and file-document 400s

---------

Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com>
2026-10-13 11:42:39 -07:00

391 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,
drain_rig_upstream,
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)
@pytest.fixture(autouse=True)
def _drained_upstream() -> None:
drain_rig_upstream()
@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"