"""Gemini functionResponse waste-signal visibility (issue #819). Gemini ``functionResponse`` parts are preserved verbatim on the wire (never compressed), but their payloads previously never reached ``parse_messages``, so tool output — where most waste lives — contributed nothing to waste detection on the Gemini paths. The fix is telemetry-only: 1. ``_gemini_contents_to_messages(..., include_function_responses=True)`` additionally emits each functionResponse payload as a ``role="tool"`` message. 2. ``TransformPipeline.apply(..., waste_messages=...)`` parses that richer list for waste signals instead of the transform input. The transform path and token accounting are untouched. """ from __future__ import annotations import json import pytest pytest.importorskip("fastapi") pytest.importorskip("httpx") from headroom import OpenAIProvider, Tokenizer from headroom.config import HeadroomConfig from headroom.parser import parse_messages from headroom.proxy.server import HeadroomProxy, ProxyConfig from headroom.transforms.pipeline import TransformPipeline _provider = OpenAIProvider() @pytest.fixture def proxy() -> HeadroomProxy: config = ProxyConfig( optimize=False, cache_enabled=False, rate_limit_enabled=False, cost_tracking_enabled=False, ) return HeadroomProxy(config) @pytest.fixture def tokenizer() -> Tokenizer: return Tokenizer(_provider.get_token_counter("gpt-4o"), "gpt-4o") def _big_payload(rows: int = 200) -> dict: return { "result": [ {"id": i, "name": f"item_{i}", "status": "ok", "score": i * 3.14} for i in range(rows) ] } def _function_response_content(payload: object, name: str = "fetch_data") -> dict: return { "role": "user", "parts": [{"functionResponse": {"name": name, "response": payload}}], } class TestFunctionResponseConversion: def test_default_conversion_emits_no_tool_messages(self, proxy): contents = [ {"role": "user", "parts": [{"text": "fetch the data"}]}, _function_response_content(_big_payload()), ] messages, preserved = proxy._gemini_contents_to_messages(contents) assert [m["role"] for m in messages] == ["user"] assert preserved == {1} def test_flag_emits_tool_message_for_dict_response(self, proxy): payload = _big_payload() contents = [ {"role": "user", "parts": [{"text": "fetch the data"}]}, _function_response_content(payload), ] messages, preserved = proxy._gemini_contents_to_messages( contents, include_function_responses=True ) assert [m["role"] for m in messages] == ["user", "tool"] assert json.loads(messages[1]["content"]) == payload # preserved_indices semantics unchanged: the entry is still restored # verbatim on the wire regardless of the telemetry conversion. assert preserved == {1} def test_flag_passes_string_response_through(self, proxy): contents = [_function_response_content("plain text tool output")] messages, _ = proxy._gemini_contents_to_messages(contents, include_function_responses=True) assert messages == [{"role": "tool", "content": "plain text tool output"}] def test_flag_skips_missing_response(self, proxy): contents = [ {"role": "user", "parts": [{"functionResponse": {"name": "noop"}}]}, {"role": "user", "parts": [{"functionResponse": {"name": "none", "response": None}}]}, ] messages, _ = proxy._gemini_contents_to_messages(contents, include_function_responses=True) assert messages == [] def test_flag_emits_text_before_tool_within_entry(self, proxy): contents = [ { "role": "user", "parts": [ {"text": "tool said:"}, {"functionResponse": {"name": "f", "response": "output"}}, ], } ] messages, _ = proxy._gemini_contents_to_messages(contents, include_function_responses=True) assert [m["role"] for m in messages] == ["user", "tool"] assert messages[0]["content"] == "tool said:" assert messages[1]["content"] == "output" def test_unserializable_response_falls_back_to_str(self, proxy): circular: dict = {"name": "loop"} circular["self"] = circular text = proxy._function_response_text({"response": circular}) assert "loop" in text class TestFunctionResponseWasteParsing: def test_function_response_payload_reaches_waste_signals(self, proxy, tokenizer): contents = [ {"role": "user", "parts": [{"text": "fetch the data"}]}, _function_response_content(_big_payload()), ] messages, _ = proxy._gemini_contents_to_messages(contents, include_function_responses=True) blocks, _, waste = parse_messages(messages, tokenizer) assert any(b.kind == "tool_result" for b in blocks) assert waste.json_bloat_tokens > 0 def test_repeated_function_response_counts_as_reread(self, proxy, tokenizer): payload = _big_payload() filler = [{"role": "user", "parts": [{"text": f"working on step {i}"}]} for i in range(5)] contents = [ _function_response_content(payload), *filler, _function_response_content(payload), ] messages, _ = proxy._gemini_contents_to_messages(contents, include_function_responses=True) _, _, waste = parse_messages(messages, tokenizer) assert waste.reread_tokens > 0 class TestPipelineWasteMessages: @staticmethod def _base_messages() -> list[dict]: # Compressible enough that the pipeline clears the >100 saved-token # gate that guards waste-signal detection. return [ {"role": "system", "content": "You are a helpful assistant."}, {"role": "user", "content": "Inspect the data set."}, {"role": "tool", "content": json.dumps(_big_payload(400)["result"])}, ] def test_waste_messages_override_waste_source(self, tokenizer): messages = self._base_messages() extra_tool = {"role": "tool", "content": json.dumps(_big_payload(300))} baseline = TransformPipeline(HeadroomConfig()).apply( [dict(m) for m in messages], model="gpt-4o", model_limit=128000 ) enriched = TransformPipeline(HeadroomConfig()).apply( [dict(m) for m in messages], model="gpt-4o", model_limit=128000, waste_messages=[*messages, extra_tool], ) assert baseline.waste_signals is not None assert enriched.waste_signals is not None assert enriched.waste_signals.json_bloat_tokens > baseline.waste_signals.json_bloat_tokens def test_waste_messages_do_not_affect_transform_output(self, tokenizer): messages = self._base_messages() extra_tool = {"role": "tool", "content": json.dumps(_big_payload(300))} baseline = TransformPipeline(HeadroomConfig()).apply( [dict(m) for m in messages], model="gpt-4o", model_limit=128000 ) enriched = TransformPipeline(HeadroomConfig()).apply( [dict(m) for m in messages], model="gpt-4o", model_limit=128000, waste_messages=[*messages, extra_tool], ) assert enriched.messages == baseline.messages assert enriched.tokens_before == baseline.tokens_before assert enriched.tokens_after == baseline.tokens_after def test_no_waste_messages_falls_back_to_transform_input(self, tokenizer): result = TransformPipeline(HeadroomConfig()).apply( [dict(m) for m in self._base_messages()], model="gpt-4o", model_limit=128000 ) assert result.waste_signals is not None assert result.waste_signals.json_bloat_tokens > 0