diff --git a/headroom/proxy/outcome.py b/headroom/proxy/outcome.py index 3a4dbde96..66ec1e6e0 100644 --- a/headroom/proxy/outcome.py +++ b/headroom/proxy/outcome.py @@ -191,6 +191,28 @@ class RequestOutcome: return 0.0 return self.tokens_saved / self.original_tokens * 100.0 + @property + def tokens_inflated(self) -> int: + """Tokens the forwarded request grew by, if it ended up larger. + + ``tokens_saved`` is clamped at zero, so a request that leaves the + proxy *bigger* than it arrived is indistinguishable from one the + proxy simply could not compress: both report ``tok_saved=0``. That + ambiguity hides real regressions — anything that adds to the body + after compression (proactive context expansion, memory injection) + can outweigh the compression it sits on top of and still look like + a neutral turn. + + Report the swallowed amount alongside it so the two cases are + distinguishable. This is diagnostic only: it deliberately does not + feed ``tokens_saved`` or ``attempted_input_tokens``, because + ``attempted_input_tokens = optimized_tokens + tokens_saved`` is a + size, not a signed delta, and because injection paths already book + their own cost through the retrieval-drawback channel — letting a + negative land here too would count it twice. + """ + return max(0, self.optimized_tokens - self.original_tokens) + @classmethod def from_stream( cls, @@ -512,6 +534,7 @@ async def emit_request_outcome(handler: Any, outcome: RequestOutcome) -> None: f"model={outcome.model} msgs={outcome.num_messages} " f"tok_before={outcome.original_tokens} tok_after={outcome.optimized_tokens} " f"tok_saved={outcome.tokens_saved} " + f"tok_inflated={outcome.tokens_inflated} " f"tool_saved={tool_saved} " f"total_saved={total_saved} " f"cache_read={outcome.cache_read_tokens} cache_write={outcome.cache_write_tokens} " diff --git a/tests/test_request_outcome.py b/tests/test_request_outcome.py index 89df90a27..e9368bf3e 100644 --- a/tests/test_request_outcome.py +++ b/tests/test_request_outcome.py @@ -431,6 +431,7 @@ async def test_funnel_emits_perf_log_with_canonical_shape( assert "tok_before=1000" in line assert "tok_after=300" in line assert "tok_saved=700" in line + assert "tok_inflated=0" in line assert "cache_read=200" in line assert "cache_write=100" in line assert "cache_hit_pct=67" in line # 200/(200+100) * 100 = 67 @@ -651,3 +652,44 @@ def test_from_stream_threads_waste_signals_for_openai_via_backend_site() -> None waste_signals={"skipped_units": 3, "applied_units": 7}, ) assert o.waste_signals == {"skipped_units": 3, "applied_units": 7} + + +# ── tokens_inflated: distinguishing "could not compress" from "grew" ── + + +def test_tokens_inflated_is_zero_when_request_shrank() -> None: + """A normally-compressed request reports no inflation.""" + o = _outcome(original_tokens=1000, optimized_tokens=300, tokens_saved=700) + assert o.tokens_inflated == 0 + + +def test_tokens_inflated_is_zero_when_compression_was_a_no_op() -> None: + """Nothing compressible is not the same as growth — both keep tok_saved=0.""" + o = _outcome(original_tokens=1000, optimized_tokens=1000, tokens_saved=0) + assert o.tokens_inflated == 0 + assert o.tokens_saved == 0 + + +def test_tokens_inflated_reports_growth_the_clamp_swallows() -> None: + """A request forwarded larger than it arrived is no longer indistinguishable. + + tokens_saved stays clamped at 0 (its consumers treat it as a size, and + injection paths book their own cost separately), so the grown amount has + to surface as its own number or the regression is invisible. + """ + o = _outcome(original_tokens=55161, optimized_tokens=57845, tokens_saved=0) + assert o.tokens_saved == 0 + assert o.tokens_inflated == 2684 + + +def test_tokens_inflated_does_not_disturb_derived_sizes() -> None: + """attempted_input_tokens and savings_pct keep their unsigned semantics.""" + o = _outcome( + original_tokens=55161, + optimized_tokens=57845, + tokens_saved=0, + attempted_input_tokens=57845, + ) + assert o.attempted_input_tokens == 57845 + assert o.savings_pct == 0.0 + assert o.tokens_inflated == 2684