From 06754db0c222b2b22b09ff72641987d8068f737f Mon Sep 17 00:00:00 2001 From: Kumaresan Date: Sat, 12 Sep 2026 22:24:57 +0530 Subject: [PATCH] fix(openapi): omit header and cookie params the model leaves null A model routinely emits null for an optional parameter it decided not to use. _prepare_request_params already drops such a value for a query parameter, but passed it through for header and cookie parameters: - httpx refuses to encode a None header value, so the whole tool call fails with "Header value must be str or bytes, not " before the request is ever sent, and the model gets no usable error to recover from. - httpx accepts a None cookie and serializes it as a bare, valueless `Cookie: ` pair, so the request goes out malformed and silently. Skip both when the value is None, matching how an unset query parameter is already handled in the same loop. An explicitly empty string is still sent, so a caller can keep passing a deliberate empty value. --- .../openapi_spec_parser/rest_api_tool.py | 12 +- .../openapi_spec_parser/test_rest_api_tool.py | 149 ++++++++++++++++++ 2 files changed, 159 insertions(+), 2 deletions(-) diff --git a/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py b/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py index a1ba7ecd15..0418737622 100644 --- a/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py +++ b/src/google/adk/tools/openapi_tool/openapi_spec_parser/rest_api_tool.py @@ -418,10 +418,18 @@ def _prepare_request_params( elif param_location == "query": if v is not None: query_params[original_k] = v + # A model routinely fills an optional parameter it decided not to use + # with null, which arrives here as None. For a header that reaches httpx + # as a value it refuses to encode, failing the whole call with + # "Header value must be str or bytes"; for a cookie it is serialized as + # a bare, valueless `Cookie: ` pair. Omit the parameter instead, + # the same way an unset query parameter is omitted above. elif param_location == "header": - header_params[original_k] = v + if v is not None: + header_params[original_k] = v elif param_location == "cookie": - cookie_params[original_k] = v + if v is not None: + cookie_params[original_k] = v # Construct URL base_url = self.endpoint.base_url or "" diff --git a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py index e477d9bc4d..1ac69fbd0e 100644 --- a/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py +++ b/tests/unittests/tools/openapi_tool/openapi_spec_parser/test_rest_api_tool.py @@ -968,6 +968,155 @@ def test_prepare_request_params_cookie_param( assert request_params["cookies"]["session_id"] == "cookie_value" + def test_prepare_request_params_omits_none_header_param( + self, + sample_endpoint, + sample_auth_credential, + sample_auth_scheme, + sample_operation, + ): + """An unset optional header parameter is omitted from the request. + + httpx refuses to encode a None header value, so keeping it would fail the + whole call with "Header value must be str or bytes, not NoneType". + """ + tool = RestApiTool( + name="test_tool", + description="Test Tool", + endpoint=sample_endpoint, + operation=sample_operation, + auth_credential=sample_auth_credential, + auth_scheme=sample_auth_scheme, + ) + params = [ + ApiParameter( + original_name="X-Custom-Header", + py_name="x_custom_header", + param_location="header", + param_schema=OpenAPISchema(type="string"), + ) + ] + kwargs = {"x_custom_header": None} + + request_params = tool._prepare_request_params(params, kwargs) + + assert "X-Custom-Header" not in request_params["headers"] + + def test_prepare_request_params_omits_none_cookie_param( + self, + sample_endpoint, + sample_auth_credential, + sample_auth_scheme, + sample_operation, + ): + """An unset optional cookie parameter is omitted from the request. + + httpx serializes a None cookie as a bare, valueless ``Cookie: session_id`` + pair rather than rejecting it, so keeping it corrupts the request silently. + """ + tool = RestApiTool( + name="test_tool", + description="Test Tool", + endpoint=sample_endpoint, + operation=sample_operation, + auth_credential=sample_auth_credential, + auth_scheme=sample_auth_scheme, + ) + params = [ + ApiParameter( + original_name="session_id", + py_name="session_id", + param_location="cookie", + param_schema=OpenAPISchema(type="string"), + ) + ] + kwargs = {"session_id": None} + + request_params = tool._prepare_request_params(params, kwargs) + + assert "session_id" not in request_params["cookies"] + + def test_prepare_request_params_keeps_falsy_header_and_cookie_params( + self, + sample_endpoint, + sample_auth_credential, + sample_auth_scheme, + sample_operation, + ): + """Only None is dropped: an explicit empty string is still sent.""" + tool = RestApiTool( + name="test_tool", + description="Test Tool", + endpoint=sample_endpoint, + operation=sample_operation, + auth_credential=sample_auth_credential, + auth_scheme=sample_auth_scheme, + ) + params = [ + ApiParameter( + original_name="X-Custom-Header", + py_name="x_custom_header", + param_location="header", + param_schema=OpenAPISchema(type="string"), + ), + ApiParameter( + original_name="session_id", + py_name="session_id", + param_location="cookie", + param_schema=OpenAPISchema(type="string"), + ), + ] + kwargs = {"x_custom_header": "", "session_id": ""} + + request_params = tool._prepare_request_params(params, kwargs) + + assert request_params["headers"]["X-Custom-Header"] == "" + assert request_params["cookies"]["session_id"] == "" + + @patch( + "google.adk.tools.openapi_tool.openapi_spec_parser.rest_api_tool._request" + ) + @pytest.mark.asyncio + async def test_call_succeeds_when_model_sends_null_optional_header( + self, + mock_request, + mock_tool_context, + sample_endpoint, + sample_auth_scheme, + sample_auth_credential, + ): + """The call still goes through when the model sends null for a header.""" + mock_response = MagicMock() + mock_response.json.return_value = {"result": "success"} + mock_request.return_value = mock_response + + operation = Operation( + operationId="test_op", + parameters=[ + OpenAPIParameter(**{ + "name": "X-Custom-Header", + "in": "header", + "required": False, + "schema": OpenAPISchema(type="string"), + }) + ], + ) + tool = RestApiTool( + name="test_tool", + description="Test Tool", + endpoint=sample_endpoint, + operation=operation, + auth_scheme=sample_auth_scheme, + auth_credential=sample_auth_credential, + ) + + result = await tool.call( + args={"x_custom_header": None}, tool_context=mock_tool_context + ) + + assert result == {"result": "success"} + assert "X-Custom-Header" not in mock_request.call_args[1]["headers"] + def test_prepare_request_params_quota_project_id( self, sample_endpoint,