From a65f954013bd26798dccc2d12bc34966eefbd11b Mon Sep 17 00:00:00 2001 From: andrewwhitecdw Date: Tue, 11 Aug 2026 10:07:45 -0500 Subject: [PATCH 1/2] fix: content-filter responses raise instead of normalizing ## Summary `_response()` raised `ValueError("LiteLLM returned no text content")` for any response with empty `content`, including content-filter responses where empty output is expected. This caused legitimate content-filter results to fail instead of being normalized. ## Root cause The guard at the end of `_response()` did not distinguish between a truly empty/invalid response and a `content_filter` finish reason, which by design returns no content. ## Fix Allow empty `content` when the finish reason is `content_filter`; keep the existing raise for all other empty-content cases so unexpected responses still fail fast. ```diff - if not content: - raise ValueError("LiteLLM returned no text content") + if not content and choice.finish_reason != "content_filter": + raise ValueError("LiteLLM returned no text content") ``` ## Testing Added `test_response_content_filter_empty_content` to `examples/experimental/litellm/tests/test_client.py` and verified the full non-e2e suite passes: ``` uv run --project examples/experimental/litellm --python 3.12 \ pytest examples/experimental/litellm/tests/test_client.py -m "not e2e" -v # 34 passed ``` ## Contributor guidelines - DCO sign-off included. - One focused commit per PR. Signed-off-by: andrewwhitecdw --- .../litellm/src/switchyard_litellm/client.py | 2 +- .../experimental/litellm/tests/test_client.py | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/examples/experimental/litellm/src/switchyard_litellm/client.py b/examples/experimental/litellm/src/switchyard_litellm/client.py index dd83b5b0a..277fbe6fa 100644 --- a/examples/experimental/litellm/src/switchyard_litellm/client.py +++ b/examples/experimental/litellm/src/switchyard_litellm/client.py @@ -300,7 +300,7 @@ def _response(response: ModelResponse) -> dict[str, object]: "name": tool_call.function.name, "arguments": arguments, }) - if not content: + if not content and choice.finish_reason != "content_filter": raise ValueError("LiteLLM returned no text content") return { "id": response.id, diff --git a/examples/experimental/litellm/tests/test_client.py b/examples/experimental/litellm/tests/test_client.py index 157b35c05..88328bcd6 100644 --- a/examples/experimental/litellm/tests/test_client.py +++ b/examples/experimental/litellm/tests/test_client.py @@ -582,3 +582,25 @@ async def test_cached_token_count_preserves_explicit_zero() -> None: await client.aclose() assert response["usage"]["cached_input_tokens"] == 0 + + +def test_response_content_filter_empty_content() -> None: + """Empty content with a content_filter finish reason must be normalized.""" + from types import SimpleNamespace + + from switchyard_litellm.client import _response + + response = SimpleNamespace( + id="chatcmpl-test", + model="openai/strong", + choices=[ + SimpleNamespace( + message=SimpleNamespace(content=None, tool_calls=None), + finish_reason="content_filter", + ) + ], + usage=None, + ) + result = _response(response) + assert result["outputs"][0]["content"] == [] + assert result["outputs"][0]["stop_reason"] == "content_filter" From 09ea680e53638d038795b33840b4e1d724015e77 Mon Sep 17 00:00:00 2001 From: andrewwhitecdw Date: Fri, 14 Aug 2026 23:09:19 -0500 Subject: [PATCH 2/2] test: type the content-filter fixture and cover the non-filter error path CodeRabbit review feedback on the content-filter normalization fix: - The regression test constructed the LiteLLM response with `SimpleNamespace`, which mypy strict rejects because `_response()` requires a typed `litellm.ModelResponse`. Construct a real `ModelResponse` with `Choices`/`Message`/`Usage` from `litellm.types.utils` instead, preserving the content_filter response values. - The normalized `outputs` collection is now narrowed with isinstance checks before indexing so the `dict[str, object]` result passes strict mypy checks (previously two new [index] errors). - Added a companion regression test: empty content with any finish reason other than `content_filter` (here `stop`) must still raise `ValueError("LiteLLM returned no text content")`, guarding the non-filter error path the original change intentionally preserves. mypy strict on the two changed files: 11 errors before (base) -> 9 now, all 9 remaining are pre-existing on main (verified via git stash); the two errors introduced by the original test are gone. Ruff check: clean. pytest (non-e2e), from examples/experimental/litellm: PYTHONPATH=src .venv/bin/python -m pytest tests/test_client.py -m "not e2e" -q 35 passed (34 before; the new companion test is the +1) Signed-off-by: andrewwhitecdw --- .../experimental/litellm/tests/test_client.py | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/examples/experimental/litellm/tests/test_client.py b/examples/experimental/litellm/tests/test_client.py index 88328bcd6..0f7e6555a 100644 --- a/examples/experimental/litellm/tests/test_client.py +++ b/examples/experimental/litellm/tests/test_client.py @@ -15,6 +15,7 @@ from litellm.exceptions import ( ContextWindowExceededError as LiteLLMContextWindowExceededError, ) +from litellm.types.utils import Choices, Message, Usage from switchyard_litellm import LiteLLMSyClient from switchyard.libsy import ContextWindowExceededError @@ -586,21 +587,42 @@ async def test_cached_token_count_preserves_explicit_zero() -> None: def test_response_content_filter_empty_content() -> None: """Empty content with a content_filter finish reason must be normalized.""" - from types import SimpleNamespace - from switchyard_litellm.client import _response - response = SimpleNamespace( + response = ModelResponse( id="chatcmpl-test", model="openai/strong", choices=[ - SimpleNamespace( - message=SimpleNamespace(content=None, tool_calls=None), + Choices( + message=Message(content=None, tool_calls=None), finish_reason="content_filter", ) ], - usage=None, + usage=Usage(prompt_tokens=0, completion_tokens=0, total_tokens=0), ) result = _response(response) - assert result["outputs"][0]["content"] == [] - assert result["outputs"][0]["stop_reason"] == "content_filter" + outputs = result["outputs"] + assert isinstance(outputs, list) + output = outputs[0] + assert isinstance(output, dict) + assert output["content"] == [] + assert output["stop_reason"] == "content_filter" + + +def test_response_empty_content_without_content_filter_raises() -> None: + """Empty content with any other finish reason must still raise.""" + from switchyard_litellm.client import _response + + response = ModelResponse( + id="chatcmpl-test", + model="openai/strong", + choices=[ + Choices( + message=Message(content=None, tool_calls=None), + finish_reason="stop", + ) + ], + usage=Usage(prompt_tokens=0, completion_tokens=0, total_tokens=0), + ) + with pytest.raises(ValueError, match="LiteLLM returned no text content"): + _response(response)