fix(vertex): report an unreadable credentials file as a read failure

Tell a path apart from inline JSON by shape, not os.path.exists().

Fixes #40166
This commit is contained in:
Kolade Fajimi 2026-09-07 23:44:27 +01:00
parent 6908318c16
commit 0c7757e3ab
4 changed files with 274 additions and 25 deletions

View file

@ -0,0 +1,121 @@
"""
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.
"""
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.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
_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 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.
"""
if is_inline_credentials_json(credentials):
inline: Final = _parse_json_object(credentials)
if isinstance(inline, _NotAJsonObject):
return VertexCredentialsInlineNotJson(inline.detail)
return VertexCredentialsJson(inline)
try:
with open(credentials, encoding="utf-8") as f:
contents: Final = f.read()
except OSError as e:
return VertexCredentialsFileUnreadable(credentials, f"{e.strerror or e} ({type(e).__name__})")
except UnicodeDecodeError:
return VertexCredentialsFileNotJson(credentials, "file is not UTF-8 text")
from_file: Final = _parse_json_object(contents)
if isinstance(from_file, _NotAJsonObject):
return VertexCredentialsFileNotJson(credentials, from_file.detail)
return VertexCredentialsJson(from_file)
def raise_vertex_credentials_failure(failure: VertexCredentialsFailure) -> NoReturn:
"""Map a load failure onto the ValueError the auth flow already surfaces as a 500.
A path names itself in the message so the operator's log says which file failed. The
proxy scrubs filesystem paths out of what it hands back to the API caller, and the value
is redacted first so a non-path value misconfigured here cannot leak instead.
"""
match failure:
case VertexCredentialsFileUnreadable(path=path, reason=reason):
raise ValueError(
f"Unable to read the vertex credentials file at {redact_string(path)}: {reason}. "
"Set `vertex_credentials` to a readable file path, or to the credentials JSON itself."
)
case VertexCredentialsFileNotJson(path=path, detail=detail):
raise ValueError(
f"The vertex credentials file at {redact_string(path)} is not valid JSON: {detail}. "
"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 _:
assert_never(failure)

View file

@ -26,6 +26,12 @@ from .common_utils import (
get_vertex_base_model_name,
get_vertex_base_url,
)
from .credentials_source import (
VertexCredentialsJson,
is_inline_credentials_json,
load_vertex_credentials_source,
raise_vertex_credentials_failure,
)
def _graft_default_vertex_path(api_base: str, default_url: str) -> str:
@ -127,27 +133,15 @@ 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
source: Final = load_vertex_credentials_source(credentials)
verbose_logger.debug(
"Vertex: Loading vertex credentials, is_file_path=%s, current dir %s",
_is_path,
not is_inline_credentials_json(credentials),
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__}"
)
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

@ -2193,3 +2193,125 @@ 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_path_not_the_json(self, tmp_path):
missing = tmp_path / "vertexai.json"
with pytest.raises(ValueError) as exc_info:
VertexBase().load_auth(credentials=str(missing), project_id="p")
message = str(exc_info.value)
assert str(missing) in message
assert "No such file or directory" in message
assert "not valid JSON" not in message
def test_unreadable_credentials_file_names_the_read_failure(self, tmp_path):
# 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 pytest.raises(ValueError) as exc_info:
VertexBase().load_auth(credentials=str(unreadable), project_id="p")
message = str(exc_info.value)
assert str(unreadable) in message
assert "Unable to read the vertex credentials file" in message
assert "not valid JSON" not in message
def test_malformed_credentials_file_names_the_file_and_keeps_the_private_key_hint(self, tmp_path):
malformed = tmp_path / "vertexai.json"
malformed.write_text('{"type": "service_account", "private_key": "-----BEGIN\nPRIVATE KEY-----"}')
with pytest.raises(ValueError) as exc_info:
VertexBase().load_auth(credentials=str(malformed), project_id="p")
message = str(exc_info.value)
assert str(malformed) in message
assert "not valid JSON" in message
assert "private_key" in message
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) as exc_info:
VertexBase().load_auth(credentials=inline, project_id="p")
message = str(exc_info.value)
assert "not valid JSON" in message
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_credentials_path_reaches_the_operator_log_but_not_the_api_caller(self, tmp_path):
"""The path is the actionable detail, so it is in the message the proxy logs.
What the proxy hands back to the caller goes through redact_internal_details."""
from litellm.litellm_core_utils.secret_redaction import redact_internal_details
missing = tmp_path / "vertexai.json"
with pytest.raises(ValueError) as exc_info:
VertexBase().load_auth(credentials=str(missing), project_id="p")
logged = str(exc_info.value)
assert str(missing) in logged
assert str(missing) not in redact_internal_details(logged)
def test_a_credential_misconfigured_as_a_path_is_redacted_not_echoed(self):
"""A value that is neither a path nor JSON still lands in the file branch,
so it is scrubbed before it reaches the message."""
pem = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADA\n-----END PRIVATE KEY-----"
with pytest.raises(ValueError) 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

View file

@ -452,15 +452,27 @@ 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
failures = (
VertexCredentialsFileUnreadable("/etc/litellm/vertexai.json", "No such file or directory (FileNotFoundError)"),
VertexCredentialsFileNotJson("/etc/litellm/vertexai.json", "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) as exc_info:
raise_vertex_credentials_failure(failure)
message = str(exc_info.value)
assert _redact_string(message) == message # nothing to redact
def test_vertex_traceback_redacts_pem():