fix(content_router): guard against empty compression output causing Anthropic 400 (#771)

## Problem

When the compression pipeline returns empty/whitespace content for a
**non-empty** user message, the proxied request reaches Anthropic with
empty message content and is rejected with:

```
HTTP 400 — messages.N: user messages must have non-empty content
```

This kills the whole request (not just compression), so a single bad
compression output takes down the turn.

## Root cause

`ContentRouter.compress()` returns whatever the selected transform
produced. Nothing asserts the invariant that **compression must never
blank out non-empty input**. Any transform path that yields
`""`/whitespace from non-empty input therefore surfaces as a 400 at the
API boundary.

In production this was triggered by a `pyo3` `unsendable` panic in the
tree-sitter parser path (cross-thread parser reuse) that produced empty
content. That specific panic is already addressed on `main` by the
thread-local parser fix (38aefc1d). **This PR is the complementary,
transform-agnostic safety net** — it catches *any* future path that
could blank out content, independent of the tree-sitter panic.

## Fix

A final guard in `compress()`: if input is non-empty but the compressed
result is empty/whitespace, fall back to the original content
(passthrough) and log a warning.

```python
if (
    content
    and content.strip()
    and (result.compressed is None or not str(result.compressed).strip())
):
    logger.warning(
        "content_router: compression produced EMPTY output from non-empty "
        "input (%d chars, strategy=%s); falling back to original to avoid 400.",
        len(content),
        getattr(result.strategy_used, "value", result.strategy_used),
    )
    result.compressed = content
```

- 18 lines, single file (`headroom/transforms/content_router.py`).
- No behavior change on the normal path (only activates when output
would otherwise be empty).
- `py_compile` clean.

## Testing

Verified against a token-mode proxy under cross-thread
`ThreadPoolExecutor` load: previously-failing requests (empty-content
400) now pass through with original content preserved; no 400s observed.
Normal compression output is unaffected.

Co-authored-by: yoonhwan <yoonhwan.ko@byourz.com>
This commit is contained in:
yoonhwan 2026-06-09 14:28:32 +09:00 committed by GitHub
parent 11ab5f83a1
commit 2f9ff07e6c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -947,6 +947,24 @@ class ContentRouter(Transform):
else:
result = self._compress_pure(content, strategy, context, question, bias=bias)
# Empty-output guard: compression must NEVER blank out non-empty input.
# An empty user-message content makes Anthropic reject the whole request
# with 400 ("messages.N: user messages must have non-empty content").
# If any transform yields empty/whitespace from non-empty input, fall
# back to the original content (passthrough) instead of emitting empty.
if (
content
and content.strip()
and (result.compressed is None or not str(result.compressed).strip())
):
logger.warning(
"content_router: compression produced EMPTY output from non-empty "
"input (%d chars, strategy=%s); falling back to original to avoid 400.",
len(content),
getattr(result.strategy_used, "value", result.strategy_used),
)
result.compressed = content
# One observer call per routing decision; the observer is the
# forcing function for catching strategy-level regressions.
# Empty routing_log (passthrough fast path) → no calls.