This commit is contained in:
Kolade Fajimi 2026-09-23 14:40:20 +00:00 • committed by GitHub
commit 192396948b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 337 additions and 30 deletions

View file

@ -0,0 +1,146 @@
"""
Resolve the `vertex_credentials` config value into the JSON object google-auth needs.
The value is either the credentials JSON itself or a path to a file holding it. Which one
it is decides what a failure means, so the two are told apart by the shape of the value and
each failure is returned as its own case instead of collapsing into one parse error.
"""
import os
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Final, NoReturn, TypeAlias
from pydantic import TypeAdapter, ValidationError
from typing_extensions import assert_never
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.secret_redaction import redact_string
@dataclass(frozen=True, slots=True)
class VertexCredentialsJson:
value: Mapping[str, object]
@dataclass(frozen=True, slots=True)
class VertexCredentialsFileUnreadable:
path: str
reason: str
@dataclass(frozen=True, slots=True)
class VertexCredentialsFileNotJson:
path: str
detail: str
@dataclass(frozen=True, slots=True)
class VertexCredentialsInlineNotJson:
detail: str
@dataclass(frozen=True, slots=True)
class _NotAJsonObject:
detail: str
VertexCredentialsFailure: TypeAlias = (
VertexCredentialsFileUnreadable | VertexCredentialsFileNotJson | VertexCredentialsInlineNotJson
)
VertexCredentialsSource: TypeAlias = VertexCredentialsJson | VertexCredentialsFailure
_VertexCredentialsFile: TypeAlias = (
VertexCredentialsJson | VertexCredentialsFileUnreadable | VertexCredentialsFileNotJson
)
_JSON_OBJECT_ADAPTER: Final = TypeAdapter(dict[str, object])
def _parse_json_object(raw: str) -> Mapping[str, object] | _NotAJsonObject:
"""Parse *raw*, describing any failure without echoing it: the input can be key material."""
try:
return _JSON_OBJECT_ADAPTER.validate_json(raw)
except ValidationError as e:
# Only "msg" is reported. Pydantic keeps the offending value under "input", and that
# value is the credential.
return _NotAJsonObject("; ".join(detail["msg"] for detail in e.errors()))
def _read_json_file(path: str) -> _VertexCredentialsFile:
try:
with open(path, encoding="utf-8") as f:
contents: Final = f.read()
except OSError as e:
return VertexCredentialsFileUnreadable(path, f"{e.strerror or e} ({type(e).__name__})")
except UnicodeDecodeError:
return VertexCredentialsFileNotJson(path, "file is not UTF-8 text")
except ValueError as e:
# open() rejects a few paths before touching the filesystem, e.g. "embedded null byte".
return VertexCredentialsFileUnreadable(path, f"{e} ({type(e).__name__})")
parsed: Final = _parse_json_object(contents)
if isinstance(parsed, _NotAJsonObject):
return VertexCredentialsFileNotJson(path, parsed.detail)
return VertexCredentialsJson(parsed)
def _is_inline_credentials_json(credentials: str) -> bool:
"""Whether *credentials* carries the JSON itself rather than a path to a file holding it."""
return credentials.lstrip().startswith("{")
def load_vertex_credentials_source(credentials: str) -> VertexCredentialsSource:
"""Read *credentials* as the credentials JSON when it is shaped like one, else as a path to it.
Telling the two apart by shape rather than by `os.path.exists()` is what keeps a read
failure recognisable: `os.path.exists()` answers False for an unreadable path as well as
an absent one, so both used to reach the inline branch and be reported as malformed JSON.
"""
inline_first: Final = _is_inline_credentials_json(credentials)
verbose_logger.debug(
"Vertex: Loading vertex credentials, is_file_path=%s, current dir %s", not inline_first, os.getcwd()
)
if not inline_first:
return _read_json_file(credentials)
inline: Final = _parse_json_object(credentials)
if not isinstance(inline, _NotAJsonObject):
return VertexCredentialsJson(inline)
# A file can legitimately be named "{vertex}.json", so a brace-prefixed value that does
# not parse is still given the file read it used to get before dispatch moved to shape.
from_file: Final = _read_json_file(credentials)
if isinstance(from_file, VertexCredentialsJson):
return from_file
return VertexCredentialsInlineNotJson(inline.detail)
def raise_vertex_credentials_failure(failure: VertexCredentialsFailure) -> NoReturn:
"""Map a load failure onto the ValueError the auth flow already surfaces to the caller.
The caller is told which of the three faults it was; the path goes to the proxy log
instead, because the message reaches whoever sent the request and the operator who can
act on the path is reading the log anyway. The path is redacted on the way there too, so
a credential misconfigured into this field does not become a log entry.
"""
match failure:
case VertexCredentialsFileUnreadable(path=path, reason=reason):
verbose_logger.error("Vertex: cannot read the credentials file at %s: %s", redact_string(path), reason)
raise ValueError(
f"Unable to read the vertex credentials file: {reason}. The proxy log names the path. "
"Set `vertex_credentials` to a readable file path, or to the credentials JSON itself."
)
case VertexCredentialsFileNotJson(path=path, detail=detail):
verbose_logger.error("Vertex: credentials file at %s is not valid JSON: %s", redact_string(path), detail)
raise ValueError(
f"The vertex credentials file is not valid JSON: {detail}. The proxy log names the path. "
"Check for unescaped newlines in private_key."
)
case VertexCredentialsInlineNotJson(detail=detail):
raise ValueError(
f"The inline `vertex_credentials` value is not valid JSON: {detail}. "
"Check for unescaped newlines in private_key."
)
case _: # pragma: no cover - exhaustiveness guard, unreachable while the union holds
assert_never(failure) # pragma: no cover

View file

@ -6,7 +6,6 @@ Handles Authentication and generating request urls for Vertex AI and Google AI S
import asyncio
import json
import os
import threading
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol
@ -26,6 +25,11 @@ from .common_utils import (
get_vertex_base_model_name,
get_vertex_base_url,
)
from .credentials_source import (
VertexCredentialsJson,
load_vertex_credentials_source,
raise_vertex_credentials_failure,
)
def _graft_default_vertex_path(api_base: str, default_url: str) -> str:
@ -127,27 +131,10 @@ class VertexBase:
) -> tuple[_VertexCredentialsObject | None, str]:
if credentials is not None:
if isinstance(credentials, str):
_is_path: Final = os.path.exists(
credentials
) # credentials is from server config (litellm_params), not user input
verbose_logger.debug(
"Vertex: Loading vertex credentials, is_file_path=%s, current dir %s",
_is_path,
os.getcwd(),
)
try:
if _is_path:
with open(credentials) as f:
json_obj = json.load(f)
else:
json_obj = json.loads(credentials)
except Exception as e:
raise Exception(
"Unable to load vertex credentials from environment. "
"Ensure the JSON is valid (check for unescaped newlines in private_key). "
f"Parse error: {type(e).__name__}"
)
source: Final = load_vertex_credentials_source(credentials)
if not isinstance(source, VertexCredentialsJson):
raise_vertex_credentials_failure(source)
json_obj: Mapping[str, object] = source.value
elif isinstance(credentials, dict):
json_obj = credentials
else:

View file

@ -1,5 +1,6 @@
import asyncio
import json
import logging
from unittest.mock import MagicMock, call, patch
import pytest
@ -2193,3 +2194,162 @@ class TestVertexBase:
assert token == "cached-token"
assert not mock_get_lock.called, "Fast path should not acquire lock"
class TestVertexCredentialsSource:
"""A `vertex_credentials` path that cannot be read must not be reported as malformed JSON.
Regression for https://github.com/BerriAI/litellm/issues/40166: dispatching on
os.path.exists() sent a missing or unreadable path down the inline-JSON branch,
where the path string itself was parsed and every failure came back as a
JSONDecodeError blaming the key material.
"""
def test_missing_credentials_file_names_the_read_failure(self, tmp_path, caplog):
missing = tmp_path / "vertexai.json"
with caplog.at_level(logging.ERROR, logger="LiteLLM"):
with pytest.raises(ValueError, match="No such file or directory") as exc_info:
VertexBase().load_auth(credentials=str(missing), project_id="p")
message = str(exc_info.value)
assert "not valid JSON" not in message
assert str(missing) not in message
assert str(missing) in caplog.text
def test_unreadable_credentials_file_names_the_read_failure(self, tmp_path, caplog):
# Present but not openable as a file, the same shape as a path on a mount
# that has stopped serving reads.
unreadable = tmp_path / "vertexai.json"
unreadable.mkdir()
with caplog.at_level(logging.ERROR, logger="LiteLLM"):
with pytest.raises(ValueError, match="Unable to read the vertex credentials file") as exc_info:
VertexBase().load_auth(credentials=str(unreadable), project_id="p")
message = str(exc_info.value)
assert "not valid JSON" not in message
assert str(unreadable) not in message
assert str(unreadable) in caplog.text
def test_malformed_credentials_file_keeps_the_private_key_hint(self, tmp_path, caplog):
malformed = tmp_path / "vertexai.json"
malformed.write_text('{"type": "service_account", "private_key": "-----BEGIN\nPRIVATE KEY-----"}')
with caplog.at_level(logging.ERROR, logger="LiteLLM"):
with pytest.raises(ValueError, match="is not valid JSON") as exc_info:
VertexBase().load_auth(credentials=str(malformed), project_id="p")
message = str(exc_info.value)
assert "private_key" in message
assert str(malformed) not in message
assert str(malformed) in caplog.text
def test_malformed_inline_credentials_do_not_echo_the_credential(self):
inline = (
'{"type": "service_account", "private_key": '
'"-----BEGIN PRIVATE KEY-----\nMIIEvQIBADA\n-----END PRIVATE KEY-----"}'
)
with pytest.raises(
ValueError, match="The inline `vertex_credentials` value is not valid JSON"
) as exc_info:
VertexBase().load_auth(credentials=inline, project_id="p")
assert "MIIEvQIBADA" not in str(exc_info.value)
def test_a_credential_misconfigured_as_a_path_is_not_echoed(self):
"""A value that is neither a path nor JSON must not come back in the message."""
pem = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADA\n-----END PRIVATE KEY-----"
with pytest.raises(ValueError, match="Unable to read the vertex credentials file") as exc_info:
VertexBase().load_auth(credentials=pem, project_id="p")
message = str(exc_info.value)
assert "MIIEvQIBADA" not in message
assert "BEGIN PRIVATE KEY" not in message
def test_readable_credentials_file_is_still_loaded(self, tmp_path):
creds_file = tmp_path / "vertexai.json"
creds_file.write_text(json.dumps({"type": "service_account", "project_id": "from-file"}))
vertex_base = VertexBase()
mock_creds = MagicMock()
mock_creds.project_id = "from-file"
with (
patch.object(vertex_base, "_credentials_from_service_account", return_value=mock_creds) as from_sa,
patch.object(vertex_base, "refresh_auth"),
):
creds, project_id = vertex_base.load_auth(credentials=str(creds_file), project_id=None)
assert creds is mock_creds
assert project_id == "from-file"
assert from_sa.call_args.args[0] == {"type": "service_account", "project_id": "from-file"}
def test_inline_credentials_json_is_still_parsed(self):
vertex_base = VertexBase()
mock_creds = MagicMock()
mock_creds.project_id = "from-inline"
with (
patch.object(vertex_base, "_credentials_from_service_account", return_value=mock_creds) as from_sa,
patch.object(vertex_base, "refresh_auth"),
):
creds, project_id = vertex_base.load_auth(
credentials=json.dumps({"type": "service_account", "project_id": "from-inline"}),
project_id=None,
)
assert creds is mock_creds
assert project_id == "from-inline"
assert from_sa.call_args.args[0] == {"type": "service_account", "project_id": "from-inline"}
def test_a_credential_misconfigured_as_a_path_is_not_logged_either(self, caplog):
"""The path reaches the log, so a credential put in that field must be scrubbed first."""
pem = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADA\n-----END PRIVATE KEY-----"
with caplog.at_level(logging.ERROR, logger="LiteLLM"):
with pytest.raises(ValueError, match="Unable to read the vertex credentials file"):
VertexBase().load_auth(credentials=pem, project_id="p")
assert "MIIEvQIBADA" not in caplog.text
assert "REDACTED" in caplog.text
def test_a_path_open_rejects_outright_is_reported_not_raised_raw(self):
"""open() rejects some paths with a bare ValueError before any filesystem call."""
with pytest.raises(ValueError, match="Unable to read the vertex credentials file") as exc_info:
VertexBase().load_auth(credentials="creds" + chr(0) + ".json", project_id="p")
assert "embedded null" in str(exc_info.value)
def test_a_file_whose_name_starts_with_a_brace_is_still_read(self, tmp_path, monkeypatch):
"""Shape dispatch must not strand a real file that happens to be named like JSON.
The value has to be relative for this to bite: an absolute path never starts with
a brace, so only "{vertex}.json" reaches the inline branch and needs the fallback.
"""
braced = tmp_path / "{vertex}.json"
braced.write_text(json.dumps({"type": "service_account", "project_id": "from-braced-file"}))
monkeypatch.chdir(tmp_path)
vertex_base = VertexBase()
mock_creds = MagicMock()
mock_creds.project_id = "from-braced-file"
with (
patch.object(vertex_base, "_credentials_from_service_account", return_value=mock_creds) as from_sa,
patch.object(vertex_base, "refresh_auth"),
):
creds, project_id = vertex_base.load_auth(credentials="{vertex}.json", project_id=None)
assert creds is mock_creds
assert project_id == "from-braced-file"
assert from_sa.call_args.args[0] == {"type": "service_account", "project_id": "from-braced-file"}
def test_a_credentials_file_that_is_not_text_is_reported_as_such(self, tmp_path):
not_text = tmp_path / "vertexai.json"
not_text.write_bytes(bytes([0xFF, 0xFE, 0x00, 0x01]))
with pytest.raises(ValueError, match="is not valid JSON") as exc_info:
VertexBase().load_auth(credentials=str(not_text), project_id="p")
assert "UTF-8" in str(exc_info.value)

View file

@ -467,15 +467,29 @@ def test_service_account_blob_fully_redacted():
def test_vertex_error_message_no_credential_leak():
"""The old Vertex error format leaked the full credential JSON.
The new format must not contain any credential material."""
new_msg = (
"Unable to load vertex credentials from environment. "
"Ensure the JSON is valid (check for unescaped newlines in private_key). "
"Parse error: JSONDecodeError"
"""The old Vertex error format leaked the full credential JSON. Every message the
credential loader raises today must survive redaction unchanged, which it only can
if it never carried credential material in the first place."""
from litellm.llms.vertex_ai.credentials_source import (
VertexCredentialsFileNotJson,
VertexCredentialsFileUnreadable,
VertexCredentialsInlineNotJson,
raise_vertex_credentials_failure,
)
result = _redact_string(new_msg)
assert result == new_msg # nothing to redact
path = "/etc/litellm/vertexai.json"
failures = (
VertexCredentialsFileUnreadable(path, "No such file or directory (FileNotFoundError)"),
VertexCredentialsFileNotJson(path, "Invalid control character at: line 1 column 55"),
VertexCredentialsInlineNotJson("Expecting value: line 1 column 1 (char 0)"),
)
for failure in failures:
with pytest.raises(ValueError, match="vertex") as exc_info:
raise_vertex_credentials_failure(failure)
message = str(exc_info.value)
assert path not in message # the path goes to the log, not to the API caller
assert _redact_string(message) == message # nothing to redact
def test_vertex_traceback_redacts_pem():