From fbf0a1cf4ada25de3e51baf4da65344bfd842132 Mon Sep 17 00:00:00 2001 From: Combrink van der Vyver Date: Wed, 10 Jun 2026 15:37:02 +0100 Subject: [PATCH 1/3] fix(adk): handle empty choices and zero-content streaming chunks from guardrail-blocked responses When an OpenAI-compatible content filter (e.g. an AWS Bedrock guardrail) blocks a response, it can return a ChatCompletion with an empty choices list, or a stream where every chunk is filtered out. Both previously caused an IndexError or an empty-parts response. Return a SAFETY finish reason with a placeholder part instead. Signed-off-by: Combrink van der Vyver --- .../src/kagent/adk/models/_openai.py | 14 +++++ .../tests/unittests/models/test_openai.py | 56 +++++++++++++++++++ 2 files changed, 70 insertions(+) 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..08cae07641 100644 --- a/python/packages/kagent-adk/src/kagent/adk/models/_openai.py +++ b/python/packages/kagent-adk/src/kagent/adk/models/_openai.py @@ -316,6 +316,14 @@ 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.""" + if not response.choices: + return LlmResponse( + content=types.Content( + role="model", + parts=[types.Part.from_text(text="Response blocked by content policy.")], + ), + finish_reason=types.FinishReason.SAFETY, + ) choice = response.choices[0] message = choice.message @@ -582,6 +590,12 @@ 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: + final_parts.append(types.Part.from_text(text="Response blocked by content policy.")) + 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..9a94f91e99 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,48 @@ 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 MockDelta: + role = "assistant" + tool_calls = None + content = None + + class MockChunkChoice: + def __init__(self, finish_reason=None): + self.delta = MockDelta() + self.finish_reason = finish_reason + self.index = 0 + + class MockChunk: + id = "chatcmpl-test" + created = 1234567890 + model = "gpt-3.5-turbo" + object = "chat.completion.chunk" + usage = None + + def __init__(self, finish_reason=None): + self.choices = [MockChunkChoice(finish_reason)] + + with mock.patch.object(openai_llm, "_client") as mock_client: + + async def mock_stream_gen_func(*args, **kwargs): + async def gen(): + yield MockChunk(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." + + # ============================================================================ # SSL/TLS Configuration Tests # ============================================================================ @@ -933,6 +975,20 @@ 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." + def test_preserves_thought_signature_from_openai_tool_call_response(self): response = self._MockResponse( self._MockMessage( From 834673e82bd76787f20f5c9ba0224e14bee2a2b1 Mon Sep 17 00:00:00 2001 From: Combrink van der Vyver Date: Wed, 10 Jun 2026 16:38:24 +0100 Subject: [PATCH 2/3] fix(adk): address review feedback on guardrail fallback paths - Preserve usage_metadata when choices is empty - Keep MAX_TOKENS finish reason when an empty stream was truncated by length rather than blocked by a content filter - Centralize the blocked-content placeholder in a module constant Signed-off-by: Combrink van der Vyver --- .../src/kagent/adk/models/_openai.py | 32 ++++++++++++------- .../tests/unittests/models/test_openai.py | 19 +++++++++++ 2 files changed, 39 insertions(+), 12 deletions(-) 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 08cae07641..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,12 +319,22 @@ 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="Response blocked by content policy.")], + parts=[types.Part.from_text(text=_CONTENT_BLOCKED_PLACEHOLDER)], ), + usage_metadata=usage_metadata, finish_reason=types.FinishReason.SAFETY, ) choice = response.choices[0] @@ -354,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": @@ -593,8 +597,12 @@ async def generate_content_async( # Guardrail or content filter can produce zero content/tool chunks. # An empty parts list causes downstream IndexError; emit a placeholder. if not final_parts: - final_parts.append(types.Part.from_text(text="Response blocked by content policy.")) - final_reason = types.FinishReason.SAFETY + 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) 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 9a94f91e99..3d6553e3e9 100644 --- a/python/packages/kagent-adk/tests/unittests/models/test_openai.py +++ b/python/packages/kagent-adk/tests/unittests/models/test_openai.py @@ -567,6 +567,23 @@ async def gen(): 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 MockChunk(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 @@ -988,6 +1005,8 @@ def test_empty_choices_returns_safety_finish_reason(self): 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( From 77a0fa0d4b278ba524cb98252fb931b602525652 Mon Sep 17 00:00:00 2001 From: Combrink van der Vyver Date: Wed, 10 Jun 2026 16:57:33 +0100 Subject: [PATCH 3/3] test(adk): rename empty-content stream mocks to scenario-specific names Signed-off-by: Combrink van der Vyver --- .../tests/unittests/models/test_openai.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 3d6553e3e9..275c463351 100644 --- a/python/packages/kagent-adk/tests/unittests/models/test_openai.py +++ b/python/packages/kagent-adk/tests/unittests/models/test_openai.py @@ -530,18 +530,18 @@ async def gen(): 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 MockDelta: + class EmptyContentDelta: role = "assistant" tool_calls = None content = None - class MockChunkChoice: + class EmptyContentChunkChoice: def __init__(self, finish_reason=None): - self.delta = MockDelta() + self.delta = EmptyContentDelta() self.finish_reason = finish_reason self.index = 0 - class MockChunk: + class EmptyContentChunk: id = "chatcmpl-test" created = 1234567890 model = "gpt-3.5-turbo" @@ -549,13 +549,13 @@ class MockChunk: usage = None def __init__(self, finish_reason=None): - self.choices = [MockChunkChoice(finish_reason)] + 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 MockChunk(finish_reason="content_filter") + yield EmptyContentChunk(finish_reason="content_filter") return gen() @@ -572,7 +572,7 @@ async def gen(): async def mock_length_stream_gen_func(*args, **kwargs): async def gen(): - yield MockChunk(finish_reason="length") + yield EmptyContentChunk(finish_reason="length") return gen()