fix: Fix Vertex AI Gemini streaming content_filter handling

- Add promptFeedback.blockReason check in chunk_parser
- Return content_filter finish_reason when blocked
- Extract content filter logic into _check_prompt_level_content_filter() method
- Update unit tests to reflect simplified implementation

Signed-off-by: Kris Xia <xiajiayi0506@gmail.com>
This commit is contained in:
Kris Xia
2026-01-31 12:01:31 +08:00
parent 8b563a7641
commit bfabb39fc6
2 changed files with 175 additions and 0 deletions
@@ -1732,6 +1732,52 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
else:
return "stop"
@staticmethod
def _check_prompt_level_content_filter(
processed_chunk: GenerateContentResponseBody,
response_id: Optional[str],
) -> Optional["ModelResponseStream"]:
"""
Check if prompt is blocked due to content filtering at the prompt level.
This handles the case where Vertex AI blocks the prompt before generation begins,
indicated by promptFeedback.blockReason being present.
Args:
processed_chunk: The parsed response chunk from Vertex AI
response_id: The response ID from the chunk
Returns:
ModelResponseStream with content_filter finish_reason if blocked, None otherwise.
Note:
This is consistent with non-streaming _handle_blocked_response() behavior.
Candidate-level content filtering (SAFETY, RECITATION, etc.) is handled
separately via _process_candidates() → _check_finish_reason().
"""
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
# Check if prompt is blocked due to content filtering
prompt_feedback = processed_chunk.get("promptFeedback")
if prompt_feedback and "blockReason" in prompt_feedback:
verbose_logger.debug(
f"Prompt blocked due to: {prompt_feedback.get('blockReason')} - {prompt_feedback.get('blockReasonMessage')}"
)
# Create a content_filter response (consistent with non-streaming _handle_blocked_response)
choice = StreamingChoices(
finish_reason="content_filter",
index=0,
delta=Delta(content=None, role="assistant"),
logprobs=None,
enhancements=None,
)
model_response = ModelResponseStream(choices=[choice], id=response_id)
return model_response
return None
@staticmethod
def _calculate_web_search_requests(grounding_metadata: List[dict]) -> Optional[int]:
web_search_requests: Optional[int] = None
@@ -2813,6 +2859,15 @@ class ModelResponseIterator:
processed_chunk = GenerateContentResponseBody(**chunk) # type: ignore
response_id = processed_chunk.get("responseId")
model_response = ModelResponseStream(choices=[], id=response_id)
# Check if prompt is blocked due to content filtering
blocked_response = VertexGeminiConfig._check_prompt_level_content_filter(
processed_chunk=processed_chunk,
response_id=response_id,
)
if blocked_response is not None:
model_response = blocked_response
usage: Optional[Usage] = None
_candidates: Optional[List[Candidates]] = processed_chunk.get("candidates")
grounding_metadata: List[dict] = []
@@ -3218,3 +3218,123 @@ def test_video_metadata_only_for_gemini_3():
assert file_part_3 is not None
assert "media_resolution" in file_part_3, "Gemini 3 should have media_resolution"
assert "video_metadata" in file_part_3, "Gemini 3 should have video_metadata"
def test_chunk_parser_handles_prompt_feedback_block():
"""Test chunk_parser correctly handles promptFeedback.blockReason"""
from unittest.mock import Mock
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
ModelResponseIterator,
)
# Arrange - mock a blocked response
blocked_chunk = {
"promptFeedback": {
"blockReason": "PROHIBITED_CONTENT",
"blockReasonMessage": "The prompt is blocked due to prohibited contents"
},
"responseId": "test_response_id",
"modelVersion": "gemini-3-pro-preview"
}
logging_obj = Mock()
logging_obj.optional_params = {}
streaming_obj = ModelResponseIterator(
streaming_response=iter([]),
sync_stream=True,
logging_obj=logging_obj
)
# Act
result = streaming_obj.chunk_parser(blocked_chunk)
# Assert
assert result is not None, "Result should not be None"
assert len(result.choices) == 1, "Should have exactly one choice"
assert result.choices[0].finish_reason == "content_filter", f"finish_reason should be content_filter, got {result.choices[0].finish_reason}"
assert result.choices[0].delta.content is None, "content should be None"
def test_chunk_parser_handles_prompt_feedback_safety_block():
"""Test chunk_parser handles different blockReason types (SAFETY)"""
from unittest.mock import Mock
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
ModelResponseIterator,
)
# Arrange - mock a SAFETY blocked response
blocked_chunk = {
"promptFeedback": {
"blockReason": "SAFETY",
"blockReasonMessage": "The prompt is blocked due to safety concerns"
},
"responseId": "test_safety_response_id",
}
logging_obj = Mock()
logging_obj.optional_params = {}
streaming_obj = ModelResponseIterator(
streaming_response=iter([]),
sync_stream=True,
logging_obj=logging_obj
)
# Act
result = streaming_obj.chunk_parser(blocked_chunk)
# Assert
assert result is not None
assert len(result.choices) == 1
assert result.choices[0].finish_reason == "content_filter"
def test_chunk_parser_handles_prompt_feedback_block_with_usage():
"""Test chunk_parser correctly extracts usageMetadata when promptFeedback.blockReason is present"""
from unittest.mock import Mock
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
ModelResponseIterator,
)
# Arrange - 模拟一个包含 usageMetadata 的 blocked response
blocked_chunk = {
"promptFeedback": {
"blockReason": "PROHIBITED_CONTENT",
"blockReasonMessage": "The prompt is blocked due to prohibited contents"
},
"responseId": "test_response_id_with_usage",
"modelVersion": "gemini-3-pro-preview",
"usageMetadata": {
"promptTokenCount": 8175,
"candidatesTokenCount": 0,
"totalTokenCount": 8175
}
}
logging_obj = Mock()
logging_obj.optional_params = {}
streaming_obj = ModelResponseIterator(
streaming_response=iter([]),
sync_stream=True,
logging_obj=logging_obj
)
# Act
result = streaming_obj.chunk_parser(blocked_chunk)
# Assert - 验证 content_filter 响应和 usage 都被正确处理
assert result is not None, "Result should not be None"
assert len(result.choices) == 1, "Should have exactly one choice"
assert result.choices[0].finish_reason == "content_filter", f"finish_reason should be content_filter, got {result.choices[0].finish_reason}"
assert result.choices[0].delta.content is None, "content should be None"
# 验证 usage 信息被正确提取
assert hasattr(result, "usage"), "result should have usage attribute"
assert result.usage is not None, "usage should not be None"
assert result.usage.prompt_tokens == 8175, f"prompt_tokens should be 8175, got {result.usage.prompt_tokens}"
assert result.usage.completion_tokens == 0, f"completion_tokens should be 0, got {result.usage.completion_tokens}"
assert result.usage.total_tokens == 8175, f"total_tokens should be 8175, got {result.usage.total_tokens}"