Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions headroom/proxy/memory_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1056,17 +1056,30 @@ def _extract_tool_calls(
if provider == "anthropic":
content = response.get("content", [])
if isinstance(content, list):
return [block for block in content if block.get("type") == "tool_use"]
return [
block
for block in content
if isinstance(block, dict) and block.get("type") == "tool_use"
]
return []

elif provider == "openai":
# Chat Completions format: choices[0].message.tool_calls
choices = response.get("choices", [])
if choices:
message = choices[0].get("message", {})
tc_list = list(message.get("tool_calls", []) or [])
if tc_list:
return tc_list
# Chat Completions format: choices[0].message.tool_calls. Guard the
# first choice for dict-ness the way the sibling
# CCRResponseHandler._extract_assistant_message already does: an
# OpenAI-compatible gateway can send `choices: [null]` on a
# content-filtered / usage-only response, and this runs from
# has_memory_tool_calls, which is called outside the try that wraps
# the rest of memory handling — so an unguarded `choices[0].get` here
# would surface AttributeError to the request handler.
choices = response.get("choices")
first = choices[0] if isinstance(choices, list) and choices else None
if isinstance(first, dict):
message = first.get("message")
if isinstance(message, dict):
tc_list = list(message.get("tool_calls", []) or [])
if tc_list:
return tc_list

# Responses API format: output[] with type=function_call
output = response.get("output", [])
Expand Down
29 changes: 29 additions & 0 deletions tests/test_memory_handler_null_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,32 @@ def test_has_memory_tool_calls_survives_null_function():
def test_has_memory_tool_calls_all_null_functions_is_false():
response = _openai_response([{"id": "c1", "type": "function", "function": None}])
assert _handler.has_memory_tool_calls(response, "openai") is False


def test_extract_tool_calls_survives_null_choice_element():
# An OpenAI-compatible gateway can send `choices: [null]` on a
# content-filtered / usage-only response. `choices[0].get(...)` on the null
# element would raise AttributeError; detection must treat it as no tool
# calls, matching the sibling guard in CCRResponseHandler.
for choices in ([None], ["not-a-dict"], [{"message": None}]):
response = {"choices": choices}
assert _handler._extract_tool_calls(response, "openai") == []
assert _handler.has_memory_tool_calls(response, "openai") is False


def test_extract_tool_calls_falls_through_to_responses_output():
# A null first choice must not shadow a Responses-API `output[]` payload in
# the same response; the function_call there is still detected.
response = {
"choices": [None],
"output": [{"type": "function_call", "name": "memory_save", "arguments": "{}"}],
}
assert _handler.has_memory_tool_calls(response, "openai") is True


def test_extract_tool_calls_survives_non_dict_anthropic_block():
# A reconstructed Anthropic response may carry a null content block; the
# `block.get("type")` filter must skip it instead of raising.
response = {"content": [None, {"type": "tool_use", "id": "t1", "name": "memory_save"}]}
calls = _handler._extract_tool_calls(response, "anthropic")
assert [c["id"] for c in calls] == ["t1"]
Loading