mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
11ab5f83a1
commit
2f9ff07e6c
1 changed files with 18 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue