diff --git a/headroom/ccr/response_handler.py b/headroom/ccr/response_handler.py index f81fe2236..af9241e82 100644 --- a/headroom/ccr/response_handler.py +++ b/headroom/ccr/response_handler.py @@ -379,7 +379,15 @@ class CCRResponseHandler: "content": response.get("content", []), } elif provider == "openai": - message = response.get("choices", [{}])[0].get("message", {}) + # Guard an empty/malformed ``choices`` the same way the Google branch + # below (and ccr/tool_calls.py) already do: ``response.get("choices", + # [{}])`` only falls back when the key is absent, so a present-but- + # empty ``choices: []`` (or ``[null]``) — which OpenAI-compatible + # gateways can send on a content-filtered/usage-only response — made + # ``[0]`` raise IndexError (or ``.get`` raise on a non-dict). + choices = response.get("choices") + first = choices[0] if isinstance(choices, list) and choices else {} + message = first.get("message", {}) if isinstance(first, dict) else {} return { "role": "assistant", "content": message.get("content"), diff --git a/tests/test_ccr_response_handler.py b/tests/test_ccr_response_handler.py index 2e850ac1b..d7278b9ab 100644 --- a/tests/test_ccr_response_handler.py +++ b/tests/test_ccr_response_handler.py @@ -719,3 +719,30 @@ class TestExtractAssistantMessage: assert message["role"] == "assistant" assert message["content"] == "Hello" assert message["tool_calls"] == [{"id": "123"}] + + +class TestExtractAssistantMessageEdgeCases: + """Regression: `_extract_assistant_message` must not crash on an empty or + malformed OpenAI `choices` array (OpenAI-compatible gateways can send + `choices: []` or `[null]` on content-filtered / usage-only responses).""" + + def test_openai_empty_choices_does_not_crash(self): + handler = CCRResponseHandler() + msg = handler._extract_assistant_message({"choices": []}, "openai") + assert msg == {"role": "assistant", "content": None, "tool_calls": None} + + def test_openai_null_first_choice_does_not_crash(self): + handler = CCRResponseHandler() + msg = handler._extract_assistant_message({"choices": [None]}, "openai") + assert msg == {"role": "assistant", "content": None, "tool_calls": None} + + def test_openai_absent_choices_does_not_crash(self): + handler = CCRResponseHandler() + msg = handler._extract_assistant_message({}, "openai") + assert msg == {"role": "assistant", "content": None, "tool_calls": None} + + def test_openai_normal_choice_still_extracts(self): + handler = CCRResponseHandler() + resp = {"choices": [{"message": {"content": "hi", "tool_calls": [{"id": "1"}]}}]} + msg = handler._extract_assistant_message(resp, "openai") + assert msg == {"role": "assistant", "content": "hi", "tool_calls": [{"id": "1"}]}