From 2f9ff07e6caef0fe32d00ece6266a476eecff5a3 Mon Sep 17 00:00:00 2001 From: yoonhwan Date: Tue, 9 Jun 2026 14:28:32 +0900 Subject: [PATCH] fix(content_router): guard against empty compression output causing Anthropic 400 (#771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- headroom/transforms/content_router.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/headroom/transforms/content_router.py b/headroom/transforms/content_router.py index 6dc6f0203..6225f409a 100644 --- a/headroom/transforms/content_router.py +++ b/headroom/transforms/content_router.py @@ -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.