diff --git a/python/packages/kagent-adk/src/kagent/adk/models/_openai.py b/python/packages/kagent-adk/src/kagent/adk/models/_openai.py index ae6fd6df46..27a6c89603 100644 --- a/python/packages/kagent-adk/src/kagent/adk/models/_openai.py +++ b/python/packages/kagent-adk/src/kagent/adk/models/_openai.py @@ -38,6 +38,9 @@ if TYPE_CHECKING: from google.adk.models.llm_request import LlmRequest +# Emitted when a guardrail or content filter blocks a response, leaving no content to surface. +_CONTENT_BLOCKED_PLACEHOLDER = "Response blocked by content policy." + def _convert_role_to_openai(role: Optional[str]) -> str: """Convert google.genai role to OpenAI role.""" @@ -316,6 +319,24 @@ def _convert_tools_to_openai(tools: list[types.Tool]) -> list[ChatCompletionTool def _convert_openai_response_to_llm_response(response: ChatCompletion) -> LlmResponse: """Convert OpenAI response to LlmResponse.""" + # Handle usage metadata + usage_metadata = None + if hasattr(response, "usage") and response.usage: + usage_metadata = types.GenerateContentResponseUsageMetadata( + prompt_token_count=response.usage.prompt_tokens, + candidates_token_count=response.usage.completion_tokens, + total_token_count=response.usage.total_tokens, + ) + + if not response.choices: + return LlmResponse( + content=types.Content( + role="model", + parts=[types.Part.from_text(text=_CONTENT_BLOCKED_PLACEHOLDER)], + ), + usage_metadata=usage_metadata, + finish_reason=types.FinishReason.SAFETY, + ) choice = response.choices[0] message = choice.message @@ -346,15 +367,6 @@ def _convert_openai_response_to_llm_response(response: ChatCompletion) -> LlmRes content = types.Content(role="model", parts=parts) - # Handle usage metadata - usage_metadata = None - if hasattr(response, "usage") and response.usage: - usage_metadata = types.GenerateContentResponseUsageMetadata( - prompt_token_count=response.usage.prompt_tokens, - candidates_token_count=response.usage.completion_tokens, - total_token_count=response.usage.total_tokens, - ) - # Handle finish reason finish_reason = types.FinishReason.STOP if choice.finish_reason == "length": @@ -582,6 +594,16 @@ async def generate_content_async( elif finish_reason == "tool_calls": final_reason = types.FinishReason.STOP # Tool calls is a normal completion + # Guardrail or content filter can produce zero content/tool chunks. + # An empty parts list causes downstream IndexError; emit a placeholder. + if not final_parts: + if final_reason == types.FinishReason.MAX_TOKENS: + # Truncated by length before any content; not a safety block. + final_parts.append(types.Part.from_text(text="")) + else: + final_parts.append(types.Part.from_text(text=_CONTENT_BLOCKED_PLACEHOLDER)) + final_reason = types.FinishReason.SAFETY + # Always yield final response to signal completion and valid metadata final_content = types.Content(role="model", parts=final_parts) yield LlmResponse( diff --git a/python/packages/kagent-adk/tests/unittests/models/test_openai.py b/python/packages/kagent-adk/tests/unittests/models/test_openai.py index 5dba0e52ac..275c463351 100644 --- a/python/packages/kagent-adk/tests/unittests/models/test_openai.py +++ b/python/packages/kagent-adk/tests/unittests/models/test_openai.py @@ -526,6 +526,65 @@ async def gen(): assert final_response.usage_metadata.total_token_count == 15 +@pytest.mark.asyncio +async def test_streaming_with_no_content_chunks_returns_safety_finish_reason(openai_llm, llm_request): + """A stream where every chunk is filtered (e.g. by a guardrail) should not raise IndexError.""" + + class EmptyContentDelta: + role = "assistant" + tool_calls = None + content = None + + class EmptyContentChunkChoice: + def __init__(self, finish_reason=None): + self.delta = EmptyContentDelta() + self.finish_reason = finish_reason + self.index = 0 + + class EmptyContentChunk: + id = "chatcmpl-test" + created = 1234567890 + model = "gpt-3.5-turbo" + object = "chat.completion.chunk" + usage = None + + def __init__(self, finish_reason=None): + self.choices = [EmptyContentChunkChoice(finish_reason)] + + with mock.patch.object(openai_llm, "_client") as mock_client: + + async def mock_stream_gen_func(*args, **kwargs): + async def gen(): + yield EmptyContentChunk(finish_reason="content_filter") + + return gen() + + mock_client.chat.completions.create.side_effect = mock_stream_gen_func + + stream_results = [resp async for resp in openai_llm.generate_content_async(llm_request, stream=True)] + + final_response = stream_results[-1] + assert final_response.finish_reason == types.FinishReason.SAFETY + assert final_response.content.parts[0].text == "Response blocked by content policy." + + # An empty stream truncated by length keeps MAX_TOKENS instead of being marked SAFETY. + with mock.patch.object(openai_llm, "_client") as mock_client: + + async def mock_length_stream_gen_func(*args, **kwargs): + async def gen(): + yield EmptyContentChunk(finish_reason="length") + + return gen() + + mock_client.chat.completions.create.side_effect = mock_length_stream_gen_func + + stream_results = [resp async for resp in openai_llm.generate_content_async(llm_request, stream=True)] + + final_response = stream_results[-1] + assert final_response.finish_reason == types.FinishReason.MAX_TOKENS + assert final_response.content.parts[0].text == "" + + # ============================================================================ # SSL/TLS Configuration Tests # ============================================================================ @@ -933,6 +992,22 @@ def __init__(self, message): self.choices = [TestConvertOpenAIResponseToLlmResponse._MockChoice(message)] self.usage = TestConvertOpenAIResponseToLlmResponse._MockUsage() + class _MockEmptyChoicesResponse: + def __init__(self): + self.choices = [] + self.usage = TestConvertOpenAIResponseToLlmResponse._MockUsage() + + def test_empty_choices_returns_safety_finish_reason(self): + """A response with no choices (e.g. blocked by a guardrail) should not raise IndexError.""" + response = self._MockEmptyChoicesResponse() + + llm_response = _convert_openai_response_to_llm_response(response) + + assert llm_response.finish_reason == types.FinishReason.SAFETY + assert llm_response.content.parts[0].text == "Response blocked by content policy." + assert llm_response.usage_metadata is not None + assert llm_response.usage_metadata.total_token_count == 8 + def test_preserves_thought_signature_from_openai_tool_call_response(self): response = self._MockResponse( self._MockMessage(