From d0c1f5b8ad68c7a44ed3aaa0fe40e3a656950123 Mon Sep 17 00:00:00 2001 From: Tejas Chopra Date: Tue, 11 Aug 2026 16:18:57 -0700 Subject: [PATCH] fix(ccr): avoid injecting tool on chat streaming Avoid unsupported CCR tool injection on OpenAI chat streaming (#2924). --- headroom/proxy/handlers/openai.py | 33 +++++++++++++++++-- .../test_openai_chat_ccr_injection.py | 16 +++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 tests/test_proxy/test_openai_chat_ccr_injection.py diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index 23751c65e..7eb55fa9b 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -979,6 +979,18 @@ def _should_buffer_openai_responses_stream_ccr( ) +def _should_inject_openai_chat_ccr_tool(*, ccr_inject_tool: bool, stream: bool) -> bool: + """Return whether chat-completions can redeem an injected CCR tool. + + The chat streaming path forwards SSE events immediately and deliberately + does not run the response continuation loop. Injecting ``headroom_retrieve`` + there makes OpenAI-compatible clients attempt an unknown tool call. The + non-streaming path can intercept and resolve it; Responses streaming has a + separate buffered-CCR path and is unaffected by this predicate. + """ + return bool(ccr_inject_tool and not stream) + + def _responses_input_to_items(input_data: Any) -> list[dict[str, Any]]: """Normalize a Responses ``input`` field into an item list for CCR continuation. @@ -3410,19 +3422,34 @@ class OpenAIHandlerMixin: # anchored on the previous turn's tool list never busts. tools = body.get("tools") _original_tools = tools # Preserve for diagnostic / future retry + can_inject_ccr_tool = _should_inject_openai_chat_ccr_tool( + ccr_inject_tool=self.config.ccr_inject_tool, + stream=stream, + ) if ( self.config.ccr_inject_tool or self.config.ccr_inject_system_instructions ) and not _bypass: + if self.config.ccr_inject_tool and stream: + logger.info( + f"[{request_id}] CCR: skipping retrieval-tool injection for " + "OpenAI chat streaming; this path cannot intercept tool calls" + ) injector = CCRToolInjector( provider="openai", inject_tool=False, # routed through sticky helper below - inject_system_instructions=self.config.ccr_inject_system_instructions, + inject_system_instructions=( + self.config.ccr_inject_system_instructions and not stream + ), ) injector.scan_for_markers(optimized_messages) - if self.config.ccr_inject_system_instructions and injector.has_compressed_content: + if ( + self.config.ccr_inject_system_instructions + and not stream + and injector.has_compressed_content + ): optimized_messages = injector.inject_into_system_message(optimized_messages) - if self.config.ccr_inject_tool: + if can_inject_ccr_tool: from headroom.proxy.helpers import ( apply_session_sticky_ccr_tool, has_new_ccr_markers, diff --git a/tests/test_proxy/test_openai_chat_ccr_injection.py b/tests/test_proxy/test_openai_chat_ccr_injection.py new file mode 100644 index 000000000..030228685 --- /dev/null +++ b/tests/test_proxy/test_openai_chat_ccr_injection.py @@ -0,0 +1,16 @@ +"""Streaming chat CCR injection must not advertise an unredeemable tool.""" + +from headroom.proxy.handlers.openai import _should_inject_openai_chat_ccr_tool + + +def test_streaming_chat_does_not_inject_ccr_tool() -> None: + assert _should_inject_openai_chat_ccr_tool(ccr_inject_tool=True, stream=True) is False + + +def test_non_streaming_chat_still_injects_ccr_tool() -> None: + assert _should_inject_openai_chat_ccr_tool(ccr_inject_tool=True, stream=False) is True + + +def test_disabled_ccr_never_injects_tool() -> None: + assert _should_inject_openai_chat_ccr_tool(ccr_inject_tool=False, stream=False) is False + assert _should_inject_openai_chat_ccr_tool(ccr_inject_tool=False, stream=True) is False