litellm/tests/integration/compatibility/test_status_code_followups_chaos.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

283 lines
11 KiB
Python

"""The provider goes down in the middle of a concurrent burst across /v1/chat/completions, /v1/messages and
/v1/responses, streaming and not, on a two-worker proxy: every failed call reaches the caller as an error status,
another key on another deployment keeps working, and once the provider is back every call serves and lands in the
spend log exactly once."""
from __future__ import annotations
import json
import os
import re
import signal
import uuid
from collections.abc import Iterator, Mapping
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from hashlib import sha256
from pathlib import Path
from typing import Final
import httpx
import pytest
from integration._support.client import Gateway, eventually, gateway_from_environment
from integration._support.database import read_rows
from integration._support.process import UpstreamSlot, owned_proxy_process, owned_upstream
from integration._support.upstream import register_scenario
from integration.compatibility._status_code_audit import (
CHAT,
CHAT_FRAMES,
ENDPOINT_BODIES,
ENDPOINT_PATHS,
MESSAGE,
MESSAGE_FRAMES,
RESPONSE,
RESPONSES_FRAMES,
Upstream,
json_response,
sse_response,
stream_finished,
)
from pydantic import JsonValue
from tests.integration.cost_calculation.cost_tracking_case import StoredResponse
pytestmark: Final = pytest.mark.timeout(480)
_STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]")
_MARKER_HEADER: Final = "x-litellm-spend-logs-metadata"
_KINDS: Final[dict[str, tuple[str, bool, str, str, StoredResponse]]] = {
"chat": ("chat", False, "chaos-chat", "openai/gpt-4o-mini", json_response(CHAT)),
"chatstream": ("chat", True, "chaos-chat-stream", "openai/gpt-4o-mini", sse_response(CHAT_FRAMES)),
"responses": ("responses", False, "chaos-responses", "openai/gpt-4o-mini", json_response(RESPONSE)),
"responsesstream": (
"responses",
True,
"chaos-responses-stream",
"openai/gpt-4o-mini",
sse_response(RESPONSES_FRAMES),
),
"messages": ("messages", False, "chaos-message", "anthropic/claude-haiku-4-5", json_response(MESSAGE)),
"messagesstream": (
"messages",
True,
"chaos-message-stream",
"anthropic/claude-haiku-4-5",
sse_response(MESSAGE_FRAMES),
),
}
_BURST: Final = 30
@dataclass(frozen=True, slots=True)
class _Chaos:
gateway: Gateway
log: Path
slot: UpstreamSlot
identities: Mapping[str, str]
stable_identity: str
burst_key: str
other_key: str
@dataclass(frozen=True, slots=True)
class _Outcome:
marker: str
kind: str
status: int | None
text: str
finished: bool = False
def _register_on_slot(slot: UpstreamSlot, identities: Mapping[str, str]) -> None:
for kind, identity in identities.items():
register_scenario(identity, _KINDS[kind][4], control_url=slot.url)
@pytest.fixture(scope="module")
def chaos(tmp_path_factory: pytest.TempPathFactory) -> Iterator[_Chaos]:
directory: Final = tmp_path_factory.mktemp("audit-chaos")
with (
owned_upstream(directory) as slot,
gateway_from_environment() as gateway,
gateway.scenario() as scenario,
):
identities: Final = {kind: f"chaos-{kind}-{uuid.uuid4().hex}" for kind in _KINDS}
_register_on_slot(slot, identities)
stable_identity: Final = f"chaos-stable-{uuid.uuid4().hex}"
stable_handle: Final = register_scenario(stable_identity, json_response(CHAT))
model_list: Final[list[JsonValue]] = [
*(
{
"model_name": _KINDS[kind][2],
"litellm_params": {
"model": _KINDS[kind][3],
"api_base": f"{slot.url}/{identity}",
"api_key": identity,
},
}
for kind, identity in identities.items()
),
{
"model_name": "chaos-stable",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_base": stable_handle.api_base(),
"api_key": stable_identity,
},
},
]
config: Final = directory / "chaos.yaml"
config.write_text(
json.dumps({"model_list": model_list, "router_settings": {"num_retries": 0, "disable_cooldowns": True}}),
encoding="utf-8",
)
burst_key: Final = scenario.key(key_alias=f"chaos-burst-{uuid.uuid4().hex}")
other_key: Final = scenario.key(key_alias=f"chaos-other-{uuid.uuid4().hex}", models=["chaos-stable"])
with owned_proxy_process(gateway, directory, {}, config=config, workers=2) as owned:
eventually(lambda: _worker_pids(owned.log), lambda pids: len(pids) == 2, seconds=120)
yield _Chaos(
Gateway(owned.gateway.client, owned.gateway.key, gateway.upstream_url),
owned.log,
slot,
identities,
stable_identity,
burst_key,
other_key,
)
def _worker_pids(log: Path) -> tuple[int, ...]:
return tuple(int(found[1]) for found in _STARTED_WORKER.finditer(log.read_text()))
def _call(chaos: _Chaos, kind: str, marker: str, *, key: str | None = None) -> _Outcome:
endpoint, stream, model_name, _target, _response = _KINDS[kind]
body: Final[dict[str, JsonValue]] = {
**ENDPOINT_BODIES[endpoint],
**({"stream": True} if stream else {}),
"model": model_name,
**({"input": marker} if endpoint == "responses" else {"messages": [{"role": "user", "content": marker}]}),
}
headers: Final = {
"Authorization": f"Bearer {chaos.burst_key if key is None else key}",
_MARKER_HEADER: json.dumps({"marker": marker}),
}
try:
with httpx.Client(base_url=str(chaos.gateway.client.base_url), trust_env=False, timeout=60) as client:
with client.stream("POST", ENDPOINT_PATHS[endpoint], json=body, headers=headers) as response:
lines: Final = tuple(response.iter_lines())
status: Final = response.status_code
except httpx.TransportError as error:
return _Outcome(marker, kind, None, repr(error))
return _Outcome(marker, kind, status, "\n".join(lines), not stream or stream_finished(endpoint, lines))
def _burst(chaos: _Chaos, phase: str) -> tuple[_Outcome, ...]:
kinds: Final = tuple(_KINDS)
plan: Final = tuple(
(kinds[index % len(kinds)], f"{phase}-{kinds[index % len(kinds)]}-{index}-{uuid.uuid4().hex}")
for index in range(_BURST)
)
with ThreadPoolExecutor(max_workers=12) as pool:
return tuple(pool.map(lambda item: _call(chaos, item[0], item[1]), plan))
def _upstream_hits(slot: UpstreamSlot, markers: frozenset[str]) -> Mapping[str, int]:
upstream: Final = Upstream(slot.url)
upstream.drain()
bodies: Final = tuple(json.dumps(item.get("body")) for item in upstream.items)
return {marker: sum(marker in body for body in bodies) for marker in markers}
def _spend_markers(key: str, markers: frozenset[str]) -> Mapping[str, tuple[str, ...]]:
rows: Final = read_rows(
"SELECT status, metadata->'spend_logs_metadata'->>'marker' AS marker "
'FROM "LiteLLM_SpendLogs" WHERE api_key = %s',
(sha256(key.encode()).hexdigest(),),
)
return {
marker: found
for marker in markers
if (found := tuple(str(row["status"]) for row in rows if str(row["marker"]) == marker))
}
def _assert_served(outcomes: tuple[_Outcome, ...]) -> None:
assert [(outcome.kind, outcome.status, outcome.finished) for outcome in outcomes] == [
(outcome.kind, 200, True) for outcome in outcomes
], [(outcome.kind, outcome.status, outcome.text[:300]) for outcome in outcomes if outcome.status != 200]
def _stable_call(chaos: _Chaos) -> httpx.Response:
return chaos.gateway.client.post(
"/v1/chat/completions",
json={"model": "chaos-stable", "messages": [{"role": "user", "content": "other key"}]},
headers={"Authorization": f"Bearer {chaos.other_key}"},
timeout=30,
)
def _settled(chaos: _Chaos) -> bool:
return all(_call(chaos, kind, f"settle-{kind}-{uuid.uuid4().hex}").status == 200 for kind in _KINDS)
def _stop_mid_burst(chaos: _Chaos) -> tuple[_Outcome, ...]:
with ThreadPoolExecutor(max_workers=1) as stopper:
stopping: Final = stopper.submit(chaos.slot.stop)
during: Final = _burst(chaos, "during")
stopping.result(timeout=120)
return during
def _restart(chaos: _Chaos) -> None:
chaos.slot.start()
_register_on_slot(chaos.slot, chaos.identities)
eventually(lambda: _settled(chaos), lambda ok: ok, seconds=90)
def test_provider_outage_mid_burst_errors_reach_callers_and_recovery_serves_once(chaos: _Chaos) -> None:
before: Final = _burst(chaos, "before")
_assert_served(before)
during: Final = _stop_mid_burst(chaos)
outage: Final = _burst(chaos, "outage")
for outcome in outage:
assert outcome.status is not None and outcome.status >= 500, (outcome.kind, outcome.status, outcome.text)
assert '"error"' in outcome.text, (outcome.kind, outcome.text)
for outcome in during:
assert (outcome.status == 200 and outcome.finished) or (
outcome.status is not None and outcome.status >= 500 and '"error"' in outcome.text
), (outcome.kind, outcome.status, outcome.text[:300])
assert chaos.gateway.request("GET", "/health/liveliness").status_code == 200
stable: Final = _stable_call(chaos)
assert stable.status_code == 200, stable.text
_restart(chaos)
after: Final = _burst(chaos, "after")
_assert_served(after)
after_markers: Final = frozenset(outcome.marker for outcome in after)
assert _upstream_hits(chaos.slot, after_markers) == dict.fromkeys(after_markers, 1)
served: Final = frozenset(outcome.marker for outcome in (*before, *after))
landed: Final = eventually(
lambda: _spend_markers(chaos.burst_key, served), lambda found: frozenset(found) == served, seconds=120
)
assert landed == dict.fromkeys(served, ("success",)), landed
assert len(_worker_pids(chaos.log)) >= 2
def _tolerant_status(chaos: _Chaos, kind: str) -> int | None:
return _call(chaos, kind, f"kill-probe-{kind}-{uuid.uuid4().hex}").status
def test_killed_worker_leaves_the_sibling_serving_and_the_respawn_serving(chaos: _Chaos) -> None:
workers: Final = _worker_pids(chaos.log)
assert len(workers) >= 2, workers
os.kill(workers[-1], signal.SIGKILL)
eventually(lambda: _tolerant_status(chaos, "chat"), lambda status: status == 200, seconds=60)
_assert_served(_burst(chaos, "killed"))
stable: Final = _stable_call(chaos)
assert stable.status_code == 200, stable.text
eventually(lambda: _worker_pids(chaos.log), lambda pids: len(pids) == len(workers) + 1, seconds=180)
eventually(lambda: _settled(chaos), lambda ok: ok, seconds=120)
_assert_served(_burst(chaos, "respawned"))