From afe5772862a65397963df390239e3d1a6f39a0cf Mon Sep 17 00:00:00 2001 From: S0ngRu1 <1922909737@qq.com> Date: Wed, 13 May 2026 16:55:16 +0800 Subject: [PATCH] fix(vertex_ai/gemini): enhance error handling in GCS metadata retrieval - Improved error handling in the `_get_gcs_object_content_type` function to raise `BadRequestError` with detailed messages when encountering HTTP errors or invalid JSON responses while using explicit Vertex credentials. - Added tests to ensure that appropriate errors are raised with HTTP details when explicit credentials are provided, and that the function returns `None` for anonymous requests on HTTP errors. - Updated mock responses in tests to reflect the new error handling logic. --- .../llms/vertex_ai/gemini/transformation.py | 70 ++++++++++++++++-- .../llms/vertex_ai/test_vertex.py | 71 ++++++++++++++++++- 2 files changed, 134 insertions(+), 7 deletions(-) diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index ef334a379f2..f54b0a68944 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -300,12 +300,72 @@ def _get_gcs_object_content_type( url=str(metadata_url), headers=headers or None, ) - response.raise_for_status() - content_type = response.json().get("contentType") - if isinstance(content_type, str) and len(content_type) > 0: - return content_type - except Exception: + except httpx.RequestError as e: + if explicit_vertex_auth_provided: + raise litellm.BadRequestError( + message=( + "Unable to reach GCS JSON API for object metadata with provided " + f"Vertex credentials. {type(e).__name__}: {e}" + ), + model=None, + llm_provider="vertex_ai", + ) from e return None + + if response.is_error: + if explicit_vertex_auth_provided: + preview = (response.text or "")[:1024] + raise litellm.BadRequestError( + message=( + "Unable to read GCS object metadata with provided Vertex credentials. " + f"HTTP {response.status_code}. Response body (truncated): {preview!r}" + ), + model=None, + llm_provider="vertex_ai", + ) + return None + + try: + payload = response.json() + except ValueError as e: + if explicit_vertex_auth_provided: + raise litellm.BadRequestError( + message=( + "GCS metadata response was not valid JSON when using provided " + f"Vertex credentials (HTTP {response.status_code}). Error: {e}" + ), + model=None, + llm_provider="vertex_ai", + ) from e + return None + + if not isinstance(payload, dict): + if explicit_vertex_auth_provided: + raise litellm.BadRequestError( + message=( + "GCS metadata response was not a JSON object when using provided " + f"Vertex credentials (HTTP {response.status_code})." + ), + model=None, + llm_provider="vertex_ai", + ) + return None + + content_type = payload.get("contentType") + if isinstance(content_type, str) and len(content_type) > 0: + return content_type + + if explicit_vertex_auth_provided: + preview = (response.text or "")[:1024] + raise litellm.BadRequestError( + message=( + "GCS metadata JSON did not include a non-empty contentType field when " + f"using provided Vertex credentials (HTTP {response.status_code}). " + f"Body (truncated): {preview!r}" + ), + model=None, + llm_provider="vertex_ai", + ) return None diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex.py b/tests/test_litellm/llms/vertex_ai/test_vertex.py index 3b2868e19e4..f1f89cdb38a 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex.py @@ -1434,8 +1434,9 @@ def test_get_gcs_object_content_type_uses_shared_vertex_base_instance(): mock_vertex_base = MagicMock() mock_vertex_base.get_access_token.return_value = ("test-token", "test-project") mock_http_response = MagicMock() + mock_http_response.is_error = False + mock_http_response.status_code = 200 mock_http_response.json.return_value = {"contentType": "image/png"} - mock_http_response.raise_for_status.return_value = None mock_http_handler = MagicMock() mock_http_handler.get.return_value = mock_http_response @@ -1509,6 +1510,69 @@ def test_get_gcs_object_content_type_fails_fast_with_explicit_credentials(): ) +def test_get_gcs_object_content_type_raises_with_http_details_when_explicit_creds(): + """HTTP failure after successful token fetch must not be swallowed as None.""" + from litellm.llms.vertex_ai.gemini import transformation as gemini_transformation + + mock_vertex_base = MagicMock() + mock_vertex_base.get_access_token.return_value = ("test-token", "test-project") + + mock_http_response = MagicMock() + mock_http_response.is_error = True + mock_http_response.status_code = 403 + mock_http_response.text = '{"error":{"message":"Permission denied"}}' + + mock_http_handler = MagicMock() + mock_http_handler.get.return_value = mock_http_response + + with ( + patch.object( + gemini_transformation, "_GCS_METADATA_VERTEX_BASE", mock_vertex_base + ), + patch( + "litellm.llms.vertex_ai.gemini.transformation._get_gcs_metadata_http_handler", + return_value=mock_http_handler, + ), + ): + with pytest.raises(litellm.BadRequestError, match="HTTP 403") as exc_info: + gemini_transformation._get_gcs_object_content_type( + image_url="gs://my-bucket/path/to/obj", + vertex_project="project-123", + vertex_credentials="credential-json", + ) + assert "Permission denied" in str(exc_info.value) + + +def test_get_gcs_object_content_type_returns_none_on_http_error_without_creds(): + """Anonymous metadata: HTTP errors stay soft (no surfaced oracle for private objects).""" + from litellm.llms.vertex_ai.gemini import transformation as gemini_transformation + + mock_vertex_base = MagicMock() + mock_http_response = MagicMock() + mock_http_response.is_error = True + mock_http_response.status_code = 403 + mock_http_response.text = "Forbidden" + + mock_http_handler = MagicMock() + mock_http_handler.get.return_value = mock_http_response + + with ( + patch.object( + gemini_transformation, "_GCS_METADATA_VERTEX_BASE", mock_vertex_base + ), + patch( + "litellm.llms.vertex_ai.gemini.transformation._get_gcs_metadata_http_handler", + return_value=mock_http_handler, + ), + ): + content_type = gemini_transformation._get_gcs_object_content_type( + image_url="gs://public-bucket/public-object", + ) + + assert content_type is None + mock_vertex_base.get_access_token.assert_not_called() + + def test_get_gcs_object_content_type_without_credentials_skips_auth(): """Without explicit Vertex credentials, must not use the server's default Google credentials to access GCS. @@ -1520,8 +1584,9 @@ def test_get_gcs_object_content_type_without_credentials_skips_auth(): mock_vertex_base = MagicMock() mock_http_response = MagicMock() + mock_http_response.is_error = False + mock_http_response.status_code = 200 mock_http_response.json.return_value = {"contentType": "image/jpeg"} - mock_http_response.raise_for_status.return_value = None mock_http_handler = MagicMock() mock_http_handler.get.return_value = mock_http_response @@ -1575,6 +1640,8 @@ def test_async_transform_request_body_does_not_block_event_loop(): def slow_http_get(*args, **kwargs): time.sleep(0.5) response = MagicMock() + response.is_error = False + response.status_code = 200 response.raise_for_status.return_value = None response.json.return_value = {"contentType": "image/png"} return response