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.
This commit is contained in:
S0ngRu1 2026-05-13 16:55:16 +08:00
parent e6e2108f59
commit afe5772862
2 changed files with 134 additions and 7 deletions

View file

@ -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

View file

@ -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