Skip to content

fix: normalize empty content-filter responses instead of raising - #358

Merged
nachiketb-nvidia merged 2 commits into
NVIDIA-NeMo:mainfrom
andrewwhitecdw:bugfix/client-content-filter-responses-raise-instead
Aug 18, 2026
Merged

fix: normalize empty content-filter responses instead of raising#358
nachiketb-nvidia merged 2 commits into
NVIDIA-NeMo:mainfrom
andrewwhitecdw:bugfix/client-content-filter-responses-raise-instead

Conversation

@andrewwhitecdw

@andrewwhitecdw andrewwhitecdw commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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.

@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
Contributor

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 | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the fix for empty content-filter responses and matches the main change.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

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 thread examples/experimental/litellm/tests/test_client.py Outdated
@andrewwhitecdw andrewwhitecdw changed the title fix: content-filter responses raise instead of normalizing fix: normalize empty content-filter responses instead of raising Aug 15, 2026
@andrewwhitecdw

Copy link
Copy Markdown
Contributor Author

Completed all CodeRabbit review recommendations:

  1. Major inline (typed fixture) — addressed in d2c4535: fixture now builds a typed litellm.ModelResponse using Choices/Message/Usage from litellm.types.utils; outputs is narrowed with isinstance before indexing. The two strict-mypy [index] errors this PR introduced are gone (base: 11 errors → now 9; the remaining 9 are all pre-existing on main, verified via git stash).
  2. Nitpick (companion test) — added test_response_empty_content_without_content_filter_raises: finish_reason="stop" + content=None must still raise ValueError("LiteLLM returned no text content"). Existing content_filter test unchanged in behavior.
  3. Title warning — PR retitled to fix: normalize empty content-filter responses instead of raising.

Validation: pytest tests/test_client.py -m "not e2e" → 35 passed; ruff check clean.

@nachiketb-nvidia nachiketb-nvidia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good, needs a rebase pls! Thank you!

## 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>
…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 <andrewwhitecdw@users.noreply.github.com>
@nachiketb-nvidia
nachiketb-nvidia force-pushed the bugfix/client-content-filter-responses-raise-instead branch from d2c4535 to 09ea680 Compare August 17, 2026 23:25
@nachiketb-nvidia
nachiketb-nvidia enabled auto-merge (squash) August 18, 2026 00:15
@nachiketb-nvidia
nachiketb-nvidia merged commit fa006f2 into NVIDIA-NeMo:main Aug 18, 2026
16 checks passed
michaelneale added a commit to michaelneale/Switchyard that referenced this pull request Aug 18, 2026
* origin/main: (31 commits)
  feat(server): add Dockerfile for switchyard-server container image (NVIDIA-NeMo#421)
  fix: normalize empty content-filter responses instead of raising (NVIDIA-NeMo#358)
  feat(python): unify LLM classifier bindings (NVIDIA-NeMo#465)
  feat(libsy): record task_kind and agent_role on the run span (NVIDIA-NeMo#249)
  fix(translation): accept SSE data fields with no space after the colon (NVIDIA-NeMo#447)
  fix(client): strip api-key and OpenAI org/project headers before forwarding (NVIDIA-NeMo#420)
  fix(llm-client): detect native sglang context-overflow messages (NVIDIA-NeMo#426)
  refactor(protocol): use typed HTTP status codes (NVIDIA-NeMo#457)
  fix(translation): preserve chat reasoning details (NVIDIA-NeMo#415)
  docs(changelog): note packaging extras removal in Unreleased (NVIDIA-NeMo#433)
  docs(changelog): fix broken Metrics Reference link (NVIDIA-NeMo#432)
  feat(libsy-llm-client): Move retry logic from libsy to libsy-llm-client (NVIDIA-NeMo#431)
  fix(server): use normalized session IDs in routing stats (NVIDIA-NeMo#430)
  feat(benchmark): pre-bake hermes agent into dataset images for closed-book runs (NVIDIA-NeMo#350)
  fix: support json_object classifier responses (NVIDIA-NeMo#411)
  fix(metrics): extend LLM latency histogram buckets (NVIDIA-NeMo#385)
  feat(server): forward inbound Anthropic auth (NVIDIA-NeMo#372)
  fix(protocol): normalize nested metadata strings (NVIDIA-NeMo#422)
  Revert "ci: base full CI on changed paths (NVIDIA-NeMo#403)" (NVIDIA-NeMo#414)
  feat: Move `Decision::reasoning` to a log message (NVIDIA-NeMo#413)
  ...

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>

# Conflicts:
#	crates/switchyard-server/src/config.rs
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.

2 participants