mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-08 08:08:37 +00:00
* 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>
283 lines
11 KiB
Python
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"))
|