diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index 067bdcb23..a2fd6bac3 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -764,19 +764,44 @@ class OpenAIHandlerMixin: item["output"] = replacement headroom_retrieve_call_ids: set[str] = set() + # Map each Responses tool call to its name so that outputs belonging to + # excluded tools (HEADROOM_EXCLUDE_TOOLS) can be protected from + # compression. The chat/Anthropic paths get this via + # ContentRouter._build_tool_name_map; the Responses payload carries the + # name on the `function_call` item and the originating call_id on the + # matching `function_call_output`, so we correlate them here. + function_name_by_call_id: dict[str, str] = {} for item in items: if not isinstance(item, dict): continue if item.get("type") != "function_call": continue name = item.get("name") + call_id = item.get("call_id") + if isinstance(name, str) and isinstance(call_id, str) and call_id: + function_name_by_call_id[call_id] = name if isinstance(name, str) and ( name == "headroom_retrieve" or name.endswith("__headroom_retrieve") ): - call_id = item.get("call_id") if isinstance(call_id, str) and call_id: headroom_retrieve_call_ids.add(call_id) + # Resolve the effective exclude set once (None -> built-in defaults), + # mirroring ContentRouter's policy. exclude_tools already contains both + # original and lowercased name variants (see _parse_exclude_tools), but + # we also test the lowercased name defensively for case-insensitivity. + from headroom.config import DEFAULT_EXCLUDE_TOOLS + + router_exclude_tools = getattr(router.config, "exclude_tools", None) + effective_exclude_tools = ( + router_exclude_tools if router_exclude_tools is not None else DEFAULT_EXCLUDE_TOOLS + ) + excluded_call_ids: set[str] = { + call_id + for call_id, fn_name in function_name_by_call_id.items() + if fn_name in effective_exclude_tools or fn_name.lower() in effective_exclude_tools + } + timing_sink: dict[str, float] = timing if timing is not None else {} def _add_timing(name: str, started_at: float) -> None: @@ -816,6 +841,20 @@ class OpenAIHandlerMixin: } ) continue + if isinstance(call_id, str) and call_id in excluded_call_ids: + if debug_enabled: + extraction_debug.append( + { + "index": idx, + "eligible": False, + "reason": "exclude_tools_protected", + "item_type": item_type, + "call_id": call_id, + "tool_name": function_name_by_call_id.get(call_id), + "item": item, + } + ) + continue slot = _slot_text(item) if slot is not None: text, slot_ref = slot diff --git a/tests/test_openai_responses_compression_units.py b/tests/test_openai_responses_compression_units.py index aaa8b5212..be323c47e 100644 --- a/tests/test_openai_responses_compression_units.py +++ b/tests/test_openai_responses_compression_units.py @@ -399,6 +399,157 @@ def test_openai_responses_adapter_preserves_headroom_retrieve_outputs(): assert strategy_chain == [] +def test_openai_responses_adapter_preserves_excluded_tool_outputs(): + """Regression for #940: outputs for HEADROOM_EXCLUDE_TOOLS tools stay raw. + + The Responses path carries the tool name on the ``function_call`` item and + the originating ``call_id`` on the matching ``function_call_output``; the + adapter must correlate them and skip compression for excluded tools. + """ + router = ContentRouter() + router.config.exclude_tools = {"serena.find_symbol", "find_symbol"} + + def compress(self, content: str, **_kwargs): + return RouterCompressionResult( + compressed="should not be used", + original=content, + strategy_used=CompressionStrategy.KOMPRESS, + ) + + router.compress = MethodType(compress, router) + handler = _handler_with_router(router) + output = " ".join(f"sym{i}" for i in range(180)) + payload = { + "model": "gpt-5", + "input": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "serena.find_symbol", + "arguments": "{}", + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": output, + }, + ], + } + + new_payload, modified, saved, transforms, units_by_category, strategy_chain, _attempted = ( + handler._compress_openai_responses_live_text_units_with_router( + payload, + model="gpt-5", + request_id="req_test", + ) + ) + + assert modified is False + assert saved == 0 + assert transforms == [] + assert new_payload == payload + assert units_by_category == {} + assert strategy_chain == [] + + +def test_openai_responses_adapter_excludes_tool_case_insensitively_with_debug(monkeypatch): + """Excluded match is case-insensitive, and the debug path stays exercised. + + The configured name is lowercase only; the call advertises a mixed-case + name, so the protection must hit via the lowercased fallback. Debug logging + is enabled so the protected-extraction debug record is also covered. + """ + monkeypatch.setattr(openai_handler, "_log_codex_compression_debug", lambda *_a, **_k: None) + router = ContentRouter() + router.config.exclude_tools = {"serena.find_symbol"} + + def compress(self, content: str, **_kwargs): + return RouterCompressionResult( + compressed="should not be used", + original=content, + strategy_used=CompressionStrategy.KOMPRESS, + ) + + router.compress = MethodType(compress, router) + handler = _handler_with_router(router) + output = " ".join(f"sym{i}" for i in range(180)) + payload = { + "model": "gpt-5", + "input": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "Serena.Find_Symbol", + "arguments": "{}", + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": output, + }, + ], + } + + new_payload, modified, saved, *_ = ( + handler._compress_openai_responses_live_text_units_with_router( + payload, + model="gpt-5", + request_id="req_test", + ) + ) + + assert modified is False + assert saved == 0 + assert new_payload == payload + + +def test_openai_responses_adapter_compresses_non_excluded_tool_outputs(): + """Only excluded tools are protected; other tool outputs still compress.""" + router = ContentRouter() + router.config.exclude_tools = {"serena.find_symbol"} + + def compress(self, content: str, **_kwargs): + return RouterCompressionResult( + compressed="compressed tool output", + original=content, + strategy_used=CompressionStrategy.KOMPRESS, + ) + + router.compress = MethodType(compress, router) + handler = _handler_with_router(router) + output = " ".join(f"word{i}" for i in range(180)) + payload = { + "model": "gpt-5", + "input": [ + { + "type": "function_call", + "call_id": "call_1", + "name": "some.other_tool", + "arguments": "{}", + }, + { + "type": "function_call_output", + "call_id": "call_1", + "output": output, + }, + ], + } + + new_payload, modified, saved, transforms, units_by_category, strategy_chain, _attempted = ( + handler._compress_openai_responses_live_text_units_with_router( + payload, + model="gpt-5", + request_id="req_test", + ) + ) + + assert modified is True + assert saved > 0 + assert new_payload["input"][1]["output"] == "compressed tool output" + assert "router:openai:responses:function_call_output:kompress" in transforms + assert units_by_category == {"applied": 1} + + def test_openai_responses_adapter_keeps_small_and_opaque_items(): router = ContentRouter()