From 6e341b79d80d8ab0657af51ff7b13e6a1f360b74 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 29 Aug 2026 16:08:29 -0700 Subject: [PATCH] test(e2e): stop the fallback tests flaking on gpt-5.5's reasoning budget max_tokens=64 caps reasoning plus visible output on gpt-5.5, so the fallback target can legitimately return finish_reason="length" with empty content. litellm-e2e build 90 hit exactly that: the response cost of $0.002005 backs out to 64 completion tokens at gpt-5.5's $3e-05/token, i.e. the whole budget spent reasoning about "say hi" with none left to answer. The fallback itself worked -- 200, served by gpt-5.5-2026-04-23, x-litellm-attempted-fallbacks present -- so the only thing that failed was an assertion about OpenAI's token budgeting rather than about routing. Raise the reliability helper's budget to 512 and accept empty content only when finish_reason is "length". Empty content under any other finish_reason still fails, so the tests keep catching a fallback that returns nothing for a reason we do control. The relaxed assertion lives in the helper both reliability fallback tests share, so test_timeout_routes_to_fallback is covered too; it has the same shape and had not tripped yet. --- tests/e2e/models.py | 1 + tests/e2e/router/reliability_support.py | 12 +++++++++++- .../router/test_reliability_fallbacks_e2e.py | 19 ++++++++++++++----- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 56b6a7a7055..6d9ccad9a24 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -333,6 +333,7 @@ class OutMessage(BaseModel): class ChatChoice(BaseModel): message: OutMessage | None = None + finish_reason: str | None = None class PromptTokensDetails(BaseModel): diff --git a/tests/e2e/router/reliability_support.py b/tests/e2e/router/reliability_support.py index cc1c91c635b..a61e1568e9d 100644 --- a/tests/e2e/router/reliability_support.py +++ b/tests/e2e/router/reliability_support.py @@ -57,7 +57,7 @@ def chat_override( json=ReliabilityChatBody( model=model, messages=[ChatMessage(role="user", content=content)], - max_tokens=64, + max_tokens=512, stream=stream, router_settings_override=override, cache=cache, @@ -77,3 +77,13 @@ def content_of(resp: StreamingResponse) -> str | None: return None message = parsed.choices[0].message return message.content if message is not None else None + + +def finish_reason_of(resp: StreamingResponse) -> str | None: + try: + parsed = ChatResponse.model_validate_json(resp.body) + except ValidationError: + return None + if not parsed.choices: + return None + return parsed.choices[0].finish_reason diff --git a/tests/e2e/router/test_reliability_fallbacks_e2e.py b/tests/e2e/router/test_reliability_fallbacks_e2e.py index fe2d924ae2c..d45ca7a031c 100644 --- a/tests/e2e/router/test_reliability_fallbacks_e2e.py +++ b/tests/e2e/router/test_reliability_fallbacks_e2e.py @@ -3,9 +3,11 @@ healthy one. Each test registers a primary deployment that fails (an unreachable base URL, or a 1ms deadline) and calls it with a `router_settings_override` mapping it to the -real `gpt-5.5`. The proof the fallback fired is twofold: the response is a real -completion from `gpt-5.5` (a non-empty content string), and the proxy reports at -least one attempted fallback in the x-litellm-attempted-fallbacks header. +real `gpt-5.5`. The proof the fallback fired is twofold: the response is a +completion from `gpt-5.5`, and the proxy reports at least one attempted fallback +in the x-litellm-attempted-fallbacks header. Empty content is accepted only for +`finish_reason == "length"`, since gpt-5.5 counts reasoning against max_tokens +and can consume the whole budget before emitting any text. """ from __future__ import annotations @@ -22,6 +24,7 @@ from reliability_support import ( content_of, create_bad_base_deployment, create_timeout_deployment, + finish_reason_of, ) pytestmark = pytest.mark.e2e @@ -30,10 +33,16 @@ pytestmark = pytest.mark.e2e def _assert_served_by_fallback(resp: StreamingResponse) -> None: assert resp.status_code == 200, f"expected 200 after fallback, got {resp.status_code}: {resp.body[:300]}" content = content_of(resp) - assert isinstance(content, str) and content, ( - f"the gpt-5.5 fallback should have returned a real completion, got content {content!r} " + finish_reason = finish_reason_of(resp) + assert isinstance(content, str), ( + f"the gpt-5.5 fallback should have returned a completion body, got content {content!r} " f"(body={resp.body[:300]})" ) + assert content or finish_reason == "length", ( + f"the gpt-5.5 fallback returned empty content with finish_reason={finish_reason!r}; only " + f"finish_reason='length' may be empty, since gpt-5.5 can spend the whole max_tokens " + f"budget on reasoning (body={resp.body[:300]})" + ) attempted = resp.headers.get("x-litellm-attempted-fallbacks") assert attempted is not None, "response is missing the x-litellm-attempted-fallbacks header" assert int(attempted) >= 1, f"x-litellm-attempted-fallbacks should be >= 1, got {attempted!r}"