Skip to content

fix: content-filter responses raise instead of normalizing - #358

Open
andrewwhitecdw wants to merge 1 commit into
NVIDIA-NeMo:mainfrom
andrewwhitecdw:bugfix/client-content-filter-responses-raise-instead
Open

fix: content-filter responses raise instead of normalizing#358
andrewwhitecdw wants to merge 1 commit into
NVIDIA-NeMo:mainfrom
andrewwhitecdw:bugfix/client-content-filter-responses-raise-instead

Conversation

@andrewwhitecdw

@andrewwhitecdw andrewwhitecdw commented Aug 11, 2026

Copy link
Copy Markdown

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.

-    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 andrewwhitecdw@users.noreply.github.com

Summary by CodeRabbit

  • Bug Fixes

    • Responses blocked by content filtering are now handled correctly when they contain no text.
    • Other unexpected empty responses continue to be reported as errors.
  • Tests

    • Added coverage to verify content-filtered responses preserve their stop reason and return an empty output.

## 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 <andrewwhitecdw@users.noreply.github.com>
@andrewwhitecdw
andrewwhitecdw requested a review from a team as a code owner August 11, 2026 15:25
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The LiteLLM response parser now accepts empty content when the finish reason is content_filter. A regression test verifies the normalized empty output and preserved stop reason.

Changes

LiteLLM content filter handling

Layer / File(s) Summary
Content-filter response normalization and regression coverage
examples/experimental/litellm/src/switchyard_litellm/client.py, examples/experimental/litellm/tests/test_client.py
_response accepts missing content for content_filter responses. The test verifies empty output content and the preserved stop reason.

Estimated code review effort: 1 (Trivial) | ~5 minutes

Poem

A bunny found a filtered reply,
With empty content drifting by.
The parser kept its stopping sign,
And tests now guard the altered line.
Hop, hop—clean output in a row!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title states that content-filter responses should raise instead of normalize, but the change prevents that error and normalizes empty content. Revise the title to state that content-filter responses with empty content are normalized instead of raising an error.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
examples/experimental/litellm/tests/test_client.py (1)

557-576: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add a regression test for the preserved non-filter error path.

This test covers the allowed content_filter path. It does not verify that empty content with another finish_reason still raises ValueError("LiteLLM returned no text content"). Add a companion test for finish_reason="stop" with content=None.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/experimental/litellm/tests/test_client.py` around lines 557 - 576,
Add a companion test alongside test_response_content_filter_empty_content using
finish_reason="stop" and content=None, then assert that _response raises
ValueError with the exact message "LiteLLM returned no text content". Keep the
existing content_filter test unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/experimental/litellm/tests/test_client.py`:
- Around line 563-576: Update the _response regression test fixture to construct
a typed ModelResponse instead of SimpleNamespace, preserving the content_filter
response values. Narrow or validate the returned outputs collection before
indexing it so the dict[str, object] result passes strict mypy checks.

---

Nitpick comments:
In `@examples/experimental/litellm/tests/test_client.py`:
- Around line 557-576: Add a companion test alongside
test_response_content_filter_empty_content using finish_reason="stop" and
content=None, then assert that _response raises ValueError with the exact
message "LiteLLM returned no text content". Keep the existing content_filter
test unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: ee5a76f1-690f-44cf-81a4-ce4df4182125

📥 Commits

Reviewing files that changed from the base of the PR and between fb3fc30 and 8692e42.

📒 Files selected for processing (2)
  • examples/experimental/litellm/src/switchyard_litellm/client.py
  • examples/experimental/litellm/tests/test_client.py

Comment on lines +563 to +576
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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
uv run mypy --strict \
  examples/experimental/litellm/src/switchyard_litellm/client.py \
  examples/experimental/litellm/tests/test_client.py

Repository: NVIDIA-NeMo/Switchyard

Length of output: 200


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- target test ---'
sed -n '1,120p' examples/experimental/litellm/tests/test_client.py
sed -n '520,610p' examples/experimental/litellm/tests/test_client.py

printf '%s\n' '--- client response definitions and call sites ---'
rg -n -C 5 'def _response|ModelResponse|content_filter|stop_reason|outputs' \
  examples/experimental/litellm/src examples/experimental/litellm/tests

printf '%s\n' '--- project typing configuration ---'
fd -HI 'pyproject.toml|mypy.ini|setup.cfg|tox.ini' . -x sh -c 'echo "--- $1"; sed -n "1,240p" "$1"' sh {}

Repository: NVIDIA-NeMo/Switchyard

Length of output: 34012


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- available type-checking tools ---'
command -v mypy || true
command -v pyright || true
python3 - <<'PY'
import importlib.util
for name in ("mypy", "pyright", "litellm"):
    print(f"{name}: {importlib.util.find_spec(name)}")
PY

printf '%s\n' '--- LiteLLM dependency and lock references ---'
rg -n -C 3 'litellm|1\.92\.0|ModelResponse' \
  examples/experimental/litellm uv.lock requirements*.txt 2>/dev/null || true

printf '%s\n' '--- test module declarations and type-check exclusions ---'
rg -n -C 4 'mypy|strict|ignore_errors|test_client|examples/experimental/litellm' \
  pyproject.toml examples/experimental/litellm

Repository: NVIDIA-NeMo/Switchyard

Length of output: 48555


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

client_path = Path("examples/experimental/litellm/src/switchyard_litellm/client.py")
test_path = Path("examples/experimental/litellm/tests/test_client.py")

client = ast.parse(client_path.read_text())
test = ast.parse(test_path.read_text())

def find_function(tree: ast.AST, name: str) -> ast.FunctionDef:
    for node in ast.walk(tree):
        if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name:
            return node
    raise AssertionError(f"missing function: {name}")

response_fn = find_function(client, "_response")
arg = response_fn.args.args[0]
print(f"_response parameter annotation: {ast.unparse(arg.annotation)}")
print(f"_response return annotation: {ast.unparse(response_fn.returns)}")

test_fn = find_function(test, "test_response_content_filter_empty_content")
for node in ast.walk(test_fn):
    if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
        if node.func.id in {"SimpleNamespace", "_response"}:
            print(f"{node.func.id} call at line {node.lineno}: {ast.unparse(node)}")
    if isinstance(node, ast.Subscript):
        print(f"subscript at line {node.lineno}: {ast.unparse(node)}")

config = ast.parse(Path("pyproject.toml").read_text()) if False else None
print("SimpleNamespace fixture present: " + str(
    any(
        isinstance(node, ast.Call)
        and isinstance(node.func, ast.Name)
        and node.func.id == "SimpleNamespace"
        for node in ast.walk(test_fn)
    )
))
print("result output is indexed twice: " + str(sum(
    isinstance(node, ast.Subscript)
    and isinstance(node.value, ast.Subscript)
    and isinstance(node.value.value, ast.Name)
    and node.value.value.id == "result"
    for node in ast.walk(test_fn)
) == 2))
PY

Repository: NVIDIA-NeMo/Switchyard

Length of output: 1099


Make the regression fixture type-safe under strict mypy.

_response requires ModelResponse, but this test passes a SimpleNamespace. Its dict[str, object] result is also indexed without narrowing. Use a typed ModelResponse fixture and narrow outputs before indexing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/experimental/litellm/tests/test_client.py` around lines 563 - 576,
Update the _response regression test fixture to construct a typed ModelResponse
instead of SimpleNamespace, preserving the content_filter response values.
Narrow or validate the returned outputs collection before indexing it so the
dict[str, object] result passes strict mypy checks.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant