fix(rate-limit): keep the slot owner consistent for unmapped MCP tools and pass-through bodies

A tool call that failed before its logging data was built, such as a tool
name the gateway does not serve, reached the failure hook with no call id
and still freed its parent's slot. The tool call's own id is now set right
after the call is read.

A pass-through body that carried its own litellm_call_id made that value
the slot's owner, so the failure hook, which sees the proxy's id, skipped
the release and the slot stayed taken for an hour. The limiter no longer
takes an owner from a pass-through body.
This commit is contained in:
mateo-berri 2026-10-28 06:18:41 -07:00
parent d041c0ffe1
commit d259ea474d
5 changed files with 370 additions and 774 deletions

View file

@ -616,7 +616,7 @@ class _PROXY_DynamicRateLimitHandlerV3(CustomLogger):
if "model" not in data:
return None
claim_request_stash_for_data(data)
claim_request_stash_for_data(data, call_type)
model: Final = data["model"]
priority: Final = self._get_priority_from_user_api_key_dict(user_api_key_dict=user_api_key_dict)

View file

@ -785,10 +785,10 @@ def get_or_create_request_stash() -> RequestRateLimiterStash:
return stash
def claim_request_stash_for_data(data: dict) -> RequestRateLimiterStash:
def claim_request_stash_for_data(data: dict, call_type: str | None) -> RequestRateLimiterStash:
stash: Final = get_or_create_request_stash()
owner_call_id: Final = data.get("litellm_call_id")
if isinstance(owner_call_id, str):
if isinstance(owner_call_id, str) and call_type != CallTypes.pass_through.value:
stash.owner_litellm_call_id = owner_call_id
return stash
@ -3984,7 +3984,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
"""
if not isinstance(data, dict):
return
stash: Final = claim_request_stash_for_data(data)
stash: Final = claim_request_stash_for_data(data, call_type)
io_token_descriptors: Final = [
d for d in descriptors if d["key"] in (PROJECT_ITPM_DESCRIPTOR_KEY, PROJECT_OTPM_DESCRIPTOR_KEY)
]
@ -4212,7 +4212,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
"""
verbose_proxy_logger.debug("Inside Rate Limit Pre-Call Hook")
stash: Final = claim_request_stash_for_data(data)
stash: Final = claim_request_stash_for_data(data, call_type)
reuses_request_slot: Final = call_type == CallTypes.call_mcp_tool.value and stash.parallel_slot is not None
if reuses_request_slot:
stash.parallel_slot_reused_at = self._get_current_time()

View file

@ -742,6 +742,10 @@ class LiteLLM_Proxy_MCP_Handler:
tool_arguments,
tool_call_id,
) = LiteLLM_Proxy_MCP_Handler.extract_tool_call_details(tool_call)
tool_logging_call_id = (
f"{litellm_call_id}_{tool_call_id or uuid.uuid4()}" if litellm_call_id else str(uuid.uuid4())
)
logging_request_data = {"litellm_call_id": tool_logging_call_id}
if not tool_name:
verbose_logger.warning("Tool call missing name: %s", tool_call)
@ -772,9 +776,6 @@ class LiteLLM_Proxy_MCP_Handler:
},
}
]
tool_logging_call_id = (
f"{litellm_call_id}_{tool_call_id or uuid.uuid4()}" if litellm_call_id else str(uuid.uuid4())
)
logging_metadata: dict[str, object] = {
"tool_call_id": tool_call_id,
"tool_name": sanitized_tool_name,

File diff suppressed because it is too large Load diff

View file

@ -419,6 +419,25 @@ async def test_execute_tool_calls_logs_failure_via_post_call_failure_hook(monkey
assert post_call_failure_hook.await_args.kwargs.get("route") == "/responses/mcp/call_tool"
@pytest.mark.asyncio
async def test_execute_tool_calls_reports_an_unmapped_tool_failure_under_the_tool_calls_own_id(monkeypatch):
call_tool_mock: Final = _setup_mcp_call_environment(monkeypatch)
post_call_failure_hook: Final = _setup_proxy_logging(monkeypatch)
results: Final = await LiteLLM_Proxy_MCP_Handler.execute_tool_calls(
tool_server_map={},
tool_calls=[{"id": "call-unmapped", "function": {"name": "unknown_tool", "arguments": "{}"}}],
user_api_key_auth=types.SimpleNamespace(api_key="test_key", user_id="test_user"),
litellm_call_id="cid",
)
assert results[0]["result"].startswith("Error executing tool")
call_tool_mock.assert_not_awaited()
post_call_failure_hook.assert_awaited_once()
assert post_call_failure_hook.await_args is not None
assert post_call_failure_hook.await_args.kwargs["request_data"]["litellm_call_id"] == "cid_call-unmapped"
@pytest.mark.asyncio
async def test_execute_tool_calls_logs_each_tool_call_under_its_own_id_on_the_request_trace(
monkeypatch,