Skip to content

fix(responses): fail loudly on MCP gateway failures (initial call, mid-stream, zero resolved tools) - #32648

Open
tin-berri wants to merge 15 commits into
litellm_internal_stagingfrom
litellm_fix_mcp_gateway_failure_handling
Open

fix(responses): fail loudly on MCP gateway failures (initial call, mid-stream, zero resolved tools)#32648
tin-berri wants to merge 15 commits into
litellm_internal_stagingfrom
litellm_fix_mcp_gateway_failure_handling

Conversation

@tin-berri

Copy link
Copy Markdown
Contributor

Note

Copy of #32579 by @thibault-linktree, pushed to a litellm_-prefixed branch so the full CircleCI suite (which needs secrets and is skipped on fork PRs) runs. Commit authorship is preserved (commits pushed by SHA); full credit to the original author. Based on litellm_internal_staging.

Original PR: #32579

Relevant issues

Fixes #32561, fixes #32562, fixes #32563

Combines #32565, #32566 and #32567 into a single PR per review logistics (the three fixes share the _stream_error stash / _make_stream_error_event helper and their tests interact). All Greptile feedback from those PRs is already incorporated; see the closed PRs for the per-fix review threads.

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem (three swallow-points of the same defect: MCP gateway failures are invisible to clients)
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / Proof of Fix

Live-repro evidence below is from the original author (@thibault-linktree). Real proxy (litellm --config, real OpenAI calls, deepwiki MCP server registered). Commit hashes are from the original per-fix branches; the combined branch is the same changes.

1. Initial LLM call fails (bogus previous_response_id), streaming

Before (main @ 999637883c) — HTTP 200 + discovery events with no response.created (crashes openai-node: expected 'response.created' event, got response.mcp_list_tools.in_progress):

HTTP/1.1 200 OK
data: {"type":"response.mcp_list_tools.in_progress",...}
data: {"type":"response.mcp_list_tools.completed",...}
data: {"type":"response.output_item.done","item":{"type":"mcp_list_tools",...}}
data: [DONE]

After (@ b6bbee9c92) — real 400 with the provider error body, before any SSE bytes:

HTTP/1.1 400 Bad Request
{"error":{"message":"litellm.BadRequestError: OpenAIException - {...\"message\": \"Previous response with id 'resp_bogus_e2e_capture' not found.\"...}","code":"400"}}

2. Tool execution / follow-up failure mid-stream (requires fault injection; captures drive the real iterator with only the failing dependency mocked)

Before — tool events, then the stream ends with no terminal event; a batch tool-execution failure additionally attempted the doomed follow-up (follow-up model call attempted: True), rejected by the provider with "No tool output found for function call ...".

After (@ bd9e13914e) — terminal error event; doomed follow-up skipped:

... mcp_call events ...
error  code=400 message=litellm.BadRequestError: {"error": {"message": "No tool output found for function call ..."}}
<< stream ended >>   (follow-up model call attempted: False in the tool-failure case)

3. MCP tools requested, zero resolved (key lacks server access / unknown server)

Before (main @ 999637883c) — HTTP 200, model called with tools=None, fabricated answer ("It seems I can't access external tools or links, including Linktree. You can check the number of links on your Linktree by logging into your account ..."); only trace is a "success" list_mcp_tools spend log with [].

After (@ 60dc62fb84) — 400 with an actionable message:

{"error":{"message":"litellm.BadRequestError: MCP gateway resolved 0 tools for the requested MCP tool(s) (server_url(s): ['litellm_proxy/mcp/linktree_omnibot']). Likely causes: the API key/team does not have access to the MCP server (server has allow_all_keys=false and no key/team object-permission grant), the server name does not exist, or allowed_tools matches no tool on the server. ...","code":"400"}}

Happy path, fully e2e (real deepwiki read_wiki_structure call): unchanged on every fix — full two-phase flow, real answer ("The first topic name is "Overview.""), zero error events.

Type

🐛 Bug Fix

Changes

Three swallow-points in the MCP gateway made failures invisible to clients (HTTP 200 + broken/silent streams, or silent no-tools model calls):

  1. Initial LLM call (aresponses_api_with_mcp + MCPEnhancedStreamingIterator): the initial call is now made eagerly, before any SSE bytes are written, and a stashed creation failure is re-raised so the proxy returns a real 4xx/5xx. If a failure still surfaces during iteration, the stream emits a single terminal error event instead of mcp_list_tools discovery events with no response.created.
  2. Tool execution / follow-up call: failures are stashed and surfaced as a terminal error event instead of silently ending the stream; after a batch tool-execution failure the doomed follow-up call (function_calls with no outputs → provider 400) is skipped entirely.
  3. Zero resolved MCP tools: requests that asked for litellm_proxy MCP tools but resolved none (and carry no other tools to fall back on) now raise a 400 naming the requested server URLs and likely causes. Mixed requests with function tools fall back to those tools (previous behaviour). Opt-out: litellm.reject_empty_mcp_resolved_tools = False (default True per maintainer guidance).

Tests: 9 new tests across tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py (new file) and tests/test_litellm/responses/mcp/test_mcp_empty_resolved_tools.py (new file); the auth-header pass-through test in tests/mcp_tests/test_aresponses_api_with_mcp.py now resolves a dummy tool (its purpose is header propagation, not zero-tool behaviour). Full local run: 50 passed.

fernando-izar and others added 14 commits July 2, 2026 21:22
…out (#31632)

* fix(prometheus): bound per-request budget metric emission with a timeout

Wrap the per-request budget-metric gather in asyncio.wait_for so a slow Redis or DB lookup cannot consume the whole LoggingWorker watchdog and get the success-logging event cancelled. On timeout the emission is skipped in isolation; budget gauges are still refreshed by the periodic cron. The timeout is configurable via PROMETHEUS_BUDGET_METRICS_PER_REQUEST_TIMEOUT and defaults to 5.0 seconds, falling back to the default on an invalid value instead of raising

* fix(prometheus): reject non-finite and non-positive budget-metrics timeout env

float() accepts 0, negatives, nan and inf, which bypass the fallback: a value <= 0 makes asyncio.wait_for time out immediately and skip every per-request emission, and inf reintroduces the unbounded wait the timeout was meant to bound. Validate the parsed value is finite and greater than zero before using it, otherwise fall back to the default
When a guardrail blocks a post-call response, the synthetic violation response
reported hard-coded zero usage, discarding the token usage the upstream call
had already consumed.

Fix the root cause rather than re-counting tokens:
- Add an optional `original_response` field to ModifyResponseException.
- The unified guardrail's post-call success hook attaches the blocked LLM
  response to the exception.
- The /v1/messages and OpenAI-format (/v1/chat/completions, /v1/completions)
  block handlers report `original_response.usage` directly. Pre-call blocks
  never invoked the LLM, so usage is zero.

Mock-based tests cover the helper (returns original usage / zero), the success
hook attaching original_response, and the endpoint reporting it end-to-end.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ock (#31389)

Streaming moderation improvements for the unified guardrail post-call
streaming iterator hook:

- streaming_buffer_until_moderated: withhold all chunks until end-of-stream
  moderation passes, then release the original response (clean) or only the
  block message (blocked) -- the original content is never delivered on a
  block. Snapshot chunks with a shallow list() copy (end-of-stream builds a
  separate assembled response; chunks aren't mutated in place).
- Clean Anthropic SSE on block: synthesize a well-formed termination sequence
  instead of a bare data: {"error": ...} blob that truncates the stream.
  Provider-specific synthesis lives in AnthropicMessagesHandler via
  build_block_sse_chunks (format-agnostic routing stays in the hook).
- Mid-stream blocks continue the in-progress message (close open content
  block, append block message, terminate) rather than emitting a second
  message_start, which clients reject. Standalone envelope only when no chunks
  were sent (buffered path).
- ModifyResponseException imported under TYPE_CHECKING + locally at runtime to
  avoid a module-level cyclic import.

Adds regression tests for buffering (content withheld on block) and mid-stream
continuation (single message_start).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… content-rewriting guardrails

- _standalone_block_chunks and _block_continuation_chunks now read real
  token usage from ModifyResponseException.original_response instead of
  hardcoding zero, matching the non-streaming _blocked_response_usage path.
  Shared helper moved to guardrail_translation/utils.py.
- streaming_buffer_until_moderated is now forced off when the guardrail has
  mask_response_content=True, since buffered replay releases the withheld
  original chunks verbatim -- unsafe for a guardrail that rewrites content
  (e.g. PII masking).
- Fix inverted streaming-flag precedence comment.
…-of-stream detection

_check_streaming_has_ended assumed responses_so_far held ModelResponse
objects with .choices, but for the Responses API the accumulated chunks
are raw SSE event dicts, causing an AttributeError on every call
…emitting a broken stream

When the initial LLM call inside MCPEnhancedStreamingIterator fails (e.g.
an invalid previous_response_id -> provider 400 'No tool output found for
function call ...'), the proxy returned HTTP 200 and the stream emitted the
pre-generated mcp_list_tools discovery events with no response.created
before them. That violates the Responses API streaming contract and crashes
SDK stream accumulators (openai-node: "expected 'response.created' event,
got response.mcp_list_tools.in_progress").

- aresponses_api_with_mcp now makes the initial call eagerly, before any
  SSE bytes are written, and re-raises the stashed failure so the client
  gets a real 4xx/5xx with the provider error body.
- If a creation failure still surfaces during iteration, the stream emits a
  single terminal 'error' event instead of discovery events.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…low-up failures

When tool execution failed as a batch, the stream proceeded to a follow-up
call carrying function_call items with no outputs — rejected by the
provider with 'No tool output found for function call ...' — and when the
follow-up call itself failed, the stream simply ended with no terminal
event. In both cases the client received HTTP 200 and a stream that looks
like a truncated success: tool events, then silence.

- Stash tool-execution and follow-up failures on the iterator.
- Skip the doomed follow-up call entirely after a tool-execution failure.
- Emit a single terminal OpenAI-style 'error' stream event carrying the
  mapped failure instead of ending silently.

Builds on the initial-call failure handling from the previous commit
(shares the _stream_error stash and _make_stream_error_event helper).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A request that explicitly asks for MCP tools via server_url litellm_proxy/...
but resolves none of them (the API key/team has no access to the MCP server
via allow_all_keys=false and no object-permission grant, the server name does
not exist, or allowed_tools matches nothing) was silently sent to the model
with no tools. The model then hallucinates, and the only trace is a
list_mcp_tools spend log with status success and an empty response — the
request looks healthy end to end while being completely broken.

Raise a 400 BadRequestError naming the requested server URLs and the likely
causes instead. Guard scope:

- Mixed requests are exempt: with other (function) tools present, the request
  proceeds using those tools, matching the previous fallback behaviour.
- Opt-out via litellm.reject_empty_mcp_resolved_tools = False (default True,
  per maintainer guidance).

The auth-header pass-through test in tests/mcp_tests now resolves a dummy
tool, since its purpose is header propagation, not zero-tool behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rop orphaned tool events on batch failure

Review feedback (Greptile on #32579):

- The terminal error event was numbered sequence_number=1, out of order
  after tool-execution events. __anext__ now tracks the highest
  sequence_number that passed through the stream and the error event is
  numbered after it.
- A batch tool-execution failure queued mcp_call.in_progress events that
  never received a terminal per-item event. Those queued events are now
  dropped; the terminal error event carries the failure instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Drop test_initial_call_success_does_not_emit_error_event: the tool-call
  happy path (test_tool_call_happy_path_emits_no_error_event) already guards
  against false-positive error events and exercises more of the changed code
  (tool-exec + follow-up success paths).
- Drop the stream=True parametrization on the zero-resolved-tools guard: the
  guard runs before the stream/non-stream branch in aresponses_api_with_mcp,
  so both cases hit identical code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removed MCP server configuration for deepwiki.
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@CLAassistant

CLAassistant commented Jul 9, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
5 out of 6 committers have signed the CLA.

✅ Sameerlite
✅ fernando-izar
✅ seph-barker
✅ thibault-linktree
✅ tin-berri
❌ cursoragent
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes three distinct swallow-points in the MCP gateway where client-visible failures were silently turned into HTTP 200 responses with broken or incomplete streams, or into silent no-tools model calls that caused hallucinations.

  • Zero resolved MCP tools: a new guard in aresponses_api_with_mcp raises BadRequestError (400) with an actionable message before any LLM call is made, when mcp_tools_with_litellm_proxy is non-empty but no tools resolved and no other tools are present; controlled by litellm.reject_empty_mcp_resolved_tools (default True).
  • Initial LLM call failure: the streaming path now calls _create_initial_response_iterator() eagerly before returning the iterator, then re-raises any stashed _initial_creation_error so the proxy returns a real 4xx/5xx instead of an HTTP 200 whose stream starts with mcp_list_tools events and no response.created.
  • Mid-stream failures (tool execution and follow-up call): a _stream_error stash and _make_stream_error_event() helper surface batch tool-execution errors and follow-up call errors as a terminal error event with a monotonic sequence_number; the doomed follow-up call (function_call items with no outputs) is now skipped entirely after a tool-execution failure.

Confidence Score: 5/5

Safe to merge; all three failure modes are well-guarded, opt-out flags are in place, and the existing happy path is covered by a dedicated regression test.

The changes are tightly scoped to the MCP streaming path, introduce no new external dependencies, and are defended by 9 new mock-only tests that cover both the failure paths and the success path. The backwards-incompatible zero-tools behaviour is gated behind a feature flag. No auth, data-integrity, or protocol-contract issues were identified.

No files require special attention.

Important Files Changed

Filename Overview
litellm/init.py Adds reject_empty_mcp_resolved_tools: bool = True flag with a clear docstring; follows the feature-flag pattern required by the backwards-compat rule.
litellm/responses/main.py Adds zero-resolved-tools guard (raises BadRequestError before any LLM call) and the eager initial-call path (_create_initial_response_iterator + re-raise) so pre-stream failures surface as HTTP 4xx rather than HTTP 200 with a broken stream.
litellm/responses/mcp/mcp_streaming_iterator.py Introduces _stream_error / _initial_creation_error stash, _make_stream_error_event helper, and _last_sequence_number tracker; updates _handle_initial_response_phase, _generate_tool_execution_events, and _create_follow_up_iterator to surface failures as terminal error events instead of silent stream ends.
tests/mcp_tests/test_aresponses_api_with_mcp.py Existing auth-header test now returns a dummy tool from mock_process so it does not trip the new zero-tools guard; the test purpose (header propagation) is preserved.
tests/test_litellm/responses/mcp/test_mcp_empty_resolved_tools.py Five new mock-only tests covering the zero-resolved-tools guard: raises before model call, falls back on function tools, respects the opt-out flag, passes through when tools resolve, and ignores requests with no MCP tools at all.
tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py Four new mock-only tests for the streaming error-surfacing paths (initial call failure, eager re-raise, tool execution batch failure, follow-up call failure) plus a happy-path regression guard; all tests use monkeypatch/AsyncMock with no real network calls.

Reviews (3): Last reviewed commit: "Merge litellm_internal_staging into lite..." | Re-trigger Greptile

Comment on lines 588 to 599
Check if the streaming has ended.
"""
return all(response.choices[0].finish_reason is not None for response in responses_so_far)
if not responses_so_far:
return False
terminal_types = {
ResponsesAPIStreamEvents.RESPONSE_COMPLETED.value,
ResponsesAPIStreamEvents.RESPONSE_FAILED.value,
ResponsesAPIStreamEvents.RESPONSE_INCOMPLETE.value,
}
return responses_so_far[-1].get("type") in terminal_types

def get_streaming_string_so_far(self, responses_so_far: List[Any]) -> str:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 AttributeError on non-dict stream items in _check_streaming_has_ended

The rewritten method calls responses_so_far[-1].get("type"), which assumes every accumulated item is a dict. For the OpenAI Responses API, stream events may arrive as Pydantic model instances (or other objects), which don't have a .get() method — causing an unhandled AttributeError that propagates out of the async_post_call_streaming_iterator_hook in unified_guardrail.py. The hasattr(_check_streaming_has_ended) guard in the caller only confirms the method exists, not that it executes safely.

The old implementation (all(response.choices[0].finish_reason ...)) was certainly broken for the Responses API format, so this is an improvement — but the new path should guard against non-dict items, e.g. using getattr(responses_so_far[-1], "type", None) instead of .get("type").

Comment thread litellm/responses/main.py
# Silently calling the model with no tools makes it hallucinate, and
# the only trace is a list_mcp_tools spend log with an empty response —
# so fail loudly instead.
requested_mcp_urls = [tool.get("server_url") for tool in mcp_tools_with_litellm_proxy if isinstance(tool, dict)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The list comprehension passes through dicts missing a server_url key, so tool.get("server_url") silently populates the list with None values, making the error message less actionable.

Suggested change
requested_mcp_urls = [tool.get("server_url") for tool in mcp_tools_with_litellm_proxy if isinstance(tool, dict)]
requested_mcp_urls = [tool["server_url"] for tool in mcp_tools_with_litellm_proxy if isinstance(tool, dict) and "server_url" in tool]

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +8364 to +8377
def _blocked_response_usage(original_response: Optional[Any]) -> "litellm.Usage":
"""
Token usage for a synthetic guardrail-blocked response.

A post-call block replaces the LLM's response with the violation message,
but the upstream call already consumed tokens -- report that real usage
(carried on ``ModifyResponseException.original_response``) rather than
discarding it. Pre-call blocks never invoked the LLM (no original_response),
so usage is zero.
"""
usage = getattr(original_response, "usage", None) if original_response is not None else None
if isinstance(usage, litellm.Usage):
return usage
return litellm.Usage(prompt_tokens=0, completion_tokens=0, total_tokens=0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _blocked_response_usage here only handles the case where original_response carries a litellm.Usage object. For a streaming chat completion block where the unified guardrail sets e.original_response = responses_so_far (a list of raw chunks), getattr(list, "usage", None) returns None and usage is silently zeroed out. The more general blocked_response_usage in utils.py handles streaming chunks and could be reused here.

@greptile-apps

greptile-apps Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes three silent failure modes in the MCP gateway where failures were swallowed and returned HTTP 200 with malformed or empty streams. It also adds real token-usage reporting for guardrail-blocked responses (instead of hardcoded zeros) and a configurable timeout for Prometheus per-request budget metric emission.

  • MCP streaming fixes: initial LLM call failures now raise before any SSE bytes are written; mid-stream tool-execution and follow-up failures emit a terminal error event instead of silently ending the stream; zero resolved MCP tools raises a 400 with actionable causes rather than silently calling the model with no tools.
  • Guardrail token usage: ModifyResponseException gains an original_response field threaded through the unified guardrail success hook and each endpoint handler, so the blocked response reports the upstream call's real token counts instead of zeros.
  • Streaming guardrail block termination: unified_guardrail and the Anthropic handler can now emit a well-formed SSE continuation (or standalone block message) when a guardrail fires mid-stream, avoiding a bare data: {\"error\":…} blob that truncates the Anthropic SDK parser.

Confidence Score: 4/5

Safe to merge; all three MCP gateway failure modes are addressed with tests and the changes are well-scoped. The guardrail-side additions are larger but covered by new tests.

The core MCP streaming iterator changes are clean and well-tested. Two minor issues exist: a private _blocked_response_usage helper in proxy_server.py that only handles litellm.Usage instances (silently zeroing dict-shaped usage), which diverges from the richer blocked_response_usage in utils.py born in the same PR; and mid-file module-level imports in one test file that could cause confusing collection failures if any of those symbols ever become unavailable. Neither affects current behaviour, but both are worth tidying before further iteration on the blocked-response-usage feature.

litellm/proxy/proxy_server.py (_blocked_response_usage diverges from utils.py), tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py (mid-file imports), litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py (complex flag interactions worth a second read).

Important Files Changed

Filename Overview
litellm/responses/mcp/mcp_streaming_iterator.py Core MCP fix: adds _stream_error/_initial_creation_error stash, converts silent stream ends and pre-stream LLM failures into terminal error events, and skips the doomed follow-up call after batch tool-execution failures. Logic is sound and well-commented.
litellm/responses/main.py Adds zero-resolved-tools guard (raises BadRequestError before any SSE bytes) and eagerly creates the initial LLM response so pre-stream failures surface as HTTP 4xx instead of HTTP 200 with a broken stream. Both changes are correctly scoped and gated by a flag.
litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py Adds streaming_buffer_until_moderated mode, mid-stream and end-of-stream ModifyResponseException handling that emits a proper SSE block sequence, and token usage propagation. Large diff with interacting state variables (chunks_yielded, responses_yielded, pending_end_of_stream_items); logic appears correct but warrants careful testing under each combination of flags.
litellm/llms/anthropic/chat/guardrail_translation/handler.py Adds build_block_sse_chunks, _block_continuation_chunks and _content_block_state helpers for emitting a well-formed Anthropic SSE block sequence. SSE event parsing is duplicated from utils.py; otherwise the implementation is well-tested.
litellm/llms/base_llm/guardrail_translation/utils.py Adds blocked_response_usage helper for extracting real token usage from blocked LLM responses (supports streaming SSE chunks, dicts, and attribute access). SSE parsing logic is partially duplicated from handler.py.
litellm/proxy/proxy_server.py Adds _blocked_response_usage (narrow: litellm.Usage-only) and wires it into the chat/text-completion block paths. Removes the hardcoded zero-usage return before the non-streaming response. A second, richer implementation lives in utils.py — the two may drift.
litellm/integrations/prometheus.py Wraps asyncio.gather for per-request budget metrics in asyncio.wait_for with a configurable timeout (PROMETHEUS_BUDGET_METRICS_PER_REQUEST_TIMEOUT, default 5 s) to prevent slow Redis/DB lookups from consuming the logging watchdog budget. Correctly skips the emit (not errors) on timeout.
litellm/exceptions.py Adds original_response field to ModifyResponseException so post-call guardrail blocks can carry the LLM response's real token usage. The field is Optional[Any] with a None default, making it backward-compatible.
tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py New test file covering all three MCP streaming failure modes plus the happy path; correctly uses mocks. Three module-level imports (types, MagicMock, mcp.types) appear mid-file after the second test function instead of at the top.
tests/mcp_tests/test_aresponses_api_with_mcp.py Auth-header pass-through test updated to return a dummy resolved tool so the new zero-tools guard does not interrupt it. The change is correct and does not weaken test coverage — the test still validates auth header propagation.

Reviews (2): Last reviewed commit: "Remove deepwiki MCP server configuration" | Re-trigger Greptile

Comment on lines +155 to +160
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
fake_manager,
)
monkeypatch.setitem(
sys.modules,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Mid-file module-level imports

Three import statements — import types, from unittest.mock import MagicMock, and from mcp.types import CallToolResult, TextContent — appear after the second test function instead of at the top of the file. Python evaluates them at module load time regardless of position, but linters (isort, ruff I) will flag them, and a future import failure on one of those symbols would raise at load time, silently preventing the entire module from being collected and making all tests look like they passed (they'd simply not run).

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines 8361 to +8378
)


def _blocked_response_usage(original_response: Optional[Any]) -> "litellm.Usage":
"""
Token usage for a synthetic guardrail-blocked response.

A post-call block replaces the LLM's response with the violation message,
but the upstream call already consumed tokens -- report that real usage
(carried on ``ModifyResponseException.original_response``) rather than
discarding it. Pre-call blocks never invoked the LLM (no original_response),
so usage is zero.
"""
usage = getattr(original_response, "usage", None) if original_response is not None else None
if isinstance(usage, litellm.Usage):
return usage
return litellm.Usage(prompt_tokens=0, completion_tokens=0, total_tokens=0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _blocked_response_usage silently zeroes dict-shaped usage

The local _blocked_response_usage in proxy_server.py only returns real token counts when original_response.usage is already a litellm.Usage instance; any other representation (plain dict, Pydantic model from a non-standard provider, etc.) falls back to zeros. The richer blocked_response_usage in litellm/llms/base_llm/guardrail_translation/utils.py handles dicts and attribute access. The two helpers were born in the same PR but are already diverging — consider importing and wrapping the utils version rather than maintaining a separate implementation, to avoid usage being silently discarded as the guardrail feature evolves.

Comment on lines 1 to +42
from __future__ import annotations

from typing import Any, List
import json
from typing import Any, List, Optional

from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicUsage
from litellm.types.llms.openai import AllMessageValues


def _anthropic_stream_chunk_events(item: Any) -> list[dict]:
if isinstance(item, dict):
return [item]
if isinstance(item, bytes):
chunk = item.decode("utf-8", errors="replace")
elif isinstance(item, str):
chunk = item
else:
return []

events: list[dict] = []
for block in chunk.split("\n\n"):
for line in block.splitlines():
stripped = line.strip()
if not stripped.startswith("data:"):
continue
payload = stripped[len("data:") :].strip()
if not payload or payload == "[DONE]":
continue
try:
parsed = json.loads(payload)
except json.JSONDecodeError:
continue
if isinstance(parsed, dict):
events.append(parsed)
return events


def _usage_from_anthropic_stream_chunks(original_response: list[Any]) -> Optional[AnthropicUsage]:
input_tokens = 0
output_tokens = 0
found_usage = False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duplicated SSE event parsing logic

_anthropic_stream_chunk_events here and AnthropicMessagesHandler._iter_sse_events in litellm/llms/anthropic/chat/guardrail_translation/handler.py implement the same SSE-bytes-to-event-dicts conversion with slightly different splitting/stripping logic. There is now a third reading of the same bytes in _block_continuation_chunks. Having multiple independent parsers for the same wire format increases the chance of subtle behavioural differences (e.g., one handles \r\n line endings, another does not). Centralising to a single helper and delegating from the other call sites would reduce this risk.

…andling

Resolve conflicts in the MCP streaming iterator and its test after
internal_staging removed the standalone follow_up_response phase (follow-ups
now loop back through continue_initial_response with a round cap). Re-apply the
failure-surfacing semantics on top of that flow: stash internal failures and
emit a single terminal error event (or skip the doomed follow-up) instead of
silently ending the stream, and set base_iterator to None on a stashed error so
the new phase-4 loop stops. Union the two independently-added iterator test
suites (round-cap loop plus error-surfacing) into one file, deduping shared
helpers.
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.87179% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
litellm/responses/main.py 87.50% 1 Missing ⚠️
litellm/responses/mcp/mcp_streaming_iterator.py 96.66% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_fix_mcp_gateway_failure_handling (d089b2b) with litellm_internal_staging (60729f7)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (b340a26) during the generation of this report, so 60729f7 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

7 participants